فهرست منبع

feat(s8): add notification routing and logs

YY968XX 1 ماه پیش
والد
کامیت
3fb8b8c347
18فایلهای تغییر یافته به همراه1412 افزوده شده و 37 حذف شده
  1. 6 0
      Web/src/utils/aidopMenuDisplay.ts
  2. 4 0
      Web/src/views/aidop/s8/api/s8ConfigApi.ts
  3. 2 2
      Web/src/views/aidop/s8/config/S8ConfigHubPage.vue
  4. 265 32
      Web/src/views/aidop/s8/config/S8NotificationLayerPage.vue
  5. 73 0
      scripts/s8/check-s8-business-roles.sql
  6. 90 0
      scripts/s8/seed-s8-business-roles.sql
  7. 80 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8NotificationLayerResolverTests.cs
  8. 101 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8NotificationLogPayloadParseTests.cs
  9. 41 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8RoleResolverTokenTests.cs
  10. 4 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigNotificationLayersController.cs
  11. 24 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigNotificationLogsController.cs
  12. 40 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8NotificationLogDtos.cs
  13. 195 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationLayerResolver.cs
  14. 28 3
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationLayerService.cs
  15. 98 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationLogQueryService.cs
  16. 166 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationPushAdapter.cs
  17. 100 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8RoleResolver.cs
  18. 95 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

+ 6 - 0
Web/src/utils/aidopMenuDisplay.ts

@@ -73,6 +73,12 @@ const S8_CONFIG_HIDDEN_ROUTES: Array<{ path: string; title: string; component: s
 		component: '/aidop/s8/config/S8DashboardCellConfigPage',
 		name: 'aidopS8DashboardCellConfig',
 	},
+	{
+		path: '/aidop/s8/config/notifications',
+		title: '通知分层配置',
+		component: '/aidop/s8/config/S8NotificationLayerPage',
+		name: 'aidopS8NotificationLayerConfig',
+	},
 ];
 
 function collectPathsUnder(node: AMenu, acc: Set<string>): void {

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

@@ -177,4 +177,8 @@ export const s8ConfigApi = {
 		remove: (id: number) =>
 			service.delete(`/api/aidop/s8/config/dashboard-cells/${id}`).then(unwrap),
 	},
+	notificationLayers: {
+		roleOptions: () =>
+			service.get<Array<{ value: string; label: string }>>('/api/aidop/s8/config/notification-layers/role-options').then(unwrap),
+	},
 };

+ 2 - 2
Web/src/views/aidop/s8/config/S8ConfigHubPage.vue

@@ -25,8 +25,8 @@ const cards = [
 	{ path: '/aidop/s8/config/watch-rules', title: '监控规则', desc: '轮询节拍与表达式' },
 	{ path: '/aidop/s8/config/dashboard-cells', title: '大屏卡片', desc: '页面/卡片编码、绑定类型、指标与时间窗' },
 	{ path: '/aidop/s8/config/roles', title: '角色权限 / 操作员绑定', desc: '操作员与员工的绑定维护' },
-	// 隐藏卡片:保留代码以便后续恢复
-	// { path: '/aidop/s8/config/notifications', title: '通知分层', desc: '场景 + 严重度 + 层级' },
+	{ path: '/aidop/s8/config/notifications', title: '通知分层', desc: '场景 + 严重度 + 层级 + 推送渠道' },
+	// 演示阶段隐藏(保留代码,后续恢复取消注释即可):
 	// { path: '/aidop/s8/config/alert-rules', title: '报警规则', desc: '阈值与触发条件' },
 	// { path: '/aidop/s8/config/data-sources', title: '数据源', desc: '连接与启用' },
 ];

+ 265 - 32
Web/src/views/aidop/s8/config/S8NotificationLayerPage.vue

@@ -1,45 +1,278 @@
+<template>
+	<AidopDemoShell title="通知分层配置" subtitle="S8 / 配置 / 通知分层">
+		<div class="toolbar">
+			<el-button type="primary" @click="openCreate">新增</el-button>
+		</div>
+
+		<el-table :data="rows" border stripe v-loading="loading" style="width: 100%; margin-top: 12px">
+			<el-table-column prop="sceneCode" label="场景编码" width="160">
+				<template #default="{ row }">
+					<span>{{ row.sceneCode }}</span>
+					<el-tag v-if="row.sceneCode === 'S8_DEMO_DEFAULT'" type="info" size="small" style="margin-left: 6px">DEMO</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column prop="severity" label="严重度" width="100">
+				<template #default="{ row }">{{ severityLabel(row.severity) }}</template>
+			</el-table-column>
+			<el-table-column prop="levelCode" label="层级" width="130">
+				<template #default="{ row }">{{ levelLabel(row.levelCode) }}</template>
+			</el-table-column>
+			<el-table-column prop="targetRoleIds" label="目标角色" min-width="160">
+				<template #default="{ row }">{{ formatRoleIds(row.targetRoleIds) }}</template>
+			</el-table-column>
+			<el-table-column prop="notifyChannel" label="通知渠道" min-width="160">
+				<template #default="{ row }">{{ row.notifyChannel }}</template>
+			</el-table-column>
+			<el-table-column prop="updatedAt" label="更新时间" width="170">
+				<template #default="{ row }">{{ row.updatedAt ?? row.createdAt }}</template>
+			</el-table-column>
+			<el-table-column label="操作" width="130" fixed="right">
+				<template #default="{ row }">
+					<el-button size="small" @click="openEdit(row)">编辑</el-button>
+					<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
+				</template>
+			</el-table-column>
+		</el-table>
+
+		<!-- Dialog -->
+		<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑通知分层' : '新增通知分层'" width="560px" destroy-on-close>
+			<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
+				<el-form-item label="场景编码" prop="sceneCode">
+					<el-select
+						v-model="form.sceneCode"
+						filterable
+						allow-create
+						placeholder="选择或输入场景编码"
+						style="width: 100%"
+					>
+						<el-option v-for="opt in sceneOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="严重度" prop="severity">
+					<el-select v-model="form.severity" style="width: 100%">
+						<el-option v-for="opt in severityOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="层级" prop="levelCode">
+					<el-select v-model="form.levelCode" style="width: 100%">
+						<el-option v-for="opt in levelOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="目标角色" prop="targetRoleIdList">
+					<el-select v-model="form.targetRoleIdList" multiple placeholder="选择角色" style="width: 100%" v-loading="rolesLoading">
+						<el-option v-for="opt in roleOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="通知渠道" prop="notifyChannelList">
+					<el-checkbox-group v-model="form.notifyChannelList">
+						<el-checkbox v-for="opt in channelOptions" :key="opt.value" :label="opt.value">{{ opt.label }}</el-checkbox>
+					</el-checkbox-group>
+				</el-form-item>
+				<el-form-item>
+					<span style="font-size: 12px; color: var(--el-text-color-secondary)">
+						渠道启停受全局通知配置控制(ApprovalFlowNotifyConfig)
+					</span>
+				</el-form-item>
+			</el-form>
+			<template #footer>
+				<el-button @click="dialogVisible = false">取消</el-button>
+				<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
+			</template>
+		</el-dialog>
+	</AidopDemoShell>
+</template>
+
 <script setup lang="ts" name="aidopS8NotificationLayerConfig">
-import S8CrudConfigPage from '../components/config/S8CrudConfigPage.vue';
+import { ref, reactive, onMounted } from 'vue';
+import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
+import AidopDemoShell from '../../components/AidopDemoShell.vue';
 import { s8ConfigApi } from '../api/s8ConfigApi';
+import service from '/@/utils/request';
+
+// ── static option sets ──────────────────────────────────────────────────────
 
