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

fix(s6): make IPQC approval authority tenant-local | server 1.0.537

S6 两条审批链(IPQC_INSPECTION / S6_PROCESS_INSPECTION)此前把「谁能审批」
物化成默认租户的物理 RoleId:三个角色 ROLE_S6_IPQC_INSPECTOR/_SUPERVISOR/
_QUALITY_ENGINEER 是全库单例、固定挂 1300000000001,各租户用户靠跨租户
SysUserRole 绑定借用,流程定义又把这些 RoleId 写死进 FlowJson。

后果(已取证):可用性挂在一条无租户列、无唯一键、无审计的手工绑定上。
UAT 租户 838257186181189 的 N2 主管绑定被删掉一行后,该租户 S6 提交审核
当场解析为 0 审批人,报错却是语义误导的「未配置审批人」。同一模式覆盖
7/8 个 BizType、9/10 个租户,S6 只是暴露得最彻底的那个。

本提交只做迁移(Migration),不含 Resolver 守卫:

1. 新增 S6TenantAuthoritySeed —— 两条链共用的租户权威基线
   - 角色查重键由全局 Any(Code) 改为 (TenantId, Code),按 SysTenant 注册表
     为每个租户建自己的三个 S6 角色;默认租户沿用既有固定 Id 不换号
   - 把「用户在 A 租户却绑默认租户 S6 角色」的既有授权语义,等价复制成
     该用户自己租户内的本地绑定。COPY 不是 MOVE:legacy 绑定原样保留,
     供历史实例与尚未迁移的 legacy 定义继续使用;自带防重(该表无唯一键)
   - Activation Gate:三个角色在本租户都有**同租户**成员才算 ready

2. 两条链的 FlowJson 审批权威由物理 RoleId 改为 RoleCode
   引擎的 Code 分支按 (Code, effectiveTenantId, Status=Enable) 在当前租户内
   重新解析本地 RoleId,天然租户安全;该分支既有实现未改动

3. 流程定义副本维度由 Org 改为 Tenant 并显式落 TenantId
   StartFlowCore 选定义时只按 TenantId 过滤、完全不看 OrgId —— 原先按 Org
   建副本且 S6_PROCESS_INSPECTION 从不设置 TenantId(20 份全 NULL),
   导致同租户候选排序键全并列、FirstAsync 选谁不确定。改为每租户一份后
   租户内候选唯一。只为通过 Activation Gate 的租户发布,避免把仍靠 legacy
   路径工作的租户迁成 0 审批人

历史定义与历史绑定**一律不下架、不删除**,本提交纯增量;legacy 清理与
IPQC 历史重复定义(默认租户 7 份 / 797403760988230 5 份)另立批次。

验证:dotnet build --framework net10.0 = 0 Error,改动文件 0 warning;
全量测试 2397 passed / 1 failed(S8AuthorizationGuardTests
.MutationActions_DoNotUseReadOnlyCapabilities,master 既有失败,与本批无关)。
⚠️ Runtime 未验证:共享主机 123.60.180.165 故障(MySQL 3306 refused、
git 4647 HTTP 500),种子实际执行与 838 租户链路恢复待环境恢复后补验。
YY968XX 3 дней назад
Родитель
Сommit
701ea6f8ff

+ 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.536</AssemblyVersion>
-    <FileVersion>1.0.536</FileVersion>
-    <Version>1.0.536</Version>
+    <AssemblyVersion>1.0.537</AssemblyVersion>
+    <FileVersion>1.0.537</FileVersion>
+    <Version>1.0.537</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 231 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/ApprovalFlow/S6TenantAuthorityMigrationContractTests.cs

