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

feat(s8): 增加规则复核与升级责任池

把规则的责任模型补齐成三个池:处理 / 复核 / 升级,成员一律 SysUser。
角色管"能做什么",责任池管"负责什么",两者不再混为一谈。

存储:复核 + 升级合并进新表 ado_s8_rule_responsibility_user(用
responsibility_type 区分);处理池留在存量表不迁移 —— 它已有四条生产读路径
(就绪门禁 / 认领资格 / 可见性收敛 / 通知解析),为观感去动它不值。
存储差异由统一 reader 收敛,调用方只看到一套 API。

修掉一处真实错位:FlowEngineService.ResolveApprovers 只读流程定义节点,
完全不看 BizData —— 提交复核时传的 verifierUserId 从未参与审批人解析。
于是"页面上选的检验人"与"审批任务的实际处理人"是两条各走各的链,
当前 UAT 看起来正常只是因为节点角色恰好只有一名成员。现在由 S8 自己的
ExceptionClosureBizHandler 在 OnFlowStarted 里把任务对齐到选定的人
(StartFlow 的顺序是先建任务再回调),不改共用的 FlowEngine。

超时升级的 authority 从 exception_type.escalate_role_code 换成规则的升级池:
旧字段按异常类型跨租户查,原实现注释已自陈会把 B 租户的配置用到 A 租户的异常上;
责任池天然带 tenant_id,这条越界路径从根上消失。空池一律 fail closed,不回落角色。

就绪门禁的三条判据改为各自独立声明。此前处理池判据被写在部门分支的提前返回
之后,事实上只对"必须配部门"的规则生效;当前取值与拆分前行为完全一致,
本次不改变任何规则的启用条件。

通知收件人改为由责任关系推导,业务侧不再选技术枚举;新增 ESCALATION_POOL,
REVIEWER 的回落从"解析审批流节点"改为规则的复核池。
"复核通过/复核退回"两个事件刻意不加 —— 代码里没有对应派发点。

迁移 1.0.503 只建表 + 索引 + 断言,不写任何成员数据、不绑定任何账号或角色:
部署后三个池初始为空,由管理员显式配置,空池被就绪门禁挡在启用之前。
YY968XX 1 месяц назад
Родитель
Сommit
9f6789d038
23 измененных файлов с 1825 добавлено и 138 удалено
  1. 3 0
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  2. 135 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.503.sql
  3. 8 2
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8ApprovalFlowRepairTests.cs
  4. 16 5
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8NotificationRecipientTests.cs
  5. 462 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8ResponsibilityPoolTests.cs
  6. 136 17
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8RuleEnableReadinessTests.cs
  7. 6 2
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8RuleHandlerPoolTests.cs
  8. 12 7
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8SchedulerScopeGuardTests.cs
  9. 54 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Const/S8/S8NotificationCatalog.cs
  10. 65 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Const/S8/S8ResponsibilityCatalog.cs
  11. 112 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs
  12. 37 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ExceptionsController.cs
  13. 49 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8RuleResponsibilityUser.cs
  14. 148 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8VerifierEligibility.cs
  15. 84 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/ExceptionClosureBizHandler.cs
  16. 14 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/Definitions/S8PurchaseDeliveryRuleDefinitions.cs
  17. 40 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/Definitions/S8RuleDefinition.cs
  18. 98 25
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8RuleReadinessGate.cs
  19. 78 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationRecipientConfigService.cs
  20. 26 28
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationRecipientResolver.cs
  21. 199 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8RuleResponsibilityPoolService.cs
  22. 13 10
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs
  23. 30 39
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TimeoutAutoEscalationService.cs

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

@@ -700,6 +700,9 @@
     <None Update="UpdateScripts\1.0.500.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.503.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\1.0.504.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 135 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.503.sql

