Parcourir la source

fix(s8): 修复 UAT 异常审核链,让提交复核后真的有人接得住

这是部署门禁修复,不是新功能。解决的只有一件事:当前租户提交复核之后,
到底有没有一个真实、合法、能操作的审核账号接住。

根因(本地取证,非推测):UAT 租户 3 条 PENDING_VERIFICATION 异常的
verifier_user_id 都是 UATQualityA,而对应的 EXCEPTION_CLOSURE 实例
Status=Running、tasks=0、异常上的 active_flow_instance_id 仍是 NULL。
节点配的是 approverType=Role, approverIds=1300000000101,1300000000888 ——
**别租户的角色 Id**;ResolveApprovers 末尾的 u.TenantId == effectiveTenantId
把成员全过滤掉 → 返回 0 人 → ProcessNextNode 抛「审批人列表为空」→
实例已落库但 OnFlowStarted 从未被调用 → 流程永久悬挂,而 S8 侧显示一切正常。
EXCEPTION_ESCALATION 同理:三个节点全部 SpecificUser=789819678195781(Demo02,
属默认租户),在 UAT 三个租户下同样解析不出人。

修复方式(最小改动,优先改配置不改引擎):新增一次性对账服务,按**该租户自己的
权限事实**派生审批人 —— 复核取该租户被授予 s8:verification:approve 的角色,
升级取 s8:exception:upgrade 的角色、没有则回落审核角色并在留证里写明回落原因。
派生结果写成 RoleCode 而非数字 Id:ResolveApprovers 的三个分支租户安全性并不一致,
只有 Role 的 code 分支带 SysRole.TenantId 校验。

**刻意不改 FlowEngine**:给 SpecificUser / Department 补租户谓词会外溢到
S1/S5/S6/S7 —— 真库实测 74 个 SpecificUser 节点里有大量跨租户配置
(CONTRACT_REVIEW / ORDER_CHANGE_REVIEW / MATERIAL_SHORTAGE 在三个租户里都指向
租户 797 的账号),一改就会把那些流程一起打空。那是另一个批次的事,
本批只修 S8 自己的两条链,该缺口作为跨模块风险登记在报告里。

判据是「该节点引用的每个对象是否都属于本租户」,不是「用了哪种 approverType」。
第一版按写法判(只认 RoleCode),把默认租户一条 SpecificUser 指向**本租户账号**的
正确配置也改写了 —— 那是把「这种写法在别的租户下不安全」错当成「这个配置错了」。
因此服务同时具备**按自己的留证回退**的能力:只撤自己写过、且按现在的判据本不该改的
那些节点。对账服务写下的东西自己就该能撤,否则一次判据失误会永久生效。

同时对齐 verifier 与审批任务:检验人必须持有「审核」动作权限,前端候选集也只列
这些账号。复核任务是按「被授予审核权限的角色」派发的;少了这条校验就会出现错位 ——
页面上选了甲,任务发给乙那一组,甲手里没有任务、乙又过不了 verifier_user_id 校验,
两边互相卡住且都不报错。

本地全链路验收:审批任务真实生成且 AssigneeId 与 verifier_user_id 完全一致;
审核账号在「我的待办」看得到该任务;Approve → 异常 CLOSED/APPROVED、实例 Approved、
任务 Approved、active_flow_instance_id 清空;Reject → 异常回到 IN_PROGRESS、
实例 Rejected;非检验人执行审批 403;无审核权限的账号不能被选为检验人;
默认租户原配置经自我纠错完整还原;再次启动 12 条 SKIPPED_ALREADY_OK(幂等)。
YY968XX il y a 2 jours
Parent
commit
660edb7538

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.388",
+	"version": "2.4.389",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 2 - 1
Web/src/views/aidop/s8/api/s8ExceptionApi.ts