@@ -0,0 +1,231 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
+
+/// <summary>
+/// S6-TENANT-AUTHORITY-1 源码契约测试:S6 两条 IPQC 审批链的审批权威必须租户本地化。
+///
+/// 背景(Evidence Audit 结论):
+///   Primary   = SYSTEMIC TENANT CLONE DEFECT —— 三个 S6 角色全库单例挂默认租户,
+///               流程定义把默认租户的**物理 RoleId** 写死进 FlowJson,各租户靠跨租户
+///               SysUserRole 绑定借用;该绑定被删一行,整个租户的 N2 当场解析为 0 人。
+///   Secondary = UAT ROLE DRIFT(引爆点,非根因)
+///   Contributing = Resolver Branch B FAIL-SILENT
+///
+/// 目标模型:tenant-local Role + same-tenant membership + RoleCode authority
+///           + tenant-scoped Definition + S6 专属 fail-closed 守卫。
+///
+/// 本层沿用本仓对 FlowEngine / 种子的既有做法——源码契约断言而非行为级集成测试:
+/// 这些类型依赖 ISqlSugarClient / SqlSugarRepository&lt;T&gt;,裸 xUnit 进程无法实例化
+/// (见 S8ExceptionFlowTenantContextContractTests 与 S5IqcGenericApprovalGuardContractTests 的同款说明)。
+/// 真实解析行为由本批的 Runtime 矩阵 R1–R13 覆盖。
+/// </summary>
+public class S6TenantAuthorityMigrationContractTests
+{
+    private const string InspectorCode = "ROLE_S6_IPQC_INSPECTOR";
+    private const string SupervisorCode = "ROLE_S6_IPQC_SUPERVISOR";
+    private const string QeCode = "ROLE_S6_IPQC_QUALITY_ENGINEER";
+
+    private static string AuthoritySeed() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "S6TenantAuthoritySeed.cs"));
+
+    private static string IpqcSeed() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "IpqcInspectionFlowSeed.cs"));
+
+    private static string ProcessSeed() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "S6ProcessInspectionFlowSeed.cs"));
+
+    // ───────────── 角色种子:tenant-local ─────────────
+
+    /// <summary>角色查重键必须是 (TenantId, Code);原实现的全局 Any(Code) 正是「全库单例」成因。</summary>
+    [Fact]
+    public void RoleSeed_DedupesByTenantAndCode_NotGlobally()
+    {
+        var s = AuthoritySeed();
+        Assert.Contains("r.TenantId == tenantId && r.Code == spec.Code", s);
+        // 不得退回「只要任何租户有这个 Code 就不再建」
+        Assert.DoesNotContain("Any(x => x.Code == code)", s);
+    }
+
+    /// <summary>同 Code 在不同租户各一份是合法的:RoleId 必须逐租户新取号,不得全租户共用固定 Id。</summary>
+    [Fact]
+    public void RoleSeed_AllocatesPerTenantRoleId_ExceptDefaultTenantLegacy()
+    {
+        var s = AuthoritySeed();
+        Assert.Contains("YitIdHelper.NextId()", s);
+        // 默认租户沿用既有固定 Id(历史 FlowJson 与业务门禁仍按该 Id 判定,换号会打断它们)
+        Assert.Contains("tenantId == SqlSugarConst.DefaultTenantId", s);
+        Assert.Contains("spec.LegacyDefaultTenantRoleId", s);
+    }
+
+    /// <summary>租户清单必须取自正式注册表 SysTenant,不得自行构造。</summary>
+    [Fact]
+    public void RoleSeed_EnumeratesTenantsFromOfficialRegistry()
+    {
+        Assert.Contains("Queryable<SysTenant>()", AuthoritySeed());
+    }
+
+    /// <summary>通用种子里禁止出现任何具体租户 Id 或 UAT 账号。</summary>
+    [Theory]
+    [InlineData("838257186181189")]
+    [InlineData("797403760988229")]
+    [InlineData("UATAdminA")]
+    [InlineData("UATQualityA")]
+    [InlineData("UATQualityB")]
+    public void Seeds_NeverHardcodeTenantOrUatAccount(string forbidden)
+    {
+        Assert.DoesNotContain(forbidden, AuthoritySeed());
+        Assert.DoesNotContain(forbidden, IpqcSeed());
+        Assert.DoesNotContain(forbidden, ProcessSeed());
+    }
+
+    /// <summary>membership 迁移是 COPY 不是 MOVE:不得删除既有跨租户绑定。</summary>
+    [Fact]
+    public void MembershipMigration_CopiesAndNeverDeletesLegacyBinding()
+    {
+        var s = AuthoritySeed();
+        Assert.Contains("CopyLegacyCrossTenantMembership", s);
+        Assert.DoesNotContain("Deleteable<SysUserRole>", s);
+        Assert.DoesNotContain("DeleteAsync", s);
+    }
+
+    /// <summary>SysUserRole 没有 (UserId, RoleId) 唯一约束,迁移必须自己防重。</summary>
+    [Fact]
+    public void MembershipMigration_IsIdempotent()
+    {
+        var body = Slice(AuthoritySeed(), "private static void CopyLegacyCrossTenantMembership", "public static long? ResolveLocalRoleId");
+        Assert.Contains("Any(x => x.UserId == user.Id && x.RoleId == localRoleId.Value)", body);
+        Assert.Contains("if (bound) continue;", body);
+    }
+
+    /// <summary>Activation Gate:成员必须与角色同租户才算 ready,只数绑定行会误判。</summary>
+    [Fact]
+    public void ActivationGate_RequiresSameTenantMembersForAllThreeRoles()
+    {
+        var body = Slice(AuthoritySeed(), "public static List<long> GetActivatableTenantIds", "private static List<long> ResolveTenantIds");
+        Assert.Contains("u.TenantId == tenantId", body);
+        Assert.Contains("if (sameTenantMembers == 0)", body);
+        Assert.Contains("foreach (var spec in RoleSpecs)", body);
+    }
+
+    // ───────────── FlowJson:RoleCode 权威 ─────────────
+
+    /// <summary>两条 S6 链的 Role 节点一律用 RoleCode,不得再出现物理 RoleId。</summary>
+    [Theory]
+    [InlineData("IpqcInspectionFlowConst.RoleInspectorId.ToString()")]
+    [InlineData("IpqcInspectionFlowConst.RoleSupervisorId.ToString()")]
+    [InlineData("IpqcInspectionFlowConst.RoleQualityEngineerId.ToString()")]
+    public void IpqcFlowJson_NoLongerEmbedsPhysicalRoleId(string forbidden)
+    {
+        Assert.DoesNotContain(forbidden, IpqcSeed());
+    }
+
+    /// <summary>S6_PROCESS_INSPECTION 同样不得再写物理 RoleId。</summary>
+    [Theory]
+    [InlineData("RoleSupervisorId.ToString()")]
+    [InlineData("RoleQualityEngineerId.ToString()")]
+    public void ProcessFlowJson_NoLongerEmbedsPhysicalRoleId(string forbidden)
+    {
+        Assert.DoesNotContain(forbidden, ProcessSeed());
+    }
+
+    /// <summary>IPQC 三个 Role 节点的 approverIds 必须是三个 RoleCode 常量。</summary>
+    [Theory]
+    [InlineData("ApproverIds = IpqcInspectionFlowConst.RoleInspectorCode")]
+    [InlineData("ApproverIds = IpqcInspectionFlowConst.RoleSupervisorCode")]
+    [InlineData("ApproverIds = IpqcInspectionFlowConst.RoleQualityEngineerCode")]
+    public void IpqcFlowJson_UsesRoleCodeAuthority(string expected)
+    {
+        Assert.Contains(expected, IpqcSeed());
+    }
+
+    /// <summary>S6_PROCESS_INSPECTION 的 N2/N3 必须是 RoleCode(N1 是 Initiator,不查角色)。</summary>
+    [Theory]
+    [InlineData("ApproverIds = S6ProcessInspectionFlowConst.RoleSupervisorCode")]
+    [InlineData("ApproverIds = S6ProcessInspectionFlowConst.RoleQualityEngineerCode")]
+    public void ProcessFlowJson_UsesRoleCodeAuthority(string expected)
+    {
+        Assert.Contains(expected, ProcessSeed());
+    }
+
+    /// <summary>三个 RoleCode 字面量本身必须稳定,改动会让既有定义与新定义解析到不同角色。</summary>
+    [Fact]
+    public void RoleCodes_AreStable()
+    {
+        var s = File.ReadAllText(FindFile(
+            "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionFlowConst.cs"));
+        Assert.Contains($"RoleInspectorCode = \"{InspectorCode}\"", s);
+        Assert.Contains($"RoleSupervisorCode = \"{SupervisorCode}\"", s);
+        Assert.Contains($"RoleQualityEngineerCode = \"{QeCode}\"", s);
+    }
+
+    // ───────────── Definition:tenant-scoped ─────────────
+
+    /// <summary>定义副本维度必须是 Tenant 而不是 Org —— 引擎选定义时只看 TenantId、完全不看 OrgId。</summary>
+    [Theory]
+    [InlineData("Ipqc")]
+    [InlineData("Process")]
+    public void Definitions_AreKeyedByTenantNotOrg(string which)
+    {
+        var s = which == "Ipqc" ? IpqcSeed() : ProcessSeed();
+        var body = Slice(s, "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId");
+        Assert.Contains("GetActivatableTenantIds(db)", body);
+        Assert.Contains("x.TenantId == tenantId", body);
+        Assert.Contains("TenantId = tenantId,", body);
+        // 不得再按 SysOrg 逐组织复制
+        Assert.DoesNotContain("Queryable<SysOrg>()", body);
+    }
+
+    /// <summary>新定义必须显式落 TenantId,不得再产生 TenantId 为空的「全局」定义。</summary>
+    [Theory]
+    [InlineData("Ipqc")]
+    [InlineData("Process")]
+    public void Definitions_SetExplicitTenantId(string which)
+    {
+        var s = which == "Ipqc" ? IpqcSeed() : ProcessSeed();
+        Assert.Contains("TenantId = tenantId,", s);
+        Assert.DoesNotContain("TenantId = null", s);
+    }
+
+    /// <summary>幂等:已是 RoleCode 版就跳过,不得每次启动都堆一版。</summary>
+    [Theory]
+    [InlineData("Ipqc")]
+    [InlineData("Process")]
+    public void Definitions_AreIdempotentOnRoleCodeMarker(string which)
+    {
+        var s = which == "Ipqc" ? IpqcSeed() : ProcessSeed();
+        Assert.Contains("RoleSupervisorCode))", s);
+        Assert.Contains("continue;", s);
+    }
+
+    /// <summary>本批纯增量:不得下架或删除历史定义(legacy 清理属后续独立批次)。</summary>
+    [Theory]
+    [InlineData("Ipqc")]
+    [InlineData("Process")]
+    public void Definitions_NeverUnpublishOrDeleteLegacy(string which)
+    {
+        var s = which == "Ipqc" ? IpqcSeed() : ProcessSeed();
+        Assert.DoesNotContain("x.IsPublished == false", s);
+        Assert.DoesNotContain("Deleteable<ApprovalFlowEntity>", 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));
+    }
+}

