瀏覽代碼

feat(s8): 统一规则通知收件人模型

把「这个事件通知谁」从旧的 ado_s8_notification_layer 迁到按<规则 + 事件>组织的
新模型,并让它成为 CURRENT AUTHORITY。

旧表为什么不够用(本地取证):它按 (scene_code, severity, level_code) 组织,
**不按规则**——而 P1-C 已经把责任落到了规则级,它无法回答「这条规则出事通知谁」;
level_code 的 L1/L2/L3 三档在库里只有 L1_OPERATOR 被真正用过(16 行全是它,
另有 1 行 level_code 为空),且「主管 / 总监」在系统中没有对应实体;
收件人只支持 Role 一种,无法表达「通知当前处理人」「通知责任池」这类真实诉求。

旧表**不删**,降级为 LEGACY fallback:仅当新表对该 (租户, 规则, 事件) 没有任何
配置时才使用。优先级单一、可解释,不是两个平级 authority——也不做"两者合并",
合并会让管理员删掉收件人之后通知照发,而他在新页面上已经看不到那些人。

第一版四种收件人,全部能真实解析出账号:
- HANDLER_POOL 直接复用 P1-C 的责任池,不复制第二份人员配置(两份名单迟早不一致,
  而不一致的那一刻没有任何报错);
- ASSIGNEE 当前处理人;
- REVIEWER **不是新建的审核池**——按已锁定的决策,它解析当前事实:优先取单据上的
  verifier_user_id(提交复核时手选、真正能点通过/退回的人),尚未提交复核时回落
  审批流节点解析出的审批人;
- SPECIFIC_USER 指定账号,写入时逐个校验同租户 + 启用。
Supervisor 缺席是刻意的:系统里不存在组织主管关系,给一个永远解析为空的选项
等于让管理员配一条永远发不出去的通知。

事件只覆盖真实派发点(7 个),并补上两处:
- 提交复核此前**没有任何通知**,检验人不会知道有单子在等他;
- 认领 / 转派原本靠一段写死收件人的代码单发,管理员无从更改、也无法让责任池里的
  其他人同时知道——现在与规则建单 / 升级 / 恢复共用同一套配置。

本地验证中发现并修掉两个真实缺陷:
1. 认领走原子 UPDATE,它只写库、不回写内存实体;直接拿旧实体派发会让 ASSIGNEE
   读到 null 而解析出 0 人,且整条链路一路成功、日志里连一条记录都不留。改为
   重新加载后再派发。
2. 已注册的 pusher 只有 DingTalk / WorkWeixin / SignalR / Sms,**没有站内信**——
   旧分层派发从来只推 SignalR(用户不在线就等于没发过),站内信是那段写死代码单独
   发的。统一模型如果只走 pushAdapter 会静默丢掉它。现在所有事件都先落站内信、
   再推实时通道,两者各自独立 try,互不阻断。

0 收件人必须可观测:解析结果为空时按类型分别记数并告警,绝不静默当作发送成功。
通知失败不阻断业务动作(认领已经落库,不能因为发不出消息就回滚),但必须落日志。

本地验证:未知事件 / 未知类型 / 勾了指定账号却不给人 / 跨租户账号四类负向配置全部
被拒(含 ASSIGNEE_SUPERVISOR);按推荐策略配置后实跑,站内信收件人与配置逐条吻合
——认领通知落到 UATPurchaseA、提交复核通知落到手选的检验人 UATQualityA。
YY968XX 1 周之前
父節點
當前提交
91cd59209b

+ 1 - 1
Web/package.json

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

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