@@ -112,7 +112,8 @@ export const s8ExceptionApi = {
 	 * S8 人员选择器:当前租户的启用系统账号。
 	 * 取代已退役的 master-data/employees —— 员工列表里绝大多数选项其实没绑账号、选了也用不了。
 	 */
-	users: () => service.get('/api/aidop/s8/master-data/users', { params: withAidopTenantFactoryParams({}) }).then(unwrap),
+	users: (requiredAction?: string) =>
+		service.get('/api/aidop/s8/master-data/users', { params: withAidopTenantFactoryParams(requiredAction ? { requiredAction } : {}) }).then(unwrap),
 	/** 认领:处理人恒为当前登录账号,请求体只带备注。 */
 	claim: (id: number, body: { remark?: string }) =>
 		service.post(`/api/aidop/s8/exceptions/${id}/claim`, withAidopTenantFactoryParams(body)).then(unwrap),

+ 29 - 2
Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue

@@ -165,6 +165,25 @@ async function loadUsers() {
 	users.value = (await s8ExceptionApi.users()) as typeof users.value;
 }
 
+/**
+ * S8-UAT-APPROVAL-REPAIR-1:检验人候选<b>只列具备「审核」权限的账号</b>。
+ *
+ * 复核审批任务是按「该租户被授予审核权限的角色」派发的;如果这里还能选到
+ * 没有审核权限的人,就会出现本地实测过的错位:页面上选了甲,审批任务却发给乙那一组,
+ * 甲手里没有任务、乙又过不了 S8 的 verifier_user_id 校验,两边互相卡住且都不报错。
+ */
+const verifierUsers = ref<{ id: number; name: string; account?: string }[]>([]);
+const verifierLoaded = ref(false);
+
+async function loadVerifierCandidates() {
+	verifierLoaded.value = false;
+	try {
+		verifierUsers.value = (await s8ExceptionApi.users('EXCEPTION_VERIFY')) as typeof verifierUsers.value;
+	} finally {
+		verifierLoaded.value = true;
+	}
+}
+
 function openAction(mode: string) {
 	dialogMode.value = mode;
 	dialogVisible.value = true;
@@ -292,6 +311,7 @@ function formatPerson(name: string | null | undefined, id: number | string | nul
 onMounted(async () => {
 	await loadDetail();
 	await loadUsers();
+	await loadVerifierCandidates();
 });
 </script>
 
@@ -509,11 +529,18 @@ onMounted(async () => {
 					</el-select>
 				</el-form-item>
 				<el-form-item v-if="dialogMode === 'submitVerification'" label="检验人" required>
-					<el-select v-model="actionForm.targetUserId" style="width: 100%" filterable clearable>
-						<el-option v-for="item in users" :key="item.id" :label="item.name" :value="item.id">
+					<el-select v-model="actionForm.targetUserId" style="width: 100%" filterable clearable
+						:loading="!verifierLoaded" placeholder="选择具备审核权限的账号">
+						<el-option v-for="item in verifierUsers" :key="item.id" :label="item.name" :value="item.id">
 							<span>{{ item.name }} <span style="color:#999;font-size:12px">{{ item.account ? '(' + item.account + ')' : '' }}</span></span>
 						</el-option>
 					</el-select>
+					<div v-if="verifierLoaded && verifierUsers.length === 0" style="color:#e6a23c;font-size:12px;line-height:1.9">
+						本租户没有任何账号具备「审核」权限,无法提交复核。请先在「异常操作权限」中为某个角色授予「审核」。
+					</div>
+					<div v-else style="color:#909399;font-size:12px;line-height:1.9">
+						只列出具备「审核」权限的账号 —— 复核审批任务正是按这些账号所属的角色派发的。
+					</div>
 				</el-form-item>
 				<el-form-item
 					:label="dialogMode === 'upgrade' ? '升级原因' : dialogMode === 'rejectVerification' ? '退回原因' : '备注'"

+ 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.501</AssemblyVersion>
-    <FileVersion>1.0.501</FileVersion>
-    <Version>1.0.501</Version>
+    <AssemblyVersion>1.0.502</AssemblyVersion>
+    <FileVersion>1.0.502</FileVersion>
+    <Version>1.0.502</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 270 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8ApprovalFlowRepairTests.cs

@@ -0,0 +1,270 @@
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-UAT-APPROVAL-REPAIR-1:审核链修复。
+///
+/// <para><b>修的是一个完整的静默悬挂</b>(本地取证,非推测):UAT 租户 3 条
+/// <c>PENDING_VERIFICATION</c> 异常的 <c>verifier_user_id</c> 都是 UATQualityA,
+/// 而对应 <c>EXCEPTION_CLOSURE</c> 实例 <b>Status=Running、tasks=0</b>、
+/// 异常上的 <c>active_flow_instance_id</c> 仍是 NULL —— 节点配的是别租户角色 Id,
+/// <c>ResolveApprovers</c> 返回 0 人 → <c>ProcessNextNode</c> 抛错 →
+/// <c>OnFlowStarted</c> 从未调用 → 实例永久悬挂,而 S8 侧显示一切正常。</para>
+/// </summary>
+public class S8ApprovalFlowRepairTests
+{
+    private static readonly string PluginRoot =
+        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    private static string CodeOnly(string relativePath)
+    {
+        var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+        return string.Join('\n', File.ReadAllLines(full)
+            .Where(l =>
+            {
+                var t = l.TrimStart();
+                return !t.StartsWith("///", StringComparison.Ordinal)
+                       && !t.StartsWith("//", StringComparison.Ordinal);
+            }));
+    }
+
+    // ══════════════════════════════════════════════════════════════════════
+    // 判据:什么算「已经租户安全」
+    // ══════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// T5 / T6:判据是<b>「该节点引用的每个对象是否都属于本租户」</b>,
+    /// 而不是「用了哪种 approverType 写法」。
+    ///
+    /// <para>第一版按写法判(只认 RoleCode),把默认租户一条
+    /// <c>SpecificUser</c> 指向<b>本租户账号</b>的正确配置也改写了 ——
+    /// 那是把「这种写法在别的租户下不安全」错当成「这个配置错了」。</para>
+    /// </summary>
+    [Theory]
+    // 本租户 RoleCode → 安全
+    [InlineData("Role", "ROLE_QC", true)]
+    [InlineData("Role", "ROLE_QC,ROLE_S8_CONFIG_ADMIN", true)]
+    // 本租户 RoleId(数字)→ 也安全:判据是「引用对象归不归本租户」,不是「用了哪种写法」
+    [InlineData("Role", "9001", true)]
+    // 跨租户数字 RoleId(正是坏掉的那个配置)→ 不安全
+    [InlineData("Role", "1300000000101,1300000000888", false)]
+    // 混入跨租户对象 → 整体不安全
+    [InlineData("Role", "ROLE_QC,1300000000101", false)]
+    [InlineData("Role", "ROLE_NOT_IN_TENANT", false)]
+    // SpecificUser 指向**本租户**账号 → 安全(第一版把它误判为不安全,改坏了默认租户的正确配置)
+    [InlineData("SpecificUser", "7001", true)]
+    // SpecificUser 指向别租户账号 → 不安全(escalation 的现状)
+    [InlineData("SpecificUser", "789819678195781", false)]
+    // Department 同理
+    [InlineData("Department", "8001", true)]
+    [InlineData("Department", "246", false)]
+    // Initiator 由运行时按发起人解析,天然不跨租户
+    [InlineData("Initiator", null, true)]
+    // 空配置 / 认不出的类型 → 不安全
+    [InlineData("Role", "", false)]
+    [InlineData("Role", null, false)]
+    [InlineData("WhoKnows", "1", false)]
+    public void TenantSafetyPredicate_JudgesByReferencedObjectOwnership(string type, string? ids, bool expected)
+    {
+        var scope = new S8ApprovalFlowRepairService.S8TenantApproverScope(
+            RoleCodes: new HashSet<string>(StringComparer.Ordinal) { "ROLE_QC", "ROLE_S8_CONFIG_ADMIN" },
+            RoleIds: new HashSet<long> { 9001 },
+            UserIds: new HashSet<long> { 7001 },
+            OrgIds: new HashSet<long> { 8001 });
+        Assert.Equal(expected, S8ApprovalFlowRepairService.IsAlreadyTenantSafe(type, ids, scope));
+    }
+
+    /// <summary>
+    /// T3:跨租户审批人配置必须被判为需要修复 —— 这正是 escalation 的现状
+    /// (三个节点全部 <c>SpecificUser=789819678195781</c>,Demo02 属默认租户)。
+    /// </summary>
+    [Fact]
+    public void T3_CrossTenantApprover_IsRejectedAsUnsafe()
+    {
+        var scope = new S8ApprovalFlowRepairService.S8TenantApproverScope(
+            RoleCodes: new HashSet<string>(StringComparer.Ordinal) { "ROLE_QC" },
+            RoleIds: new HashSet<long>(), UserIds: new HashSet<long>(), OrgIds: new HashSet<long>());
+        Assert.False(S8ApprovalFlowRepairService.IsAlreadyTenantSafe("SpecificUser", "789819678195781", scope));
+        Assert.False(S8ApprovalFlowRepairService.IsAlreadyTenantSafe("Role", "1300000000101,1300000000888", scope));
+    }
+
+    /// <summary>
+    /// 对账服务写下的改动必须能按自己的留证撤回。
+    ///
+    /// <para>判据本身也会出错:第一版把「SpecificUser 指向本租户账号」误判为不安全,
+    /// 于是改坏了默认租户一条本来正确的配置。没有回退能力,一次判据失误就永久生效。</para>
+    /// </summary>
+    [Fact]
+    public void Repair_CanUndoItsOwnWritesWhenPredicateChanges()
+    {
+        var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
+        Assert.Contains("RESTORED", code);
+        Assert.Contains("s8_flow_repair_restored", code);
+        Assert.Contains("priorLogs.TryGetValue((flow.Id, nodeName), out var prior)", code);
+        // 只撤自己写的:当前值必须与留证里的 after 完全一致才回退
+        Assert.Contains("string.Equals(prior.AfterIds, beforeIds, StringComparison.Ordinal)", code);
+        // 且回退目标必须按**现在的**判据是安全的
+        Assert.Contains("IsAlreadyTenantSafe(prior.BeforeType, prior.BeforeIds, tenantScope)", code);
+    }
+
+    // ══════════════════════════════════════════════════════════════════════
+    // 修复行为
+    // ══════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 派生依据必须是<b>该租户自己的权限事实</b>,不是硬编码角色名。
+    /// 升级流程没有专门角色时回落审核角色,且回落必须写进留证 ——
+    /// 否则将来没人知道升级审批为什么是质检在审。
+    /// </summary>
+    [Fact]
+    public void Repair_DerivesRolesFromTenantOwnPermissionGrants()
+    {
+        var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
+
+        Assert.Contains("S8PermissionCatalog.VerificationApprove", code);
+        Assert.Contains("S8PermissionCatalog.ExceptionUpgrade", code);
+        Assert.Contains("r.TenantId.Value", code);
+        Assert.Contains("回落使用审核角色", code);
+
+        // 不得出现硬编码的具体角色名
+        foreach (var hardcoded in new[] { "\"ROLE_QC\"", "\"UAT_FUNCTION_USER\"", "\"ROLE_S8_CONFIG_ADMIN\"" })
+            Assert.DoesNotContain(hardcoded, code);
+    }
+
+    /// <summary>
+    /// T4:推导不出角色时<b>不猜</b> —— 保持原样并大声报出来。
+    ///
+    /// <para>塞一个"看起来能用"的角色进去会让人以为审核链已经通了,
+    /// 那比留着坏配置更危险。</para>
+    /// </summary>
+    [Fact]
+    public void T4_NoDerivableRole_BlocksLoudlyInsteadOfGuessing()
+    {
+        var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
+        Assert.Contains("BLOCKED_NO_ROLE", code);
+        Assert.Contains("s8_flow_repair_blocked", code);
+        Assert.Contains("保持原样", code);
+    }
+
+    /// <summary>
+    /// 只修坏的,不碰好的;判据本身保证幂等(修好后第二次运行命中「已 OK」而跳过)。
+    ///
+    /// <para>每次启动无条件回写会覆盖管理员的手工配置 ——
+    /// 那是比配置错误更难发现的故障。</para>
+    /// </summary>
+    [Fact]
+    public void Repair_IsIdempotentAndDoesNotOverwriteGoodConfig()
+    {
+        var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
+        Assert.Contains("SKIPPED_ALREADY_OK", code);
+        Assert.Contains("IsAlreadyTenantSafe(beforeType, beforeIds, tenantScope)", code);
+        Assert.Contains("result.AlreadyOkCount++", code);
+    }
+
+    /// <summary>
+    /// 修复<b>只作用于 S8 自己的两条流程</b>,绝不波及 S1/S5/S6/S7。
+    ///
+    /// <para>真库实测 74 个 SpecificUser 节点里有大量跨租户配置
+    /// (CONTRACT_REVIEW / ORDER_CHANGE_REVIEW / MATERIAL_SHORTAGE 在三个租户里
+    /// 都指向租户 797 的账号)。本批不碰它们。</para>
+    /// </summary>
+    [Fact]
+    public void Repair_ScopeIsLimitedToS8FlowsOnly()
+    {
+        var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
+        Assert.Contains("f.BizType == ClosureBizType || f.BizType == EscalationBizType", code);
+        Assert.Equal("EXCEPTION_CLOSURE", S8ApprovalFlowRepairService.ClosureBizType);
+        Assert.Equal("EXCEPTION_ESCALATION", S8ApprovalFlowRepairService.EscalationBizType);
+
+        foreach (var other in new[] { "CONTRACT_REVIEW", "ORDER_CHANGE_REVIEW", "MATERIAL_SHORTAGE", "ORDER_REVIEW" })
+            Assert.DoesNotContain(other, code);
+    }
+
+    /// <summary>
+    /// <b>不改 FlowEngine</b>:给 SpecificUser / Department 补租户谓词会外溢到其它模块,
+    /// 属另一个批次。本批以「只用 RoleCode 形态」绕开该缺陷。
+    /// </summary>
+    [Fact]
+    public void FlowEngine_IsNotModifiedByThisBatch()
+    {
+        var engine = Path.GetFullPath(Path.Combine(
+            PluginRoot, "../Admin.NET.Plugin.ApprovalFlow/Service/FlowEngine/FlowEngineService.cs"));
+        Assert.True(File.Exists(engine));
+        var src = File.ReadAllText(engine);
+
+        // Role 的 code 分支本来就有租户校验 —— 这正是本批选择 RoleCode 形态的依据
+        Assert.Contains("r.TenantId == effectiveTenantId", src);
+        // 本批没有往里加 S8 专属逻辑
+        Assert.DoesNotContain("S8-UAT-APPROVAL-REPAIR", src);
+    }
+
+    /// <summary>修复必须留证:改前 / 改后 / 原因 / 结论,缺一不可。</summary>
+    [Fact]
+    public void Repair_LeavesAuditTrail()
+    {
+        var t = typeof(AdoS8ApprovalFlowRepairLog);
+        foreach (var p in new[] { "TenantId", "BizType", "NodeName", "BeforeType", "BeforeIds", "AfterType", "AfterIds", "Reason", "Outcome" })
+            Assert.NotNull(t.GetProperty(p));
+
+        var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
+        Assert.Contains("REPAIRED", code);
+    }
+
+    // ══════════════════════════════════════════════════════════════════════
+    // verifier 与审批任务的一致性(§10)
+    // ══════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// T7 / T10 / T11:检验人必须具备「审核」动作权限。
+    ///
+    /// <para>这条校验保证「UI 能选到的人」⊆「审批任务的收件人」:
+    /// 复核节点按「被授予 s8:verification:approve 的角色」派发,
+    /// 而检验人也必须持有同一能力。少了它就会出现错位 ——
+    /// 页面上选了甲,任务发给乙那一组,甲手里没有任务、乙又过不了
+    /// verifier_user_id 校验,两边互相卡住且都不报错。</para>
+    /// </summary>
+    [Fact]
+    public void T7_T10_T11_Verifier_MustHoldVerifyPermission()
+    {
+        var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
+        Assert.Contains("_actionAuthorizer.AuthorizeAsync(tenantId, verifier.UserId, S8ExceptionActionCode.Verify)", code);
+        Assert.Contains("没有审核权限,不能作为检验人", code);
+
+        // verifier_user_id 仍然只装 SysUser.Id
+        Assert.Contains("e.VerifierUserId = verifier.UserId;", code);
+        Assert.DoesNotContain("EmployeeMaster", code.Replace("EmployeeMaster.Department", string.Empty));
+    }
+
+    /// <summary>前端候选集必须与后端校验同源,不能让用户选到一个提交就被拒的账号。</summary>
+    [Fact]
+    public void VerifierPicker_UsesSameSourceAsBackendCheck()
+    {
+        var page = File.ReadAllText(Path.GetFullPath(Path.Combine(
+            PluginRoot, "../../../Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue")));
+
+        Assert.Contains("s8ExceptionApi.users('EXCEPTION_VERIFY')", page);
+        Assert.Contains("verifierUsers", page);
+        // 没有可审核账号时必须显式提示,而不是给一个空下拉
+        Assert.Contains("本租户没有任何账号具备「审核」权限", page);
+
+        var adapter = CodeOnly("Service/S8/S8MasterDataAdapter.cs");
+        Assert.Contains("requiredAction", adapter);
+        Assert.Contains("_actionAuthorizer.AuthorizeAsync(scope.TenantId, u.UserId, requiredAction!)", adapter);
+    }
+
+    /// <summary>T12 / T13:修复链路上没有 Employee、没有 Factory。</summary>
+    [Theory]
+    [InlineData("Service/S8/S8ApprovalFlowRepairService.cs")]
+    [InlineData("Service/S8/S8MasterDataAdapter.cs")]
+    public void T12_T13_NoEmployeeNoFactory(string relativePath)
+    {
+        var code = CodeOnly(relativePath);
+        Assert.DoesNotContain("AdoS0EmployeeMaster", code);
+        Assert.DoesNotContain("FactoryRefId", code);
+    }
+}

+ 2 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8MasterDataController.cs

@@ -40,9 +40,9 @@ public class AdoS8MasterDataController : ControllerBase
     /// <summary>S8 人员选择器:当前租户的启用系统账号。</summary>
     [HttpGet("users")]
     [S8Permission(S8PermissionCatalog.ExceptionRead)]
-    public async Task<IActionResult> UsersAsync()
+    public async Task<IActionResult> UsersAsync([FromQuery] string? requiredAction = null)
     {
-        try { return Ok(await _svc.GetOperatorUsersAsync(await _scope.ResolveAsync())); }
+        try { return Ok(await _svc.GetOperatorUsersAsync(await _scope.ResolveAsync(), requiredAction)); }
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
 

+ 52 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8ApprovalFlowRepairLog.cs

@@ -0,0 +1,52 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+/// <summary>
+/// S8-UAT-APPROVAL-REPAIR-1:S8 审批流审批人配置修复留证。
+///
+/// <para><b>为什么必须留证</b>:这次改的是<b>用户可见的流程配置</b>。明天有人问
+/// 「我的审批人怎么变了」,必须能当场答出改前是什么、为什么改、依据是哪条权限事实。
+/// 没有这张表,这就是一次无法解释的静默改写。</para>
+/// </summary>
+[SugarTable("ado_s8_approval_flow_repair_log", "S8 审批流审批人修复留证")]
+public class AdoS8ApprovalFlowRepairLog
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "flow_id", ColumnDataType = "bigint")]
+    public long FlowId { get; set; }
+
+    [SugarColumn(ColumnName = "biz_type", Length = 64)]
+    public string BizType { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "node_name", Length = 128)]
+    public string NodeName { get; set; } = string.Empty;
+
+    /// <summary>改前的 approverType。</summary>
+    [SugarColumn(ColumnName = "before_type", Length = 32, IsNullable = true)]
+    public string? BeforeType { get; set; }
+
+    /// <summary>改前的 approverIds,逐字保留。</summary>
+    [SugarColumn(ColumnName = "before_ids", Length = 512, IsNullable = true)]
+    public string? BeforeIds { get; set; }
+
+    [SugarColumn(ColumnName = "after_type", Length = 32, IsNullable = true)]
+    public string? AfterType { get; set; }
+
+    [SugarColumn(ColumnName = "after_ids", Length = 512, IsNullable = true)]
+    public string? AfterIds { get; set; }
+
+    /// <summary>为什么改:判据 + 派生依据。</summary>
+    [SugarColumn(ColumnName = "reason", Length = 512, IsNullable = true)]
+    public string? Reason { get; set; }
+
+    /// <summary>REPAIRED / SKIPPED_ALREADY_OK / BLOCKED_NO_ROLE。</summary>
+    [SugarColumn(ColumnName = "outcome", Length = 32)]
+    public string Outcome { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "repaired_at")]
+    public DateTime RepairedAt { get; set; } = DateTime.Now;
+}

+ 349 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ApprovalFlowRepairService.cs

@@ -0,0 +1,349 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>修复结果。分项计数,运维要能看出「这次到底动了什么」。</summary>
+public sealed class S8ApprovalFlowRepairResult
+{
+    public int FlowsInspected { get; set; }
+    public int NodesInspected { get; set; }
+    /// <summary>已经指向本租户角色、无需修改的节点。</summary>
+    public int AlreadyOkCount { get; set; }
+    /// <summary>本次改写的节点。</summary>
+    public int RepairedCount { get; set; }
+    /// <summary>该租户推导不出任何审核角色,无法修复 —— 必须人工处理。</summary>
+    public int BlockedCount { get; set; }
+
+    /// <summary>
+    /// 本服务此前改过、但按<b>现在的判据</b>本不该改的节点,已按留证还原。
+    /// <para>对账服务自己写下的东西,自己就该能撤回 —— 否则一次判据失误就变成永久损坏。</para>
+    /// </summary>
+    public int RestoredCount { get; set; }
+}
+
+/// <summary>
+/// S8-UAT-APPROVAL-REPAIR-1:修复 S8 两条审批流的审批人配置,让提交复核之后
+/// <b>真的有一个本租户账号接得住</b>。
+///
+/// <para><b>本地取证到的失败形态</b>(这不是推测):UAT 租户 3 条
+/// <c>PENDING_VERIFICATION</c> 异常的 <c>verifier_user_id</c> 都是 UATQualityA,
+/// 而对应的 <c>EXCEPTION_CLOSURE</c> 实例 <b>Status=Running、tasks=0</b>、
+/// 异常上的 <c>active_flow_instance_id</c> 还是 NULL。原因是节点配的
+/// <c>approverType=Role, approverIds=1300000000101,1300000000888</c> 是<b>别租户的角色 Id</b>,
+/// <c>ResolveApprovers</c> 末尾的 <c>u.TenantId == effectiveTenantId</c> 把成员全过滤掉 →
+/// 返回 0 人 → <c>ProcessNextNode</c> 抛「审批人列表为空」→ 实例已落库但
+/// <c>OnFlowStarted</c> 从未被调用 → 流程永久悬挂,而 S8 侧一切显示正常。</para>
+///
+/// <para><b>为什么改成 RoleCode 而不是 SpecificUser / 数字 RoleId</b>(以代码为证):
+/// <c>ResolveApprovers</c> 的三个分支租户安全性并不一致 ——
+/// <list type="bullet">
+///   <item><c>Role</c> + <b>非数字 token</b>:按 <c>SysRole.Code</c> 查且带
+///         <c>r.TenantId == effectiveTenantId</c>,<b>天然租户安全</b>;</item>
+///   <item><c>Role</c> + 数字 token:直接当 RoleId 用,<b>不校验角色租户</b>
+///         (只靠末尾的用户租户过滤兜底,结果是解析为空而非报错);</item>
+///   <item><c>SpecificUser</c> / <c>Department</c>:<b>完全没有显式租户谓词</b>,
+///         行为取决于全局 multi-tenant AOP 的当前状态。</item>
+/// </list>
+/// 因此只有 RoleCode 形式能在不改 FlowEngine 的前提下保证租户正确。</para>
+///
+/// <para><b>刻意不改 FlowEngine</b>:给 <c>SpecificUser</c> / <c>Department</c> 补租户谓词
+/// 会外溢到 S1/S5/S6/S7 —— 真库实测 74 个 SpecificUser 节点里有大量跨租户配置
+/// (<c>CONTRACT_REVIEW</c> / <c>ORDER_CHANGE_REVIEW</c> / <c>MATERIAL_SHORTAGE</c>
+/// 在三个租户里都指向租户 797 的 AIDopAdmin),一改就会把那些流程一起打空。
+/// 那是另一个批次的事,本批只修 S8 自己的两条链,并把该缺口如实登记为跨模块风险。</para>
+///
+/// <para><b>只修坏的,不碰好的</b>:判据是「该节点在本租户解析不出任何审批人,
+/// 或引用了非本租户对象」。已经指向本租户角色的节点一律跳过 ——
+/// 每次启动无条件回写会覆盖管理员的手工配置,那是比配置错误更难发现的故障。
+/// 判据本身也保证了幂等:修好之后第二次运行就会命中「已 OK」而跳过。</para>
+/// </summary>
+public class S8ApprovalFlowRepairService : ITransient
+{
+    /// <summary>复核流程。与 <c>S8TaskFlowService.TryStartVerificationFlowAsync</c> 一致。</summary>
+    internal const string ClosureBizType = "EXCEPTION_CLOSURE";
+
+    /// <summary>升级流程。与 <c>S8TaskFlowService.UpgradeAsync</c> 一致。</summary>
+    internal const string EscalationBizType = "EXCEPTION_ESCALATION";
+
+    private readonly SqlSugarRepository<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow> _flowRep;
+    private readonly SqlSugarRepository<AdoS8ApprovalFlowRepairLog> _logRep;
+    private readonly ILogger<S8ApprovalFlowRepairService> _logger;
+
+    public S8ApprovalFlowRepairService(
+        SqlSugarRepository<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow> flowRep,
+        SqlSugarRepository<AdoS8ApprovalFlowRepairLog> logRep,
+        ILogger<S8ApprovalFlowRepairService> logger)
+    {
+        _flowRep = flowRep;
+        _logRep = logRep;
+        _logger = logger;
+    }
+
+    public async Task<S8ApprovalFlowRepairResult> RepairAsync(CancellationToken ct = default)
+    {
+        var result = new S8ApprovalFlowRepairResult();
+
+        var flows = await _flowRep.AsQueryable().ClearFilter()
+            .Where(f => (f.BizType == ClosureBizType || f.BizType == EscalationBizType)
+                        && !f.IsDelete && f.TenantId != null)
+            .ToListAsync(ct);
+
+        result.FlowsInspected = flows.Count;
+        if (flows.Count == 0) return result;
+
+        var tenantIds = flows.Select(f => f.TenantId!.Value).Distinct().ToList();
+        var verifyRoles = await RoleCodesByPermissionAsync(tenantIds, S8PermissionCatalog.VerificationApprove, ct);
+        var upgradeRoles = await RoleCodesByPermissionAsync(tenantIds, S8PermissionCatalog.ExceptionUpgrade, ct);
+
+        // 先读上一轮留证:本服务写下的改动,必须能按现在的判据重新审视并撤回。
+        // 判据本身也会出错(第一版把「SpecificUser 指向本租户账号」误判为不安全,
+        // 改坏了默认租户一条本来正确的配置),没有回退能力就等于一次失误永久生效。
+        var priorLogs = (await _logRep.AsQueryable().ClearFilter()
+                .Where(x => x.Outcome == "REPAIRED")
+                .ToListAsync(ct))
+            .GroupBy(x => (x.FlowId, x.NodeName))
+            .ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.Id).First());
+
+        var logs = new List<AdoS8ApprovalFlowRepairLog>();
+        var now = DateTime.Now;
+
+        foreach (var flow in flows)
+        {
+            ct.ThrowIfCancellationRequested();
+            var tenantId = flow.TenantId!.Value;
+
+            // 升级流程优先用该租户自己的「升级」角色;没有就回落「审核」角色。
+            // 回落必须在留证里写清楚 —— 否则将来没人知道升级审批为什么是质检在审。
+            var isEscalation = flow.BizType == EscalationBizType;
+            var wanted = isEscalation
+                ? (upgradeRoles.GetValueOrDefault(tenantId) ?? verifyRoles.GetValueOrDefault(tenantId))
+                : verifyRoles.GetValueOrDefault(tenantId);
+
+            var fellBack = isEscalation
+                            && (upgradeRoles.GetValueOrDefault(tenantId)?.Count ?? 0) == 0
+                            && (wanted?.Count ?? 0) > 0;
+
+            var tenantScope = await TenantScopeAsync(tenantId, ct);
+
+            JsonNode? root;
+            try { root = JsonNode.Parse(flow.FlowJson ?? string.Empty); }
+            catch (Exception ex)
+            {
+                _logger.LogWarning(ex, "s8_flow_repair_parse_failed flowId={FlowId} bizType={BizType}", flow.Id, flow.BizType);
+                continue;
+            }
+            if (root?["nodes"] is not JsonArray nodes) continue;
+
+            var changed = false;
+            foreach (var node in nodes.OfType<JsonObject>())
+            {
+                if (node["properties"] is not JsonObject props) continue;
+                if (props["approverType"] is null) continue;
+
+                result.NodesInspected++;
+
+                var nodeName = props["nodeName"]?.GetValue<string>() ?? "审批节点";
+                var beforeType = props["approverType"]?.ToString();
+                var beforeIds = props["approverIds"]?.ToString();
+
+                // ── 自我纠错:本服务写下的改动,按现在的判据重新审视一次 ──
+                if (priorLogs.TryGetValue((flow.Id, nodeName), out var prior)
+                    && string.Equals(prior.AfterType, beforeType, StringComparison.Ordinal)
+                    && string.Equals(prior.AfterIds, beforeIds, StringComparison.Ordinal)
+                    && IsAlreadyTenantSafe(prior.BeforeType, prior.BeforeIds, tenantScope))
+                {
+                    props["approverType"] = prior.BeforeType;
+                    props["approverIds"] = prior.BeforeIds;
+                    props["approverNames"] = prior.BeforeIds;
+                    changed = true;
+                    result.RestoredCount++;
+                    logs.Add(Log(tenantId, flow, nodeName, beforeType, beforeIds,
+                        prior.BeforeType, prior.BeforeIds,
+                        "上一轮判据把「指向本租户账号的 SpecificUser」误判为不安全;按留证还原原配置",
+                        "RESTORED", now));
+                    _logger.LogWarning(
+                        "s8_flow_repair_restored tenant={Tenant} bizType={BizType} node={Node};"
+                        + "还原为 {Type}:{Ids}",
+                        tenantId, flow.BizType, nodeName, prior.BeforeType, prior.BeforeIds);
+                    continue;
+                }
+
+                // 引用的对象全部属于本租户 → 不碰。
+                if (IsAlreadyTenantSafe(beforeType, beforeIds, tenantScope))
+                {
+                    result.AlreadyOkCount++;
+                    logs.Add(Log(tenantId, flow, nodeName, beforeType, beforeIds, beforeType, beforeIds,
+                        "所引用的对象全部属于本租户,未修改", "SKIPPED_ALREADY_OK", now));
+                    continue;
+                }
+
+                if (wanted == null || wanted.Count == 0)
+                {
+                    // 推导不出角色就<b>不猜</b>:宁可留着坏配置并大声报出来,
+                    // 也不要塞一个"看起来能用"的角色进去 —— 后者会让人以为审核链已经通了。
+                    result.BlockedCount++;
+                    logs.Add(Log(tenantId, flow, nodeName, beforeType, beforeIds, beforeType, beforeIds,
+                        $"本租户没有任何角色被授予 {(isEscalation ? S8PermissionCatalog.ExceptionUpgrade : S8PermissionCatalog.VerificationApprove)},无法派生审批人,保持原样",
+                        "BLOCKED_NO_ROLE", now));
+                    _logger.LogWarning(
+                        "s8_flow_repair_blocked tenant={Tenant} bizType={BizType} node={Node};"
+                        + "该租户推导不出审核角色,审批人仍为空,需人工配置",
+                        tenantId, flow.BizType, nodeName);
+                    continue;
+                }
+
+                var afterIds = string.Join(',', wanted);
+                props["approverType"] = nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.Role);
+                props["approverIds"] = afterIds;
+                // approverNames 只是设计器的显示文案;同步成角色编码,避免页面继续显示
+                // 「系统管理员」这种早已不成立的名字。
+                props["approverNames"] = afterIds;
+                changed = true;
+                result.RepairedCount++;
+
+                var reason = $"原配置在本租户解析不出任何审批人({beforeType}:{beforeIds});"
+                           + $"改为本租户 RoleCode(ResolveApprovers 的 code 分支带 SysRole.TenantId 校验)"
+                           + (fellBack ? ";本租户无专门的升级审批角色,回落使用审核角色" : string.Empty);
+                logs.Add(Log(tenantId, flow, nodeName, beforeType, beforeIds,
+                    nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.Role), afterIds, reason, "REPAIRED", now));
+            }
+
+            if (!changed) continue;
+
+            await _flowRep.Context.Updateable<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow>()
+                .SetColumns(f => new Admin.NET.Plugin.ApprovalFlow.ApprovalFlow { FlowJson = root.ToJsonString() })
+                .Where(f => f.Id == flow.Id)
+                .ExecuteCommandAsync(ct);
+        }
+
+        if (logs.Count > 0)
+        {
+            // 留证表每次全量重写:它描述的是「最近一次对账看到了什么」,
+            // 累积历史只会让人分不清哪一行是当前事实。
+            await _logRep.AsDeleteable().Where(x => true).ExecuteCommandAsync();
+            await _logRep.AsInsertable(logs).ExecuteCommandAsync();
+        }
+
+        _logger.LogInformation(
+            "s8_flow_repair_done flows={Flows} nodes={Nodes} repaired={Repaired} restored={Restored} alreadyOk={Ok} blocked={Blocked}",
+            result.FlowsInspected, result.NodesInspected, result.RepairedCount, result.RestoredCount,
+            result.AlreadyOkCount, result.BlockedCount);
+
+        return result;
+    }
+
+    // ────────────────────────────────────────────────────────────────
+
+    /// <summary>某个租户里可被审批人配置引用的对象集合。</summary>
+    internal sealed record S8TenantApproverScope(
+        IReadOnlySet<string> RoleCodes,
+        IReadOnlySet<long> RoleIds,
+        IReadOnlySet<long> UserIds,
+        IReadOnlySet<long> OrgIds);
+
+    /// <summary>
+    /// 「已经租户安全」的判据:<b>该节点引用的每一个对象都属于本租户</b>。
+    ///
+    /// <para><b>判据是"引用对象的归属",不是"用了哪种 approverType"。</b>
+    /// 第一版把所有 <c>SpecificUser</c> 一律判为不安全,结果把默认租户一条<b>本来正确</b>
+    /// 的配置(<c>SpecificUser=789819678195781</c>,该账号正属于默认租户)也改写了 ——
+    /// 那是把"这种写法在别的租户下不安全"错当成"这个配置错了"。
+    /// 修复服务只该动<b>真的指向别人</b>的配置。</para>
+    ///
+    /// <para><c>Initiator</c> 由运行时按发起人解析,天然不跨租户,一律视为安全。</para>
+    /// </summary>
+    internal static bool IsAlreadyTenantSafe(string? approverType, string? approverIds, S8TenantApproverScope scope)
+    {
+        if (string.IsNullOrWhiteSpace(approverType)) return false;
+
+        if (string.Equals(approverType, nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.Initiator), StringComparison.Ordinal))
+            return true;
+
+        if (string.IsNullOrWhiteSpace(approverIds)) return false;
+        var tokens = approverIds.Split(',', StringSplitOptions.RemoveEmptyEntries)
+            .Select(t => t.Trim()).Where(t => t.Length > 0).ToList();
+        if (tokens.Count == 0) return false;
+
+        if (string.Equals(approverType, nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.Role), StringComparison.Ordinal))
+            return tokens.All(t => long.TryParse(t, out var id)
+                ? scope.RoleIds.Contains(id)
+                : scope.RoleCodes.Contains(t));
+
+        if (string.Equals(approverType, nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.SpecificUser), StringComparison.Ordinal))
+            return tokens.All(t => long.TryParse(t, out var id) && scope.UserIds.Contains(id));
+
+        if (string.Equals(approverType, nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.Department), StringComparison.Ordinal))
+            return tokens.All(t => long.TryParse(t, out var id) && scope.OrgIds.Contains(id));
+
+        // 认不出的类型不敢断言安全,但也不改 —— 交给下面的 BLOCKED 分支大声报出来。
+        return false;
+    }
+
+    /// <summary>某能力码在各租户下由哪些<b>本租户</b>角色持有(返回 RoleCode)。</summary>
+    private async Task<Dictionary<long, List<string>>> RoleCodesByPermissionAsync(
+        List<long> tenantIds, string permission, CancellationToken ct)
+    {
+        var rows = await _flowRep.Context.Queryable<SysRole>().ClearFilter()
+            .InnerJoin<SysRoleMenu>((r, rm) => r.Id == rm.RoleId)
+            .InnerJoin<SysMenu>((r, rm, m) => rm.MenuId == m.Id)
+            .Where((r, rm, m) => r.TenantId != null
+                                 && tenantIds.Contains(r.TenantId.Value)
+                                 && r.Status == StatusEnum.Enable
+                                 && m.Type == MenuTypeEnum.Btn
+                                 && m.Permission == permission
+                                 && r.Code != null && r.Code != "")
+            .Select((r, rm, m) => new { TenantId = r.TenantId!.Value, r.Code })
+            .ToListAsync(ct);
+
+        return rows
+            .GroupBy(x => x.TenantId)
+            .ToDictionary(g => g.Key, g => g.Select(x => x.Code!).Distinct().OrderBy(x => x, StringComparer.Ordinal).ToList());
+    }
+
+    private async Task<S8TenantApproverScope> TenantScopeAsync(long tenantId, CancellationToken ct)
+    {
+        var roles = await _flowRep.Context.Queryable<SysRole>().ClearFilter()
+            .Where(r => r.TenantId == tenantId)
+            .Select(r => new { r.Id, r.Code })
+            .ToListAsync(ct);
+
+        var userIds = await _flowRep.Context.Queryable<SysUser>().ClearFilter()
+            .Where(u => u.TenantId == tenantId)
+            .Select(u => u.Id)
+            .ToListAsync(ct);
+
+        var orgIds = await _flowRep.Context.Queryable<SysOrg>().ClearFilter()
+            .Where(o => o.TenantId == tenantId)
+            .Select(o => o.Id)
+            .ToListAsync(ct);
+
+        return new S8TenantApproverScope(
+            roles.Where(r => !string.IsNullOrWhiteSpace(r.Code)).Select(r => r.Code!).ToHashSet(StringComparer.Ordinal),
+            roles.Select(r => r.Id).ToHashSet(),
+            userIds.ToHashSet(),
+            orgIds.ToHashSet());
+    }
+
+    private static AdoS8ApprovalFlowRepairLog Log(
+        long tenantId, Admin.NET.Plugin.ApprovalFlow.ApprovalFlow flow, string nodeName,
+        string? beforeType, string? beforeIds, string? afterType, string? afterIds,
+        string reason, string outcome, DateTime now) => new()
+    {
+        TenantId = tenantId,
+        FlowId = flow.Id,
+        BizType = flow.BizType ?? string.Empty,
+        NodeName = nodeName,
+        BeforeType = beforeType,
+        BeforeIds = beforeIds,
+        AfterType = afterType,
+        AfterIds = afterIds,
+        Reason = reason,
+        Outcome = outcome,
+        RepairedAt = now
+    };
+}