+ 6 - 4
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S6/Manufacturing/S6ProcessInspectionReviewContractTests.cs

@@ -69,10 +69,12 @@ public class S6ProcessInspectionReviewContractTests
     {
         var seed = Seed();
         Assert.Contains("nameof(ApproverTypeEnum.Initiator)", seed);      // N1=发起人
-        Assert.Contains("RoleSupervisorId", seed);                        // N2 复用 IPQC 主管角色
-        Assert.Contains("RoleQualityEngineerId", seed);                   // N3 复用 IPQC QE 角色
-        // 复用 IPQC 角色 Id(不新造角色)
-        Assert.Contains("IpqcInspectionFlowConst.RoleSupervisorId", Const());
+        // S6-TENANT-AUTHORITY-1:审批权威由物理 RoleId 改为 RoleCode(原断言锁的是已被本批修复的
+        // 「FlowJson 写死默认租户 RoleId」实现细节)。「N2/N3 复用 IPQC 角色」这一原意不变,只是改按 Code 表达。
+        Assert.Contains("RoleSupervisorCode", seed);                      // N2 复用 IPQC 主管角色
+        Assert.Contains("RoleQualityEngineerCode", seed);                 // N3 复用 IPQC QE 角色
+        // 复用 IPQC 角色定义(不新造角色码)
+        Assert.Contains("IpqcInspectionFlowConst.RoleSupervisorCode", Const());
     }
 
     private static string FindFile(params string[] parts)

