Просмотр исходного кода

fix(s7): make FQC approval authority tenant-local | server 1.0.547

只关「未来新建 S7 FQC 流程」这条口子。不迁 7 条 Running 快照、不删跨租户绑定、
不动 840240525086789、不开 TenantStrictRoleBizTypes(顺序必须是
local roles → future definition → active migration → strict guard)。

根因(2026-09-14 审计 EVIDENCE COMPLETE):
- ROLE_S7_FQC_INSPECTOR/_SUPERVISOR/_QE 三个 Code 全库各只有一行、固定挂默认租户
  (对照 ROLE_S6_IPQC_* 已各 10 份),成因是 EnsureRole 用全局
  ClearFilter().Any(x => x.Code == code) 查重;
- 全部 57 条定义(V1–V3)的 N2/N3 都写死默认租户物理 RoleId 1329915020002/1329915020003;
- 定义按 SysOrg 播种(Code=S7FQC_{orgId},TenantId 由 Org 反解),导致定义租户与业务租户错配;
- 各租户靠 9 条跨租户 SysUserRole 绑定借用,其中 4 条被 Running 实例实际依赖。

改法(镜像 S6 已验证的 tenant-local 模式,未发明新框架):
- 新增 S7TenantAuthoritySeed:按 SysTenant 注册表为每个租户建三个 S7 角色,查重键
  (TenantId, Code);默认租户沿用既有固定 Id,其余走雪花号;成员按既有跨租户绑定
  COPY(不 MOVE、不删、不猜新成员);Activation Gate 要求三角色在该租户都有本地角色
  且各有同租户成员,避免把「目前靠 legacy 正常工作」的租户迁成 0 审批人。
  未泛化 S6TenantAuthoritySeed,是为了不把 S6 拖进本批变更面。
- FqcInspBillFlowSeed:角色供给改调 S7TenantAuthoritySeed;定义播种维度由 SysOrg 改为
  可激活租户(Code=S7FQC_T{tenantId}、TenantId=目标租户);幂等判据改为「FlowJson 是否已含
  RoleCode」;不再下架历史定义(原 v2→v3 升级会 SetColumns(IsPublished=false)),
  靠 StartFlowCore 的「本租户优先 + Version 降序」自然胜出。
- N2/N3 的 ApproverIds 由 RoleSupervisorId/RoleQeId.ToString() 改为 RoleSupervisorCode/RoleQeCode;
  N1 保持 Initiator。设计代次常量升 v4,并注明它不等于 ApprovalFlow.Version(后者按租户递增)。

Runtime(重启后 seed 生效):
- 角色:三个 Code 各由 1 行/1 租户变为 10 行/10 租户/10 个不同 RoleId;4 个租户达成
  Activation Gate(默认 + 797403760988229 + 838257186181189 + 838257212780613),
  其余 6 个租户 0 同租户成员,按门禁不激活、维持原状。
- 定义:新增 4 条 S7FQC_T* 已发布定义,N1=Initiator / N2=ROLE_S7_FQC_SUPERVISOR /
  N3=ROLE_S7_FQC_QE,TenantId=目标租户,无任何数字 RoleId;历史 57 条一条未改、23 条仍发布。
- 新实例(经官方链路 报检 → 认领 → 生成检验单,禁止直接 INSERT):
  797 实例 848824265953349 用定义 S7FQC_T797403760988229 v4;
  838 实例 848823458029637 用定义 S7FQC_T838257186181189 v3;
  两者快照均 N1=Initiator / N2,N3=RoleCode,legacy RoleId 命中数 0。
- 解析:797 的 N2/N3 分别命中本地角色 848821564117061 / 848821564616773(均 TENANT_LOCAL),
  838 的 N2 命中 848821570179141;待办人 User.TenantId == Role.TenantId == BusinessTenant,
  且三个 Code 在两租户都有本地绑定 → 新实例不再依赖跨租户绑定。
- 原 7 条 Running 指纹(Id|FlowId|FlowVersion|Status|CurrentNodeId|snapshot MD5)
  与原 14 条实例指纹前后逐字相同;840240525086789 的 Initiator/状态/节点/快照 MD5 未变;
  9 条跨租户绑定仍在(按设计保留)。