-const columns = [
-	{ key: 'sceneCode', label: '场景编码', width: 140 },
-	{ key: 'severity', label: '严重度', width: 120 },
-	{ key: 'levelCode', label: '层级', width: 120 },
-	{ key: 'notifyChannel', label: '通知渠道', width: 120 },
-	{ key: 'targetRoleIds', label: '目标角色' },
+const severityOptions = [
+	{ value: 'LOW', label: '低' },
+	{ value: 'MEDIUM', label: '中' },
+	{ value: 'HIGH', label: '高' },
+	{ value: 'CRITICAL', label: '严重' },
 ];
 
-const fields = [
-	{ key: 'sceneCode', label: '场景编码', type: 'select', required: true, optionsKey: 'scenes' },
-	{ key: 'severity', label: '严重度', type: 'select', required: true, optionsKey: 'severities' },
-	{ key: 'levelCode', label: '层级', type: 'input', required: true },
-	{ key: 'notifyChannel', label: '通知渠道', type: 'input' },
-	{ key: 'targetRoleIds', label: '目标角色', type: 'textarea' },
-] as const;
+const levelOptions = [
+	{ value: 'L1_OPERATOR', label: 'L1 操作员' },
+	{ value: 'L2_MANAGER', label: 'L2 主管' },
+	{ value: 'L3_DIRECTOR', label: 'L3 总监' },
+];
+
+const channelOptions = [
+	{ value: 'SignalR', label: 'SignalR' },
+	{ value: 'Email', label: 'Email' },
+	{ value: 'Sms', label: 'Sms' },
+	{ value: 'DingTalk', label: 'DingTalk' },
+	{ value: 'WorkWeixin', label: 'WorkWeixin' },
+];
+
+// ── state ───────────────────────────────────────────────────────────────────
+
+const loading = ref(false);
+const saving = ref(false);
+const rolesLoading = ref(false);
+const dialogVisible = ref(false);
+const isEdit = ref(false);
+const editId = ref<number>(0);
 
-const buildDefault = () => ({
+const rows = ref<any[]>([]);
+const sceneOptions = ref<Array<{ value: string; label: string }>>([]);
+const roleOptions = ref<Array<{ value: string; label: string }>>([]);
+
+const formRef = ref<FormInstance>();
+
+const defaultForm = () => ({
 	sceneCode: '',
 	severity: 'MEDIUM',
-	levelCode: '',
-	notifyChannel: 'log',
-	targetRoleIds: '',
+	levelCode: 'L1_OPERATOR',
+	targetRoleIdList: [] as string[],
+	notifyChannelList: [] as string[],
 });
 
-async function loadOptions() {
-	const [scenes, severities] = await Promise.all([s8ConfigApi.scenes({ tenantId: 1, factoryId: 1 }), s8ConfigApi.severities()]);
-	return { scenes: scenes.map((item: any) => ({ label: item.sceneName, value: item.sceneCode })), severities };
+const form = reactive(defaultForm());
+
+const rules: FormRules = {
+	sceneCode: [{ required: true, message: '场景编码必填', trigger: 'blur' }],
+	severity: [{ required: true, message: '严重度必填', trigger: 'change' }],
+	levelCode: [{ required: true, message: '层级必填', trigger: 'change' }],
+	targetRoleIdList: [{ required: true, type: 'array', min: 1, message: '目标角色必填', trigger: 'change' }],
+	notifyChannelList: [{ required: true, type: 'array', min: 1, message: '通知渠道至少选一个', trigger: 'change' }],
+};
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+function severityLabel(val: string): string {
+	return severityOptions.find((o) => o.value === val)?.label ?? val;
+}
+
+function levelLabel(val: string): string {
+	return levelOptions.find((o) => o.value === val)?.label ?? val;
+}
+
+function formatRoleIds(csv: string | null | undefined): string {
+	if (!csv) return '';
+	return csv
+		.split(',')
+		.filter(Boolean)
+		.map((v) => roleOptions.value.find((o) => o.value === v)?.label ?? v)
+		.join(', ');
+}
+
+// ── data loading ─────────────────────────────────────────────────────────────
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await service.get('/api/aidop/s8/config/notification-layers', { params: { tenantId: 1, factoryId: 1 } }).then((r: any) => r.data);
+		rows.value = Array.isArray(data) ? data : [];
+	} finally {
+		loading.value = false;
+	}
+}
+
+async function loadScenes() {
+	try {
+		const data: any[] = await s8ConfigApi.scenes({ tenantId: 1, factoryId: 1 });
+		sceneOptions.value = data.map((item: any) => ({ value: item.sceneCode, label: `${item.sceneCode}(${item.sceneName ?? ''})` }));
+	} catch {
+		sceneOptions.value = [];
+	}
+}
+
+async function loadRoleOptions() {
+	rolesLoading.value = true;
+	try {
+		const data = await s8ConfigApi.notificationLayers.roleOptions();
+		roleOptions.value = Array.isArray(data) ? data : [];
+	} catch {
+		roleOptions.value = [];
+	} finally {
+		rolesLoading.value = false;
+	}
+}
+
+onMounted(() => {
+	loadList();
+	loadScenes();
+	loadRoleOptions();
+});
+
+// ── dialog ───────────────────────────────────────────────────────────────────
+
+function openCreate() {
+	isEdit.value = false;
+	editId.value = 0;
+	Object.assign(form, defaultForm());
+	dialogVisible.value = true;
+}
+
+function openEdit(row: any) {
+	isEdit.value = true;
+	editId.value = row.id;
+	Object.assign(form, {
+		sceneCode: row.sceneCode ?? '',
+		severity: row.severity ?? 'MEDIUM',
+		levelCode: row.levelCode ?? 'L1_OPERATOR',
+		targetRoleIdList: row.targetRoleIds ? row.targetRoleIds.split(',').filter(Boolean) : [],
+		notifyChannelList: row.notifyChannel ? row.notifyChannel.split(',').filter(Boolean) : [],
+	});
+	dialogVisible.value = true;
+}
+
+async function handleSave() {
+	if (!formRef.value) return;
+	const valid = await formRef.value.validate().catch(() => false);
+	if (!valid) return;
+
+	const payload = {
+		tenantId: 1,
+		factoryId: 1,
+		sceneCode: form.sceneCode,
+		severity: form.severity,
+		levelCode: form.levelCode,
+		targetRoleIds: form.targetRoleIdList.join(','),
+		notifyChannel: form.notifyChannelList.join(','),
+	};
+
+	saving.value = true;
+	try {
+		if (isEdit.value) {
+			await service.put(`/api/aidop/s8/config/notification-layers/${editId.value}`, payload);
+		} else {
+			await service.post('/api/aidop/s8/config/notification-layers', payload);
+		}
+		ElMessage.success('保存成功');
+		dialogVisible.value = false;
+		loadList();
+	} catch (err: any) {
+		const msg = err?.response?.data?.message ?? err?.message ?? '保存失败';
+		ElMessage.error(msg);
+	} finally {
+		saving.value = false;
+	}
+}
+
+async function handleDelete(row: any) {
+	await ElMessageBox.confirm(`确认删除 ${row.sceneCode} / ${row.severity} / ${row.levelCode}?`, '删除确认', { type: 'warning' });
+	try {
+		await service.delete(`/api/aidop/s8/config/notification-layers/${row.id}`);
+		ElMessage.success('已删除');
+		loadList();
+	} catch (err: any) {
+		ElMessage.error(err?.message ?? '删除失败');
+	}
 }
 </script>
 
-<template>
-	<S8CrudConfigPage
-		title="通知分层配置"
-		subtitle="S8 / 配置 / 通知分层"
-		endpoint="/api/aidop/s8/config/notification-layers"
-		:columns="columns"
-		:fields="fields"
-		:build-default="buildDefault"
-		:load-options="loadOptions"
-	/>
-</template>
+<style scoped>
+.toolbar {
+	display: flex;
+	justify-content: flex-end;
+}
+</style>

+ 73 - 0
scripts/s8/check-s8-business-roles.sql

@@ -0,0 +1,73 @@
+-- =============================================================================
+-- check-s8-business-roles.sql
+-- S8-SEED-DURABILITY-1: read-only verification for S8 business role seed
+--
+-- Usage:
+--   mysql -h <host> -P 3306 -u <user> -p<pwd> <db> < check-s8-business-roles.sql
+--
+-- Expected output:
+--   SysRole count:                7
+--   S8RolePermConfig count:       7
+--   Demo01 binding count:         1
+--   Notification layer bad dates: 0
+-- =============================================================================
+
+SELECT '=== SysRole: S8 business roles ===' AS '';
+SELECT Id, Code, Name, Status
+FROM SysRole
+WHERE Id BETWEEN 1329908000101 AND 1329908000107
+ORDER BY Id;
+
+SELECT CONCAT(
+    'SysRole count: ', COUNT(*),
+    ' (expected 7)'
+) AS check_result
+FROM SysRole
+WHERE Id BETWEEN 1329908000101 AND 1329908000107;
+
+SELECT '=== ado_s8_role_permission_config ===' AS '';
+SELECT id, role_code, permission_codes
+FROM ado_s8_role_permission_config
+WHERE id BETWEEN 1329908000001 AND 1329908000007
+ORDER BY id;
+
+SELECT CONCAT(
+    'S8RolePermConfig count: ', COUNT(*),
+    ' (expected 7)'
+) AS check_result
+FROM ado_s8_role_permission_config
+WHERE id BETWEEN 1329908000001 AND 1329908000007;
+
+SELECT '=== SysUserRole: S8 business role bindings ===' AS '';
+SELECT ur.Id, ur.UserId, u.Account, r.Code AS RoleCode, r.Name AS RoleName
+FROM SysUserRole ur
+JOIN SysRole r ON ur.RoleId = r.Id
+JOIN SysUser u ON ur.UserId = u.Id
+WHERE ur.Id BETWEEN 1329909000001 AND 1329909000007
+ORDER BY ur.Id;
+
+SELECT CONCAT(
+    'S8 role binding count: ', COUNT(*),
+    ' (expected 7)'
+) AS check_result
+FROM SysUserRole
+WHERE Id BETWEEN 1329909000001 AND 1329909000007;
+
+SELECT CONCAT(
+    'Roles without any binding: ',
+    COUNT(*),
+    ' (expected 0)'
+) AS check_result
+FROM SysRole r
+WHERE r.Id BETWEEN 1329908000101 AND 1329908000107
+  AND NOT EXISTS (
+      SELECT 1 FROM SysUserRole ur WHERE ur.RoleId = r.Id
+  );
+
+SELECT '=== notification_layer zero-date guard ===' AS '';
+SELECT CONCAT(
+    'Notification layer bad dates: ', COUNT(*),
+    ' (expected 0)'
+) AS check_result
+FROM ado_s8_notification_layer
+WHERE YEAR(created_at) = 0;

+ 90 - 0
scripts/s8/seed-s8-business-roles.sql

@@ -0,0 +1,90 @@
+-- =============================================================================
+-- seed-s8-business-roles.sql
+-- S8-SEED-DURABILITY-1: idempotent seed for S8 business roles
+--
+-- Purpose: Restore S8 business SysRole entries and related config that
+--          S8SysRoleSeedData / S8RolePermissionSeedData would normally insert
+--          when EnableIncreSeed=true. This script is safe to run repeatedly.
+--
+-- Root cause: EnableIncreSeed=false in Database.json (intentional, avoids
+--             unintended re-seed of other modules on restart).
+--
+-- Usage:
+--   mysql -h <host> -P 3306 -u <user> -p<pwd> <db> < seed-s8-business-roles.sql
+--
+-- Idempotency: INSERT IGNORE skips rows with duplicate primary keys.
+--              No DROP / TRUNCATE / DELETE on any table.
+--
+-- Targets (dev DB): aidopdev
+-- Last updated: 2026-04-29
+-- =============================================================================
+
+-- ---------------------------------------------------------------------------
+-- Section 1: S8 business roles → SysRole
+-- Matches S8SysRoleSeedData.HasData() exactly.
+-- TenantId = 1300000000001 (SqlSugarConst.DefaultTenantId)
+-- DataScope = 4 (DataScopeEnum.Self)
+-- Status = 1 (StatusEnum.Enable)
+-- ---------------------------------------------------------------------------
+INSERT IGNORE INTO SysRole
+    (Id, Name, Code, OrderNo, DataScope, Remark, Status, TenantId, CreateTime)
+VALUES
+    (1329908000101, '订单计划员', 'ROLE_ORDER_PLANNER',      800, 4, 'S8 业务角色(异常协同承接)', 1, 1300000000001, '2026-04-18 00:00:00'),
+    (1329908000102, '生产计划员', 'ROLE_PRODUCTION_PLANNER', 801, 4, 'S8 业务角色(异常协同承接)', 1, 1300000000001, '2026-04-18 00:00:00'),
+    (1329908000103, '采购员',     'ROLE_PURCHASER',          802, 4, 'S8 业务角色(异常协同承接)', 1, 1300000000001, '2026-04-18 00:00:00'),
+    (1329908000104, '仓库收货员', 'ROLE_WH_INBOUND',         803, 4, 'S8 业务角色(异常协同承接)', 1, 1300000000001, '2026-04-18 00:00:00'),
+    (1329908000105, '仓库发货员', 'ROLE_WH_OUTBOUND',        804, 4, 'S8 业务角色(异常协同承接)', 1, 1300000000001, '2026-04-18 00:00:00'),
+    (1329908000106, '质检员',     'ROLE_QC',                 805, 4, 'S8 业务角色(异常协同承接)', 1, 1300000000001, '2026-04-18 00:00:00'),
+    (1329908000107, '设备维修员', 'ROLE_EQUIP_MAINT',        806, 4, 'S8 业务角色(异常协同承接)', 1, 1300000000001, '2026-04-18 00:00:00');
+
+SELECT CONCAT('SysRole inserted: ', ROW_COUNT()) AS result;
+
+-- ---------------------------------------------------------------------------
+-- Section 2: S8 role permission baseline → ado_s8_role_permission_config
+-- Matches S8RolePermissionSeedData.HasData() exactly.
+-- TenantId=0 / FactoryId=0 = global baseline (not tenant-scoped).
+-- ---------------------------------------------------------------------------
+INSERT IGNORE INTO ado_s8_role_permission_config
+    (id, tenant_id, factory_id, role_code, permission_codes, created_at)
+VALUES
+    (1329908000001, 0, 0, 'ROLE_ORDER_PLANNER',      's8:exception:read,s8:exception:assign', '2026-04-18 00:00:00'),
+    (1329908000002, 0, 0, 'ROLE_PRODUCTION_PLANNER', 's8:exception:read,s8:exception:assign', '2026-04-18 00:00:00'),
+    (1329908000003, 0, 0, 'ROLE_PURCHASER',          's8:exception:read,s8:exception:assign', '2026-04-18 00:00:00'),
+    (1329908000004, 0, 0, 'ROLE_WH_INBOUND',         's8:exception:read,s8:exception:assign', '2026-04-18 00:00:00'),
+    (1329908000005, 0, 0, 'ROLE_WH_OUTBOUND',        's8:exception:read,s8:exception:assign', '2026-04-18 00:00:00'),
+    (1329908000006, 0, 0, 'ROLE_QC',                 's8:exception:read,s8:exception:assign', '2026-04-18 00:00:00'),
+    (1329908000007, 0, 0, 'ROLE_EQUIP_MAINT',        's8:exception:read,s8:exception:assign', '2026-04-18 00:00:00');
+
+SELECT CONCAT('S8RolePermissionConfig inserted: ', ROW_COUNT()) AS result;
+
+-- ---------------------------------------------------------------------------
+-- Section 3: S8 business role → test user bindings → SysUserRole
+-- Demo01 UserId=1300000000117 (Account='Demo01')
+-- Demo02 UserId=789819678195781 (Account='Demo02')
+-- Strategy: Demo01 → planning/purchasing/warehouse; Demo02 → production/QC/maintenance
+-- All 7 roles must resolve to ≥1 userId for notification chain to work.
+-- ---------------------------------------------------------------------------
+INSERT IGNORE INTO SysUserRole (Id, UserId, RoleId)
+VALUES
+    (1329909000001, 1300000000117,   1329908000101),  -- Demo01 → ROLE_ORDER_PLANNER
+    (1329909000002, 789819678195781, 1329908000102),  -- Demo02 → ROLE_PRODUCTION_PLANNER
+    (1329909000003, 1300000000117,   1329908000103),  -- Demo01 → ROLE_PURCHASER
+    (1329909000004, 1300000000117,   1329908000104),  -- Demo01 → ROLE_WH_INBOUND
+    (1329909000005, 1300000000117,   1329908000105),  -- Demo01 → ROLE_WH_OUTBOUND
+    (1329909000006, 789819678195781, 1329908000106),  -- Demo02 → ROLE_QC
+    (1329909000007, 789819678195781, 1329908000107);  -- Demo02 → ROLE_EQUIP_MAINT
+
+SELECT CONCAT('SysUserRole (S8 role bindings) inserted: ', ROW_COUNT()) AS result;
+
+-- ---------------------------------------------------------------------------
+-- Section 4: notification_layer zero-datetime guard
+-- ado_s8_notification_layer.created_at is NOT NULL with no default.
+-- If rows exist with '0000-00-00 00:00:00', SqlSugar DateTime binding fails.
+-- Fix any zero-date rows (safe: only affects layers that already have bad data).
+-- ---------------------------------------------------------------------------
+-- YEAR(created_at)=0 avoids the '0000-00-00' literal which MySQL strict mode rejects.
+UPDATE ado_s8_notification_layer
+SET created_at = '2026-04-29 00:00:00'
+WHERE YEAR(created_at) = 0;
+
+SELECT CONCAT('notification_layer zero-date fixed: ', ROW_COUNT()) AS result;

+ 80 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8NotificationLayerResolverTests.cs

@@ -0,0 +1,80 @@
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8NotificationLayerResolver 静态契约单元测试。
+/// 实例方法 DispatchByLayerAsync 依赖 SqlSugarRepository + S8RoleResolver + S8NotificationPushAdapter,
+/// 在带 DB 的集成环境(dev DB seed: scene_code=S8_DEMO_DEFAULT/severity=HIGH/target=sys_admin/channel=SignalR)验证。
+/// 本文件覆盖渠道解析契约 + 静态防御。
+/// </summary>
+public class S8NotificationLayerResolverTests
+{
+    [Fact]
+    public void ParseChannels_NullOrEmpty_ReturnsEmpty()
+    {
+        Assert.Empty(S8NotificationLayerResolver.ParseChannels(null));
+        Assert.Empty(S8NotificationLayerResolver.ParseChannels(""));
+        Assert.Empty(S8NotificationLayerResolver.ParseChannels("   "));
+    }
+
+    [Fact]
+    public void ParseChannels_Comma_TrimsAndSplits()
+    {
+        var r = S8NotificationLayerResolver.ParseChannels(" SignalR , Email , DingTalk ");
+        Assert.Equal(new[] { "SignalR", "Email", "DingTalk" }, r);
+    }
+
+    [Fact]
+    public void ParseChannels_Semicolon_AlsoSupported()
+    {
+        var r = S8NotificationLayerResolver.ParseChannels("Email;Sms;WorkWeixin");
+        Assert.Equal(new[] { "Email", "Sms", "WorkWeixin" }, r);
+    }
+
+    [Fact]
+    public void ParseChannels_Mixed_DropsEmpty()
+    {
+        var r = S8NotificationLayerResolver.ParseChannels("SignalR,;Email,,Sms");
+        Assert.Equal(new[] { "SignalR", "Email", "Sms" }, r);
+    }
+
+    [Fact]
+    public void DispatchByLayerInput_DefaultsAreSafe()
+    {
+        var x = new S8NotificationLayerResolver.DispatchByLayerInput();
+        Assert.Equal(string.Empty, x.SceneCode);
+        Assert.Equal(string.Empty, x.Severity);
+        Assert.Equal(string.Empty, x.Title);
+        Assert.Equal(string.Empty, x.Content);
+        Assert.Null(x.ExceptionId);
+        Assert.Null(x.ExceptionNo);
+        Assert.Null(x.JumpUrl);
+        Assert.False(x.Recovered);
+    }
+
+    [Fact]
+    public void DispatchByLayerInput_RecoveredFlag_Settable()
+    {
+        var x = new S8NotificationLayerResolver.DispatchByLayerInput { Recovered = true };
+        Assert.True(x.Recovered);
+    }
+
+    /// <summary>
+    /// 验证 recovered 字段在 payload summary 中的布尔解析契约:
+    /// Context["recovered"]="true"(大小写不敏感)→ true;其余 → false。
+    /// </summary>
+    [Theory]
+    [InlineData("true", true)]
+    [InlineData("True", true)]
+    [InlineData("TRUE", true)]
+    [InlineData("false", false)]
+    [InlineData("", false)]
+    [InlineData(null, false)]
+    public void RecoveredContextValue_ParsesCorrectly(string? raw, bool expected)
+    {
+        var result = "true".Equals(raw, StringComparison.OrdinalIgnoreCase);
+        Assert.Equal(expected, result);
+    }
+}

+ 101 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8NotificationLogPayloadParseTests.cs

@@ -0,0 +1,101 @@
+using System.Text.Json;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8NotificationLogQueryService.MapToDto payload 解析契约(S8-NOTIFY-AUDIT-LOG-API-1)
+/// MapToDto 是 private;此处复刻相同解析逻辑作等价契约测试。
+/// </summary>
+public class S8NotificationLogPayloadParseTests
+{
+    private static (bool? success, bool? recovered, int? targetCount, string? error) ParsePayload(string? payload)
+    {
+        if (string.IsNullOrWhiteSpace(payload)) return (null, null, null, null);
+        try
+        {
+            using var doc = JsonDocument.Parse(payload);
+            var root = doc.RootElement;
+
+            bool? success = null;
+            if (root.TryGetProperty("success", out var sv) && (sv.ValueKind == JsonValueKind.True || sv.ValueKind == JsonValueKind.False))
+                success = sv.GetBoolean();
+
+            bool? recovered = null;
+            if (root.TryGetProperty("recovered", out var rv) && (rv.ValueKind == JsonValueKind.True || rv.ValueKind == JsonValueKind.False))
+                recovered = rv.GetBoolean();
+
+            int? targetCount = null;
+            if (root.TryGetProperty("targetCount", out var tc) && tc.ValueKind == JsonValueKind.Number)
+                targetCount = tc.GetInt32();
+
+            string? error = null;
+            if (root.TryGetProperty("error", out var ev) && ev.ValueKind == JsonValueKind.String)
+                error = ev.GetString();
+
+            return (success, recovered, targetCount, error);
+        }
+        catch { return (null, null, null, null); }
+    }
+
+    [Fact]
+    public void CreatedPayload_ParsesAllFields()
+    {
+        var json = "{\"title\":\"T\",\"recovered\":false,\"success\":true,\"targetCount\":3,\"error\":null}";
+        var (success, recovered, targetCount, error) = ParsePayload(json);
+        Assert.True(success);
+        Assert.False(recovered);
+        Assert.Equal(3, targetCount);
+        Assert.Null(error);
+    }
+
+    [Fact]
+    public void RecoveredPayload_RecoveredTrue()
+    {
+        var json = "{\"recovered\":true,\"success\":true,\"targetCount\":1,\"error\":null}";
+        var (success, recovered, targetCount, _) = ParsePayload(json);
+        Assert.True(success);
+        Assert.True(recovered);
+        Assert.Equal(1, targetCount);
+    }
+
+    [Fact]
+    public void FailedPush_SuccessFalse_ErrorPresent()
+    {
+        var json = "{\"recovered\":false,\"success\":false,\"targetCount\":0,\"error\":\"channel_disabled_by_config\"}";
+        var (success, recovered, _, error) = ParsePayload(json);
+        Assert.False(success);
+        Assert.False(recovered);
+        Assert.Equal("channel_disabled_by_config", error);
+    }
+
+    [Fact]
+    public void NullPayload_ReturnsAllNull()
+    {
+        var (success, recovered, targetCount, error) = ParsePayload(null);
+        Assert.Null(success);
+        Assert.Null(recovered);
+        Assert.Null(targetCount);
+        Assert.Null(error);
+    }
+
+    [Fact]
+    public void InvalidJson_ReturnsAllNull_NoBoomNoThrow()
+    {
+        var (success, recovered, targetCount, error) = ParsePayload("not-json{{{");
+        Assert.Null(success);
+        Assert.Null(recovered);
+        Assert.Null(targetCount);
+        Assert.Null(error);
+    }
+
+    [Fact]
+    public void TruncatedPayload_ReturnsAllNull()
+    {
+        var (success, recovered, targetCount, error) = ParsePayload("{\"recovered\":true,\"success\":tru...");
+        Assert.Null(success);
+        Assert.Null(recovered);
+        Assert.Null(targetCount);
+        Assert.Null(error);
+    }
+}

+ 41 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8RoleResolverTokenTests.cs

@@ -0,0 +1,41 @@
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8RoleResolver.SplitTokens 纯函数单元测试。
+/// 实例方法 ResolveUserIdsAsync 依赖 SqlSugarRepository&lt;SysRole&gt; + SysUserRoleService,
+/// 需在带 DB 的集成环境验证;本文件仅覆盖静态切分契约。
+/// </summary>
+public class S8RoleResolverTokenTests
+{
+    [Fact]
+    public void SplitTokens_NullOrEmpty_ReturnsEmpty()
+    {
+        Assert.Empty(S8RoleResolver.SplitTokens(null));
+        Assert.Empty(S8RoleResolver.SplitTokens(""));
+        Assert.Empty(S8RoleResolver.SplitTokens("   "));
+    }
+
+    [Fact]
+    public void SplitTokens_Comma_TrimsAndDropsEmpty()
+    {
+        var r = S8RoleResolver.SplitTokens(" ROLE_A , 123, , ROLE_B  ");
+        Assert.Equal(new[] { "ROLE_A", "123", "ROLE_B" }, r);
+    }
+
+    [Fact]
+    public void SplitTokens_Semicolon_AlsoSupported()
+    {
+        var r = S8RoleResolver.SplitTokens("ROLE_X;456;ROLE_Y");
+        Assert.Equal(new[] { "ROLE_X", "456", "ROLE_Y" }, r);
+    }
+
+    [Fact]
+    public void SplitTokens_Mixed_CommaAndSemicolon()
+    {
+        var r = S8RoleResolver.SplitTokens("ROLE_A,789;ROLE_B");
+        Assert.Equal(new[] { "ROLE_A", "789", "ROLE_B" }, r);
+    }
+}

+ 4 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigNotificationLayersController.cs

@@ -12,6 +12,10 @@ public class AdoS8ConfigNotificationLayersController : ControllerBase
 
     public AdoS8ConfigNotificationLayersController(S8NotificationLayerService svc) => _svc = svc;
 
+    [HttpGet("role-options")]
+    public async Task<IActionResult> GetRoleOptionsAsync() =>
+        Ok(await _svc.GetRoleOptionsAsync());
+
     [HttpGet]
     public async Task<IActionResult> ListAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1) =>
         Ok(await _svc.ListAsync(tenantId, factoryId));

+ 24 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigNotificationLogsController.cs

@@ -0,0 +1,24 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
+
+[ApiController]
+[Route("api/aidop/s8/config/notification-logs")]
+[NonUnify]
+public class AdoS8ConfigNotificationLogsController : ControllerBase
+{
+    private readonly S8NotificationLogQueryService _svc;
+
+    public AdoS8ConfigNotificationLogsController(S8NotificationLogQueryService svc)
+    {
+        _svc = svc;
+    }
+
+    [HttpGet]
+    public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS8NotificationLogQueryDto q)
+    {
+        var (total, list) = await _svc.GetPagedAsync(q);
+        return Ok(new { total, page = q.Page, pageSize = q.PageSize, list });
+    }
+}

