using System.Diagnostics; using Admin.NET.Plugin.AiDOP.FinishedWarehouse; using Yitter.IdGenerator; namespace Admin.NET.Plugin.AiDOP.Infrastructure; /// /// S7-FQC-TENANT-AUTHORITY-1:S7 成品检验(FQC)三类审批角色的**租户本地化**权威基线。 /// /// 修复的缺陷(与 S6 同一 SYSTEMIC TENANT CLONE DEFECT,2026-09-14 审计确认): /// - ROLE_S7_FQC_INSPECTOR / _SUPERVISOR / _QE 三个 Code **全库各只有一行、固定挂默认租户** /// (对照 ROLE_S6_IPQC_* 已各有 10 个租户副本); /// - 各租户用户靠**跨租户 SysUserRole 绑定**借用(审计实测 9 条跨租户绑定,其中 4 条被 Running 实例依赖); /// - 流程定义又把默认租户的**物理 RoleId**(1329915020002 / 1329915020003)写死进 FlowJson, /// 于是「谁是成品检验主管」跨租户共享,可用性挂在一条无约束、无审计、可被单行 SQL 删除的手工绑定上。 /// /// 目标模型(与 S6 及平台主流 ROLE_QC / SMART_OPS_* 一致): /// 每个租户各有自己的三个 S7 角色,Code 相同、RoleId 不同、成员与角色同租户; /// FlowJson 以 **RoleCode** 表达审批权威,由 FlowEngineService 的 Code 分支 /// (r.TenantId == effectiveTenantId && r.Status == Enable && codes.Contains(r.Code)) /// 在目标租户内重新解析。 /// /// 为什么新建文件而不是把 泛化:后者是 S6 两条链的在用权威基线, /// 改它等于把 S6 拖进本批变更面。本文件结构与其逐一对应(RoleSpecs / EnsureTenantLocalRoles / /// CopyLegacyCrossTenantMembership / ResolveLocalRoleId / GetActivatableTenantIds / ResolveTenantIds), /// 便于将来两者一并抽公共基类——那是独立的重构批次,不在本批。 /// /// 边界: /// - 默认租户 的那三行**本身就是它的 tenant-local 角色**, /// 沿用 的既有固定 Id,不重建、不改动(历史 FlowJson 仍按该 Id 判定)。 /// - membership 是 **COPY 不是 MOVE**:既有跨租户绑定原样保留,供 7 条 Running 实例与尚未迁移的 /// legacy 快照继续使用;清理属后续独立批次(必须排在 Active Snapshot Migration 之后)。 /// - 本文件**不得**出现任何具体租户 Id 或 UAT 账号——只按 SysTenant 注册表通用处理。 /// - 本文件**不触碰**任何 ApprovalFlowInstance / FlowJsonSnapshot:历史实例迁移不在此发生。 /// internal static class S7TenantAuthoritySeed { /// 三类 S7 角色:Code / 名称 / 排序号 / 默认租户既有固定 Id。名称沿用既有 S7 角色语义。 private static readonly (string Code, string Name, int OrderNo, long LegacyDefaultTenantRoleId)[] RoleSpecs = { (FqcInspBillFlowConst.RoleInspectorCode, "成品检验员", 820, FqcInspBillFlowConst.RoleInspectorId), (FqcInspBillFlowConst.RoleSupervisorCode, "成品检验主管", 821, FqcInspBillFlowConst.RoleSupervisorId), (FqcInspBillFlowConst.RoleQeCode, "成品检验QE", 822, FqcInspBillFlowConst.RoleQeId), }; /// 三个 S7 RoleCode(供流程种子与 Readiness 判定共用)。 public static IReadOnlyList RoleCodes { get; } = RoleSpecs.Select(x => x.Code).ToList(); /// /// 幂等地建立租户本地角色并复制既有成员语义。重复调用无副作用。 /// public static void EnsureTenantAuthority(ISqlSugarClient db) { try { EnsureTenantLocalRoles(db); CopyLegacyCrossTenantMembership(db); } catch (Exception ex) { Trace.TraceWarning("S7 tenant authority seed: " + ex); } } /// /// 为 SysTenant 注册表中的每个租户建立三个 S7 角色。查重键是 (TenantId, Code)—— /// 原实现(FqcInspBillFlowSeed.EnsureRole)用全局 ClearFilter().Any(x => x.Code == code), /// 一旦默认租户已有该 Code 就再也不会为其它租户建,这正是「全库单例」的成因。 /// private static void EnsureTenantLocalRoles(ISqlSugarClient db) { var now = DateTime.Now; foreach (var tenantId in ResolveTenantIds(db)) { foreach (var spec in RoleSpecs) { var exists = db.Queryable() .ClearFilter() .Any(r => r.TenantId == tenantId && r.Code == spec.Code); if (exists) continue; // 默认租户沿用既有固定 Id(历史 FlowJson 仍按该 Id 判定,不能换号); // 其余租户一律走雪花号,遵循 SysRole 的 tenant-local 身份语义。 var roleId = tenantId == SqlSugarConst.DefaultTenantId ? spec.LegacyDefaultTenantRoleId : YitIdHelper.NextId(); db.Insertable(new SysRole { Id = roleId, TenantId = tenantId, Name = spec.Name, Code = spec.Code, OrderNo = spec.OrderNo, DataScope = DataScopeEnum.Self, Status = StatusEnum.Enable, Remark = "S7 成品检验流程角色(租户本地)", CreateTime = now, }).ExecuteCommand(); } } } /// /// 把「用户在 A 租户、却绑着默认租户 S7 角色」的既有授权事实,等价复制成该用户自己租户内的本地绑定。 /// 只复制**已经存在**的授权事实,不新增任何业务成员、不猜谁该当主管; /// legacy 绑定保留不动(COPY 不是 MOVE,7 条 Running 仍依赖它)。 /// private static void CopyLegacyCrossTenantMembership(ISqlSugarClient db) { foreach (var spec in RoleSpecs) { var memberIds = db.Queryable() .ClearFilter() .Where(ur => ur.RoleId == spec.LegacyDefaultTenantRoleId) .Select(ur => ur.UserId) .ToList(); if (memberIds.Count == 0) continue; var crossTenantMembers = db.Queryable() .ClearFilter() .Where(u => memberIds.Contains(u.Id) && u.TenantId != SqlSugarConst.DefaultTenantId) .ToList(); foreach (var user in crossTenantMembers) { if (user.TenantId is not > 0) continue; // 无租户归属的用户不产生本地绑定 var localRoleId = ResolveLocalRoleId(db, user.TenantId.Value, spec.Code); if (localRoleId is not > 0) continue; // SysUserRole 无 (UserId, RoleId) 唯一约束,防重只能靠这里自己做。 var bound = db.Queryable() .ClearFilter() .Any(x => x.UserId == user.Id && x.RoleId == localRoleId.Value); if (bound) continue; db.Insertable(new SysUserRole { Id = YitIdHelper.NextId(), UserId = user.Id, RoleId = localRoleId.Value, }).ExecuteCommand(); } } } /// 按 (TenantId, Code) 取租户本地角色 Id;不存在返回 null。 public static long? ResolveLocalRoleId(ISqlSugarClient db, long tenantId, string code) { return db.Queryable() .ClearFilter() .Where(r => r.TenantId == tenantId && r.Code == code && r.Status == StatusEnum.Enable) .Select(r => r.Id) .First(); } /// /// Activation Gate:只有三个 S7 角色在该租户内**都存在本地角色且各至少 1 名同租户成员**时, /// 才允许为该租户发布 RoleCode 版流程定义。 /// /// 这道门禁的意义:不得把一个「目前还靠 legacy 跨租户 RoleId 正常工作」的租户,迁成 0 审批人。 /// 成员不齐的租户维持原状(继续命中 legacy 定义),等它齐了下次启动自动补发。 /// public static List GetActivatableTenantIds(ISqlSugarClient db) { var ready = new List(); foreach (var tenantId in ResolveTenantIds(db)) { var allReady = true; foreach (var spec in RoleSpecs) { var roleId = ResolveLocalRoleId(db, tenantId, spec.Code); if (roleId is not > 0) { allReady = false; break; } var memberIds = db.Queryable() .ClearFilter() .Where(ur => ur.RoleId == roleId.Value) .Select(ur => ur.UserId) .ToList(); if (memberIds.Count == 0) { allReady = false; break; } // 成员必须与角色同租户——审批人解析最终按 SysUser.TenantId 过滤, // 只数绑定行会把「有绑定但没有同租户成员」误判为 ready。 var sameTenantMembers = db.Queryable() .ClearFilter() .Count(u => memberIds.Contains(u.Id) && u.TenantId == tenantId); if (sameTenantMembers == 0) { allReady = false; break; } } if (allReady) ready.Add(tenantId); } return ready; } /// /// 租户清单一律取自项目正式注册表 SysTenant(含默认租户),不自行构造、不按 SysOrg 数量机械展开 /// (按 Org 播种正是 S7 原实现产生 57 条定义、且多条定义租户与业务租户错配的成因)。 /// private static List ResolveTenantIds(ISqlSugarClient db) { var ids = db.Queryable() .ClearFilter() .Where(x => x.Id > 1) .Select(x => x.Id) .ToList(); if (!ids.Contains(SqlSugarConst.DefaultTenantId)) ids.Add(SqlSugarConst.DefaultTenantId); return ids.Distinct().ToList(); } }