测试:全量 2719 passed / 1 failed(既有 S8AuthorizationGuardTests,与本批无关)。
YY968XX 4 дней назад
Родитель
Сommit
050f469770

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.546</AssemblyVersion>
-    <FileVersion>1.0.546</FileVersion>
-    <Version>1.0.546</Version>
+    <AssemblyVersion>1.0.547</AssemblyVersion>
+    <FileVersion>1.0.547</FileVersion>
+    <Version>1.0.547</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 337 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/ApprovalFlow/S7TenantAuthorityContractTests.cs

@@ -0,0 +1,337 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
+
+/// <summary>
+/// S7-FQC-TENANT-AUTHORITY-1 源码契约测试:S7 成品检验审批权威**租户本地化**(仅未来新建)。
+///
+/// 修复的根因(2026-09-14 审计 EVIDENCE COMPLETE):
+/// - <c>ROLE_S7_FQC_*</c> 三个 Code 全库各只有一行、固定挂默认租户(对照 ROLE_S6_IPQC_* 已各 10 份);
+/// - 所有 S7 定义(57 条,V1–V3 全版本)的 N2/N3 都写死默认租户物理 RoleId
+///   1329915020002 / 1329915020003;
+/// - 各租户靠 9 条跨租户 SysUserRole 绑定借用,其中 4 条被 Running 实例实际依赖。
+///
+/// 本批只关未来新建这条口子:租户本地角色 + 定义按租户播种 + N2/N3 改 RoleCode。
+/// **不迁** 7 条 Running 的 FlowJsonSnapshot、**不删**跨租户绑定、**不开** TenantStrictRoleBizTypes
+/// (顺序必须是 local roles → future definition → active migration → strict guard)。
+///
+/// 沿用本仓既有做法——源码契约断言而非行为级集成测试:seed 依赖 ISqlSugarClient,
+/// 裸 xUnit 进程无法实例化。真实效果由本批 Runtime R1–R10 覆盖。
+/// </summary>
+public class S7TenantAuthorityContractTests
+{
+    private const string SupervisorCode = "ROLE_S7_FQC_SUPERVISOR";
+    private const string QeCode = "ROLE_S7_FQC_QE";
+    private const string InspectorCode = "ROLE_S7_FQC_INSPECTOR";
+
+    private static string AuthoritySeed() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "S7TenantAuthoritySeed.cs"));
+
+    private static string FlowSeed() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "FqcInspBillFlowSeed.cs"));
+
+    private static string FlowConst() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcInspBillFlowBizHandler.cs"));
+
+    private static string Engine() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
+
+    private static string FlowTaskService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs"));
+
+    /// <summary>
+    /// **去注释**后的 C# 源码视图。这些文件的注释里会成段引用被淘汰的写法
+    /// (如「原写 RoleSupervisorId=1329915020002」「历史实例各用自己的 FlowJsonSnapshot」)来解释
+    /// 为什么不再那么做;裸文本 DoesNotContain 会把说明文字当成违规实现命中。
+    /// 断言「实现里不得出现 X」时一律用本视图;断言「注释里必须写明某约定」时用原文。
+    /// </summary>
+    private static string CsCode(string src)
+    {
+        var s = System.Text.RegularExpressions.Regex.Replace(src, @"/\*.*?\*/", "", System.Text.RegularExpressions.RegexOptions.Singleline);
+        var kept = s.Split('\n').Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal));
+        return string.Join("\n", kept);
+    }
+
+    private static string FlowJsonBody() => Slice(CsCode(FlowSeed()), "private static string BuildFlowJson()", "private static ApprovalFlowEdgeItem BuildEdge");
+
+    // ── §27-1 / §27-2:N2 / N3 权威必须是 RoleCode ──
+
+    /// <summary>N2 检验主管节点必须用 RoleCode 常量,而不是任何数字 Id。</summary>
+    [Fact]
+    public void DefinitionN2_UsesSupervisorRoleCode()
+    {
+        var n2 = Slice(FlowJsonBody(), "NodeName = \"检验主管审核\"", "Text = new FlowTextItem");
+        Assert.Contains("ApproverIds = FqcInspBillFlowConst.RoleSupervisorCode", n2);
+        Assert.DoesNotContain("RoleSupervisorId", n2);
+        Assert.DoesNotContain(".ToString()", n2);
+    }
+
+    /// <summary>N3 QE 处置节点同理。</summary>
+    [Fact]
+    public void DefinitionN3_UsesQeRoleCode()
+    {
+        var n3 = Slice(FlowJsonBody(), "NodeName = \"QE处置\"", "Text = new FlowTextItem");
+        Assert.Contains("ApproverIds = FqcInspBillFlowConst.RoleQeCode", n3);
+        Assert.DoesNotContain("RoleQeId", n3);
+        Assert.DoesNotContain(".ToString()", n3);
+    }
+
+    /// <summary>RoleCode 常量的字面值必须与 DB 中既有 Code 完全一致(改字面量会让解析静默落空)。</summary>
+    [Theory]
+    [InlineData("RoleInspectorCode", InspectorCode)]
+    [InlineData("RoleSupervisorCode", SupervisorCode)]
+    [InlineData("RoleQeCode", QeCode)]
+    public void RoleCodeConstants_MatchExistingDbCodes(string constName, string expected)
+    {
+        Assert.Contains($"public const string {constName} = \"{expected}\"", FlowConst());
+    }
+
+    // ── §27-3:定义里禁止出现默认租户物理 RoleId ──
+
+    /// <summary>
+    /// 三个 legacy 物理 RoleId 只能作为「默认租户既有固定 Id」出现在租户本地化种子里,
+    /// **不得**出现在流程定义 JSON 的构造中。
+    /// </summary>
+    [Theory]
+    [InlineData("1329915020001")]
+    [InlineData("1329915020002")]
+    [InlineData("1329915020003")]
+    public void DefinitionJson_ContainsNoLegacyPhysicalRoleId(string legacyId)
+    {
+        Assert.DoesNotContain(legacyId, FlowJsonBody());   // FlowJsonBody 已是去注释视图
+    }
+
+    /// <summary>定义构造中不得引用任何 RoleId 常量(即使是本租户的,也只能用 Code)。</summary>
+    [Theory]
+    [InlineData("RoleInspectorId")]
+    [InlineData("RoleSupervisorId")]
+    [InlineData("RoleQeId")]
+    public void DefinitionJson_ReferencesNoRoleIdConstant(string idConst)
+    {
+        Assert.DoesNotContain(idConst, FlowJsonBody());
+    }
+
+    /// <summary>legacy 固定 Id 仍必须保留在常量与种子里——默认租户的历史 FlowJson 与本地角色靠它对齐。</summary>
+    [Fact]
+    public void LegacyRoleIds_StillRetainedForDefaultTenantIdentity()
+    {
+        Assert.Contains("RoleSupervisorId = 1329915020002", FlowConst());
+        Assert.Contains("LegacyDefaultTenantRoleId", AuthoritySeed());
+        Assert.Contains("tenantId == SqlSugarConst.DefaultTenantId", AuthoritySeed());
+    }
+
+    // ── §27-4 / §27-5:角色供给是租户本地的,同 Code 可跨租户共存 ──
+
+    /// <summary>查重键必须是 (TenantId, Code);用全局 Any(Code) 就是「全库单例」的成因。</summary>
+    [Fact]
+    public void RoleProvisioning_DedupesByTenantAndCode()
+    {
+        var body = Slice(AuthoritySeed(), "private static void EnsureTenantLocalRoles", "private static void CopyLegacyCrossTenantMembership");
+        Assert.Contains("r.TenantId == tenantId && r.Code == spec.Code", body);
+        Assert.Contains("TenantId = tenantId,", body);
+    }
+
+    /// <summary>
+    /// 同一个 RoleCode 在不同租户必须是不同 RoleId:默认租户沿用固定 Id,其余走雪花号。
+    /// 若两处都用固定 Id 就会主键冲突/退化回单例。
+    /// </summary>
+    [Fact]
+    public void SameRoleCode_GetsDistinctRoleIdPerTenant()
+    {
+        var body = Slice(AuthoritySeed(), "private static void EnsureTenantLocalRoles", "private static void CopyLegacyCrossTenantMembership");
+        Assert.Contains("? spec.LegacyDefaultTenantRoleId", body);
+        Assert.Contains(": YitIdHelper.NextId()", body);
+    }
+
+    /// <summary>旧的全局 EnsureRole(单例成因)必须已从 S7 种子中移除。</summary>
+    [Fact]
+    public void LegacyGlobalEnsureRole_IsRemovedFromFlowSeed()
+    {
+        var s = CsCode(FlowSeed());
+        Assert.Contains("S7TenantAuthoritySeed.EnsureTenantAuthority(db)", s);
+        Assert.DoesNotContain("ClearFilter().Any(x => x.Code == code)", s);
+        Assert.DoesNotContain("private static void EnsureRoles(", s);
+    }
+
+    /// <summary>租户清单取自 SysTenant 注册表,禁止硬编码具体租户 Id / UAT 账号。</summary>
+    [Theory]
+    [InlineData("797403760988229")]
+    [InlineData("838257186181189")]
+    [InlineData("838257212780613")]
+    [InlineData("AIDOPDemo")]
+    [InlineData("UATQualityA")]
+    public void TenantAuthoritySeed_HardcodesNoTenantOrAccount(string forbidden)
+    {
+        Assert.DoesNotContain(forbidden, AuthoritySeed());
+        Assert.DoesNotContain(forbidden, FlowSeed());
+    }
+
+    /// <summary>租户来源必须是 SysTenant,而不是 SysOrg(按 Org 播种正是 57 条定义与租户错配的成因)。</summary>
+    [Fact]
+    public void TenantSet_ComesFromSysTenantNotSysOrg()
+    {
+        Assert.Contains("db.Queryable<SysTenant>()", AuthoritySeed());
+        var publish = Slice(FlowSeed(), "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId");
+        Assert.Contains("S7TenantAuthoritySeed.GetActivatableTenantIds(db)", publish);
+        Assert.DoesNotContain("db.Queryable<SysOrg>()", publish);
+        Assert.DoesNotContain("foreach (var orgId in orgIds)", publish);
+    }
+
+    // ── §十一:定义租户必须等于目标租户 ──
+
+    /// <summary>新定义的 TenantId 必须就是目标租户,不得再经 Org→Tenant 间接解析。</summary>
+    [Fact]
+    public void NewDefinition_TenantIdEqualsTargetTenant()
+    {
+        var publish = Slice(FlowSeed(), "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId");
+        Assert.Contains("TenantId = tenantId,", publish);
+        Assert.DoesNotContain("ApprovalFlowTenantSeed.ResolveTenantId", publish);
+        Assert.Contains("x.TenantId == tenantId", publish);
+    }
+
+    // ── §十二:历史定义不得被改动 ──
+
+    /// <summary>不得下架 / 改写 / 删除任何历史定义(原 v2→v3 升级会 SetColumns(IsPublished=false))。</summary>
+    [Fact]
+    public void HistoricalDefinitions_AreNeitherUnpublishedNorRewritten()
+    {
+        var s = CsCode(FlowSeed());
+        Assert.DoesNotContain("x.IsPublished == false", s);
+        Assert.DoesNotContain("Updateable<ApprovalFlowEntity>", s);
+        Assert.DoesNotContain("Deleteable", s);
+    }
+
+    /// <summary>补发靠新版本号(既有最大 + 1),不原地改已被实例引用的定义。</summary>
+    [Fact]
+    public void NewDefinition_UsesIncrementedVersion()
+    {
+        var publish = Slice(FlowSeed(), "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId");
+        Assert.Contains("Version = (existing?.Version ?? 0) + 1", publish);
+    }
+
+    /// <summary>幂等判据是「已含 RoleCode」——重复启动不会反复补发。</summary>
+    [Fact]
+    public void Seeding_IsIdempotentOnRoleCodePresence()
+    {
+        var publish = Slice(FlowSeed(), "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId");
+        Assert.Contains("Contains(FqcInspBillFlowConst.RoleSupervisorCode)", publish);
+    }
+
+    // ── §27-6:N1 仍是 Initiator ──
+
+    /// <summary>本批不得把 N1 改回 Inspector Role —— 业务设计是「认领人(发起人)」。</summary>
+    [Fact]
+    public void DefinitionN1_RemainsInitiator()
+    {
+        var n1 = Slice(FlowJsonBody(), "NodeName = \"检验员检验\"", "Text = new FlowTextItem");
+        Assert.Contains("ApproverType = nameof(ApproverTypeEnum.Initiator)", n1);
+        Assert.Contains("ApproverIds = \"\"", n1);
+    }
+
+    // ── §十四 / §十五:成员语义只复制既有事实,legacy 绑定保留 ──
+
+    /// <summary>成员复制必须是 COPY 而非 MOVE:不得删除任何既有绑定。</summary>
+    [Fact]
+    public void MembershipCopy_NeverDeletesLegacyBinding()
+    {
+        Assert.DoesNotContain("Deleteable", CsCode(AuthoritySeed()));
+        Assert.Contains("COPY 不是 MOVE", AuthoritySeed());   // 该约定必须写在注释里
+    }
+
+    /// <summary>只复制已存在的授权事实,不凭猜测新增业务成员。</summary>
+    [Fact]
+    public void MembershipCopy_DerivesOnlyFromExistingBindings()
+    {
+        var body = Slice(AuthoritySeed(), "private static void CopyLegacyCrossTenantMembership", "public static long? ResolveLocalRoleId");
+        Assert.Contains("ur.RoleId == spec.LegacyDefaultTenantRoleId", body);
+        Assert.Contains("u.TenantId != SqlSugarConst.DefaultTenantId", body);
+        // SysUserRole 无唯一键,必须自己防重
+        Assert.Contains("x.UserId == user.Id && x.RoleId == localRoleId.Value", body);
+    }
+
+    /// <summary>Activation Gate:三角色都有本地角色且各有同租户成员,才为该租户发布 RoleCode 定义。</summary>
+    [Fact]
+    public void ActivationGate_RequiresLocalRoleWithSameTenantMember()
+    {
+        var body = Slice(AuthoritySeed(), "public static List<long> GetActivatableTenantIds", "private static List<long> ResolveTenantIds");
+        Assert.Contains("ResolveLocalRoleId(db, tenantId, spec.Code)", body);
+        Assert.Contains("u.TenantId == tenantId", body);
+        Assert.Contains("if (sameTenantMembers == 0) { allReady = false; break; }", body);
+    }
+
+    // ── §27-7 / §二十五:本批不得触碰历史实例 ──
+
+    /// <summary>种子与租户权威基线都不得读写 ApprovalFlowInstance / FlowJsonSnapshot。</summary>
+    [Theory]
+    [InlineData("ApprovalFlowInstance")]
+    [InlineData("FlowJsonSnapshot")]
+    [InlineData("InitiatorId")]
+    public void Seeding_NeverTouchesExistingInstances(string forbidden)
+    {
+        Assert.DoesNotContain(forbidden, CsCode(AuthoritySeed()));
+        Assert.DoesNotContain(forbidden, CsCode(FlowSeed()));
+    }
+
+    // ── §二十八 / §二十九:回归与顺序 ──
+
+    /// <summary>S7 通用审批旁路守卫必须仍然生效(上一批 CLOSED,不得被本批影响)。</summary>
+    [Fact]
+    public void GenericApprovalGuard_StillCoversS7()
+    {
+        var dict = Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};");
+        Assert.Contains("[\"S7_FQC_INSPBILL\"]", dict);
+    }
+
+    /// <summary>
+    /// §29:本批**不得**把 S7 加入 TenantStrictRoleBizTypes ——
+    /// 7 条 Running 仍是 legacy 物理 RoleId 快照,先开守卫会当场把它们全部拦死。
+    /// 正确顺序:local roles → future definition RoleCode → active migration → strict guard。
+    /// </summary>
+    [Fact]
+    public void StrictRoleGuard_NotYetOpenedForS7()
+    {
+        var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};");
+        Assert.DoesNotContain("S7_FQC_INSPBILL", set);
+        Assert.Contains("\"IPQC_INSPECTION\"", set);
+        Assert.Contains("\"S6_PROCESS_INSPECTION\"", set);
+    }
+
+    /// <summary>RoleCode 解析必须是租户内解析(这是 RoleCode 方案成立的前提)。</summary>
+    [Fact]
+    public void RoleCodeResolver_IsTenantScoped()
+    {
+        var body = Slice(Engine(), "if (codes.Count > 0)", "if (ids.Count == 0)");
+        Assert.Contains("r.TenantId == effectiveTenantId", body);
+        Assert.Contains("r.Status == StatusEnum.Enable", body);
+        Assert.Contains("codes.Contains(r.Code)", body);
+    }
+
+    /// <summary>设计代次常量已升到 v4,且明确它不等于 ApprovalFlow.Version。</summary>
+    [Fact]
+    public void FlowDefinitionVersion_BumpedAndDocumented()
+    {
+        var s = FlowConst();
+        Assert.Contains("public const int FlowDefinitionVersion = 4;", s);
+        Assert.Contains("不等于", s);
+    }
+
+    private static string Slice(string src, string from, string to)
+    {
+        var a = src.IndexOf(from, StringComparison.Ordinal);
+        Assert.True(a >= 0, $"未找到起点:{from}");
+        var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
+        return b > a ? src[a..b] : src[a..];
+    }
+
+    private static string FindFile(params string[] parts)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null)
+        {
+            var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
+            if (File.Exists(candidate)) return candidate;
+            dir = dir.Parent;
+        }
+        throw new FileNotFoundException(string.Join("/", parts));
+    }
+}

