瀏覽代碼

fix(s7): migrate active FQC flow authority to role codes | server 1.0.549

把原 7 条仍在运行的 S7 成品检验实例快照里冻结的默认租户物理 RoleId
(1329915020002 / 1329915020003)结构化改写为 RoleCode
(ROLE_S7_FQC_SUPERVISOR / ROLE_S7_FQC_QE),使它们在租户本地化模型下继续流转、
且不再依赖 9 条跨租户 SysUserRole 绑定。

为什么必须迁数据而不是改引擎:StartFlowCore 落实例时把 ApprovalFlow.FlowJson
整体冻结进 FlowJsonSnapshot,此后所有推进路径一律读快照、再不看定义。因此上一批
(1.0.547)的定义 RoleCode 化只对新实例生效,存量实例仍靠跨租户绑定解析。
运行时隐式把 A 角色当 B 角色执行会让「执行的」与「快照里展示的」永久分离,
故本批是显式、一次性、可审计、可回滚的数据迁移,不是 resolver 兼容层。

设计镜像已验证的 S6LegacyFlowAuthorityMigrationService(未复用其本体:它的
TargetBizTypes 与租户反查硬绑 S6 两条链,泛化就要改 S6 已关闭的逻辑)。S7 侧额外做了:
- DryRunAsync 与 MigrateAsync 共用同一个 EvaluateAsync,预演与实改在代码上无法分叉;
- 改写路径门禁:数字引用只允许出现在 N2/N3,改写后再独立复核一次落点;
- 目标 Code 白名单:Code 由 SysRole.Code 反查而非硬编码,不在白名单即挡下;
- 留证表按要求补 changed_paths / applied_by / migration_key。

安全边界:批次边界 2026-09-14 22:00(RoleCode 定义发布前),之后发起的实例一律不碰;
终态实例不动;同租户数字 RoleId 不动;N1 是 Initiator 节点,服务只看 approverType==Role
故天然不碰;业务租户由 qms_qcpp_inspbill 反查,不取登录/定义/发起人/待办人租户;
UPDATE 带 Id + Running + 改前 MD5 三条件且只改 FlowJsonSnapshot 一列;
留证 INSERT 与快照 UPDATE 同一事务;幂等判据取自快照内容而非留证表。

Runtime:
- Dry Run(只读、留证表 0 行):candidates=7 applicable=7 blocked=0,每条只改 2 条路径
  且全落 nodes[id=N2_SUP_REVIEW|N3_QE_DISPOSITION].properties.approverIds;
  目标角色 797→848821564117061/848821564616773、838→848821570179141/848821570670661
  (均 tenant-local、各 1 名同租户成员);回滚来源各命中恰好 1 条 ApprovalFlowVersion。
- Apply:selected=7 applied=7,blockedTargetAuthority/RollbackSource/ChangedPath/Concurrent 全 0;
  留证 7 行全 APPLIED、1 个批次、7 个不同实例。
- 迁移后:原 7 条 legacy RoleId 命中 0/7,RoleCode 命中 7/7(N2 与 N3 各 7)。
- 零副作用:Active 状态指纹(Id|FlowId|FlowVersion|Status|CurrentNodeId|InitiatorId|任务
  id:assignee:status)前后相同 075ef325…;新建健康 2 条快照指纹相同 629ae11f…;
  终态 7 条指纹相同 6ab93ac0…;业务行 pd/hgsl/bhgsl/clfs/FBILLSTATUS 指纹相同 702860f2…;
  ApprovalFlowLog 49 条、CompletedNode 48 条均未变;9 条跨租户绑定按设计保留。
- 幂等:第二次启动 candidates=0 selected=0 applied=0,留证仍 7 行 1 批次。
- Resolver 证明(不推进流程):797 与 838 的 N2/N3 均解析到本租户 TENANT_LOCAL 角色,
  成员 AIDOPDemo@797 / UATQualityA@838,满足 User.TenantId == Role.TenantId == BusinessTenant。
- 特殊实例 840240525086789:Role authority 已修复,但 InitiatorId 仍为 1300000000101
  (默认租户)、N1 仍是 Initiator、CurrentNode 仍 N2、原待办未变 ——
  其 Initiator tenant mismatch 仍 OPEN,Reject→N1 依旧不可用,留待独立批次。

本批未开启 TenantStrictRoleBizTypes、未清理跨租户绑定、未改通用审批守卫与流程定义。
测试:全量 2842 passed / 1 failed(既有 S8AuthorizationGuardTests,与本批无关)。
YY968XX 1 天之前
父節點
當前提交
803fdc96f0

+ 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.548</AssemblyVersion>
-    <FileVersion>1.0.548</FileVersion>
-    <Version>1.0.548</Version>
+    <AssemblyVersion>1.0.549</AssemblyVersion>
+    <FileVersion>1.0.549</FileVersion>
+    <Version>1.0.549</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 542 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S7/S7LegacyFlowAuthorityMigrationContractTests.cs