+ 40 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8NotificationLogDtos.cs

@@ -0,0 +1,40 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace Admin.NET.Plugin.AiDOP.Dto.S8;
+
+public class AdoS8NotificationLogQueryDto
+{
+    public long TenantId { get; set; } = 1;
+    public long FactoryId { get; set; } = 1;
+    public long? ExceptionId { get; set; }
+    public string? Channel { get; set; }
+    public bool? Success { get; set; }
+    public bool? Recovered { get; set; }
+    /// <summary>关键字,匹配 payload / channel</summary>
+    public string? Keyword { get; set; }
+    [FromQuery(Name = "from")]
+    public DateTime? CreatedAtStart { get; set; }
+    [FromQuery(Name = "to")]
+    public DateTime? CreatedAtEnd { get; set; }
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 20;
+}
+
+public class AdoS8NotificationLogListItemDto
+{
+    public long Id { get; set; }
+    public long TenantId { get; set; }
+    public long FactoryId { get; set; }
+    public long? ExceptionId { get; set; }
+    public string Channel { get; set; } = string.Empty;
+    /// <summary>从 payload.targetCount 解析;解析失败返回 null</summary>
+    public int? TargetCount { get; set; }
+    /// <summary>从 payload.success 解析;解析失败返回 null</summary>
+    public bool? Success { get; set; }
+    /// <summary>从 payload.recovered 解析;解析失败返回 null</summary>
+    public bool? Recovered { get; set; }
+    /// <summary>从 payload.error 解析;解析失败返回 null</summary>
+    public string? ErrorMessage { get; set; }
+    public string? Payload { get; set; }
+    public DateTime CreatedAt { get; set; }
+}

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