+ 8 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcInspBillFlowBizHandler.cs

@@ -43,8 +43,14 @@ public static class FqcInspBillFlowConst
     /// <summary>QE 角色固定 Id</summary>
     public const long RoleQeId = 1329915020003L;
 
-    /// <summary>流程定义版本(v2 加入 GW_RESULT/N3_QE_DISPOSITION;v3 N1 审批人改 Initiator 发起人本人=认领人;seed 幂等升级依据)</summary>
-    public const int FlowDefinitionVersion = 3;
+    /// <summary>
+    /// 流程定义**设计代次**(v2 加入 GW_RESULT/N3_QE_DISPOSITION;v3 N1 审批人改 Initiator 发起人本人=认领人;
+    /// v4 = S7-FQC-TENANT-AUTHORITY-1,N2/N3 权威由默认租户物理 RoleId 改为 RoleCode,定义按租户播种)。
+    ///
+    /// 注意:这是设计代次标记,**不等于** `ApprovalFlow.Version` —— 后者由 seed 按
+    /// 「该租户既有最大版本 + 1」逐租户计算(各租户历史版本数不同,无法用同一个数字表达)。
+    /// </summary>
+    public const int FlowDefinitionVersion = 4;
 
     /// <summary>UAT 冒烟绑定账号(同绑三角色,仅 UAT 自审)</summary>
     public const string UatAccount = "superAdmin.NET";