@@ -0,0 +1,542 @@
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.Service.S7;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S7;
+
+/// <summary>
+/// S7-LEGACY-SNAPSHOT-MIGRATION-1 契约测试:S7 存量 Active 实例快照权威迁移。
+///
+/// 行为级部分直接调用 <see cref="S7LegacyFlowAuthorityMigrationService"/> 的两个纯静态函数
+/// (解析 / 改写),源码契约部分断言门禁与边界仍在。DB 相关的候选选取与事务落库
+/// 由 Runtime dry-run / apply 覆盖(service 依赖 ISqlSugarClient,裸 xUnit 进程无法实例化)。
+/// </summary>
+public class S7LegacyFlowAuthorityMigrationContractTests
+{
+    private const string LegacySupervisorId = "1329915020002";
+    private const string LegacyQeId = "1329915020003";
+    private const string SupervisorCode = "ROLE_S7_FQC_SUPERVISOR";
+    private const string QeCode = "ROLE_S7_FQC_QE";
+
+    private static string Service() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Service", "S7", "S7LegacyFlowAuthorityMigrationService.cs"));
+
+    private static string Entity() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Entity", "S7", "AdoS7FlowAuthorityMigrationLog.cs"));
+
+    private static string FlowTaskService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs"));
+
+    private static string Engine() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
+
+    /// <summary>与真实 S7 快照同形:N1=Initiator、N2/N3=Role 数字,另含 SpecificUser / Department 干扰节点。</summary>
+    private static string SampleSnapshot(string n2Ids = LegacySupervisorId, string n3Ids = LegacyQeId) => JsonSerializer.Serialize(new
+    {
+        nodes = new object[]
+        {
+            new { id = "start", type = "bpmn:startEvent", properties = new { nodeName = "开始" } },
+            new { id = "N1_INSPECT", 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 = "QE处置", approverType = "Role", approverIds = n3Ids, approverNames = "成品检验QE" } },
+            // 干扰项:数字但语义不是 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 = "edge-start-n1", sourceNodeId = "start", targetNodeId = "N1_INSPECT" } },
+    });
+
+    // ───────────── §43-1..4 候选谓词 ─────────────
+
+    /// <summary>§43-1:legacy Running 快照(含数字 Role token)应被识别为可迁。</summary>
+    [Fact]
+    public void Predicate_LegacyRunningSnapshot_IsCandidate()
+    {
+        var refs = S7LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(SampleSnapshot());
+        Assert.Equal(2, refs.Count);
+        Assert.Contains(refs, r => r.NodeId == "N2_SUP_REVIEW" && r.NumericTokens.Single() == LegacySupervisorId);
+        Assert.Contains(refs, r => r.NodeId == "N3_QE_DISPOSITION" && r.NumericTokens.Single() == LegacyQeId);
+    }
+
+    /// <summary>§43-2:已是 RoleCode 的快照(上一批新建的健康实例)选不出任何数字引用 → 非候选。</summary>
+    [Fact]
+    public void Predicate_RoleCodeSnapshot_IsNotCandidate()
+    {
+        var refs = S7LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(SampleSnapshot(SupervisorCode, QeCode));
+        Assert.Empty(refs);
+    }
+
+    /// <summary>§43-3:终态实例由 Status==Running 谓词排除;§43-4:BizType 白名单只含 S7。</summary>
+    [Fact]
+    public void Predicate_RequiresRunningStatusAndS7BizType()
+    {
+        var body = Slice(Service(), "public async Task<List<S7MigrationCandidate>> SelectCandidatesAsync", "public async Task<S7FlowAuthorityMigrationResult> MigrateAsync");
+        Assert.Contains("x.Status == FlowInstanceStatusEnum.Running", body);
+        Assert.Contains("bizTypes.Contains(x.BizType)", body);
+        Assert.Equal("S7_FQC_INSPBILL", S7LegacyFlowAuthorityMigrationService.TargetBizTypes.Single());
+    }
+
+    /// <summary>ACTIVE 判据不能只看 Status —— 必须另要求存在 Pending 任务(S7 有 3 条终态实例仍停在 N3_*)。</summary>
+    [Fact]
+    public void Predicate_AlsoRequiresPendingTask()
+    {
+        var body = Slice(Service(), "public async Task<List<S7MigrationCandidate>> SelectCandidatesAsync", "public async Task<S7FlowAuthorityMigrationResult> MigrateAsync");
+        Assert.Contains("t.Status == FlowTaskStatusEnum.Pending", body);
+        Assert.Contains("if (!hasPending) continue;", body);
+    }
+
+    /// <summary>批次边界存在且早于「RoleCode 定义发布」之后的新实例,杜绝退化成长期 auto-heal。</summary>
+    [Fact]
+    public void Predicate_HasBatchCutoff()
+    {
+        var s = Service();
+        Assert.Contains("BatchCutoff = new(2026, 9, 14, 22, 0, 0", s);
+        Assert.Contains("x.StartTime < cutoff", s);
+    }
+
+    // ───────────── §43-5..9 改写 ─────────────
+
+    /// <summary>§43-5:N2 数字 RoleId → Supervisor RoleCode。</summary>
+    [Fact]
+    public void Rewrite_N2_NumericRoleIdBecomesSupervisorCode()
+    {
+        var after = Rewrite(SampleSnapshot());
+        Assert.Equal(SupervisorCode, ApproverIdsOf(after, "N2_SUP_REVIEW"));
+    }
+
+    /// <summary>§43-6:N3 数字 RoleId → QE RoleCode。</summary>
+    [Fact]
+    public void Rewrite_N3_NumericRoleIdBecomesQeCode()
+    {
+        var after = Rewrite(SampleSnapshot());
+        Assert.Equal(QeCode, ApproverIdsOf(after, "N3_QE_DISPOSITION"));
+    }
+
+    /// <summary>§43-7 / §13:N1 是 Initiator 节点,approverType 与空 approverIds 必须原样不动。</summary>
+    [Fact]
+    public void Rewrite_N1_InitiatorIsUntouched()
+    {
+        var after = Rewrite(SampleSnapshot());
+        Assert.Equal("Initiator", ApproverTypeOf(after, "N1_INSPECT"));
+        Assert.Equal("", ApproverIdsOf(after, "N1_INSPECT"));
+        Assert.DoesNotContain("N1_INSPECT", ChangedNodesOf(SampleSnapshot()));
+    }
+
+    /// <summary>§43-8:非 Role 节点里的数字(SpecificUser=UserId / Department=OrgId)不得被改。</summary>
+    [Fact]
+    public void Rewrite_UnrelatedNumericNodesUntouched()
+    {
+        var after = Rewrite(SampleSnapshot());
+        Assert.Equal("1300000000101", ApproverIdsOf(after, "X_SPECIFIC"));
+        Assert.Equal("1300000000002", ApproverIdsOf(after, "X_DEPT"));
+    }
+
+    /// <summary>节点 id / 名称 / edges / 网关 / approverNames 一概不动,只有 approverIds 变。</summary>
+    [Fact]
+    public void Rewrite_PreservesEverythingElse()
+    {
+        var before = SampleSnapshot();
+        var after = Rewrite(before);
+        var b = JsonDocument.Parse(before).RootElement;
+        var a = JsonDocument.Parse(after).RootElement;
+        Assert.Equal(b.GetProperty("nodes").GetArrayLength(), a.GetProperty("nodes").GetArrayLength());
+        Assert.Equal(b.GetProperty("edges").ToString(), a.GetProperty("edges").ToString());
+        Assert.Equal("成品检验主管", a.GetProperty("nodes").EnumerateArray()
+            .First(n => n.GetProperty("id").GetString() == "N2_SUP_REVIEW")
+            .GetProperty("properties").GetProperty("approverNames").GetString());
+    }
+
+    /// <summary>多 token 时保持原顺序与数量,只替换命中的那个。</summary>
+    [Fact]
+    public void Rewrite_KeepsTokenOrderAndCount()
+    {
+        var after = S7LegacyFlowAuthorityMigrationService.RewriteAuthority(
+            SampleSnapshot($"999,{LegacySupervisorId},888"),
+            new Dictionary<string, string> { [LegacySupervisorId] = SupervisorCode })!;
+        Assert.Equal($"999,{SupervisorCode},888", ApproverIdsOf(after.After, "N2_SUP_REVIEW"));
+    }
+
+    /// <summary>§43-9 / §12:只能走结构化 JSON,禁止字符串/正则替换。</summary>
+    [Theory]
+    [InlineData("string.Replace")]
+    [InlineData(".Replace(")]
+    [InlineData("Regex")]
+    public void Rewrite_StructuredJsonOnly(string forbidden)
+    {
+        Assert.DoesNotContain(forbidden, CsCode(Service()));
+    }
+
+    /// <summary>非法 JSON / 无 nodes 一律返回 null,不做任何猜测性改写。</summary>
+    [Fact]
+    public void Rewrite_FailsClosedOnMalformedSnapshot()
+    {
+        Assert.Null(S7LegacyFlowAuthorityMigrationService.RewriteAuthority("not json", new Dictionary<string, string>()));
+        Assert.Null(S7LegacyFlowAuthorityMigrationService.RewriteAuthority("{\"x\":1}", new Dictionary<string, string>()));
+        Assert.Empty(S7LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs("not json"));
+        Assert.Empty(S7LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(null));
+    }
+
+    // ───────────── §14 精确路径门禁 ─────────────
+
+    /// <summary>改写结果必须回报被改节点与可读路径,供调用方独立复核。</summary>
+    [Fact]
+    public void Rewrite_ReportsChangedPaths()
+    {
+        var r = S7LegacyFlowAuthorityMigrationService.RewriteAuthority(SampleSnapshot(), FullMap())!;
+        Assert.Equal(2, r.ChangedNodeIds.Count);
+        Assert.Contains("nodes[id=N2_SUP_REVIEW].properties.approverIds", r.ChangedPathText);
+        Assert.Contains("nodes[id=N3_QE_DISPOSITION].properties.approverIds", r.ChangedPathText);
+        Assert.Contains($"\"{LegacySupervisorId}\" => \"{SupervisorCode}\"", r.ChangedPathText);
+    }
+
+    /// <summary>可迁节点集恰为 N2/N3;改写路径越界必须 BLOCKED_CHANGED_PATH。</summary>
+    [Fact]
+    public void ChangedPathGate_OnlyAllowsN2AndN3()
+    {
+        Assert.Equal(
+            new[] { "N2_SUP_REVIEW", "N3_QE_DISPOSITION" }.OrderBy(x => x),
+            S7LegacyFlowAuthorityMigrationService.MigratableNodeIds.OrderBy(x => x));
+        var s = Service();
+        Assert.Contains("!MigratableNodeIds.Contains(r.NodeId)", s);
+        Assert.Contains("BLOCKED_CHANGED_PATH", s);
+        Assert.Contains("rewrite.ChangedNodeIds.Where(n => !MigratableNodeIds.Contains(n))", s);
+    }
+
+    /// <summary>目标 Code 必须落在 S7 白名单内,防止把意外 Code 写进快照。</summary>
+    [Fact]
+    public void TargetCodeGate_HasAllowList()
+    {
+        Assert.Equal(
+            new[] { QeCode, SupervisorCode }.OrderBy(x => x),
+            S7LegacyFlowAuthorityMigrationService.AllowedTargetRoleCodes.OrderBy(x => x));
+        Assert.Contains("!AllowedTargetRoleCodes.Contains(code)", Service());
+    }
+
+    // ───────────── §43-10..13 Role 校验 fail-closed ─────────────
+
+    /// <summary>§43-10/11/13:目标租户下该 Code 必须恰好一个启用角色(0 或多个都拒)。</summary>
+    [Fact]
+    public void RoleGate_RequiresExactlyOneEnabledLocalRole()
+    {
+        var s = Service();
+        Assert.Contains("x.TenantId == c.BusinessTenantId && x.Code == code && x.Status == StatusEnum.Enable", s);
+        Assert.Contains("if (targets.Count != 1)", s);
+        Assert.Contains("AMBIGUOUS TARGET ROLE", s);
+    }
+
+    /// <summary>§43-12:目标角色必须有同租户成员,否则迁完仍解析 0 人 → 拒。</summary>
+    [Fact]
+    public void RoleGate_RequiresSameTenantMember()
+    {
+        var s = Service();
+        Assert.Contains("u.TenantId == c.BusinessTenantId", s);
+        Assert.Contains("if (sameTenantMembers == 0)", s);
+        Assert.Contains("无同租户成员", s);
+    }
+
+    /// <summary>同租户的数字 RoleId 是合法引用,不得因为「是数字」就改。</summary>
+    [Fact]
+    public void RoleGate_DoesNotTouchSameTenantNumericRole()
+    {
+        Assert.Contains("legacyRole.TenantId == c.BusinessTenantId", Service());
+    }
+
+    /// <summary>§16:快照只能写 RoleCode,代码里不得出现任何租户本地 RoleId 字面量。</summary>
+    [Theory]
+    [InlineData("848821564117061")]
+    [InlineData("848821564616773")]
+    [InlineData("848821570179141")]
+    [InlineData("848821570670661")]
+    public void NeverHardcodesTenantLocalRoleId(string localRoleId)
+    {
+        Assert.DoesNotContain(localRoleId, Service());
+    }
+
+    /// <summary>§17:业务租户必须由 S7 业务表反查,不得取登录 / 定义 / 发起人 / 待办人租户。</summary>
+    [Fact]
+    public void BusinessTenant_ComesFromBusinessRow()
+    {
+        var body = Slice(Service(), "private async Task<long?> ResolveBusinessTenantAsync", "internal static List<S7NumericRoleRef>");
+        Assert.Contains("SELECT tenant_id FROM qms_qcpp_inspbill WHERE id=@id", body);
+        Assert.DoesNotContain("_userManager", Service());
+    }
+
+    // ───────────── §43-14..17 并发 / 事务 / 留证 / 幂等 ─────────────
+
+    /// <summary>§43-14 / §18 / §20:UPDATE 必须带 Id + Running + 改前 MD5,affected≠1 即抛。</summary>
+    [Fact]
+    public void ConcurrencyGuard_UpdateIsConditional()
+    {
+        var s = Service();
+        Assert.Contains("WHERE Id=@id AND Status=@running AND MD5(FlowJsonSnapshot)=@beforeMd5", s);
+        Assert.Contains("if (affected != 1)", s);
+        Assert.Contains("CONCURRENT MODIFICATION", s);
+    }
+
+    /// <summary>UPDATE 只改 FlowJsonSnapshot 一列 —— 不得用整实体 Updateable 回写过期列。</summary>
+    [Fact]
+    public void Update_TouchesOnlySnapshotColumn()
+    {
+        var s = Service();
+        Assert.Contains("SET FlowJsonSnapshot=@after", s);
+        Assert.DoesNotContain("AsUpdateable(inst)", s);
+        Assert.DoesNotContain("Updateable<ApprovalFlowInstance>", s);
+    }
+
+    /// <summary>§43-15 / §19:留证 INSERT 与快照 UPDATE 必须同一事务,失败整体回滚。</summary>
+    [Fact]
+    public void Transaction_EvidenceAndUpdateAreAtomic()
+    {
+        var body = Slice(Service(), "var tran = await _db.AsTenant().UseTranAsync", "if (!tran.IsSuccess)");
+        Assert.Contains("_logRep.AsInsertable", body);
+        Assert.Contains("UPDATE ApprovalFlowInstance", body);
+        Assert.Contains("if (!tran.IsSuccess)", Service());
+    }
+
+    /// <summary>§43-16 / §10:留证表 append-only 且含全部要求字段。</summary>
+    [Theory]
+    [InlineData("migration_batch")]
+    [InlineData("migration_key")]
+    [InlineData("instance_id")]
+    [InlineData("biz_type")]
+    [InlineData("business_tenant_id")]
+    [InlineData("before_snapshot")]
+    [InlineData("after_snapshot")]
+    [InlineData("before_md5")]
+    [InlineData("after_md5")]
+    [InlineData("changed_paths")]
+    [InlineData("rollback_source")]
+    [InlineData("applied_by")]
+    [InlineData("applied_at")]
+    [InlineData("outcome")]
+    public void EvidenceTable_HasRequiredColumn(string column)
+    {
+        Assert.Contains($"ColumnName = \"{column}\"", Entity());
+    }
+
+    /// <summary>留证表不得被后续运行抹掉(对照 S8 那张每轮重写的表)。</summary>
+    [Fact]
+    public void EvidenceTable_IsAppendOnly()
+    {
+        Assert.DoesNotContain("Deleteable", CsCode(Service()));
+        Assert.Contains("append-only", Entity());
+    }
+
+    /// <summary>被挡下的候选也必须留证 —— 「为什么没迁」同样要能回答。</summary>
+    [Fact]
+    public void BlockedCandidates_AlsoWriteEvidence()
+    {
+        var body = Slice(Service(), "private async Task BlockAsync", "private static string MigrationIdentity");
+        Assert.Contains("_logRep.AsInsertable", body);
+        Assert.Contains("Outcome = outcome", body);
+    }
+
+    /// <summary>§43-17 / §28:幂等判据是快照内容本身,不是留证表里有没有这条实例。</summary>
+    [Fact]
+    public void Idempotency_DerivesFromSnapshotContentNotEvidenceTable()
+    {
+        var body = Slice(Service(), "public async Task<List<S7MigrationCandidate>> SelectCandidatesAsync", "public async Task<S7FlowAuthorityMigrationResult> MigrateAsync");
+        Assert.Contains("ParseNumericRoleRefs(inst.FlowJsonSnapshot)", body);
+        Assert.Contains("if (refs.Count == 0) continue;", body);
+        // 候选选取不得查留证表
+        Assert.DoesNotContain("_logRep", body);
+    }
+
+    /// <summary>§25:dry-run(候选选取)不得写任何表,包括迁移留证表。</summary>
+    [Fact]
+    public void DryRun_WritesNothing()
+    {
+        var body = Slice(Service(), "public async Task<List<S7MigrationCandidate>> SelectCandidatesAsync", "public async Task<S7FlowAuthorityMigrationResult> MigrateAsync");
+        foreach (var write in new[] { "AsInsertable", "AsUpdateable", "ExecuteCommandAsync", "UseTranAsync", "Deleteable" })
+            Assert.DoesNotContain(write, body);
+    }
+
+    /// <summary>§11:回滚来源必须唯一且与当前快照逐字节一致。</summary>
+    [Fact]
+    public void RollbackSource_RequiresExactlyOneMatchingVersionRow()
+    {
+        var s = Service();
+        Assert.Contains("v.FlowId == inst.FlowId && v.Version == inst.FlowVersion", s);
+        Assert.Contains("Md5(v.FlowJson ?? string.Empty) == beforeMd5", s);
+        Assert.Contains("versionRows.Count != 1 || usable.Count != 1", s);
+        Assert.Contains("BLOCKED_ROLLBACK_SOURCE", s);
+    }
+
+    // ───────────── §43-18/19 特殊实例 / 新实例 ─────────────
+
+    /// <summary>
+    /// §43-18 / §24:迁移不得触碰 Initiator、不得改节点类型、不得重建待办 —— 特殊实例
+    /// 840240525086789 的 Initiator tenant mismatch 必须保持 OPEN。
+    /// </summary>
+    [Theory]
+    [InlineData("InitiatorId =")]
+    [InlineData("CurrentNodeId =")]
+    [InlineData("approverType\"] =")]
+    [InlineData("CreateTasksForNode")]
+    [InlineData("ReturnToPrev")]
+    [InlineData("StartFlow")]
+    [InlineData("Approve(")]
+    public void Migration_NeverTouchesFlowStateOrInitiator(string forbidden)
+    {
+        Assert.DoesNotContain(forbidden, CsCode(Service()));
+    }
+
+    /// <summary>迁移不得删除或改写任何任务 / 日志 / 已完成节点。</summary>
+    [Theory]
+    [InlineData("ApprovalFlowTask")]
+    [InlineData("ApprovalFlowLog")]
+    [InlineData("ApprovalFlowCompletedNode")]
+    public void Migration_OnlyReadsFlowSideTables(string table)
+    {
+        var code = CsCode(Service());
+        // 允许只读查询(候选判据要看 Pending),但不允许出现写操作
+        foreach (var write in new[] { $"AsUpdateable<{table}>", $"Deleteable<{table}>", $"AsInsertable<{table}>" })
+            Assert.DoesNotContain(write, code);
+    }
+
+    /// <summary>§43-19:新建的 RoleCode 实例因快照无数字 token 天然落选(已由 Predicate 测试覆盖),此处冻结该推理链。</summary>
+    [Fact]
+    public void HealthyRoleCodeInstances_ExcludedByContentPredicate()
+    {
+        Assert.Empty(S7LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(SampleSnapshot(SupervisorCode, QeCode)));
+        Assert.Contains("上一批新建的 RoleCode 实例", Service());
+    }
+
+    // ───────────── Dry-run 与 Apply 共用门禁(§25/§26)─────────────
+
+    /// <summary>
+    /// dry-run 与 apply 必须走同一个 <c>EvaluateAsync</c>:否则「预演看到的」与「实际改的」
+    /// 会是两套逻辑,Dry Run Gate 就失去意义。
+    /// </summary>
+    [Fact]
+    public void DryRunAndApply_ShareTheSameEvaluation()
+    {
+        var s = Service();
+        var dry = Slice(s, "public async Task<List<S7MigrationEvaluation>> DryRunAsync", "private async Task MigrateOneAsync");
+        var one = Slice(s, "private async Task MigrateOneAsync", "private async Task<S7MigrationEvaluation> EvaluateAsync");
+        Assert.Contains("await EvaluateAsync(c)", dry);
+        Assert.Contains("await EvaluateAsync(c)", one);
+        // 门禁只实现一份
+        Assert.Equal(1, CountOf(s, "private async Task<S7MigrationEvaluation> EvaluateAsync"));
+    }
+
+    /// <summary>被挡下的评估由 <c>Blocked(...)</c> 纯构造返回,不在评估阶段写库。</summary>
+    [Fact]
+    public void Evaluation_BlockedIsPureAndWritesNothing()
+    {
+        var body = Slice(Service(), "private static S7MigrationEvaluation Blocked(", "private async Task ApplyOneAsync");
+        Assert.Contains("CanApply = false", body);
+        foreach (var write in new[] { "AsInsertable", "ExecuteCommandAsync", "UseTranAsync", "_logRep" })
+            Assert.DoesNotContain(write, body);
+    }
+
+    /// <summary>
+    /// 一次性执行方式:由 Startup 在启动期调用,先 DryRun 再 Migrate;
+    /// 不得引入后台长期轮询任务(§41)。
+    /// </summary>
+    [Fact]
+    public void Execution_IsStartupOneTime_NotBackgroundPolling()
+    {
+        var startup = File.ReadAllText(FindFile(
+            "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Startup.cs"));
+        Assert.Contains("s7Mig.DryRunAsync().GetAwaiter().GetResult();", startup);
+        Assert.Contains("s7Mig.MigrateAsync().GetAwaiter().GetResult();", startup);
+        Assert.Contains("AdoS7FlowAuthorityMigrationLog", startup);
+        // 迁移服务不得被注册成后台常驻
+        Assert.DoesNotContain("AddHostedService<Admin.NET.Plugin.AiDOP.Service.S7.S7LegacyFlowAuthorityMigrationService>", startup);
+    }
+
+    // ───────────── §43-20 / §38 / §39 回归 ─────────────
+
+    /// <summary>§43-20 / §39:S7 通用审批旁路守卫必须仍然生效。</summary>
+    [Fact]
+    public void GenericApprovalGuard_StillCoversS7()
+    {
+        var dict = Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};");
+        Assert.Contains("[\"S7_FQC_INSPBILL\"]", dict);
+    }
+
+    /// <summary>§38:本批**不得**把 S7 加入 TenantStrictRoleBizTypes(Guard 属下一治理边界)。</summary>
+    [Fact]
+    public void StrictRoleGuard_StillNotOpenedForS7()
+    {
+        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>§37:本批不得清理跨租户绑定。</summary>
+    [Theory]
+    [InlineData("SysUserRole")]
+    [InlineData("SysRole")]
+    public void Migration_NeverWritesRoleTables(string table)
+    {
+        var code = CsCode(Service());
+        foreach (var write in new[] { $"Deleteable<{table}>", $"AsUpdateable<{table}>", $"AsInsertable<{table}>", $"Insertable(new {table}" })
+            Assert.DoesNotContain(write, code);
+    }
+
+    // ───────────── helpers ─────────────
+
+    private static Dictionary<string, string> FullMap() => new(StringComparer.Ordinal)
+    {
+        [LegacySupervisorId] = SupervisorCode,
+        [LegacyQeId] = QeCode,
+    };
+
+    private static string Rewrite(string before) =>
+        S7LegacyFlowAuthorityMigrationService.RewriteAuthority(before, FullMap())!.After;
+
+    private static List<string> ChangedNodesOf(string before) =>
+        S7LegacyFlowAuthorityMigrationService.RewriteAuthority(before, FullMap())!.ChangedNodeIds;
+
+    private static string? ApproverIdsOf(string snapshot, string nodeId) =>
+        NodeProp(snapshot, nodeId, "approverIds");
+
+    private static string? ApproverTypeOf(string snapshot, string nodeId) =>
+        NodeProp(snapshot, nodeId, "approverType");
+
+    private static string? NodeProp(string snapshot, string nodeId, string prop) =>
+        JsonDocument.Parse(snapshot).RootElement.GetProperty("nodes").EnumerateArray()
+            .First(n => n.GetProperty("id").GetString() == nodeId)
+            .GetProperty("properties").GetProperty(prop).GetString();
+
+    /// <summary>去注释视图:注释里会引用被淘汰的写法来解释「为何不那么做」,裸文本断言会误判。</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)
+                                            && !l.TrimStart().StartsWith("///", StringComparison.Ordinal));
+        return string.Join("\n", kept);
+    }
+
+    private static int CountOf(string src, string needle)
+    {
+        int n = 0, i = 0;
+        while ((i = src.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) { n++; i += needle.Length; }
+        return n;
+    }
+
+    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));
+    }
+}