+ 46 - 56
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/IpqcInspectionFlowSeed.cs

@@ -32,7 +32,9 @@ public static class IpqcInspectionFlowSeed
             // 流程状态轻量扩展表 + 质量处置表(CodeFirst 建表,无 SQL)
             db.CodeFirst.InitTables(typeof(AdoS6IpqcInspectionFlowState));
             db.CodeFirst.InitTables(typeof(AdoS6IpqcQualityDisposition));
-            EnsureRoles(db);
+            // S6-TENANT-AUTHORITY-1:三个 S6 角色改由共享种子按租户建立(原实现全库单例挂默认租户),
+            // 并把既有跨租户成员语义等价复制到各自租户。两条 S6 链共用,重复调用幂等。
+            S6TenantAuthoritySeed.EnsureTenantAuthority(db);
             EnsureUatMembers(db);
             EnsureBizType(db);
             EnsurePublishedFlow(db);
@@ -43,31 +45,6 @@ public static class IpqcInspectionFlowSeed
         }
     }
 
-    private static void EnsureRoles(ISqlSugarClient db)
-    {
-        EnsureRole(db, IpqcInspectionFlowConst.RoleInspectorId, IpqcInspectionFlowConst.RoleInspectorCode, "过程检验员", 820);
-        EnsureRole(db, IpqcInspectionFlowConst.RoleSupervisorId, IpqcInspectionFlowConst.RoleSupervisorCode, "过程检验主管", 821);
-        EnsureRole(db, IpqcInspectionFlowConst.RoleQualityEngineerId, IpqcInspectionFlowConst.RoleQualityEngineerCode, "过程检验质量工程师", 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 = "S6 过程检验流程角色",
-            CreateTime = DateTime.Now,
-        }).ExecuteCommand();
-    }
-
     private static void EnsureUatMembers(ISqlSugarClient db)
     {
         var user = db.Queryable<SysUser>().ClearFilter()
@@ -111,54 +88,50 @@ public static class IpqcInspectionFlowSeed
         }).ExecuteCommand();
     }
 