+ 45 - 57
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/FqcInspBillFlowSeed.cs

@@ -28,7 +28,10 @@ public static class FqcInspBillFlowSeed
                 typeof(ApprovalFlowVersion));
             // QE 处置表(CodeFirst 建表,无 SQL)
             db.CodeFirst.InitTables(typeof(AdoS7FqcQeDisposition));
-            EnsureRoles(db);
+            // S7-FQC-TENANT-AUTHORITY-1:角色供给改为**租户本地化**(原 EnsureRole 用全局
+            // ClearFilter().Any(Code) 查重,默认租户一有该 Code 就再不为其它租户建 → 全库单例)。
+            // 默认租户沿用既有固定 Id,其余租户各自一份;既有跨租户绑定 COPY 保留、不删。
+            S7TenantAuthoritySeed.EnsureTenantAuthority(db);
             EnsureUatMembers(db);
             EnsureBizType(db);
             EnsurePublishedFlow(db);
@@ -39,31 +42,6 @@ public static class FqcInspBillFlowSeed
         }
     }
 
-    private static void EnsureRoles(ISqlSugarClient db)
-    {
-        EnsureRole(db, FqcInspBillFlowConst.RoleInspectorId, FqcInspBillFlowConst.RoleInspectorCode, "成品检验员", 820);
-        EnsureRole(db, FqcInspBillFlowConst.RoleSupervisorId, FqcInspBillFlowConst.RoleSupervisorCode, "成品检验主管", 821);
-        EnsureRole(db, FqcInspBillFlowConst.RoleQeId, FqcInspBillFlowConst.RoleQeCode, "成品检验QE", 822);
-    }
-
-    private static void EnsureRole(ISqlSugarClient db, long id, string code, string name, int orderNo)
-    {
-        var exists = db.Queryable<SysRole>().ClearFilter().Any(x => x.Code == code);
-        if (exists) return;
-        db.Insertable(new SysRole
-        {
-            Id = id,
-            TenantId = SqlSugarConst.DefaultTenantId,
-            Name = name,
-            Code = code,
-            OrderNo = orderNo,
-            DataScope = DataScopeEnum.Self,
-            Status = StatusEnum.Enable,
-            Remark = "S7 成品检验流程角色",
-            CreateTime = DateTime.Now,
-        }).ExecuteCommand();
-    }
-
     private static void EnsureUatMembers(ISqlSugarClient db)
     {
         var user = db.Queryable<SysUser>().ClearFilter()
@@ -107,54 +85,48 @@ public static class FqcInspBillFlowSeed
         }).ExecuteCommand();
     }
 
