소스 검색

feat(s8): 增加规则处理人员池与认领资格

回答「哪些账号负责这条规则」。与 1.0.497 的异常操作权限(哪个角色能做认领这类
动作)正交:最终能否认领 = 动作权限 ∩ 规则责任 ∩ 状态机,三者各自独立判定。
把三者混在一处判断是本仓踩过的坑——某一维被"顺手"放宽后,另外两维看起来仍然
正常,缺陷只在特定组合下出现。

存储选型基于本仓真实证据,不是偏好:

- 不放 params_json。① 认领资格、列表可见性过滤、后续的通知收件人解析都需要
  「按 user 反查规则」与「与 SysUser 关联校验租户/启用」,JSON 数组做不到;
  ② 该字段有事故先例——PUT /params {"enabled":false} 曾把整块 JSON 抹成 NULL,
  把「谁能处理」放进同一个易被整体重写的 blob 等于让它暴露在同一类静默失效下;
  ③ 去重与合法性可由唯一索引在 DB 层保证,JSON 只能靠应用层自觉。
- 身份用 rule_code 而非 rule_id:provisioning 的身份口径就是 (TenantId, RuleCode),
  而 watch_rule 的行会被重建——1.0.496 的 clean reset 之后 id 全部变化。绑 id 会让
  人员池在那一刻集体失联,而且不报错:规则照常跑,只是谁都不再"负责"它。

具体改动:

- S8RuleHandlerPoolService + IS8RuleHandlerPoolReader(读侧窄接口)。成员合法性
  复用 S8UserScopeValidator,不另写一套判据:池成员与认领人必须用同一把尺子,
  否则会出现「配得进池、认领时又被拒」这种管理员完全无法自查的状态。
  失效成员照样返回并标 Valid=false,不静默过滤——账号停用后池子无声少一个人,
  管理员既看不见也不知道要补。
- S8ClaimEligibility:动作权限 + 规则责任两维,两个拒绝原因可分辨。
  「没权限」要找管理员配角色,「不负责这条规则」要找管理员加责任池,
  给同一句话会让用户去找错人。状态与并发那一维不在这里重复实现。
- 转派目标同样受责任池约束,不留「转给池外账号」的后门:留了等于绕过责任池本身
  ——池子写着 A、B 负责,单据却落到 C 手里,而池配置看起来仍然正确。
- 人工提报(无来源规则)不受池约束。池是「谁负责这条规则」,它根本不来自任何规则;
  一刀切要求入池会让人工提报的异常永远无人可认领。
- 启用门禁新增一条:至少一名**当前有效**的处理账号。判据不是"表里有没有行"——
  账号停用后那行还在,但人已经不能认领了,只数行数会让一条实际上没人能处理的
  规则通过检查。Preview 不受此限(只读试算,与人员池正确性无关)。
- 可见性收敛:没有 EXCEPTION_VIEW_ALL 的账号只看得到自己负责的规则产出的异常、
  人工提报、以及与自己直接相关的单子。最后一支是必要的:单据可能在他认领之后被
  移出责任池,少了它他会看不见自己手上正在处理的单子。超管与无登录上下文的
  内部调用(看板聚合 / 后台作业)不收敛,否则统计数字会凭空少掉。
  EXCEPTION_VIEW_ALL 在 1.0.497 建立时还没有消费方,至此闭环。

本地验证(UAT 租户,Rule01 池 = UATPurchaseA + UATExceptionA):
池空时启用被门禁挡下、Preview 仍可用;跨租户账号入池被拒;配池后启用成功并跑出
28 条规则来源异常;池内成员认领 200;有 CLAIM 权限但不在池的 UATWarehouseA 被
责任池拦下且文案与"没有权限"不同;无 CLAIM 权限的 UATQualityA 在权限维就被拦;
可见性 31 vs 3;池内两人同刻各发 5 个认领请求恰好一个成功、时间线只 1 条 CLAIM。
YY968XX 3 일 전
부모
커밋
82f8b6708b

+ 1 - 1
Web/package.json

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

+ 17 - 0
Web/src/views/aidop/s8/api/s8ConfigApi.ts

@@ -6,6 +6,15 @@ function unwrap<T>(res: { data: T }): T {
 }
 
 /** S8-ACTION-PERMISSION-1:权限矩阵一行。 */
