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