+    /// <summary>
+    /// S7-FQC-TENANT-AUTHORITY-1:定义播种维度由 **SysOrg** 改为 **可激活租户**。
+    ///
+    /// 原实现按 SysOrg 逐个播种(<c>Code = $"S7FQC_{orgId}"</c>、<c>TenantId = ResolveTenantId(db, orgId)</c>),
+    /// 造成 57 条定义且多条定义租户与业务租户错配(审计实测:5 条 Running + 4 条终态实例的
+    /// 定义租户是 788012541595717,而业务租户是 797 / 838)。审批权威的维度是**租户**,不是机构。
+    ///
+    /// 幂等判据改为「已发布定义的 FlowJson 是否已含 RoleCode」:含则跳过,否则补发新版本。
+    /// **不再下架历史定义**(原 v2→v3 升级会 SetColumns(IsPublished=false))——历史事实保留,
+    /// 新版本靠 StartFlowCore 的「本租户优先 + Version 降序」自然胜出即可。
+    /// 历史 Running/Approved 实例各用自己的 FlowJsonSnapshot,本方法对其零影响。
+    /// </summary>
     private static void EnsurePublishedFlow(ISqlSugarClient db)
     {
-        var orgIds = db.Queryable<SysOrg>()
-            .Select(x => x.Id)
-            .ToList()
-            .Where(x => x > 0)
-            .Distinct()
-            .ToList();
-        if (orgIds.Count == 0) orgIds.Add(0);
-
         var now = DateTime.Now;
         var flowJson = BuildFlowJson();
-        foreach (var orgId in orgIds)
+        foreach (var tenantId in S7TenantAuthoritySeed.GetActivatableTenantIds(db))
         {
             var existing = db.Queryable<ApprovalFlowEntity>()
-                .Where(x => x.BizType == FqcInspBillFlowConst.BizType && x.OrgId == orgId && x.IsPublished && !x.IsDelete)
+                .ClearFilter()
+                .Where(x => x.BizType == FqcInspBillFlowConst.BizType && x.TenantId == tenantId && !x.IsDelete)
                 .OrderByDescending(x => x.Version)
                 .First();
 
-            // v2→v3 幂等升级:v3 = N1 检验节点审批人由 Role(成品检验员) 改为 Initiator(发起人本人=认领人)。
-            // 已发布定义若已是 v3(FlowJson 含 "Initiator")则跳过;否则(v1/v2,无 Initiator)下架旧版、发布 v3。
-            // 历史 Running/Approved 实例用各自 FlowJsonSnapshot,不受影响;Seed 重复执行幂等、不重复建多个 v3。
-            if (existing != null)
-            {
-                var isV3 = (existing.FlowJson ?? "").Contains(nameof(ApproverTypeEnum.Initiator));
-                if (isV3) continue;
-                db.Updateable<ApprovalFlowEntity>()
-                    .SetColumns(x => x.IsPublished == false)
-                    .SetColumns(x => x.UpdateTime == now)
-                    .Where(x => x.Id == existing.Id)
-                    .ExecuteCommand();
-            }
+            // 已有 RoleCode 版则跳过(幂等);仍是纯数字 RoleId 的需要补发。
+            if (existing != null && (existing.FlowJson ?? "").Contains(FqcInspBillFlowConst.RoleSupervisorCode))
+                continue;
 
-            var nextVersion = (existing?.Version ?? 0) + 1;
             var flow = new ApprovalFlowEntity
             {
                 Id = YitIdHelper.NextId(),
-                Code = $"S7FQC_{orgId}",
+                Code = $"S7FQC_T{tenantId}",
                 Name = "成品检验单审批",
                 BizType = FqcInspBillFlowConst.BizType,
                 FormJson = "{}",
                 FlowJson = flowJson,
                 Status = 1,
-                Remark = "系统默认流程 v3:开始-检验员检验(发起人本人/认领人)-检验主管审核-网关(pd=1)-QE处置-结束",
-                Version = nextVersion,
+                Remark = "租户级流程:开始-检验员检验(发起人本人/认领人)-检验主管审核-网关(pd=1)-QE处置-结束(审批权威=RoleCode)",
+                Version = (existing?.Version ?? 0) + 1,
                 IsPublished = true,
-                OrgId = orgId,
-                TenantId = ApprovalFlowTenantSeed.ResolveTenantId(db, orgId),
+                OrgId = ResolveTenantOrgId(db, tenantId),
+                TenantId = tenantId,
                 CreateTime = now,
                 UpdateTime = now,
             };
@@ -174,6 +146,17 @@ public static class FqcInspBillFlowSeed
         }
     }
 