@@ -0,0 +1,135 @@
+-- =====================================================================================
+-- 1.0.503  S8-RESPONSIBILITY-POOL-1 · 规则复核账号池 / 升级账号池
+--
+-- 目标:把 Rule 的责任模型补齐成三个池 —— 处理 / 复核 / 升级,成员一律 SysUser.Id。
+--   · 处理池 = 谁负责处理这条规则产生的异常(1.0.498 已建,本脚本不动它)
+--   · 复核池 = 谁有资格被选为这条规则的复核人(本脚本新建)
+--   · 升级池 = 异常超时 / 触发升级时找谁(本脚本新建)
+--
+-- 存储选型(最低风险优先,不是审美优先):
+--   · 复核 + 升级合并进**一张**关系表 ado_s8_rule_responsibility_user,
+--     用 responsibility_type 区分。两类的读写操作完全同构,分两张表等于把同一段
+--     服务代码抄两遍 —— 抄出来的两份迟早不一致,而不一致的那一刻没有任何报错。
+--   · 处理池**不迁**。它已有稳定的存量表与四条生产读路径(就绪门禁 / 认领资格 /
+--     可见性收敛 / 通知收件人解析)。为了"三张表长得一样"去迁它,收益是观感、
+--     代价是动四条已经跑通的链路。存储差异被收敛在服务层的统一 reader 里,
+--     调用方看到的仍是同一套 API。
+--   · 身份用 rule_code 而非 rule_id:与 1.0.498 同口径 —— watch_rule 的行会被
+--     clean reset + provisioning 重建、id 全部变化,绑 id 会让责任池在那一刻
+--     集体失联,而且不报错:规则照常跑,只是谁都不再"负责"它。
+--
+-- 表由 CodeFirst(InitTables) 创建;本脚本只补索引 + 断言,**不写任何成员数据**
+-- (成员是 per-tenant 的人工配置,写死在迁移里既不适配新租户、也会覆盖管理员的选择,
+--  更会让"责任池是空的"这个必须被看见的事实被一批假成员盖住)。
+-- =====================================================================================
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 1 · PREFLIGHT
+--   建表与 CodeFirst 等价;两者都执行时以先到者为准,后者跳过。
+-- ─────────────────────────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS `ado_s8_rule_responsibility_user` (
+  `id`                  BIGINT       NOT NULL AUTO_INCREMENT,
+  `tenant_id`           BIGINT       NOT NULL COMMENT '租户(唯一安全边界)',
+  `rule_code`           VARCHAR(128) NOT NULL COMMENT '规则编码,与 provisioning 身份口径一致',
+  `responsibility_type` VARCHAR(32)  NOT NULL COMMENT 'REVIEWER / ESCALATION',
+  `user_id`             BIGINT       NOT NULL COMMENT '成员账号 SysUser.Id',
+  `created_at`          DATETIME     NOT NULL,
+  `created_by`          VARCHAR(64)  NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='S8 规则复核/升级账号池';
+
+-- 处理池必须已存在:本批的统一 reader 会路由到它,缺表等于三个池里少一个。
+SET @has_handler := (SELECT COUNT(*) FROM information_schema.TABLES
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_rule_handler_user');
+DROP TABLE IF EXISTS `tmp_s8_503_guard`;
+CREATE TABLE `tmp_s8_503_guard` (
+  probe BIGINT NOT NULL,
+  CONSTRAINT s8_503_BLOCKED_handler_pool_table_missing CHECK (probe = 0)
+) COMMENT '1.0.503 断言门禁;通过后自动删除';
+INSERT INTO `tmp_s8_503_guard` (probe) VALUES ((1 - @has_handler));
+DROP TABLE IF EXISTS `tmp_s8_503_guard`;
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 2 · SCHEMA
+--   唯一索引带 responsibility_type:同一账号既当复核人又当升级人是合法配置,
+--   两条行必须能共存;而"同一类型下重复配同一人"必须在 DB 层挡住 ——
+--   没有它,重复成员会让"移出池"变成删一行、剩一行:界面显示已移出,实际仍然算数。
+--   反查索引:按 (tenant, user) 反查该账号承担的责任,避免全表扫。
+-- ─────────────────────────────────────────────────────────────────────────────────────
+SET @has_uk := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_rule_responsibility_user'
+    AND INDEX_NAME='uk_s8_rule_responsibility');
+
+-- 加唯一索引前先去重,否则 ALTER 会失败并把整个迁移卡住。
+SET @sql := IF(@has_uk = 0, '
+DELETE a FROM `ado_s8_rule_responsibility_user` a
+  JOIN `ado_s8_rule_responsibility_user` b
+    ON a.`tenant_id` = b.`tenant_id`
+   AND a.`rule_code` = b.`rule_code`
+   AND a.`responsibility_type` = b.`responsibility_type`
+   AND a.`user_id` = b.`user_id`
+   AND a.`id` > b.`id`', 'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @sql := IF(@has_uk = 0,
+  'ALTER TABLE `ado_s8_rule_responsibility_user` ADD UNIQUE INDEX `uk_s8_rule_responsibility` (`tenant_id`,`rule_code`,`responsibility_type`,`user_id`)',
+  'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @has_idx := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_rule_responsibility_user'
+    AND INDEX_NAME='idx_s8_rule_responsibility_user');
+SET @sql := IF(@has_idx = 0,
+  'ALTER TABLE `ado_s8_rule_responsibility_user` ADD INDEX `idx_s8_rule_responsibility_user` (`tenant_id`,`user_id`)',
+  'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 3 · POSTCHECK
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- ① 唯一索引必须覆盖四列。只查索引名存在是不够的 —— 1.0.496 踩过:
+--    列被 DROP 之后 MySQL 会把它移出复合索引却保留索引名,名字还在、约束已经没了。
+SET @cols := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_rule_responsibility_user'
+    AND INDEX_NAME='uk_s8_rule_responsibility'
+    AND COLUMN_NAME IN ('tenant_id','rule_code','responsibility_type','user_id'));
+DROP TABLE IF EXISTS `tmp_s8_503_guard`;
+CREATE TABLE `tmp_s8_503_guard` (
+  probe BIGINT NOT NULL,
+  CONSTRAINT s8_503_BLOCKED_unique_index_missing CHECK (probe = 0)
+) COMMENT '1.0.503 断言门禁;通过后自动删除';
+INSERT INTO `tmp_s8_503_guard` (probe) VALUES ((4 - @cols));
+DROP TABLE IF EXISTS `tmp_s8_503_guard`;
+
+-- ② 不允许出现「池成员不属于该行租户」的数据。
+--    这是本批的核心安全断言:责任池的隔离维度只有 tenant_id,工厂不参与。
+SET @bad_tenant := (SELECT COUNT(*) FROM `ado_s8_rule_responsibility_user` r
+  JOIN `SysUser` u ON u.`Id` = r.`user_id`
+ WHERE u.`TenantId` <> r.`tenant_id`);
+DROP TABLE IF EXISTS `tmp_s8_503_guard`;
+CREATE TABLE `tmp_s8_503_guard` (
+  probe BIGINT NOT NULL,
+  CONSTRAINT s8_503_BLOCKED_cross_tenant_member CHECK (probe = 0)
+) COMMENT '1.0.503 断言门禁;通过后自动删除';
+INSERT INTO `tmp_s8_503_guard` (probe) VALUES (@bad_tenant);
+DROP TABLE IF EXISTS `tmp_s8_503_guard`;
+
+-- ③ responsibility_type 只允许目录内的两个值。
+--    HANDLER 不该出现在本表 —— 它在存量表里;出现即说明有人绕过了服务层写入。
+SET @bad_type := (SELECT COUNT(*) FROM `ado_s8_rule_responsibility_user`
+  WHERE `responsibility_type` NOT IN ('REVIEWER','ESCALATION'));
+DROP TABLE IF EXISTS `tmp_s8_503_guard`;
+CREATE TABLE `tmp_s8_503_guard` (
+  probe BIGINT NOT NULL,
+  CONSTRAINT s8_503_BLOCKED_unknown_responsibility_type CHECK (probe = 0)
+) COMMENT '1.0.503 断言门禁;通过后自动删除';
+INSERT INTO `tmp_s8_503_guard` (probe) VALUES (@bad_type);
+DROP TABLE IF EXISTS `tmp_s8_503_guard`;
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 4 · IDEMPOTENT RERUN GUARD
+--   第二次执行:建表跳过、索引已存在跳过、断言依旧成立。
+--   本脚本不写任何成员数据,也不绑定任何具体账号或角色,
+--   因此重跑不会改变任何一条责任归属 —— 部署后三个池的初始状态一律为空,
+--   由管理员在「责任分配」页显式配置。空池会被就绪门禁挡在启用之前(fail closed)。
+-- ─────────────────────────────────────────────────────────────────────────────────────

+ 8 - 2
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8ApprovalFlowRepairTests.cs

@@ -232,8 +232,14 @@ public class S8ApprovalFlowRepairTests
     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);
+
+        // S8-RESPONSIBILITY-POOL-1:判据本体迁到 IS8VerifierEligibility(候选列表与提交校验共用同一份),
+        // 但**保护对象没变**:选出来的人必须真的能点「检验通过 / 检验退回」。
+        Assert.Contains("_verifierEligibility.CheckAsync(tenantId, verifier.UserId, e)", code);
+
+        var eligibility = CodeOnly("Infrastructure/S8/S8VerifierEligibility.cs");
+        Assert.Contains("S8ExceptionActionCode.Verify", eligibility);
+        Assert.Contains("没有审核权限,不能作为检验人", eligibility);
 
         // verifier_user_id 仍然只装 SysUser.Id
         Assert.Contains("e.VerifierUserId = verifier.UserId;", code);

+ 16 - 5
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8NotificationRecipientTests.cs

@@ -82,10 +82,13 @@ public class S8NotificationRecipientTests
     [Fact]
     public void RecipientTypes_ExcludeSupervisorAndAnyPhantom()
     {
-        Assert.Equal(4, S8NotificationCatalog.RecipientTypes.Count);
+        // S8-RESPONSIBILITY-POOL-1 新增 ESCALATION_POOL:它解析的是**真实存在**的升级账号池,
+        // 不是幽灵类型 —— 本守卫要挡的一直是"配了却永远发不出去的选项"。
+        Assert.Equal(5, S8NotificationCatalog.RecipientTypes.Count);
         Assert.Contains(S8RecipientType.HandlerPool, S8NotificationCatalog.RecipientTypes);
         Assert.Contains(S8RecipientType.Assignee, S8NotificationCatalog.RecipientTypes);
         Assert.Contains(S8RecipientType.Reviewer, S8NotificationCatalog.RecipientTypes);
+        Assert.Contains(S8RecipientType.EscalationPool, S8NotificationCatalog.RecipientTypes);
         Assert.Contains(S8RecipientType.SpecificUser, S8NotificationCatalog.RecipientTypes);
 
         foreach (var phantom in new[] { "ASSIGNEE_SUPERVISOR", "SUPERVISOR", "DEPARTMENT_LEADER", "L2_MANAGER", "L3_DIRECTOR" })
@@ -182,13 +185,21 @@ public class S8NotificationRecipientTests
     public void ReviewerRecipient_ResolvesFactsNotANewPool()
     {
         var code = CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
+
+        // 单据上已有检验人时,收件人就是他 —— 最准确的当前事实,这一条没变。
         Assert.Contains("e.VerifierUserId is > 0", code);
-        Assert.Contains("ResolveFlowApproversAsync", code);
 
-        foreach (var invented in new[] { "ReviewerPool", "reviewer_user_ids", "ReviewerUserIds" })
-            Assert.DoesNotContain(invented, code);
+        // S8-RESPONSIBILITY-POOL-1:尚未提交复核时的回落,从"解析审批流节点审批人"
+        // 改为"该规则的复核账号池"。P1-D 当时刻意不建 Reviewer Pool,本批显式改变该决定:
+        // 审批人来自流程定义的角色,与「谁有资格复核这条规则」不是同一个问题,
+        // 且会让通知链随流程节点结构悄悄变化。
+        Assert.DoesNotContain("ResolveFlowApproversAsync", code);
+        Assert.Contains("S8ResponsibilityType.Reviewer", code);
 
-        // 存储实体上同样不得出现 reviewer 池字段
+        // 复核池是**独立关系表**,不得塞进收件人配置行 ——
+        // 两处各存一份人员名单迟早不一致,而不一致的那一刻没有任何报错。
+        foreach (var invented in new[] { "reviewer_user_ids", "ReviewerUserIds" })
+            Assert.DoesNotContain(invented, code);
         Assert.Null(typeof(AdoS8NotificationRecipient).GetProperty("ReviewerUserIds"));
     }
 

+ 462 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8ResponsibilityPoolTests.cs

@@ -0,0 +1,462 @@
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-RESPONSIBILITY-POOL-1:规则三责任池(处理 / 复核 / 升级)。
+///
+/// <para><b>本批要挡住的</b>:角色与责任被重新混为一谈、复核人可以是池外的人、
+/// 页面选甲而审批任务发给乙、升级回落到某个角色、通知对象与责任池各说各话。
+/// 这些都是<b>静默</b>失败 —— 每一层都报成功,只有真实业务跑到那一步才会发现。</para>
+/// </summary>
+public class S8ResponsibilityPoolTests
+{
+    private static readonly string PluginRoot =
+        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    private static string Read(string relativePath)
+    {
+        var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+        return File.ReadAllText(full);
+    }
+
+    /// <summary>去掉注释行,避免"注释里提到某个词"被当成实现证据。</summary>
+    private static string CodeOnly(string relativePath) =>
+        string.Join('\n', Read(relativePath).Split('\n').Where(l =>
+        {
+            var t = l.TrimStart();
+            return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal);
+        }));
+
+    private static string PoolService() => CodeOnly("Service/S8/S8RuleResponsibilityPoolService.cs");
+    private static string VerifierEligibility() => CodeOnly("Infrastructure/S8/S8VerifierEligibility.cs");
+    private static string ReadinessGate() => CodeOnly("Service/S8/Rules/S8RuleReadinessGate.cs");
+    private static string TaskFlow() => CodeOnly("Service/S8/S8TaskFlowService.cs");
+    private static string ClosureHandler() => CodeOnly("Service/S8/ExceptionClosureBizHandler.cs");
+    private static string Escalation() => CodeOnly("Service/S8/S8TimeoutAutoEscalationService.cs");
+    private static string NotifyResolver() => CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
+
+    // ══════════════════════════════ 目录与存储 ══════════════════════════════
+
+    /// <summary>三种责任类型齐备,且每一种都有面向业务的文案(页面不出现技术枚举)。</summary>
+    [Fact]
+    public void Catalog_HasThreePoolsWithBusinessWording()
+    {
+        Assert.Equal(3, S8ResponsibilityCatalog.All.Count);
+        Assert.Equal(
+            new[] { S8ResponsibilityType.Handler, S8ResponsibilityType.Reviewer, S8ResponsibilityType.Escalation },
+            S8ResponsibilityCatalog.All.OrderBy(x => x.OrderNo).Select(x => x.Code));
+
+        foreach (var d in S8ResponsibilityCatalog.All)
+        {
+            Assert.False(string.IsNullOrWhiteSpace(d.DisplayName));
+            Assert.False(string.IsNullOrWhiteSpace(d.Description));
+            // 业务文案里不得出现技术术语
+            foreach (var banned in new[] { "Role", "ROLE_", "ApproverType", "Pool", "SysUser" })
+                Assert.DoesNotContain(banned, d.DisplayName + d.Description);
+        }
+    }
+
+    /// <summary>
+    /// T1/T2/T3 存储路由:处理池仍走存量表,复核 / 升级走新表。
+    /// <para>这条断言锁的是「不为了表结构好看去迁已经稳定的处理池」这个决定 ——
+    /// 迁它要动就绪门禁 / 认领资格 / 可见性收敛 / 通知解析四条已跑通的链路。</para>
+    /// </summary>
+    [Fact]
+    public void Storage_HandlerStaysInLegacyTable_ReviewerAndEscalationInNewTable()
+    {
+        Assert.False(S8ResponsibilityCatalog.IsStoredInResponsibilityTable(S8ResponsibilityType.Handler));
+        Assert.True(S8ResponsibilityCatalog.IsStoredInResponsibilityTable(S8ResponsibilityType.Reviewer));
+        Assert.True(S8ResponsibilityCatalog.IsStoredInResponsibilityTable(S8ResponsibilityType.Escalation));
+
+        var code = PoolService();
+        // HANDLER 读写都路由到存量实现,不在本服务里复制第二份处理池逻辑
+        Assert.Contains("_handlerReader.GetMemberIdsAsync", code);
+        Assert.Contains("_handlerWriter.SetMembersAsync", code);
+    }
+
+    /// <summary>T1–T3 写入:三类共用同一个写入口,成员判据同源。</summary>
+    [Fact]
+    public void T1_T2_T3_AllThreePools_ShareOneWriteEntryAndOneValidator()
+    {
+        var code = PoolService();
+        Assert.Contains("public async Task SetMembersAsync(S8TrustedScope scope, string ruleCode, string responsibilityType", code);
+        Assert.Contains("_userScope.ValidateUsersAsync", code);
+        // 未知类型不落库
+        Assert.Contains("未知的责任类型", Read("Service/S8/S8RuleResponsibilityPoolService.cs"));
+    }
+
+    // ══════════════════════════════ 租户与账号 ══════════════════════════════
+
+    /// <summary>
+    /// T4/T5/T6 跨租户成员必须被拒;T7 停用账号同样被拒。
+    /// <para>判据统一走 <c>S8UserScopeValidator</c>(同租户 + 启用),
+    /// 且<b>跨租户与不存在共用同一文案</b>,不泄漏存在性。</para>
+    /// </summary>
+    [Fact]
+    public void T4_T7_CrossTenantAndInactiveMembers_AreRejectedWithSameMessage()
+    {
+        var code = PoolService();
+        Assert.Contains("var validated = await _userScope.ValidateUsersAsync(scope.TenantId, wanted);", code);
+        Assert.Contains("throw new S8BizException(\"所选账号不存在或已停用\")", code);
+
+        // 池自己不写用户查询 —— 各写一套判据正是"配得进池、运行时又被拒"的来源
+        Assert.DoesNotContain("Queryable<SysUser>", code);
+    }
+
+    /// <summary>T8 工厂不参与责任池;T9 不出现员工主数据。</summary>
+    [Theory]
+    [InlineData("Service/S8/S8RuleResponsibilityPoolService.cs")]
+    [InlineData("Entity/S8/AdoS8RuleResponsibilityUser.cs")]
+    [InlineData("Infrastructure/S8/S8VerifierEligibility.cs")]
+    public void T8_T9_NoFactoryNoEmployee(string relativePath)
+    {
+        var code = CodeOnly(relativePath);
+        Assert.DoesNotContain("FactoryId", code);
+        Assert.DoesNotContain("factory_id", code);
+        Assert.DoesNotContain("EmployeeMaster", code);
+        Assert.DoesNotContain("EmployeeId", code);
+    }
+
+    /// <summary>
+    /// T26 租户隔离:<b>每一条</b>池查询都带 tenant 谓词。
+    /// <para>读路径用 <c>tenantId</c> 参数、写路径用 <c>scope.TenantId</c>(服务端盖章),
+    /// 两种写法都算数;缺任何一条就是跨租户读写。</para>
+    /// </summary>
+    [Fact]
+    public void T26_EveryPoolQuery_CarriesTenantPredicate()
+    {
+        var code = PoolService();
+        var queries = code.Split("AsQueryable()").Skip(1).ToList();
+        Assert.NotEmpty(queries);
+        foreach (var q in queries)
+        {
+            var head = q[..Math.Min(q.Length, 400)];
+            Assert.True(head.Contains("x.TenantId == tenantId", StringComparison.Ordinal)
+                        || head.Contains("x.TenantId == scope.TenantId", StringComparison.Ordinal),
+                $"存在没有租户谓词的池查询:{head[..Math.Min(head.Length, 160)]}");
+        }
+    }
+
+    // ══════════════════════════════ 就绪门禁 ══════════════════════════════
+
+    /// <summary>
+    /// T10/T11/T12 三条判据由<b>各自的声明</b>驱动,互不捆绑。
+    ///
+    /// <para>拆分前处理池判据被写在部门分支的提前返回之后,事实上只对"必须配部门"的规则生效。
+    /// 再叠加复核 / 升级两条判据只会把耦合放大成三重。</para>
+    /// </summary>
+    [Fact]
+    public void T10_T11_T12_EachReadinessCheck_HasItsOwnDeclaration()
+    {
+        var code = ReadinessGate();
+        Assert.Contains("if (policy.RequiresDepartmentDefaultsForEnable)", code);
+        Assert.Contains("if (policy.RequiresHandlerPoolForEnable)", code);
+        Assert.Contains("if (policy.RequiresVerification)", code);
+        Assert.Contains("if (policy.SupportsTimeoutEscalation)", code);
+
+        // 各自独立的原因码 —— 管理员必须知道缺的是哪个池
+        Assert.Contains("HandlerPoolEmpty", code);
+        Assert.Contains("ReviewerPoolEmpty", code);
+        Assert.Contains("EscalationPoolEmpty", code);
+    }
+
+    /// <summary>
+    /// 空池判据是「有没有<b>当前有效</b>的成员」,不是「表里有没有行」。
+    /// <para>账号停用后那一行还在,但那个人已经不算数了。只数行数会放行一条实际上没人负责的规则。</para>
+    /// </summary>
+    [Fact]
+    public void ReadinessGate_JudgesByValidMembers_NotRowCount()
+    {
+        var code = ReadinessGate();
+        Assert.Contains("members.Any(m => m.Valid)", code);
+        // 「一个都没配」与「配了但都失效」分开报:管理员的下一步动作完全不同
+        Assert.Contains("members.Count == 0 ? emptyMessage : allInvalidMessage", code);
+    }
+
+    /// <summary>Rule 01 显式声明三条判据;处理池那条与拆分前行为一致,不顺带改变启用条件。</summary>
+    [Fact]
+    public void Rule01_DeclaresAllThreeResponsibilities()
+    {
+        var def = Read("Service/S8/Rules/Definitions/S8PurchaseDeliveryRuleDefinitions.cs");
+        Assert.Contains("RequiresHandlerPoolForEnable = true", def);
+        Assert.Contains("RequiresVerification = true", def);
+        Assert.Contains("SupportsTimeoutEscalation = true", def);
+    }
+
+    /// <summary>
+    /// T23 空池<b>绝不</b>回落到角色。
+    /// <para>回落会让「我没给这条规则配人」与「有人在负责」同时成立 —— 最难查的一类错配。</para>
+    /// </summary>
+    [Fact]
+    public void T23_EmptyPool_NeverFallsBackToAnyRole()
+    {
+        foreach (var code in new[] { ReadinessGate(), Escalation(), VerifierEligibility() })
+        {
+            Assert.DoesNotContain("ROLE_QC", code);
+            Assert.DoesNotContain("EscalateRoleCode", code);
+            Assert.DoesNotContain("escalate_role_code", code);
+        }
+    }
+
+    /// <summary>T24 L1/L2/L3 与虚拟主管层级不得成为当前 authority。</summary>
+    [Fact]
+    public void T24_NoVirtualHierarchyAsCurrentAuthority()
+    {
+        foreach (var rel in new[]
+                 {
+                     "Service/S8/S8RuleResponsibilityPoolService.cs",
+                     "Infrastructure/S8/S8VerifierEligibility.cs",
+                     "Service/S8/S8TimeoutAutoEscalationService.cs",
+                     "Const/S8/S8ResponsibilityCatalog.cs",
+                 })
+        {
+            var code = CodeOnly(rel);
+            foreach (var banned in new[] { "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR", "DepartmentLeader", "DirectorId", "ManagerUserId" })
+                Assert.DoesNotContain(banned, code);
+        }
+    }
+
+    // ══════════════════════════════ 复核契约 ══════════════════════════════
+
+    /// <summary>
+    /// T13/T14/T15 复核人资格 = 复核池 ∩ 审核权限 ∩ 可用账号,三者缺一不可。
+    /// </summary>
+    [Fact]
+    public void T13_T14_T15_VerifierMustSatisfyAllThreeConditions()
+    {
+        var code = VerifierEligibility();
+        Assert.Contains("_userScope.FindActiveUserAsync(tenantId, verifierUserId)", code);
+        Assert.Contains("S8ResponsibilityType.Reviewer, verifierUserId", code);
+        Assert.Contains("S8ExceptionActionCode.Verify", code);
+
+        Assert.Contains("NotInReviewerPool", code);
+        Assert.Contains("NoActionPermission", code);
+        Assert.Contains("NotUsable", code);
+    }
+
+    /// <summary>
+    /// 候选列表与提交校验<b>同一份实现</b>。
+    /// <para>两边各写一套,就会出现「页面能选、提交被拒」或更糟的「页面选甲、后端放行乙」。</para>
+    /// </summary>
+    [Fact]
+    public void VerifierCandidates_AndSubmitCheck_ShareOneImplementation()
+    {
+        var eligibility = VerifierEligibility();
+        Assert.Contains("Task<S8VerifierEligibilityResult> CheckAsync", eligibility);
+        Assert.Contains("Task<List<S8VerifierCandidateDto>> ListCandidatesAsync", eligibility);
+
+        // 提交复核只调这一个门,不再自己拼权限判据
+        var flow = TaskFlow();
+        Assert.Contains("_verifierEligibility.CheckAsync(tenantId, verifier.UserId, e)", flow);
+        Assert.DoesNotContain(
+            "_actionAuthorizer.AuthorizeAsync(tenantId, verifier.UserId, S8ExceptionActionCode.Verify)", flow);
+    }
+
+    /// <summary>T15 绕过 UI 直接调 API 注入池外 verifier 同样被拒 —— 门在服务端。</summary>
+    [Fact]
+    public void T15_ApiInjectedOutsiderVerifier_IsRejectedServerSide()
+    {
+        var flow = TaskFlow();
+        var idx = flow.IndexOf("SubmitVerificationAsync(", StringComparison.Ordinal);
+        Assert.True(idx > 0);
+        var body = flow[idx..(idx + 1800)];
+
+        // 资格校验必须发生在状态迁移与落库之前
+        var checkIdx = body.IndexOf("_verifierEligibility.CheckAsync", StringComparison.Ordinal);
+        var mutateIdx = body.IndexOf("e.VerifierUserId = verifier.UserId", StringComparison.Ordinal);
+        Assert.True(checkIdx > 0 && mutateIdx > 0);
+        Assert.True(checkIdx < mutateIdx, "复核人资格必须在写入 verifier_user_id 之前判定");
+    }
+
+    /// <summary>
+    /// T16 <b>页面选定的检验人 == 审批任务的实际处理人</b>。
+    ///
+    /// <para>这是本批修的核心错位:<c>ResolveApprovers</c> 只看流程定义节点,
+    /// <b>完全不读 BizData</b>,提交复核时传的 <c>verifierUserId</c> 从未参与审批人解析。
+    /// 当前 UAT 看起来正常只是因为节点角色恰好只有一名成员。</para>
+    /// </summary>
+    [Fact]
+    public void T16_SelectedVerifier_BecomesTheActualFlowTaskAssignee()
+    {
+        var code = ClosureHandler();
+        Assert.Contains("AlignTaskToSelectedVerifierAsync", code);
+        Assert.Contains("t.Status == FlowTaskStatusEnum.Pending", code);
+        Assert.Contains("keep.AssigneeId = verifierId;", code);
+
+        // 对齐必须发生在 OnFlowStarted 里 —— StartFlow 的顺序是先建任务再回调
+        var started = code.IndexOf("OnFlowStarted(long bizId", StringComparison.Ordinal);
+        var align = code.IndexOf("await AlignTaskToSelectedVerifierAsync", StringComparison.Ordinal);
+        Assert.True(started > 0 && align > started);
+
+        // 多余待办必须删除:留着等于池外的人仍能点通过,而 S8 侧校验又会拒绝他
+        Assert.Contains("AsDeleteable().Where(t => drop.Contains(t.Id))", code);
+    }
+
+    /// <summary>对齐失败不回滚已提交的复核,但必须留下可查的日志 —— 静默失败是本模块的老病。</summary>
+    [Fact]
+    public void TaskAlignmentFailure_IsLoggedNotSwallowed()
+    {
+        var code = ClosureHandler();
+        Assert.Contains("s8_verify_task_aligned", code);
+        Assert.Contains("s8_verify_task_align_failed", code);
+    }
+
+    /// <summary>不改共用的 FlowEngine —— 它是 S1/S5/S6/S7 共用的平台组件。</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 code = File.ReadAllText(engine);
+
+        // 没有为 S8 加 BizData 动态审批人分支
+        Assert.DoesNotContain("verifierUserId", code);
+        Assert.DoesNotContain("S8ResponsibilityType", code);
+    }
+
+    // ══════════════════════════════ 升级契约 ══════════════════════════════
+
+    /// <summary>
+    /// T19 超时升级按<b>该规则的升级账号池</b>解析,不再用 exception_type.escalate_role_code。
+    ///
+    /// <para>旧字段按异常类型跨租户查,实测会把 B 租户的配置用到 A 租户的异常上
+    /// (原实现注释已自陈)。责任池天然带 tenant_id,不存在这条越界路径。</para>
+    /// </summary>
+    [Fact]
+    public void T19_Escalation_ResolvesExactlyTheRuleEscalationPool()
+    {
+        var code = Escalation();
+        Assert.Contains("S8ResponsibilityType.Escalation", code);
+        Assert.Contains("_pools.GetValidMemberIdsAsync", code);
+
+        // 旧 authority 已彻底移除(含类型仓储)
+        Assert.DoesNotContain("EscalateRoleCode", code);
+        Assert.DoesNotContain("AdoS8ExceptionType", code);
+        Assert.DoesNotContain("_typeRep", code);
+    }
+
+    /// <summary>空升级池 fail closed:跳过并落日志,不回落、不猜。</summary>
+    [Fact]
+    public void EmptyEscalationPool_SkipsLoudlyInsteadOfGuessing()
+    {
+        var code = Escalation();
+        Assert.Contains("reason=escalation_pool_empty", code);
+        Assert.Contains("if (escalationPool.Count == 0)", code);
+        // 人工提报没有来源规则,不参与规则级自动升级
+        Assert.Contains("reason=no_source_rule", code);
+    }
+
+    // ══════════════════════════════ 通知推导 ══════════════════════════════
+
+    /// <summary>
+    /// T20/T21/T22 通知对象由责任关系<b>推导</b>,业务用户不选技术枚举。
+    /// </summary>
+    [Fact]
+    public void T20_T21_T22_RecipientsAreDerivedFromResponsibilities()
+    {
+        Assert.Equal(S8RecipientType.HandlerPool,
+            S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.ExceptionCreated));
+        Assert.Equal(S8RecipientType.Reviewer,
+            S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.VerificationSubmitted));
+        Assert.Equal(S8RecipientType.EscalationPool,
+            S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.EscalationTriggered));
+        Assert.Equal(S8RecipientType.Assignee,
+            S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.ExceptionClaimed));
+        // 转派后 assignee_user_id 已经是新处理人,不需要额外的收件人类型
+        Assert.Equal(S8RecipientType.Assignee,
+            S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.ExceptionTransferred));
+    }
+
+    /// <summary>
+    /// <b>不制造不存在的事件</b>:目录里每个事件都必须有真实派发点,
+    /// 且每个事件都必须有默认收件人推导。
+    /// </summary>
+    [Fact]
+    public void EveryCatalogEvent_HasBothADispatchSiteAndADerivedRecipient()
+    {
+        var serviceDir = Path.Combine(PluginRoot, "Service", "S8");
+        var allCode = string.Join('\n', Directory.GetFiles(serviceDir, "*.cs", SearchOption.AllDirectories)
+            .Select(File.ReadAllText));
+
+        foreach (var e in S8NotificationCatalog.Events)
+        {
+            var member = e.Code.Split('_')
+                .Select(p => char.ToUpperInvariant(p[0]) + p[1..].ToLowerInvariant())
+                .Aggregate(string.Empty, (a, b) => a + b);
+            Assert.True(allCode.Contains($"S8NotifyEventCode.{member}", StringComparison.Ordinal),
+                $"事件 {e.Code} 在 Service/S8 下没有任何派发点 —— 配了却永远不触发的通知比没有通知更难排查");
+
+            Assert.NotNull(S8NotificationCatalog.ResolveDefaultRecipientType(e.Code));
+        }
+    }
+
+    /// <summary>「复核通过 / 复核退回」刻意缺席 —— 代码里没有这两个派发点。</summary>
+    [Fact]
+    public void NonExistentVerificationEvents_AreNotInvented()
+    {
+        Assert.DoesNotContain("VERIFICATION_APPROVED", string.Join(',', S8NotificationCatalog.Events.Select(e => e.Code)));
+        Assert.DoesNotContain("VERIFICATION_REJECTED", string.Join(',', S8NotificationCatalog.Events.Select(e => e.Code)));
+    }
+
+    /// <summary>
+    /// 通知里的 REVIEWER 不再解析审批流节点。
+    /// <para>审批人来自流程定义的角色,与「谁有资格复核这条规则」不是同一个问题,
+    /// 且会让通知链随流程节点结构悄悄变化。</para>
+    /// </summary>
+    [Fact]
+    public void NotificationReviewer_NoLongerParsesApprovalFlowNodes()
+    {
+        var code = NotifyResolver();
+        Assert.DoesNotContain("ResolveFlowApproversAsync", code);
+        Assert.DoesNotContain("_reviewSource", code);
+        Assert.Contains("S8ResponsibilityType.Reviewer", code);
+        Assert.Contains("S8ResponsibilityType.Escalation", code);
+    }
+
+    /// <summary>通知开关写入口只接布尔,收件人类型由目录推导 —— 页面不选技术枚举。</summary>
+    [Fact]
+    public void NotificationToggle_TakesOnlyABooleanFromTheUi()
+    {
+        var svc = CodeOnly("Service/S8/S8NotificationRecipientConfigService.cs");
+        Assert.Contains("SetEventEnabledAsync(S8TrustedScope scope, string ruleCode, string eventCode, bool enabled)", svc);
+        Assert.Contains("S8NotificationCatalog.ResolveDefaultRecipientType(eventCode)", svc);
+
+        var dto = Read("Controllers/S8/AdoS8ConfigWatchRulesController.cs");
+        Assert.Contains("class AdoS8NotifyEventToggleDto", dto);
+    }
+
+    // ══════════════════════════════ 回归 ══════════════════════════════
+
+    /// <summary>T25 First Claim Wins 未被本批改动:仍是一条带前置条件的原子 UPDATE。</summary>
+    [Fact]
+    public void T25_FirstClaimWins_IsUnchanged()
+    {
+        var code = TaskFlow();
+        Assert.Contains("x.Status == fromStatus", code);
+        Assert.Contains("x.AssigneeUserId == null", code);
+        Assert.Contains("该异常已被其他人员认领", code);
+        // 认领资格仍是 动作权限 ∩ 处理池
+        Assert.Contains("_claimEligibility.CheckAsync(tenantId, currentUserId, e)", code);
+    }
+
+    /// <summary>迁移不绑定任何具体账号或角色 —— 部署后三个池初始为空,由管理员显式配置。</summary>
+    [Fact]
+    public void Migration_BindsNoConcreteAccountsOrRoles()
+    {
+        var sql = File.ReadAllText(Path.GetFullPath(Path.Combine(
+            PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.503.sql")));
+
+        Assert.DoesNotContain("INSERT INTO `ado_s8_rule_responsibility_user`", sql);
+        Assert.DoesNotContain("ROLE_", sql);
+        Assert.DoesNotContain("DELIMITER", sql);   // AutoVersionUpdate 会整份拒绝含该词的脚本
+
+        // 唯一索引断言按**列成员**判定,不是只看索引名是否存在(1.0.496 踩过这个坑)
+        Assert.Contains("COLUMN_NAME IN ('tenant_id','rule_code','responsibility_type','user_id')", sql);
+        Assert.Contains("s8_503_BLOCKED_cross_tenant_member", sql);
+        Assert.Contains("s8_503_BLOCKED_unknown_responsibility_type", sql);
+    }
+}

+ 136 - 17
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8RuleEnableReadinessTests.cs

@@ -1,5 +1,6 @@
 using System.Reflection;
 using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -91,33 +92,57 @@ public class S8RuleEnableReadinessTests
     /// 默认给 1,让既有的部门用例继续只考察部门那一维 —— 一个测试同时压两个门禁,
     /// 失败时无法分辨是哪一维出的问题。</para>
     /// </summary>
-    private sealed class FakePool : IS8RuleHandlerPoolReader
+    private sealed class FakePool : IS8RuleResponsibilityReader
     {
         private readonly int _valid;
         private readonly int _total;
-        public FakePool(int valid, int total) { _valid = valid; _total = total; }
+        private readonly Dictionary<string, (int Valid, int Total)> _perType;
+
+        /// <param name="valid">默认各类责任池的有效成员数。</param>
+        /// <param name="total">默认各类责任池的成员总数。</param>
+        /// <param name="perType">
+        /// 按责任类型覆盖。<b>只覆盖被考察的那一维</b>,其余维保持默认的"有人" ——
+        /// 一个测试同时压两个门禁,失败时无法分辨是哪一维出的问题。
+        /// </param>
+        public FakePool(int valid, int total, Dictionary<string, (int, int)>? perType = null)
+        {
+            _valid = valid;
+            _total = total;
+            _perType = perType?.ToDictionary(k => k.Key, v => (v.Value.Item1, v.Value.Item2),
+                StringComparer.OrdinalIgnoreCase) ?? new(StringComparer.OrdinalIgnoreCase);
+        }
 
-        public Task<List<S8HandlerUserDto>> GetMembersAsync(long tenantId, string ruleCode) =>
-            Task.FromResult(Enumerable.Range(0, _total)
-                .Select(i => new S8HandlerUserDto { UserId = 1000 + i, Valid = i < _valid })
+        private (int Valid, int Total) For(string type) =>
+            _perType.TryGetValue(type, out var v) ? v : (_valid, _total);
+
+        public Task<List<S8HandlerUserDto>> GetMembersAsync(long tenantId, string ruleCode, string responsibilityType)
+        {
+            var (valid, total) = For(responsibilityType);
+            return Task.FromResult(Enumerable.Range(0, total)
+                .Select(i => new S8HandlerUserDto { UserId = 1000 + i, Valid = i < valid })
                 .ToList());
+        }
+
+        public Task<IReadOnlyList<long>> GetMemberIdsAsync(long tenantId, string ruleCode, string responsibilityType) =>
+            Task.FromResult((IReadOnlyList<long>)Enumerable.Range(0, For(responsibilityType).Total)
+                .Select(i => (long)(1000 + i)).ToList());
+
+        public Task<IReadOnlyList<long>> GetValidMemberIdsAsync(long tenantId, string ruleCode, string responsibilityType) =>
+            Task.FromResult((IReadOnlyList<long>)Enumerable.Range(0, For(responsibilityType).Valid)
+                .Select(i => (long)(1000 + i)).ToList());
 
-        public Task<IReadOnlyList<long>> GetMemberIdsAsync(long tenantId, string ruleCode) =>
-            Task.FromResult((IReadOnlyList<long>)Enumerable.Range(0, _total).Select(i => (long)(1000 + i)).ToList());
-        public Task<bool> IsMemberAsync(long tenantId, string ruleCode, long userId) => Task.FromResult(_valid > 0);
-        public Task<IReadOnlyList<string>> GetRuleCodesForUserAsync(long tenantId, long userId) =>
-            Task.FromResult((IReadOnlyList<string>)Array.Empty<string>());
-        public Task<IReadOnlyDictionary<string, List<long>>> GetPoolsAsync(long tenantId, IEnumerable<string> ruleCodes) =>
-            Task.FromResult((IReadOnlyDictionary<string, List<long>>)new Dictionary<string, List<long>>());
+        public Task<bool> IsMemberAsync(long tenantId, string ruleCode, string responsibilityType, long userId) =>
+            Task.FromResult(For(responsibilityType).Valid > 0);
     }
 
-    /// <summary>门禁实例:DeptInA 属租户 A,DeptInB 属租户 B;责任池默认有 1 名有效成员。</summary>
-    private static S8RuleReadinessGate Gate(int validMembers = 1, int totalMembers = 1) =>
+    /// <summary>门禁实例:DeptInA 属租户 A,DeptInB 属租户 B;三类责任池默认各有 1 名有效成员。</summary>
+    private static S8RuleReadinessGate Gate(int validMembers = 1, int totalMembers = 1,
+        Dictionary<string, (int, int)>? perType = null) =>
         new(new FakeDeptValidator(new Dictionary<long, long>
         {
             [DeptInA] = TenantA,
             [DeptInB] = TenantB
-        }), new FakePool(validMembers, totalMembers));
+        }), new FakePool(validMembers, totalMembers, perType));
 
     private static Task<S8RuleReadinessCheck> Check(long tenantId, long? occ, long? resp, S8RuleDefinition? def = null)
     {
@@ -458,11 +483,15 @@ public class S8RuleEnableReadinessTests
     }
 
     /// <summary>