+    /// <summary>
+    /// S6-TENANT-AUTHORITY-1:副本维度由 **Org 改为 Tenant**。
+    ///
+    /// 原实现按 SysOrg 逐组织建一份,而 FlowEngineService.StartFlowCore 选定义时**只按 TenantId** 过滤、
+    /// 完全不看 OrgId —— 维度错配导致同一租户下出现多份排序键全并列的候选,FirstAsync 选谁不确定。
+    /// 改为每租户一份后,租户内候选唯一,且新版本号更高,选取具确定性。
+    ///
+    /// 只为通过 Activation Gate(三个角色在本租户均有同租户成员)的租户发布,
+    /// 避免把目前仍靠 legacy 跨租户 RoleId 工作的租户迁成 0 审批人。
+    /// 历史定义(按 Org 建的、以及 TenantId 为空的)**一律不下架、不删除**:本方法纯增量,
+    /// 依靠「本租户优先 + 版本号更高」让新定义被稳定命中;legacy 清理属后续独立批次。
+    /// </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 S6TenantAuthoritySeed.GetActivatableTenantIds(db))
         {
             var existing = db.Queryable<ApprovalFlowEntity>()
-                .Where(x => x.BizType == IpqcInspectionFlowConst.BizType && x.OrgId == orgId && x.IsPublished && !x.IsDelete)
+                .ClearFilter()
+                .Where(x => x.BizType == IpqcInspectionFlowConst.BizType && x.TenantId == tenantId && !x.IsDelete)
                 .OrderByDescending(x => x.Version)
                 .First();
 
-            // v1→v2 幂等升级:已发布定义若已含 N3/GW_RESULT(v2)则跳过;若仍为 v1(无 N3)则下架旧版、发布 v2。
-            // 历史 Running/Approved 实例使用各自 FlowJsonSnapshot 冻结,不受本次 republish 影响。
-            if (existing != null)
-            {
-                var isV2 = (existing.FlowJson ?? "").Contains(IpqcInspectionFlowConst.NodeDisposition)
-                    && (existing.FlowJson ?? "").Contains(IpqcInspectionFlowConst.GatewayResult);
-                if (isV2) continue;
-                db.Updateable<ApprovalFlowEntity>()
-                    .SetColumns(x => x.IsPublished == false)
-                    .SetColumns(x => x.UpdateTime == now)
-                    .Where(x => x.Id == existing.Id)
-                    .ExecuteCommand();
-            }
+            // 目标形态判据:审批权威已是 RoleCode。含 RoleCode 即为当前版,跳过(幂等)。
+            // 仍是纯数字 RoleId 的(无论 v1 还是 v2 拓扑)都需要补发一版 RoleCode 定义。
+            if (existing != null && (existing.FlowJson ?? "").Contains(IpqcInspectionFlowConst.RoleSupervisorCode))
+                continue;
 
             var nextVersion = (existing?.Version ?? 0) + 1;
             var flow = new ApprovalFlowEntity
             {
                 Id = YitIdHelper.NextId(),
-                Code = $"S6IPQC_{orgId}",
+                Code = $"S6IPQC_T{tenantId}",
                 Name = "过程检验单审批",
                 BizType = IpqcInspectionFlowConst.BizType,
                 FormJson = "{}",
                 FlowJson = flowJson,
                 Status = 1,
-                Remark = "系统默认流程 v2:开始-检验员检验-检验主管审核-网关(整批判定)-质量处置-结束",
+                Remark = "租户级流程:开始-检验员检验-检验主管审核-网关(整批判定)-质量处置-结束(审批权威=RoleCode)",
                 Version = nextVersion,
                 IsPublished = true,
-                OrgId = orgId,
-                TenantId = ApprovalFlowTenantSeed.ResolveTenantId(db, orgId),
+                OrgId = ResolveTenantOrgId(db, tenantId),
+                TenantId = tenantId,
                 CreateTime = now,
                 UpdateTime = now,
             };
@@ -178,6 +151,20 @@ public static class IpqcInspectionFlowSeed
         }
     }
 