@@ -401,6 +401,11 @@ export const s8ConfigApi = {
 			service.delete(`/api/aidop/s8/config/exception-types/${overrideId}/factory-override`).then(unwrap),
 	},
 	watchRules: {
+		/** S8-NOTIFY-RECIPIENT-1:该规则的事件通知收件人配置。 */
+		notifyRecipients: (ruleId: number) =>
+			service.get(`/api/aidop/s8/config/watch-rules/${ruleId}/notify-recipients`, { params: withAidopTenantFactoryParams({}) }).then(unwrap),
+		setNotifyRecipients: (ruleId: number, eventCode: string, body: { recipientTypes: string[]; specificUserIds: number[] }) =>
+			service.put(`/api/aidop/s8/config/watch-rules/${ruleId}/notify-recipients/${eventCode}`, withAidopTenantFactoryParams(body)).then(unwrap),
 		/** S8-RULE-4TAB-1:只读审核来源(不改任何审批语义,只呈现当前事实)。 */
 		reviewSource: (ruleId: number) =>
 			service.get(`/api/aidop/s8/config/watch-rules/${ruleId}/review-source`, { params: withAidopTenantFactoryParams({}) }).then(unwrap),

+ 96 - 6
Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue

@@ -200,6 +200,47 @@ const reviewLoaded = ref(false);
 const reviewStages = ref<any[]>([]);
 const escalateRoles = ref<string[]>([]);
 
+// ── S8-NOTIFY-RECIPIENT-1:事件收件人配置 ──
+const notifyLoaded = ref(false);
+const notifyEvents = ref<any[]>([]);
+const notifyTypes = ref<string[]>([]);
+const savingNotify = ref('');
+
+/** 收件人类型的业务文案。刻意不出现「主管 / 总监」—— 系统里没有对应实体。 */
+const RECIPIENT_LABEL: Record<string, string> = {
+	HANDLER_POOL: '该规则的处理账号池',
+	ASSIGNEE: '当前处理人',
+	REVIEWER: '当前实际审核人',
+	SPECIFIC_USER: '指定账号',
+};
+
+async function loadNotifyRecipients(ruleId: number) {
+	notifyLoaded.value = false;
+	try {
+		const r: any = await s8ConfigApi.watchRules.notifyRecipients(ruleId);
+		notifyEvents.value = r?.events ?? [];
+		notifyTypes.value = r?.recipientTypes ?? [];
+	} finally {
+		notifyLoaded.value = true;
+	}
+}
+
+async function saveNotifyEvent(row: any) {
+	if (!current.value) return;
+	savingNotify.value = row.eventCode;
+	try {
+		await s8ConfigApi.watchRules.setNotifyRecipients(Number(current.value.id), row.eventCode, {
+			recipientTypes: row.recipientTypes ?? [],
+			specificUserIds: row.specificUserIds ?? [],
+		});
+		ElMessage.success((row.recipientTypes?.length)
+			? `已保存「${row.displayName}」的收件人`
+			: `「${row.displayName}」已清空收件人(将回落旧分层配置)`);
+	} finally {
+		savingNotify.value = '';
+	}
+}
+
 async function loadReviewSource(ruleId: number) {
 	reviewLoaded.value = false;
 	try {
@@ -213,7 +254,9 @@ async function loadReviewSource(ruleId: number) {
 
 /** 审批流程配置归流程管理维护;本页只读展示,改配置去那边,不在规则弹窗里改。 */
 function goApprovalConfig() {
-	router.push('/aidop/flowManage/approvalFlowList');
+	// 路由取自菜单实测值:/aidop/flowManage/approvalFlow(不是 approvalFlowList,
+	// 后者不存在,会落到「找不到此页面」——本地点开验证时发现的)。
+	router.push('/aidop/flowManage/approvalFlow');
 }
 
 function openDetail(row: S8WatchRuleConfigRow) {
@@ -231,6 +274,7 @@ function openDetail(row: S8WatchRuleConfigRow) {
 	activeTab.value = 'info';
 	void loadHandlerPool(Number(row.id));
 	void loadReviewSource(Number(row.id));
+	void loadNotifyRecipients(Number(row.id));
 }
 
 /**
@@ -703,13 +747,59 @@ async function provision() {
 						那三档在库里只有 L1 真正被用过,且「主管 / 总监」没有对应业务实体。
 					-->
 					<el-tab-pane label="通知与升级" name="notify">
-						<el-empty description="通知收件人配置将在下一批(P1-E)接入">
-							<div style="color: #909399; font-size: 12px; line-height: 1.9; max-width: 560px">
-								届时可按事件配置收件人:异常产生 → 处理人员池;认领成功 → 当前处理人;<br />
-								提交复核 → 上方「审核来源」解析出的实际审核人;超时升级 → 指定账号。
+						<el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px">
+							<template #title>收件人按<strong>事件</strong>配置,最终都投递到系统账号</template>
+							<div style="line-height: 1.9">
+								未配置的事件会回落到旧的「通知分层配置」(按场景 + 严重度 → 角色)。
+								一旦这里配了任意收件人,该事件就<strong>完全不再</strong>使用旧配置。
 							</div>
-						</el-empty>
+						</el-alert>
+
+						<el-table :data="notifyEvents" v-loading="!notifyLoaded" border style="width: 100%">
+							<el-table-column prop="displayName" label="事件" width="130">
+								<template #default="scope">
+									<span>{{ scope.row.displayName }}</span>
+									<el-tag v-if="!(scope.row.recipientTypes || []).length" size="small" type="info" style="margin-left: 6px">回落旧配置</el-tag>
+								</template>
+							</el-table-column>
+							<el-table-column prop="triggerHint" label="触发时机" min-width="200">
+								<template #default="scope">
+									<span style="color: #909399; font-size: 12px">{{ scope.row.triggerHint }}</span>
+								</template>
+							</el-table-column>
+							<el-table-column label="通知谁" min-width="260">
+								<template #default="scope">
+									<el-select v-model="scope.row.recipientTypes" multiple collapse-tags collapse-tags-tooltip
+										placeholder="未配置(回落旧分层配置)" style="width: 100%">
+										<el-option v-for="t in notifyTypes" :key="t" :label="RECIPIENT_LABEL[t] || t" :value="t" />
+									</el-select>
+								</template>
+							</el-table-column>
+							<el-table-column label="指定账号" min-width="230">
+								<template #default="scope">
+									<el-select v-if="(scope.row.recipientTypes || []).includes('SPECIFIC_USER')"
+										v-model="scope.row.specificUserIds" multiple filterable collapse-tags collapse-tags-tooltip
+										placeholder="选择账号" style="width: 100%">
+										<el-option v-for="u in tenantUsers" :key="u.id" :label="u.name" :value="u.id" />
+									</el-select>
+									<span v-else style="color: #c0c4cc; font-size: 12px">仅「指定账号」需要</span>
+								</template>
+							</el-table-column>
+							<el-table-column label="操作" width="90" fixed="right">
+								<template #default="scope">
+									<el-button type="primary" link :loading="savingNotify === scope.row.eventCode"
+										@click="saveNotifyEvent(scope.row)">保存</el-button>
+								</template>
+							</el-table-column>
+						</el-table>
+
+						<div style="color: #909399; font-size: 12px; margin-top: 10px; line-height: 1.9">
+							「当前实际审核人」解析的是单据上已手选的检验人;尚未提交复核时回落到上方
+							「审核来源」列出的审批流审批人 —— 它不是一个新的审核人员池。<br />
+							可用渠道:站内信、SignalR。
+						</div>
 					</el-tab-pane>
+
 				</el-tabs>
 			</template>
 		</el-dialog>

+ 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.499</AssemblyVersion>
-    <FileVersion>1.0.499</FileVersion>
-    <Version>1.0.499</Version>
+    <AssemblyVersion>1.0.500</AssemblyVersion>
+    <FileVersion>1.0.500</FileVersion>
+    <Version>1.0.500</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -697,6 +697,9 @@
     <None Update="UpdateScripts\1.0.498.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.500.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 82 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.500.sql

@@ -0,0 +1,82 @@
+-- =====================================================================================
+-- 1.0.500  S8-NOTIFY-RECIPIENT-1 · 通知收件人模型
+--
+-- 取代 ado_s8_notification_layer 成为「这个事件通知谁」的 CURRENT AUTHORITY。
+--
+-- 旧表为什么不够用(真库取证):
+--   · 它按 (scene_code, severity, level_code) 组织,**不按规则** —— 无法回答
+--     「这条规则出事通知谁」,而 P1-C 已经把责任落到了规则级;
+--   · level_code 的 L1/L2/L3 三档在库里只有 L1_OPERATOR 被真正用过(16 行全是它,
+--     另有 1 行 level_code 为空),且「主管 / 总监」在系统中没有对应实体
+--     (SysOrg.DirectorId 与 SysUser.ManagerUserId 填充率均为 0);
+--   · 收件人只支持 Role 一种,无法表达「通知当前处理人」「通知责任池」这类真实诉求。
+--
+-- 旧表**不删**,降级为 LEGACY fallback:仅当新表对该 (租户, 规则, 事件) 没有任何配置
+-- 时才使用。优先级单一、可解释 —— 不是两个平级 authority。这样现网既有通知不会被
+-- 这批改动打断,管理员可以按规则逐步迁移。
+--
+-- 表由 CodeFirst(InitTables) 创建;本脚本只补索引 + postcheck,不写任何收件人数据
+-- (收件人是 per-tenant / per-rule 的人工配置,写死既不适配新租户也会覆盖管理员的选择)。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS `ado_s8_notification_recipient` (
+  `id`                 BIGINT       NOT NULL AUTO_INCREMENT,
+  `tenant_id`          BIGINT       NOT NULL COMMENT '租户(唯一安全边界)',
+  `rule_code`          VARCHAR(128) NOT NULL COMMENT "规则编码;'*' = 租户级默认(无来源规则的异常)",
+  `event_code`         VARCHAR(64)  NOT NULL COMMENT '事件码,须在 S8NotificationCatalog.Events 内',
+  `recipient_type`     VARCHAR(32)  NOT NULL COMMENT 'HANDLER_POOL / ASSIGNEE / REVIEWER / SPECIFIC_USER',
+  `recipient_user_ids` VARCHAR(2000) NULL COMMENT '仅 SPECIFIC_USER 使用,逗号分隔 SysUser.Id',
+  `created_at`         DATETIME     NOT NULL,
+  `created_by`         VARCHAR(64)  NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='S8 通知收件人配置';
+
+-- 唯一索引:同一 (租户, 规则, 事件, 收件人类型) 只允许一行。
+-- 没有它,重复行会让"取消某个收件人类型"变成删一行、剩一行 ——
+-- 页面显示已取消,实际还在发。
+SET @has_uk := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_notification_recipient'
+    AND INDEX_NAME='uk_s8_notify_recipient');
+
+SET @sql := IF(@has_uk = 0, '
+DELETE a FROM `ado_s8_notification_recipient` a
+  JOIN `ado_s8_notification_recipient` b
+    ON a.`tenant_id` = b.`tenant_id` AND a.`rule_code` = b.`rule_code`
+   AND a.`event_code` = b.`event_code` AND a.`recipient_type` = b.`recipient_type`
+   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_notification_recipient` ADD UNIQUE INDEX `uk_s8_notify_recipient` (`tenant_id`,`rule_code`,`event_code`,`recipient_type`)',
+  'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- 旧表打上 LEGACY 标记(只改注释,不动任何数据行)——
+-- 让直接看库的人也能立刻知道哪张才是当前 authority。
+ALTER TABLE `ado_s8_notification_layer`
+  COMMENT='[LEGACY · 1.0.500 起降级] S8 通知分层配置;CURRENT AUTHORITY = ado_s8_notification_recipient,本表仅在新表无配置时作为 fallback';
+
+-- ── POSTCHECK ──
+SET @cols := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_notification_recipient'
+    AND INDEX_NAME='uk_s8_notify_recipient'
+    AND COLUMN_NAME IN ('tenant_id','rule_code','event_code','recipient_type'));
+DROP TABLE IF EXISTS `tmp_s8_500_guard`;
+CREATE TABLE `tmp_s8_500_guard` (
+  probe BIGINT NOT NULL,
+  CONSTRAINT s8_500_BLOCKED_unique_index_missing CHECK (probe = 0)
+) COMMENT '1.0.500 断言门禁;通过后自动删除';
+INSERT INTO `tmp_s8_500_guard` (probe) VALUES ((4 - @cols));
+DROP TABLE IF EXISTS `tmp_s8_500_guard`;
+
+-- 旧表一行都不能少:本批只降级、不清理。
+SET @legacy := (SELECT COUNT(*) FROM `ado_s8_notification_layer`);
+DROP TABLE IF EXISTS `tmp_s8_500_guard`;
+CREATE TABLE `tmp_s8_500_guard` (
+  probe BIGINT NOT NULL,
+  CONSTRAINT s8_500_BLOCKED_legacy_layer_wiped CHECK (probe = 0)
+) COMMENT '1.0.500 断言门禁;通过后自动删除';
+INSERT INTO `tmp_s8_500_guard` (probe) VALUES (IF(@legacy = 0, 1, 0));
+DROP TABLE IF EXISTS `tmp_s8_500_guard`;
+
+-- 幂等:第二次执行建表跳过、索引已存在跳过、注释重复设置无副作用、断言仍成立。

+ 330 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8NotificationRecipientTests.cs

@@ -0,0 +1,330 @@
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-NOTIFY-RECIPIENT-1:通知收件人模型。
+///
+/// <para><b>取代旧的 <c>ado_s8_notification_layer</c> 成为 CURRENT AUTHORITY。</b>
+/// 旧表按 <c>(scene_code, severity, level_code)</c> 组织,不按规则,无法回答
+/// 「这条规则出事通知谁」;level_code 的 L1/L2/L3 三档在库里只有 L1 被真正用过,
+/// 且「主管 / 总监」在系统中没有对应实体;收件人只支持 Role 一种。</para>
+///
+/// <para><b>优先级必须单一</b>:新模型有配置就完全不走旧表。做成"两者合并"会让
+/// 管理员删掉收件人之后通知照发,而他在新页面上已经看不到那些人 ——
+/// 那是最难自查的一类配置故障。</para>
+/// </summary>
+public class S8NotificationRecipientTests
+{
+    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>
+    /// 每个事件都必须有真实派发点。配了却永远不触发的通知,比没有通知更难排查 ——
+    /// 用户会以为"配置生效了但对方没看",而实际上代码里根本没有那一行。
+    /// </summary>
+    [Fact]
+    public void EveryEvent_HasARealDispatchSite()
+    {
+        var wiring = string.Join('\n', new[]
+        {
+            CodeOnly("Service/S8/S8TaskFlowService.cs"),
+            CodeOnly("Service/S8/S8WatchSchedulerService.cs"),
+            CodeOnly("Service/S8/S8TimeoutAutoEscalationService.cs"),
+        });
+
+        foreach (var e in S8NotificationCatalog.Events)
+        {
+            var member = e.Code switch
+            {
+                S8NotifyEventCode.ExceptionCreated => nameof(S8NotifyEventCode.ExceptionCreated),
+                S8NotifyEventCode.ExceptionClaimed => nameof(S8NotifyEventCode.ExceptionClaimed),
+                S8NotifyEventCode.ExceptionTransferred => nameof(S8NotifyEventCode.ExceptionTransferred),
+                S8NotifyEventCode.VerificationSubmitted => nameof(S8NotifyEventCode.VerificationSubmitted),
+                S8NotifyEventCode.EscalationTriggered => nameof(S8NotifyEventCode.EscalationTriggered),
+                S8NotifyEventCode.ExceptionRecovered => nameof(S8NotifyEventCode.ExceptionRecovered),
+                S8NotifyEventCode.ExceptionOverdueClosed => nameof(S8NotifyEventCode.ExceptionOverdueClosed),
+                _ => null
+            };
+            Assert.True(member != null, $"事件 {e.Code} 未登记到本断言,无法确认它有派发点");
+            Assert.True(wiring.Contains($"S8NotifyEventCode.{member}", StringComparison.Ordinal),
+                $"事件 {e.Code} 在代码里找不到任何派发点 —— 不得制造没有业务入口的事件");
+            Assert.False(string.IsNullOrWhiteSpace(e.TriggerHint));
+        }
+    }
+
+    /// <summary>
+    /// 收件人类型第一版只有四类,且<b>全部能真实解析出账号</b>。
+    /// Supervisor 缺席是刻意的:系统里不存在组织主管关系
+    /// (SysOrg.DirectorId 与 SysUser.ManagerUserId 填充率均为 0),
+    /// 给一个永远解析为空的选项等于让管理员配一条永远发不出去的通知。
+    /// </summary>
+    [Fact]
+    public void RecipientTypes_ExcludeSupervisorAndAnyPhantom()
+    {
+        Assert.Equal(4, S8NotificationCatalog.RecipientTypes.Count);
+        Assert.Contains(S8RecipientType.HandlerPool, S8NotificationCatalog.RecipientTypes);
+        Assert.Contains(S8RecipientType.Assignee, S8NotificationCatalog.RecipientTypes);
+        Assert.Contains(S8RecipientType.Reviewer, S8NotificationCatalog.RecipientTypes);
+        Assert.Contains(S8RecipientType.SpecificUser, S8NotificationCatalog.RecipientTypes);
+
+        foreach (var phantom in new[] { "ASSIGNEE_SUPERVISOR", "SUPERVISOR", "DEPARTMENT_LEADER", "L2_MANAGER", "L3_DIRECTOR" })
+            Assert.DoesNotContain(phantom, S8NotificationCatalog.RecipientTypes);
+    }
+
+    /// <summary>未知事件 / 未知类型一律拒绝,不猜、不放行。</summary>
+    [Fact]
+    public void UnknownEventOrType_IsRejected()
+    {
+        Assert.False(S8NotificationCatalog.IsKnownEvent("NOT_A_REAL_EVENT"));
+        Assert.False(S8NotificationCatalog.IsKnownEvent(null));
+        Assert.False(S8NotificationCatalog.IsKnownRecipientType("ASSIGNEE_SUPERVISOR"));
+        Assert.False(S8NotificationCatalog.IsKnownRecipientType(null));
+
+        var svc = CodeOnly("Service/S8/S8NotificationRecipientConfigService.cs");
+        Assert.Contains("未知事件码", svc);
+        Assert.Contains("未知收件人类型", svc);
+    }
+
+    // ══════════════════════════════════════════════════════════════════════
+    // Authority 优先级
+    // ══════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 新模型有配置 → 旧分层<b>完全不参与</b>;两者不得合并。
+    ///
+    /// <para>合并的后果:管理员删掉收件人之后通知照发,而他在新页面上已经看不到那些人。</para>
+    /// </summary>
+    [Fact]
+    public void NewModel_TakesPrecedence_AndLegacyIsOnlyFallback()
+    {
+        var code = CodeOnly("Service/S8/S8NotificationLayerResolver.cs");
+
+        // 新模型先判,命中即 return,不落到旧表查询
+        Assert.Contains("_recipientResolver.ResolveAsync(input.TenantId, input.EventCode!, input.ExceptionRef)", code);
+        Assert.Contains("if (resolution.Source != \"NONE\")", code);
+        Assert.Contains("await DispatchToUsersAsync(input, resolution);\n\t\t\t\treturn;", code.Replace("\r\n", "\n"));
+
+        // 规则级与租户级默认也不合并(后者只在前者为空时使用)
+        var resolver = CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
+        Assert.Contains("if (rows.Count == 0 && ruleCode != S8NotificationCatalog.TenantDefaultRuleCode)", resolver);
+    }
+
+    /// <summary>旧分层表<b>不删</b>,只降级 —— 现网既有通知不能被这批改动打断。</summary>
+    [Fact]
+    public void LegacyLayer_IsDemotedNotDeleted()
+    {
+        var sql = File.ReadAllText(Path.GetFullPath(Path.Combine(
+            PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.500.sql")));
+
+        Assert.DoesNotContain("DROP TABLE `ado_s8_notification_layer`", sql);
+        Assert.DoesNotContain("DELETE FROM `ado_s8_notification_layer`", sql);
+        Assert.Contains("[LEGACY", sql);
+        Assert.Contains("s8_500_BLOCKED_legacy_layer_wiped", sql);
+        Assert.DoesNotContain("DELIMITER", sql);
+    }
+
+    // ══════════════════════════════════════════════════════════════════════
+    // 解析语义
+    // ══════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 输出只认 SysUserId,且最终统一过一遍账号有效性 ——
+    /// 配置可能是几个月前配的,人可能已停用或换了租户。
+    /// </summary>
+    [Fact]
+    public void Resolver_OutputsValidatedSysUserIdsOnly()
+    {
+        var code = CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
+        Assert.Contains("_userScope.ValidateUsersAsync(tenantId, ids)", code);
+        Assert.DoesNotContain("EmployeeMaster", code);
+        Assert.DoesNotContain("FactoryId", code);
+    }
+
+    /// <summary>
+    /// HANDLER_POOL 直接复用 P1-C 的责任池,不复制第二份人员配置。
+    /// 两份人员名单迟早不一致,而不一致的那一刻没有任何报错。
+    /// </summary>
+    [Fact]
+    public void HandlerPoolRecipient_ReusesRuleHandlerPool()
+    {
+        var code = CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
+        Assert.Contains("_handlerPool.GetMemberIdsAsync", code);
+        Assert.DoesNotContain("handler_role", code);
+        Assert.DoesNotContain("OperatorBinding", code);
+    }
+
+    /// <summary>
+    /// REVIEWER 解析<b>当前事实</b>,不是新建的 Reviewer Pool:
+    /// 优先取单据上的 verifier_user_id,尚未提交复核时回落审批流解析出的审批人。
+    /// </summary>
+    [Fact]
+    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);
+
+        // 存储实体上同样不得出现 reviewer 池字段
+        Assert.Null(typeof(AdoS8NotificationRecipient).GetProperty("ReviewerUserIds"));
+    }
+
+    /// <summary>
+    /// <b>0 收件人必须可观测</b>:配了却发不出去与"没配置"是两回事,
+    /// 绝不静默当作发送成功 —— 那正是本模块反复出现的失败形态。
+    /// </summary>
+    [Fact]
+    public void ZeroRecipients_IsObservable()
+    {
+        var resolver = CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
+        Assert.Contains("ConfiguredButEmpty", resolver);
+        Assert.Contains("s8_notify_recipients_empty", resolver);
+        Assert.Contains("PerType", resolver);   // 逐类型计数,0 人的那一类要能被看见
+
+        var dispatch = CodeOnly("Service/S8/S8NotificationLayerResolver.cs");
+        Assert.Contains("resolved 0 recipients", dispatch);
+    }
+
+    /// <summary>
+    /// 通知失败不阻断业务动作,但必须落日志。
+    /// 认领已经成功落库,不能因为发不出消息就回滚;静默吞掉则会让
+    /// 「通知没收到」变成无从查起的问题。
+    /// </summary>
+    [Fact]
+    public void NotificationFailure_DoesNotBlockBusinessAction()
+    {
+        var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
+        Assert.Contains("s8_notify_dispatch_failed", code);
+        Assert.Contains("DispatchEventAsync", code);
+        // 原来写死收件人的方法必须消失
+        Assert.DoesNotContain("NotifyUserAsync", code);
+    }
+
+    /// <summary>
+    /// 站内信必须与实时通道一起投递。
+    ///
+    /// <para>本部署已注册的 pusher 只有 DingTalk / WorkWeixin / SignalR / Sms ——
+    /// <b>没有站内信</b>。旧分层派发因此从来只推 SignalR(用户不在线就等于没发过),
+    /// 而认领 / 转派的站内信是靠一段写死收件人的代码单独发的。
+    /// 统一模型如果只走 pushAdapter,会静默丢掉那部分站内信(本地实测已发生过)。</para>
+    /// </summary>
+    [Fact]
+    public void Dispatch_PublishesInAppNoticeNotOnlyRealtimePush()
+    {
+        var code = CodeOnly("Service/S8/S8NotificationLayerResolver.cs");
+        Assert.Contains("_noticeService.PublishToUsersAsync", code);
+        Assert.Contains("BuildNoticeContent", code);
+        // 站内信失败不能连带阻断实时推送,反之亦然 —— 两个独立 try
+        Assert.Contains("notice publish throw", code);
+        Assert.Contains("push throw", code);
+    }
+
+    /// <summary>
+    /// 认领必须用<b>更新后</b>的实体派发通知。
+    ///
+    /// <para>认领走原子 UPDATE,它只写库、不回写内存实体;直接拿旧实体派发会让
+    /// ASSIGNEE 读到 null 而解析出 0 人,且整条链路一路成功、日志里连一条记录都不留
+    /// —— 本地实测正是这个形态。</para>
+    /// </summary>
+    [Fact]
+    public void Claim_DispatchesWithReloadedEntity()
+    {
+        var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
+        Assert.Contains("var claimed = await LoadAsync(id, tenantId) ?? e;", code);
+        Assert.Contains("DispatchEventAsync(claimed, S8NotifyEventCode.ExceptionClaimed", code);
+        // 不得再用陈旧实体派发认领通知
+        Assert.DoesNotContain("DispatchEventAsync(e, S8NotifyEventCode.ExceptionClaimed", code);
+    }
+
+    /// <summary>渠道只保留真实可用的两个,不为 UI 完整而虚构短信 / 邮件。</summary>
+    [Fact]
+    public void Channels_AreRealOnly()
+    {
+        var code = CodeOnly("Service/S8/S8NotificationLayerResolver.cs");
+        Assert.Contains("DefaultChannels", code);
+        Assert.DoesNotContain("\"SMS\"", code);
+        Assert.DoesNotContain("\"Email\"", code);
+        Assert.DoesNotContain("\"WeChat\"", code);
+    }
+
+    // ══════════════════════════════════════════════════════════════════════
+    // 存储与写入
+    // ══════════════════════════════════════════════════════════════════════
+
+    /// <summary>身份是 (租户, 规则, 事件, 类型);无工厂、无员工。</summary>
+    [Fact]
+    public void Storage_IdentityIsTenantRuleEventType()
+    {
+        var t = typeof(AdoS8NotificationRecipient);
+        foreach (var p in new[] { "TenantId", "RuleCode", "EventCode", "RecipientType" })
+            Assert.NotNull(t.GetProperty(p));
+        Assert.Null(t.GetProperty("FactoryId"));
+        Assert.Null(t.GetProperty("EmployeeId"));
+        Assert.Null(t.GetProperty("LevelCode"));   // L1/L2/L3 不进入新模型
+    }
+
+    /// <summary>
+    /// SPECIFIC_USER 写入时逐个校验账号;没勾该类型时不得留下账号列表 ——
+    /// 留着会在下次勾选时悄悄复活一批旧人选。
+    /// </summary>
+    [Fact]
+    public void SpecificUser_IsValidatedAndClearedWhenUnselected()
+    {
+        var code = CodeOnly("Service/S8/S8NotificationRecipientConfigService.cs");
+        Assert.Contains("_userScope.ValidateUsersAsync(scope.TenantId, users)", code);
+        Assert.Contains("所选账号不存在或已停用", code);
+        Assert.Contains("users.Clear();", code);
+        Assert.Contains("请至少选择一个账号", code);
+    }
+
+    /// <summary>
+    /// 人工提报没有来源规则,其通知只能落在租户级默认上;
+    /// 用显式哨兵而不是 NULL —— NULL 在唯一索引里的比较语义会让人配出两条"默认"。
+    /// </summary>
+    [Fact]
+    public void TenantDefault_UsesExplicitSentinelNotNull()
+    {
+        Assert.Equal("*", S8NotificationCatalog.TenantDefaultRuleCode);
+        var code = CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
+        Assert.Contains("S8NotificationCatalog.TenantDefaultRuleCode", code);
+    }
+
+    /// <summary>Tab 4 不得回到 L1/L2/L3 那套语义。</summary>
+    [Fact]
+    public void NotifyTab_DoesNotReintroduceLevelSemantics()
+    {
+        var page = File.ReadAllText(Path.GetFullPath(Path.Combine(
+            PluginRoot, "../../../Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue")));
+        var template = page[page.IndexOf("<template>", StringComparison.Ordinal)..];
+        var noComments = System.Text.RegularExpressions.Regex.Replace(
+            template, "<!--.*?-->", string.Empty,
+            System.Text.RegularExpressions.RegexOptions.Singleline);
+
+        foreach (var phantom in new[] { "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR" })
+            Assert.DoesNotContain(phantom, noComments);
+        Assert.Contains("RECIPIENT_LABEL", page);
+    }
+}

+ 17 - 4
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8SysUserOnlyIdentityTests.cs

@@ -218,16 +218,29 @@ public class S8SysUserOnlyIdentityTests
     }
 
     /// <summary>
-    /// T13:通知投递直接收账号 Id,不再有 employeeRecId 形参。
+    /// T13:通知投递直接落到系统账号,不再有 employeeRecId 形参。
+    ///
+    /// <para>S8-NOTIFY-RECIPIENT-1 之后收件人不再写死在代码里:原 <c>NotifyUserAsync(exception, sysUserId, title)</c>
+    /// 已由 <c>DispatchEventAsync(exception, eventCode, ...)</c> 取代,收件人由统一模型解析、
+    /// 输出仍然只可能是 <c>SysUser.Id</c>。守卫对象随之改为「派发按事件走统一链路,
+    /// 且 Employee 桥不得复活」—— 守的仍是同一件事:通知的收件人只有账号一种身份。</para>
     /// </summary>
     [Fact]
     public void T13_Notification_TakesSysUserIdDirectly()
     {
-        var notify = typeof(S8TaskFlowService).GetMethod("NotifyUserAsync", Instance);
-        Assert.NotNull(notify);
-        Assert.Contains(notify!.GetParameters(), p => p.Name == "sysUserId");
+        var dispatch = typeof(S8TaskFlowService).GetMethod("DispatchEventAsync", Instance);
+        Assert.NotNull(dispatch);
+        Assert.Contains(dispatch!.GetParameters(), p => p.Name == "eventCode");
+
+        // Employee 桥与写死收件人的旧方法都必须已消失
+        Assert.Null(typeof(S8TaskFlowService).GetMethod("NotifyUserAsync", Instance));
         Assert.Null(typeof(S8TaskFlowService).GetMethod("NotifyAssigneeAsync", Instance));
         Assert.Null(typeof(S8TaskFlowService).GetMethod("GetEmployeeSysUserIdAsync", Instance));
+
+        // 解析器输出的仍然只能是账号 Id
+        var resolver = CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
+        Assert.Contains("List<long> UserIds", resolver);
+        Assert.DoesNotContain("EmployeeMaster", resolver);
     }
 
     // ═══════════════════════════════════════════════════════════════════════════════

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

@@ -0,0 +1,102 @@
+namespace Admin.NET.Plugin.AiDOP.Const.S8;
+
+/// <summary>
+/// S8-NOTIFY-RECIPIENT-1:通知事件码。
+///
+/// <para><b>每一条都对应一个真实存在的派发点</b>(见 <c>TriggerHint</c>)。
+/// 刻意不造没有业务入口的事件 —— 配了却永远不触发的通知,比没有通知更难排查。</para>
+/// </summary>
+public static class S8NotifyEventCode
+{
+    /// <summary>规则命中建单 / 人工提报建单。</summary>
+    public const string ExceptionCreated = "EXCEPTION_CREATED";
+
+    /// <summary>被认领。</summary>
+    public const string ExceptionClaimed = "EXCEPTION_CLAIMED";
+
+    /// <summary>被转派。</summary>
+    public const string ExceptionTransferred = "EXCEPTION_TRANSFERRED";
+
+    /// <summary>提交复核(等待审核)。</summary>
+    public const string VerificationSubmitted = "VERIFICATION_SUBMITTED";
+
+    /// <summary>超时自动升级。</summary>
+    public const string EscalationTriggered = "ESCALATION_TRIGGERED";
+
+    /// <summary>检测到恢复。</summary>
+    public const string ExceptionRecovered = "EXCEPTION_RECOVERED";
+
+    /// <summary>超时关闭(闭环及时性回顾)。</summary>
+    public const string ExceptionOverdueClosed = "EXCEPTION_OVERDUE_CLOSED";
+}
+
+/// <summary>
+/// 收件人类型。<b>第一版只有四类,全部能真实解析出 SysUserId</b>。
+///
+/// <para><b>Supervisor 刻意缺席</b>:系统里不存在组织主管关系
+/// (<c>SysOrg.DirectorId</c> 与 <c>SysUser.ManagerUserId</c> 实测填充率均为 0)。
+/// 给一个永远解析为空的选项,等于让管理员配一条永远发不出去的通知。</para>
+/// </summary>
+public static class S8RecipientType
+{
+    /// <summary>该规则的处理账号池(复用 P1-C 的 Rule Handler Pool,不复制第二份人员配置)。</summary>
+    public const string HandlerPool = "HANDLER_POOL";
+
+    /// <summary>当前处理人(<c>assignee_user_id</c>)。</summary>
+    public const string Assignee = "ASSIGNEE";
+
+    /// <summary>
+    /// 当前实际审核人。
+    /// <para><b>不是新建的 Reviewer Pool</b> —— 它解析的是<b>当前事实</b>:
+    /// 优先取单据上的 <c>verifier_user_id</c>(提交复核时手选、真正能点通过/退回的人);
+    /// 尚未提交复核时回落到审批流节点解析出的审批人。</para>
+    /// </summary>
+    public const string Reviewer = "REVIEWER";
+
+    /// <summary>指定账号(多选 SysUser)。</summary>
+    public const string SpecificUser = "SPECIFIC_USER";
+}
+
+/// <summary>事件元信息。</summary>
+public sealed record S8NotifyEventDefinition(string Code, string DisplayName, string TriggerHint, int OrderNo);
+
+/// <summary>
+/// 事件与收件人类型目录。后端派发、配置校验、前端 UI 三方共用同一份,
+/// 避免出现「页面能配、后端根本不派发」或反之。
+/// </summary>
+public static class S8NotificationCatalog
+{
+    public static readonly IReadOnlyList<S8NotifyEventDefinition> Events = new List<S8NotifyEventDefinition>
+    {
+        new(S8NotifyEventCode.ExceptionCreated, "异常产生", "规则命中建单 / 人工提报", 10),
+        new(S8NotifyEventCode.ExceptionClaimed, "认领成功", "POST {id}/claim", 20),
+        new(S8NotifyEventCode.ExceptionTransferred, "转派", "POST {id}/transfer", 30),
+        new(S8NotifyEventCode.VerificationSubmitted, "提交复核", "POST {id}/submit-verification", 40),
+        new(S8NotifyEventCode.EscalationTriggered, "超时升级", "超时自动升级作业", 50),
+        new(S8NotifyEventCode.ExceptionRecovered, "异常恢复", "调度器检测到恢复", 60),
+        new(S8NotifyEventCode.ExceptionOverdueClosed, "超时关闭", "检验通过后闭环及时性回顾", 70),
+    };
+
+    public static readonly IReadOnlyList<string> RecipientTypes = new[]
+    {
+        S8RecipientType.HandlerPool,
+        S8RecipientType.Assignee,
+        S8RecipientType.Reviewer,
+        S8RecipientType.SpecificUser,
+    };
+
+    /// <summary>
+    /// 租户级默认配置的 <c>rule_code</c> 哨兵值。
+    /// <para>人工提报的异常没有来源规则,它的通知只能落在租户级默认上。
+    /// 用显式常量而不是 NULL:NULL 在唯一索引里的比较语义容易让人配出两条"默认"。</para>
+    /// </summary>
+    public const string TenantDefaultRuleCode = "*";
+
+    private static readonly HashSet<string> EventSet =
+        Events.Select(e => e.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
+    private static readonly HashSet<string> TypeSet =
+        RecipientTypes.ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+    public static bool IsKnownEvent(string? code) => !string.IsNullOrWhiteSpace(code) && EventSet.Contains(code!);
+    public static bool IsKnownRecipientType(string? t) => !string.IsNullOrWhiteSpace(t) && TypeSet.Contains(t!);
+}

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

@@ -262,6 +262,57 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
 
+    /// <summary>S8-NOTIFY-RECIPIENT-1:该规则的事件通知收件人配置。</summary>
+    [HttpGet("{id:long}/notify-recipients")]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
+    public async Task<IActionResult> NotifyRecipientsAsync(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,
+                recipientTypes = S8NotificationCatalog.RecipientTypes,
+                events = await svc.GetMatrixAsync(scope.TenantId, ruleCode),
+            });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    /// <summary>全量替换某事件的收件人配置。</summary>
+    /// <remarks>
+    /// 权限位用 <c>ConfigWatchRule</c> 而不是 <c>ConfigNotification</c>:本端点是<b>规则级</b>配置
+    /// (路由挂在规则下、作用范围就是这一条规则),一个控制器只用自己那一个 config 能力,
+    /// 是本仓既有的授权分组约束 —— 混用会让「只有通知权限的人」能从规则控制器写入。
+    /// 全局的通知分层配置仍由 <c>ConfigNotification</c> 守护,两者互不越界。
+    /// </remarks>
+    [HttpPut("{id:long}/notify-recipients/{eventCode}")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
+    public async Task<IActionResult> SetNotifyRecipientsAsync(long id, string eventCode,
+        [FromBody] AdoS8NotifyRecipientSetDto? 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.SetAsync(scope, ruleCode, eventCode, body?.RecipientTypes, body?.SpecificUserIds);
+            return Ok(new { ruleCode, eventCode, typeCount = body?.RecipientTypes?.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)
@@ -379,3 +430,12 @@ public class AdoS8HandlerPoolSetDto
     /// <summary>完整成员集合(全量替换)。传空数组 = 清空责任池,该规则将无法启用。</summary>
     public List<long>? UserIds { get; set; }
 }
+
+/// <summary>事件收件人设置载荷。</summary>
+public class AdoS8NotifyRecipientSetDto
+{
+    /// <summary>收件人类型集合(全量替换)。传空 = 该事件不发通知(会回落 LEGACY 分层)。</summary>
+    public List<string>? RecipientTypes { get; set; }
+    /// <summary>仅当包含 SPECIFIC_USER 时有效。</summary>
+    public List<long>? SpecificUserIds { get; set; }
+}

+ 6 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8NotificationLayer.cs

@@ -1,6 +1,11 @@
 namespace Admin.NET.Plugin.AiDOP.Entity.S8;
 
-[SugarTable("ado_s8_notification_layer", "S8 通知分层")]
+// S8-NOTIFY-RECIPIENT-1:本表自 1.0.500 起降级为 LEGACY fallback。
+// CURRENT AUTHORITY 是 ado_s8_notification_recipient(按 规则 + 事件 组织)。
+// 标记写在这里而不是只写进 migration —— CodeFirst 的 InitTables 会在启动时按实体描述
+// 回写表注释,迁移里的 ALTER ... COMMENT 会被它覆盖(本地实测:1.0.500 执行成功,
+// 但重启后注释仍是旧文案)。
+[SugarTable("ado_s8_notification_layer", "[LEGACY] S8 通知分层(CURRENT AUTHORITY = ado_s8_notification_recipient;仅在新表无配置时 fallback)")]
 public class AdoS8NotificationLayer
 {
     [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]

+ 53 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8NotificationRecipient.cs

@@ -0,0 +1,53 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+/// <summary>
+/// S8-NOTIFY-RECIPIENT-1:「某条规则的某个事件发生时,通知谁」。
+///
+/// <para><b>身份是 (tenant_id, rule_code, event_code, recipient_type)</b>。
+/// <c>rule_code = '*'</c> 表示<b>租户级默认</b>,用于没有来源规则的异常(人工提报);
+/// 用显式哨兵而不是 NULL,是因为 NULL 在唯一索引里的比较语义会让人配出两条"默认"
+/// 而互不冲突。</para>
+///
+/// <para><b>取代旧的 <c>ado_s8_notification_layer</c> 成为 CURRENT AUTHORITY。</b>
+/// 旧表按 <c>(scene_code, severity, level_code)</c> 组织,其中 level_code 的
+/// L1/L2/L3 三档在库里只有 L1 被真正用过,且「主管 / 总监」在系统中没有对应实体;
+/// 更关键的是它<b>不按规则</b>,无法回答「这条规则出事通知谁」。
+/// 旧表降级为 fallback:仅当本表对该 (租户, 规则, 事件) 没有任何配置时才使用,
+/// 且在页面上标注 LEGACY。优先级单一、可解释,不是两个平级 authority。</para>
+/// </summary>
+[SugarTable("ado_s8_notification_recipient", "S8 通知收件人配置")]
+[SugarIndex("uk_s8_notify_recipient", nameof(TenantId), OrderByType.Asc, nameof(RuleCode), OrderByType.Asc, nameof(EventCode), OrderByType.Asc, nameof(RecipientType), OrderByType.Asc, IsUnique = true)]
+public class AdoS8NotificationRecipient
+{
+    [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>规则编码;<c>'*'</c> = 租户级默认(无来源规则的异常走这一条)。</summary>
+    [SugarColumn(ColumnName = "rule_code", Length = 128)]
+    public string RuleCode { get; set; } = string.Empty;
+
+    /// <summary>事件码,取值须在 <c>S8NotificationCatalog.Events</c> 内。</summary>
+    [SugarColumn(ColumnName = "event_code", Length = 64)]
+    public string EventCode { get; set; } = string.Empty;
+
+    /// <summary>收件人类型,取值须在 <c>S8NotificationCatalog.RecipientTypes</c> 内。</summary>
+    [SugarColumn(ColumnName = "recipient_type", Length = 32)]
+    public string RecipientType { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 仅 <c>SPECIFIC_USER</c> 使用:逗号分隔的 <c>SysUser.Id</c>。
+    /// 其余类型为空 —— 它们的人选在派发那一刻由上下文解析,不预存。
+    /// </summary>
+    [SugarColumn(ColumnName = "recipient_user_ids", Length = 2000, IsNullable = true)]
+    public string? RecipientUserIds { 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; }
+}

+ 111 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationLayerResolver.cs

@@ -31,17 +31,23 @@ public class S8NotificationLayerResolver : ITransient
 {
 	private readonly SqlSugarRepository<AdoS8NotificationLayer> _layerRep;
 	private readonly S8RoleResolver _roleResolver;
+	private readonly IS8NotificationRecipientResolver _recipientResolver;
+	private readonly Admin.NET.Core.Service.SysNoticeService _noticeService;
 	private readonly S8NotificationPushAdapter _pushAdapter;
 	private readonly ILogger<S8NotificationLayerResolver> _logger;
 
 	public S8NotificationLayerResolver(
 		SqlSugarRepository<AdoS8NotificationLayer> layerRep,
 		S8RoleResolver roleResolver,
+		IS8NotificationRecipientResolver recipientResolver,
+		Admin.NET.Core.Service.SysNoticeService noticeService,
 		S8NotificationPushAdapter pushAdapter,
 		ILogger<S8NotificationLayerResolver> logger)
 	{
 		_layerRep = layerRep;
 		_roleResolver = roleResolver;
+		_recipientResolver = recipientResolver;
+		_noticeService = noticeService;
 		_pushAdapter = pushAdapter;
 		_logger = logger;
 	}
@@ -57,6 +63,18 @@ public class S8NotificationLayerResolver : ITransient
 		public string Content { get; set; } = string.Empty;
 		public string? Status { get; set; }
 		public string? SourceRuleCode { get; set; }
+
+		/// <summary>
+		/// S8-NOTIFY-RECIPIENT-1:本次派发对应的事件码(<c>S8NotifyEventCode</c>)。
+		/// <para>为空表示调用方尚未接入新收件人模型,本次直接走 LEGACY 分层。</para>
+		/// </summary>
+		public string? EventCode { get; set; }
+
+		/// <summary>
+		/// 触发本次派发的异常单本体。新模型要靠它解析 ASSIGNEE / REVIEWER / HANDLER_POOL。
+		/// 为 null 时同样退回 LEGACY 分层。
+		/// </summary>
+		public Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8Exception? ExceptionRef { get; set; }
 		public string? JumpUrl { get; set; }
 		/// <summary>
 		/// S8-NOTIFY-WIRE-RECOVERED-1:true 表示恢复事件,BuildNotification 会在 Context 中追加
@@ -100,6 +118,23 @@ public class S8NotificationLayerResolver : ITransient
 			return;
 		}
 
+		// ── S8-NOTIFY-RECIPIENT-1:新收件人模型是 CURRENT AUTHORITY ──
+		//
+		// 新模型对该 (租户, 规则, 事件) 一旦有任何配置,旧分层表就**完全不参与**本次派发。
+		// 不做"两者合并":合并会让管理员删掉收件人之后通知照发,而他在新页面上
+		// 已经看不到那些人了 —— 那是最难自查的一类配置故障。
+		//
+		// 只有新模型没有配置时才回落 LEGACY 分层,保证现网既有通知不被这批改动打断。
+		if (!string.IsNullOrWhiteSpace(input.EventCode) && input.ExceptionRef != null)
+		{
+			var resolution = await _recipientResolver.ResolveAsync(input.TenantId, input.EventCode!, input.ExceptionRef);
+			if (resolution.Source != "NONE")
+			{
+				await DispatchToUsersAsync(input, resolution);
+				return;
+			}
+		}
+
 		// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:覆盖粒度由「整个 (scene, severity) 键」收敛为**逐 level 覆盖**。
 		//
 		// 原实现是两次往返 + 整键 fallback(精确租户查到任意一行 → 完全不读 (0,0))。
@@ -195,6 +230,82 @@ public class S8NotificationLayerResolver : ITransient
 		}
 	}
 
+	/// <summary>
+	/// 新收件人模型的投递。复用与 LEGACY 完全相同的 <see cref="BuildNotification"/> 与
+	/// PushAdapter —— 两条路径只在"收件人怎么来"上不同,通知内容与投递方式必须一致,
+	/// 否则同一条异常会因为配置来源不同而呈现出不同的消息。
+	/// </summary>
+	private async Task DispatchToUsersAsync(DispatchByLayerInput input, S8RecipientResolution resolution)
+	{
+		if (resolution.UserIds.Count == 0)
+		{
+			// 0 收件人必须可观测:配了却发不出去,与"没配置"是两回事。
+			// 绝不静默返回让调用方以为发送成功 —— 那正是本模块反复出现的失败形态。
+			_logger.LogWarning(
+				"S8RecipientDispatch: resolved 0 recipients (tenant={Tenant} event={Event} exceptionId={ExceptionId} perType={PerType})",
+				input.TenantId, input.EventCode, input.ExceptionId,
+				string.Join(',', resolution.PerType.Select(kv => $"{kv.Key}={kv.Value}")));
+			return;
+		}
+
+		_logger.LogInformation(
+			"S8RecipientDispatch: source={Source} recipients={Count} (tenant={Tenant} event={Event} exceptionId={ExceptionId} perType={PerType})",
+			resolution.Source, resolution.UserIds.Count, input.TenantId, input.EventCode, input.ExceptionId,
+			string.Join(',', resolution.PerType.Select(kv => $"{kv.Key}={kv.Value}")));
+
+		// ① 站内信:这是本部署里**唯一会为用户留下一条可见消息**的通道。
+		//
+		//    已注册的 pusher 只有 DingTalk / WorkWeixin / SignalR / Sms —— 没有站内信。
+		//    旧的分层派发因此从来只推 SignalR(用户不在线就等于没发过),
+		//    而认领 / 转派的站内信是靠一段写死收件人的代码单独发的。
+		//    新模型把两者合并:所有事件都先落站内信,再推实时通道。
+		//    少了这一步,本次重构会静默丢掉认领 / 转派原本有的站内信(本地实测已发生)。
+		try
+		{
+			await _noticeService.PublishToUsersAsync(
+				input.Title,
+				BuildNoticeContent(input),
+				resolution.UserIds.ToArray(),
+				0,
+				"S8异常监控");
+		}
+		catch (Exception ex)
+		{
+			_logger.LogWarning(ex, "S8RecipientDispatch: notice publish throw (event={Event})", input.EventCode);
+		}
+
+		// ② 实时通道:在线用户即时可见;不在线也不影响①已经留下的站内信。
+		try
+		{
+			await _pushAdapter.PushAsync(
+				tenantId: input.TenantId,
+				factoryId: S8ConfigScope.GlobalFactoryId,
+				exceptionId: input.ExceptionId,
+				userIds: resolution.UserIds,
+				notification: BuildNotification(input),
+				channels: DefaultChannels);
+		}
+		catch (Exception ex)
+		{
+			_logger.LogWarning(ex, "S8RecipientDispatch: push throw (event={Event})", input.EventCode);
+		}
+	}
+
+	/// <summary>站内信正文:内容 + 一个能点回异常详情的链接。</summary>
+	private static string BuildNoticeContent(DispatchByLayerInput input)
+	{
+		var jump = string.IsNullOrWhiteSpace(input.JumpUrl) ? null : $"/#{input.JumpUrl}";
+		return $"<p>{input.ExceptionNo} {input.Content}</p>"
+			+ (jump == null ? string.Empty : $"<p><a href=\"{jump}\">查看异常详情</a></p>");
+	}
+
+	/// <summary>
+	/// 新模型的投递渠道。只列<b>真实可用</b>的两个 —— 站内信与 SignalR,
+	/// 与旧分层表里实际在用的 <c>notify_channel='log,SignalR'</c> 同源。
+	/// 不为了 UI 看起来完整而虚构短信 / 邮件之类当前根本没有实现的渠道。
+	/// </summary>
+	private static readonly List<string> DefaultChannels = new() { "log", "SignalR" };
+
 	private static FlowNotification BuildNotification(DispatchByLayerInput input)
 	{
 		// 注意:FlowNotificationTypeEnum 不含 S8_EXCEPTION 值;BizType 字段承载 "S8_EXCEPTION" 语义。

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

@@ -0,0 +1,135 @@
+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>配置页的一行:一个事件 + 它当前配置的收件人类型。</summary>
+public sealed class S8NotifyEventRowDto
+{
+    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; }
+    public List<string> RecipientTypes { get; set; } = new();
+    /// <summary>SPECIFIC_USER 选中的账号。</summary>
+    public List<long> SpecificUserIds { get; set; } = new();
+    /// <summary>本事件尚未配置任何收件人 —— 会回落 LEGACY 分层,页面必须说明。</summary>
+    public bool NotConfigured => RecipientTypes.Count == 0;
+}
+
+/// <summary>
+/// S8-NOTIFY-RECIPIENT-1:收件人配置的读写。
+///
+/// <para><b>写入侧只认目录内的事件与类型</b>,并对 SPECIFIC_USER 逐个校验账号
+/// (同租户 + 启用,复用 <see cref="IS8UserScopeValidator"/>)——
+/// 让「配得进去、发的时候又发不出」这种状态在写入那一刻就不可能产生。</para>
+/// </summary>
+public class S8NotificationRecipientConfigService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8NotificationRecipient> _rep;
+    private readonly IS8UserScopeValidator _userScope;
+    private readonly UserManager _userManager;
+    private readonly ILogger<S8NotificationRecipientConfigService> _logger;
+
+    public S8NotificationRecipientConfigService(
+        SqlSugarRepository<AdoS8NotificationRecipient> rep,
+        IS8UserScopeValidator userScope,
+        UserManager userManager,
+        ILogger<S8NotificationRecipientConfigService> logger)
+    {
+        _rep = rep;
+        _userScope = userScope;
+        _userManager = userManager;
+        _logger = logger;
+    }
+
+    /// <summary>某条规则(或租户默认)的事件矩阵。目录里每个事件都出一行,未配置的也要出现。</summary>
+    public async Task<List<S8NotifyEventRowDto>> GetMatrixAsync(long tenantId, string ruleCode)
+    {
+        var rows = await _rep.AsQueryable().ClearFilter()
+            .Where(x => x.TenantId == tenantId && x.RuleCode == ruleCode)
+            .ToListAsync();
+
+        var byEvent = rows.GroupBy(x => x.EventCode, StringComparer.OrdinalIgnoreCase)
+            .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
+
+        return S8NotificationCatalog.Events.OrderBy(e => e.OrderNo).Select(e =>
+        {
+            var cfg = byEvent.GetValueOrDefault(e.Code) ?? new List<AdoS8NotificationRecipient>();
+            var specific = cfg.FirstOrDefault(c => c.RecipientType == S8RecipientType.SpecificUser);
+            return new S8NotifyEventRowDto
+            {
+                EventCode = e.Code,
+                DisplayName = e.DisplayName,
+                TriggerHint = e.TriggerHint,
+                OrderNo = e.OrderNo,
+                RecipientTypes = cfg.Select(c => c.RecipientType).Distinct().ToList(),
+                SpecificUserIds = S8RoleResolver.SplitTokens(specific?.RecipientUserIds)
+                    .Select(t => long.TryParse(t, out var v) ? v : 0).Where(v => v > 0).ToList()
+            };
+        }).ToList();
+    }
+
+    /// <summary>全量替换某事件的收件人配置。</summary>
+    public async Task SetAsync(S8TrustedScope scope, string ruleCode, string eventCode,
+        IEnumerable<string>? recipientTypes, IEnumerable<long>? specificUserIds)
+    {
+        if (string.IsNullOrWhiteSpace(ruleCode)) throw new S8BizException("规则编码不能为空");
+        if (!S8NotificationCatalog.IsKnownEvent(eventCode))
+            throw new S8BizException($"未知事件码:{eventCode}");
+
+        var types = (recipientTypes ?? Enumerable.Empty<string>())
+            .Where(t => !string.IsNullOrWhiteSpace(t)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+
+        var unknown = types.Where(t => !S8NotificationCatalog.IsKnownRecipientType(t)).ToList();
+        if (unknown.Count > 0)
+            throw new S8BizException($"未知收件人类型:{string.Join('、', unknown)}");
+
+        var users = (specificUserIds ?? Enumerable.Empty<long>()).Where(x => x > 0).Distinct().ToList();
+
+        if (types.Contains(S8RecipientType.SpecificUser, StringComparer.OrdinalIgnoreCase))
+        {
+            if (users.Count == 0)
+                throw new S8BizException("已选择「指定账号」,请至少选择一个账号");
+
+            var valid = (await _userScope.ValidateUsersAsync(scope.TenantId, users)).Select(u => u.UserId).ToHashSet();
+            if (users.Any(id => !valid.Contains(id)))
+                // 与人员校验同口径:跨租户与不存在共用同一文案,不泄漏存在性。
+                throw new S8BizException("所选账号不存在或已停用");
+        }
+        else
+        {
+            // 没勾「指定账号」就不该留下账号列表 —— 留着会在下次勾选时悄悄复活一批旧人选。
+            users.Clear();
+        }
+
+        await _rep.AsDeleteable()
+            .Where(x => x.TenantId == scope.TenantId && x.RuleCode == ruleCode && x.EventCode == eventCode)
+            .ExecuteCommandAsync();
+
+        if (types.Count > 0)
+        {
+            var now = DateTime.Now;
+            await _rep.AsInsertable(types.Select(t => new AdoS8NotificationRecipient
+            {
+                TenantId = scope.TenantId,
+                RuleCode = ruleCode,
+                EventCode = eventCode,
+                RecipientType = t,
+                RecipientUserIds = t.Equals(S8RecipientType.SpecificUser, StringComparison.OrdinalIgnoreCase)
+                    ? string.Join(',', users)
+                    : null,
+                CreatedAt = now,
+                CreatedBy = _userManager.Account
+            }).ToList()).ExecuteCommandAsync();
+        }
+
+        _logger.LogInformation(
+            "s8_notify_recipient_updated tenant={Tenant} rule={Rule} event={Event} types={Types} users={Users}",
+            scope.TenantId, ruleCode, eventCode, string.Join(',', types), users.Count);
+    }
+}

+ 173 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationRecipientResolver.cs

@@ -0,0 +1,173 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>一次收件人解析的结果。</summary>
+public sealed class S8RecipientResolution
+{
+    /// <summary>去重后的收件账号。<b>只可能是 <c>SysUser.Id</c>。</b></summary>
+    public List<long> UserIds { get; set; } = new();
+
+    /// <summary>本次实际使用的配置来源:<c>RECIPIENT_MODEL</c> / <c>LEGACY_LAYER</c> / <c>NONE</c>。</summary>
+    public string Source { get; set; } = "NONE";
+
+    /// <summary>每个收件人类型解析出多少人 —— 0 人的必须能被看见,不能混在总数里被抹平。</summary>
+    public Dictionary<string, int> PerType { get; set; } = new();
+
+    /// <summary>配置存在但一个人都解析不出来。<b>这不是"没配置",是"配了但发不出去"。</b></summary>
+    public bool ConfiguredButEmpty => Source != "NONE" && UserIds.Count == 0;
+}
+
+/// <summary>
+/// S8-NOTIFY-RECIPIENT-1:「这个事件该通知谁」的<b>唯一</b>解析实现。
+///
+/// <para><b>输出只认 <c>SysUserId</c></b>。所有个人通知最终都投递到系统账号 ——
+/// 不存在"通知某个员工"这条路径(S8-SYSUSER-ONLY-1 已把 EmployeeMaster 移出人员主链)。</para>
+///
+/// <para><b>Authority 优先级(单一链路,不是两个平级来源)</b>:
+/// ① 新收件人模型的规则级配置 → ② 新模型的租户级默认(<c>rule_code = '*'</c>)
+/// → ③ 旧 <c>ado_s8_notification_layer</c>(LEGACY fallback)。
+/// 一旦新模型对该 (租户, 规则, 事件) 有任何配置,旧表就<b>完全不参与</b> ——
+/// 否则会出现"我明明删掉了收件人,怎么还在发"。</para>
+///
+/// <para><b>HANDLER_POOL 直接复用 P1-C 的责任池</b>,不复制第二份人员配置:
+/// 两份人员名单迟早不一致,而不一致的那一刻没有任何报错。</para>
+///
+/// <para><b>REVIEWER 不是新建的 Reviewer Pool</b>:它解析当前事实 ——
+/// 优先取单据上的 <c>verifier_user_id</c>(提交复核时手选、真正能点通过/退回的人),
+/// 尚未提交复核时回落审批流节点解析出的审批人。本批不发明 Rule 级审核池。</para>
+///
+/// <para><b>0 收件人必须可观测</b>:解析结果为空时返回 <c>ConfiguredButEmpty</c>,
+/// 由调用方落日志。绝不静默当作"发送成功"—— 那正是本模块反复出现的失败形态。</para>
+/// </summary>
+public interface IS8NotificationRecipientResolver
+{
+    Task<S8RecipientResolution> ResolveAsync(long tenantId, string eventCode, AdoS8Exception exception);
+}
+
+/// <summary>正式实现。生产代码里只有这一份;接口只为测试留 seam。</summary>
+public sealed class S8NotificationRecipientResolver : IS8NotificationRecipientResolver, ITransient
+{
+    private readonly SqlSugarRepository<AdoS8NotificationRecipient> _rep;
+    private readonly IS8RuleHandlerPoolReader _handlerPool;
+    private readonly IS8UserScopeValidator _userScope;
+    private readonly S8ReviewSourceService _reviewSource;
+    private readonly ILogger<S8NotificationRecipientResolver> _logger;
+
+    public S8NotificationRecipientResolver(
+        SqlSugarRepository<AdoS8NotificationRecipient> rep,
+        IS8RuleHandlerPoolReader handlerPool,
+        IS8UserScopeValidator userScope,
+        S8ReviewSourceService reviewSource,
+        ILogger<S8NotificationRecipientResolver> logger)
+    {
+        _rep = rep;
+        _handlerPool = handlerPool;
+        _userScope = userScope;
+        _reviewSource = reviewSource;
+        _logger = logger;
+    }
+
+    public async Task<S8RecipientResolution> ResolveAsync(long tenantId, string eventCode, AdoS8Exception exception)
+    {
+        var result = new S8RecipientResolution();
+
+        if (!S8NotificationCatalog.IsKnownEvent(eventCode) || tenantId <= 0 || exception == null)
+            return result;
+
+        var ruleCode = string.IsNullOrWhiteSpace(exception.SourceRuleCode)
+            ? S8NotificationCatalog.TenantDefaultRuleCode
+            : exception.SourceRuleCode!;
+
+        // ① 规则级配置优先;② 没有才回落租户级默认。
+        //    刻意**不合并**两者:合并会让"我给这条规则单独配了收件人"变成"在默认之上又加了几个",
+        //    管理员想缩小范围时会发现怎么也去不掉默认里的那些人。
+        var rows = await LoadAsync(tenantId, ruleCode, eventCode);
+        if (rows.Count == 0 && ruleCode != S8NotificationCatalog.TenantDefaultRuleCode)
+            rows = await LoadAsync(tenantId, S8NotificationCatalog.TenantDefaultRuleCode, eventCode);
+
+        if (rows.Count == 0) return result;   // Source 保持 NONE,调用方回落 LEGACY
+
+        result.Source = "RECIPIENT_MODEL";
+        var ids = new HashSet<long>();
+
+        foreach (var row in rows)
+        {
+            var resolved = await ResolveOneAsync(tenantId, row, exception);
+            result.PerType[row.RecipientType] = resolved.Count;
+            foreach (var id in resolved) ids.Add(id);
+        }
+
+        // 最终统一过一遍账号有效性:配置可能是几个月前配的,人可能已经停用或换了租户。
+        var valid = await _userScope.ValidateUsersAsync(tenantId, ids);
+        result.UserIds = valid.Select(u => u.UserId).ToList();
+
+        if (result.ConfiguredButEmpty)
+            _logger.LogWarning(
+                "s8_notify_recipients_empty tenant={Tenant} rule={Rule} event={Event} perType={PerType}"
+                + ";配置存在但解析不出任何有效账号,本次通知不会发给任何人",
+                tenantId, ruleCode, eventCode, string.Join(',', result.PerType.Select(kv => $"{kv.Key}={kv.Value}")));
+
+        return result;
+    }
+
+    private async Task<List<AdoS8NotificationRecipient>> LoadAsync(long tenantId, string ruleCode, string eventCode) =>
+        await _rep.AsQueryable().ClearFilter()
+            .Where(x => x.TenantId == tenantId && x.RuleCode == ruleCode && x.EventCode == eventCode)
+            .ToListAsync();
+
+    private async Task<List<long>> ResolveOneAsync(long tenantId, AdoS8NotificationRecipient row, AdoS8Exception e)
+    {
+        switch (row.RecipientType)
+        {
+            case S8RecipientType.HandlerPool:
+                if (string.IsNullOrWhiteSpace(e.SourceRuleCode)) return new List<long>();
+                return (await _handlerPool.GetMemberIdsAsync(tenantId, e.SourceRuleCode!)).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);
+
+            case S8RecipientType.SpecificUser:
+                return S8RoleResolver.SplitTokens(row.RecipientUserIds)
+                    .Select(t => long.TryParse(t, out var v) ? v : 0)
+                    .Where(v => v > 0)
+                    .ToList();
+
+            default:
+                // 未知类型不猜、不放行 —— 配置里出现它本身就说明有人绕过了目录校验。
+                _logger.LogWarning("s8_notify_unknown_recipient_type type={Type} rowId={Id}", row.RecipientType, row.Id);
+                return new List<long>();
+        }
+    }
+
+    /// <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();
+    }
+}

+ 45 - 23
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs

@@ -1,3 +1,4 @@
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Core.Service;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
@@ -114,9 +115,16 @@ public class S8TaskFlowService : ITransient
 
         await InsertTimelineAsync(e.Id, "CLAIM", "认领", fromStatus, "ASSIGNED", currentUserId, null, remark);
 
+        // 认领走的是原子 UPDATE —— 它只写了库,**没有回写内存里的 e**。
+        // 直接拿 e 去派发通知,收件人类型 ASSIGNEE 会读到 null 而解析出 0 人,
+        // 且整条链路一路成功、日志里连一条记录都不会留下(本地实测到过这个形态)。
+        // 因此先重新加载,再用带上处理人的实体派发。
+        var claimed = await LoadAsync(id, tenantId) ?? e;
+
         // 注:认领仅承接异常,不视为审批流完成。审批流的"通过"在"开始处理"那一步触发。
-        await NotifyUserAsync(e, currentUserId, "S8异常已分派给你");
-        return await LoadAsync(id, tenantId) ?? e;
+        await DispatchEventAsync(claimed, S8NotifyEventCode.ExceptionClaimed, "S8异常已分派给你",
+            $"{claimed.ExceptionCode} 已被认领。");
+        return claimed;
     }
 
     /// <summary>
@@ -167,7 +175,8 @@ public class S8TaskFlowService : ITransient
             await InsertTimelineAsync(e.Id, "TRANSFER", "转派", e.Status, e.Status, GetCurrentUserId(), null, remark);
         }, ex => throw ex);
 
-        await NotifyUserAsync(e, target.UserId, "S8异常已转派给你");
+        await DispatchEventAsync(e, S8NotifyEventCode.ExceptionTransferred, "S8异常已转派给你",
+            $"{e.ExceptionCode} 已转派给 {target.DisplayName}。");
 
         // 双线合一:S8 转派 = TB001 任务转办给新处理人。
         await TryTransferIntakeOnTransferAsync(e.Id, target.UserId, remark);
@@ -382,6 +391,11 @@ public class S8TaskFlowService : ITransient
                 currentUserId, null, remark);
         }, ex => throw ex);
 
+        // S8-NOTIFY-RECIPIENT-1:提交复核有真实业务入口(POST {id}/submit-verification),
+        // 此前却没有任何通知 —— 检验人不会知道有单子在等他。这里补上,收件人由统一模型解析。
+        await DispatchEventAsync(e, S8NotifyEventCode.VerificationSubmitted, "S8异常待你复核",
+            $"{e.ExceptionCode} 已提交复核,等待检验。");
+
         // 双线合一:提交复检 = 启动 EXCEPTION_CLOSURE 流程,指派检验人。
         // 该流程定义复用,作为复检/关闭确认通用审批载体;handler 只做 ActiveFlowInstanceId 维护。
         await TryStartVerificationFlowAsync(e, verifier.UserId, remark);
@@ -482,6 +496,8 @@ public class S8TaskFlowService : ITransient
                 ExceptionNo = e.ExceptionCode,
                 SceneCode = sceneCode,
                 Severity = severity,
+                EventCode = S8NotifyEventCode.ExceptionOverdueClosed,
+                ExceptionRef = e,
                 Title = $"【超时关闭】{e.ExceptionCode}",
                 Content = content,
                 Status = e.Status,
@@ -609,34 +625,40 @@ public class S8TaskFlowService : ITransient
     }
 
     /// <summary>
-    /// 站内信通知某个<b>系统账号</b>
+    /// S8-NOTIFY-RECIPIENT-1:按事件派发通知,收件人由统一模型解析
     ///
-    /// <para>S8-SYSUSER-ONLY-1:原实现是 <c>NotifyAssigneeAsync(employeeRecId)</c> ——
-    /// 先经 <c>EmployeeMaster.SysUserId</c> 换算成账号才能投递,换不到就打一行
-    /// <c>s8_assignee_notice_unmapped</c> 然后<b>静默不发</b>。真库 1105 名员工里只有 8 个绑了账号,
-    /// 也就是说这条路径在绝大多数情况下根本发不出通知,而调用方看到的是"成功"。
-    /// 现在处理人本身就是账号,桥和它的失败模式一起删除。</para>
+    /// <para>取代原来的 <c>NotifyUserAsync(exception, sysUserId, title)</c> —— 那是把
+    /// 「通知谁」写死在代码里:认领通知处理人本人、转派通知新处理人,管理员无从更改,
+    /// 也无法让别人(比如责任池里的其他人)同时知道。现在这两件事都由
+    /// <see cref="S8NotificationLayerResolver"/> 走同一条收件人链路,
+    /// 与规则建单 / 升级 / 恢复的通知共用一套配置。</para>
+    ///
+    /// <para>通知失败<b>不阻断业务动作</b>:认领已经成功落库,不能因为发不出消息就回滚。
+    /// 但失败必须落日志 —— 静默吞掉会让"通知没收到"变成无从查起的问题。</para>
     /// </summary>
-    private async Task NotifyUserAsync(AdoS8Exception exception, long sysUserId, string title)
+    private async Task DispatchEventAsync(AdoS8Exception exception, string eventCode, string title, string content)
     {
-        if (sysUserId <= 0) return;
-
-        var jump = $"/#/aidop/s8/exceptions/{exception.Id}";
-        var content =
-            $"<p>{exception.ExceptionCode} {exception.Title}</p>" +
-            $"<p><a href=\"{jump}\">查看异常详情</a></p>";
         try
         {
-            await _noticeService.PublishToUsersAsync(
-                title,
-                content,
-                new[] { sysUserId },
-                _userManager.UserId,
-                string.IsNullOrWhiteSpace(_userManager.RealName) ? "S8异常监控" : _userManager.RealName);
+            await _notificationLayerResolver.DispatchByLayerAsync(new S8NotificationLayerResolver.DispatchByLayerInput
+            {
+                TenantId = exception.TenantId,
+                ExceptionId = exception.Id,
+                ExceptionNo = exception.ExceptionCode,
+                SceneCode = string.IsNullOrWhiteSpace(exception.SceneCode) ? "S8_DEMO_DEFAULT" : exception.SceneCode,
+                Severity = S8SeverityCode.Normalize(exception.Severity),
+                EventCode = eventCode,
+                ExceptionRef = exception,
+                Title = title,
+                Content = content,
+                Status = exception.Status,
+                SourceRuleCode = exception.SourceRuleCode,
+                JumpUrl = $"/aidop/s8/exceptions/{exception.Id}",
+            });
         }
         catch (Exception ex)
         {
-            _logger.LogWarning(ex, "s8_assignee_notice_failed exceptionId={Id} sysUserId={UserId}", exception.Id, sysUserId);
+            _logger.LogWarning(ex, "s8_notify_dispatch_failed exceptionId={Id} event={Event}", exception.Id, eventCode);
         }
     }
 

+ 3 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TimeoutAutoEscalationService.cs

@@ -1,3 +1,4 @@
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Microsoft.Extensions.Logging;
@@ -138,6 +139,8 @@ public class S8TimeoutAutoEscalationService : ITransient
                 SceneCode = string.IsNullOrWhiteSpace(e.ModuleCode) ? e.SceneCode : e.ModuleCode!,
                 Severity = e.Severity,
                 Status = "ESCALATED",
+                EventCode = S8NotifyEventCode.EscalationTriggered,
+                ExceptionRef = e,
                 Title = $"[AUTO] 异常升级 - {e.ExceptionCode}",
                 Content = "SLA 已超时,系统自动触发升级。",
                 SourceRuleCode = e.SourceRuleCode,

+ 5 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -1,3 +1,4 @@
+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.Rules;
@@ -1339,6 +1340,8 @@ public class S8WatchSchedulerService : ITransient
                 ExceptionNo = entity.ExceptionCode,
                 SceneCode = sceneCode,
                 Severity = severity,
+                EventCode = S8NotifyEventCode.ExceptionCreated,
+                ExceptionRef = entity,
                 Title = entity.Title ?? string.Empty,
                 Content = content,
                 Status = entity.Status,
@@ -1390,6 +1393,8 @@ public class S8WatchSchedulerService : ITransient
                 ExceptionNo = entity.ExceptionCode,
                 SceneCode = sceneCode,
                 Severity = severity,
+                EventCode = S8NotifyEventCode.ExceptionRecovered,
+                ExceptionRef = entity,
                 Title = title,
                 Content = content,
                 Status = entity.Status,

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

@@ -256,6 +256,8 @@ public class Startup : AppStartup
                 typeof(AdoS8ExceptionActionRole),
                 // S8-HANDLER-POOL-1:规则 ↔ 处理账号池。
                 typeof(AdoS8RuleHandlerUser),
+                // S8-NOTIFY-RECIPIENT-1:事件 ↔ 收件人配置。
+                typeof(AdoS8NotificationRecipient),
                 typeof(AdoS8NotificationLog),
                 typeof(AdoS8DashboardCellConfig),
                 typeof(AdoS8ExceptionType),