@@ -0,0 +1,195 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.ApprovalFlow.Service;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// S8 通知分层路由解析器(S8-NOTIFY-LAYER-RESOLVE-1)
+///
+/// 职责链:
+///   (tenantId, factoryId, sceneCode, severity)
+///     → 命中 ado_s8_notification_layer 行(多行 OK)
+///     → 解析 target_role_ids(dual-format)
+///     → 解析 notify_channel(IgnoreCase, ',' / ';' 分隔)
+///     → S8RoleResolver → userIds
+///     → S8NotificationPushAdapter.PushAsync
+///     → 由 PushAdapter 落 AdoS8NotificationLog(每渠道一条)
+///
+/// 不接入 watch / scheduler / task 主链路;不写 ApprovalFlowNotifyLog;
+/// 不修改任何 schema;任何分支异常仅 LogWarning,不向上抛。
+///
+/// tenant/factory baseline fallback:先精确匹配 (tenantId, factoryId);
+/// 若 0 行,再尝试 (tenant=0, factory=0) baseline;仍 0 行 LogInformation 后 return。
+/// </summary>
+public class S8NotificationLayerResolver : ITransient
+{
+	private readonly SqlSugarRepository<AdoS8NotificationLayer> _layerRep;
+	private readonly S8RoleResolver _roleResolver;
+	private readonly S8NotificationPushAdapter _pushAdapter;
+	private readonly ILogger<S8NotificationLayerResolver> _logger;
+
+	public S8NotificationLayerResolver(
+		SqlSugarRepository<AdoS8NotificationLayer> layerRep,
+		S8RoleResolver roleResolver,
+		S8NotificationPushAdapter pushAdapter,
+		ILogger<S8NotificationLayerResolver> logger)
+	{
+		_layerRep = layerRep;
+		_roleResolver = roleResolver;
+		_pushAdapter = pushAdapter;
+		_logger = logger;
+	}
+
+	public class DispatchByLayerInput
+	{
+		public long TenantId { get; set; }
+		public long FactoryId { get; set; }
+		public long? ExceptionId { get; set; }
+		public string? ExceptionNo { get; set; }
+		public string SceneCode { get; set; } = string.Empty;
+		public string Severity { get; set; } = string.Empty;
+		public string Title { get; set; } = string.Empty;
+		public string Content { get; set; } = string.Empty;
+		public string? Status { get; set; }
+		public string? SourceRuleCode { get; set; }
+		public string? JumpUrl { get; set; }
+		/// <summary>
+		/// S8-NOTIFY-WIRE-RECOVERED-1:true 表示恢复事件,BuildNotification 会在 Context 中追加
+		/// "recovered"="true"。默认 false,保持 CREATED 路径载荷向后兼容。
+		/// </summary>
+		public bool Recovered { get; set; }
+	}
+
+	/// <summary>
+	/// 调用方:watch/scheduler/task 在拿到 sceneCode + severity 后调用本方法(本轮不接入主链路)。
+	/// </summary>
+	public async Task DispatchByLayerAsync(DispatchByLayerInput input)
+	{
+		if (input == null)
+		{
+			_logger.LogWarning("S8LayerDispatch: input null");
+			return;
+		}
+		if (string.IsNullOrWhiteSpace(input.SceneCode) || string.IsNullOrWhiteSpace(input.Severity))
+		{
+			_logger.LogInformation("S8LayerDispatch skip: empty sceneCode or severity (exceptionId={ExceptionId})", input.ExceptionId);
+			return;
+		}
+
+		List<AdoS8NotificationLayer> layers;
+		try
+		{
+			layers = await _layerRep.AsQueryable()
+				.Where(x => x.TenantId == input.TenantId
+					&& x.FactoryId == input.FactoryId
+					&& x.SceneCode == input.SceneCode
+					&& x.Severity == input.Severity)
+				.ToListAsync();
+
+			// baseline fallback:未命中精确租户/工厂 → 尝试 tenant=0/factory=0 全局基线
+			// (task 第 4 条允许;schema 中 tenant_id/factory_id 为 NOT NULL bigint,0 视为基线占位)
+			if (layers.Count == 0)
+			{
+				layers = await _layerRep.AsQueryable()
+					.Where(x => x.TenantId == 0
+						&& x.FactoryId == 0
+						&& x.SceneCode == input.SceneCode
+						&& x.Severity == input.Severity)
+					.ToListAsync();
+				if (layers.Count > 0)
+					_logger.LogInformation("S8LayerDispatch: matched {N} baseline (tenant=0/factory=0) layer rows", layers.Count);
+			}
+		}
+		catch (Exception ex)
+		{
+			_logger.LogWarning(ex, "S8LayerDispatch: query AdoS8NotificationLayer failed (scene={Scene}, sev={Sev})", input.SceneCode, input.Severity);
+			return;
+		}
+
+		if (layers.Count == 0)
+		{
+			_logger.LogInformation("S8LayerDispatch: no layer matched (tenant={Tenant}, factory={Factory}, scene={Scene}, sev={Sev})",
+				input.TenantId, input.FactoryId, input.SceneCode, input.Severity);
+			return;
+		}
+
+		var notification = BuildNotification(input);
+
+		foreach (var layer in layers)
+		{
+			List<long> userIds;
+			try
+			{
+				var tokens = S8RoleResolver.SplitTokens(layer.TargetRoleIds);
+				if (tokens.Count == 0)
+				{
+					_logger.LogWarning("S8LayerDispatch: layer id={LayerId} target_role_ids empty, skip row", layer.Id);
+					continue;
+				}
+				userIds = await _roleResolver.ResolveUserIdsAsync(tokens);
+			}
+			catch (Exception ex)
+			{
+				_logger.LogWarning(ex, "S8LayerDispatch: resolve roles failed for layer id={LayerId}", layer.Id);
+				continue;
+			}
+
+			if (userIds.Count == 0)
+			{
+				_logger.LogWarning("S8LayerDispatch: layer id={LayerId} resolved 0 users from target_role_ids='{Roles}'",
+					layer.Id, layer.TargetRoleIds);
+				continue;
+			}
+
+			var channels = ParseChannels(layer.NotifyChannel);
+			try
+			{
+				await _pushAdapter.PushAsync(
+					tenantId: input.TenantId,
+					factoryId: input.FactoryId,
+					exceptionId: input.ExceptionId,
+					userIds: userIds,
+					notification: notification,
+					channels: channels);
+			}
+			catch (Exception ex)
+			{
+				// PushAdapter 自身已对内部错误做了捕获;这里再兜一层保证主流程不被打断。
+				_logger.LogWarning(ex, "S8LayerDispatch: push throw (layer id={LayerId})", layer.Id);
+			}
+		}
+	}
+
+	private static FlowNotification BuildNotification(DispatchByLayerInput input)
+	{
+		// 注意:FlowNotificationTypeEnum 不含 S8_EXCEPTION 值;BizType 字段承载 "S8_EXCEPTION" 语义。
+		// InstanceId 仅作为载荷字段(PushAdapter 不写 ApprovalFlowNotifyLog,不会脏写该列)。
+		var ctx = new Dictionary<string, string?>
+		{
+			["exceptionId"] = input.ExceptionId?.ToString(),
+			["exceptionNo"] = input.ExceptionNo,
+			["sceneCode"] = input.SceneCode,
+			["severity"] = input.Severity,
+			["status"] = input.Status,
+			["sourceRuleCode"] = input.SourceRuleCode,
+			["jumpUrl"] = input.JumpUrl,
+		};
+		if (input.Recovered) ctx["recovered"] = "true";
+		return new FlowNotification
+		{
+			Type = FlowNotificationTypeEnum.NewTask,
+			BizType = "S8_EXCEPTION",
+			InstanceId = input.ExceptionId ?? 0,
+			Title = input.Title ?? string.Empty,
+			Content = input.Content ?? string.Empty,
+			Context = ctx,
+		};
+	}
+
+	public static List<string> ParseChannels(string? csv)
+	{
+		if (string.IsNullOrWhiteSpace(csv)) return new List<string>();
+		return csv.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
+	}
+}