-    /// T17:未声明部门必需的规则,责任池同样不检查 ——
+    /// T17:什么都没声明的规则,四条判据一条都不跑 ——
     /// 该门禁整体是<b>逐规则声明</b>的,不是 S8 全局规则。
+    ///
+    /// <para>S8-RESPONSIBILITY-POOL-1 之后这条更名副其实:原先处理池判据写在部门分支的
+    /// 提前返回之后,"不检查处理池"是搭部门那趟车的<b>副作用</b>;现在四条判据各自独立声明,
+    /// 不声明就是真的不检查。</para>
     /// </summary>
     [Fact]
-    public async Task T17_RuleWithoutDepartmentRequirement_SkipsPoolCheckToo()
+    public async Task T17_RuleDeclaringNothing_SkipsEveryCheck()
     {
         var result = await Gate(validMembers: 0, totalMembers: 0)
             .CheckAsync(
@@ -471,4 +500,94 @@ public class S8RuleEnableReadinessTests
 
         Assert.True(result.Ok);
     }
+
+    // ───────────────── T18–T21:复核池 / 升级池就绪(S8-RESPONSIBILITY-POOL-1)─────────────────
+
+    /// <summary>
+    /// T18:声明了包含复核环节的规则,复核池为空时不得启用。
+    ///
+    /// <para>没有复核人的后果不是报错,而是处理人做完点「提交复核」发现候选人列表是空的,
+    /// 单据卡在 IN_PROGRESS 出不去 —— 而规则一路显示「已启用 / 最近结果:成功」。</para>
+    /// </summary>
+    [Fact]
+    public async Task T18_Enable_Rejected_WhenReviewerPoolEmpty()
+    {
+        var result = await Gate(perType: new() { [S8ResponsibilityType.Reviewer] = (0, 0) })
+            .CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, DeptInA, DeptInA), Rule01()), TenantA);
+
+        Assert.False(result.Ok);
+        Assert.Equal(S8RuleReadinessReasonCode.ReviewerPoolEmpty, result.ReasonCode);
+        Assert.Contains("复核账号", result.Message);
+    }
+
+    /// <summary>T19:复核池配了人但全部失效,与"一个都没配"分开报 —— 管理员的下一步动作不同。</summary>
+    [Fact]
+    public async Task T19_ReviewerPool_AllInvalid_ReportsDistinctMessage()
+    {
+        var result = await Gate(perType: new() { [S8ResponsibilityType.Reviewer] = (0, 2) })
+            .CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, DeptInA, DeptInA), Rule01()), TenantA);
+
+        Assert.False(result.Ok);
+        Assert.Equal(S8RuleReadinessReasonCode.ReviewerPoolEmpty, result.ReasonCode);
+        Assert.Contains("已停用或不在当前租户", result.Message);
+    }
+
+    /// <summary>T20:声明了参与超时升级的规则,升级池为空时不得启用。</summary>
+    [Fact]
+    public async Task T20_Enable_Rejected_WhenEscalationPoolEmpty()
+    {
+        var result = await Gate(perType: new() { [S8ResponsibilityType.Escalation] = (0, 0) })
+            .CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, DeptInA, DeptInA), Rule01()), TenantA);
+
+        Assert.False(result.Ok);
+        Assert.Equal(S8RuleReadinessReasonCode.EscalationPoolEmpty, result.ReasonCode);
+        Assert.Contains("升级账号", result.Message);
+    }
+
+    /// <summary>
+    /// T21:未声明复核 / 升级的规则,即便两个池都是空的也照常启用。
+    /// <para>强制一条"处理完即闭环"的规则配复核人,只会逼出一个占位账号 ——
+    /// 那比没有配置更危险。</para>
+    /// </summary>
+    [Fact]
+    public async Task T21_RuleNotDeclaringVerificationOrEscalation_IgnoresThosePools()
+    {
+        var def = RuleWithoutDepartmentRequirement();
+        def = new S8RuleDefinition
+        {
+            RuleCode = def.RuleCode,
+            DisplayName = def.DisplayName,
+            DatasetCode = def.DatasetCode,
+            RuleType = def.RuleType,
+            SourceObjectType = def.SourceObjectType,
+            SceneCode = def.SceneCode,
+            ExceptionTypeCode = def.ExceptionTypeCode,
+            Timeout = def.Timeout,
+            // 只要求处理池,不要求复核 / 升级
+            Parameters = new S8RuleParameterPolicy { RequiresHandlerPoolForEnable = true }
+        };
+
+        var result = await Gate(perType: new()
+            {
+                [S8ResponsibilityType.Reviewer] = (0, 0),
+                [S8ResponsibilityType.Escalation] = (0, 0),
+            })
+            .CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, null, null), def), TenantA);
+
+        Assert.True(result.Ok);
+    }
+
+    /// <summary>
+    /// T22:三个池都空时,先报<b>处理池</b> —— 判据顺序稳定,管理员按提示逐个补齐即可,
+    /// 不会出现"修好一个又蹦出另一个、每次报的还不一样"。
+    /// </summary>
+    [Fact]
+    public async Task T22_AllPoolsEmpty_ReportsHandlerFirstDeterministically()
+    {
+        var result = await Gate(validMembers: 0, totalMembers: 0)
+            .CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, DeptInA, DeptInA), Rule01()), TenantA);
+
+        Assert.False(result.Ok);
+        Assert.Equal(S8RuleReadinessReasonCode.HandlerPoolEmpty, result.ReasonCode);
+    }
 }