+ 104 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S7/AdoS7FlowAuthorityMigrationLog.cs

@@ -0,0 +1,104 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S7;
+
+/// <summary>
+/// S7-LEGACY-SNAPSHOT-MIGRATION-1:S7 成品检验存量运行中实例的审批权威快照迁移留证。
+///
+/// <para><b>为什么必须留证</b>:<c>ApprovalFlowInstance</c> 没有 version、没有 checksum、
+/// 没有并发令牌,<c>UpdateTime</c> 也不是 DB 自动列 —— 裸 UPDATE 之后
+/// <b>无法证明改过什么、为什么改、改前是什么</b>。而 <c>FlowJsonSnapshot</c> 同时是
+/// 通用审批中心渲染历史流程图的数据源,没有这张表这就是一次无法解释的静默改写。</para>
+///
+/// <para><b>为什么不复用 <c>ado_s6_flow_authority_migration_log</c></b>:那张表的 schema 确实是
+/// 通用的(batch / instance / biz / tenant / before-after / md5 / mapping / rollback / outcome),
+/// 但它的**表名与文档语义都限定在 S6**。把 S7 的迁移事实写进「S6 迁移留证」表,会让
+/// 未来任何人按表名审计 S6 时读到 S7 行、按 S7 审计时找不到表 —— 可审计性反而变差。
+/// 故本表独立建立,字段有意与 S6 对齐以便将来合并,并按本批要求额外落
+/// <see cref="ChangedPaths"/> / <see cref="AppliedBy"/> / <see cref="MigrationKey"/>。</para>
+///
+/// <para><b>append-only。</b>与 <c>AdoS8ApprovalFlowRepairLog</c>(每轮 DELETE 后整体重写,
+/// 描述「最近一次对账看到了什么」)语义不同:迁移是**一次性历史事件**,记录一旦写下
+/// 不允许被后续运行抹掉,否则回滚依据会随时间消失。</para>
+///
+/// <para><b>幂等不依赖本表。</b>「是否已迁」的判据是快照自身内容(Role 节点的 approverIds
+/// 是否还存在纯数字 token),不是「本表里有没有这条 InstanceId」——
+/// 留证一旦丢失就重复改写,是 S8 先例踩过的坑。</para>
+/// </summary>
+[SugarTable("ado_s7_flow_authority_migration_log", "S7 存量流程审批权威迁移留证(append-only)")]
+public class AdoS7FlowAuthorityMigrationLog
+{
+    [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;
+
+    /// <summary>
+    /// 稳定的单实例迁移键(<c>S7-LEGACY-SNAPSHOT-MIGRATION-1#&lt;instanceId&gt;</c>)。
+    /// 与 <see cref="MigrationBatch"/> 的区别:batch 含时间戳、每次执行都不同;
+    /// 本字段跨执行稳定,用于「同一实例是否已被成功迁过」的重复留证检测。
+    /// </summary>
+    [SugarColumn(ColumnName = "migration_key", Length = 96)]
+    public string MigrationKey { 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>由 S7 业务实体(<c>qms_qcpp_inspbill.tenant_id</c>)反查得到,**不取登录用户租户**。</summary>
+    [SugarColumn(ColumnName = "business_tenant_id", ColumnDataType = "bigint")]
+    public long BusinessTenantId { 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>
+    /// 本次实际改写的 JSON 路径清单,逐条形如
+    /// <c>nodes[id=N2_SUP_REVIEW].properties.approverIds</c>。
+    /// 用于回答「到底动了哪几个字段」,并支撑「只允许 N2/N3 两个节点」的门禁复核。
+    /// </summary>
+    [SugarColumn(ColumnName = "changed_paths", ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
+    public string? ChangedPaths { get; set; }
+
+    /// <summary>逐节点映射:<c>node=N2_SUP_REVIEW;1329915020002=&gt;ROLE_S7_FQC_SUPERVISOR@848821564117061;members=1</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>
+    /// 迁移执行身份。本迁移在应用启动时以系统身份运行、无登录用户上下文,
+    /// 故记录「执行体 + 主机」而不是某个 UserId —— 写一个假的 UserId 比留空更糟。
+    /// </summary>
+    [SugarColumn(ColumnName = "applied_by", Length = 128, IsNullable = true)]
+    public string? AppliedBy { get; set; }
+
+    /// <summary>APPLIED / BLOCKED_TARGET_AUTHORITY / BLOCKED_ROLLBACK_SOURCE / BLOCKED_CHANGED_PATH / BLOCKED_CONCURRENT_MODIFICATION。</summary>
+    [SugarColumn(ColumnName = "outcome", Length = 48)]
+    public string Outcome { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "applied_at")]
+    public DateTime AppliedAt { get; set; } = DateTime.Now;
+}

+ 651 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S7/S7LegacyFlowAuthorityMigrationService.cs

@@ -0,0 +1,651 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Entity.S7;
+using Admin.NET.Plugin.AiDOP.FinishedWarehouse;
+using Admin.NET.Plugin.ApprovalFlow;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S7;
+
+/// <summary>迁移结果。分项计数,运维要能看出「这次到底动了什么、什么被挡下了」。</summary>
+public sealed class S7FlowAuthorityMigrationResult
+{
+    /// <summary>通过全部安全谓词、进入逐条处理的候选实例数。</summary>
+    public int CandidatesSelected { get; set; }
+    /// <summary>本次实际改写快照的实例数。</summary>
+    public int Applied { get; set; }
+    /// <summary>目标租户角色缺失 / 禁用 / 无同租户成员 / 角色不唯一 / 目标 Code 不在允许集 → 未改。</summary>
+    public int BlockedByTargetAuthority { get; set; }
+    /// <summary>回滚来源缺失或不唯一 → 未改。</summary>
+    public int BlockedByRollbackSource { get; set; }
+    /// <summary>改写落在 N2/N3 之外的 JSON 路径 → 未改。</summary>
+    public int BlockedByChangedPath { get; set; }
+    /// <summary>UPDATE 影响行数 ≠ 1(期间被并发改动)→ 已回滚。</summary>
+    public int BlockedByConcurrentModification { get; set; }
+}
+
+/// <summary>
+/// S7-LEGACY-SNAPSHOT-MIGRATION-1:把**仍在运行**的 S7 成品检验流程实例快照里冻结的
+/// 默认租户物理 RoleId 改写为 RoleCode,让它们在租户本地化模型下继续流转。
+///
+/// <para><b>问题形态</b>(已取证,非推测):<c>StartFlowCore</c> 落实例时把
+/// <c>ApprovalFlow.FlowJson</c> 整体冻结进 <c>FlowJsonSnapshot</c>(<c>:128</c>),此后
+/// **所有推进路径一律读快照、再不看定义**(<c>:180/302/366/467/515/555/1230</c>)。因此
+/// 2026-09-14 的 S7 租户本地化(定义 N2/N3 由 <c>1329915020002</c>/<c>1329915020003</c>
+/// 改成 RoleCode)**只对新发起的实例生效**;此前发起且仍未走完的实例,快照里那个默认租户
+/// RoleId 只能靠 9 条跨租户 <c>SysUserRole</c> 绑定才解析得出人 —— 那些绑定本身是待清理的缺陷。</para>
+///
+/// <para><b>为什么不做 runtime auto-heal</b>:运行时隐式把 A 角色当 B 角色执行,会让
+/// 「执行的」与「快照里展示的」永久分离,比显式改写更难解释;而放宽引擎门禁会同时影响新实例。
+/// 故本服务是 <b>显式、一次性、可审计、可回滚的数据迁移</b>,不是 resolver 兼容层。</para>
+///
+/// <para><b>批次边界(关键)</b>:只处理 <see cref="BatchCutoff"/> 之前发起的实例。该时点是
+/// S7 RoleCode 定义发布之前;此后发起的实例本就会拿到 RoleCode 快照 —— 若之后仍出现默认租户
+/// RoleId,那是**新的 authority regression**,必须让它响亮失败被人看见,绝不能被本服务静默治好。
+/// 这条边界是「一次性迁移」与「长期 auto-heal」的分界线。</para>
+///
+/// <para><b>只修坏的,不碰好的</b>:终态实例一律不动(对 runtime 已无影响,改了没用,
+/// 却百分之百是纯历史记录);同租户的物理 RoleId 也不动(numeric ≠ bad);
+/// N1 是 <c>Initiator</c> 节点,本服务只看 <c>approverType == Role</c>,天然不会碰它。</para>
+///
+/// <para><b>与 S6 的关系</b>:设计思想、门禁顺序、并发守卫、事务边界全部镜像已验证的
+/// <c>S6LegacyFlowAuthorityMigrationService</c>。未复用该类本体,因为它的 <c>TargetBizTypes</c>
+/// 与租户反查都硬绑 S6 两条链,泛化它就要改 S6 已关闭的逻辑。</para>
+/// </summary>
+public class S7LegacyFlowAuthorityMigrationService : ITransient
+{
+    /// <summary>本迁移只处理 S7 成品检验链。</summary>
+    internal static IReadOnlyList<string> TargetBizTypes { get; } =
+        new List<string> { FqcInspBillFlowConst.BizType };
+
+    /// <summary>
+    /// 允许被改写的节点 —— 只有主管审核与 QE 处置两个 Role 节点。
+    /// N1(<c>N1_INSPECT</c>)是 Initiator 节点,不在此列也不该出现在改写路径里。
+    /// </summary>
+    internal static IReadOnlySet<string> MigratableNodeIds { get; } =
+        new HashSet<string>(StringComparer.Ordinal)
+        {
+            FqcInspBillFlowConst.NodeSupervisor,
+            FqcInspBillFlowConst.NodeDisposition,
+        };
+
+    /// <summary>
+    /// 允许作为改写目标的 RoleCode 白名单。目标 Code 是从 legacy 角色行的 <c>SysRole.Code</c>
+    /// 反查来的(比硬编码映射更可靠:Code 若与预期不符会被本白名单挡下,而不是静默写入意外值)。
+    /// </summary>
+    internal static IReadOnlySet<string> AllowedTargetRoleCodes { get; } =
+        new HashSet<string>(StringComparer.Ordinal)
+        {
+            FqcInspBillFlowConst.RoleSupervisorCode,
+            FqcInspBillFlowConst.RoleQeCode,
+        };
+
+    /// <summary>
+    /// 批次边界。S7 租户本地 RoleCode 定义于 2026-09-14 晚间发布(<c>S7FQC_T*</c>),
+    /// 此后发起的实例快照本就是 RoleCode。之后再出现默认租户 RoleId = 新缺陷,
+    /// 不属本次迁移范围,必须 fail-closed 暴露。
+    /// </summary>
+    private static readonly DateTime BatchCutoff = new(2026, 9, 14, 22, 0, 0, DateTimeKind.Unspecified);
+
+    private const string BatchPrefix = "S7-LEGACY-SNAPSHOT-MIGRATION-1";
+
+    private readonly ISqlSugarClient _db;
+    private readonly SqlSugarRepository<AdoS7FlowAuthorityMigrationLog> _logRep;
+    private readonly ILogger<S7LegacyFlowAuthorityMigrationService> _logger;
+
+    public S7LegacyFlowAuthorityMigrationService(
+        ISqlSugarClient db,
+        SqlSugarRepository<AdoS7FlowAuthorityMigrationLog> logRep,
+        ILogger<S7LegacyFlowAuthorityMigrationService> logger)
+    {
+        _db = db;
+        _logRep = logRep;
+        _logger = logger;
+    }
+
+    /// <summary>
+    /// 选出候选(**只读,不写任何表,包括迁移留证表**)。Apply 与 dry-run 共用同一段谓词,
+    /// 避免「预演看到的」和「实际改的」是两套逻辑。
+    /// </summary>
+    public async Task<List<S7MigrationCandidate>> SelectCandidatesAsync()
+    {
+        var candidates = new List<S7MigrationCandidate>();
+
+        // ① 运行中 + 批次边界内。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(实测 S7 有 3 条这样的终态实例),只看状态字段会误判。
+            var hasPending = await _db.Queryable<ApprovalFlowTask>().ClearFilter()
+                .AnyAsync(t => t.InstanceId == inst.Id && t.Status == FlowTaskStatusEnum.Pending);
+            if (!hasPending) continue;
+
+            // ③ Business Tenant 必须由 S7 业务实体反查,禁止取登录用户租户 / 定义租户 /
+            //    发起人租户 / 待办人租户 —— 实测这四者在 S7 存量实例上互不相等。
+            var tenantId = await ResolveBusinessTenantAsync(inst.BizType, inst.BizId);
+            if (tenantId is not > 0) continue;
+
+            // ④ 快照里是否还存在「Role 节点 + 纯数字 token」。这同时就是幂等判据的补集:
+            //    迁完之后本条恒为 false,第二次运行自然选不中;上一批新建的 RoleCode 实例
+            //    也因此天然被排除(它们的 approverIds 是 Code,没有数字 token)。
+            var refs = ParseNumericRoleRefs(inst.FlowJsonSnapshot);
+            if (refs.Count == 0) continue;
+
+            candidates.Add(new S7MigrationCandidate
+            {
+                Instance = inst,
+                BusinessTenantId = tenantId.Value,
+                NumericRefs = refs,
+            });
+        }
+
+        return candidates;
+    }
+
+    /// <summary>执行迁移。每个候选独立事务:留证 INSERT 与快照 UPDATE 原子提交,任一失败整条回滚。</summary>
+    public async Task<S7FlowAuthorityMigrationResult> MigrateAsync(CancellationToken ct = default)
+    {
+        var result = new S7FlowAuthorityMigrationResult();
+        var batch = $"{BatchPrefix}@{DateTime.Now:yyyyMMddHHmmss}";
+
+        List<S7MigrationCandidate> candidates;
+        try
+        {
+            candidates = await SelectCandidatesAsync();
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "S7 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(
+            "S7LegacyFlowAuthorityMigration batch={Batch} selected={Selected} applied={Applied} "
+            + "blockedTargetAuthority={BlockedTarget} blockedRollbackSource={BlockedRollback} "
+            + "blockedChangedPath={BlockedPath} blockedConcurrent={BlockedConcurrent}",
+            batch, result.CandidatesSelected, result.Applied,
+            result.BlockedByTargetAuthority, result.BlockedByRollbackSource,
+            result.BlockedByChangedPath, result.BlockedByConcurrentModification);
+
+        return result;
+    }
+
+    /// <summary>
+    /// 预演(**只读,不写任何表,包括迁移留证表**)。与 Apply 共用 <see cref="SelectCandidatesAsync"/>
+    /// 与 <see cref="EvaluateAsync"/>:预演看到的门禁结论就是 Apply 会走的那一条,不存在两套逻辑。
+    /// </summary>
+    public async Task<List<S7MigrationEvaluation>> DryRunAsync()
+    {
+        var report = new List<S7MigrationEvaluation>();
+        foreach (var c in await SelectCandidatesAsync())
+            report.Add(await EvaluateAsync(c));
+
+        // 预演结论必须可观测:用服务自己的 ILogger 输出(Startup 里的 Trace 不进应用日志文件)。
+        _logger.LogInformation("S7MigrationDryRun candidates={Count} applicable={Ok} blocked={Blocked}",
+            report.Count, report.Count(x => x.CanApply), report.Count(x => !x.CanApply));
+        foreach (var r in report)
+            _logger.LogInformation(
+                "S7MigrationDryRun inst={Inst} tenant={Tenant} node={Node} canApply={CanApply} "
+                + "beforeMd5={Before} afterMd5={After} changedNodes=[{Nodes}] paths={Paths} "
+                + "mapping={Mapping} rollback={Rollback} outcome={Outcome} reason={Reason}",
+                r.Candidate.Instance.Id, r.Candidate.BusinessTenantId, r.Candidate.Instance.CurrentNodeId,
+                r.CanApply, r.BeforeMd5, r.AfterMd5, string.Join(",", r.ChangedNodeIds), r.ChangedPaths,
+                string.Join(" | ", r.Mappings), r.RollbackSource, r.Outcome, r.Reason);
+
+        return report;
+    }
+
+    private async Task MigrateOneAsync(S7MigrationCandidate c, string batch, S7FlowAuthorityMigrationResult result)
+    {
+        var e = await EvaluateAsync(c);
+        if (!e.CanApply)
+        {
+            await BlockAsync(c, batch, e.BeforeMd5, e.Mappings, e.ChangedPaths, e.Outcome!, e.Reason!,
+                result, _ =>
+                {
+                    switch (e.Outcome)
+                    {
+                        case "BLOCKED_CHANGED_PATH": result.BlockedByChangedPath++; break;
+                        case "BLOCKED_ROLLBACK_SOURCE": result.BlockedByRollbackSource++; break;
+                        default: result.BlockedByTargetAuthority++; break;
+                    }
+                });
+            return;
+        }
+
+        await ApplyOneAsync(c, e, batch, result);
+    }
+
+    /// <summary>
+    /// 逐条跑完全部门禁并算出改写结果,**不写库**。返回「可应用的计划」或「被挡下的原因」。
+    /// 抽出这一层的目的:让 dry-run 与 apply 在代码上无法分叉。
+    /// </summary>
+    private async Task<S7MigrationEvaluation> EvaluateAsync(S7MigrationCandidate c)
+    {
+        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)
+        {
+            // Gate 1a:数字引用只允许出现在 N2/N3 两个可迁节点上。出现在别处说明快照结构
+            //          与预期不符(例如 N1 被改成过 Role),必须停下让人看,而不是顺手改。
+            if (!MigratableNodeIds.Contains(r.NodeId))
+            {
+                return Blocked(c, beforeMd5, mappings, null, "BLOCKED_CHANGED_PATH",
+                        $"节点 {r.NodeId} 含数字 Role 引用,但它不在可迁节点集 "
+                    + $"[{string.Join(",", MigratableNodeIds)}] 内");
+            }
+
+            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)
+                {
+                    return Blocked(c, beforeMd5, mappings, null, "BLOCKED_TARGET_AUTHORITY",
+                        $"legacy RoleId {token} 在 SysRole 中不存在");
+                }
+
+                // 同租户的物理 RoleId 是合法引用,不是缺陷 —— 不得因为「是数字」就改它。
+                if (legacyRole.TenantId == c.BusinessTenantId)
+                {
+                    return Blocked(c, beforeMd5, mappings, null, "BLOCKED_TARGET_AUTHORITY",
+                        $"RoleId {token} 属于本租户 {c.BusinessTenantId},非跨租户引用,不在迁移范围");
+                }
+
+                var code = legacyRole.Code;
+                if (string.IsNullOrWhiteSpace(code))
+                {
+                    return Blocked(c, beforeMd5, mappings, null, "BLOCKED_TARGET_AUTHORITY",
+                        $"legacy RoleId {token} 无 Code,无法映射");
+                }
+
+                // Gate 1b:目标 Code 必须在 S7 白名单内。防止「legacy 行的 Code 不是我们以为的那个」
+                //          时把意外值写进快照。
+                if (!AllowedTargetRoleCodes.Contains(code))
+                {
+                    return Blocked(c, beforeMd5, mappings, null, "BLOCKED_TARGET_AUTHORITY",
+                        $"legacy RoleId {token} 的 Code={code} 不在 S7 允许的目标 Code 集 "
+                        + $"[{string.Join(",", AllowedTargetRoleCodes)}] 内");
+                }
+
+                // Gate 2:目标租户下该 Code 必须**恰好一个**启用角色。不得 First() 随便挑。
+                var targets = await _db.Queryable<SysRole>().ClearFilter()
+                    .Where(x => x.TenantId == c.BusinessTenantId && x.Code == code && x.Status == StatusEnum.Enable)
+                    .ToListAsync();
+                if (targets.Count != 1)
+                {
+                    return Blocked(c, beforeMd5, mappings, null, "BLOCKED_TARGET_AUTHORITY",
+                        targets.Count == 0
+                            ? $"目标租户 {c.BusinessTenantId} 下不存在启用的 {code}"
+                            : $"目标租户 {c.BusinessTenantId} 下 {code} 有 {targets.Count} 个,AMBIGUOUS TARGET ROLE");
+                }
+                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.BusinessTenantId);
+                if (sameTenantMembers == 0)
+                {
+                    return Blocked(c, beforeMd5, mappings, null, "BLOCKED_TARGET_AUTHORITY",
+                        $"目标角色 {code}@{c.BusinessTenantId}(RoleId {target.Id})无同租户成员");
+                }
+
+                tokenMap[token] = code;
+                mappings.Add($"node={r.NodeId};{token}=>{code}@{target.Id};members={sameTenantMembers}");
+            }
+        }
+
+        // ── Gate 4:回滚来源必须唯一且与当前快照逐字节一致。ApprovalFlowVersion 全库存在
+        //    (FlowId,Version) 重复行、该表也没有唯一约束 —— 这道门禁不是形式主义。
+        //    注意:改前快照全文本身已落 before_snapshot(第一回滚来源),本门禁是第二来源校验。
+        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)
+        {
+            return Blocked(c, beforeMd5, mappings, null, "BLOCKED_ROLLBACK_SOURCE",
+                        $"ApprovalFlowVersion(FlowId={inst.FlowId},Version={inst.FlowVersion}) 命中 {versionRows.Count} 行、"
+                + $"其中与当前快照 MD5 一致 {usable.Count} 行;要求恰好 1/1");
+        }
+        var rollbackSource = $"ApprovalFlowVersion#{usable[0].Id}";
+
+        // ── 结构化改写:只动 Role 节点的 approverIds,其余一律不碰。禁止字符串替换。
+        var rewrite = RewriteAuthority(before, tokenMap);
+        if (rewrite == null || rewrite.After == before)
+        {
+            return Blocked(c, beforeMd5, mappings, null, "BLOCKED_TARGET_AUTHORITY",
+                        "结构化改写未产生变化或解析失败");
+        }
+
+        // ── Gate 5:改写路径必须全部落在 N2/N3 的 approverIds 上。这是对改写实现的独立复核,
+        //    不依赖 RewriteAuthority 自己的自觉(§14 精确路径门禁)。
+        var illegal = rewrite.ChangedNodeIds.Where(n => !MigratableNodeIds.Contains(n)).ToList();
+        if (illegal.Count > 0)
+        {
+            return Blocked(c, beforeMd5, mappings, rewrite.ChangedPathText, "BLOCKED_CHANGED_PATH",
+                $"改写路径越界:{string.Join(",", illegal)} 不在可迁节点集内");
+        }
+
+        var after = rewrite.After;
+
+        return new S7MigrationEvaluation
+        {
+            Candidate = c,
+            CanApply = true,
+            Before = before,
+            BeforeMd5 = beforeMd5,
+            After = after,
+            AfterMd5 = Md5(after),
+            ChangedNodeIds = rewrite.ChangedNodeIds,
+            ChangedPaths = rewrite.ChangedPathText,
+            Mappings = mappings,
+            RollbackSource = rollbackSource,
+        };
+    }
+
+    /// <summary>被门禁挡下的评估结论(不写库,由调用方决定是否落留证)。</summary>
+    private static S7MigrationEvaluation Blocked(S7MigrationCandidate c, string beforeMd5,
+        List<string> mappings, string? changedPaths, string outcome, string reason) => new()
+        {
+            Candidate = c,
+            CanApply = false,
+            Before = c.Instance.FlowJsonSnapshot ?? string.Empty,
+            BeforeMd5 = beforeMd5,
+            Mappings = mappings,
+            ChangedPaths = changedPaths,
+            Outcome = outcome,
+            Reason = reason,
+        };
+
+    /// <summary>
+    /// 落地一条已通过全部门禁的计划:留证 INSERT 与快照 UPDATE 同一事务,任一失败整条回滚。
+    /// </summary>
+    private async Task ApplyOneAsync(S7MigrationCandidate c, S7MigrationEvaluation e,
+        string batch, S7FlowAuthorityMigrationResult result)
+    {
+        var inst = c.Instance;
+        // ── 留证 + 改写同一事务。任一失败整条回滚:不接受「改了但没留证」,
+        //    也不接受「留证说改了但实际没改」。
+        var tran = await _db.AsTenant().UseTranAsync(async () =>
+        {
+            await _logRep.AsInsertable(new AdoS7FlowAuthorityMigrationLog
+            {
+                MigrationBatch = batch,
+                MigrationKey = $"{BatchPrefix}#{inst.Id}",
+                InstanceId = inst.Id,
+                BizType = inst.BizType,
+                BizId = inst.BizId,
+                BusinessTenantId = c.BusinessTenantId,
+                BeforeSnapshot = e.Before,
+                AfterSnapshot = e.After,
+                BeforeMd5 = e.BeforeMd5,
+                AfterMd5 = e.AfterMd5,
+                ChangedPaths = e.ChangedPaths,
+                AuthorityMapping = string.Join(" | ", e.Mappings),
+                RollbackSource = e.RollbackSource,
+                Reason = "运行中实例的快照冻结了默认租户物理 RoleId,只能靠跨租户 SysUserRole 绑定解析;"
+                         + "改写为 RoleCode 后由引擎在本租户内重新解析,不再依赖跨租户绑定",
+                AppliedBy = MigrationIdentity(),
+                Outcome = "APPLIED",
+            }).ExecuteCommandAsync();
+
+            // 并发守卫:实例表无 version/checksum,只能用「改前 MD5 + 状态」作乐观锁。
+            // 用裸 SQL 精确只改这一列,避免整实体 Updateable 把过期读到的其它列一并回写
+            // (FlowId / FlowVersion / CurrentNodeId / InitiatorId 因此天然不受影响)。
+            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", e.After), new("@id", inst.Id),
+                    new("@running", (int)FlowInstanceStatusEnum.Running), new("@beforeMd5", e.BeforeMd5),
+                });
+            if (affected != 1)
+                throw Oops.Oh($"CONCURRENT MODIFICATION:实例 {inst.Id} 期间被改动,affected={affected}");
+        });
+
+        if (!tran.IsSuccess)
+        {
+            result.BlockedByConcurrentModification++;
+            _logger.LogWarning(tran.ErrorException,
+                "S7LegacyFlowAuthorityMigration: 实例 {InstanceId} 迁移失败已整体回滚", inst.Id);
+            return;
+        }
+
+        result.Applied++;
+        _logger.LogInformation(
+            "S7LegacyFlowAuthorityMigration APPLIED instance={InstanceId} bizType={BizType} bizId={BizId} "
+            + "tenant={Tenant} beforeMd5={BeforeMd5} afterMd5={AfterMd5} paths={Paths} mapping={Mapping}",
+            inst.Id, inst.BizType, inst.BizId, c.BusinessTenantId, e.BeforeMd5, e.AfterMd5,
+            e.ChangedPaths, string.Join(" | ", e.Mappings));
+    }
+
+    /// <summary>挡下的候选同样留证 —— 「为什么没迁」和「为什么迁了」一样需要能回答。</summary>
+    private async Task BlockAsync(S7MigrationCandidate c, string batch, string beforeMd5,
+        List<string> mappings, string? changedPaths, string outcome, string reason,
+        S7FlowAuthorityMigrationResult result, Action<S7FlowAuthorityMigrationResult> bump)
+    {
+        bump(result);
+        await _logRep.AsInsertable(new AdoS7FlowAuthorityMigrationLog
+        {
+            MigrationBatch = batch,
+            MigrationKey = $"{BatchPrefix}#{c.Instance.Id}",
+            InstanceId = c.Instance.Id,
+            BizType = c.Instance.BizType,
+            BizId = c.Instance.BizId,
+            BusinessTenantId = c.BusinessTenantId,
+            BeforeSnapshot = c.Instance.FlowJsonSnapshot,
+            BeforeMd5 = beforeMd5,
+            ChangedPaths = changedPaths,
+            AuthorityMapping = mappings.Count == 0 ? null : string.Join(" | ", mappings),
+            Reason = reason,
+            AppliedBy = MigrationIdentity(),
+            Outcome = outcome,
+        }).ExecuteCommandAsync();
+        _logger.LogWarning("S7LegacyFlowAuthorityMigration {Outcome} instance={InstanceId} reason={Reason}",
+            outcome, c.Instance.Id, reason);
+    }
+
+    /// <summary>
+    /// 迁移执行身份。本迁移以系统身份在启动期运行、没有登录用户上下文,
+    /// 故记录「执行体 + 主机」;不伪造 UserId。
+    /// </summary>
+    private static string MigrationIdentity() => $"{BatchPrefix}@{Environment.MachineName}";
+
+    /// <summary>
+    /// Business Tenant 由 S7 业务实体反查。<c>ApprovalFlowInstance</c> 自身没有 TenantId 列,
+    /// 而推进时引擎用的是登录租户 —— 迁移绝不能沿用那条路径,否则会把租户判定建立在
+    /// 「谁在执行迁移」而不是「这条单据属于谁」之上。
+    /// </summary>
+    private async Task<long?> ResolveBusinessTenantAsync(string bizType, long bizId) => bizType switch
+    {
+        // BizId = qms_qcpp_inspbill.id(见 FqcInspBillFlowService 头部注释)
+        "S7_FQC_INSPBILL" => await _db.Ado.SqlQuerySingleAsync<long?>(
+            "SELECT tenant_id FROM qms_qcpp_inspbill 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;<c>Initiator</c> 节点(S7 的 N1)则根本没有 approverIds。</para>
+    /// </summary>
+    internal static List<S7NumericRoleRef> ParseNumericRoleRefs(string? snapshot)
+    {
+        var refs = new List<S7NumericRoleRef>();
+        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 S7NumericRoleRef
+            {
+                NodeId = node?["id"]?.GetValue<string>() ?? string.Empty,
+                ApproverIds = ids,
+                NumericTokens = numeric,
+            });
+        }
+
+        return refs;
+    }
+
+    /// <summary>
+    /// 结构化改写:只把 Role 节点 approverIds 里的数字 token 换成对应 RoleCode,
+    /// 保持 token 原顺序与原数量。节点 id / 名称 / approverType / approverNames /
+    /// edges / 网关条件一概不动;<c>Initiator</c> 节点不进入循环体。
+    /// 同时返回被改动的节点与可读路径,供调用方做独立的路径门禁复核。
+    /// </summary>
+    internal static S7AuthorityRewrite? 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 changedNodes = new List<string>();
+        var paths = new List<string>();
+        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;
+            var nodeId = node?["id"]?.GetValue<string>() ?? string.Empty;
+            changedNodes.Add(nodeId);
+            paths.Add($"nodes[id={nodeId}].properties.approverIds: \"{ids}\" => \"{joined}\"");
+        }
+
+        if (changedNodes.Count == 0) return null;
+
+        return new S7AuthorityRewrite
+        {
+            After = root!.ToJsonString(),
+            ChangedNodeIds = changedNodes,
+            ChangedPathText = string.Join(" | ", paths),
+        };
+    }
+
+    private static string Md5(string s)
+    {
+        var bytes = MD5.HashData(Encoding.UTF8.GetBytes(s));
+        return Convert.ToHexString(bytes).ToLowerInvariant();
+    }
+}
+
+/// <summary>候选实例 + 其反查出的业务租户 + 快照中待迁的数字角色引用。</summary>
+public sealed class S7MigrationCandidate
+{
+    public ApprovalFlowInstance Instance { get; set; } = null!;
+    public long BusinessTenantId { get; set; }
+    public List<S7NumericRoleRef> NumericRefs { get; set; } = new();
+}
+
+/// <summary>快照中一个 Role 节点里的数字角色引用。</summary>
+public sealed class S7NumericRoleRef
+{
+    public string NodeId { get; set; } = string.Empty;
+    public string ApproverIds { get; set; } = string.Empty;
+    public List<string> NumericTokens { get; set; } = new();
+}
+
+/// <summary>
+/// 逐条评估结论:要么是一份「可应用的计划」,要么是「被哪道门禁挡下、为什么」。
+/// Dry-run 与 Apply 消费同一个对象,因此预演输出与实际改写在代码上无法分叉。
+/// </summary>
+public sealed class S7MigrationEvaluation
+{
+    public S7MigrationCandidate Candidate { get; set; } = null!;
+    public bool CanApply { get; set; }
+
+    public string Before { get; set; } = string.Empty;
+    public string BeforeMd5 { get; set; } = string.Empty;
+    /// <summary>仅 <see cref="CanApply"/> 为真时有值。</summary>
+    public string? After { get; set; }
+    public string? AfterMd5 { get; set; }
+
+    public List<string> ChangedNodeIds { get; set; } = new();
+    public string? ChangedPaths { get; set; }
+    public List<string> Mappings { get; set; } = new();
+    public string? RollbackSource { get; set; }
+
+    /// <summary>被挡下时的 outcome 码与原因;可应用时为 null。</summary>
+    public string? Outcome { get; set; }
+    public string? Reason { get; set; }
+}
+
+/// <summary>结构化改写结果:改后全文 + 被改动节点 + 可读路径。</summary>
+public sealed class S7AuthorityRewrite
+{
+    public string After { get; set; } = string.Empty;
+    public List<string> ChangedNodeIds { get; set; } = new();
+    public string ChangedPathText { get; set; } = string.Empty;
+}

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