+ 28 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationLayerService.cs

@@ -1,3 +1,4 @@
+using Admin.NET.Core;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
@@ -5,26 +6,46 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8;
 public class S8NotificationLayerService : ITransient
 {
     private readonly SqlSugarRepository<AdoS8NotificationLayer> _rep;
+    private readonly SqlSugarRepository<SysRole> _roleRep;
 
-    public S8NotificationLayerService(SqlSugarRepository<AdoS8NotificationLayer> rep) => _rep = rep;
+    public S8NotificationLayerService(
+        SqlSugarRepository<AdoS8NotificationLayer> rep,
+        SqlSugarRepository<SysRole> roleRep)
+    {
+        _rep = rep;
+        _roleRep = roleRep;
+    }
 
     public async Task<List<AdoS8NotificationLayer>> ListAsync(long tenantId, long factoryId) =>
         await _rep.AsQueryable()
             .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
             .ToListAsync();
 
+    public async Task<List<object>> GetRoleOptionsAsync()
+    {
+        var roles = await _roleRep.AsQueryable()
+            .Where(x => (x.Id >= 1329908000101L && x.Id <= 1329908000107L) || x.Id == 1300000000101L)
+            .OrderBy(x => x.Id)
+            .Select(x => new { value = x.Code, label = x.Name })
+            .ToListAsync();
+        return roles.Cast<object>().ToList();
+    }
+
     public async Task<AdoS8NotificationLayer> CreateAsync(AdoS8NotificationLayer body)
     {
         if (string.IsNullOrWhiteSpace(body.SceneCode) || string.IsNullOrWhiteSpace(body.Severity) || string.IsNullOrWhiteSpace(body.LevelCode))
             throw new S8BizException("场景、严重度、层级必填");
+        if (string.IsNullOrWhiteSpace(body.TargetRoleIds))
+            throw new S8BizException("目标角色必填");
+        if (string.IsNullOrWhiteSpace(body.NotifyChannel))
+            throw new S8BizException("通知渠道至少选一个");
         var exists = await _rep.AsQueryable()
             .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId &&
                            x.SceneCode == body.SceneCode && x.Severity == body.Severity && x.LevelCode == body.LevelCode);
         if (exists) throw new S8BizException("同场景+严重度+层级已存在");
         body.Id = 0;
         body.CreatedAt = DateTime.Now;
-        await _rep.InsertAsync(body);
-        return body;
+        return await _rep.InsertReturnEntityAsync(body);
     }
 
     public async Task<AdoS8NotificationLayer> UpdateAsync(long id, AdoS8NotificationLayer body)
