Quellcode durchsuchen

fix(s6): repair legacy active process inspection authority | server 1.0.542

2026-09-14 的租户本地化迁移(c9aa3c94e)把 Flow Definition 的 approverIds 从默认租户
物理 RoleId 改成 RoleCode,但 StartFlowCore 落实例时会把定义整体冻结进
FlowJsonSnapshot,此后所有推进路径一律读快照、再不看定义。因此该迁移只对新发起的
实例生效;此前发起且仍未走完的实例,快照里那个跨租户 RoleId 会被 a75fa7e6b 的
守卫 fail-closed 拒绝,单据永久卡死 —— bill 3 即是。

这是显式、一次性、可审计的数据迁移,不是 runtime 兼容层。刻意不放宽守卫、不做
auto-heal:放宽会同时影响新实例,等于把刚建好的门禁拆掉;运行时隐式把 A 角色当
B 角色执行,会让「执行的」与「快照里展示的」永久分离,比显式改写更难解释。

新增:
1. AdoS6FlowAuthorityMigrationLog —— append-only 留证。实例表无 version/checksum/
   并发令牌、UpdateTime 也非 DB 自动列,裸 UPDATE 后无法证明改过什么;而
   FlowJsonSnapshot 同时是通用审批中心渲染历史流程图的数据源。存完整 before/after
   快照 + MD5 + 逐节点映射 + 回滚源 + 原因。**不复用 S8 那张每轮全量重写的表** ——
   迁移留证被抹掉等于回滚依据消失。
2. S6LegacyFlowAuthorityMigrationService —— 候选谓词写成「所有 ACTIVE broken S6 实例」
   而非硬编码某个 InstanceId;批次边界 2026-09-14 之前发起,之后再出现跨租户引用
   属新的 authority regression,必须响亮失败而不是被静默治好。

安全门禁(任一不过即整条不迁,禁止部分迁移):
- ACTIVE 判据 = Running + 存在 Pending task(终态实例 CurrentNodeId 可能仍停在 N3_*,
  只看状态字段会误判);终态一律不动
- Effective Tenant 由业务实体反查(实例表无 TenantId 列),禁取登录用户租户
- 只迁 Role.TenantId != Effective Tenant 的引用;同租户 numeric 是合法引用,numeric ≠ bad
- 目标租户下该 RoleCode 必须恰好 1 个启用角色(不 First() 随便挑)且有 ≥1 同租户成员
- 回滚源 ApprovalFlowVersion(FlowId,Version) 必须恰好 1 行且与当前快照 MD5 一致
  (该表全库存在 (FlowId,Version) 重复行、且无唯一约束,这道门禁不是形式主义)
- UPDATE 带改前 MD5 乐观锁并断言 affected==1,否则整条回滚
- 留证 INSERT 与快照 UPDATE 同一事务;JsonNode 结构化改写,禁止字符串 REPLACE

Runtime(租户 838257186181189):
- Dry-run 恰 1 候选 = 844794832621637 / S6_PROCESS_INSPECTION / bill 3 / N1_SUBMIT
- Apply: selected=1 applied=1 blocked=0/0/0;映射
  1329916010002=>ROLE_S6_IPQC_SUPERVISOR@848643233415237(members=1) |
  1329916010003=>ROLE_S6_IPQC_QUALITY_ENGINEER@848643233902661(members=1)
- 结构化 diff = 恰 2 条路径(两个 approverIds),key 集合完全相同;节点/边/网关/
  nodeName/approverNames 一概未动
- Active cross-tenant 1→0;Historical cross-tenant 8→8,8/8 MD5 逐字节未变
- 真实第二次运行 selected=0 applied=0,留证仍 1 行(append-only)
- 正式专用入口闭环:同一实例 844794832621637 从 N1 重提到 N2,新 N2 指派 UATAdminA;
  而他名下唯一的 S6 角色绑定就是本租户 local SUPERVISOR、零 legacy 跨租户绑定 ——
  证明解析来自 RoleCode→本租户角色,不是 legacy 偶然命中
- 第二轮 退回→重提 同样成功,证明不是一次偶然
- 4 个任务 assignee 全在租户 838,零跨租户泄漏;实例仍唯一,未产生第二个实例
- FlowId/FlowVersion 未动;Task/Log/CompletedNode 仅因正常业务动作增长(3/3/2→4/4/2)
- 业务数据未变(COMPLETED/PASS,samples MD5 一致)
- Generic Approval Guard 四入口仍全阻断、0 mutation;ApprovalFlow 插件零改动

验证:build 0 Error、改动文件 0 warning;全量 2524 passed / 1 failed
(S8AuthorizationGuardTests.MutationActions_DoNotUseReadOnlyCapabilities,
offender AdoS8OrderReviewKpiController 本批未改动,PRE-EXISTING)。
YY968XX vor 4 Tagen
Ursprung
Commit
d1dc5f0b36

+ 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.541</AssemblyVersion>
-    <FileVersion>1.0.541</FileVersion>
-    <Version>1.0.541</Version>
+    <AssemblyVersion>1.0.542</AssemblyVersion>
+    <FileVersion>1.0.542</FileVersion>
+    <Version>1.0.542</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 425 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S6/Manufacturing/S6LegacyFlowAuthorityMigrationContractTests.cs