@@ -240,6 +240,28 @@ public class Startup : AppStartup
             Trace.TraceError("Ai-DOP S6LegacyFlowAuthorityMigration FAILED(本次启动未完成存量快照迁移): " + ex);
         }
 
+        // S7-LEGACY-SNAPSHOT-MIGRATION-1:把批次边界内、仍在运行的 S7 实例快照里冻结的
+        // 默认租户物理 RoleId 迁成 RoleCode。批次边界之后发起的实例一律不碰 —— 那之后再出现
+        // 默认租户 RoleId 属于新的 authority regression,必须响亮失败而不是被这里静默治好。
+        // 迁完即幂等:判据是快照自身内容,第二次启动选不出候选、不发 UPDATE。
+        //
+        // 先 DryRun 再 Migrate:两者共用同一套谓词与门禁(EvaluateAsync),
+        // DryRun 只读不写,其逐条结论会落到应用日志,便于事后核对「实际改的就是预演过的」。
+        try
+        {
+            using var s7MigScope = app.ApplicationServices.CreateScope();
+            var s7MigDb = s7MigScope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
+            s7MigDb.CodeFirst.InitTables(typeof(Admin.NET.Plugin.AiDOP.Entity.S7.AdoS7FlowAuthorityMigrationLog));
+            var s7Mig = s7MigScope.ServiceProvider
+                .GetRequiredService<Admin.NET.Plugin.AiDOP.Service.S7.S7LegacyFlowAuthorityMigrationService>();
+            s7Mig.DryRunAsync().GetAwaiter().GetResult();
+            s7Mig.MigrateAsync().GetAwaiter().GetResult();   // 汇总与逐条结论由服务内 ILogger 输出
+        }
+        catch (Exception ex)
+        {
+            Trace.TraceError("Ai-DOP S7LegacyFlowAuthorityMigration FAILED(本次启动未完成存量快照迁移): " + ex);
+        }
+
         try
         {
             using var scopeS4 = app.ApplicationServices.CreateScope();