@@ -32,6 +53,10 @@ public class S8NotificationLayerService : ITransient
         var e = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
         if (string.IsNullOrWhiteSpace(body.SceneCode) || string.IsNullOrWhiteSpace(body.Severity) || string.IsNullOrWhiteSpace(body.LevelCode))
             throw new S8BizException("场景、严重度、层级必填");
+        if (string.IsNullOrWhiteSpace(body.TargetRoleIds))
+            throw new S8BizException("目标角色必填");
+        if (string.IsNullOrWhiteSpace(body.NotifyChannel))
+            throw new S8BizException("通知渠道至少选一个");
         var exists = await _rep.AsQueryable()
             .AnyAsync(x => x.Id != id && x.TenantId == body.TenantId && x.FactoryId == body.FactoryId &&
                            x.SceneCode == body.SceneCode && x.Severity == body.Severity && x.LevelCode == body.LevelCode);

+ 98 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationLogQueryService.cs

@@ -0,0 +1,98 @@
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// S8 通知日志只读查询(S8-NOTIFY-AUDIT-LOG-API-1)
+/// 仅读 ado_s8_notification_log,不写任何表,不调用 FlowNotifyService。
+/// </summary>
+public class S8NotificationLogQueryService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8NotificationLog> _rep;
+    private readonly ILogger<S8NotificationLogQueryService> _logger;
+
+    public S8NotificationLogQueryService(
+        SqlSugarRepository<AdoS8NotificationLog> rep,
+        ILogger<S8NotificationLogQueryService> logger)
+    {
+        _rep = rep;
+        _logger = logger;
+    }
+
+    public async Task<(int total, List<AdoS8NotificationLogListItemDto> list)> GetPagedAsync(AdoS8NotificationLogQueryDto q)
+    {
+        (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize);
+
+        // payload LIKE patterns(System.Text.Json camelCase 序列化,格式稳定)
+        var recoveredPattern = q.Recovered.HasValue
+            ? (q.Recovered.Value ? "\"recovered\":true" : "\"recovered\":false")
+            : null;
+        var successPattern = q.Success.HasValue
+            ? (q.Success.Value ? "\"success\":true" : "\"success\":false")
+            : null;
+
+        var query = _rep.AsQueryable()
+            .Where(x => x.TenantId == q.TenantId && x.FactoryId == q.FactoryId)
+            .WhereIF(q.ExceptionId.HasValue, x => x.ExceptionId == q.ExceptionId!.Value)
+            .WhereIF(!string.IsNullOrWhiteSpace(q.Channel), x => x.Channel == q.Channel)
+            .WhereIF(recoveredPattern != null, x => x.Payload != null && x.Payload.Contains(recoveredPattern!))
+            .WhereIF(successPattern != null, x => x.Payload != null && x.Payload.Contains(successPattern!))
+            .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword),
+                x => (x.Payload != null && x.Payload.Contains(q.Keyword!)) || x.Channel.Contains(q.Keyword!))
+            .WhereIF(q.CreatedAtStart.HasValue, x => x.CreatedAt >= q.CreatedAtStart!.Value)
+            .WhereIF(q.CreatedAtEnd.HasValue, x => x.CreatedAt <= q.CreatedAtEnd!.Value);
+
+        var total = await query.CountAsync();
+        var rows = await query
+            .OrderBy(x => x.CreatedAt, OrderByType.Desc)
+            .OrderBy(x => x.Id, OrderByType.Desc)
+            .ToPageListAsync(q.Page, q.PageSize);
+
+        var list = rows.Select(x => MapToDto(x)).ToList();
+        return (total, list);
+    }
+
+    private AdoS8NotificationLogListItemDto MapToDto(AdoS8NotificationLog x)
+    {
+        var dto = new AdoS8NotificationLogListItemDto
+        {
+            Id = x.Id,
+            TenantId = x.TenantId,
+            FactoryId = x.FactoryId,
+            ExceptionId = x.ExceptionId,
+            Channel = x.Channel,
+            Payload = x.Payload,
+            CreatedAt = x.CreatedAt,
+        };
+
+        if (string.IsNullOrWhiteSpace(x.Payload)) return dto;
+
+        try
+        {
+            using var doc = JsonDocument.Parse(x.Payload);
+            var root = doc.RootElement;
+
+            if (root.TryGetProperty("success", out var sv) && (sv.ValueKind == JsonValueKind.True || sv.ValueKind == JsonValueKind.False))
+                dto.Success = sv.GetBoolean();
+
+            if (root.TryGetProperty("recovered", out var rv) && (rv.ValueKind == JsonValueKind.True || rv.ValueKind == JsonValueKind.False))
+                dto.Recovered = rv.GetBoolean();
+
+            if (root.TryGetProperty("targetCount", out var tc) && tc.ValueKind == JsonValueKind.Number)
+                dto.TargetCount = tc.GetInt32();
+
+            if (root.TryGetProperty("error", out var ev) && ev.ValueKind == JsonValueKind.String)
+                dto.ErrorMessage = ev.GetString();
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex, "S8NotificationLogQuery: payload parse failed for id={Id}", x.Id);
+        }
+
+        return dto;
+    }
+}

+ 166 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationPushAdapter.cs