+    /// <summary>
+    /// 取租户的主机构 Id 落到定义的 OrgId 上。OrgId 不参与 Runtime 选取(StartFlowCore 已 ClearFilter),
+    /// 仅为保持实体字段有意义;取不到时落 0。
+    /// </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
@@ -203,7 +190,10 @@ public static class IpqcInspectionFlowSeed
                     {
                         NodeName = "检验员检验",
                         ApproverType = nameof(ApproverTypeEnum.Role),
-                        ApproverIds = IpqcInspectionFlowConst.RoleInspectorId.ToString(),
+                        // S6-TENANT-AUTHORITY-1:审批权威用 RoleCode,不用默认租户物理 RoleId。
+                        // FlowEngineService 的 Code 分支会按 (Code, effectiveTenantId, Status=Enable)
+                        // 在**当前租户内**重新解析出本地 RoleId,天然租户安全。
+                        ApproverIds = IpqcInspectionFlowConst.RoleInspectorCode,
                         ApproverNames = "过程检验员",
                         MultiApproveMode = "Any",
                     },
@@ -219,7 +209,7 @@ public static class IpqcInspectionFlowSeed
                     {
                         NodeName = "检验主管审核",
                         ApproverType = nameof(ApproverTypeEnum.Role),
-                        ApproverIds = IpqcInspectionFlowConst.RoleSupervisorId.ToString(),
+                        ApproverIds = IpqcInspectionFlowConst.RoleSupervisorCode,
                         ApproverNames = "过程检验主管",
                         MultiApproveMode = "Any",
                     },
@@ -263,7 +253,7 @@ public static class IpqcInspectionFlowSeed
                     {
                         NodeName = "质量处置",
                         ApproverType = nameof(ApproverTypeEnum.Role),
-                        ApproverIds = IpqcInspectionFlowConst.RoleQualityEngineerId.ToString(),
+                        ApproverIds = IpqcInspectionFlowConst.RoleQualityEngineerCode,
                         ApproverNames = "过程检验质量工程师",
                         MultiApproveMode = "Any",
                     },

+ 39 - 14
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S6ProcessInspectionFlowSeed.cs

@@ -29,6 +29,9 @@ public static class S6ProcessInspectionFlowSeed
                 typeof(ApprovalFlowEntity),
                 typeof(ApprovalFlowVersion));
             db.CodeFirst.InitTables(typeof(AdoS6IpqcQualityDisposition));
+            // S6-TENANT-AUTHORITY-1:与 IPQC 链共用同一套租户本地角色。幂等,重复调用无副作用;
+            // 显式调用而不依赖 Startup 的种子顺序,避免顺序被调整后本链拿不到本地角色。
+            S6TenantAuthoritySeed.EnsureTenantAuthority(db);
             EnsureBizType(db);
             EnsurePublishedFlow(db);
         }
@@ -54,36 +57,46 @@ public static class S6ProcessInspectionFlowSeed
         }).ExecuteCommand();
     }
 