+ 6 - 2
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8RuleHandlerPoolTests.cs

@@ -216,9 +216,13 @@ public class S8RuleHandlerPoolTests
     {
         var code = CodeOnly("Service/S8/Rules/S8RuleReadinessGate.cs");
         Assert.Contains(S8RuleReadinessReasonCode.HandlerPoolEmpty, code);
-        Assert.Contains("_handlerPool.GetMembersAsync", code);
+
+        // S8-RESPONSIBILITY-POOL-1:三类责任池走统一 reader,处理池由 responsibilityType 指定。
+        Assert.Contains("_pools.GetMembersAsync(tenantId, ruleCode, responsibilityType)", code);
+        Assert.Contains("S8ResponsibilityType.Handler", code);
+
         // 有效性判据:不是 Count > 0,而是"存在一个 Valid 的"
-        Assert.Contains("members.All(m => !m.Valid)", code);
+        Assert.Contains("members.Any(m => m.Valid)", code);
         Assert.DoesNotContain("members.Count > 0)", code);
 
         // 两种失败要给不同提示:没配过 vs 配过但都失效

+ 12 - 7
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8SchedulerScopeGuardTests.cs

@@ -119,13 +119,18 @@ public class S8SchedulerScopeGuardTests
         // 旧写法已移除:全局 typeMap 按 TypeCode 直接取用。
         Assert.DoesNotContain("typeMap.TryGetValue", code);
 
-        // 新写法:逐异常按 TenantId 解析(S8-TENANT-ONLY-BATCH6:工厂维度已从判据移除)。
-        Assert.Contains("ResolveTypeForScope", code);
-        Assert.Contains("t.TenantId == tenantId || t.TenantId == S8ConfigScope.GlobalTenantId", code);
-        Assert.Contains("ResolveTypeForScope(e.ExceptionTypeCode!, e.TenantId)", code);
-
-        // 工厂判据不得复活:本批之后租户覆盖行的 factory_id 恒为 0,
-        // 带工厂等值会让「本租户已自定义 escalate_role_code」的异常拿到平台默认值。
+        // S8-RESPONSIBILITY-POOL-1:升级 authority 从 exception_type.escalate_role_code
+        // 换成**该规则的升级账号池**。本守卫要保护的一直是「不得把 B 租户的配置用到 A 租户的异常上」——
+        // 现在这条越界路径从根上消失了:升级人取自 (tenant_id, rule_code) 的责任池,
+        // 而 tenant_id 就是异常自己的租户,不存在"跨租户解析"这个动作。
+        Assert.DoesNotContain("ResolveTypeForScope", code);
+        Assert.DoesNotContain("AdoS8ExceptionType", code);
+        Assert.DoesNotContain("EscalateRoleCode", code);
+
+        Assert.Contains("_pools.GetValidMemberIdsAsync(", code);
+        Assert.Contains("e.TenantId, e.SourceRuleCode!, S8ResponsibilityType.Escalation", code);
+
+        // 工厂判据不得复活。
         Assert.DoesNotContain("t.FactoryId == factoryId", code);
         Assert.DoesNotContain("OrderByDescending(t => t.FactoryId)", code);
     }

+ 54 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Const/S8/S8NotificationCatalog.cs

@@ -53,6 +53,13 @@ public static class S8RecipientType
     /// </summary>
     public const string Reviewer = "REVIEWER";
 
+    /// <summary>
+    /// 该规则的<b>升级账号池</b>(S8-RESPONSIBILITY-POOL-1)。
+    /// <para>取代 <c>exception_type.escalate_role_code</c> 成为升级通知的收件来源:
+    /// 旧字段按异常类型跨租户查,实测会把 B 租户的配置用到 A 租户的异常上。</para>
+    /// </summary>
+    public const string EscalationPool = "ESCALATION_POOL";
+
     /// <summary>指定账号(多选 SysUser)。</summary>
     public const string SpecificUser = "SPECIFIC_USER";
 }
@@ -82,9 +89,56 @@ public static class S8NotificationCatalog
         S8RecipientType.HandlerPool,
         S8RecipientType.Assignee,
         S8RecipientType.Reviewer,
+        S8RecipientType.EscalationPool,
         S8RecipientType.SpecificUser,
     };
 
+    /// <summary>
+    /// S8-RESPONSIBILITY-POOL-1:事件 → 收件人的<b>默认推导</b>。
+    ///
+    /// <para><b>为什么是推导而不是让人配</b>:规则已经知道处理池、复核池、升级池、
+    /// 当前处理人、选定复核人是谁。再让业务用户去选 <c>HANDLER_POOL</c> / <c>ASSIGNEE</c>
+    /// 这类技术枚举,等于把内部实现搬到页面上,还多出一处会与责任池不一致的配置。
+    /// 业务用户只需要回答「这件事要不要提醒」,对象由责任关系自动决定。</para>
+    ///
+    /// <para><b>每一条都对应真实派发点</b>:<c>EXCEPTION_CREATED</c>(调度器建单)·
+    /// <c>EXCEPTION_CLAIMED</c> / <c>EXCEPTION_TRANSFERRED</c> / <c>VERIFICATION_SUBMITTED</c>
+    /// (<c>S8TaskFlowService</c>)· <c>ESCALATION_TRIGGERED</c>(超时升级作业)·
+    /// <c>EXCEPTION_RECOVERED</c>(调度器判定恢复)· <c>EXCEPTION_OVERDUE_CLOSED</c>(闭环及时性回顾)。
+    /// <b>「复核通过」「复核退回」刻意缺席</b>:代码里没有这两个派发点,
+    /// 凭空加进目录只会配出永远不触发的通知 —— 比没有通知更难排查。</para>
+    ///
+    /// <para><b>转派通知的对象是「新处理人」</b>:转派完成时 <c>assignee_user_id</c>
+    /// 已经是新的人,因此 <c>ASSIGNEE</c> 解析出来的就是他,不需要额外的收件人类型。</para>
+    /// </summary>
+    public static readonly IReadOnlyDictionary<string, string> DefaultRecipientByEvent =
+        new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
+        {
+            [S8NotifyEventCode.ExceptionCreated] = S8RecipientType.HandlerPool,
+            [S8NotifyEventCode.ExceptionClaimed] = S8RecipientType.Assignee,
+            [S8NotifyEventCode.ExceptionTransferred] = S8RecipientType.Assignee,
+            [S8NotifyEventCode.VerificationSubmitted] = S8RecipientType.Reviewer,
+            [S8NotifyEventCode.EscalationTriggered] = S8RecipientType.EscalationPool,
+            [S8NotifyEventCode.ExceptionRecovered] = S8RecipientType.Assignee,
+            [S8NotifyEventCode.ExceptionOverdueClosed] = S8RecipientType.Assignee,
+        };
+
+    /// <summary>事件的默认收件人类型;未知事件返回 <c>null</c>(调用方不得猜)。</summary>
+    public static string? ResolveDefaultRecipientType(string? eventCode) =>
+        !string.IsNullOrWhiteSpace(eventCode)
+        && DefaultRecipientByEvent.TryGetValue(eventCode!, out var t) ? t : null;
+
+    /// <summary>收件人类型的业务文案(页面只读展示,不出现技术枚举)。</summary>
+    public static string DescribeRecipientType(string? type) => type switch
+    {
+        S8RecipientType.HandlerPool => "处理人员",
+        S8RecipientType.Assignee => "当前处理人",
+        S8RecipientType.Reviewer => "当前复核人",
+        S8RecipientType.EscalationPool => "升级人员",
+        S8RecipientType.SpecificUser => "指定账号",
+        _ => "未配置",
+    };
+
     /// <summary>
     /// 租户级默认配置的 <c>rule_code</c> 哨兵值。
     /// <para>人工提报的异常没有来源规则,它的通知只能落在租户级默认上。

+ 65 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Const/S8/S8ResponsibilityCatalog.cs