+ 22 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8MasterDataAdapter.cs

@@ -21,17 +21,20 @@ public class S8MasterDataAdapter : ITransient
     private readonly SqlSugarRepository<AdoS0LineMaster> _lineRep;
     private readonly SqlSugarRepository<SysUser> _sysUserRep;
     private readonly IS8UserScopeValidator _userScope;
+    private readonly IS8ExceptionActionAuthorizer _actionAuthorizer;
 
     public S8MasterDataAdapter(
         SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
         SqlSugarRepository<AdoS0LineMaster> lineRep,
         SqlSugarRepository<SysUser> sysUserRep,
-        IS8UserScopeValidator userScope)
+        IS8UserScopeValidator userScope,
+        IS8ExceptionActionAuthorizer actionAuthorizer)
     {
         _deptRep = deptRep;
         _lineRep = lineRep;
         _sysUserRep = sysUserRep;
         _userScope = userScope;
+        _actionAuthorizer = actionAuthorizer;
     }
 
     /// <summary>
@@ -65,7 +68,13 @@ public class S8MasterDataAdapter : ITransient
     /// <para>边界只有租户,与 <see cref="GetDepartmentsAsync"/> 同因:
     /// <c>factory_ref_id = 1000</c> 横跨两个租户,工厂从来不是隔离维度。</para>
     /// </summary>