+    /// <summary>
+    /// S6-TENANT-AUTHORITY-1:副本维度由 **Org 改为 Tenant**,并显式落 TenantId。
+    ///
+    /// 原实现按 SysOrg 建 20 份且**从不设置 TenantId**(全部落 NULL),而 Runtime 只按 TenantId 选取 ——
+    /// 20 份在「本租户优先」「版本号」两个排序键上全部并列,FirstAsync 选谁不确定。
+    /// 改为每租户一份且 TenantId 明确后,本租户定义在排序键① 上稳定胜出 legacy 的 NULL 定义。
+    ///
+    /// 只为通过 Activation Gate 的租户发布;历史 20 份 NULL 定义**不下架、不删除**(纯增量),
+    /// 未就绪的租户继续沿用原路径,等成员补齐后下次启动自动补发。
+    /// </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 S6TenantAuthoritySeed.GetActivatableTenantIds(db))
         {
             var existing = db.Queryable<ApprovalFlowEntity>()
-                .Where(x => x.BizType == S6ProcessInspectionFlowConst.BizType && x.OrgId == orgId && x.IsPublished && !x.IsDelete)
+                .ClearFilter()
+                .Where(x => x.BizType == S6ProcessInspectionFlowConst.BizType && x.TenantId == tenantId && !x.IsDelete)
                 .OrderByDescending(x => x.Version)
                 .First();
-            if (existing != null) continue; // 已发布则跳过(幂等)
+
+            // 已有 RoleCode 版则跳过(幂等);仍是纯数字 RoleId 的需要补发。
+            if (existing != null && (existing.FlowJson ?? "").Contains(S6ProcessInspectionFlowConst.RoleSupervisorCode))
+                continue;
 
             var flow = new ApprovalFlowEntity
             {
                 Id = YitIdHelper.NextId(),
-                Code = $"S6PI_{orgId}",
+                Code = $"S6PI_T{tenantId}",
                 Name = "过程检验单(Phase1C)审核",
                 BizType = S6ProcessInspectionFlowConst.BizType,
                 FormJson = "{}",
                 FlowJson = flowJson,
                 Status = 1,
-                Remark = "系统默认流程 v1:开始-提交审核(发起人)-检验主管审核-网关(质量结果)-质量处置-结束",
-                Version = 1,
+                Remark = "租户级流程:开始-提交审核(发起人)-检验主管审核-网关(质量结果)-质量处置-结束(审批权威=RoleCode)",
+                Version = (existing?.Version ?? 0) + 1,
                 IsPublished = true,
-                OrgId = orgId,
+                OrgId = ResolveTenantOrgId(db, tenantId),
+                TenantId = tenantId,
                 CreateTime = now,
                 UpdateTime = now,
             };
@@ -103,6 +116,17 @@ public static class S6ProcessInspectionFlowSeed
         }
     }
 
+    /// <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
@@ -135,7 +159,8 @@ public static class S6ProcessInspectionFlowSeed
                     {
                         NodeName = "检验主管审核",
                         ApproverType = nameof(ApproverTypeEnum.Role),
-                        ApproverIds = S6ProcessInspectionFlowConst.RoleSupervisorId.ToString(),
+                        // S6-TENANT-AUTHORITY-1:审批权威用 RoleCode,由引擎在当前租户内重新解析本地 RoleId。
+                        ApproverIds = S6ProcessInspectionFlowConst.RoleSupervisorCode,
                         ApproverNames = "过程检验主管",
                         MultiApproveMode = "Any",
                     },
@@ -173,7 +198,7 @@ public static class S6ProcessInspectionFlowSeed
                     {
                         NodeName = "质量处置",
                         ApproverType = nameof(ApproverTypeEnum.Role),
-                        ApproverIds = S6ProcessInspectionFlowConst.RoleQualityEngineerId.ToString(),
+                        ApproverIds = S6ProcessInspectionFlowConst.RoleQualityEngineerCode,
                         ApproverNames = "过程检验质量工程师",
                         MultiApproveMode = "Any",
                     },

+ 203 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S6TenantAuthoritySeed.cs