@@ -0,0 +1,65 @@
+namespace Admin.NET.Plugin.AiDOP.Const.S8;
+
+/// <summary>
+/// S8-RESPONSIBILITY-POOL-1:规则的三种责任类型。
+///
+/// <para><b>与角色严格分离</b>:<c>SysRole</c> 回答「这个账号<b>能做什么</b>」
+/// (认领 / 转派 / 审核 / 升级这类动作权限);责任池回答「这个账号<b>负责什么</b>」
+/// (哪一条规则产生的异常归他)。最终允许 = 动作权限 ∩ 规则责任 ∩ 状态机。</para>
+///
+/// <para><b>刻意不引入</b> Supervisor / 部门负责人 / L1-L2-L3 三层虚拟层级:
+/// 系统里不存在组织主管关系(<c>SysOrg.DirectorId</c> 与 <c>SysUser.ManagerUserId</c>
+/// 实测填充率均为 0),拿它当产品主模型只会配出一条永远解析为空的责任链。
+/// 将来业务真需要层级升级时,另建真正的 Escalation Policy,不在这里假装。</para>
+/// </summary>
+public static class S8ResponsibilityType
+{
+    /// <summary>处理账号池:谁负责处理这条规则产生的异常。存量表 <c>ado_s8_rule_handler_user</c>。</summary>
+    public const string Handler = "HANDLER";
+
+    /// <summary>复核账号池:谁有资格被选为这条规则的复核人。</summary>
+    public const string Reviewer = "REVIEWER";
+
+    /// <summary>升级账号池:异常超时 / 触发升级时找谁。</summary>
+    public const string Escalation = "ESCALATION";
+}
+
+/// <summary>责任类型的展示元信息。后端校验、配置接口、前端 UI 三方共用同一份。</summary>
+/// <param name="Code">类型编码。</param>
+/// <param name="DisplayName">业务用户看到的名字(不出现任何技术术语)。</param>
+/// <param name="Description">页面上的一句话说明。</param>
+/// <param name="OrderNo">展示顺序。</param>
+public sealed record S8ResponsibilityDefinition(string Code, string DisplayName, string Description, int OrderNo);
+
+/// <summary>责任类型目录。</summary>
+public static class S8ResponsibilityCatalog
+{
+    public static readonly IReadOnlyList<S8ResponsibilityDefinition> All = new List<S8ResponsibilityDefinition>
+    {
+        new(S8ResponsibilityType.Handler, "处理人员", "以下账号负责处理该规则产生的异常。", 10),
+        new(S8ResponsibilityType.Reviewer, "复核人员", "处理人提交复核时,只能从以下账号中选择复核人。", 20),
+        new(S8ResponsibilityType.Escalation, "升级人员", "异常超时或触发升级时通知以下账号。", 30),
+    };
+
+    private static readonly HashSet<string> TypeSet =
+        All.Select(x => x.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+    public static bool IsKnown(string? code) => !string.IsNullOrWhiteSpace(code) && TypeSet.Contains(code!);
+
+    public static S8ResponsibilityDefinition? Find(string? code) =>
+        string.IsNullOrWhiteSpace(code)
+            ? null
+            : All.FirstOrDefault(x => string.Equals(x.Code, code, StringComparison.OrdinalIgnoreCase));
+
+    /// <summary>
+    /// 存储在新表 <c>ado_s8_rule_responsibility_user</c> 里的类型。
+    ///
+    /// <para><b>HANDLER 不在其中</b>:它有稳定的存量表与生产读路径(就绪门禁 / 认领资格 /
+    /// 可见性收敛 / 通知解析都已依赖),为了"三张表长得一样"去迁它,收益是审美、
+    /// 代价是动四条已验证的链路。调用方通过统一 reader 拿到一致的 API,
+    /// 存储差异被收敛在一处。</para>
+    /// </summary>
+    public static bool IsStoredInResponsibilityTable(string? code) =>
+        string.Equals(code, S8ResponsibilityType.Reviewer, StringComparison.OrdinalIgnoreCase)
+        || string.Equals(code, S8ResponsibilityType.Escalation, StringComparison.OrdinalIgnoreCase);
+}

+ 112 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs

@@ -231,6 +231,108 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
 
+    /// <summary>
+    /// S8-RESPONSIBILITY-POOL-1:读取该规则某一类责任池。
+    /// <para><c>type</c> ∈ <c>HANDLER</c> / <c>REVIEWER</c> / <c>ESCALATION</c>。
+    /// 三类共用同一套读写 API —— 存储差异(HANDLER 在存量表)被收敛在服务层,
+    /// 调用方不需要知道。</para>
+    /// </summary>
+    [HttpGet("{id:long}/responsibility/{type}")]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
+    public async Task<IActionResult> ResponsibilityPoolAsync(long id, string type,
+        [FromServices] S8RuleResponsibilityPoolService pool,
+        [FromServices] SqlSugarRepository<Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8WatchRule> ruleRep)
+    {
+        try
+        {
+            if (!S8ResponsibilityCatalog.IsKnown(type))
+                throw new S8BizException($"未知的责任类型:{type}");
+
+            var scope = await _scope.ResolveAsync();
+            var ruleCode = await ruleRep.AsQueryable().ClearFilter()
+                .Where(x => x.Id == id && x.TenantId == scope.TenantId)
+                .Select(x => x.RuleCode).FirstAsync()
+                ?? throw new S8BizException("规则不存在");
+
+            var members = await pool.GetMembersAsync(scope.TenantId, ruleCode, type);
+            var def = S8ResponsibilityCatalog.Find(type);
+            return Ok(new
+            {
+                ruleCode,
+                type,
+                title = def?.DisplayName,
+                description = def?.Description,
+                members,
+                validCount = members.Count(m => m.Valid),
+            });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    /// <summary>全量替换该规则某一类责任池。成员必须是本租户的启用账号。</summary>
+    [HttpPut("{id:long}/responsibility/{type}")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
+    public async Task<IActionResult> SetResponsibilityPoolAsync(long id, string type,
+        [FromBody] AdoS8HandlerPoolSetDto? body,
+        [FromServices] S8RuleResponsibilityPoolService pool,
+        [FromServices] SqlSugarRepository<Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8WatchRule> ruleRep)
+    {
+        try
+        {
+            var scope = await _scope.ResolveAsync();
+            var ruleCode = await ruleRep.AsQueryable().ClearFilter()
+                .Where(x => x.Id == id && x.TenantId == scope.TenantId)
+                .Select(x => x.RuleCode).FirstAsync()
+                ?? throw new S8BizException("规则不存在");
+            await pool.SetMembersAsync(scope, ruleCode, type, body?.UserIds);
+            return Ok(new { ruleCode, type, memberCount = body?.UserIds?.Count ?? 0 });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    /// <summary>
+    /// S8-RESPONSIBILITY-POOL-1:「通知设置」页的事件开关矩阵。
+    /// <para>只回答「什么事件需要提醒」;提醒谁由责任关系推导,页面只读展示业务文案。</para>
+    /// </summary>
+    [HttpGet("{id:long}/notify-events")]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
+    public async Task<IActionResult> NotifyEventsAsync(long id,
+        [FromServices] S8NotificationRecipientConfigService svc,
+        [FromServices] SqlSugarRepository<Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8WatchRule> ruleRep)
+    {
+        try
+        {
+            var scope = await _scope.ResolveAsync();
+            var ruleCode = await ruleRep.AsQueryable().ClearFilter()
+                .Where(x => x.Id == id && x.TenantId == scope.TenantId)
+                .Select(x => x.RuleCode).FirstAsync()
+                ?? throw new S8BizException("规则不存在");
+            return Ok(new { ruleCode, events = await svc.GetEventSwitchesAsync(scope.TenantId, ruleCode) });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    /// <summary>按开关启用 / 关闭某事件的通知。收件人类型由目录推导,请求体不传。</summary>
+    [HttpPut("{id:long}/notify-events/{eventCode}")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
+    public async Task<IActionResult> SetNotifyEventAsync(long id, string eventCode,
+        [FromBody] AdoS8NotifyEventToggleDto? body,
+        [FromServices] S8NotificationRecipientConfigService svc,
+        [FromServices] SqlSugarRepository<Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8WatchRule> ruleRep)
+    {
+        try
+        {
+            var scope = await _scope.ResolveAsync();
+            var ruleCode = await ruleRep.AsQueryable().ClearFilter()
+                .Where(x => x.Id == id && x.TenantId == scope.TenantId)
+                .Select(x => x.RuleCode).FirstAsync()
+                ?? throw new S8BizException("规则不存在");
+            await svc.SetEventEnabledAsync(scope, ruleCode, eventCode, body?.Enabled ?? false);
+            return Ok(new { ruleCode, eventCode, enabled = body?.Enabled ?? false });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
     /// <summary>
     /// S8-RULE-4TAB-1:<b>只读</b>返回「这条规则的异常,审核时到底由谁审」。
     ///
@@ -424,13 +526,22 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
     }
 }
 
-/// <summary>规则处理账号池设置载荷。</summary>
+/// <summary>责任池设置载荷(处理 / 复核 / 升级三类共用)。</summary>
 public class AdoS8HandlerPoolSetDto
 {
     /// <summary>完整成员集合(全量替换)。传空数组 = 清空责任池,该规则将无法启用。</summary>
     public List<long>? UserIds { get; set; }
 }
 
+/// <summary>
+/// 事件通知开关载荷。
+/// <para><b>刻意只有一个布尔</b>:收件人由责任关系推导,业务用户不选技术类型。</para>
+/// </summary>
+public class AdoS8NotifyEventToggleDto
+{
+    public bool Enabled { get; set; }
+}
+
 /// <summary>事件收件人设置载荷。</summary>
 public class AdoS8NotifyRecipientSetDto
 {

+ 37 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ExceptionsController.cs

@@ -171,6 +171,43 @@ public class AdoS8ExceptionsController : ControllerBase
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
 
+    /// <summary>
+    /// S8-RESPONSIBILITY-POOL-1:该异常当前可选的复核人。
+    ///
+    /// <para>候选 = 该规则的<b>复核账号池</b> ∩ 拥有「审核」动作权限 ∩ 同租户启用账号。
+    /// 与 <c>submit-verification</c> 的服务端校验<b>同一份实现</b>
+    /// (<see cref="IS8VerifierEligibility"/>)—— 候选列表只是"好用",
+    /// 真正的门在服务端,绕过 UI 直接调 API 注入池外账号同样会被拒。</para>
+    ///
+    /// <para>人工提报的异常没有来源规则,候选回落为本租户内有审核权限的账号
+    /// (与认领 / 转派对人工提报的豁免同一口径)。</para>
+    /// </summary>
+    [HttpGet("{id:long}/verifier-candidates")]
+    [S8ExceptionAction(S8ExceptionActionCode.SubmitVerify)]
+    public async Task<IActionResult> VerifierCandidatesAsync(long id,
+        [FromServices] IS8VerifierEligibility eligibility,
+        [FromServices] SqlSugarRepository<Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8Exception> rep)
+    {
+        try
+        {
+            var scope = await _scope.ResolveAsync();
+            var e = await rep.AsQueryable().ClearFilter()
+                .Where(x => x.Id == id && x.TenantId == scope.TenantId && !x.IsDeleted)
+                .FirstAsync()
+                ?? throw new S8BizException("异常不存在");
+
+            var candidates = await eligibility.ListCandidatesAsync(scope.TenantId, e);
+            return Ok(new
+            {
+                ruleCode = e.SourceRuleCode,
+                // 无来源规则 = 人工提报,前端据此说明"候选来自全租户"而不是"复核池"。
+                fromReviewerPool = !string.IsNullOrWhiteSpace(e.SourceRuleCode),
+                candidates,
+            });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
     [HttpPost("{id:long}/submit-verification")]
     [S8ExceptionAction(S8ExceptionActionCode.SubmitVerify)]
     public async Task<IActionResult> SubmitVerificationAsync(long id,

+ 49 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8RuleResponsibilityUser.cs

@@ -0,0 +1,49 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+/// <summary>
+/// S8-RESPONSIBILITY-POOL-1:规则的<b>复核账号池</b>与<b>升级账号池</b>成员。
+///
+/// <para><b>为什么不含 HANDLER</b>:处理账号池已有 <see cref="AdoS8RuleHandlerUser"/>,
+/// 且就绪门禁、认领资格、可见性收敛、通知收件人解析四条生产链路都已依赖它。
+/// 把它迁进本表只换来"三种责任长得一样"的观感,代价却是动四条已经跑通的链路 ——
+/// 存储差异由统一 reader 收敛在一处,调用方看到的仍是同一套 API。</para>
+///
+/// <para><b>身份用 <c>rule_code</c> 而不是 <c>rule_id</c></b>:与
+/// <see cref="AdoS8RuleHandlerUser"/> 同口径。<c>ado_s8_watch_rule</c> 的行会被
+/// clean reset + provisioning 重建、id 全部变化;绑 id 会让责任池在那一刻集体失联,
+/// 而且不报错 —— 规则照常跑,只是谁都不再"负责"它。</para>
+///
+/// <para><b>唯一索引带 <c>responsibility_type</c></b>:同一个账号既是复核人又是升级人
+/// 是合法配置,两条行必须能共存;而"同一类型下重复配同一人"必须在 DB 层挡住,
+/// 不能只靠应用层自觉。</para>
+/// </summary>
+[SugarTable("ado_s8_rule_responsibility_user", "S8 规则复核/升级账号池")]
+[SugarIndex("uk_s8_rule_responsibility", nameof(TenantId), OrderByType.Asc, nameof(RuleCode), OrderByType.Asc, nameof(ResponsibilityType), OrderByType.Asc, nameof(UserId), OrderByType.Asc, IsUnique = true)]
+[SugarIndex("idx_s8_rule_responsibility_user", nameof(TenantId), OrderByType.Asc, nameof(UserId), OrderByType.Asc)]
+public class AdoS8RuleResponsibilityUser
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    /// <summary>租户。<b>唯一安全边界</b>;工厂不参与。</summary>
+    [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
+    public long TenantId { get; set; }
+
+    /// <summary>规则编码(与 provisioning 身份口径一致,不用 rule_id)。</summary>
+    [SugarColumn(ColumnName = "rule_code", Length = 128)]
+    public string RuleCode { get; set; } = string.Empty;
+
+    /// <summary>责任类型:<c>REVIEWER</c> / <c>ESCALATION</c>(见 <c>S8ResponsibilityType</c>)。</summary>
+    [SugarColumn(ColumnName = "responsibility_type", Length = 32)]
+    public string ResponsibilityType { get; set; } = string.Empty;
+
+    /// <summary>成员账号(<c>SysUser.Id</c>)。写入时经 <c>S8UserScopeValidator</c> 校验同租户 + 启用。</summary>
+    [SugarColumn(ColumnName = "user_id", ColumnDataType = "bigint")]
+    public long UserId { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnName = "created_by", Length = 64, IsNullable = true)]
+    public string? CreatedBy { get; set; }
+}

+ 148 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8VerifierEligibility.cs

@@ -0,0 +1,148 @@
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+/// <summary>复核人资格判定结果。拒绝时带原因码,让「没审核权限」与「不在复核池」可分辨。</summary>
+public sealed record S8VerifierEligibilityResult(bool Allowed, string? ReasonCode, string? Message)
+{
+    public static S8VerifierEligibilityResult Pass() => new(true, null, null);
+    public static S8VerifierEligibilityResult Deny(string reason, string message) => new(false, reason, message);
+}
+
+/// <summary>复核人被拒的原因码。</summary>
+public static class S8VerifierDenyReason
+{
+    /// <summary>账号没有「审核」这个动作的权限(角色维度)。</summary>
+    public const string NoActionPermission = "verifier_no_action_permission";
+
+    /// <summary>账号不在这条规则的复核账号池里(责任维度)。</summary>
+    public const string NotInReviewerPool = "verifier_not_in_reviewer_pool";
+
+    /// <summary>账号不存在 / 已停用 / 不在本租户。</summary>
+    public const string NotUsable = "verifier_not_usable";
+}
+
+/// <summary>复核人候选(提交复核时的下拉项)。</summary>
+public sealed class S8VerifierCandidateDto
+{
+    public long UserId { get; set; }
+    public string Account { get; set; } = string.Empty;
+    public string DisplayName { get; set; } = string.Empty;
+
+    /// <summary>页面展示文案:<c>RealName (Account)</c>。前后端共用同一份,避免两处各拼各的。</summary>
+    public string Label { get; set; } = string.Empty;
+}
+
+/// <summary>
+/// S8-RESPONSIBILITY-POOL-1:「谁可以被选为这条异常的复核人」的<b>唯一</b>判定实现。
+///
+/// <para><b>三个条件同时成立才算候选</b>:
+/// <list type="number">
+///   <item><b>规则责任</b>:账号在该异常来源规则的<b>复核账号池</b>里;</item>
+///   <item><b>动作权限</b>:角色被授予 <c>EXCEPTION_VERIFY</c>(能真的点「通过 / 退回」);</item>
+///   <item><b>账号可用</b>:同租户 + 启用。</item>
+/// </list></para>
+///
+/// <para><b>候选列表与提交校验必须同源</b>——这正是本类存在的理由。
+/// 两边各写一套判据,就会出现「页面能选、提交被拒」或更糟的
+/// 「页面选了甲、后端放行乙」。上一批 Gate 修的错位形态就是同一个病根。</para>
+///
+/// <para><b>人工提报的异常没有来源规则</b>(<c>SourceRuleCode</c> 为空),不受复核池约束 ——
+/// 池是"谁负责这条规则",而它根本不来自任何规则。一刀切要求入池,
+/// 人工提报的异常将永远提交不了复核。此时候选 = 本租户内有审核权限的账号。</para>
+/// </summary>
+public interface IS8VerifierEligibility
+{
+    /// <summary>判定某账号能否成为该异常的复核人。</summary>
+    Task<S8VerifierEligibilityResult> CheckAsync(long tenantId, long verifierUserId, AdoS8Exception exception);
+
+    /// <summary>列出该异常当前<b>全部</b>合格的复核人候选。与 <see cref="CheckAsync"/> 判据同源。</summary>
+    Task<List<S8VerifierCandidateDto>> ListCandidatesAsync(long tenantId, AdoS8Exception exception);
+}
+
+/// <summary>正式实现。生产代码里只有这一份;接口只为测试留 seam。</summary>
+public sealed class S8VerifierEligibility : IS8VerifierEligibility, ITransient
+{
+    private readonly IS8ExceptionActionAuthorizer _authorizer;
+    private readonly IS8RuleResponsibilityReader _pools;
+    private readonly IS8UserScopeValidator _userScope;
+    private readonly SqlSugarRepository<SysUser> _userRep;
+
+    public S8VerifierEligibility(
+        IS8ExceptionActionAuthorizer authorizer,
+        IS8RuleResponsibilityReader pools,
+        IS8UserScopeValidator userScope,
+        SqlSugarRepository<SysUser> userRep)
+    {
+        _authorizer = authorizer;
+        _pools = pools;
+        _userScope = userScope;
+        _userRep = userRep;
+    }
+
+    public async Task<S8VerifierEligibilityResult> CheckAsync(long tenantId, long verifierUserId, AdoS8Exception exception)
+    {
+        // ① 账号可用性。跨租户与不存在共用同一文案,不泄漏存在性。
+        var user = await _userScope.FindActiveUserAsync(tenantId, verifierUserId);
+        if (user == null)
+            return S8VerifierEligibilityResult.Deny(
+                S8VerifierDenyReason.NotUsable, "所选账号不存在或已停用");
+
+        // ② 规则责任。人工提报无来源规则,不受池约束(见接口注释)。
+        var ruleCode = exception.SourceRuleCode;
+        if (!string.IsNullOrWhiteSpace(ruleCode)
+            && !await _pools.IsMemberAsync(tenantId, ruleCode!, S8ResponsibilityType.Reviewer, verifierUserId))
+        {
+            return S8VerifierEligibilityResult.Deny(
+                S8VerifierDenyReason.NotInReviewerPool,
+                $"{user.DisplayName} 不是该规则的复核人员,不能作为检验人。如需由其复核,请在规则的「责任分配」中将其加入复核账号池。");
+        }
+
+        // ③ 动作权限 —— 选出来的人要真的能点「检验通过 / 检验退回」。
+        var auth = await _authorizer.AuthorizeAsync(tenantId, verifierUserId, S8ExceptionActionCode.Verify);
+        if (!auth.Allowed)
+            return S8VerifierEligibilityResult.Deny(
+                S8VerifierDenyReason.NoActionPermission,
+                $"{user.DisplayName} 没有审核权限,不能作为检验人。请在「异常操作权限」中为其角色授予「审核」,或改选其他账号。");
+
+        return S8VerifierEligibilityResult.Pass();
+    }
+
+    public async Task<List<S8VerifierCandidateDto>> ListCandidatesAsync(long tenantId, AdoS8Exception exception)
+    {
+        var ruleCode = exception.SourceRuleCode;
+
+        // 候选来源:有来源规则 → 该规则的复核池;人工提报 → 本租户账号。
+        IReadOnlyList<long> poolIds = string.IsNullOrWhiteSpace(ruleCode)
+            ? await _userRep.AsQueryable().ClearFilter()
+                .Where(u => u.TenantId == tenantId)
+                .Take(S8UserScopeValidator.MaxHydrationKeys)
+                .Select(u => u.Id)
+                .ToListAsync()
+            : await _pools.GetValidMemberIdsAsync(tenantId, ruleCode!, S8ResponsibilityType.Reviewer);
+
+        if (poolIds.Count == 0) return new List<S8VerifierCandidateDto>();
+
+        var usable = await _userScope.ValidateUsersAsync(tenantId, poolIds);
+
+        var result = new List<S8VerifierCandidateDto>();
+        foreach (var u in usable)
+        {
+            // 与 CheckAsync 的第 ③ 步同一个调用,不另写一套权限判据。
+            var auth = await _authorizer.AuthorizeAsync(tenantId, u.UserId, S8ExceptionActionCode.Verify);
+            if (!auth.Allowed) continue;
+
+            result.Add(new S8VerifierCandidateDto
+            {
+                UserId = u.UserId,
+                Account = u.Account,
+                DisplayName = u.DisplayName,
+                Label = $"{u.DisplayName} ({u.Account})",
+            });
+        }
+
+        return result.OrderBy(x => x.DisplayName, StringComparer.CurrentCulture).ToList();
+    }
+}

+ 84 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/ExceptionClosureBizHandler.cs

@@ -1,6 +1,8 @@
+using Admin.NET.Core;
 using Admin.NET.Plugin.ApprovalFlow;
 using Admin.NET.Plugin.ApprovalFlow.Service;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Microsoft.Extensions.Logging;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
 
@@ -15,13 +17,22 @@ public class ExceptionClosureBizHandler : IFlowBizHandler, ITransient
 
     private readonly SqlSugarRepository<AdoS8Exception> _rep;
     private readonly SqlSugarRepository<AdoS8ExceptionTimeline> _timelineRep;
+    private readonly SqlSugarRepository<ApprovalFlowTask> _taskRep;
+    private readonly SqlSugarRepository<SysUser> _userRep;
+    private readonly ILogger<ExceptionClosureBizHandler> _logger;
 
     public ExceptionClosureBizHandler(
         SqlSugarRepository<AdoS8Exception> rep,
-        SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep)
+        SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep,
+        SqlSugarRepository<ApprovalFlowTask> taskRep,
+        SqlSugarRepository<SysUser> userRep,
+        ILogger<ExceptionClosureBizHandler> logger)
     {
         _rep = rep;
         _timelineRep = timelineRep;
+        _taskRep = taskRep;
+        _userRep = userRep;
+        _logger = logger;
     }
 
     public async Task OnFlowStarted(long bizId, long instanceId)
@@ -31,9 +42,81 @@ public class ExceptionClosureBizHandler : IFlowBizHandler, ITransient
         e.ActiveFlowBizType = BizType;
         e.UpdatedAt = DateTime.Now;
         await _rep.UpdateAsync(e);
+        await AlignTaskToSelectedVerifierAsync(e, instanceId);
         await InsertTimelineAsync(e.Id, "VERIFY_FLOW_START", "复检审批流启动", instanceId, null);
     }
 
+    /// <summary>
+    /// S8-RESPONSIBILITY-POOL-1:把复检任务对齐到<b>页面上选定的那个检验人</b>。
+    ///
+    /// <para><b>要解决的错位</b>:<c>FlowEngineService.ResolveApprovers</c> 只看流程定义节点的
+    /// <c>ApproverType</c> / <c>ApproverIds</c>,<b>完全不读 <c>BizData</c></b> ——
+    /// 提交复核时传进去的 <c>verifierUserId</c> 从来没有参与过审批人解析。
+    /// 于是「<c>verifier_user_id</c>(业务状态机 authority)」与
+    /// 「<c>ApprovalFlowTask.AssigneeId</c>(审批任务 authority)」是两条各走各的链:
+    /// UI 选了甲,任务却发给节点角色里的那一组人。当前 UAT 之所以看起来正常,
+    /// 只是因为该角色恰好只有一名成员 —— 一旦角色多一个人,错位立刻出现且两边都不报错。</para>
+    ///
+    /// <para><b>为什么在这里对齐</b>:<c>StartFlow</c> 的顺序是
+    /// <c>ProcessNextNode</c>(建任务)→ <c>OnFlowStarted</c>(本回调),
+    /// 任务此刻已存在,S8 可以在自己的流程实例上把它指到正确的人。
+    /// <b>不改 FlowEngine</b> —— 那是 S1/S5/S6/S7 共用的平台组件,
+    /// 为 S8 一家加一条 BizData 动态审批人分支,风险面远超收益。</para>
+    ///
+    /// <para><b>节点默认或签</b>(一人通过即完成、其余 Pending 自动取消),
+    /// 因此把该节点的待办收敛成"选定检验人一个人"不改变流程语义。
+    /// 多余的待办直接删除而不是留着:留着就等于池外的人仍然能点通过,
+    /// 而 S8 侧的 <c>verifier_user_id</c> 校验又会拒绝他 —— 回到两边互相卡住的老形态。</para>
+    ///
+    /// <para>对齐失败<b>不回滚</b>已提交的复核(状态与时间线已落库),但必须落 Warning:
+    /// 静默失败会让"任务发错人"重新变成无从查起的问题。</para>
+    /// </summary>
+    private async Task AlignTaskToSelectedVerifierAsync(AdoS8Exception e, long instanceId)
+    {
+        try
+        {
+            if (e.VerifierUserId is not > 0) return;
+            var verifierId = e.VerifierUserId.Value;
+
+            var pending = await _taskRep.AsQueryable().ClearFilter()
+                .Where(t => t.InstanceId == instanceId && t.Status == FlowTaskStatusEnum.Pending)
+                .ToListAsync();
+            if (pending.Count == 0) return;
+
+            // 已经就是这个人:不写库、不留噪音日志。
+            if (pending.Count == 1 && pending[0].AssigneeId == verifierId) return;
+
+            var verifier = await _userRep.AsQueryable().ClearFilter()
+                .Where(u => u.Id == verifierId).FirstAsync();
+
+            var keep = pending.FirstOrDefault(t => t.AssigneeId == verifierId) ?? pending[0];
+            var drop = pending.Where(t => t.Id != keep.Id).Select(t => t.Id).ToList();
+
+            if (keep.AssigneeId != verifierId)
+            {
+                keep.AssigneeId = verifierId;
+                keep.AssigneeName = verifier?.RealName ?? verifier?.Account;
+                await _taskRep.AsUpdateable(keep)
+                    .UpdateColumns(t => new { t.AssigneeId, t.AssigneeName })
+                    .ExecuteCommandAsync();
+            }
+
+            if (drop.Count > 0)
+                await _taskRep.AsDeleteable().Where(t => drop.Contains(t.Id)).ExecuteCommandAsync();
+
+            _logger.LogInformation(
+                "s8_verify_task_aligned exceptionId={Id} instanceId={Instance} verifierUserId={Verifier} kept={Kept} dropped={Dropped}",
+                e.Id, instanceId, verifierId, keep.Id, drop.Count);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex,
+                "s8_verify_task_align_failed exceptionId={Id} instanceId={Instance} verifierUserId={Verifier};"
+                + "复核已提交但审批任务可能仍指向流程定义里的角色成员,需人工核对",
+                e.Id, instanceId, e.VerifierUserId);
+        }
+    }
+
     public async Task OnFlowCompleted(long bizId, long instanceId, FlowInstanceStatusEnum finalStatus, long? lastApproverId)
     {
         var e = await _rep.GetByIdAsync(bizId) ?? throw new S8BizException("异常不存在");

+ 14 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/Definitions/S8PurchaseDeliveryRuleDefinitions.cs

@@ -99,7 +99,20 @@ public sealed class S8PurchaseDeliveryRuleDefinitions : IS8RuleDefinitionSource
                 // 命中行带不出 occurrence / responsible,建单时部门只能来自这两个运行参数。
                 // 不配就是「启用成功、调度成功、异常一条建不出来」——各层都报成功的静默失败。
                 // 故声明为启用前置条件,由 S8RuleReadinessGate 在三条执行入口统一拦截。
-                RequiresDepartmentDefaultsForEnable = true
+                RequiresDepartmentDefaultsForEnable = true,
+
+                // S8-RESPONSIBILITY-POOL-1:三条责任判据显式化。
+                //
+                // 处理池一条是**把既有行为原样写出来**:拆分前它被写在部门分支之后,
+                // 对本规则本就生效;对其余规则本就被跳过。此处置 true 后行为不变。
+                //
+                // 复核与升级是本批新增的启用前置:本规则的异常走完整闭环
+                // (认领 → 处理 → 提交复核 → 通过/退回),且参与 SLA 超时升级。
+                // 少配任何一个池,对应环节都会在运行时静默断掉 ——
+                // 复核候选为空则单据卡在 IN_PROGRESS,升级收件人为空则超时无人知晓。
+                RequiresHandlerPoolForEnable = true,
+                RequiresVerification = true,
+                SupportsTimeoutEscalation = true
             }
         }
     };

+ 40 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/Definitions/S8RuleDefinition.cs

@@ -214,4 +214,44 @@ public sealed class S8RuleParameterPolicy
     /// 与 <see cref="AllowsDepartmentDefaults"/> 是两件事:后者管"能不能配",本项管"必须配"。</para>
     /// </summary>
     public bool RequiresDepartmentDefaultsForEnable { get; init; }
+
+    /// <summary>
+    /// S8-RESPONSIBILITY-POOL-1:启用前是否必须已配置<b>处理账号池</b>。
+    ///
+    /// <para><b>本项是把既有判据显式化,不是新增门禁。</b>处理池判据原先写在
+    /// <see cref="S8RuleReadinessGate"/> 里、却位于 <see cref="RequiresDepartmentDefaultsForEnable"/>
+    /// 的提前返回<b>之后</b> —— 于是它事实上只对"必须配部门"的规则生效,
+    /// 对其余规则被静默跳过。这种「两件事被一个开关捆在一起」的耦合,
+    /// 在新增复核 / 升级两条判据时会立刻放大成三重耦合。</para>
+    ///
+    /// <para>拆出独立开关后,各条判据由各自的声明驱动;当前取值刻意与拆分前的
+    /// <b>实际行为完全一致</b>(仅 Rule 01 为 <c>true</c>),本批不顺带改变任何规则的启用条件。</para>
+    /// </summary>
+    public bool RequiresHandlerPoolForEnable { get; init; }
+
+    /// <summary>
+    /// S8-RESPONSIBILITY-POOL-1:该规则产生的异常,处理流程是否<b>包含复核环节</b>。
+    ///
+    /// <para>为 <c>true</c> 时,启用前必须已配置至少一名有效的<b>复核账号</b> ——
+    /// 否则处理人做完点「提交复核」会发现候选人列表是空的,单据卡在
+    /// IN_PROGRESS 出不去,而规则本身一路显示正常。</para>
+    ///
+    /// <para><b>不做成全局要求</b>:将来若有规则处理完即闭环、根本不走复核,
+    /// 强制它配复核人只会逼出一个占位账号 —— 那比没有配置更危险。</para>
+    /// </summary>
+    public bool RequiresVerification { get; init; }
+
+    /// <summary>
+    /// S8-RESPONSIBILITY-POOL-1:该规则的异常是否参与<b>超时自动升级</b>。
+    ///
+    /// <para>为 <c>true</c> 时,启用前必须已配置至少一名有效的<b>升级账号</b>,
+    /// 且超时升级作业按<b>本规则的升级账号池</b>解析收件人。</para>
+    ///
+    /// <para><b>取代 <c>exception_type.escalate_role_code</c> 成为 S8 规则链路的升级 authority</b>:
+    /// 旧字段是跨租户扫描时按异常类型查的,实测会把 B 租户的 <c>escalate_role_code</c>
+    /// 用到 A 租户的异常上(见 <c>S8TimeoutAutoEscalationService</c> 原注释)。
+    /// 责任池天然带 <c>tenant_id</c>,不存在这条越界路径。旧字段保留供其它模块兼容,
+    /// S8 规则链路不再依赖。</para>
+    /// </summary>
+    public bool SupportsTimeoutEscalation { get; init; }
 }

+ 98 - 25
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8RuleReadinessGate.cs

@@ -1,3 +1,4 @@
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
@@ -17,6 +18,19 @@ public static class S8RuleReadinessReasonCode
     /// 而调度器每一轮都报成功。与"配了部门但没人处理"是同一类静默失败,必须在启用前挡住。</para>
     /// </summary>
     public const string HandlerPoolEmpty = "rule_handler_pool_empty";
+
+    /// <summary>
+    /// 规则声明了包含复核环节,却没有任何有效的复核账号(S8-RESPONSIBILITY-POOL-1)。
+    /// <para>处理人做完点「提交复核」会发现候选人列表是空的,单据卡在 IN_PROGRESS 出不去,
+    /// 而规则本身一路显示「已启用 / 最近结果:成功」。</para>
+    /// </summary>
+    public const string ReviewerPoolEmpty = "rule_reviewer_pool_empty";
+
+    /// <summary>
+    /// 规则声明了参与超时升级,却没有任何有效的升级账号(S8-RESPONSIBILITY-POOL-1)。
+    /// <para>异常超时后没有任何人被通知,异常静静躺到过期 —— 升级作业每一轮都报成功。</para>
+    /// </summary>
+    public const string EscalationPoolEmpty = "rule_escalation_pool_empty";
 }
 
 /// <summary>就绪判定结果。<see cref="Ok"/> 为 true 时另外两项为 null。</summary>
@@ -71,29 +85,80 @@ public sealed class S8RuleReadinessCheck
 public sealed class S8RuleReadinessGate : ITransient
 {
     private readonly IS8DepartmentScopeValidator _deptValidator;
-    private readonly IS8RuleHandlerPoolReader _handlerPool;
+    private readonly IS8RuleResponsibilityReader _pools;
 
     public S8RuleReadinessGate(
         IS8DepartmentScopeValidator deptValidator,
-        IS8RuleHandlerPoolReader handlerPool)
+        IS8RuleResponsibilityReader pools)
     {
         _deptValidator = deptValidator;
-        _handlerPool = handlerPool;
+        _pools = pools;
     }
 
-    /// <summary>按生效形态判定就绪。不抛异常,由调用方决定失败语义(400 / run-result)。</summary>
+    /// <summary>
+    /// 按生效形态判定就绪。不抛异常,由调用方决定失败语义(400 / run-result)。
+    ///
+    /// <para><b>每条判据由各自的声明驱动</b>(S8-RESPONSIBILITY-POOL-1):
+    /// 拆分前处理池判据被写在部门分支的提前返回之后,事实上只对"必须配部门"的规则生效。
+    /// 判据之间没有业务上的从属关系,不该被一个开关捆在一起 ——
+    /// 尤其在要再加复核 / 升级两条判据的时候。</para>
+    /// </summary>
     public async Task<S8RuleReadinessCheck> CheckAsync(S8EffectiveRule effective, long tenantId)
     {
         var policy = effective.Definition.Parameters;
+        if (policy == null) return S8RuleReadinessCheck.Pass();
+
+        var ruleCode = effective.Row.RuleCode ?? string.Empty;
+
+        // ① 部门兜底参数(仅数据集拿不出部门的规则声明)。
+        if (policy.RequiresDepartmentDefaultsForEnable)
+        {
+            var deptCheck = await CheckDepartmentsAsync(effective, tenantId);
+            if (!deptCheck.Ok) return deptCheck;
+        }
+
+        // ② 处理账号池 —— 没人负责的规则会源源不断建单、无人认领,而调度器每轮都报成功。
+        if (policy.RequiresHandlerPoolForEnable)
+        {
+            var check = await CheckPoolAsync(
+                tenantId, ruleCode, S8ResponsibilityType.Handler,
+                S8RuleReadinessReasonCode.HandlerPoolEmpty,
+                "启用失败:请先配置至少一名处理账号。",
+                "启用失败:已配置的处理账号均已停用或不在当前租户,请重新配置。");
+            if (!check.Ok) return check;
+        }
+
+        // ③ 复核账号池 —— 空池会让「提交复核」的候选人列表为空,单据卡在 IN_PROGRESS。
+        if (policy.RequiresVerification)
+        {
+            var check = await CheckPoolAsync(
+                tenantId, ruleCode, S8ResponsibilityType.Reviewer,
+                S8RuleReadinessReasonCode.ReviewerPoolEmpty,
+                "启用失败:请先配置至少一名复核账号。",
+                "启用失败:已配置的复核账号均已停用或不在当前租户,请重新配置。");
+            if (!check.Ok) return check;
+        }
+
+        // ④ 升级账号池 —— 空池会让超时升级发不给任何人,异常静静躺到过期。
+        if (policy.SupportsTimeoutEscalation)
+        {
+            var check = await CheckPoolAsync(
+                tenantId, ruleCode, S8ResponsibilityType.Escalation,
+                S8RuleReadinessReasonCode.EscalationPoolEmpty,
+                "启用失败:请先配置至少一名升级账号。",
+                "启用失败:已配置的升级账号均已停用或不在当前租户,请重新配置。");
+            if (!check.Ok) return check;
+        }
 
-        // 未声明「启用必需部门」的规则不受本门禁约束。
-        if (policy is not { RequiresDepartmentDefaultsForEnable: true })
-            return S8RuleReadinessCheck.Pass();
+        return S8RuleReadinessCheck.Pass();
+    }
 
+    private async Task<S8RuleReadinessCheck> CheckDepartmentsAsync(S8EffectiveRule effective, long tenantId)
+    {
         var occurrence = effective.Parameters.DefaultOccurrenceDeptId;
         var responsible = effective.Parameters.DefaultResponsibleDeptId;
 
-        // ① 未配置 —— 与「配了但非法」分开报,管理员需要知道到底是哪一种。
+        // 未配置 —— 与「配了但非法」分开报,管理员需要知道到底是哪一种。
         if (!occurrence.HasValue)
             return S8RuleReadinessCheck.Fail(
                 S8RuleReadinessReasonCode.RequiredParameterMissing,
@@ -104,7 +169,7 @@ public sealed class S8RuleReadinessGate : ITransient
                 S8RuleReadinessReasonCode.RequiredParameterMissing,
                 "启用失败:请先配置默认责任部门。");
 
-        // ② 配了但不属当前租户(或已停用)。判据与运行时建单同源,见 S8DepartmentScopeValidator。
+        // 配了但不属当前租户(或已停用)。判据与运行时建单同源,见 S8DepartmentScopeValidator。
         if (!await _deptValidator.ExistsInTenantAsync(occurrence, tenantId))
             return S8RuleReadinessCheck.Fail(
                 S8RuleReadinessReasonCode.DepartmentNotInTenant,
@@ -115,25 +180,33 @@ public sealed class S8RuleReadinessGate : ITransient
                 S8RuleReadinessReasonCode.DepartmentNotInTenant,
                 "启用失败:默认责任部门不属于当前租户。");
 
-        // ③ S8-HANDLER-POOL-1:至少要有一个**当前有效**的处理账号。
-        //
-        // 判据走 GetMembersAsync 而不是「表里有没有行」:账号被停用或移出租户之后,
-        // 池子里的那一行还在,但那个人已经不能认领了。只数行数会让一条实际上没人能处理的
-        // 规则通过启用检查 —— 那正是本门禁存在的意义。
-        //
-        // Preview 不受此限(见类注释):预演只做只读试算,人员池与它的正确性无关,
-        // 管理员本来就该「先预演看命中,再配人,再启用」。
-        var members = await _handlerPool.GetMembersAsync(tenantId, effective.Row.RuleCode ?? string.Empty);
-        if (members.All(m => !m.Valid))
-            return S8RuleReadinessCheck.Fail(
-                S8RuleReadinessReasonCode.HandlerPoolEmpty,
-                members.Count == 0
-                    ? "启用失败:请先配置至少一名处理账号。"
-                    : "启用失败:已配置的处理账号均已停用或不在当前租户,请重新配置。");
-
         return S8RuleReadinessCheck.Pass();
     }
 
+    /// <summary>
+    /// 责任池判据:至少要有一个<b>当前有效</b>的成员。
+    ///
+    /// <para>判据走 <c>GetMembersAsync</c> 而不是「表里有没有行」:账号被停用或移出租户之后,
+    /// 池子里的那一行还在,但那个人已经不算数了。只数行数会让一条实际上没人负责的规则
+    /// 通过启用检查 —— 那正是本门禁存在的意义。</para>
+    ///
+    /// <para><b>「一个都没配」与「配了但都失效了」分开报</b>:管理员的下一步动作完全不同,
+    /// 前者是去配人,后者是去查那几个账号出了什么事。</para>
+    ///
+    /// <para><b>空池绝不回落到任何角色</b>(fail closed)。回落到 <c>ROLE_QC</c> 之流会让
+    /// 「我明明没给这条规则配人」和「有人在处理」同时成立,是最难查的一类错配。</para>
+    /// </summary>
+    private async Task<S8RuleReadinessCheck> CheckPoolAsync(
+        long tenantId, string ruleCode, string responsibilityType,
+        string reasonCode, string emptyMessage, string allInvalidMessage)
+    {
+        var members = await _pools.GetMembersAsync(tenantId, ruleCode, responsibilityType);
+        if (members.Any(m => m.Valid)) return S8RuleReadinessCheck.Pass();
+
+        return S8RuleReadinessCheck.Fail(
+            reasonCode, members.Count == 0 ? emptyMessage : allInvalidMessage);
+    }
+
     /// <summary>启用 / 立即执行路径用这一个:不就绪即抛 <see cref="S8BizException"/>(→ 400)。</summary>
     public async Task EnsureReadyAsync(S8EffectiveRule effective, long tenantId)
     {

+ 78 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationRecipientConfigService.cs

@@ -21,6 +21,27 @@ public sealed class S8NotifyEventRowDto
     public bool NotConfigured => RecipientTypes.Count == 0;
 }
 
+/// <summary>
+/// 「通知设置」页的一行:一个事件 + 它提醒谁(业务文案)+ 是否启用。
+/// <para>页面上不出现任何收件人技术枚举 —— 对象由责任关系推导。</para>
+/// </summary>
+public sealed class S8NotifyEventSwitchDto
+{
+    public string EventCode { get; set; } = string.Empty;
+    public string DisplayName { get; set; } = string.Empty;
+    public string TriggerHint { get; set; } = string.Empty;
+    public int OrderNo { get; set; }
+
+    /// <summary>是否已在新模型里启用通知。</summary>
+    public bool Enabled { get; set; }
+
+    /// <summary>提醒谁(业务文案,如「处理人员」「当前复核人」)。只读展示。</summary>
+    public string RecipientLabel { get; set; } = string.Empty;
+
+    /// <summary>当前配置是否就是代码级默认推导。false = 历史手工配置与默认不同,页面需提示。</summary>
+    public bool MatchesDerivedDefault { get; set; } = true;
+}
+
 /// <summary>
 /// S8-NOTIFY-RECIPIENT-1:收件人配置的读写。
 ///
@@ -74,6 +95,63 @@ public class S8NotificationRecipientConfigService : ITransient
         }).ToList();
     }
 
+    /// <summary>
+    /// S8-RESPONSIBILITY-POOL-1:业务视角的事件开关矩阵。
+    ///
+    /// <para>业务用户只回答「这件事要不要提醒」;<b>提醒谁由责任关系推导</b>
+    /// (见 <see cref="S8NotificationCatalog.DefaultRecipientByEvent"/>),
+    /// 页面上不出现 <c>HANDLER_POOL</c> / <c>ASSIGNEE</c> 这类技术枚举。</para>
+    /// </summary>
+    public async Task<List<S8NotifyEventSwitchDto>> GetEventSwitchesAsync(long tenantId, string ruleCode)
+    {
+        var matrix = await GetMatrixAsync(tenantId, ruleCode);
+        var byEvent = matrix.ToDictionary(m => m.EventCode, StringComparer.OrdinalIgnoreCase);
+
+        return S8NotificationCatalog.Events.OrderBy(e => e.OrderNo).Select(e =>
+        {
+            var derived = S8NotificationCatalog.ResolveDefaultRecipientType(e.Code);
+            var cfg = byEvent.GetValueOrDefault(e.Code);
+            var configured = cfg?.RecipientTypes ?? new List<string>();
+
+            // 「已启用」= 该事件在新模型里有任何收件人配置。
+            // 未启用时刻意**不**声称"不会通知"——旧 LEGACY 分层仍可能兜底,
+            // 页面据此显示"未启用(可能仍走旧配置)",而不是给一个会骗人的关闭态。
+            return new S8NotifyEventSwitchDto
+            {
+                EventCode = e.Code,
+                DisplayName = e.DisplayName,
+                TriggerHint = e.TriggerHint,
+                OrderNo = e.OrderNo,
+                Enabled = configured.Count > 0,
+                RecipientLabel = S8NotificationCatalog.DescribeRecipientType(
+                    configured.FirstOrDefault() ?? derived),
+                // 已配置的类型与代码级默认不一致 —— 通常是上一版手工配过。
+                // 不静默改写,只标出来让管理员知道这一行与默认推导不同。
+                MatchesDerivedDefault = configured.Count == 0
+                                        || (configured.Count == 1 && string.Equals(
+                                                configured[0], derived, StringComparison.OrdinalIgnoreCase)),
+            };
+        }).ToList();
+    }
+
+    /// <summary>
+    /// 按事件开关写入 / 移除收件人配置。收件人类型由目录推导,<b>调用方不传</b>。
+    ///
+    /// <para>这是「通知设置」页唯一的写入口:业务用户点的是开关,
+    /// 落库的仍是既有的收件人模型 —— 内部实现保留,只是不再暴露给页面。</para>
+    /// </summary>
+    public async Task SetEventEnabledAsync(S8TrustedScope scope, string ruleCode, string eventCode, bool enabled)
+    {
+        if (!S8NotificationCatalog.IsKnownEvent(eventCode))
+            throw new S8BizException($"未知事件码:{eventCode}");
+
+        var derived = S8NotificationCatalog.ResolveDefaultRecipientType(eventCode)
+            ?? throw new S8BizException($"事件 {eventCode} 没有默认收件人推导规则,无法按开关配置");
+
+        await SetAsync(scope, ruleCode, eventCode,
+            enabled ? new[] { derived } : Array.Empty<string>(), null);
+    }
+
     /// <summary>全量替换某事件的收件人配置。</summary>
     public async Task SetAsync(S8TrustedScope scope, string ruleCode, string eventCode,
         IEnumerable<string>? recipientTypes, IEnumerable<long>? specificUserIds)

+ 26 - 28
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationRecipientResolver.cs

@@ -37,9 +37,12 @@ public sealed class S8RecipientResolution
 /// <para><b>HANDLER_POOL 直接复用 P1-C 的责任池</b>,不复制第二份人员配置:
 /// 两份人员名单迟早不一致,而不一致的那一刻没有任何报错。</para>
 ///
-/// <para><b>REVIEWER 不是新建的 Reviewer Pool</b>:它解析当前事实 ——
-/// 优先取单据上的 <c>verifier_user_id</c>(提交复核时手选、真正能点通过/退回的人),
-/// 尚未提交复核时回落审批流节点解析出的审批人。本批不发明 Rule 级审核池。</para>
+/// <para><b>收件人由责任关系推导,不再让业务用户配技术枚举</b>(S8-RESPONSIBILITY-POOL-1):
+/// <c>HANDLER_POOL</c> / <c>ESCALATION_POOL</c> 直接复用规则的处理池与升级池;
+/// <c>REVIEWER</c> 优先取单据上的 <c>verifier_user_id</c>(提交复核时手选、真正能点通过/退回的人),
+/// 尚未提交复核时回落该规则的<b>复核账号池</b>。
+/// <b>不再解析审批流节点</b>——审批人来自流程定义的角色,与「谁有资格复核这条规则」
+/// 不是同一个问题,且会让通知链随流程节点结构悄悄变化。</para>
 ///
 /// <para><b>0 收件人必须可观测</b>:解析结果为空时返回 <c>ConfiguredButEmpty</c>,
 /// 由调用方落日志。绝不静默当作"发送成功"—— 那正是本模块反复出现的失败形态。</para>
@@ -54,21 +57,21 @@ public sealed class S8NotificationRecipientResolver : IS8NotificationRecipientRe
 {
     private readonly SqlSugarRepository<AdoS8NotificationRecipient> _rep;
     private readonly IS8RuleHandlerPoolReader _handlerPool;
+    private readonly IS8RuleResponsibilityReader _pools;
     private readonly IS8UserScopeValidator _userScope;
-    private readonly S8ReviewSourceService _reviewSource;
     private readonly ILogger<S8NotificationRecipientResolver> _logger;
 
     public S8NotificationRecipientResolver(
         SqlSugarRepository<AdoS8NotificationRecipient> rep,
         IS8RuleHandlerPoolReader handlerPool,
+        IS8RuleResponsibilityReader pools,
         IS8UserScopeValidator userScope,
-        S8ReviewSourceService reviewSource,
         ILogger<S8NotificationRecipientResolver> logger)
     {
         _rep = rep;
         _handlerPool = handlerPool;
+        _pools = pools;
         _userScope = userScope;
-        _reviewSource = reviewSource;
         _logger = logger;
     }
 
@@ -128,14 +131,29 @@ public sealed class S8NotificationRecipientResolver : IS8NotificationRecipientRe
                 if (string.IsNullOrWhiteSpace(e.SourceRuleCode)) return new List<long>();
                 return (await _handlerPool.GetMemberIdsAsync(tenantId, e.SourceRuleCode!)).ToList();
 
+            case S8RecipientType.EscalationPool:
+                // S8-RESPONSIBILITY-POOL-1:升级通知的收件来源是该规则的升级账号池。
+                // 人工提报无来源规则 → 解析为 0 人,由 ConfiguredButEmpty 落 Warning,
+                // **不回落到任何角色**:回落会让"没配升级人"看起来像"已有人跟进"。
+                if (string.IsNullOrWhiteSpace(e.SourceRuleCode)) return new List<long>();
+                return (await _pools.GetMemberIdsAsync(
+                    tenantId, e.SourceRuleCode!, S8ResponsibilityType.Escalation)).ToList();
+
             case S8RecipientType.Assignee:
                 return e.AssigneeUserId is > 0 ? new List<long> { e.AssigneeUserId.Value } : new List<long>();
 
             case S8RecipientType.Reviewer:
                 // 单据上已有检验人 = 真正能点通过/退回的那个人,最准确。
                 if (e.VerifierUserId is > 0) return new List<long> { e.VerifierUserId.Value };
-                // 尚未提交复核 → 回落审批流节点解析出的审批人(解析当前事实,不发明 pool)。
-                return await ResolveFlowApproversAsync(tenantId);
+                // S8-RESPONSIBILITY-POOL-1:尚未提交复核 → 回落到该规则的**复核账号池**。
+                //
+                // 原实现回落到审批流节点解析出的审批人。那条路径有两个问题:
+                // ① 审批人来自流程定义的角色,与「谁有资格复核这条规则」根本不是同一个问题;
+                // ② 它把通知链耦合到 ApprovalFlow 的内部结构上,流程节点一改,通知就悄悄变了。
+                // 复核池现在是 Rule 级的唯一 authority,通知与候选列表因此同源。
+                if (string.IsNullOrWhiteSpace(e.SourceRuleCode)) return new List<long>();
+                return (await _pools.GetMemberIdsAsync(
+                    tenantId, e.SourceRuleCode!, S8ResponsibilityType.Reviewer)).ToList();
 
             case S8RecipientType.SpecificUser:
                 return S8RoleResolver.SplitTokens(row.RecipientUserIds)
@@ -150,24 +168,4 @@ public sealed class S8NotificationRecipientResolver : IS8NotificationRecipientRe
         }
     }
 
-    /// <summary>从复核审批流节点解析实际审批人。解析不出就是解析不出,不回落到任何"兜底人"。</summary>
-    private async Task<List<long>> ResolveFlowApproversAsync(long tenantId)
-    {
-        var stages = await _reviewSource.DescribeAsync(tenantId);
-        var verify = stages.FirstOrDefault(s => s.Stage == "复核");
-        if (verify == null) return new List<long>();
-
-        // DescribeAsync 输出的是展示名,这里需要 Id —— 复用同一份租户账号集合做反查,
-        // 保证"页面上看到的审核人"与"实际收到通知的人"是同一批。
-        var tenantUsers = await _userScope.ValidateUsersAsync(tenantId,
-            await _rep.Context.Queryable<SysUser>().ClearFilter()
-                .Where(u => u.TenantId == tenantId)
-                .Select(u => u.Id).ToListAsync());
-
-        var display = verify.Nodes.SelectMany(n => n.ResolvedUsers).ToHashSet();
-        return tenantUsers
-            .Where(u => display.Contains($"{u.DisplayName} ({u.Account})"))
-            .Select(u => u.UserId)
-            .ToList();
-    }
 }