-    public async Task<object> GetOperatorUsersAsync(S8TrustedScope scope)
+    /// <param name="requiredAction">
+    /// S8-UAT-APPROVAL-REPAIR-1:只列具备该动作权限的账号(如挑选检验人时传
+    /// <c>EXCEPTION_VERIFY</c>)。为空则返回全部启用账号。
+    /// <para>存在的意义是让「页面上能选到的人」与「后端会放行的人」一致 ——
+    /// 否则用户会选中一个看起来正常、提交却被拒的账号。</para>
+    /// </param>
+    public async Task<object> GetOperatorUsersAsync(S8TrustedScope scope, string? requiredAction = null)
     {
         var users = await _userScope.ValidateUsersAsync(
             scope.TenantId,
@@ -75,6 +84,17 @@ public class S8MasterDataAdapter : ITransient
                 .Select(u => u.Id)
                 .ToListAsync());
 
+        if (!string.IsNullOrWhiteSpace(requiredAction))
+        {
+            var allowed = new List<S8OperatorUser>();
+            foreach (var u in users)
+            {
+                var auth = await _actionAuthorizer.AuthorizeAsync(scope.TenantId, u.UserId, requiredAction!);
+                if (auth.Allowed) allowed.Add(u);
+            }
+            users = allowed;
+        }
+
         return users
             .OrderBy(u => u.DisplayName, StringComparer.Ordinal)
             .Select(u => new { id = u.UserId, name = u.DisplayName, account = u.Account })

+ 15 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs

@@ -14,6 +14,7 @@ public class S8TaskFlowService : ITransient
     private readonly SqlSugarRepository<AdoS8ExceptionTimeline> _timelineRep;
     private readonly IS8UserScopeValidator _userScope;
     private readonly IS8ClaimEligibility _claimEligibility;
+    private readonly IS8ExceptionActionAuthorizer _actionAuthorizer;
     private readonly IS8RuleHandlerPoolReader _handlerPool;
     private readonly SqlSugarRepository<ApprovalFlowInstance> _flowInstanceRep;
     private readonly SqlSugarRepository<ApprovalFlowTask> _flowTaskRep;
@@ -28,6 +29,7 @@ public class S8TaskFlowService : ITransient
         SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep,
         IS8UserScopeValidator userScope,
         IS8ClaimEligibility claimEligibility,
+        IS8ExceptionActionAuthorizer actionAuthorizer,
         IS8RuleHandlerPoolReader handlerPool,
         SqlSugarRepository<ApprovalFlowInstance> flowInstanceRep,
         SqlSugarRepository<ApprovalFlowTask> flowTaskRep,
@@ -41,6 +43,7 @@ public class S8TaskFlowService : ITransient
         _timelineRep = timelineRep;
         _userScope = userScope;
         _claimEligibility = claimEligibility;
+        _actionAuthorizer = actionAuthorizer;
         _handlerPool = handlerPool;
         _flowInstanceRep = flowInstanceRep;
         _flowTaskRep = flowTaskRep;
@@ -375,6 +378,18 @@ public class S8TaskFlowService : ITransient
         var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在");
         EnsureCurrentUserIsOperator(e.AssigneeUserId, currentUserId, "只有当前处理人才能提交复检");
         var verifier = await _userScope.EnsureActiveUserAsync(tenantId, verifierUserId, "检验人");
+
+        // S8-UAT-APPROVAL-REPAIR-1:检验人必须具备「审核」动作权限。
+        //
+        // 这不是新加一道门,而是把两条本来就该一致的链对齐:审批流的复核节点按
+        // 「该租户被授予 s8:verification:approve 的角色」派发任务,而这里选出来的人
+        // 要真的能点「检验通过 / 检验退回」。少了这条校验就会出现本地实测过的错位形态 ——
+        // 页面上选了甲当检验人,审批任务却发给乙那一组,甲手里没有任务、乙又过不了
+        // S8 自己的 verifier_user_id 校验,两边互相卡住且都不报错。
+        var verifierAuth = await _actionAuthorizer.AuthorizeAsync(tenantId, verifier.UserId, S8ExceptionActionCode.Verify);
+        if (!verifierAuth.Allowed)
+            throw new S8BizException(
+                $"{verifier.DisplayName} 没有审核权限,不能作为检验人。请在「异常操作权限」中为其角色授予「审核」,或改选其他账号。");
         if (!S8StatusRules.IsAllowedTransition(e.Status, "PENDING_VERIFICATION"))
             throw new S8BizException($"状态 {e.Status} 不可提交复检");
 

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

@@ -96,6 +96,9 @@ public class Startup : AppStartup
 
         // S8-ACTION-PERMISSION-1:异常动作授权矩阵的首次供给(同 S8RuleProvisioningService,无接口需显式登记)。
         services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.S8ExceptionActionRoleProvisioningService>();
+
+        // S8-UAT-APPROVAL-REPAIR-1:S8 审批流审批人配置一次性对账(只修解析为 0 人的节点)。
+        services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.S8ApprovalFlowRepairService>();
     }
 
     /// <summary>
