S7FqcInitiatorTenantGuard.cs 5.7 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
  2. /// <summary>
  3. /// S7-FQC-INITIATOR-TENANT-GUARD-1:S7 成品检验「检验负责人 / 流程发起人」的租户门禁。
  4. ///
  5. /// <para><b>要守住的不变量</b>:对任何新进入 S7 FQC 流程的单据,
  6. /// <c>BusinessTenant == jyfzr 用户.TenantId == ApprovalFlowInstance.Initiator 用户.TenantId</c>。
  7. /// S7 的契约是三者恒等(<c>FqcInspBillFlowService.SubmitResult</c> 的负责人门与
  8. /// <c>FqcTaskEntryService.GenerateInspBill</c> 的认领人门共同保证
  9. /// <c>jyfzr == 当前用户 == N1 Assignee</c>),因此只要 <c>jyfzr</c> 一定同租户,
  10. /// Initiator 就一定同租户。</para>
  11. ///
  12. /// <para><b>为什么原来会破</b>(已取证):写入侧与读取侧的租户口径不一致 ——
  13. /// <list type="bullet">
  14. /// <item><c>Claim</c> 写 <c>_userManager.UserId</c>,而 <c>UserManager.UserId/TenantId</c> 都取自 JWT claim;
  15. /// <c>SysTenantService.ChangeTenant</c> 只把**内存中**的 <c>user.TenantId</c> 改成目标租户后重签 token、
  16. /// 并不持久化,于是超管切租户后 <c>_userManager.TenantId=目标租户</c> 而 <c>SysUser.TenantId=默认租户</c>,
  17. /// 一个默认租户身份的用户就被写成了目标租户单据的负责人。</item>
  18. /// <item><c>Assign</c> 写 <c>InspectorUserId</c> 时**完全不查用户**,只过滤单据租户。</item>
  19. /// <item><c>FlowEngineService:125</c> 起流时 <c>InitiatorId = _userManager.UserId</c>,照实捕获该用户;
  20. /// 而 <c>ResolveApprovers</c> 的 Initiator 分支用 <c>_userRep.GetByIdAsync</c>(无 ClearFilter)
  21. /// 按**环境租户**过滤 → 该发起人此后永远解析不出来 → <c>ReturnToPrev</c> 到 N1 时报「未配置审批人」。</item>
  22. /// </list></para>
  23. ///
  24. /// <para><b>为什么守在 S7 业务边界而不是引擎</b>:引擎照实记录操作者是正确行为,
  25. /// 改 <c>FlowEngineService</c> 会把影响面扩散到全部 BizType。
  26. /// 真正错的是「让一个不属于该租户的人成为该租户单据的负责人」,那是 S7 的业务规则,
  27. /// 因此三道防线全部落在 S7 自己的入口上(Assign / Claim / 起流边界)。</para>
  28. ///
  29. /// <para><b>平台权限 ≠ 业务身份</b>:超管有跨租户访问与管理能力,但除非它真实属于该租户,
  30. /// 就不能成为该租户的 FQC 检验负责人与流程发起人 —— 否则它发起的流程在退回 N1 时无人可解析。</para>
  31. /// </summary>
  32. internal static class S7FqcInitiatorTenantGuard
  33. {
  34. /// <summary>
  35. /// 校验「<paramref name="userId"/> 可否作为租户 <paramref name="businessTenantId"/> 单据的检验负责人/发起人」。
  36. ///
  37. /// <para>用 <c>ClearFilter&lt;ITenantIdFilter&gt;()</c> 跨租户查用户是**故意的**:
  38. /// 若带租户过滤查,跨租户用户会表现为「查不到」,就无法把「用户不存在」与
  39. /// 「用户存在但不属于本租户」区分开,只能给出误导性的文案。</para>
  40. ///
  41. /// <para>对外文案不暴露 TenantId / RoleId / 内部过滤器细节。</para>
  42. /// </summary>
  43. /// <param name="db">SqlSugar 客户端</param>
  44. /// <param name="userId">待校验用户 Id</param>
  45. /// <param name="businessTenantId">单据所属租户(由业务行/业务上下文确定,非登录 token 直接透传)</param>
  46. /// <param name="subject">文案主语:如「指定检验负责人」「当前用户」</param>
  47. public static async Task EnsureCanOwnFqcAsync(
  48. ISqlSugarClient db, long userId, long businessTenantId, string subject)
  49. {
  50. if (userId <= 0)
  51. throw Oops.Oh($"{subject}无效,无法作为检验负责人");
  52. var user = await db.Queryable<SysUser>()
  53. .ClearFilter<ITenantIdFilter>()
  54. .Where(u => u.Id == userId)
  55. .Select(u => new GuardUser { Id = u.Id, TenantId = u.TenantId, Status = u.Status })
  56. .FirstAsync();
  57. if (user == null)
  58. throw Oops.Oh($"{subject}不存在,无法作为检验负责人");
  59. if (user.Status != StatusEnum.Enable)
  60. throw Oops.Oh($"{subject}已停用,无法作为检验负责人");
  61. if (user.TenantId != businessTenantId)
  62. throw Oops.Oh($"{subject}不属于当前单据租户,不能作为该单据的检验负责人");
  63. }
  64. /// <summary>
  65. /// 起流前的最后一道:校验业务行上记录的 <c>jyfzr</c> 本身合法且同租户。
  66. ///
  67. /// <para>这道防线不可省:库内**已存在**先于本守卫写入的坏 <c>jyfzr</c>
  68. /// (既有跨租户用户 Id,也有根本不是用户 Id 的字符串)。只在 Assign/Claim 设卡,
  69. /// 挡不住这些存量单据将来被起流而再造一条跨租户 Initiator 实例。</para>
  70. /// </summary>
  71. public static async Task EnsureBillOwnerTenantSafeAsync(
  72. ISqlSugarClient db, string? jyfzr, long businessTenantId)
  73. {
  74. var raw = (jyfzr ?? string.Empty).Trim();
  75. if (raw.Length == 0)
  76. throw Oops.Oh("该报检尚未指定检验负责人,无法启动检验流程");
  77. // jyfzr 语义是 SysUser.Id;存量数据里存在非 Id 的历史值,必须显式拒绝而不是当 0 处理。
  78. if (!long.TryParse(raw, out var ownerId) || ownerId <= 0)
  79. throw Oops.Oh("该报检的检验负责人记录无效,请重新指定负责人后再启动检验流程");
  80. await EnsureCanOwnFqcAsync(db, ownerId, businessTenantId, "该报检的检验负责人");
  81. }
  82. /// <summary>只取校验所需三列,避免整实体投影把无关列带进来。</summary>
  83. private sealed class GuardUser
  84. {
  85. public long Id { get; set; }
  86. public long? TenantId { get; set; }
  87. public StatusEnum Status { get; set; }
  88. }
  89. }