+/** S8-HANDLER-POOL-1:规则处理账号池成员。 */
+export interface S8HandlerUser {
+	userId: number;
+	account: string;
+	displayName: string;
+	/** false = 账号已停用或不在本租户,历史配置已失效,前端必须显式提示。 */
+	valid: boolean;
+}
+
 export interface S8ActionRoleRow {
 	actionCode: string;
 	displayName: string;
@@ -351,6 +360,14 @@ export const s8ConfigApi = {
 		service.get<S8DatasetOption[]>('/api/aidop/s8/config/datasets', { params: withAidopTenantFactoryParams({}) }).then(unwrap),
 	importRolesFromSys: (params?: { tenantId?: number; factoryId?: number }) =>
 		service.post('/api/aidop/s8/config/roles/import-from-sys', null, { params: withAidopTenantFactoryParams(params ?? {}) }).then(unwrap),
+	/** S8-HANDLER-POOL-1:规则处理账号池(规则级责任,与全局动作权限正交)。 */
+	handlerPool: {
+		get: (ruleId: number) =>
+			service.get<S8HandlerUser[]>(`/api/aidop/s8/config/watch-rules/${ruleId}/handler-pool`, { params: withAidopTenantFactoryParams({}) }).then(unwrap),
+		set: (ruleId: number, userIds: number[]) =>
+			service.put(`/api/aidop/s8/config/watch-rules/${ruleId}/handler-pool`, withAidopTenantFactoryParams({ userIds })).then(unwrap),
+	},
+
 	/** S8-ACTION-PERMISSION-1:异常操作权限(全局配置,非规则级)。 */
 	actionPermissions: {
 		matrix: () => service.get<S8ActionRoleRow[]>('/api/aidop/s8/config/action-permissions', { params: withAidopTenantFactoryParams({}) }).then(unwrap),

+ 80 - 1
Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue

@@ -17,7 +17,8 @@
 import { computed, onMounted, reactive, ref } from 'vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
-import { s8ConfigApi, type S8RuleParametersPayload, type S8WatchRuleConfigRow, type S8WatchRulePreviewResult } from '../api/s8ConfigApi';
+import { s8ConfigApi, type S8HandlerUser, type S8RuleParametersPayload, type S8WatchRuleConfigRow, type S8WatchRulePreviewResult } from '../api/s8ConfigApi';
+import { s8ExceptionApi } from '../api/s8ExceptionApi';
 import { s8ReportApi } from '../api/s8ReportApi';
 
 const loading = ref(false);
@@ -151,6 +152,46 @@ const graceMax = computed(() => {
 	return max >= 2147483647 ? undefined : max;
 });
 
+// ── S8-HANDLER-POOL-1:规则处理账号池 ──
+// 责任维度(谁负责这条规则),与「异常操作权限」的角色维度正交。
+// P1-D 会把它搬进四页签弹窗的 Tab3;本批先在既有抽屉里给出可用入口,
+// 否则新加的启用门禁会让所有规则都停在"请先配置处理账号"而无处可配。
+const handlerPool = ref<number[]>([]);
+const handlerPoolLoaded = ref(false);
+const handlerPoolInvalid = ref<S8HandlerUser[]>([]);
+const savingPool = ref(false);
+const tenantUsers = ref<{ id: number; name: string; account?: string }[]>([]);
+
+async function loadHandlerPool(ruleId: number) {
+	handlerPoolLoaded.value = false;
+	handlerPoolInvalid.value = [];
+	try {
+		const [members, users] = await Promise.all([
+			s8ConfigApi.handlerPool.get(ruleId),
+			s8ExceptionApi.users(),
+		]);
+		const list = (members as S8HandlerUser[]) ?? [];
+		handlerPool.value = list.map((m) => Number(m.userId));
+		// 失效成员不静默丢弃:管理员必须看见"这里有一个人已经不算数了"。
+		handlerPoolInvalid.value = list.filter((m) => !m.valid);
+		tenantUsers.value = (users as typeof tenantUsers.value) ?? [];
+	} finally {
+		handlerPoolLoaded.value = true;
+	}
+}
+
+async function saveHandlerPool() {
+	if (!current.value) return;
+	savingPool.value = true;
+	try {
+		await s8ConfigApi.handlerPool.set(Number(current.value.id), handlerPool.value);
+		handlerPoolInvalid.value = [];
+		ElMessage.success(handlerPool.value.length ? '处理账号池已保存' : '已清空处理账号池(该规则将无法启用)');
+	} finally {
+		savingPool.value = false;
+	}
+}
+
 function openDetail(row: S8WatchRuleConfigRow) {
 	current.value = row;
 	form.pollIntervalSeconds = row.pollIntervalSeconds;
@@ -163,6 +204,7 @@ function openDetail(row: S8WatchRuleConfigRow) {
 	snapshot = JSON.stringify(form);
 	preview.value = null;
 	drawerOpen.value = true;
+	void loadHandlerPool(Number(row.id));
 }
 
 /**
@@ -406,6 +448,43 @@ async function provision() {
 
 		<!-- 详情抽屉:A 基础定义(只读) / B 运行参数 / C 预演 / D 运行状态 -->
 		<el-drawer v-model="drawerOpen" size="720px" :title="current ? ruleName(current) : ''">
+			<template v-if="current">
+				<!-- S8-HANDLER-POOL-1:责任维度。谁负责这条规则,与「异常操作权限」的角色维度正交。 -->
+				<el-card shadow="never" style="margin-bottom: 12px">
+					<template #header>
+						<div style="display: flex; justify-content: space-between; align-items: center">
+							<span style="font-weight: 600">处理账号(该规则由谁负责)</span>
+							<el-button type="primary" size="small" :loading="savingPool"
+								:disabled="!handlerPoolLoaded" @click="saveHandlerPool">保存处理账号</el-button>
+						</div>
+					</template>
+					<el-alert v-if="handlerPoolLoaded && handlerPool.length === 0" type="warning"
+						:closable="false" show-icon style="margin-bottom: 10px">
+						<template #title>该规则尚未配置处理账号</template>
+						<div style="line-height: 1.9">
+							没有处理账号的规则<strong>无法启用</strong>:它会源源不断建出异常、却没有人能认领,
+							而调度器每一轮都报成功。
+						</div>
+					</el-alert>
+					<el-alert v-if="handlerPoolInvalid.length" type="error" :closable="false" show-icon
+						style="margin-bottom: 10px">
+						<template #title>有 {{ handlerPoolInvalid.length }} 个已配置的账号当前不可用</template>
+						<div style="line-height: 1.9">
+							这些账号已停用或不在当前租户,<strong>不再具备认领资格</strong>。请重新选择后保存。
+						</div>
+					</el-alert>
+					<el-select v-model="handlerPool" multiple filterable clearable collapse-tags collapse-tags-tooltip
+						placeholder="选择负责该规则的系统账号" style="width: 100%" :loading="!handlerPoolLoaded">
+						<el-option v-for="u in tenantUsers" :key="u.id" :label="u.name" :value="u.id">
+							<span>{{ u.name }} <span style="color: #999; font-size: 12px">{{ u.account ? '(' + u.account + ')' : '' }}</span></span>
+						</el-option>
+					</el-select>
+					<div style="color: #909399; font-size: 12px; margin-top: 6px">
+						池成员才能认领 / 被转派该规则产生的异常。能否认领还同时取决于账号是否拥有「认领」动作权限,
+						以及单据当前状态。
+					</div>
+				</el-card>
+			</template>
 			<template v-if="current">
 				<el-alert v-if="current.definitionMissing" type="error" :closable="false" show-icon
 					title="当前版本未找到规则定义"

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.497</AssemblyVersion>
-    <FileVersion>1.0.497</FileVersion>
-    <Version>1.0.497</Version>
+    <AssemblyVersion>1.0.498</AssemblyVersion>
+    <FileVersion>1.0.498</FileVersion>
+    <Version>1.0.498</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -694,6 +694,9 @@
     <None Update="UpdateScripts\1.0.497.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.498.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 97 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.498.sql

@@ -0,0 +1,97 @@
+-- =====================================================================================
+-- 1.0.498  S8-HANDLER-POOL-1 · 规则处理账号池
+--
+-- 目标:回答「哪些账号负责这条规则」。与「异常操作权限」(1.0.497,哪个角色能做认领
+-- 这类动作)正交 —— 最终能否认领 = 动作权限 ∩ 规则责任 ∩ 状态机。
+--
+-- 存储选型(基于本仓真实证据,不是偏好):
+--   · 不放 params_json。① 认领资格、列表可见性过滤、通知收件人解析都需要
+--     「按 user 反查规则」与「与 SysUser 关联校验租户/启用」,JSON 数组做不到;
+--     ② 该字段有事故先例 —— PUT /params {"enabled":false} 曾把整块 JSON 抹成 NULL;
+--     ③ 去重与合法性可由唯一索引在 DB 层保证,JSON 只能靠应用层自觉。
+--   · 身份用 rule_code 而非 rule_id:provisioning 的身份口径就是 (TenantId, RuleCode),
+--     而 watch_rule 的行是可以被重建的(1.0.496 的 clean reset 之后 id 全部变化)。
+--     绑 id 会让人员池在那一刻集体失联,而且不报错:规则照常跑,只是谁都不再"负责"它。
+--
+-- 表由 CodeFirst(InitTables) 创建;本脚本只补索引 + postcheck,不写任何成员数据
+-- (成员是 per-tenant 的人工配置,写死在迁移里既不适配新租户、也会覆盖管理员的选择)。
+-- =====================================================================================
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 1 · PREFLIGHT
+-- ─────────────────────────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS `ado_s8_rule_handler_user` (
+  `id`         BIGINT       NOT NULL AUTO_INCREMENT,
+  `tenant_id`  BIGINT       NOT NULL COMMENT '租户(唯一安全边界)',
+  `rule_code`  VARCHAR(128) NOT NULL COMMENT '规则编码,与 provisioning 身份口径一致',
+  `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 规则处理账号池';
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 2 · SCHEMA
+--   唯一索引:同一 (租户, 规则, 账号) 只允许一行。没有它,重复成员会让"移出池"
+--   变成删一行、剩一行 —— 界面显示已移出,实际仍然可以认领。
+--   反查索引:可见性过滤要按 (tenant, user) 反查负责的规则,没有它会全表扫。
+-- ─────────────────────────────────────────────────────────────────────────────────────
+SET @has_uk := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_rule_handler_user'
+    AND INDEX_NAME='uk_s8_rule_handler');
+
+SET @sql := IF(@has_uk = 0, '
+DELETE a FROM `ado_s8_rule_handler_user` a
+  JOIN `ado_s8_rule_handler_user` b
+    ON a.`tenant_id` = b.`tenant_id`
+   AND a.`rule_code` = b.`rule_code`
+   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_handler_user` ADD UNIQUE INDEX `uk_s8_rule_handler` (`tenant_id`,`rule_code`,`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_handler_user'
+    AND INDEX_NAME='idx_s8_rule_handler_user');
+SET @sql := IF(@has_idx = 0,
+  'ALTER TABLE `ado_s8_rule_handler_user` ADD INDEX `idx_s8_rule_handler_user` (`tenant_id`,`user_id`)',
+  'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 3 · POSTCHECK
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- ① 唯一索引必须覆盖三列
+SET @cols := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_rule_handler_user'
+    AND INDEX_NAME='uk_s8_rule_handler'
+    AND COLUMN_NAME IN ('tenant_id','rule_code','user_id'));
+DROP TABLE IF EXISTS `tmp_s8_498_guard`;
+CREATE TABLE `tmp_s8_498_guard` (
+  probe BIGINT NOT NULL,
+  CONSTRAINT s8_498_BLOCKED_unique_index_missing CHECK (probe = 0)
+) COMMENT '1.0.498 断言门禁;通过后自动删除';
+INSERT INTO `tmp_s8_498_guard` (probe) VALUES ((3 - @cols));
+DROP TABLE IF EXISTS `tmp_s8_498_guard`;
+
+-- ② 不允许出现「池成员不属于该行租户」的数据
+SET @bad := (SELECT COUNT(*) FROM `ado_s8_rule_handler_user` h
+  JOIN `SysUser` u ON u.`Id` = h.`user_id`
+ WHERE u.`TenantId` <> h.`tenant_id`);
+DROP TABLE IF EXISTS `tmp_s8_498_guard`;
+CREATE TABLE `tmp_s8_498_guard` (
+  probe BIGINT NOT NULL,
+  CONSTRAINT s8_498_BLOCKED_cross_tenant_handler CHECK (probe = 0)
+) COMMENT '1.0.498 断言门禁;通过后自动删除';
+INSERT INTO `tmp_s8_498_guard` (probe) VALUES (@bad);
+DROP TABLE IF EXISTS `tmp_s8_498_guard`;
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 4 · IDEMPOTENT RERUN GUARD
+--   第二次执行:建表跳过、索引已存在跳过、断言依旧成立。本脚本不写任何成员数据,
+--   因此重跑不会改变任何一条责任归属。
+-- ─────────────────────────────────────────────────────────────────────────────────────

+ 92 - 3
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8RuleEnableReadinessTests.cs

@@ -85,13 +85,39 @@ public class S8RuleEnableReadinessTests
             Task.FromResult(deptId.HasValue && _deptTenant.TryGetValue(deptId.Value, out var t) && t == tenantId);
     }
 
-    /// <summary>门禁实例:DeptInA 属租户 A,DeptInB 属租户 B。</summary>
-    private static S8RuleReadinessGate Gate() =>
+    /// <summary>
+    /// S8-HANDLER-POOL-1:责任池替身。
+    /// <para><paramref name="validMembers"/> 为该租户下**当前有效**的池成员数;
+    /// 默认给 1,让既有的部门用例继续只考察部门那一维 —— 一个测试同时压两个门禁,
+    /// 失败时无法分辨是哪一维出的问题。</para>
+    /// </summary>
+    private sealed class FakePool : IS8RuleHandlerPoolReader
+    {
+        private readonly int _valid;
+        private readonly int _total;
+        public FakePool(int valid, int total) { _valid = valid; _total = total; }
+
+        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 })
+                .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>>());
+    }
+
+    /// <summary>门禁实例:DeptInA 属租户 A,DeptInB 属租户 B;责任池默认有 1 名有效成员。</summary>
+    private static S8RuleReadinessGate Gate(int validMembers = 1, int totalMembers = 1) =>
         new(new FakeDeptValidator(new Dictionary<long, long>
         {
             [DeptInA] = TenantA,
             [DeptInB] = TenantB
-        }));
+        }), new FakePool(validMembers, totalMembers));
 
     private static Task<S8RuleReadinessCheck> Check(long tenantId, long? occ, long? resp, S8RuleDefinition? def = null)
     {
@@ -382,4 +408,67 @@ public class S8RuleEnableReadinessTests
                        && !t.StartsWith("//", StringComparison.Ordinal);
             }));
     }
+
+    // ═══════════════════════════════════════════════════════════════════════
+    // S8-HANDLER-POOL-1:责任池维度
+    // ═══════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// T14:部门配齐但责任池为空 → 仍然不可启用。
+    ///
+    /// <para>一条没人负责的规则会源源不断建出异常、静静躺在列表里没人认领,
+    /// 而调度器每一轮都报成功 —— 与「配了部门但建不出单」是同一类静默失败。</para>
+    /// </summary>
+    [Fact]
+    public async Task T14_Enable_Rejected_WhenHandlerPoolEmpty()
+    {
+        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);
+        Assert.Contains("请先配置至少一名处理账号", result.Message);
+    }
+
+    /// <summary>
+    /// T15:池里有人、但全部已失效 → 同样不可启用,且提示与"没配过"不同。
+    ///
+    /// <para>只数行数会让一条实际上没人能处理的规则通过检查。两种情况给同一句提示
+    /// 也不行:管理员要么去加人、要么去查为什么这些账号不可用,处置动作完全不同。</para>
+    /// </summary>
+    [Fact]
+    public async Task T15_Enable_Rejected_WhenAllHandlersInvalid()
+    {
+        var result = await Gate(validMembers: 0, totalMembers: 2)
+            .CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, DeptInA, DeptInA), Rule01()), TenantA);
+
+        Assert.False(result.Ok);
+        Assert.Equal(S8RuleReadinessReasonCode.HandlerPoolEmpty, result.ReasonCode);
+        Assert.Contains("均已停用或不在当前租户", result.Message);
+    }
+
+    /// <summary>T16:部门配齐 + 至少一名有效处理账号 → 放行。</summary>
+    [Fact]
+    public async Task T16_Enable_Passes_WithDepartmentsAndValidHandler()
+    {
+        var result = await Gate(validMembers: 1, totalMembers: 3)
+            .CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, DeptInA, DeptInA), Rule01()), TenantA);
+
+        Assert.True(result.Ok);
+    }
+
+    /// <summary>
+    /// T17:未声明部门必需的规则,责任池同样不检查 ——
+    /// 该门禁整体是<b>逐规则声明</b>的,不是 S8 全局规则。
+    /// </summary>
+    [Fact]
+    public async Task T17_RuleWithoutDepartmentRequirement_SkipsPoolCheckToo()
+    {
+        var result = await Gate(validMembers: 0, totalMembers: 0)
+            .CheckAsync(
+                S8EffectiveRule.Resolve(Row(TenantA, null, null), RuleWithoutDepartmentRequirement()),
+                TenantA);
+
+        Assert.True(result.Ok);
+    }
 }

+ 316 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8RuleHandlerPoolTests.cs

@@ -0,0 +1,316 @@
+using System.Reflection;
+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;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-HANDLER-POOL-1:规则处理账号池与认领资格。
+///
+/// <para><b>三个正交维度</b>:动作权限(角色能做什么)· 规则责任(账号负责哪条规则)·
+/// 状态机(单据现在能不能做)。最终允许 = 三者交集。把它们混在一处判断是本仓踩过的坑:
+/// 某一维被"顺手"放宽后,另外两维看起来仍然正常,缺陷只在特定组合下出现。</para>
+/// </summary>
+public class S8RuleHandlerPoolTests
+{
+    private const BindingFlags Instance =
+        BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
+
+    private static readonly string PluginRoot =
+        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    private static string CodeOnly(string relativePath)
+    {
+        var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+        return string.Join('\n', File.ReadAllLines(full)
+            .Where(l =>
+            {
+                var t = l.TrimStart();
+                return !t.StartsWith("///", StringComparison.Ordinal)
+                       && !t.StartsWith("//", StringComparison.Ordinal);
+            }));
+    }
+
+    // ══════════════════════════════════════════════════════════════════════════
+    // 存储形态
+    // ══════════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 身份是 (租户, 规则编码, 账号)。
+    ///
+    /// <para><b>必须是 rule_code 而不是 rule_id</b>:provisioning 的身份口径就是
+    /// (TenantId, RuleCode),而 watch_rule 的行会被重建 —— 1.0.496 的 clean reset
+    /// 之后 id 全部变化。绑 id 会让人员池在那一刻集体失联,而且不报错:
+    /// 规则照常跑,只是谁都不再"负责"它。</para>
+    /// </summary>
+    [Fact]
+    public void Storage_IdentityIsTenantRuleCodeUser()
+    {
+        var t = typeof(AdoS8RuleHandlerUser);
+        Assert.NotNull(t.GetProperty("TenantId"));
+        Assert.NotNull(t.GetProperty("RuleCode"));
+        Assert.NotNull(t.GetProperty("UserId"));
+        Assert.Null(t.GetProperty("RuleId"));      // 绑 id 会在规则行重建时集体失联
+        Assert.Null(t.GetProperty("FactoryId"));   // 工厂不是隔离维度
+        Assert.Null(t.GetProperty("EmployeeId"));  // S8 的人只有系统账号
+    }
+
+    /// <summary>人员池不得回流进 params_json —— 那个字段有被整块抹成 NULL 的事故先例。</summary>
+    [Fact]
+    public void HandlerPool_IsNotStoredInParamsJson()
+    {
+        var code = CodeOnly("Service/S8/Rules/S8RuleRuntimeParameters.cs");
+        Assert.DoesNotContain("HandlerUser", code);
+        Assert.DoesNotContain("handlerUserIds", code);
+
+        // params_json 的 B 类键仍然只有那三项
+        Assert.Contains("KeyGraceMinutes", code);
+        Assert.Contains("KeyDefaultOccurrenceDeptId", code);
+        Assert.Contains("KeyDefaultResponsibleDeptId", code);
+    }
+
+    // ══════════════════════════════════════════════════════════════════════════
+    // 成员合法性
+    // ══════════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 成员合法性复用 <see cref="IS8UserScopeValidator"/>,不另写一套判据。
+    /// 池成员与认领人必须用同一把尺子,否则会出现「配得进池、认领时又被拒」——
+    /// 这种状态管理员完全无法自查。
+    /// </summary>
+    [Fact]
+    public void Membership_ReusesUserScopeValidator()
+    {
+        var code = CodeOnly("Service/S8/S8RuleHandlerPoolService.cs");
+        Assert.Contains("_userScope.ValidateUsersAsync", code);
+        Assert.Contains("_userScope.FindActiveUserAsync", code);
+
+        // 不得绕过 validator 自己拼 SysUser 查询
+        Assert.DoesNotContain("Queryable<SysUser>", code);
+        Assert.DoesNotContain("StatusEnum.Enable", code);
+        // 跨租户与不存在共用同一文案,不泄漏存在性
+        Assert.Contains("所选账号不存在或已停用", code);
+    }
+
+    /// <summary>
+    /// 失效成员必须<b>返回并标记</b>,不能静默过滤。
+    /// 账号被停用之后池子无声地少一个人,管理员既看不见也不知道要补。
+    /// </summary>
+    [Fact]
+    public void InvalidMembers_AreSurfacedNotSilentlyDropped()
+    {
+        var code = CodeOnly("Service/S8/S8RuleHandlerPoolService.cs");
+        Assert.Contains("Valid = false", code);
+        Assert.Contains("账号已停用或不在本租户", code);
+    }
+
+    /// <summary>责任池链路上不得出现工厂或员工。</summary>
+    [Theory]
+    [InlineData("Service/S8/S8RuleHandlerPoolService.cs")]
+    [InlineData("Infrastructure/S8/S8ClaimEligibility.cs")]
+    public void HandlerPoolChain_HasNoFactoryOrEmployee(string relativePath)
+    {
+        var code = CodeOnly(relativePath);
+        Assert.DoesNotContain("FactoryId", code);
+        Assert.DoesNotContain("FactoryRefId", code);
+        Assert.DoesNotContain("EmployeeMaster", code);
+    }
+
+    // ══════════════════════════════════════════════════════════════════════════
+    // 认领资格:三维正交
+    // ══════════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 认领资格 = 动作权限 ∩ 规则责任,且两个拒绝原因<b>可分辨</b>。
+    ///
+    /// <para>「没权限」与「不负责这条规则」是两种完全不同的处境,给用户同一句话
+    /// 会让他去找错人 —— 前者要找管理员配角色,后者要找管理员加责任池。</para>
+    /// </summary>
+    [Fact]
+    public void ClaimEligibility_CombinesPermissionAndResponsibility()
+    {
+        var code = CodeOnly("Infrastructure/S8/S8ClaimEligibility.cs");
+
+        Assert.Contains("_authorizer.AuthorizeAsync(tenantId, userId, S8ExceptionActionCode.Claim)", code);
+        Assert.Contains("_handlerPool.IsMemberAsync", code);
+        Assert.Contains(S8ClaimDenyReason.NoActionPermission, code);
+        Assert.Contains(S8ClaimDenyReason.NotInHandlerPool, code);
+        Assert.NotEqual(S8ClaimDenyReason.NoActionPermission, S8ClaimDenyReason.NotInHandlerPool);
+    }
+
+    /// <summary>
+    /// 人工提报无来源规则,<b>不受责任池约束</b>。
+    /// 若一刀切要求入池,人工提报的异常将永远无人可认领。
+    /// </summary>
+    [Fact]
+    public void ManualReport_IsNotConstrainedByHandlerPool()
+    {
+        var code = CodeOnly("Infrastructure/S8/S8ClaimEligibility.cs");
+        Assert.Contains("string.IsNullOrWhiteSpace(ruleCode)", code);
+        Assert.Contains("return S8ClaimEligibilityResult.Pass();", code);
+    }
+
+    /// <summary>
+    /// 状态机与并发那一维<b>不在资格判定里重复实现</b> ——
+    /// 仍由 <c>S8TaskFlowService</c> 的原子 UPDATE 负责,First Claim Wins 不受本批影响。
+    /// </summary>
+    [Fact]
+    public void ClaimEligibility_DoesNotDuplicateStateOrConcurrency()
+    {
+        var elig = CodeOnly("Infrastructure/S8/S8ClaimEligibility.cs");
+        Assert.DoesNotContain("S8StatusRules", elig);
+        Assert.DoesNotContain("AssigneeUserId", elig);
+
+        var flow = CodeOnly("Service/S8/S8TaskFlowService.cs");
+        Assert.Contains("_claimEligibility.CheckAsync(tenantId, currentUserId, e)", flow);
+        // 原子谓词与竞争败者文案仍在
+        Assert.Contains("x.AssigneeUserId == null", flow);
+        Assert.Contains("该异常已被其他人员认领", flow);
+    }
+
+    /// <summary>
+    /// 转派目标同样受责任池约束 —— 不留「转给池外账号」的后门。
+    ///
+    /// <para>留后门等于绕过责任池本身:池子写着 A、B 负责,实际单据却落到 C 手里,
+    /// 而池配置看起来仍然正确。</para>
+    /// </summary>
+    [Fact]
+    public void Transfer_TargetMustBeInHandlerPool()
+    {
+        var flow = CodeOnly("Service/S8/S8TaskFlowService.cs");
+        Assert.Contains("_handlerPool.IsMemberAsync(tenantId, e.SourceRuleCode!, target.UserId)", flow);
+        Assert.Contains("不是该规则的处理人员,无法转派", flow);
+        // 人工提报同样豁免(与认领同口径)
+        Assert.Contains("!string.IsNullOrWhiteSpace(e.SourceRuleCode)", flow);
+    }
+
+    /// <summary>认领资格必须只有一份实现。</summary>
+    [Fact]
+    public void ClaimEligibility_HasSingleImplementation()
+    {
+        var impls = typeof(IS8ClaimEligibility).Assembly.GetTypes()
+            .Where(t => t is { IsAbstract: false, IsInterface: false }
+                        && typeof(IS8ClaimEligibility).IsAssignableFrom(t))
+            .ToList();
+        Assert.Single(impls);
+        Assert.Equal(typeof(S8ClaimEligibility), impls[0]);
+    }
+
+    // ══════════════════════════════════════════════════════════════════════════
+    // 启用门禁
+    // ══════════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 启用要求至少一名<b>当前有效</b>的处理账号。
+    ///
+    /// <para>判据不能是"表里有没有行":账号被停用之后那行还在,但人已经不能认领了。
+    /// 只数行数会让一条实际上没人能处理的规则通过启用检查 —— 那正是本门禁存在的意义。</para>
+    /// </summary>
+    [Fact]
+    public void Enable_RequiresAtLeastOneValidHandler()
+    {
+        var code = CodeOnly("Service/S8/Rules/S8RuleReadinessGate.cs");
+        Assert.Contains(S8RuleReadinessReasonCode.HandlerPoolEmpty, code);
+        Assert.Contains("_handlerPool.GetMembersAsync", code);
+        // 有效性判据:不是 Count > 0,而是"存在一个 Valid 的"
+        Assert.Contains("members.All(m => !m.Valid)", code);
+        Assert.DoesNotContain("members.Count > 0)", code);
+
+        // 两种失败要给不同提示:没配过 vs 配过但都失效
+        Assert.Contains("请先配置至少一名处理账号", code);
+        Assert.Contains("均已停用或不在当前租户", code);
+    }
+
+    /// <summary>
+    /// Preview 不受责任池约束(只读试算,与人员池正确性无关)。
+    ///
+    /// <para>预演在独立的 <c>S8WatchRulePreviewService</c> 里,它整个不碰就绪门禁 ——
+    /// 管理员本来就该「先预演看命中,再配人配部门,再启用」。若预演也要求先配人,
+    /// 管理员将无法在配置前判断这条规则到底有没有用。</para>
+    /// </summary>
+    [Fact]
+    public void Preview_IsNotGatedByHandlerPool()
+    {
+        var preview = CodeOnly("Service/S8/S8WatchRulePreviewService.cs");
+        Assert.Contains("PreviewAsync", preview);
+        Assert.DoesNotContain("EnsureReadyAsync", preview);
+        Assert.DoesNotContain("_handlerPool", preview);
+        Assert.DoesNotContain("IS8RuleHandlerPoolReader", preview);
+
+        // 启用路径必须仍有门禁 —— 否则这条断言会在"门禁被整个删掉"时假通过。
+        var ruleSvc = CodeOnly("Service/S8/S8WatchRuleService.cs");
+        Assert.Contains("EnsureReadyAsync", ruleSvc);
+    }
+
+    // ══════════════════════════════════════════════════════════════════════════
+    // 可见性
+    // ══════════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// View 与 View All 分离:没有 <c>EXCEPTION_VIEW_ALL</c> 的账号只看得到
+    /// 自己负责的规则产出的异常、人工提报、以及与自己直接相关的单子。
+    ///
+    /// <para>最后一支是必要的:单据可能在他认领之后被移出责任池,
+    /// 少了它他会看不见自己手上正在处理的单子。</para>
+    /// </summary>
+    [Fact]
+    public void Visibility_SeparatesViewFromViewAll()
+    {
+        var code = CodeOnly("Service/S8/S8ExceptionService.cs");
+        Assert.Contains("S8ExceptionActionCode.ViewAll", code);
+        Assert.Contains("myRuleCodes.Contains(e.SourceRuleCode)", code);
+        Assert.Contains("SqlFunc.IsNullOrEmpty(e.SourceRuleCode)", code);
+        Assert.Contains("e.AssigneeUserId == myUserId", code);
+        Assert.Contains("e.VerifierUserId == myUserId", code);
+        Assert.Contains("e.ReporterUserId == myUserId", code);
+    }
+
+    /// <summary>
+    /// 无登录上下文(看板聚合 / 后台作业)与超管不做可见性收敛。
+    /// 给它们加限制只会让统计数字凭空少掉,而且没人会想到去查这里。
+    /// </summary>
+    [Fact]
+    public void Visibility_SkipsSuperAdminAndBackgroundCallers()
+    {
+        var code = CodeOnly("Service/S8/S8ExceptionService.cs");
+        Assert.Contains("if (_userManager.SuperAdmin) return null;", code);
+        Assert.Contains("if (userId <= 0 || tenantId <= 0) return null;", code);
+    }
+
+    /// <summary>
+    /// <c>EXCEPTION_VIEW_ALL</c> 在 P1-B 建立时没有消费方,本批必须闭环 ——
+    /// 否则它就是一个配了也不起作用的开关。
+    /// </summary>
+    [Fact]
+    public void ViewAll_HasARealConsumer()
+    {
+        Assert.Contains(S8ExceptionActionCatalog.All, d => d.Code == S8ExceptionActionCode.ViewAll);
+        var code = CodeOnly("Service/S8/S8ExceptionService.cs");
+        Assert.Contains("allowed.Contains(S8ExceptionActionCode.ViewAll)", code);
+    }
+
+    // ══════════════════════════════════════════════════════════════════════════
+    // 迁移
+    // ══════════════════════════════════════════════════════════════════════════
+
+    /// <summary>迁移建唯一索引 + 反查索引,并断言不存在跨租户池成员。</summary>
+    [Fact]
+    public void Migration_GuardsUniquenessAndTenantBoundary()
+    {
+        var sql = File.ReadAllText(Path.GetFullPath(Path.Combine(
+            PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.498.sql")));
+
+        Assert.Contains("uk_s8_rule_handler", sql);
+        Assert.Contains("idx_s8_rule_handler_user", sql);
+        Assert.Contains("s8_498_BLOCKED_unique_index_missing", sql);
+        Assert.Contains("s8_498_BLOCKED_cross_tenant_handler", sql);
+        // 不写任何成员数据:成员是 per-tenant 人工配置,写死既不适配新租户也会覆盖管理员选择
+        Assert.DoesNotContain("INSERT INTO `ado_s8_rule_handler_user`", sql);
+        Assert.DoesNotContain("DELIMITER", sql);
+    }
+}

+ 52 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs

@@ -186,6 +186,51 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
     ///
     /// <para>幂等:重复启用不产生写入。</para>
     /// </summary>
+    /// <summary>
+    /// S8-HANDLER-POOL-1:规则处理账号池。
+    ///
+    /// <para>回答「哪些账号负责这条规则」。与「异常操作权限」(哪个角色能做认领这类动作)
+    /// 正交:最终能否认领 = 动作权限 ∩ 规则责任 ∩ 状态机。</para>
+    /// </summary>
+    [HttpGet("{id:long}/handler-pool")]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
+    public async Task<IActionResult> HandlerPoolAsync(long id,
+        [FromServices] S8RuleHandlerPoolService 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("规则不存在");
+            return Ok(await pool.GetMembersAsync(scope.TenantId, ruleCode));
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    /// <summary>全量替换规则处理账号池。成员必须是本租户的启用账号。</summary>
+    [HttpPut("{id:long}/handler-pool")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
+    public async Task<IActionResult> SetHandlerPoolAsync(long id,
+        [FromBody] AdoS8HandlerPoolSetDto? body,
+        [FromServices] S8RuleHandlerPoolService 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, body?.UserIds);
+            return Ok(new { ruleCode, memberCount = body?.UserIds?.Count ?? 0 });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
     [HttpPost("{id:long}/enable")]
     [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> EnableAsync(long id)
@@ -296,3 +341,10 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
             endpoint, ruleId, userId, tenantId, factoryId, LegacyHeaderUseInstead);
     }
 }
+
+/// <summary>规则处理账号池设置载荷。</summary>
+public class AdoS8HandlerPoolSetDto
+{
+    /// <summary>完整成员集合(全量替换)。传空数组 = 清空责任池,该规则将无法启用。</summary>
+    public List<long>? UserIds { get; set; }
+}

+ 47 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8RuleHandlerUser.cs

@@ -0,0 +1,47 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+/// <summary>
+/// S8-HANDLER-POOL-1:规则的<b>处理账号池</b>成员。回答「哪些账号负责这条规则」。
+///
+/// <para><b>与「异常操作权限」正交</b>:后者(<see cref="AdoS8ExceptionActionRole"/>)
+/// 回答「这个角色能不能做认领这类事」,本表回答「这个账号负不负责这条规则」。
+/// 最终能否认领 = 动作权限 ∩ 规则责任 ∩ 状态机,三者缺一不可。</para>
+///
+/// <para><b>身份用 <c>rule_code</c> 而不是 <c>rule_id</c></b>:
+/// <c>S8RuleProvisioningService</c> 的身份口径就是 <c>(TenantId, RuleCode)</c>,
+/// 而 <c>ado_s8_watch_rule</c> 的行是可以被重建的 —— 上一批的 clean reset 之后
+/// provisioning 会新建行、id 全部变化。绑 id 会让人员池在那一刻集体失联,
+/// 而且不报错:规则照常跑,只是谁都不再"负责"它。</para>
+///
+/// <para><b>为什么不塞进 <c>params_json</c></b>:① 认领资格、可见性过滤、通知收件人解析
+/// 都需要「按 user 反查规则」与「与 SysUser 关联校验租户/启用」,JSON 数组做不到;
+/// ② 该字段有事故先例 —— <c>PUT /params {"enabled":false}</c> 曾把整块 JSON 抹成 NULL,
+/// 把「谁能处理」放进同一个易被整体重写的 blob 等于让它暴露在同一类静默失效下;
+/// ③ 去重与合法性可由唯一索引在 DB 层保证,JSON 只能靠应用层自觉。</para>
+/// </summary>
+[SugarTable("ado_s8_rule_handler_user", "S8 规则处理账号池")]
+[SugarIndex("uk_s8_rule_handler", nameof(TenantId), OrderByType.Asc, nameof(RuleCode), OrderByType.Asc, nameof(UserId), OrderByType.Asc, IsUnique = true)]
+[SugarIndex("idx_s8_rule_handler_user", nameof(TenantId), OrderByType.Asc, nameof(UserId), OrderByType.Asc)]
+public class AdoS8RuleHandlerUser
+{
+    [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>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; }
+}

+ 82 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8ClaimEligibility.cs

@@ -0,0 +1,82 @@
+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 S8ClaimEligibilityResult(bool Allowed, string? ReasonCode, string? Message)
+{
+    public static S8ClaimEligibilityResult Pass() => new(true, null, null);
+    public static S8ClaimEligibilityResult Deny(string reason, string message) => new(false, reason, message);
+}
+
+/// <summary>认领被拒的原因码。</summary>
+public static class S8ClaimDenyReason
+{
+    /// <summary>账号没有「认领」这个动作的权限(角色维度)。</summary>
+    public const string NoActionPermission = "claim_no_action_permission";
+
+    /// <summary>账号不在这条规则的处理账号池里(责任维度)。</summary>
+    public const string NotInHandlerPool = "claim_not_in_handler_pool";
+}
+
+/// <summary>
+/// S8-HANDLER-POOL-1:「这个账号现在能不能认领这条异常」的<b>唯一</b>判定实现。
+///
+/// <para><b>三个正交维度,缺一不可</b>:
+/// <list type="number">
+///   <item><b>动作权限</b>:角色是否被授予 <c>EXCEPTION_CLAIM</c>(由 <see cref="IS8ExceptionActionAuthorizer"/> 回答);</item>
+///   <item><b>规则责任</b>:账号是否在该异常来源规则的处理池里(本类回答);</item>
+///   <item><b>状态机</b>:单据当前状态是否允许认领 + 是否已被人抢走
+///         (由 <c>S8TaskFlowService</c> 的原子 UPDATE 回答,本类不重复实现)。</item>
+/// </list>
+/// 把三者混在一处判断是本仓已经踩过的坑:一旦某一维度被"顺手"放宽,
+/// 另外两维看起来仍然正常,缺陷只在特定组合下出现。</para>
+///
+/// <para><b>人工提报的异常没有来源规则</b>(<c>SourceRuleCode</c> 为空)。
+/// 这类单据不受责任池约束 —— 池是"谁负责这条规则",而它根本不来自任何规则。
+/// 若在这里一刀切要求入池,人工提报的异常将永远无人可认领。</para>
+/// </summary>
+public interface IS8ClaimEligibility
+{
+    Task<S8ClaimEligibilityResult> CheckAsync(long tenantId, long userId, AdoS8Exception exception);
+}
+
+/// <summary>正式实现。生产代码里只有这一份;接口只为测试留 seam。</summary>
+public sealed class S8ClaimEligibility : IS8ClaimEligibility, ITransient
+{
+    private readonly IS8ExceptionActionAuthorizer _authorizer;
+    private readonly IS8RuleHandlerPoolReader _handlerPool;
+
+    public S8ClaimEligibility(
+        IS8ExceptionActionAuthorizer authorizer,
+        IS8RuleHandlerPoolReader handlerPool)
+    {
+        _authorizer = authorizer;
+        _handlerPool = handlerPool;
+    }
+
+    public async Task<S8ClaimEligibilityResult> CheckAsync(long tenantId, long userId, AdoS8Exception exception)
+    {
+        // ① 动作权限。Controller 上的 [S8ExceptionAction] 已经拦过一次,
+        //    这里再判一次是因为本方法也被"我能不能认领"的前端探测接口复用 ——
+        //    两处都必须给出同一个答案,否则按钮显示可点、点了却 403。
+        var auth = await _authorizer.AuthorizeAsync(tenantId, userId, S8ExceptionActionCode.Claim);
+        if (!auth.Allowed)
+            return S8ClaimEligibilityResult.Deny(
+                S8ClaimDenyReason.NoActionPermission, "没有权限执行该操作");
+
+        // ② 规则责任。人工提报无来源规则,不受池约束(见接口注释)。
+        var ruleCode = exception.SourceRuleCode;
+        if (string.IsNullOrWhiteSpace(ruleCode))
+            return S8ClaimEligibilityResult.Pass();
+
+        if (!await _handlerPool.IsMemberAsync(tenantId, ruleCode!, userId))
+            return S8ClaimEligibilityResult.Deny(
+                S8ClaimDenyReason.NotInHandlerPool,
+                "你不是该规则的处理人员,无法认领。如需处理请联系管理员将你加入该规则的处理账号池。");
+
+        return S8ClaimEligibilityResult.Pass();
+    }
+}

+ 35 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8RuleReadinessGate.cs

@@ -10,6 +10,13 @@ public static class S8RuleReadinessReasonCode
 
     /// <summary>已配置的部门不存在于当前租户(或已停用)。</summary>
     public const string DepartmentNotInTenant = "rule_department_not_in_tenant";
+
+    /// <summary>
+    /// 规则没有任何处理账号(S8-HANDLER-POOL-1)。
+    /// <para>启用一条没人负责的规则,结果是异常源源不断地建出来、静静躺在列表里没人认领——
+    /// 而调度器每一轮都报成功。与"配了部门但没人处理"是同一类静默失败,必须在启用前挡住。</para>
+    /// </summary>
+    public const string HandlerPoolEmpty = "rule_handler_pool_empty";
 }
 
 /// <summary>就绪判定结果。<see cref="Ok"/> 为 true 时另外两项为 null。</summary>
@@ -40,6 +47,10 @@ public sealed class S8RuleReadinessCheck
 /// <c>tick_rule_done ... status=SUCCESS hits=28 created=0 failed=1</c>,
 /// 页面显示「已启用 / 最近结果:成功」。这是最难发现的一类失败 —— 每一层都报成功。</para>
 ///
+/// <para><b>S8-HANDLER-POOL-1 追加一条判据</b>:规则必须至少有一个当前有效的处理账号。
+/// 少了它,一条没人负责的规则会源源不断建出异常、静静躺在列表里没人认领,
+/// 而调度器每一轮都报成功 —— 与"配了部门但没人处理"同属一类静默失败。</para>
+///
 /// <para><b>三条真实执行入口必须共用本门禁</b>:<c>EnableAsync</c> · <c>RunNowAsync</c> ·
 /// <c>S8WatchSchedulerService.RunSingleRuleAsync</c>。少接一条就等于留了旁路:
 /// 启用被挡但 RunNow 能跑、或启用时合法而部门事后被停用、调度器照样进 evaluator 再 create_failed。
@@ -60,8 +71,15 @@ public sealed class S8RuleReadinessCheck
 public sealed class S8RuleReadinessGate : ITransient
 {
     private readonly IS8DepartmentScopeValidator _deptValidator;
+    private readonly IS8RuleHandlerPoolReader _handlerPool;
 
-    public S8RuleReadinessGate(IS8DepartmentScopeValidator deptValidator) => _deptValidator = deptValidator;
+    public S8RuleReadinessGate(
+        IS8DepartmentScopeValidator deptValidator,
+        IS8RuleHandlerPoolReader handlerPool)
+    {
+        _deptValidator = deptValidator;
+        _handlerPool = handlerPool;
+    }
 
     /// <summary>按生效形态判定就绪。不抛异常,由调用方决定失败语义(400 / run-result)。</summary>
     public async Task<S8RuleReadinessCheck> CheckAsync(S8EffectiveRule effective, long tenantId)
@@ -97,6 +115,22 @@ 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();
     }
 

+ 57 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionService.cs

@@ -3,6 +3,7 @@ using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
 using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
@@ -12,6 +13,9 @@ public class S8ExceptionService : ITransient
     private readonly SqlSugarRepository<AdoS8Exception> _rep;
     private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
     private readonly IS8UserScopeValidator _userScope;
+    private readonly IS8ExceptionActionAuthorizer _actionAuthorizer;
+    private readonly IS8RuleHandlerPoolReader _handlerPool;
+    private readonly UserManager _userManager;
     private readonly S8ImpactMetricsService _impactMetricsService;
 
     // S8-DEMO-IMPACT-SORT-NOTICE-1:影响排序的候选集合上限。
@@ -25,14 +29,45 @@ public class S8ExceptionService : ITransient
         SqlSugarRepository<AdoS8Exception> rep,
         SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
         IS8UserScopeValidator userScope,
+        IS8ExceptionActionAuthorizer actionAuthorizer,
+        IS8RuleHandlerPoolReader handlerPool,
+        UserManager userManager,
         S8ImpactMetricsService impactMetricsService)
     {
         _rep = rep;
         _deptRep = deptRep;
         _userScope = userScope;
+        _actionAuthorizer = actionAuthorizer;
+        _handlerPool = handlerPool;
+        _userManager = userManager;
         _impactMetricsService = impactMetricsService;
     }
 
+    /// <summary>
+    /// 解析当前账号的异常可见范围。
+    ///
+    /// <para>返回 null = <b>不加限制</b>(持有 <c>EXCEPTION_VIEW_ALL</c>,或没有登录上下文
+    /// 的内部调用如看板聚合 / 后台作业)。返回值 = (我负责的规则编码集合, 我的账号 Id)。</para>
+    ///
+    /// <para><b>超管不受限</b>:与 <c>S8ExceptionActionAttribute</c> 同口径,
+    /// 避免出现"平台放行、S8 拦截"的不一致。</para>
+    /// </summary>
+    private async Task<(List<string> RuleCodes, long UserId)?> ResolveVisibilityScopeAsync(long tenantId)
+    {
+        if (_userManager.SuperAdmin) return null;
+
+        var userId = _userManager.UserId;
+        // 无登录上下文(看板聚合 / 后台作业)不做人员可见性收敛 ——
+        // 那些调用方本来就不是"某个人在看列表",加上限制只会让统计数字凭空少掉。
+        if (userId <= 0 || tenantId <= 0) return null;
+
+        var allowed = await _actionAuthorizer.GetAllowedActionsAsync(tenantId, userId);
+        if (allowed.Contains(S8ExceptionActionCode.ViewAll)) return null;
+
+        var ruleCodes = (await _handlerPool.GetRuleCodesForUserAsync(tenantId, userId)).ToList();
+        return (ruleCodes, userId);
+    }
+
     public async Task<(int total, List<AdoS8ExceptionListItemDto> list)> GetPagedAsync(AdoS8ExceptionQueryDto q)
     {
         (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize);
@@ -88,6 +123,28 @@ public class S8ExceptionService : ITransient
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword),
                 (e, sc, wr) => e.Title.Contains(q.Keyword!) || e.ExceptionCode.Contains(q.Keyword!));
 
+        // ── S8-HANDLER-POOL-1:可见性收敛 ──
+        //
+        // View 与 View All 是两件事:前者是"能进这个页面",后者是"能看别人负责的单子"。
+        // 没有 EXCEPTION_VIEW_ALL 的账号只看得到:① 自己负责的规则产出的异常;
+        // ② 人工提报(无来源规则,不归属任何责任池);③ 与自己直接相关的单子
+        // (处理人 / 检验人 / 提报人是本人)——最后一条是必要的:单据可能在他认领之后
+        // 被移出责任池,若不保留这一支,他会看不见自己手上正在处理的单子。
+        //
+        // 这是 EXCEPTION_VIEW_ALL 的**唯一真实消费方**。P1-B 建立该配置位时它还没有消费方,
+        // 在此闭环。
+        var visibilityScope = await ResolveVisibilityScopeAsync(q.TenantId);
+        if (visibilityScope != null)
+        {
+            var (myRuleCodes, myUserId) = visibilityScope.Value;
+            query = query.Where((e, sc, wr) =>
+                SqlFunc.IsNullOrEmpty(e.SourceRuleCode)
+                || myRuleCodes.Contains(e.SourceRuleCode)
+                || e.AssigneeUserId == myUserId
+                || e.VerifierUserId == myUserId
+                || e.ReporterUserId == myUserId);
+        }
+
         var total = await query.CountAsync();
 
         // S8-DEMO-IMPACT-SORT-NOTICE-1:候选集合 <= ImpactSortCandidateCap 时走"全量取 → 内存填影响 → 排序 → 分页",

+ 197 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8RuleHandlerPoolService.cs

@@ -0,0 +1,197 @@
+using Admin.NET.Core;
+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>人员池成员(展示用)。</summary>
+public sealed class S8HandlerUserDto
+{
+    public long UserId { get; set; }
+    public string Account { get; set; } = string.Empty;
+    public string DisplayName { get; set; } = string.Empty;
+    /// <summary>该账号当前是否仍可用(同租户 + 启用)。false = 历史配置已失效,前端需显式提示。</summary>
+    public bool Valid { get; set; }
+}
+
+/// <summary>
+/// 责任池的<b>读侧</b> seam。就绪门禁、认领资格、可见性收敛、以及后续的通知收件人解析
+/// 都只需要读 —— 让它们依赖这个窄接口而不是整个服务,既能在单测里替身,
+/// 也让「谁会写这张表」在类型层面收敛到配置入口一处。
+/// </summary>
+public interface IS8RuleHandlerPoolReader
+{
+    /// <summary>池成员账号 Id(原样返回,含可能已失效的历史成员)。</summary>
+    Task<IReadOnlyList<long>> GetMemberIdsAsync(long tenantId, string ruleCode);
+
+    /// <summary>池成员(含展示信息与有效性标记)。失效成员照样返回、只标 <c>Valid=false</c>。</summary>
+    Task<List<S8HandlerUserDto>> GetMembersAsync(long tenantId, string ruleCode);
+
+    /// <summary>该账号是否属于这条规则的责任池(停用 / 跨租户成员一律不算数)。</summary>
+    Task<bool> IsMemberAsync(long tenantId, string ruleCode, long userId);
+
+    /// <summary>该账号在本租户负责的全部规则编码(供可见性过滤使用)。</summary>
+    Task<IReadOnlyList<string>> GetRuleCodesForUserAsync(long tenantId, long userId);
+
+    /// <summary>批量取多条规则的池成员(避免规则列表页 N+1)。</summary>
+    Task<IReadOnlyDictionary<string, List<long>>> GetPoolsAsync(long tenantId, IEnumerable<string> ruleCodes);
+}
+
+/// <summary>
+/// S8-HANDLER-POOL-1:规则处理账号池的<b>唯一</b>读写实现。
+///
+/// <para>它回答的是「哪些账号负责这条规则」,与「哪个角色能做认领这类动作」
+/// (<see cref="IS8ExceptionActionAuthorizer"/>)是两个正交问题。
+/// 最终允许 = 动作权限 ∩ 规则责任 ∩ 状态机。</para>
+///
+/// <para><b>成员合法性复用 <see cref="IS8UserScopeValidator"/></b>,不另写一套判据:
+/// 池成员与认领人必须用同一把尺子,否则会出现「配得进池、认领时又被拒」这种
+/// 管理员完全无法自查的状态。</para>
+/// </summary>
+public class S8RuleHandlerPoolService : IS8RuleHandlerPoolReader, ITransient
+{
+    private readonly SqlSugarRepository<AdoS8RuleHandlerUser> _rep;
+    private readonly IS8UserScopeValidator _userScope;
+    private readonly UserManager _userManager;
+    private readonly ILogger<S8RuleHandlerPoolService> _logger;
+
+    public S8RuleHandlerPoolService(
+        SqlSugarRepository<AdoS8RuleHandlerUser> rep,
+        IS8UserScopeValidator userScope,
+        UserManager userManager,
+        ILogger<S8RuleHandlerPoolService> logger)
+    {
+        _rep = rep;
+        _userScope = userScope;
+        _userManager = userManager;
+        _logger = logger;
+    }
+
+    // ────────────────────────────── 读 ──────────────────────────────
+
+    /// <summary>池成员的账号 Id(原样返回,含可能已失效的历史成员)。</summary>
+    public async Task<IReadOnlyList<long>> GetMemberIdsAsync(long tenantId, string ruleCode)
+    {
+        if (tenantId <= 0 || string.IsNullOrWhiteSpace(ruleCode)) return Array.Empty<long>();
+        return await _rep.AsQueryable().ClearFilter()
+            .Where(x => x.TenantId == tenantId && x.RuleCode == ruleCode)
+            .Select(x => x.UserId)
+            .ToListAsync();
+    }
+
+    /// <summary>
+    /// 池成员(含展示信息与有效性标记)。
+    ///
+    /// <para><b>失效成员照样返回、只标 <c>Valid=false</c></b>,不静默过滤:
+    /// 账号被停用或移出租户之后,管理员必须能在页面上看见"这里有一个人已经不算数了",
+    /// 而不是眼看着池子无声地少了一个人。</para>
+    /// </summary>
+    public async Task<List<S8HandlerUserDto>> GetMembersAsync(long tenantId, string ruleCode)
+    {
+        var ids = await GetMemberIdsAsync(tenantId, ruleCode);
+        if (ids.Count == 0) return new List<S8HandlerUserDto>();
+
+        var valid = (await _userScope.ValidateUsersAsync(tenantId, ids)).ToDictionary(u => u.UserId);
+
+        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();
+    }
+
+    /// <summary>
+    /// 该账号是否属于这条规则的责任池。<b>停用 / 跨租户成员一律不算数</b> ——
+    /// 判据与认领时完全同源,避免"配置里有他、运行时又不认"。
+    /// </summary>
+    public async Task<bool> IsMemberAsync(long tenantId, string ruleCode, long userId)
+    {
+        if (tenantId <= 0 || userId <= 0 || string.IsNullOrWhiteSpace(ruleCode)) return false;
+
+        var exists = await _rep.AsQueryable().ClearFilter()
+            .Where(x => x.TenantId == tenantId && x.RuleCode == ruleCode && x.UserId == userId)
+            .AnyAsync();
+        if (!exists) return false;
+
+        return await _userScope.FindActiveUserAsync(tenantId, userId) != null;
+    }
+
+    /// <summary>该账号在本租户负责的全部规则编码(供可见性过滤使用)。</summary>
+    public async Task<IReadOnlyList<string>> GetRuleCodesForUserAsync(long tenantId, long userId)
+    {
+        if (tenantId <= 0 || userId <= 0) return Array.Empty<string>();
+        return await _rep.AsQueryable().ClearFilter()
+            .Where(x => x.TenantId == tenantId && x.UserId == userId)
+            .Select(x => x.RuleCode)
+            .Distinct()
+            .ToListAsync();
+    }
+
+    /// <summary>批量取多条规则的池成员(避免规则列表页 N+1)。</summary>
+    public async Task<IReadOnlyDictionary<string, List<long>>> GetPoolsAsync(long tenantId, IEnumerable<string> ruleCodes)
+    {
+        var codes = ruleCodes.Where(c => !string.IsNullOrWhiteSpace(c)).Distinct().ToList();
+        if (tenantId <= 0 || codes.Count == 0)
+            return new Dictionary<string, List<long>>(StringComparer.OrdinalIgnoreCase);
+
+        var rows = await _rep.AsQueryable().ClearFilter()
+            .Where(x => x.TenantId == tenantId && codes.Contains(x.RuleCode))
+            .Select(x => new { x.RuleCode, x.UserId })
+            .ToListAsync();
+
+        return rows.GroupBy(x => x.RuleCode, StringComparer.OrdinalIgnoreCase)
+            .ToDictionary(g => g.Key, g => g.Select(x => x.UserId).Distinct().ToList(), StringComparer.OrdinalIgnoreCase);
+    }
+
+    // ────────────────────────────── 写 ──────────────────────────────
+
+    /// <summary>
+    /// 全量替换某条规则的处理账号池。
+    ///
+    /// <para>成员必须是本租户的启用账号 —— 跨租户与不存在共用同一文案,不泄漏存在性。
+    /// 判据来自 <see cref="IS8UserScopeValidator"/>,与认领 / 转派 / 复检完全同源。</para>
+    /// </summary>
+    public async Task SetMembersAsync(S8TrustedScope scope, string ruleCode, IEnumerable<long>? userIds)
+    {
+        if (string.IsNullOrWhiteSpace(ruleCode)) throw new S8BizException("规则编码不能为空");
+
+        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)
+            .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 AdoS8RuleHandlerUser
+            {
+                TenantId = scope.TenantId,
+                RuleCode = ruleCode,
+                UserId = id,
+                CreatedAt = now,
+                CreatedBy = _userManager.Account
+            }).ToList()).ExecuteCommandAsync();
+        }
+
+        _logger.LogInformation(
+            "s8_rule_handler_pool_updated tenant={Tenant} rule={Rule} added={Added} removed={Removed} total={Total}",
+            scope.TenantId, ruleCode, toAdd.Count, toRemove.Count, wanted.Count);
+    }
+}

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

@@ -12,6 +12,8 @@ public class S8TaskFlowService : ITransient
     private readonly SqlSugarRepository<AdoS8Exception> _rep;
     private readonly SqlSugarRepository<AdoS8ExceptionTimeline> _timelineRep;
     private readonly IS8UserScopeValidator _userScope;
+    private readonly IS8ClaimEligibility _claimEligibility;
+    private readonly IS8RuleHandlerPoolReader _handlerPool;
     private readonly SqlSugarRepository<ApprovalFlowInstance> _flowInstanceRep;
     private readonly SqlSugarRepository<ApprovalFlowTask> _flowTaskRep;
     private readonly FlowEngineService _flowEngine;
@@ -24,6 +26,8 @@ public class S8TaskFlowService : ITransient
         SqlSugarRepository<AdoS8Exception> rep,
         SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep,
         IS8UserScopeValidator userScope,
+        IS8ClaimEligibility claimEligibility,
+        IS8RuleHandlerPoolReader handlerPool,
         SqlSugarRepository<ApprovalFlowInstance> flowInstanceRep,
         SqlSugarRepository<ApprovalFlowTask> flowTaskRep,
         FlowEngineService flowEngine,
@@ -35,6 +39,8 @@ public class S8TaskFlowService : ITransient
         _rep = rep;
         _timelineRep = timelineRep;
         _userScope = userScope;
+        _claimEligibility = claimEligibility;
+        _handlerPool = handlerPool;
         _flowInstanceRep = flowInstanceRep;
         _flowTaskRep = flowTaskRep;
         _flowEngine = flowEngine;
@@ -68,6 +74,17 @@ public class S8TaskFlowService : ITransient
         if (!S8StatusRules.IsAllowedTransition(e.Status, "ASSIGNED"))
             throw new S8BizException($"状态 {e.Status} 不可认领");
 
+        // S8-HANDLER-POOL-1:认领资格 = 动作权限 ∩ 规则责任。
+        // 状态与并发那一维由下面的原子 UPDATE 负责,这里不重复判断。
+        var eligibility = await _claimEligibility.CheckAsync(tenantId, currentUserId, e);
+        if (!eligibility.Allowed)
+        {
+            _logger.LogInformation(
+                "s8_claim_denied exceptionId={Id} tenantId={TenantId} userId={UserId} reason={Reason} rule={Rule}",
+                e.Id, tenantId, currentUserId, eligibility.ReasonCode, e.SourceRuleCode);
+            throw new S8BizException(eligibility.Message ?? "没有权限执行该操作");
+        }
+
         var fromStatus = e.Status;
         var now = DateTime.Now;
 
@@ -112,6 +129,20 @@ public class S8TaskFlowService : ITransient
         var allowedStatuses = new HashSet<string> { "ASSIGNED", "IN_PROGRESS" };
         var target = await _userScope.EnsureActiveUserAsync(tenantId, newAssigneeUserId, "转派目标账号");
         var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在");
+
+        // S8-HANDLER-POOL-1:转派目标同样受责任池约束。
+        //
+        // 不留「转给池外账号」的后门:那等于绕过责任池本身 —— 池子写着 A、B 负责,
+        // 实际单据却落到 C 手里,而池配置看起来仍然正确。需要转给池外的人时,
+        // 正确做法是先把他加进该规则的处理账号池,再转派。
+        //
+        // 人工提报无来源规则,不受池约束(与认领同一口径)。
+        if (!string.IsNullOrWhiteSpace(e.SourceRuleCode)
+            && !await _handlerPool.IsMemberAsync(tenantId, e.SourceRuleCode!, target.UserId))
+        {
+            throw new S8BizException(
+                $"{target.DisplayName} 不是该规则的处理人员,无法转派。请先将其加入该规则的处理账号池。");
+        }
         if (e.ActiveFlowInstanceId.HasValue)
             throw new S8BizException("审批进行中不可转派");
         if (e.Status == "ESCALATED")

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

@@ -254,6 +254,8 @@ public class Startup : AppStartup
                 typeof(AdoS8RolePermissionConfig),
                 // S8-ACTION-PERMISSION-1:异常动作 ↔ 角色授权矩阵。
                 typeof(AdoS8ExceptionActionRole),
+                // S8-HANDLER-POOL-1:规则 ↔ 处理账号池。
+                typeof(AdoS8RuleHandlerUser),
                 typeof(AdoS8NotificationLog),
                 typeof(AdoS8DashboardCellConfig),
                 typeof(AdoS8ExceptionType),