@@ -166,6 +169,29 @@ public class Startup : AppStartup
             Trace.TraceError("Ai-DOP S8ActionRoleProvisioning FAILED(本次启动未完成动作授权供给): " + ex);
         }
 
+        // S8-UAT-APPROVAL-REPAIR-1:修复 S8 审批流里解析不出本租户审批人的节点。
+        //
+        // 必须排在动作授权供给**之后** —— 它按「该租户被授予 s8:verification:approve 的角色」
+        // 派生审批人,而那份授权正是上一步供给出来的。
+        //
+        // 失败策略同上:记录后放行。审批人配置坏了的后果是流程悬挂(已经在发生),
+        // 不该再因为修复失败把整个平台拖下线。
+        try
+        {
+            using var repairScope = app.ApplicationServices.CreateScope();
+            var repair = repairScope.ServiceProvider
+                .GetRequiredService<Admin.NET.Plugin.AiDOP.Service.S8.S8ApprovalFlowRepairService>();
+            var repairSummary = repair.RepairAsync().GetAwaiter().GetResult();
+            Trace.TraceInformation(
+                $"Ai-DOP S8ApprovalFlowRepair: flows={repairSummary.FlowsInspected} nodes={repairSummary.NodesInspected} "
+                + $"repaired={repairSummary.RepairedCount} restored={repairSummary.RestoredCount} "
+                + $"alreadyOk={repairSummary.AlreadyOkCount} blocked={repairSummary.BlockedCount}");
+        }
+        catch (Exception ex)
+        {
+            Trace.TraceError("Ai-DOP S8ApprovalFlowRepair FAILED(本次启动未完成审批人配置对账): " + ex);
+        }
+
         try
         {
             using var scopeS4 = app.ApplicationServices.CreateScope();
@@ -258,6 +284,8 @@ public class Startup : AppStartup
                 typeof(AdoS8RuleHandlerUser),
                 // S8-NOTIFY-RECIPIENT-1:事件 ↔ 收件人配置。
                 typeof(AdoS8NotificationRecipient),
+                // S8-UAT-APPROVAL-REPAIR-1:审批人配置修复留证。
+                typeof(AdoS8ApprovalFlowRepairLog),
                 typeof(AdoS8NotificationLog),
                 typeof(AdoS8DashboardCellConfig),
                 typeof(AdoS8ExceptionType),