+ 199 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8RuleResponsibilityPoolService.cs

@@ -0,0 +1,199 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// 三种责任池的<b>统一</b>读侧 seam。
+///
+/// <para>就绪门禁、复核人候选、升级收件人、通知解析都只需要读。让它们依赖这一个窄接口
+/// 而不是"处理池一个接口、复核池另一个接口",是为了让<b>存储差异只存在一处</b>:
+/// HANDLER 在存量表、REVIEWER/ESCALATION 在新表,调用方一律不需要知道。</para>
+/// </summary>
+public interface IS8RuleResponsibilityReader
+{
+    /// <summary>池成员账号 Id(原样返回,含可能已失效的历史成员)。</summary>
+    Task<IReadOnlyList<long>> GetMemberIdsAsync(long tenantId, string ruleCode, string responsibilityType);
+
+    /// <summary>池成员(含展示信息与有效性标记)。失效成员照样返回、只标 <c>Valid=false</c>。</summary>
+    Task<List<S8HandlerUserDto>> GetMembersAsync(long tenantId, string ruleCode, string responsibilityType);
+
+    /// <summary>
+    /// 当前<b>真正可用</b>的成员账号 Id(同租户 + 启用)。
+    /// <para>运行期解析(升级通知、复核候选)一律用这个,不用 <see cref="GetMemberIdsAsync"/> ——
+    /// 把已停用的人算进收件人,会得到一次"发送成功但没人收到"的静默失败。</para>
+    /// </summary>
+    Task<IReadOnlyList<long>> GetValidMemberIdsAsync(long tenantId, string ruleCode, string responsibilityType);
+
+    /// <summary>该账号是否属于这条规则的某类责任池(停用 / 跨租户成员一律不算数)。</summary>
+    Task<bool> IsMemberAsync(long tenantId, string ruleCode, string responsibilityType, long userId);
+}
+
+/// <summary>
+/// S8-RESPONSIBILITY-POOL-1:复核池 / 升级池的唯一读写实现,并作为三种责任池的统一入口。
+///
+/// <para><b>HANDLER 内部路由到存量实现</b>(<see cref="IS8RuleHandlerPoolReader"/>),
+/// 本服务不复制第二份处理池逻辑 —— 两份人员判据迟早不一致,而不一致的那一刻没有任何报错。</para>
+///
+/// <para><b>成员合法性一律复用 <see cref="IS8UserScopeValidator"/></b>:
+/// 池成员、认领人、复核人、升级收件人必须用同一把尺子,否则会出现
+/// 「配得进池、运行时又被拒」这种管理员完全无法自查的状态。</para>
+/// </summary>
+public class S8RuleResponsibilityPoolService : IS8RuleResponsibilityReader, ITransient
+{
+    private readonly SqlSugarRepository<AdoS8RuleResponsibilityUser> _rep;
+    private readonly IS8RuleHandlerPoolReader _handlerReader;
+    private readonly S8RuleHandlerPoolService _handlerWriter;
+    private readonly IS8UserScopeValidator _userScope;
+    private readonly UserManager _userManager;
+    private readonly ILogger<S8RuleResponsibilityPoolService> _logger;
+
+    public S8RuleResponsibilityPoolService(
+        SqlSugarRepository<AdoS8RuleResponsibilityUser> rep,
+        IS8RuleHandlerPoolReader handlerReader,
+        S8RuleHandlerPoolService handlerWriter,
+        IS8UserScopeValidator userScope,
+        UserManager userManager,
+        ILogger<S8RuleResponsibilityPoolService> logger)
+    {
+        _rep = rep;
+        _handlerReader = handlerReader;
+        _handlerWriter = handlerWriter;
+        _userScope = userScope;
+        _userManager = userManager;
+        _logger = logger;
+    }
+
+    // ────────────────────────────── 读 ──────────────────────────────
+
+    public async Task<IReadOnlyList<long>> GetMemberIdsAsync(long tenantId, string ruleCode, string responsibilityType)
+    {
+        if (tenantId <= 0 || string.IsNullOrWhiteSpace(ruleCode)) return Array.Empty<long>();
+
+        if (IsHandler(responsibilityType))
+            return await _handlerReader.GetMemberIdsAsync(tenantId, ruleCode);
+
+        if (!S8ResponsibilityCatalog.IsStoredInResponsibilityTable(responsibilityType))
+            return Array.Empty<long>();
+
+        return await _rep.AsQueryable().ClearFilter()
+            .Where(x => x.TenantId == tenantId
+                        && x.RuleCode == ruleCode
+                        && x.ResponsibilityType == responsibilityType)
+            .Select(x => x.UserId)
+            .ToListAsync();
+    }
+
+    public async Task<List<S8HandlerUserDto>> GetMembersAsync(long tenantId, string ruleCode, string responsibilityType)
+    {
+        if (IsHandler(responsibilityType))
+            return await _handlerReader.GetMembersAsync(tenantId, ruleCode);
+
+        var ids = await GetMemberIdsAsync(tenantId, ruleCode, responsibilityType);
+        if (ids.Count == 0) return new List<S8HandlerUserDto>();
+
+        var valid = (await _userScope.ValidateUsersAsync(tenantId, ids)).ToDictionary(u => u.UserId);
+
+        // 失效成员照样返回、只标 Valid=false,不静默过滤:账号被停用或移出租户之后,
+        // 管理员必须能在页面上看见"这里有一个人已经不算数了",而不是眼看着池子无声地少一个人。
+        return ids.Distinct().Select(id => valid.TryGetValue(id, out var u)
+            ? new S8HandlerUserDto { UserId = id, Account = u.Account, DisplayName = u.DisplayName, Valid = true }
+            : new S8HandlerUserDto { UserId = id, Account = string.Empty, DisplayName = "账号已停用或不在本租户", Valid = false })
+            .ToList();
+    }
+
+    public async Task<IReadOnlyList<long>> GetValidMemberIdsAsync(long tenantId, string ruleCode, string responsibilityType)
+    {
+        var members = await GetMembersAsync(tenantId, ruleCode, responsibilityType);
+        return members.Where(m => m.Valid).Select(m => m.UserId).ToList();
+    }
+
+    public async Task<bool> IsMemberAsync(long tenantId, string ruleCode, string responsibilityType, long userId)
+    {
+        if (IsHandler(responsibilityType))
+            return await _handlerReader.IsMemberAsync(tenantId, ruleCode, userId);
+
+        if (tenantId <= 0 || userId <= 0 || string.IsNullOrWhiteSpace(ruleCode)) return false;
+        if (!S8ResponsibilityCatalog.IsStoredInResponsibilityTable(responsibilityType)) return false;
+
+        var exists = await _rep.AsQueryable().ClearFilter()
+            .Where(x => x.TenantId == tenantId
+                        && x.RuleCode == ruleCode
+                        && x.ResponsibilityType == responsibilityType
+                        && x.UserId == userId)
+            .AnyAsync();
+        if (!exists) return false;
+
+        // 表里有行 ≠ 现在还算数。判据与运行时完全同源。
+        return await _userScope.FindActiveUserAsync(tenantId, userId) != null;
+    }
+
+    // ────────────────────────────── 写 ──────────────────────────────
+
+    /// <summary>
+    /// 全量替换某条规则某一类责任池的成员。
+    ///
+    /// <para>成员必须是本租户的启用账号 —— <b>跨租户与不存在共用同一文案</b>,不泄漏存在性。</para>
+    /// </summary>
+    public async Task SetMembersAsync(S8TrustedScope scope, string ruleCode, string responsibilityType, IEnumerable<long>? userIds)
+    {
+        if (string.IsNullOrWhiteSpace(ruleCode)) throw new S8BizException("规则编码不能为空");
+        if (!S8ResponsibilityCatalog.IsKnown(responsibilityType))
+            throw new S8BizException($"未知的责任类型:{responsibilityType}");
+
+        // 处理池仍由存量服务写,保持唯一写入口,不在这里复制一份写逻辑。
+        if (IsHandler(responsibilityType))
+        {
+            await _handlerWriter.SetMembersAsync(scope, ruleCode, userIds);
+            return;
+        }
+
+        var wanted = (userIds ?? Enumerable.Empty<long>()).Where(x => x > 0).Distinct().ToList();
+
+        if (wanted.Count > 0)
+        {
+            var validated = await _userScope.ValidateUsersAsync(scope.TenantId, wanted);
+            var validIds = validated.Select(u => u.UserId).ToHashSet();
+            if (wanted.Any(id => !validIds.Contains(id)))
+                throw new S8BizException("所选账号不存在或已停用");
+        }
+
+        var existing = await _rep.AsQueryable().ClearFilter()
+            .Where(x => x.TenantId == scope.TenantId
+                        && x.RuleCode == ruleCode
+                        && x.ResponsibilityType == responsibilityType)
+            .ToListAsync();
+
+        var existingIds = existing.Select(x => x.UserId).ToHashSet();
+        var toRemove = existing.Where(x => !wanted.Contains(x.UserId)).Select(x => x.Id).ToList();
+        var toAdd = wanted.Where(id => !existingIds.Contains(id)).ToList();
+
+        if (toRemove.Count > 0)
+            await _rep.AsDeleteable().Where(x => toRemove.Contains(x.Id)).ExecuteCommandAsync();
+
+        if (toAdd.Count > 0)
+        {
+            var now = DateTime.Now;
+            await _rep.AsInsertable(toAdd.Select(id => new AdoS8RuleResponsibilityUser
+            {
+                TenantId = scope.TenantId,
+                RuleCode = ruleCode,
+                ResponsibilityType = responsibilityType,
+                UserId = id,
+                CreatedAt = now,
+                CreatedBy = _userManager.Account
+            }).ToList()).ExecuteCommandAsync();
+        }
+
+        _logger.LogInformation(
+            "s8_rule_responsibility_pool_updated tenant={Tenant} rule={Rule} type={Type} added={Added} removed={Removed} total={Total}",
+            scope.TenantId, ruleCode, responsibilityType, toAdd.Count, toRemove.Count, wanted.Count);
+    }
+
+    private static bool IsHandler(string? type) =>
+        string.Equals(type, S8ResponsibilityType.Handler, StringComparison.OrdinalIgnoreCase);
+}