@@ -0,0 +1,166 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.ApprovalFlow.Service;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// S8 通知推送适配器(S8-NOTIFY-PUSH-INTEGRATION-1)
+///
+/// 直接注入框架已有的 <see cref="INotifyPusher"/> 集合,按 channels 过滤后调用各 pusher。
+/// 不调用 <see cref="FlowNotifyService.NotifyUsers"/>,避免脏写 ApprovalFlowNotifyLog
+/// (该表 InstanceId 非空且无 BizType 列,不适合承载 S8 业务诊断日志)。
+///
+/// 每个渠道一次推送对应一条 <see cref="AdoS8NotificationLog"/>,作为 S8 自有诊断日志。
+/// 任何渠道失败仅 LogWarning,不抛出,确保通知失败不会阻断异常建单 / watch / scheduler。
+/// </summary>
+public class S8NotificationPushAdapter : ITransient
+{
+	private readonly IEnumerable<INotifyPusher> _pushers;
+	private readonly FlowNotifyConfigService _cfgService;
+	private readonly SqlSugarRepository<AdoS8NotificationLog> _logRep;
+	private readonly ILogger<S8NotificationPushAdapter> _logger;
+
+	public S8NotificationPushAdapter(
+		IEnumerable<INotifyPusher> pushers,
+		FlowNotifyConfigService cfgService,
+		SqlSugarRepository<AdoS8NotificationLog> logRep,
+		ILogger<S8NotificationPushAdapter> logger)
+	{
+		_pushers = pushers;
+		_cfgService = cfgService;
+		_logRep = logRep;
+		_logger = logger;
+	}
+
+	/// <summary>
+	/// 推送一次通知到指定 userIds + channels。
+	/// channels 大小写不敏感,与 <see cref="INotifyPusher.Channel"/> 匹配(SignalR/Email/Sms/DingTalk/WorkWeixin)。
+	/// notification.Context 推荐写入:exceptionId / exceptionNo / sceneCode / severity / status / sourceRuleCode / jumpUrl。
+	/// </summary>
+	public async Task PushAsync(
+		long tenantId,
+		long factoryId,
+		long? exceptionId,
+		List<long>? userIds,
+		FlowNotification notification,
+		List<string>? channels)
+	{
+		if (notification == null)
+		{
+			_logger.LogWarning("S8Push skipped: notification is null (exceptionId={ExceptionId})", exceptionId);
+			return;
+		}
+
+		var distinctUserIds = (userIds ?? new List<long>())
+			.Where(x => x > 0).Distinct().ToList();
+		if (distinctUserIds.Count == 0)
+		{
+			_logger.LogWarning("S8Push skipped: empty userIds (exceptionId={ExceptionId})", exceptionId);
+			return;
+		}
+
+		var wanted = new HashSet<string>(
+			(channels ?? new List<string>())
+				.Where(c => !string.IsNullOrWhiteSpace(c))
+				.Select(c => c.Trim()),
+			StringComparer.OrdinalIgnoreCase);
+		if (wanted.Count == 0)
+		{
+			_logger.LogInformation("S8Push skipped: empty channels (exceptionId={ExceptionId})", exceptionId);
+			return;
+		}
+
+		NotifyChannelConfig? cfg = null;
+		try { cfg = await _cfgService.GetEffectiveAsync(); }
+		catch (Exception ex)
+		{
+			_logger.LogWarning(ex, "S8Push: read NotifyChannelConfig failed; skip-disabled-check 将放行已注册 pusher");
+		}
+
+		var registered = _pushers.ToList();
+		var registeredChannels = new HashSet<string>(
+			registered.Select(p => p.Channel),
+			StringComparer.OrdinalIgnoreCase);
+
+		foreach (var pusher in registered)
+		{
+			if (!wanted.Contains(pusher.Channel)) continue;
+
+			if (cfg != null && !pusher.IsEnabled(cfg))
+			{
+				_logger.LogInformation("S8Push skip {Channel}: disabled by NotifyChannelConfig", pusher.Channel);
+				await SafeWriteLogAsync(tenantId, factoryId, exceptionId, pusher.Channel,
+					notification, distinctUserIds.Count, success: false, error: "channel_disabled_by_config");
+				continue;
+			}
+
+			FlowNotifyPushResult result;
+			try
+			{
+				result = await pusher.PushAsync(distinctUserIds, notification);
+			}
+			catch (Exception ex)
+			{
+				result = FlowNotifyPushResult.Fail(ex.Message);
+				_logger.LogWarning(ex, "S8Push pusher {Channel} threw", pusher.Channel);
+			}
+
+			if (!result.Success)
+				_logger.LogWarning("S8Push {Channel} fail: {Err}", pusher.Channel, result.ErrorMessage);
+
+			await SafeWriteLogAsync(tenantId, factoryId, exceptionId, pusher.Channel,
+				notification, result.ActualTargetCount, result.Success, result.ErrorMessage);
+		}
+
+		foreach (var w in wanted.Where(w => !registeredChannels.Contains(w)))
+			_logger.LogInformation("S8Push channel '{Channel}' has no registered pusher, skip", w);
+	}
+
+	private async Task SafeWriteLogAsync(
+		long tenantId, long factoryId, long? exceptionId,
+		string channel, FlowNotification n,
+		int targetCount, bool success, string? error)
+	{
+		try
+		{
+			var ctx = n.Context ?? new Dictionary<string, string?>();
+			static string? Get(Dictionary<string, string?> d, string k) => d.TryGetValue(k, out var v) ? v : null;
+			var summary = new
+			{
+				title = n.Title,
+				type = n.Type.ToString(),
+				bizType = n.BizType,
+				exceptionId = Get(ctx, "exceptionId"),
+				exceptionNo = Get(ctx, "exceptionNo"),
+				sceneCode = Get(ctx, "sceneCode"),
+				severity = Get(ctx, "severity"),
+				status = Get(ctx, "status"),
+				sourceRuleCode = Get(ctx, "sourceRuleCode"),
+				jumpUrl = Get(ctx, "jumpUrl"),
+				recovered = "true".Equals(Get(ctx, "recovered"), StringComparison.OrdinalIgnoreCase),
+				targetCount,
+				success,
+				error = string.IsNullOrEmpty(error)
+					? null
+					: (error.Length > 800 ? error.Substring(0, 800) + "..." : error),
+			};
+			var payload = System.Text.Json.JsonSerializer.Serialize(summary);
+			if (payload.Length > 4000) payload = payload.Substring(0, 3996) + "...]";
+
+			await _logRep.InsertAsync(new AdoS8NotificationLog
+			{
+				TenantId = tenantId,
+				FactoryId = factoryId,
+				ExceptionId = exceptionId,
+				Channel = channel,
+				Payload = payload,
+				CreatedAt = DateTime.Now,
+			});
+		}
+		catch (Exception ex)
+		{
+			_logger.LogWarning(ex, "S8Push: write AdoS8NotificationLog failed (channel={Channel})", channel);
+		}
+	}
+}

+ 100 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8RoleResolver.cs

@@ -0,0 +1,100 @@
+using Admin.NET.Core.Service;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// S8 本地 Role → UserIds 解析器。
+/// 不改 Admin.NET.Core;不新增 SysUserRoleService.GetUserIdListByRoleCode。
+/// 在 S8 侧自行完成 RoleCode → SysRole.Id → UserIds 的解析。
+///
+/// 兼容策略(与 ado_s8_notification_layer.target_role_ids 现状一致:表当前 0 行,
+/// SeedData 同时存在 RoleCode 命名(ROLE_ORDER_PLANNER)和 SysRole.Code 实存值(S8_01)):
+/// - 纯数字 token → 视为 SysRole.Id
+/// - 非数字 token → 视为 SysRole.Code
+/// - 找不到的 token:仅 LogWarning,不抛异常。
+/// </summary>
+public class S8RoleResolver : ITransient
+{
+	private readonly SqlSugarRepository<SysRole> _sysRoleRep;
+	private readonly SysUserRoleService _sysUserRoleService;
+	private readonly ILogger<S8RoleResolver> _logger;
+
+	public S8RoleResolver(
+		SqlSugarRepository<SysRole> sysRoleRep,
+		SysUserRoleService sysUserRoleService,
+		ILogger<S8RoleResolver> logger)
+	{
+		_sysRoleRep = sysRoleRep;
+		_sysUserRoleService = sysUserRoleService;
+		_logger = logger;
+	}
+
+	/// <summary>
+	/// 解析 RoleCode / RoleId 混合 token 列表为去重 UserId 列表。
+	/// </summary>
+	public async Task<List<long>> ResolveUserIdsAsync(IEnumerable<string>? roleTokens)
+	{
+		var result = new HashSet<long>();
+		if (roleTokens == null) return result.ToList();
+
+		var roleIds = new HashSet<long>();
+		var codes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+		foreach (var raw in roleTokens)
+		{
+			if (string.IsNullOrWhiteSpace(raw)) continue;
+			var t = raw.Trim();
+			if (long.TryParse(t, out var rid) && rid > 0) roleIds.Add(rid);
+			else codes.Add(t);
+		}
+
+		if (codes.Count > 0)
+		{
+			try
+			{
+				var codeList = codes.ToList();
+				var found = await _sysRoleRep.AsQueryable()
+					.Where(r => codeList.Contains(r.Code))
+					.Select(r => new { r.Id, r.Code })
+					.ToListAsync();
+				var foundCodes = new HashSet<string>(found.Select(f => f.Code ?? string.Empty), StringComparer.OrdinalIgnoreCase);
+				foreach (var c in codes)
+				{
+					if (!foundCodes.Contains(c))
+						_logger.LogWarning("S8RoleResolver: RoleCode '{Code}' not found in SysRole", c);
+				}
+				foreach (var f in found) roleIds.Add(f.Id);
+			}
+			catch (Exception ex)
+			{
+				_logger.LogWarning(ex, "S8RoleResolver: resolve RoleCode batch failed");
+			}
+		}
+
+		foreach (var rid in roleIds)
+		{
+			try
+			{
+				var ids = await _sysUserRoleService.GetUserIdList(rid);
+				foreach (var uid in ids)
+					if (uid > 0) result.Add(uid);
+			}
+			catch (Exception ex)
+			{
+				_logger.LogWarning(ex, "S8RoleResolver: GetUserIdList(roleId={RoleId}) failed", rid);
+			}
+		}
+
+		return result.ToList();
+	}
+
+	/// <summary>
+	/// 将 ado_s8_notification_layer.target_role_ids 这类 CSV 字段拆为 token 列表。
+	/// 兼容 ',' 和 ';' 分隔,trim + 去空。
+	/// </summary>
+	public static List<string> SplitTokens(string? csv)
+	{
+		if (string.IsNullOrWhiteSpace(csv)) return new List<string>();
+		return csv.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
+	}
+}

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