@@ -0,0 +1,425 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Admin.NET.Plugin.AiDOP.Service.S6;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S6.Manufacturing;
+
+/// <summary>
+/// S6-LEGACY-SNAPSHOT-MIGRATION-1 契约测试:存量运行中实例的审批权威快照迁移。
+///
+/// <para>两层覆盖:</para>
+/// <list type="number">
+///   <item><b>行为级</b>——<c>ParseNumericRoleRefs</c> / <c>RewriteAuthority</c> 是纯静态方法,
+///         不依赖 DB,可直接喂真实形状的 FlowJson 断言。快照解析与改写是本批最容易悄悄改坏的地方
+///         (approverType 语义、token 顺序、只动 approverIds),必须真跑。</item>
+///   <item><b>源码契约级</b>——安全谓词(ACTIVE 判据、租户反查、跨租户限定、目标角色唯一性与成员门禁、
+///         回滚来源门禁、并发守卫、留证同事务、append-only、批次边界)依赖
+///         <c>ISqlSugarClient</c>,裸 xUnit 进程无法实例化(见 S8ExceptionFlowTenantContextContractTests),
+///         故按本仓既有惯例做源码断言;真实数据行为由本批 Runtime R1–R10 覆盖。</item>
+/// </list>
+/// </summary>
+public class S6LegacyFlowAuthorityMigrationContractTests
+{
+    private static string Svc() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Service", "S6", "S6LegacyFlowAuthorityMigrationService.cs"));
+
+    private static string Entity() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Entity", "S6", "AdoS6FlowAuthorityMigrationLog.cs"));
+
+    private static string Engine() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
+
+    /// <summary>与真实快照同形:N1=Initiator、N2/N3=Role 数字、另含 SpecificUser / Department 干扰节点。</summary>
+    private static string SampleSnapshot(string n2Ids = "1329916010002", string n3Ids = "1329916010003") => JsonSerializer.Serialize(new
+    {
+        nodes = new object[]
+        {
+            new { id = "start", type = "bpmn:startEvent", properties = new { nodeName = "开始" } },
+            new { id = "N1_SUBMIT", type = "bpmn:userTask", properties = new { nodeName = "提交审核", approverType = "Initiator", approverIds = "", approverNames = "发起人" } },
+            new { id = "N2_SUP_REVIEW", type = "bpmn:userTask", properties = new { nodeName = "检验主管审核", approverType = "Role", approverIds = n2Ids, approverNames = "过程检验主管" } },
+            new { id = "GW_RESULT", type = "bpmn:exclusiveGateway", properties = new { nodeName = "质量结果分流" } },
+            new { id = "N3_QE_DISPOSITION", type = "bpmn:userTask", properties = new { nodeName = "质量处置", approverType = "Role", approverIds = n3Ids, approverNames = "过程检验质量工程师" } },
+            // 干扰项:数字但语义不是 RoleId
+            new { id = "X_SPECIFIC", type = "bpmn:userTask", properties = new { nodeName = "指定人", approverType = "SpecificUser", approverIds = "1300000000101", approverNames = "超管" } },
+            new { id = "X_DEPT", type = "bpmn:userTask", properties = new { nodeName = "部门", approverType = "Department", approverIds = "1300000000002", approverNames = "市场部" } },
+            new { id = "end", type = "bpmn:endEvent", properties = new { nodeName = "结束" } },
+        },
+        edges = new object[] { new { id = "e1", sourceNodeId = "start", targetNodeId = "N1_SUBMIT" } },
+    });
+
+    // ───────────── 行为级:解析 ─────────────
+
+    /// <summary>只认 approverType=Role 的数字 token;SpecificUser / Department 的数字不得被当成 RoleId。</summary>
+    [Fact]
+    public void Parse_OnlyPicksRoleNodes_NotSpecificUserOrDepartment()
+    {
+        var refs = S6LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(SampleSnapshot());
+        Assert.Equal(2, refs.Count);
+        Assert.Equal(new[] { "N2_SUP_REVIEW", "N3_QE_DISPOSITION" }, refs.Select(r => r.NodeId).ToArray());
+        Assert.DoesNotContain("1300000000101", refs.SelectMany(r => r.NumericTokens)); // SpecificUser 的 UserId
+        Assert.DoesNotContain("1300000000002", refs.SelectMany(r => r.NumericTokens)); // Department 的 OrgId
+    }
+
+    /// <summary>Initiator 节点 approverIds 为空串,不算 authority。</summary>
+    [Fact]
+    public void Parse_IgnoresInitiatorNode()
+    {
+        var refs = S6LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(SampleSnapshot());
+        Assert.DoesNotContain("N1_SUBMIT", refs.Select(r => r.NodeId));
+    }
+
+    /// <summary>已迁过的快照(全 RoleCode)解析不出数字 token —— 这就是幂等判据。</summary>
+    [Fact]
+    public void Parse_AlreadyMigratedSnapshot_YieldsNothing()
+    {
+        var refs = S6LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(
+            SampleSnapshot("ROLE_S6_IPQC_SUPERVISOR", "ROLE_S6_IPQC_QUALITY_ENGINEER"));
+        Assert.Empty(refs);
+    }
+
+    /// <summary>多 token 逐个识别(即使当前实例是单 token)。</summary>
+    [Fact]
+    public void Parse_HandlesMultipleTokens()
+    {
+        var refs = S6LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(SampleSnapshot("111,222,333"));
+        var n2 = refs.Single(r => r.NodeId == "N2_SUP_REVIEW");
+        Assert.Equal(new[] { "111", "222", "333" }, n2.NumericTokens.ToArray());
+    }
+
+    /// <summary>畸形 JSON 不得抛异常,返回空(fail-closed:选不中就不改)。</summary>
+    [Theory]
+    [InlineData("")]
+    [InlineData("not json")]
+    [InlineData("{\"nodes\":\"wrong-type\"}")]
+    public void Parse_MalformedInput_ReturnsEmpty(string bad)
+    {
+        Assert.Empty(S6LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(bad));
+    }
+
+    // ───────────── 行为级:改写 ─────────────
+
+    /// <summary>只改 Role 节点的 approverIds;节点集合、名称、edges、approverNames 一概不动。</summary>
+    [Fact]
+    public void Rewrite_ChangesOnlyRoleApproverIds()
+    {
+        var before = SampleSnapshot();
+        var after = S6LegacyFlowAuthorityMigrationService.RewriteAuthority(before, new Dictionary<string, string>
+        {
+            ["1329916010002"] = "ROLE_S6_IPQC_SUPERVISOR",
+            ["1329916010003"] = "ROLE_S6_IPQC_QUALITY_ENGINEER",
+        });
+        Assert.NotNull(after);
+
+        var diffs = StructuralDiff(before, after!);
+        Assert.Equal(2, diffs.Count);
+        Assert.Contains(".nodes[2].properties.approverIds", diffs.Keys);
+        Assert.Contains(".nodes[4].properties.approverIds", diffs.Keys);
+        Assert.Equal("ROLE_S6_IPQC_SUPERVISOR", diffs[".nodes[2].properties.approverIds"].after);
+        Assert.Equal("ROLE_S6_IPQC_QUALITY_ENGINEER", diffs[".nodes[4].properties.approverIds"].after);
+    }
+
+    /// <summary>SpecificUser / Department 的数字即使出现在 tokenMap 里也不得被改。</summary>
+    [Fact]
+    public void Rewrite_NeverTouchesNonRoleNodes()
+    {
+        var before = SampleSnapshot();
+        var after = S6LegacyFlowAuthorityMigrationService.RewriteAuthority(before, new Dictionary<string, string>
+        {
+            ["1329916010002"] = "ROLE_S6_IPQC_SUPERVISOR",
+            ["1300000000101"] = "SHOULD_NOT_BE_APPLIED",   // SpecificUser 的 UserId
+            ["1300000000002"] = "SHOULD_NOT_BE_APPLIED",   // Department 的 OrgId
+        });
+        Assert.NotNull(after);
+        Assert.DoesNotContain("SHOULD_NOT_BE_APPLIED", after!);
+        Assert.Contains("\"approverIds\":\"1300000000101\"", after);
+        Assert.Contains("\"approverIds\":\"1300000000002\"", after);
+    }
+
+    /// <summary>多 token 保持原顺序与原数量;未映射的 token 原样留下。</summary>
+    [Fact]
+    public void Rewrite_PreservesTokenOrderAndCount()
+    {
+        var after = S6LegacyFlowAuthorityMigrationService.RewriteAuthority(
+            SampleSnapshot("111,222,333"), new Dictionary<string, string> { ["222"] = "ROLE_B" });
+        Assert.NotNull(after);
+        Assert.Contains("\"approverIds\":\"111,ROLE_B,333\"", after!);
+    }
+
+    /// <summary>无可改之处时返回 null,调用方据此不发 UPDATE。</summary>
+    [Fact]
+    public void Rewrite_NoApplicableToken_ReturnsNull()
+    {
+        Assert.Null(S6LegacyFlowAuthorityMigrationService.RewriteAuthority(
+            SampleSnapshot(), new Dictionary<string, string> { ["999999"] = "ROLE_X" }));
+    }
+
+    /// <summary>畸形输入返回 null,不抛。</summary>
+    [Fact]
+    public void Rewrite_MalformedInput_ReturnsNull()
+    {
+        Assert.Null(S6LegacyFlowAuthorityMigrationService.RewriteAuthority("not json", new Dictionary<string, string>()));
+    }
+
+    // ───────────── 源码契约:安全谓词 ─────────────
+
+    /// <summary>BizType 范围必须与引擎的严格守卫白名单逐字一致。</summary>
+    [Fact]
+    public void Scope_MatchesEngineStrictWhitelist()
+    {
+        Assert.Equal(new[] { "IPQC_INSPECTION", "S6_PROCESS_INSPECTION" },
+            S6LegacyFlowAuthorityMigrationService.TargetBizTypes.ToArray());
+        var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};");
+        Assert.Contains("\"IPQC_INSPECTION\"", set);
+        Assert.Contains("\"S6_PROCESS_INSPECTION\"", set);
+    }
+
+    /// <summary>ACTIVE 判据不得只看 Status —— 终态实例的 CurrentNodeId 可能仍停在 N3_*。</summary>
+    [Fact]
+    public void Selection_RequiresRunningAndPendingTask()
+    {
+        var body = Slice(Svc(), "public async Task<List<S6MigrationCandidate>> SelectCandidatesAsync", "public async Task<S6FlowAuthorityMigrationResult> MigrateAsync");
+        Assert.Contains("x.Status == FlowInstanceStatusEnum.Running", body);
+        Assert.Contains("t.Status == FlowTaskStatusEnum.Pending", body);
+        Assert.Contains("if (!hasPending) continue;", body);
+    }
+
+    /// <summary>§57 终态实例必须被 Running 谓词排除,本服务不得出现「顺带把历史也迁了」的路径。</summary>
+    [Fact]
+    public void Selection_NeverTouchesTerminalInstances()
+    {
+        var body = Slice(Svc(), "public async Task<List<S6MigrationCandidate>> SelectCandidatesAsync", "public async Task<S6FlowAuthorityMigrationResult> MigrateAsync");
+        Assert.DoesNotContain("FlowInstanceStatusEnum.Approved", body);
+        Assert.DoesNotContain("FlowInstanceStatusEnum.Rejected", body);
+        Assert.DoesNotContain("FlowInstanceStatusEnum.Cancelled", body);
+    }
+
+    /// <summary>批次边界:这是「一次性迁移」与「长期 auto-heal」的分界,未来新坏实例必须响亮失败。</summary>
+    [Fact]
+    public void Selection_IsBatchScopedByCutoff()
+    {
+        var s = Svc();
+        Assert.Contains("BatchCutoff", s);
+        Assert.Contains("x.StartTime < cutoff", s);
+    }
+
+    /// <summary>§15 Effective Tenant 必须由业务实体反查,禁止取登录用户租户。</summary>
+    [Fact]
+    public void Tenant_ResolvedFromBusinessEntity_NotLoginUser()
+    {
+        var s = Svc();
+        var resolver = Slice(s, "private async Task<long?> ResolveEffectiveTenantAsync", "internal static List<S6NumericRoleRef> ParseNumericRoleRefs");
+        Assert.Contains("FROM ado_s6_process_inspection_bill WHERE id=@id", resolver);
+        Assert.Contains("FROM qms_gcjyd WHERE id=@id", resolver);
+        Assert.DoesNotContain("_userManager", s);
+        Assert.DoesNotContain("HttpContext", s);
+    }
+
+    /// <summary>§58 同租户 numeric RoleId 是合法引用,必须被挡下不改 —— numeric ≠ bad。</summary>
+    [Fact]
+    public void Mapping_SameTenantNumericRoleId_IsNotMigrated()
+    {
+        var s = Svc();
+        Assert.Contains("legacyRole.TenantId == c.EffectiveTenantId", s);
+        Assert.Contains("非跨租户引用,不在迁移范围", s);
+    }
+
+    /// <summary>§19 目标角色必须恰好一个启用项,不得 First() 随便挑。</summary>
+    [Fact]
+    public void Mapping_RequiresExactlyOneEnabledTargetRole()
+    {
+        var s = Svc();
+        Assert.Contains("targets.Count != 1", s);
+        Assert.Contains("AMBIGUOUS TARGET ROLE", s);
+        Assert.Contains("x.Status == StatusEnum.Enable", s);
+    }
+
+    /// <summary>§18 目标角色必须至少有一个同租户成员,否则迁完仍解析 0 人。</summary>
+    [Fact]
+    public void Mapping_RequiresSameTenantMember()
+    {
+        var s = Svc();
+        Assert.Contains("u.TenantId == c.EffectiveTenantId", s);
+        Assert.Contains("if (sameTenantMembers == 0)", s);
+        Assert.Contains("BLOCKED_TARGET_AUTHORITY", s);
+    }
+
+    /// <summary>§59 任一 token 映射不出来 → 整条实例不迁,禁止部分迁移。</summary>
+    [Fact]
+    public void Mapping_PartialFailure_AbortsWholeInstance()
+    {
+        var body = Slice(Svc(), "private async Task MigrateOneAsync", "/// <summary>挡下的候选同样留证");
+        // 每个 Block 分支都必须 return,不能继续往下改写
+        Assert.Contains("result, _ => result.BlockedByTargetAuthority++);\n                    return;", body);
+        Assert.Contains("禁止部分迁移", Svc());
+    }
+
+    /// <summary>§20/§21 回滚来源必须恰好 1 行且与当前快照 MD5 一致。</summary>
+    [Fact]
+    public void RollbackSource_GateRequiresExactlyOneMatchingVersionRow()
+    {
+        var s = Svc();
+        Assert.Contains("v.FlowId == inst.FlowId && v.Version == inst.FlowVersion", s);
+        Assert.Contains("versionRows.Count != 1 || usable.Count != 1", s);
+        Assert.Contains("BLOCKED_ROLLBACK_SOURCE", s);
+    }
+
+    /// <summary>§32 实例表无 version/checksum,UPDATE 必须带改前 MD5 乐观锁且断言 affected==1。</summary>
+    [Fact]
+    public void Update_UsesBeforeMd5ConcurrencyGuard()
+    {
+        var s = Svc();
+        Assert.Contains("MD5(FlowJsonSnapshot)=@beforeMd5", s);
+        Assert.Contains("if (affected != 1)", s);
+        Assert.Contains("CONCURRENT MODIFICATION", s);
+    }
+
+    /// <summary>§13 留证 INSERT 与快照 UPDATE 必须同一事务。</summary>
+    [Fact]
+    public void Evidence_AndUpdate_ShareOneTransaction()
+    {
+        var body = Slice(Svc(), "private async Task MigrateOneAsync", "/// <summary>挡下的候选同样留证");
+        var tranAt = body.IndexOf("_db.AsTenant().UseTranAsync(", StringComparison.Ordinal);
+        var logAt = body.IndexOf("_logRep.AsInsertable(new AdoS6FlowAuthorityMigrationLog", StringComparison.Ordinal);
+        var updAt = body.IndexOf("UPDATE ApprovalFlowInstance", StringComparison.Ordinal);
+        Assert.True(tranAt > 0, "缺少事务边界");
+        Assert.True(logAt > tranAt, "留证 INSERT 必须在事务内");
+        Assert.True(updAt > logAt, "先留证再改写");
+        Assert.Contains("if (!tran.IsSuccess)", body);
+    }
+
+    /// <summary>§12 留证必须 append-only:不得出现任何删除/清空。</summary>
+    [Fact]
+    public void Evidence_IsAppendOnly()
+    {
+        var s = Svc();
+        Assert.DoesNotContain("AsDeleteable", s);
+        Assert.DoesNotContain("_logRep.AsUpdateable", s);
+        Assert.Contains("append-only", Entity());
+    }
+
+    /// <summary>§11 留证字段齐全 —— 「为什么改/改前是什么/怎么回滚」必须都能回答。</summary>
+    [Theory]
+    [InlineData("migration_batch")]
+    [InlineData("instance_id")]
+    [InlineData("biz_type")]
+    [InlineData("biz_id")]
+    [InlineData("effective_tenant_id")]
+    [InlineData("before_snapshot")]
+    [InlineData("after_snapshot")]
+    [InlineData("before_md5")]
+    [InlineData("after_md5")]
+    [InlineData("authority_mapping")]
+    [InlineData("rollback_source")]
+    [InlineData("reason")]
+    [InlineData("outcome")]
+    [InlineData("created_at")]
+    public void Evidence_HasRequiredColumn(string column)
+    {
+        Assert.Contains($"ColumnName = \"{column}\"", Entity());
+    }
+
+    /// <summary>不得复用 S8 那张每轮全量重写的表 —— 迁移留证一旦被抹掉,回滚依据就消失了。</summary>
+    [Fact]
+    public void Evidence_DoesNotReuseS8RepairLog()
+    {
+        Assert.DoesNotContain("AdoS8ApprovalFlowRepairLog", Svc());
+        Assert.Contains("ado_s6_flow_authority_migration_log", Entity());
+    }
+
+    /// <summary>§27 目标表达必须是 RoleCode,不得写目标租户物理 RoleId。</summary>
+    [Fact]
+    public void Target_UsesRoleCode_NotTargetLocalRoleId()
+    {
+        var s = Svc();
+        Assert.Contains("tokenMap[token] = code;", s);
+        Assert.DoesNotContain("tokenMap[token] = target.Id", s);
+    }
+
+    /// <summary>§3/§4 本服务不得触碰引擎、守卫白名单或 resolver —— 它是数据迁移,不是兼容层。</summary>
+    [Theory]
+    [InlineData("TenantStrictRoleBizTypes")]
+    [InlineData("DedicatedEntryOnlyBizTypes")]
+    [InlineData("ResolveApprovers")]
+    [InlineData("EnsureRoleAuthorityTenantScoped")]
+    public void Migration_DoesNotTouchRuntimeGuards(string symbol)
+    {
+        Assert.DoesNotContain(symbol + "(", Svc());
+    }
+
+    /// <summary>§29/§30 不得改历史事实表,也不得切 FlowId/FlowVersion。</summary>
+    [Theory]
+    [InlineData("UPDATE ApprovalFlowTask")]
+    [InlineData("UPDATE ApprovalFlowLog")]
+    [InlineData("UPDATE ApprovalFlowCompletedNode")]
+    [InlineData("SET FlowId")]
+    [InlineData("SET FlowVersion")]
+    public void Migration_NeverWritesHistoryOrFlowIdentity(string forbidden)
+    {
+        Assert.DoesNotContain(forbidden, Svc());
+    }
+
+    /// <summary>§31 不得取消旧实例或另起新实例 —— 断言实际调用形态,不误伤文档里提到的 StartFlowCore。</summary>
+    [Theory]
+    [InlineData("_flowEngine")]
+    [InlineData(".StartFlow(")]
+    [InlineData(".Withdraw(")]
+    [InlineData("Insertable(new ApprovalFlowInstance")]
+    public void Migration_NeverRestartsInstance(string forbidden)
+    {
+        Assert.DoesNotContain(forbidden, Svc());
+    }
+
+    private static Dictionary<string, (object? before, object? after)> StructuralDiff(string a, string b)
+    {
+        var fa = Flatten(JsonNode.Parse(a));
+        var fb = Flatten(JsonNode.Parse(b));
+        return fa.Keys.Union(fb.Keys)
+            .Where(k => !Equals(fa.GetValueOrDefault(k), fb.GetValueOrDefault(k)))
+            .ToDictionary(k => k, k => (fa.GetValueOrDefault(k), fb.GetValueOrDefault(k)));
+    }
+
+    private static Dictionary<string, object?> Flatten(JsonNode? node, string path = "")
+    {
+        var flat = new Dictionary<string, object?>();
+        switch (node)
+        {
+            case JsonObject obj:
+                foreach (var kv in obj)
+                    foreach (var inner in Flatten(kv.Value, $"{path}.{kv.Key}")) flat[inner.Key] = inner.Value;
+                break;
+            case JsonArray arr:
+                for (var i = 0; i < arr.Count; i++)
+                    foreach (var inner in Flatten(arr[i], $"{path}[{i}]")) flat[inner.Key] = inner.Value;
+                break;
+            // 标量统一取原值字符串(不带 JSON 引号),否则断言里要写成 "\"x\"" 影响可读性
+            case JsonValue val:
+                flat[path] = val.TryGetValue<string>(out var s) ? s : val.ToJsonString();
+                break;
+            default:
+                flat[path] = node?.ToJsonString();
+                break;
+        }
+        return flat;
+    }
+
+    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));
+    }
+}