+ 13 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs

@@ -15,6 +15,7 @@ public class S8TaskFlowService : ITransient
     private readonly IS8UserScopeValidator _userScope;
     private readonly IS8ClaimEligibility _claimEligibility;
     private readonly IS8ExceptionActionAuthorizer _actionAuthorizer;
+    private readonly IS8VerifierEligibility _verifierEligibility;
     private readonly IS8RuleHandlerPoolReader _handlerPool;
     private readonly SqlSugarRepository<ApprovalFlowInstance> _flowInstanceRep;
     private readonly SqlSugarRepository<ApprovalFlowTask> _flowTaskRep;
@@ -30,6 +31,7 @@ public class S8TaskFlowService : ITransient
         IS8UserScopeValidator userScope,
         IS8ClaimEligibility claimEligibility,
         IS8ExceptionActionAuthorizer actionAuthorizer,
+        IS8VerifierEligibility verifierEligibility,
         IS8RuleHandlerPoolReader handlerPool,
         SqlSugarRepository<ApprovalFlowInstance> flowInstanceRep,
         SqlSugarRepository<ApprovalFlowTask> flowTaskRep,
@@ -44,6 +46,7 @@ public class S8TaskFlowService : ITransient
         _userScope = userScope;
         _claimEligibility = claimEligibility;
         _actionAuthorizer = actionAuthorizer;
