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