+    /// <summary>取租户主机构 Id 落 OrgId;OrgId 不参与 Runtime 选取,仅保持字段有意义。</summary>
+    private static long ResolveTenantOrgId(ISqlSugarClient db, long tenantId)
+    {
+        var orgId = db.Queryable<SysTenant>()
+            .ClearFilter<ITenantIdFilter>()
+            .Where(x => x.Id == tenantId)
+            .Select(x => x.OrgId)
+            .First();
+        return orgId > 0 ? orgId : 0;
+    }
+
     private static string BuildFlowJson()
     {
         var item = new ApprovalFlowItem
@@ -216,8 +199,12 @@ public static class FqcInspBillFlowSeed
                     Properties = new FlowProperties
                     {
                         NodeName = "检验主管审核",
+                        // S7-FQC-TENANT-AUTHORITY-1:权威改为 **RoleCode**,由引擎在目标租户内解析成
+                        // 该租户自己的角色。定义是模板、RoleId 是租户本地身份,模板里不能存某个具体租户的
+                        // 物理 RoleId(原写 RoleSupervisorId=1329915020002,属默认租户)。
+                        // 即使此处能查到本租户 RoleId 也不写入——那会让定义重新变成租户绑定的。
                         ApproverType = nameof(ApproverTypeEnum.Role),
-                        ApproverIds = FqcInspBillFlowConst.RoleSupervisorId.ToString(),
+                        ApproverIds = FqcInspBillFlowConst.RoleSupervisorCode,
                         ApproverNames = "成品检验主管",
                         MultiApproveMode = "Any",
                     },
@@ -260,8 +247,9 @@ public static class FqcInspBillFlowSeed
                     Properties = new FlowProperties
                     {
                         NodeName = "QE处置",
+                        // 同 N2:权威用 RoleCode,不写默认租户物理 RoleId(原 RoleQeId=1329915020003)。
                         ApproverType = nameof(ApproverTypeEnum.Role),
-                        ApproverIds = FqcInspBillFlowConst.RoleQeId.ToString(),
+                        ApproverIds = FqcInspBillFlowConst.RoleQeCode,
                         ApproverNames = "成品检验QE",
                         MultiApproveMode = "Any",
                     },

+ 212 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S7TenantAuthoritySeed.cs

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