@@ -0,0 +1,203 @@
+using System.Diagnostics;
+using Admin.NET.Plugin.AiDOP.Manufacturing;
+using Yitter.IdGenerator;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure;
+
+/// <summary>
+/// S6-TENANT-AUTHORITY-1:S6 过程检验(IPQC)三类审批角色的**租户本地化**权威基线,
+/// 由 <see cref="IpqcInspectionFlowSeed"/>(IPQC_INSPECTION)与
+/// <see cref="S6ProcessInspectionFlowSeed"/>(S6_PROCESS_INSPECTION)两条链共用。
+///
+/// 修复的缺陷(SYSTEMIC TENANT CLONE DEFECT):原先三个角色是**全库单例、固定挂默认租户**,
+/// 各租户用户靠「跨租户 SysUserRole 绑定」借用;流程定义又把默认租户的**物理 RoleId**
+/// 写死进 FlowJson。于是「谁是主管」这件事跨租户共享,且可用性挂在一条无约束、无审计、
+/// 可被直连 SQL 单行改动的手工绑定上——该绑定一旦被删,整个租户的 N2 当场解析为 0 人。
+///
+/// 目标模型(与平台主流一致:ROLE_QC / SMART_OPS_* 等 11 个 Code 均为每租户一份):
+///   每个租户各有自己的 ROLE_S6_IPQC_INSPECTOR / _SUPERVISOR / _QUALITY_ENGINEER,
+///   Code 相同、RoleId 不同、成员与角色同租户;FlowJson 以 **RoleCode** 表达审批权威,
+///   由 FlowEngineService 的 Code 分支在目标租户内重新解析。
+///
+/// 边界:
+/// - 默认租户 <see cref="SqlSugarConst.DefaultTenantId"/> 的那三行**本身就是它的 tenant-local 角色**,
+///   沿用 <see cref="IpqcInspectionFlowConst"/> 的既有固定 Id,不重建、不改动。
+/// - membership 迁移是 **COPY 不是 MOVE**:既有跨租户绑定原样保留,供历史实例与尚未迁移的
+///   legacy 定义继续使用;清理属后续独立批次。
+/// - 本文件**不得**出现任何具体租户 Id 或 UAT 账号——只按 SysTenant 注册表通用处理。
+/// </summary>
+internal static class S6TenantAuthoritySeed
+{
+    /// <summary>三类 S6 角色:Code / 名称 / 排序号 / 默认租户既有固定 Id。</summary>
+    private static readonly (string Code, string Name, int OrderNo, long LegacyDefaultTenantRoleId)[] RoleSpecs =
+    {
+        (IpqcInspectionFlowConst.RoleInspectorCode, "过程检验员", 820, IpqcInspectionFlowConst.RoleInspectorId),
+        (IpqcInspectionFlowConst.RoleSupervisorCode, "过程检验主管", 821, IpqcInspectionFlowConst.RoleSupervisorId),
+        (IpqcInspectionFlowConst.RoleQualityEngineerCode, "过程检验质量工程师", 822, IpqcInspectionFlowConst.RoleQualityEngineerId),
+    };
+
+    /// <summary>三个 S6 RoleCode(供流程种子与 Readiness 判定共用)。</summary>
+    public static IReadOnlyList<string> RoleCodes { get; } =
+        RoleSpecs.Select(x => x.Code).ToList();
+
+    /// <summary>
+    /// 幂等地建立租户本地角色并复制既有成员语义。两条 S6 链的种子都会调用,重复调用无副作用。
+    /// </summary>
+    public static void EnsureTenantAuthority(ISqlSugarClient db)
+    {
+        try
+        {
+            EnsureTenantLocalRoles(db);
+            CopyLegacyCrossTenantMembership(db);
+        }
+        catch (Exception ex)
+        {
+            Trace.TraceWarning("S6 tenant authority seed: " + ex);
+        }
+    }
+
+    /// <summary>
+    /// 为 SysTenant 注册表中的每个租户建立三个 S6 角色。查重键是 <c>(TenantId, Code)</c>——
+    /// 原实现用全局 <c>Any(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 = "S6 过程检验流程角色(租户本地)",
+                    CreateTime = now,
+                }).ExecuteCommand();
+            }
+        }
+    }
+
+    /// <summary>
+    /// 把「用户在 A 租户、却绑着默认租户 S6 角色」的既有语义,等价复制成该用户自己租户内的本地绑定。
+    /// 只复制**已经存在**的授权事实,不新增任何业务成员;legacy 绑定保留不动(COPY 不是 MOVE)。
+    /// </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:只有三个 S6 角色在该租户内**都存在本地角色且各至少 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(含默认租户),不自行构造。
+    /// </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();
+    }
+}