@@ -22,6 +22,7 @@ public class S8WatchSchedulerService : ITransient
     private readonly SqlSugarRepository<AdoS8Exception> _exceptionRep;
     private readonly SqlSugarRepository<AdoS8ExceptionType> _exceptionTypeRep;
     private readonly S8NotificationService _notificationService;
+    private readonly S8NotificationLayerResolver _notificationLayerResolver;
     private readonly S8ManualReportService _manualReportService;
     private readonly S8TimeoutRuleEvaluator _timeoutEvaluator;
     private readonly S8ShortageRuleEvaluator _shortageEvaluator;
@@ -53,6 +54,7 @@ public class S8WatchSchedulerService : ITransient
         SqlSugarRepository<AdoS8Exception> exceptionRep,
         SqlSugarRepository<AdoS8ExceptionType> exceptionTypeRep,
         S8NotificationService notificationService,
+        S8NotificationLayerResolver notificationLayerResolver,
         S8ManualReportService manualReportService,
         S8TimeoutRuleEvaluator timeoutEvaluator,
         S8ShortageRuleEvaluator shortageEvaluator,
@@ -67,6 +69,7 @@ public class S8WatchSchedulerService : ITransient
         _exceptionRep = exceptionRep;
         _exceptionTypeRep = exceptionTypeRep;
         _notificationService = notificationService;
+        _notificationLayerResolver = notificationLayerResolver;
         _manualReportService = manualReportService;
         _timeoutEvaluator = timeoutEvaluator;
         _shortageEvaluator = shortageEvaluator;
@@ -379,6 +382,7 @@ public class S8WatchSchedulerService : ITransient
             try
             {
                 var entity = await _manualReportService.CreateFromWatchAsync(dedup.Hit);
+                await TryDispatchLayerNotificationAsync(entity);
                 results.Add(new S8WatchCreationResult
                 {
                     DedupResult = dedup,
@@ -795,6 +799,10 @@ public class S8WatchSchedulerService : ITransient
                 RunId = runId, TriggerSource = DetectionTriggerSource,
                 Remark = "Rule no longer hit; recovered_at marked"
             });
+
+            // S8-NOTIFY-WIRE-RECOVERED-1:detection_log 已写入、recovered_at 已落库后挂入恢复通知。
+            // 通知失败仅 LogWarning,绝不影响恢复状态/检测日志。
+            await TryDispatchRecoveredLayerNotificationAsync(c.Id);
         }
 
         if (recoveredIds.Count > 0)
@@ -1419,6 +1427,7 @@ public class S8WatchSchedulerService : ITransient
                         .ExecuteCommandAsync();
                 }
                 await WriteDetectionLogAsync(BuildHitLog(tenantId, factoryId, rule, ruleType, hit, DetectResultCreated, entity.Id, runId));
+                await TryDispatchLayerNotificationAsync(entity);
                 results.Add(BuildCreatedResult(rule, hit, entity.Id));
             }
             catch (Exception ex)
@@ -1594,6 +1603,92 @@ public class S8WatchSchedulerService : ITransient
         if (raw < MinPollIntervalSeconds || raw > MaxPollIntervalSeconds) return DefaultPollIntervalSeconds;
         return raw;
     }
+
+    /// <summary>
+    /// S8-NOTIFY-WIRE-WATCH-1:异常自动建单成功后,非破坏性挂入通知分层路由。
+    /// 全程异常隔离:任何异常仅 LogWarning,不抛回主流程,不影响 detection_log / 事务 /
+    /// 异常状态机;该方法独立于 CreateFromWatchAsync / CreateFromHitAsync 的事务边界
+    /// (两者已 commit 后才返回 entity,故在此调用安全)。
+    /// sceneCode:先从 entity.SceneCode;空则 fallback 到 "S8_DEMO_DEFAULT"(demo 路径)。
+    /// severity:直接取 entity.Severity(CreateFromWatchAsync/CreateFromHitAsync 已保证非空)。
+    /// </summary>
+    private async Task TryDispatchLayerNotificationAsync(AdoS8Exception entity)
+    {
+        if (entity == null || entity.Id <= 0) return;
+        try
+        {
+            var sceneCode = string.IsNullOrWhiteSpace(entity.SceneCode) ? "S8_DEMO_DEFAULT" : entity.SceneCode;
+            var severity = string.IsNullOrWhiteSpace(entity.Severity) ? "MEDIUM" : entity.Severity;
+            var content =
+                $"异常 {entity.ExceptionCode}:{entity.Title}(场景 {sceneCode},严重度 {severity}" +
+                (string.IsNullOrWhiteSpace(entity.SourceRuleCode) ? "" : $",规则 {entity.SourceRuleCode}") + ")";
+
+            await _notificationLayerResolver.DispatchByLayerAsync(new S8NotificationLayerResolver.DispatchByLayerInput
+            {
+                TenantId = entity.TenantId,
+                FactoryId = entity.FactoryId,
+                ExceptionId = entity.Id,
+                ExceptionNo = entity.ExceptionCode,
+                SceneCode = sceneCode,
+                Severity = severity,
+                Title = entity.Title ?? string.Empty,
+                Content = content,
+                Status = entity.Status,
+                SourceRuleCode = entity.SourceRuleCode,
+                JumpUrl = $"/aidop/s8/exceptions/{entity.Id}",
+            });
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex, "notify_dispatch_throw exceptionId={ExceptionId}", entity.Id);
+        }
+    }
+
+    /// <summary>
+    /// S8-NOTIFY-WIRE-RECOVERED-1:异常恢复(recovered_at 已写入、RECOVERED detection_log 已落库)后,
+    /// 非破坏性挂入分层通知。全程异常隔离:任何异常仅 LogWarning,不抛回主流程,不影响 detection_log /
+    /// 状态机;call site 已在事务边界外(recovered 路径无事务)。
+    /// 重新读取 entity 拿场景/严重度/编号/状态/规则代码(恢复事件相对低频,1 次额外读可接受)。
+    /// </summary>
+    private async Task TryDispatchRecoveredLayerNotificationAsync(long exceptionId)
+    {
+        if (exceptionId <= 0) return;
+        try
+        {
+            var entity = await _exceptionRep.GetByIdAsync(exceptionId);
+            if (entity == null)
+            {
+                _logger.LogWarning("notify_recovered_dispatch_entity_missing exceptionId={ExceptionId}", exceptionId);
+                return;
+            }
+            var sceneCode = string.IsNullOrWhiteSpace(entity.SceneCode) ? "S8_DEMO_DEFAULT" : entity.SceneCode;
+            var severity = string.IsNullOrWhiteSpace(entity.Severity) ? "MEDIUM" : entity.Severity;
+            var title = $"【已恢复】{entity.ExceptionCode}";
+            var content =
+                $"异常 {entity.ExceptionCode} 已恢复,场景 {sceneCode},严重度 {severity}" +
+                (string.IsNullOrWhiteSpace(entity.SourceRuleCode) ? "" : $",规则 {entity.SourceRuleCode}");
+
+            await _notificationLayerResolver.DispatchByLayerAsync(new S8NotificationLayerResolver.DispatchByLayerInput
+            {
+                TenantId = entity.TenantId,
+                FactoryId = entity.FactoryId,
+                ExceptionId = entity.Id,
+                ExceptionNo = entity.ExceptionCode,
+                SceneCode = sceneCode,
+                Severity = severity,
+                Title = title,
+                Content = content,
+                Status = entity.Status,
+                SourceRuleCode = entity.SourceRuleCode,
+                JumpUrl = $"/aidop/s8/exceptions/{entity.Id}",
+                Recovered = true,
+            });
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex, "notify_recovered_dispatch_throw exceptionId={ExceptionId}", exceptionId);
+        }
+    }
 }
 
 public sealed class S8WatchExecutionRule