+ 77 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S6/AdoS6FlowAuthorityMigrationLog.cs

@@ -0,0 +1,77 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S6;
+
+/// <summary>
+/// S6-LEGACY-SNAPSHOT-MIGRATION-1:S6 存量运行中实例的审批权威快照迁移留证。
+///
+/// <para><b>为什么必须留证</b>:<c>ApprovalFlowInstance</c> 没有 version、没有 checksum、
+/// 没有并发令牌,<c>UpdateTime</c> 也不是 DB 自动列 —— 裸 UPDATE 之后
+/// <b>无法证明改过什么、为什么改、改前是什么</b>。而 <c>FlowJsonSnapshot</c> 同时是
+/// 通用审批中心渲染历史流程图的数据源(<c>FlowInstanceService</c> 对外暴露 →
+/// 前端 <c>center/index.vue</c> 与 <c>ApprovalPanel.vue</c> 消费)。
+/// 没有这张表,这就是一次无法解释的静默改写。</para>
+///
+/// <para><b>与 <c>AdoS8ApprovalFlowRepairLog</c> 的关键差异:本表 append-only。</b>
+/// S8 那张表每轮 <c>DELETE FROM ... WHERE true</c> 后整体重写(它描述的是「最近一次对账看到了什么」),
+/// 迁移留证不能是这种语义 —— 迁移是**一次性历史事件**,记录一旦写下就不允许被后续运行抹掉,
+/// 否则回滚依据会随时间消失。故本表独立建立,不复用 S8 那张。</para>
+///
+/// <para><b>幂等不依赖本表。</b>「是否已迁」的判据是快照自身内容(Role 节点的 approverIds
+/// 是否还存在纯数字 token),不是「本表里有没有这条 InstanceId」——
+/// 留证一旦丢失就重复改写,是 S8 先例踩过的坑。</para>
+/// </summary>
+[SugarTable("ado_s6_flow_authority_migration_log", "S6 存量流程审批权威迁移留证(append-only)")]
+public class AdoS6FlowAuthorityMigrationLog
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    /// <summary>迁移批次标识,同一次执行的所有记录共用,便于整批回滚定位。</summary>
+    [SugarColumn(ColumnName = "migration_batch", Length = 64)]
+    public string MigrationBatch { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "instance_id", ColumnDataType = "bigint")]
+    public long InstanceId { get; set; }
+
+    [SugarColumn(ColumnName = "biz_type", Length = 64)]
+    public string BizType { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "biz_id", ColumnDataType = "bigint")]
+    public long BizId { get; set; }
+
+    /// <summary>由业务实体反查得到,**不取登录用户租户**(实例表自身无 TenantId 列)。</summary>
+    [SugarColumn(ColumnName = "effective_tenant_id", ColumnDataType = "bigint")]
+    public long EffectiveTenantId { get; set; }
+
+    /// <summary>改前快照全文,逐字保留 —— 第一回滚来源。</summary>
+    [SugarColumn(ColumnName = "before_snapshot", ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
+    public string? BeforeSnapshot { get; set; }
+
+    /// <summary>改后快照全文。回滚前用它比对「现值确实是我改成的那样」。</summary>
+    [SugarColumn(ColumnName = "after_snapshot", ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
+    public string? AfterSnapshot { get; set; }
+
+    [SugarColumn(ColumnName = "before_md5", Length = 32, IsNullable = true)]
+    public string? BeforeMd5 { get; set; }
+
+    [SugarColumn(ColumnName = "after_md5", Length = 32, IsNullable = true)]
+    public string? AfterMd5 { get; set; }
+
+    /// <summary>逐节点映射:<c>node=N2_SUP_REVIEW;1329916010002=&gt;ROLE_S6_IPQC_SUPERVISOR@848643233415237</c>。</summary>
+    [SugarColumn(ColumnName = "authority_mapping", ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
+    public string? AuthorityMapping { get; set; }
+
+    /// <summary>第二回滚来源:可反查出原始 FlowJson 的 ApprovalFlowVersion 行 Id。</summary>
+    [SugarColumn(ColumnName = "rollback_source", Length = 128, IsNullable = true)]
+    public string? RollbackSource { get; set; }
+
+    /// <summary>为什么改 / 为什么没改:判据与依据。</summary>
+    [SugarColumn(ColumnName = "reason", Length = 1024, IsNullable = true)]
+    public string? Reason { get; set; }
+
+    /// <summary>APPLIED / SKIPPED_ALREADY_MIGRATED / BLOCKED_TARGET_AUTHORITY / BLOCKED_ROLLBACK_SOURCE / BLOCKED_CONCURRENT_MODIFICATION。</summary>
+    [SugarColumn(ColumnName = "outcome", Length = 48)]
+    public string Outcome { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+}

+ 457 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S6/S6LegacyFlowAuthorityMigrationService.cs

@@ -0,0 +1,457 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Entity.S6;
+using Admin.NET.Plugin.ApprovalFlow;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S6;
+
+/// <summary>迁移结果。分项计数,运维要能看出「这次到底动了什么、什么被挡下了」。</summary>
+public sealed class S6FlowAuthorityMigrationResult
+{
+    /// <summary>通过全部安全谓词、进入逐条处理的候选实例数。</summary>
+    public int CandidatesSelected { get; set; }
+    /// <summary>本次实际改写快照的实例数。</summary>
+    public int Applied { get; set; }
+    /// <summary>目标租户角色缺失 / 禁用 / 无同租户成员 / 角色不唯一 → 未改。</summary>
+    public int BlockedByTargetAuthority { get; set; }
+    /// <summary>回滚来源缺失或不唯一 → 未改。</summary>
+    public int BlockedByRollbackSource { get; set; }
+    /// <summary>UPDATE 影响行数 ≠ 1(期间被并发改动)→ 已回滚。</summary>
+    public int BlockedByConcurrentModification { get; set; }
+}
+
+/// <summary>
+/// S6-LEGACY-SNAPSHOT-MIGRATION-1:把**仍在运行**的 S6 流程实例快照里冻结的
+/// 跨租户物理 RoleId 改写为 RoleCode,让它们能在租户本地化模型下继续流转。
+///
+/// <para><b>问题形态</b>(已取证,非推测):<c>StartFlowCore</c> 落实例时把
+/// <c>ApprovalFlow.FlowJson</c> 整体冻结进 <c>FlowJsonSnapshot</c>,此后**所有推进路径
+/// 一律读快照、再不看定义**。因此 2026-09-14 的租户本地化迁移(把定义的 approverIds
+/// 从默认租户物理 RoleId 改成 RoleCode)**只对新发起的实例生效**;此前发起且仍未走完的
+/// 实例,快照里那个跨租户 RoleId 会被 <c>EnsureRoleAuthorityTenantScopedAsync</c>
+/// 直接 fail-closed 拒绝,单据永久卡死。</para>
+///
+/// <para><b>为什么不做 runtime auto-heal</b>:引擎里「跨租户 RoleId 一律拒绝」是刚建立的
+/// 门禁,放宽它会同时影响**新**实例,等于把门拆掉;而运行时隐式把 A 角色当 B 角色执行,
+/// 会让「执行的」与「快照里展示的」永久分离,比显式改写更难解释。故本服务是
+/// <b>显式、一次性、可审计的数据迁移</b>,不是 resolver 兼容层。</para>
+///
+/// <para><b>批次边界(关键)</b>:只处理 <see cref="BatchCutoff"/> 之前发起的实例。
+/// 该时点之后发起的实例本就会拿到 RoleCode 快照 —— 若之后仍出现跨租户 RoleId,
+/// 那是**新的 authority regression**,必须让它响亮地失败、被人看见,
+/// 绝不能被本服务静默治好。这条边界是「一次性迁移」与「长期 auto-heal」的分界线。</para>
+///
+/// <para><b>只修坏的,不碰好的</b>:终态实例一律不动(对 runtime 已无影响,改了没用,
+/// 却百分之百是纯历史记录);同租户的物理 RoleId 也不动(numeric ≠ bad)。</para>
+/// </summary>
+public class S6LegacyFlowAuthorityMigrationService : ITransient
+{
+    /// <summary>与 <c>FlowEngineService.TenantStrictRoleBizTypes</c> 逐字一致:只有被严格守卫的链才需要迁。</summary>
+    internal static IReadOnlyList<string> TargetBizTypes { get; } =
+        new List<string> { "IPQC_INSPECTION", "S6_PROCESS_INSPECTION" };
+
+    /// <summary>
+    /// 批次边界。租户本地 RoleCode 定义于 2026-09-14 发布,此后发起的实例快照本就是 RoleCode。
+    /// 之后再出现跨租户 RoleId = 新缺陷,不属本次迁移范围,必须 fail-closed 暴露。
+    /// </summary>
+    private static readonly DateTime BatchCutoff = new(2026, 9, 14, 0, 0, 0, DateTimeKind.Unspecified);
+
+    private const string BatchPrefix = "S6-LEGACY-SNAPSHOT-MIGRATION-1";
+
+    private readonly ISqlSugarClient _db;
+    private readonly SqlSugarRepository<AdoS6FlowAuthorityMigrationLog> _logRep;
+    private readonly ILogger<S6LegacyFlowAuthorityMigrationService> _logger;
+
+    public S6LegacyFlowAuthorityMigrationService(
+        ISqlSugarClient db,
+        SqlSugarRepository<AdoS6FlowAuthorityMigrationLog> logRep,
+        ILogger<S6LegacyFlowAuthorityMigrationService> logger)
+    {
+        _db = db;
+        _logRep = logRep;
+        _logger = logger;
+    }
+
+    /// <summary>
+    /// 选出候选(只读,不写任何东西)。Apply 与 dry-run 共用同一段谓词,
+    /// 避免「预演看到的」和「实际改的」是两套逻辑。
+    /// </summary>
+    public async Task<List<S6MigrationCandidate>> SelectCandidatesAsync()
+    {
+        var candidates = new List<S6MigrationCandidate>();
+
+        // ① 运行中 + 批次边界内。ApprovalFlowInstance 无 TenantId 列,租户后面由业务实体反查。
+        // bizTypes / cutoff 取局部变量:SqlSugar 的表达式解析器无法把静态成员翻成 SQL 参数。
+        var bizTypes = TargetBizTypes.ToList();
+        var cutoff = BatchCutoff;
+        var instances = await _db.Queryable<ApprovalFlowInstance>().ClearFilter()
+            .Where(x => bizTypes.Contains(x.BizType)
+                        && x.Status == FlowInstanceStatusEnum.Running
+                        && x.StartTime < cutoff)
+            .ToListAsync();
+
+        foreach (var inst in instances)
+        {
+            // ② ACTIVE 的完整判据:不能只看 Status —— 终态实例的 CurrentNodeId 可能仍停在
+            //    N3_* 而非 end(CompleteInstance 不重写该字段),只看状态字段会误判。
+            var hasPending = await _db.Queryable<ApprovalFlowTask>().ClearFilter()
+                .AnyAsync(t => t.InstanceId == inst.Id && t.Status == FlowTaskStatusEnum.Pending);
+            if (!hasPending) continue;
+
+            // ③ Effective Tenant 必须由业务实体反查,禁止取登录用户租户 —— 迁移的租户判定
+            //    绝不能复制「推进时按登录租户解析」那个既有结构缺陷。
+            var tenantId = await ResolveEffectiveTenantAsync(inst.BizType, inst.BizId);
+            if (tenantId is not > 0) continue;
+
+            // ④ 快照里是否还存在「Role 节点 + 纯数字 token」。这同时就是幂等判据的补集:
+            //    迁完之后本条恒为 false,第二次运行自然选不中。
+            var refs = ParseNumericRoleRefs(inst.FlowJsonSnapshot);
+            if (refs.Count == 0) continue;
+
+            candidates.Add(new S6MigrationCandidate
+            {
+                Instance = inst,
+                EffectiveTenantId = tenantId.Value,
+                NumericRefs = refs,
+            });
+        }
+
+        return candidates;
+    }
+
+    /// <summary>执行迁移。每个候选独立事务:留证 INSERT 与快照 UPDATE 原子提交,任一失败整条回滚。</summary>
+    public async Task<S6FlowAuthorityMigrationResult> MigrateAsync(CancellationToken ct = default)
+    {
+        var result = new S6FlowAuthorityMigrationResult();
+        var batch = $"{BatchPrefix}@{DateTime.Now:yyyyMMddHHmmss}";
+
+        List<S6MigrationCandidate> candidates;
+        try
+        {
+            candidates = await SelectCandidatesAsync();
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "S6 legacy flow authority migration: 候选选取失败,本次跳过");
+            return result;
+        }
+
+        result.CandidatesSelected = candidates.Count;
+        foreach (var c in candidates)
+        {
+            if (ct.IsCancellationRequested) break;
+            await MigrateOneAsync(c, batch, result);
+        }
+
+        // 无条件记一行汇总 —— 「本次 0 候选 0 改动」本身就是要被看见的结论:
+        // 迁移已收敛的证据,以及「未来若冒出新候选会被立刻发现」的可观测性基础。
+        _logger.LogInformation(
+            "S6LegacyFlowAuthorityMigration batch={Batch} selected={Selected} applied={Applied} "
+            + "blockedTargetAuthority={BlockedTarget} blockedRollbackSource={BlockedRollback} blockedConcurrent={BlockedConcurrent}",
+            batch, result.CandidatesSelected, result.Applied,
+            result.BlockedByTargetAuthority, result.BlockedByRollbackSource, result.BlockedByConcurrentModification);
+
+        return result;
+    }
+
+    private async Task MigrateOneAsync(S6MigrationCandidate c, string batch, S6FlowAuthorityMigrationResult result)
+    {
+        var inst = c.Instance;
+        var before = inst.FlowJsonSnapshot ?? string.Empty;
+        var beforeMd5 = Md5(before);
+
+        // ── Gate 1:逐 token 解析目标角色。任一 token 映射不出来 → 整条实例不迁(禁止部分迁移,
+        //    否则会留下半新半旧的快照,比全旧更难排查)。
+        var mappings = new List<string>();
+        var tokenMap = new Dictionary<string, string>(StringComparer.Ordinal);
+        foreach (var r in c.NumericRefs)
+        {
+            foreach (var token in r.NumericTokens)
+            {
+                if (tokenMap.ContainsKey(token)) continue;
+
+                var legacyRole = await _db.Queryable<SysRole>().ClearFilter()
+                    .Where(x => x.Id == long.Parse(token)).FirstAsync();
+                if (legacyRole == null)
+                {
+                    await BlockAsync(c, batch, beforeMd5, mappings, "BLOCKED_TARGET_AUTHORITY",
+                        $"legacy RoleId {token} 在 SysRole 中不存在", result, r => result.BlockedByTargetAuthority++);
+                    return;
+                }
+
+                // 同租户的物理 RoleId 是合法引用,不是缺陷 —— 不得因为「是数字」就改它。
+                if (legacyRole.TenantId == c.EffectiveTenantId)
+                {
+                    await BlockAsync(c, batch, beforeMd5, mappings, "BLOCKED_TARGET_AUTHORITY",
+                        $"RoleId {token} 属于本租户 {c.EffectiveTenantId},非跨租户引用,不在迁移范围",
+                        result, _ => result.BlockedByTargetAuthority++);
+                    return;
+                }
+
+                var code = legacyRole.Code;
+                if (string.IsNullOrWhiteSpace(code))
+                {
+                    await BlockAsync(c, batch, beforeMd5, mappings, "BLOCKED_TARGET_AUTHORITY",
+                        $"legacy RoleId {token} 无 Code,无法映射", result, _ => result.BlockedByTargetAuthority++);
+                    return;
+                }
+
+                // Gate 2:目标租户下该 Code 必须**恰好一个**启用角色。不得 First() 随便挑。
+                var targets = await _db.Queryable<SysRole>().ClearFilter()
+                    .Where(x => x.TenantId == c.EffectiveTenantId && x.Code == code && x.Status == StatusEnum.Enable)
+                    .ToListAsync();
+                if (targets.Count != 1)
+                {
+                    await BlockAsync(c, batch, beforeMd5, mappings, "BLOCKED_TARGET_AUTHORITY",
+                        targets.Count == 0
+                            ? $"目标租户 {c.EffectiveTenantId} 下不存在启用的 {code}"
+                            : $"目标租户 {c.EffectiveTenantId} 下 {code} 有 {targets.Count} 个,AMBIGUOUS TARGET ROLE",
+                        result, _ => result.BlockedByTargetAuthority++);
+                    return;
+                }
+                var target = targets[0];
+
+                // Gate 3:目标角色必须至少有一个**同租户**成员,否则迁完仍旧解析 0 人。
+                var memberIds = await _db.Queryable<SysUserRole>().ClearFilter()
+                    .Where(x => x.RoleId == target.Id).Select(x => x.UserId).ToListAsync();
+                var sameTenantMembers = memberIds.Count == 0 ? 0
+                    : await _db.Queryable<SysUser>().ClearFilter()
+                        .CountAsync(u => memberIds.Contains(u.Id) && u.TenantId == c.EffectiveTenantId);
+                if (sameTenantMembers == 0)
+                {
+                    await BlockAsync(c, batch, beforeMd5, mappings, "BLOCKED_TARGET_AUTHORITY",
+                        $"目标角色 {code}@{c.EffectiveTenantId}(RoleId {target.Id})无同租户成员",
+                        result, _ => result.BlockedByTargetAuthority++);
+                    return;
+                }
+
+                tokenMap[token] = code;
+                mappings.Add($"node={r.NodeId};{token}=>{code}@{target.Id};members={sameTenantMembers}");
+            }
+        }
+
+        // ── Gate 4:回滚来源必须唯一且与当前快照逐字节一致。ApprovalFlowVersion 全库存在
+        //    (FlowId,Version) 重复行,该表也没有唯一约束 —— 这道门禁不是形式主义。
+        var versionRows = await _db.Queryable<ApprovalFlowVersion>().ClearFilter()
+            .Where(v => v.FlowId == inst.FlowId && v.Version == inst.FlowVersion).ToListAsync();
+        var usable = versionRows.Where(v => Md5(v.FlowJson ?? string.Empty) == beforeMd5).ToList();
+        if (versionRows.Count != 1 || usable.Count != 1)
+        {
+            await BlockAsync(c, batch, beforeMd5, mappings, "BLOCKED_ROLLBACK_SOURCE",
+                $"ApprovalFlowVersion(FlowId={inst.FlowId},Version={inst.FlowVersion}) 命中 {versionRows.Count} 行、"
+                + $"其中与当前快照 MD5 一致 {usable.Count} 行;要求恰好 1/1",
+                result, _ => result.BlockedByRollbackSource++);
+            return;
+        }
+        var rollbackSource = $"ApprovalFlowVersion#{usable[0].Id}";
+
+        // ── 结构化改写:只动 Role 节点的 approverIds,其余一律不碰。禁止字符串替换。
+        var after = RewriteAuthority(before, tokenMap);
+        if (after == null || after == before)
+        {
+            await BlockAsync(c, batch, beforeMd5, mappings, "BLOCKED_TARGET_AUTHORITY",
+                "结构化改写未产生变化或解析失败", result, _ => result.BlockedByTargetAuthority++);
+            return;
+        }
+        var afterMd5 = Md5(after);
+
+        // ── 留证 + 改写同一事务。任一失败整条回滚:不接受「改了但没留证」,
+        //    也不接受「留证说改了但实际没改」。
+        var tran = await _db.AsTenant().UseTranAsync(async () =>
+        {
+            await _logRep.AsInsertable(new AdoS6FlowAuthorityMigrationLog
+            {
+                MigrationBatch = batch,
+                InstanceId = inst.Id,
+                BizType = inst.BizType,
+                BizId = inst.BizId,
+                EffectiveTenantId = c.EffectiveTenantId,
+                BeforeSnapshot = before,
+                AfterSnapshot = after,
+                BeforeMd5 = beforeMd5,
+                AfterMd5 = afterMd5,
+                AuthorityMapping = string.Join(" | ", mappings),
+                RollbackSource = rollbackSource,
+                Reason = "运行中实例的快照冻结了跨租户物理 RoleId,被 TenantStrictRoleBizTypes 守卫 fail-closed;"
+                         + "改写为 RoleCode 后由引擎在本租户内重新解析",
+                Outcome = "APPLIED",
+            }).ExecuteCommandAsync();
+
+            // 并发守卫:实例表无 version/checksum,只能用「改前 MD5 + 状态」作乐观锁。
+            // 用裸 SQL 精确只改这一列,避免整实体 Updateable 把过期读到的其它列一并回写。
+            var affected = await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE ApprovalFlowInstance
+                SET FlowJsonSnapshot=@after
+                WHERE Id=@id AND Status=@running AND MD5(FlowJsonSnapshot)=@beforeMd5
+                """,
+                new List<SugarParameter>
+                {
+                    new("@after", after), new("@id", inst.Id),
+                    new("@running", (int)FlowInstanceStatusEnum.Running), new("@beforeMd5", beforeMd5),
+                });
+            if (affected != 1)
+                throw Oops.Oh($"CONCURRENT MODIFICATION:实例 {inst.Id} 期间被改动,affected={affected}");
+        });
+
+        if (!tran.IsSuccess)
+        {
+            result.BlockedByConcurrentModification++;
+            _logger.LogWarning(tran.ErrorException,
+                "S6LegacyFlowAuthorityMigration: 实例 {InstanceId} 迁移失败已整体回滚", inst.Id);
+            return;
+        }
+
+        result.Applied++;
+        _logger.LogInformation(
+            "S6LegacyFlowAuthorityMigration APPLIED instance={InstanceId} bizType={BizType} bizId={BizId} "
+            + "tenant={Tenant} beforeMd5={BeforeMd5} afterMd5={AfterMd5} mapping={Mapping}",
+            inst.Id, inst.BizType, inst.BizId, c.EffectiveTenantId, beforeMd5, afterMd5, string.Join(" | ", mappings));
+    }
+
+    /// <summary>挡下的候选同样留证 —— 「为什么没迁」和「为什么迁了」一样需要能回答。</summary>
+    private async Task BlockAsync(S6MigrationCandidate c, string batch, string beforeMd5,
+        List<string> mappings, string outcome, string reason,
+        S6FlowAuthorityMigrationResult result, Action<S6FlowAuthorityMigrationResult> bump)
+    {
+        bump(result);
+        await _logRep.AsInsertable(new AdoS6FlowAuthorityMigrationLog
+        {
+            MigrationBatch = batch,
+            InstanceId = c.Instance.Id,
+            BizType = c.Instance.BizType,
+            BizId = c.Instance.BizId,
+            EffectiveTenantId = c.EffectiveTenantId,
+            BeforeSnapshot = c.Instance.FlowJsonSnapshot,
+            BeforeMd5 = beforeMd5,
+            AuthorityMapping = mappings.Count == 0 ? null : string.Join(" | ", mappings),
+            Reason = reason,
+            Outcome = outcome,
+        }).ExecuteCommandAsync();
+        _logger.LogWarning("S6LegacyFlowAuthorityMigration {Outcome} instance={InstanceId} reason={Reason}",
+            outcome, c.Instance.Id, reason);
+    }
+
+    /// <summary>
+    /// Effective Tenant 由业务实体反查。<c>ApprovalFlowInstance</c> 自身没有 TenantId 列,
+    /// 而推进时引擎用的是登录租户 —— 迁移绝不能沿用那条路径,否则会把租户判定建立在
+    /// 「谁在执行迁移」而不是「这条单据属于谁」之上。
+    /// </summary>
+    private async Task<long?> ResolveEffectiveTenantAsync(string bizType, long bizId) => bizType switch
+    {
+        "S6_PROCESS_INSPECTION" => await _db.Ado.SqlQuerySingleAsync<long?>(
+            "SELECT tenant_id FROM ado_s6_process_inspection_bill WHERE id=@id LIMIT 1",
+            new List<SugarParameter> { new("@id", bizId) }),
+        "IPQC_INSPECTION" => await _db.Ado.SqlQuerySingleAsync<long?>(
+            "SELECT tenant_id FROM qms_gcjyd WHERE id=@id LIMIT 1",
+            new List<SugarParameter> { new("@id", bizId) }),
+        _ => null,
+    };
+
+    /// <summary>
+    /// 解析快照中「approverType==Role 且 approverIds 含纯数字 token」的节点。
+    ///
+    /// <para>必须按 JSON 结构解析、结合 approverType 判断语义,<b>不能只 grep 数字</b>:
+    /// 实测同一个 Id 既可能是合法 SysRole.Id 又是合法 SysUser.Id,而
+    /// <c>SpecificUser</c> / <c>Department</c> 节点里的数字分别是 UserId / OrgId,
+    /// 纯数字匹配会把它们误判成 RoleId。</para>
+    /// </summary>
+    internal static List<S6NumericRoleRef> ParseNumericRoleRefs(string? snapshot)
+    {
+        var refs = new List<S6NumericRoleRef>();
+        if (string.IsNullOrWhiteSpace(snapshot)) return refs;
+
+        JsonNode? root;
+        try { root = JsonNode.Parse(snapshot); }
+        catch { return refs; }
+
+        if (root?["nodes"] is not JsonArray nodes) return refs;
+
+        foreach (var node in nodes)
+        {
+            var props = node?["properties"];
+            if (props == null) continue;
+            if (props["approverType"]?.GetValue<string>() != nameof(ApproverTypeEnum.Role)) continue;
+
+            var ids = props["approverIds"]?.GetValue<string>();
+            if (string.IsNullOrWhiteSpace(ids)) continue;
+
+            var numeric = ids.Split(',', StringSplitOptions.RemoveEmptyEntries)
+                .Select(s => s.Trim())
+                .Where(s => s.Length > 0 && long.TryParse(s, out var v) && v > 0)
+                .Distinct(StringComparer.Ordinal)
+                .ToList();
+            if (numeric.Count == 0) continue;
+
+            refs.Add(new S6NumericRoleRef
+            {
+                NodeId = node?["id"]?.GetValue<string>() ?? string.Empty,
+                ApproverIds = ids,
+                NumericTokens = numeric,
+            });
+        }
+
+        return refs;
+    }
+
+    /// <summary>
+    /// 结构化改写:只把 Role 节点 approverIds 里的数字 token 换成对应 RoleCode,
+    /// 保持 token 原顺序与原数量。节点 id / 名称 / edges / 网关条件 / approverNames 一概不动。
+    /// </summary>
+    internal static string? RewriteAuthority(string snapshot, IReadOnlyDictionary<string, string> tokenMap)
+    {
+        JsonNode? root;
+        try { root = JsonNode.Parse(snapshot); }
+        catch { return null; }
+
+        if (root?["nodes"] is not JsonArray nodes) return null;
+
+        var changed = false;
+        foreach (var node in nodes)
+        {
+            var props = node?["properties"];
+            if (props == null) continue;
+            if (props["approverType"]?.GetValue<string>() != nameof(ApproverTypeEnum.Role)) continue;
+
+            var ids = props["approverIds"]?.GetValue<string>();
+            if (string.IsNullOrWhiteSpace(ids)) continue;
+
+            var tokens = ids.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToList();
+            if (tokens.Count == 0) continue;
+
+            var rewritten = tokens.Select(t => tokenMap.TryGetValue(t, out var code) ? code : t).ToList();
+            var joined = string.Join(",", rewritten);
+            if (joined == ids) continue;
+
+            props["approverIds"] = joined;
+            changed = true;
+        }
+
+        return changed ? root!.ToJsonString() : null;
+    }
+
+    private static string Md5(string s)
+    {
+        var bytes = MD5.HashData(Encoding.UTF8.GetBytes(s));
+        return Convert.ToHexString(bytes).ToLowerInvariant();
+    }
+}
+
+/// <summary>候选实例 + 其反查出的租户 + 快照中待迁的数字角色引用。</summary>
+public sealed class S6MigrationCandidate
+{
+    public ApprovalFlowInstance Instance { get; set; } = null!;
+    public long EffectiveTenantId { get; set; }
+    public List<S6NumericRoleRef> NumericRefs { get; set; } = new();
+}
+
+/// <summary>快照中一个 Role 节点里的数字角色引用。</summary>
+public sealed class S6NumericRoleRef
+{
+    public string NodeId { get; set; } = string.Empty;
+    public string ApproverIds { get; set; } = string.Empty;
+    public List<string> NumericTokens { get; set; } = new();
+}

+ 26 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -120,6 +120,9 @@ public class Startup : AppStartup
         services.AddAuthentication()
             .AddScheme<InboundSignatureOptions, InboundSignatureHandler>(
                 InboundSignatureDefaults.AuthenticationScheme, _ => { });
+
+        // S6-LEGACY-SNAPSHOT-MIGRATION-1:S6 存量运行中实例的审批权威快照迁移(批次边界内一次性)。
+        services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S6.S6LegacyFlowAuthorityMigrationService>();
     }
 
     /// <summary>
@@ -213,6 +216,29 @@ public class Startup : AppStartup
             Trace.TraceError("Ai-DOP S8ApprovalFlowRepair FAILED(本次启动未完成审批人配置对账): " + ex);
         }
 
+        // S6-LEGACY-SNAPSHOT-MIGRATION-1:把批次边界内、仍在运行的 S6 实例快照里冻结的
+        // 跨租户物理 RoleId 迁成 RoleCode。批次边界之后发起的实例一律不碰 —— 那之后再出现
+        // 跨租户引用属于新的 authority regression,必须响亮失败而不是被这里静默治好。
+        // 迁完即幂等:判据是快照自身内容,第二次启动选不出候选、不发 UPDATE。
+        try
+        {
+            using var migScope = app.ApplicationServices.CreateScope();
+            var migDb = migScope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
+            migDb.CodeFirst.InitTables(typeof(Admin.NET.Plugin.AiDOP.Entity.S6.AdoS6FlowAuthorityMigrationLog));
+            var migration = migScope.ServiceProvider
+                .GetRequiredService<Admin.NET.Plugin.AiDOP.Service.S6.S6LegacyFlowAuthorityMigrationService>();
+            var migSummary = migration.MigrateAsync().GetAwaiter().GetResult();
+            Trace.TraceInformation(
+                $"Ai-DOP S6LegacyFlowAuthorityMigration: selected={migSummary.CandidatesSelected} "
+                + $"applied={migSummary.Applied} blockedTargetAuthority={migSummary.BlockedByTargetAuthority} "
+                + $"blockedRollbackSource={migSummary.BlockedByRollbackSource} "
+                + $"blockedConcurrent={migSummary.BlockedByConcurrentModification}");
+        }
+        catch (Exception ex)
+        {
+            Trace.TraceError("Ai-DOP S6LegacyFlowAuthorityMigration FAILED(本次启动未完成存量快照迁移): " + ex);
+        }
+
         try
         {
             using var scopeS4 = app.ApplicationServices.CreateScope();