+        _verifierEligibility = verifierEligibility;
         _handlerPool = handlerPool;
         _flowInstanceRep = flowInstanceRep;
         _flowTaskRep = flowTaskRep;
@@ -379,17 +382,17 @@ public class S8TaskFlowService : ITransient
         EnsureCurrentUserIsOperator(e.AssigneeUserId, currentUserId, "只有当前处理人才能提交复检");
         var verifier = await _userScope.EnsureActiveUserAsync(tenantId, verifierUserId, "检验人");
 
-        // S8-UAT-APPROVAL-REPAIR-1:检验人必须具备「审核」动作权限。
+        // S8-RESPONSIBILITY-POOL-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} 没有审核权限,不能作为检验人。请在「异常操作权限」中为其角色授予「审核」,或改选其他账号。");
+        // 判据与前端候选列表**同源**(IS8VerifierEligibility 一处实现,两处消费)。
+        // 各写一套的后果本仓已经实测过:页面选了甲当检验人、审批任务却发给乙那一组,
+        // 甲手里没有任务、乙又过不了 S8 自己的 verifier_user_id 校验,两边互相卡住且都不报错。
+        //
+        // 这里同时堵住绕过 UI 直接调 API 注入池外 verifier 的路径 ——
+        // 候选列表只是"好用",真正的门在服务端。
+        var eligibility = await _verifierEligibility.CheckAsync(tenantId, verifier.UserId, e);
+        if (!eligibility.Allowed)
+            throw new S8BizException(eligibility.Message ?? "所选账号不能作为检验人");
         if (!S8StatusRules.IsAllowedTransition(e.Status, "PENDING_VERIFICATION"))
             throw new S8BizException($"状态 {e.Status} 不可提交复检");
 

+ 30 - 39
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TimeoutAutoEscalationService.cs

@@ -15,27 +15,31 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8;
 /// - status 进一步限制在 ASSIGNED / IN_PROGRESS(与 <see cref="Infrastructure.S8.S8StatusRules"/> 允许 ESCALATED 的转移一致);
 ///   NEW / PENDING_VERIFICATION / REJECTED 等状态不通过本 Job 自动升级。
 /// - 已有 active_flow_instance_id 的异常跳过(防重);UpgradeAsync 内部还会再校验一次,双层保险。
-/// - exception_type.escalate_role_code 为空 / 非法时跳过并 LogInformation;不写脏数据,不补默认。
+/// - S8-RESPONSIBILITY-POOL-1:升级 authority = **该规则的升级账号池**(ado_s8_rule_responsibility_user)。
+///   池空 / 成员全部失效时跳过并 LogInformation;**绝不回落到任何角色**(fail closed)。
+///   exception_type.escalate_role_code 已退居 LEGACY:它是按异常类型跨租户查的,
+///   实测会把 B 租户的配置用到 A 租户的异常上;责任池天然带 tenant_id,不存在这条越界路径。
+/// - 人工提报的异常没有来源规则,不参与规则级自动升级(与认领 / 复核同一口径)。
 /// - 状态 / Timeline 由 <see cref="ExceptionEscalationBizHandler"/>.OnFlowStarted 写入;本服务不重复维护。
 /// - 通知层走 <see cref="S8NotificationLayerResolver"/>;当前 baseline notify_channel="log",无外部副作用。
 /// </summary>
 public class S8TimeoutAutoEscalationService : ITransient
 {
     private readonly SqlSugarRepository<AdoS8Exception> _rep;
-    private readonly SqlSugarRepository<AdoS8ExceptionType> _typeRep;
+    private readonly IS8RuleResponsibilityReader _pools;
     private readonly S8TaskFlowService _taskFlow;
     private readonly S8NotificationLayerResolver _layerResolver;
     private readonly ILogger<S8TimeoutAutoEscalationService> _logger;
 
     public S8TimeoutAutoEscalationService(
         SqlSugarRepository<AdoS8Exception> rep,
-        SqlSugarRepository<AdoS8ExceptionType> typeRep,
+        IS8RuleResponsibilityReader pools,
         S8TaskFlowService taskFlow,
         S8NotificationLayerResolver layerResolver,
         ILogger<S8TimeoutAutoEscalationService> logger)
     {
         _rep = rep;
-        _typeRep = typeRep;
+        _pools = pools;
         _taskFlow = taskFlow;
         _layerResolver = layerResolver;
         _logger = logger;
@@ -59,44 +63,31 @@ public class S8TimeoutAutoEscalationService : ITransient
 
         if (candidates.Count == 0) return 0;
 
-        // exception_type.escalate_role_code 批量映射(本租户覆盖优先于平台默认)
-        var typeCodes = candidates
-            .Select(c => c.ExceptionTypeCode)
-            .Where(c => !string.IsNullOrWhiteSpace(c))
-            .Distinct()
-            .Select(c => c!)
-            .ToList();
-        // S8-P0-1-SCHEDULER-TRUSTED-SCOPE-1:原实现按 TypeCode 全库取「factory_id 最大」的一行,
-        // 无租户/工厂谓词 —— 本 Job 是跨租户扫描,会把 B 租户的 escalate_role_code 用到 A 租户的异常上。
-        // 现改为逐异常按其自身 TenantId 解析:候选 = 本租户覆盖 ∪ 平台默认 (tenant_id = 0)。
-        //
-        // S8-TENANT-ONLY-BATCH6:precedence 由 OrderByDescending(FactoryId) 改为「租户行优先」。
-        // 本批之后租户覆盖行的 factory_id 恒为 0,与平台默认相等,按 factory 排序会退化成任意序,
-        // 可能拿平台默认的 escalate_role_code 去升级一条本租户已自定义的异常。
-        var typeRows = typeCodes.Count == 0
-            ? new List<AdoS8ExceptionType>()
-            : (await _typeRep.AsQueryable().ClearFilter()
-                .Where(t => typeCodes.Contains(t.TypeCode))
-                .ToListAsync())
-                .OrderByDescending(t => t.TenantId != S8ConfigScope.GlobalTenantId)
-                .ToList();
-
-        AdoS8ExceptionType? ResolveTypeForScope(string typeCode, long tenantId) =>
-            typeRows.FirstOrDefault(t => t.TypeCode == typeCode
-                                      && (t.TenantId == tenantId || t.TenantId == S8ConfigScope.GlobalTenantId));
-
         var processed = 0;
         foreach (var e in candidates)
         {
             ct.ThrowIfCancellationRequested();
-            var type = string.IsNullOrWhiteSpace(e.ExceptionTypeCode)
-                ? null
-                : ResolveTypeForScope(e.ExceptionTypeCode!, e.TenantId);
-            if (type == null || string.IsNullOrWhiteSpace(type.EscalateRoleCode))
+            // S8-RESPONSIBILITY-POOL-1:升级 authority = 该规则的升级账号池。
+            //
+            // 人工提报没有来源规则 —— 它不属于任何规则的责任范围,不参与规则级自动升级。
+            // 一刀切地给它找个升级人,只会把"谁都没配"伪装成"已经有人负责"。
+            if (string.IsNullOrWhiteSpace(e.SourceRuleCode))
+            {
+                _logger.LogInformation(
+                    "s8_timeout_auto_escalate_skip exceptionId={Id} exceptionCode={Code} reason=no_source_rule",
+                    e.Id, e.ExceptionCode);
+                continue;
+            }
+
+            // 空池 fail closed:不回落 escalate_role_code、不回落任何角色。
+            // 回落会让「我没给这条规则配升级人」与「有人在跟进」同时成立 —— 最难查的一类错配。
+            var escalationPool = await _pools.GetValidMemberIdsAsync(
+                e.TenantId, e.SourceRuleCode!, S8ResponsibilityType.Escalation);
+            if (escalationPool.Count == 0)
             {
                 _logger.LogInformation(
-                    "s8_timeout_auto_escalate_skip exceptionId={Id} exceptionCode={Code} reason=escalate_role_empty typeCode={TypeCode}",
-                    e.Id, e.ExceptionCode, e.ExceptionTypeCode);
+                    "s8_timeout_auto_escalate_skip exceptionId={Id} exceptionCode={Code} reason=escalation_pool_empty rule={Rule}",
+                    e.Id, e.ExceptionCode, e.SourceRuleCode);
                 continue;
             }
 
@@ -104,12 +95,12 @@ public class S8TimeoutAutoEscalationService : ITransient
             {
                 // UpgradeAsync 已内置 ActiveFlowInstanceId / IsAllowedTransition 二次校验;
                 // 与 manual upgrade 100% 等价,状态/timeline 由 OnFlowStarted 写入。
-                var remark = $"[AUTO] SLA deadline exceeded; auto escalation triggered. sla_deadline={e.SlaDeadline:yyyy-MM-dd HH:mm:ss}; escalate_role_code={type.EscalateRoleCode}";
+                var remark = $"[AUTO] SLA deadline exceeded; auto escalation triggered. sla_deadline={e.SlaDeadline:yyyy-MM-dd HH:mm:ss}; escalation_pool={escalationPool.Count}";
                 await _taskFlow.UpgradeAsync(e.Id, e.TenantId, remark);
                 processed++;
                 _logger.LogInformation(
-                    "s8_timeout_auto_escalate_started exceptionId={Id} exceptionCode={Code} typeCode={TypeCode} escalateRoleCode={Role}",
-                    e.Id, e.ExceptionCode, e.ExceptionTypeCode, type.EscalateRoleCode);
+                    "s8_timeout_auto_escalate_started exceptionId={Id} exceptionCode={Code} rule={Rule} escalationPoolSize={PoolSize}",
+                    e.Id, e.ExceptionCode, e.SourceRuleCode, escalationPool.Count);
 
                 await TryDispatchAsync(e);
             }