Forráskód Böngészése

refactor(s8): 将人员身份统一为系统账号,并清理旧运行数据

S8 此前有两套「人」:平台的 SysUser 与业务的 EmployeeMaster,靠
EmployeeMaster.sys_user_id 这一根软外键连接。真库取证显示这根线几乎没接上——
1105 名员工只有 8 个绑定了账号(0.7%),于是所有「经 EmployeeMaster.SysUserId
换算」的路径长期走在「换不到 → 静默跳过」分支上:认领后通知发不出去、
转派不联动审批流、升级审批通过后处理人原地不动、工作台整块显示
「当前账号未绑定员工主数据」。每一层都返回成功,页面上看不出任何异常。

同一套 ID 空间还被混装:assignee_id 34 个非空值里 20 个是 EmployeeMaster.RecID、
0 个是 SysUser.Id、14 个两边都不存在;timeline.operator_id 按动作分裂——
CLAIM 写 Employee.RecID,START_PROGRESS / VERIFY_* / CREATE 写 SysUser.Id,
于是「这一步是谁做的」在时间线上无法回答。

本次把 S8 的人收敛为唯一身份 SysUser.Id:

- 新增 S8UserScopeValidator 作为「账号在本租户是否可用」的唯一实现
  (主键 + TenantId + Status=Enable 三条判据;工厂不参与——真库
  factory_ref_id=1000 横跨两个租户)。认领 / 转派 / 复检 / 通知共用同一判据。
- 物理列显式改名:assignee_id / verifier_id / reporter_id → *_user_id,
  timeline.operator_id → operator_user_id。旧名两种解读都说得通,
  这正是它能装两套 ID 而无人察觉的原因。
- 删除全部 Employee 换算桥:GetEmployeeSysUserIdAsync、NotifyAssigneeAsync、
  ResolveCurrentEmployeeIdAsync、升级回调的 SysUserId 反查、工作台的
  ResolveEmployeeIds 与 EMPLOYEE_UNBOUND 分支。
- 认领语义收紧:处理人恒为当前登录账号,请求体不再接受 assignee。
  旧签名让持 ExceptionClaim(弱权限)的人能做 ExceptionAssign 才该做的事。
- First Claim Wins:并发认领改由一条带前置条件的 UPDATE 裁决
  (status 白名单 + assignee_user_id IS NULL),影响行数 0 即判负,
  消除「先 SELECT 判空再 UPDATE」之间两人同时成功的窗口。
- 「操作员账号绑定」整体退役:它唯一的用途是把员工绑成 S8 可用处理人,
  在账号即身份之后不再有对应概念。后端返回 410 且不再持有写 EmployeeMaster
  的能力,前端入口替换为退役说明。master-data/employees 同样 410,
  由 master-data/users 取代(后者列出的每一项都真的能用)。
- 转派不再自动改写责任部门:原链路是 Employee.Department → DepartmentMaster,
  SysUser 侧没有指向 DepartmentMaster 的关系,不用 SysOrg 硬凑近似值。
  责任部门由建单时确定,之后只能由人显式修改。

迁移 1.0.496 六阶段执行列改名与旧运行数据 CLEAN RESET。RESET 范围是硬编码
白名单,审批流按 BizType 定点删除(两张表由 S5/S6/S7 共用),平台主数据与
配置态一行不动并由脚本内断言兜底;watch_rule 只清运行态列、保留租户已配参数
与孤儿行。不做逐条身份迁移——混装两套 ID 空间的历史值没有可靠的迁移依据。

EmployeeMaster 表与其中已有的 8 条绑定原样保留:它属于 S0 业务主数据,不归 S8 处置。
YY968XX 3 napja
szülő
commit
27804344d3
30 módosított fájl, 1408 hozzáadás és 1072 törlés
  1. 1 1
      Web/package.json
  2. 0 20
      Web/src/views/aidop/s8/api/s8ConfigApi.ts
  3. 13 7
      Web/src/views/aidop/s8/api/s8ExceptionApi.ts
  4. 1 1
      Web/src/views/aidop/s8/api/s8ReportApi.ts
  5. 0 223
      Web/src/views/aidop/s8/config/S8OperatorBindingTab.vue
  6. 13 7
      Web/src/views/aidop/s8/config/S8RolePermissionConfigPage.vue
  7. 33 54
      Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue
  8. 4 18
      Web/src/views/approvalFlow/center/components/PendingList.vue
  9. 6 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  10. 364 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.496.sql
  11. 25 49
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8MasterDataScopeGuardTests.cs
  12. 425 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8SysUserOnlyIdentityTests.cs
  13. 37 16
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8TenantOnlyExceptionScopeTests.cs
  14. 32 41
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigBindingsController.cs
  15. 15 7
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ExceptionsController.cs
  16. 19 3
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8MasterDataController.cs
  17. 14 7
      server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8Dtos.cs
  18. 0 28
      server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8OperatorBindingDtos.cs
  19. 19 10
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8Exception.cs
  20. 12 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8ExceptionTimeline.cs
  21. 116 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8UserScopeValidator.cs
  22. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/ExceptionClosureBizHandler.cs
  23. 11 24
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/ExceptionEscalationBizHandler.cs
  24. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DecisionService.cs
  25. 38 51
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionService.cs
  26. 46 81
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ManualReportService.cs
  27. 31 50
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8MasterDataAdapter.cs
  28. 0 193
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8OperatorBindingService.cs
  29. 112 137
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs
  30. 19 37
      server/Plugins/Admin.NET.Plugin.AiDOP/Workbench/AidopWorkbenchService.cs

+ 1 - 1
Web/package.json

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

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

@@ -5,16 +5,6 @@ function unwrap<T>(res: { data: T }): T {
 	return res.data;
 }
 
-export interface S8OperatorBindingRow {
-	employeeId: number;
-	empCode: string;
-	name?: string | null;
-	factoryRefId: number;
-	sysUserId?: number | null;
-	sysUserName?: string | null;
-	bindStatus: 'BOUND' | 'UNBOUND';
-}
-
 export interface S8ConfigSysUserRow {
 	id: number;
 	account?: string | null;
@@ -342,16 +332,6 @@ export const s8ConfigApi = {
 		service.get<S8DatasetOption[]>('/api/aidop/s8/config/datasets', { params: withAidopTenantFactoryParams({}) }).then(unwrap),
 	importRolesFromSys: (params?: { tenantId?: number; factoryId?: number }) =>
 		service.post('/api/aidop/s8/config/roles/import-from-sys', null, { params: withAidopTenantFactoryParams(params ?? {}) }).then(unwrap),
-	operatorBindings: {
-		list: (params?: { factoryRefId?: number; bindStatus?: 'BOUND' | 'UNBOUND' | ''; keyword?: string }) =>
-			service.get<S8OperatorBindingRow[]>('/api/aidop/s8/config/operator-bindings', { params: withAidopTenantFactoryParams(params ?? {}) }).then(unwrap),
-		bind: (body: { employeeId: number; sysUserId: number; factoryRefId: number }) =>
-			service.post<S8OperatorBindingRow>('/api/aidop/s8/config/operator-bindings', withAidopTenantFactoryParams(body)).then(unwrap),
-		unbind: (employeeId: number) =>
-			service.delete(`/api/aidop/s8/config/operator-bindings/${employeeId}`, { params: withAidopTenantFactoryParams({}) }).then(unwrap),
-		sysUsers: (params?: { keyword?: string; excludeEmployeeId?: number }) =>
-			service.get<S8ConfigSysUserRow[]>('/api/aidop/s8/config/sys-users', { params: withAidopTenantFactoryParams(params ?? {}) }).then(unwrap),
-	},
 	exceptionTypes: {
 		list: (params?: { tenantId?: number; factoryId?: number; enabledOnly?: boolean }) =>
 			service.get<S8ExceptionTypeConfigRow[]>('/api/aidop/s8/config/exception-types', { params: withAidopTenantFactoryParams(params ?? {}) }).then(unwrap),

+ 13 - 7
Web/src/views/aidop/s8/api/s8ExceptionApi.ts

@@ -34,7 +34,8 @@ export interface S8ExceptionRow {
 	responsibleDeptName?: string | null;
 	occurrenceDeptId?: number | null;
 	occurrenceDeptName?: string | null;
-	assigneeId?: number | null;
+	/** 处理人账号 Id(SysUser.Id)。 */
+	assigneeUserId?: number | null;
 	slaDeadline?: string | null;
 	timeoutFlag: boolean;
 	createdAt: string;
@@ -44,7 +45,8 @@ export interface S8ExceptionRow {
 	isOverdueClosed?: boolean | null;
 	activeFlowInstanceId?: number | null;
 	activeFlowBizType?: string | null;
-	verifierId?: number | null;
+	/** 检验人账号 Id(SysUser.Id)。 */
+	verifierUserId?: number | null;
 	verifierName?: string | null;
 	verificationAssignedAt?: string | null;
 	verifiedAt?: string | null;
@@ -106,11 +108,15 @@ export const s8ExceptionApi = {
 		service.get(`/api/aidop/s8/exceptions/${id}/decisions`, { params: withAidopTenantFactoryParams({}), headers: silentHeaders(options?.silentError) }).then(unwrap),
 	evidences: (id: number, options?: { silentError?: boolean }) =>
 		service.get(`/api/aidop/s8/exceptions/${id}/evidences`, { params: withAidopTenantFactoryParams({}), headers: silentHeaders(options?.silentError) }).then(unwrap),
-	employees: (params?: { factoryRefId?: number }) =>
-		service.get('/api/aidop/s8/master-data/employees', { params: withAidopTenantFactoryParams(params ?? {}) }).then(unwrap),
-	claim: (id: number, body: { assigneeId: number; remark?: string }) =>
+	/**
+	 * S8 人员选择器:当前租户的启用系统账号。
+	 * 取代已退役的 master-data/employees —— 员工列表里绝大多数选项其实没绑账号、选了也用不了。
+	 */
+	users: () => service.get('/api/aidop/s8/master-data/users', { params: withAidopTenantFactoryParams({}) }).then(unwrap),
+	/** 认领:处理人恒为当前登录账号,请求体只带备注。 */
+	claim: (id: number, body: { remark?: string }) =>
 		service.post(`/api/aidop/s8/exceptions/${id}/claim`, withAidopTenantFactoryParams(body)).then(unwrap),
-	transfer: (id: number, body: { assigneeId: number; remark?: string }) =>
+	transfer: (id: number, body: { assigneeUserId: number; remark?: string }) =>
 		service.post(`/api/aidop/s8/exceptions/${id}/transfer`, withAidopTenantFactoryParams(body)).then(unwrap),
 	upgrade: (id: number, body?: { remark?: string }) =>
 		service.post(`/api/aidop/s8/exceptions/${id}/upgrade`, withAidopTenantFactoryParams(body ?? {})).then(unwrap),
@@ -120,7 +126,7 @@ export const s8ExceptionApi = {
 		service.post(`/api/aidop/s8/exceptions/${id}/start-progress`, withAidopTenantFactoryParams(body ?? {})).then(unwrap),
 	comment: (id: number, body?: { remark?: string }) =>
 		service.post(`/api/aidop/s8/exceptions/${id}/comment`, withAidopTenantFactoryParams(body ?? {})).then(unwrap),
-	submitVerification: (id: number, body: { verifierId: number; remark?: string }) =>
+	submitVerification: (id: number, body: { verifierUserId: number; remark?: string }) =>
 		service.post(`/api/aidop/s8/exceptions/${id}/submit-verification`, withAidopTenantFactoryParams(body)).then(unwrap),
 	approveVerification: (id: number, body: { remark?: string }) =>
 		service.post(`/api/aidop/s8/exceptions/${id}/approve-verification`, withAidopTenantFactoryParams(body)).then(unwrap),

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

@@ -14,7 +14,7 @@ export interface S8ReportCreate {
 	severity?: string;
 	occurrenceDeptId: number;
 	responsibleDeptId: number;
-	reporterId?: number | null;
+	// S8-SYSUSER-ONLY-1:提报人不再由前端传入,服务端一律取登录账号(后端 DTO 已删除该字段)。
 	relatedObjectCode?: string;
 	// TASK-022-MANUAL-REPORT-TEMPLATE-1:与后端 AdoS8ManualReportCreateDto 对齐,支持模板透传维度。
 	stageCode?: string;

+ 0 - 223
Web/src/views/aidop/s8/config/S8OperatorBindingTab.vue

@@ -1,223 +0,0 @@
-<script setup lang="ts" name="aidopS8OperatorBindingTab">
-import { computed, onMounted, reactive, ref } from 'vue';
-import { ElMessage, ElMessageBox } from 'element-plus';
-import { s8ConfigApi, type S8OperatorBindingRow, type S8ConfigSysUserRow } from '../api/s8ConfigApi';
-
-const filters = reactive({
-	factoryRefId: 1,
-	bindStatus: '' as '' | 'BOUND' | 'UNBOUND',
-	keyword: '',
-});
-
-const loading = ref(false);
-const rows = ref<S8OperatorBindingRow[]>([]);
-
-async function loadRows() {
-	loading.value = true;
-	try {
-		rows.value = (await s8ConfigApi.operatorBindings.list({
-			factoryRefId: filters.factoryRefId || undefined,
-			bindStatus: filters.bindStatus || undefined,
-			keyword: filters.keyword?.trim() || undefined,
-		})) as S8OperatorBindingRow[];
-	} catch {
-		// 拦截器已 toast;这里吞掉避免 Uncaught (in promise)
-	} finally {
-		loading.value = false;
-	}
-}
-
-const dialogVisible = ref(false);
-const dialogTarget = ref<S8OperatorBindingRow | null>(null);
-const dialogForm = reactive<{ sysUserId: number | null }>({ sysUserId: null });
-const sysUserOptions = ref<S8ConfigSysUserRow[]>([]);
-const sysUserKeyword = ref('');
-const sysUserLoading = ref(false);
-
-async function loadSysUsers() {
-	if (!dialogTarget.value) return;
-	sysUserLoading.value = true;
-	try {
-		sysUserOptions.value = (await s8ConfigApi.operatorBindings.sysUsers({
-			keyword: sysUserKeyword.value?.trim() || undefined,
-			excludeEmployeeId: dialogTarget.value.employeeId,
-		})) as S8ConfigSysUserRow[];
-	} catch {
-		// silent — interceptor toast already shown
-	} finally {
-		sysUserLoading.value = false;
-	}
-}
-
-function openBindDialog(row: S8OperatorBindingRow) {
-	dialogTarget.value = row;
-	dialogForm.sysUserId = row.sysUserId ?? null;
-	sysUserKeyword.value = '';
-	sysUserOptions.value = [];
-	dialogVisible.value = true;
-	loadSysUsers();
-}
-
-function isOptionDisabled(opt: S8ConfigSysUserRow): boolean {
-	if (!dialogTarget.value) return false;
-	if (opt.alreadyBoundEmployeeId == null) return false;
-	return opt.alreadyBoundEmployeeId !== dialogTarget.value.employeeId;
-}
-
-async function submitBind() {
-	if (!dialogTarget.value) return;
-	if (!dialogForm.sysUserId) {
-		ElMessage.warning('请选择系统账号');
-		return;
-	}
-	try {
-		await s8ConfigApi.operatorBindings.bind({
-			employeeId: dialogTarget.value.employeeId,
-			sysUserId: dialogForm.sysUserId,
-			factoryRefId: dialogTarget.value.factoryRefId,
-		});
-		ElMessage.success('绑定成功');
-		dialogVisible.value = false;
-		await loadRows();
-	} catch {
-		// 拦截器已弹出业务 4xx 文案
-	}
-}
-
-async function confirmUnbind(row: S8OperatorBindingRow) {
-	try {
-		await ElMessageBox.confirm(
-			`解绑后员工 ${row.name || row.empCode} 不能承担处理人/检验人,直到重新绑定。是否继续?`,
-			'解绑确认',
-			{ type: 'warning', confirmButtonText: '解绑', cancelButtonText: '取消' },
-		);
-	} catch {
-		return; // 用户取消
-	}
-	try {
-		await s8ConfigApi.operatorBindings.unbind(row.employeeId);
-		ElMessage.success('已解绑');
-		await loadRows();
-	} catch {
-		// 拦截器已 toast
-	}
-}
-
-const stats = computed(() => {
-	const total = rows.value.length;
-	const bound = rows.value.filter((r) => r.bindStatus === 'BOUND').length;
-	return { total, bound, unbound: total - bound };
-});
-
-onMounted(loadRows);
-</script>
-
-<template>
-	<div class="operator-binding-tab">
-		<el-form :inline="true" class="filter-bar">
-			<el-form-item label="工厂 ID">
-				<el-input-number v-model="filters.factoryRefId" :min="1" :step="1" :controls="false" style="width: 120px" />
-			</el-form-item>
-			<el-form-item label="绑定状态">
-				<el-select v-model="filters.bindStatus" placeholder="全部" clearable style="width: 140px">
-					<el-option label="全部" value="" />
-					<el-option label="已绑定" value="BOUND" />
-					<el-option label="未绑定" value="UNBOUND" />
-				</el-select>
-			</el-form-item>
-			<el-form-item label="工号 / 姓名">
-				<el-input v-model="filters.keyword" placeholder="empCode / name" clearable style="width: 200px" @keyup.enter="loadRows" />
-			</el-form-item>
-			<el-form-item>
-				<el-button type="primary" @click="loadRows">查询</el-button>
-				<el-button @click="filters.bindStatus = ''; filters.keyword = ''; loadRows()">重置</el-button>
-			</el-form-item>
-		</el-form>
-
-		<div class="stats-bar">
-			<el-tag>共 {{ stats.total }}</el-tag>
-			<el-tag type="success">已绑定 {{ stats.bound }}</el-tag>
-			<el-tag type="warning">未绑定 {{ stats.unbound }}</el-tag>
-		</div>
-
-		<el-table v-loading="loading" :data="rows" stripe size="small" style="width: 100%">
-			<el-table-column prop="empCode" label="工号" width="160" />
-			<el-table-column prop="name" label="姓名" width="160" />
-			<el-table-column label="绑定账号" min-width="200">
-				<template #default="{ row }">
-					<span v-if="row.bindStatus === 'BOUND'">{{ row.sysUserName }} <span class="muted">({{ row.sysUserId }})</span></span>
-					<span v-else class="muted">—</span>
-				</template>
-			</el-table-column>
-			<el-table-column label="状态" width="120">
-				<template #default="{ row }">
-					<el-tag v-if="row.bindStatus === 'BOUND'" type="success">已绑定</el-tag>
-					<el-tag v-else type="warning">未绑定</el-tag>
-				</template>
-			</el-table-column>
-			<el-table-column label="操作" width="200">
-				<template #default="{ row }">
-					<el-button v-if="row.bindStatus === 'UNBOUND'" type="primary" size="small" @click="openBindDialog(row)">绑定</el-button>
-					<template v-else>
-						<el-button size="small" @click="openBindDialog(row)">换绑</el-button>
-						<el-button type="danger" size="small" @click="confirmUnbind(row)">解绑</el-button>
-					</template>
-				</template>
-			</el-table-column>
-		</el-table>
-
-		<el-dialog v-model="dialogVisible" :title="`绑定系统账号 - ${dialogTarget?.name ?? ''}`" width="520px">
-			<el-form label-width="100px">
-				<el-form-item label="员工">
-					<span>{{ dialogTarget?.empCode }} / {{ dialogTarget?.name }}</span>
-				</el-form-item>
-				<el-form-item label="系统账号" required>
-					<el-select
-						v-model="dialogForm.sysUserId"
-						filterable
-						remote
-						:remote-method="(q: string) => { sysUserKeyword = q; loadSysUsers(); }"
-						:loading="sysUserLoading"
-						placeholder="搜索 account / realName"
-						style="width: 100%"
-					>
-						<el-option
-							v-for="opt in sysUserOptions"
-							:key="opt.id"
-							:value="opt.id"
-							:label="`${opt.realName ?? ''}(${opt.account ?? ''})`"
-							:disabled="isOptionDisabled(opt)"
-						>
-							<div style="display:flex;justify-content:space-between;align-items:center">
-								<span>{{ opt.realName }} <span class="muted">({{ opt.account }})</span></span>
-								<el-tag v-if="isOptionDisabled(opt)" size="small" type="info">已被绑定</el-tag>
-							</div>
-						</el-option>
-					</el-select>
-				</el-form-item>
-			</el-form>
-			<template #footer>
-				<el-button @click="dialogVisible = false">取消</el-button>
-				<el-button type="primary" @click="submitBind">确定</el-button>
-			</template>
-		</el-dialog>
-	</div>
-</template>
-
-<style scoped>
-.operator-binding-tab {
-	padding: 8px 0;
-}
-.filter-bar {
-	margin-bottom: 8px;
-}
-.stats-bar {
-	display: flex;
-	gap: 8px;
-	margin-bottom: 8px;
-}
-.muted {
-	color: #999;
-	font-size: 12px;
-}
-</style>

+ 13 - 7
Web/src/views/aidop/s8/config/S8RolePermissionConfigPage.vue

@@ -8,7 +8,6 @@ import { ref } from 'vue';
 import { ElMessage } from 'element-plus';
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 import S8CrudConfigPage from '../components/config/S8CrudConfigPage.vue';
-import S8OperatorBindingTab from './S8OperatorBindingTab.vue';
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 import { s8ConfigApi } from '../api/s8ConfigApi';
 
@@ -53,13 +52,20 @@ async function syncFromSys(reload: () => void) {
 			<div class="subtitle">S8 / 配置 / 操作员账号绑定</div>
 		</div>
 		<!--
-			S8-CONFIG-CLEANUP-DEMO-1:仅展示操作员账号绑定,隐藏旧版角色权限 Tab
-			permission_codes 由 S8RoleConfigService.cs:74 写硬编码 "[]",无业务分支消费;
-			S8 正式鉴权沿用 SysUserRole + SysRoleMenu。
-			保留:S8OperatorBindingTab、s8ConfigApi.operatorBindings.*、columns/fields/buildDefault/
-			      syncFromSys/S8CrudConfigPage import / endpoint /api/aidop/s8/config/roles
+			S8-SYSUSER-ONLY-1:本页已退役
+			它原来做的是「把业务员工绑定到系统账号」,好让这个员工能被 S8 当成处理人。
+			现在 S8 的人就是系统账号,这个绑定不再有任何作用 ——
+			留着页面只会让管理员继续以为「必须先绑定才能用 S8」,从而做一堆无效操作。
+			后端 /api/aidop/s8/config/operator-bindings 已返回 410
 		-->
-		<S8OperatorBindingTab />
+		<el-result icon="info" title="操作员账号绑定已退役">
+			<template #sub-title>
+				<div style="line-height: 1.9">
+					S8 的处理人 / 检验人 / 提报人现在<strong>直接就是系统账号</strong>,不再需要绑定员工主数据。<br />
+					要让某个人能使用 S8:在<strong>系统账号管理</strong>中开通账号并授予 S8 相应权限即可。
+				</div>
+			</template>
+		</el-result>
 	</div>
 </template>
 

+ 33 - 54
Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue

@@ -75,18 +75,21 @@ const detail = ref<Record<string, any> | null>(null);
 const timeline = ref<{ id: number; actionCode: string; actionLabel: string; operatorName?: string | null; actionRemark?: string | null; createdAt: string }[]>([]);
 const decisions = ref<S8DecisionRow[]>([]);
 const evidences = ref<S8EvidenceRow[]>([]);
-const employees = ref<{ id: number; name: string; empCode?: string; bindStatus?: 'BOUND' | 'UNBOUND'; sysUserName?: string | null; sysUserId?: number | null }[]>([]);
-
-const selfEmployee = computed(() => {
-	const uid = Number(userStore.userInfos?.id ?? 0);
-	if (!uid) return null;
-	return employees.value.find((e) => Number(e.sysUserId ?? 0) === uid) ?? null;
+// S8-SYSUSER-ONLY-1:人员选择器的选项就是系统账号本身。
+// 原来这里是员工列表 + bindStatus BOUND/UNBOUND,未绑定的员工选了也用不了(占绝大多数)。
+const users = ref<{ id: number; name: string; account?: string }[]>([]);
+
+/** 认领对话框里"处理人"的展示名 —— 就是当前登录账号,不再需要反查员工。 */
+const currentUserLabel = computed(() => {
+	const info = userStore.userInfos as any;
+	return info?.realName || info?.name || info?.account || '当前账号';
 });
 const dialogMode = ref('');
 const dialogVisible = ref(false);
 const submitting = ref(false);
 const actionForm = reactive({
-	assigneeId: undefined as number | undefined,
+	// 转派目标 / 检验人,均为 SysUser.Id。认领不用它 —— 处理人由后端取登录账号。
+	targetUserId: undefined as number | undefined,
 	remark: '',
 });
 
@@ -105,13 +108,13 @@ const canTransfer = computed(() => ['ASSIGNED', 'IN_PROGRESS', 'PENDING_VERIFICA
 const canUpgrade = computed(() => false);
 const canReject = computed(() => ['NEW', 'ASSIGNED', 'IN_PROGRESS'].includes(currentStatus.value));
 const canSubmitVerification = computed(
-	() => currentStatus.value === 'IN_PROGRESS' && !!detail.value?.assigneeId
+	() => currentStatus.value === 'IN_PROGRESS' && !!detail.value?.assigneeUserId
 );
 const canApproveVerification = computed(
-	() => currentStatus.value === 'PENDING_VERIFICATION' && !!detail.value?.verifierId
+	() => currentStatus.value === 'PENDING_VERIFICATION' && !!detail.value?.verifierUserId
 );
 const canRejectVerification = computed(
-	() => currentStatus.value === 'PENDING_VERIFICATION' && !!detail.value?.verifierId
+	() => currentStatus.value === 'PENDING_VERIFICATION' && !!detail.value?.verifierUserId
 );
 
 function goBack() {
@@ -145,16 +148,15 @@ async function loadDetail() {
 	}
 }
 
-async function loadEmployees() {
-	const factoryRefId = resolveAidopFactoryId() ?? Number(detail.value?.factoryId ?? 0);
-	employees.value = (await s8ExceptionApi.employees({ factoryRefId })) as typeof employees.value;
+async function loadUsers() {
+	users.value = (await s8ExceptionApi.users()) as typeof users.value;
 }
 
 function openAction(mode: string) {
 	dialogMode.value = mode;
 	dialogVisible.value = true;
 	actionForm.remark = '';
-	actionForm.assigneeId = mode === 'claim' ? selfEmployee.value?.id : undefined;
+	actionForm.targetUserId = undefined;
 }
 
 function actionTitle() {
@@ -180,11 +182,8 @@ async function onApprovalCompleted() {
 async function submitAction() {
 	const id = Number(route.params.id);
 	if (!id) return;
-	if (dialogMode.value === 'claim' && !actionForm.assigneeId) {
-		ElMessage.warning('当前账号未绑定员工,无法认领,请联系管理员');
-		return;
-	}
-	if (dialogMode.value === 'transfer' && !actionForm.assigneeId) {
+	// 认领无需任何前置校验:处理人就是当前登录账号,后端从登录上下文取。
+	if (dialogMode.value === 'transfer' && !actionForm.targetUserId) {
 		ElMessage.warning('请选择处理人');
 		return;
 	}
@@ -196,20 +195,20 @@ async function submitAction() {
 	submitting.value = true;
 		try {
 			if (dialogMode.value === 'claim') {
-				await s8ExceptionApi.claim(id, { assigneeId: actionForm.assigneeId!, remark: actionForm.remark || undefined });
+				await s8ExceptionApi.claim(id, { remark: actionForm.remark || undefined });
 			} else if (dialogMode.value === 'startProgress') {
 				await s8ExceptionApi.startProgress(id, { remark: actionForm.remark || undefined });
 			} else if (dialogMode.value === 'transfer') {
-				await s8ExceptionApi.transfer(id, { assigneeId: actionForm.assigneeId!, remark: actionForm.remark || undefined });
+				await s8ExceptionApi.transfer(id, { assigneeUserId: actionForm.targetUserId!, remark: actionForm.remark || undefined });
 			} else if (dialogMode.value === 'upgrade') {
 			await s8ExceptionApi.upgrade(id, { remark: actionForm.remark || undefined });
 		} else if (dialogMode.value === 'reject') {
 			await s8ExceptionApi.reject(id, { remark: actionForm.remark || undefined });
 			} else if (dialogMode.value === 'submitVerification') {
-				if (!actionForm.assigneeId) { ElMessage.warning('请选择检验人'); return; }
+				if (!actionForm.targetUserId) { ElMessage.warning('请选择检验人'); return; }
 				await s8ExceptionApi.submitVerification(
 					id,
-					{ verifierId: actionForm.assigneeId, remark: actionForm.remark || undefined }
+					{ verifierUserId: actionForm.targetUserId, remark: actionForm.remark || undefined }
 				);
 			} else if (dialogMode.value === 'approveVerification') {
 				await s8ExceptionApi.approveVerification(id, { remark: actionForm.remark || undefined });
@@ -279,7 +278,7 @@ function formatPerson(name: string | null | undefined, id: number | string | nul
 
 onMounted(async () => {
 	await loadDetail();
-	await loadEmployees();
+	await loadUsers();
 });
 </script>
 
@@ -310,10 +309,10 @@ onMounted(async () => {
 						<el-descriptions-item label="发生部门">{{ formatDept(detail.occurrenceDeptName, detail.occurrenceDeptId) }}</el-descriptions-item>
 						<el-descriptions-item label="责任部门">{{ formatDept(detail.responsibleDeptName, detail.responsibleDeptId) }}</el-descriptions-item>
 						<!-- S8-EXCEPTION-FIRST-VIEW-CLOSURE-1(CC-2):人员名解析不到时不再回显裸 EmployeeMaster.RecID -->
-						<el-descriptions-item label="处理人">{{ formatPerson(detail.assigneeName, detail.assigneeId) }}</el-descriptions-item>
-						<el-descriptions-item label="检验人">{{ formatPerson(detail.verifierName, detail.verifierId) }}</el-descriptions-item>
+						<el-descriptions-item label="处理人">{{ formatPerson(detail.assigneeName, detail.assigneeUserId) }}</el-descriptions-item>
+						<el-descriptions-item label="检验人">{{ formatPerson(detail.verifierName, detail.verifierUserId) }}</el-descriptions-item>
 						<el-descriptions-item label="检验结果">{{ detail.verificationResult || '—' }}</el-descriptions-item>
-						<el-descriptions-item label="提报人">{{ formatPerson(detail.reporterName, detail.reporterId) }}</el-descriptions-item>
+						<el-descriptions-item label="提报人">{{ formatPerson(detail.reporterName, detail.reporterUserId) }}</el-descriptions-item>
 						<el-descriptions-item>
 							<template #label>
 								<S8FieldTooltip field-key="priority_level" fallback-label="优先级" />
@@ -487,39 +486,19 @@ onMounted(async () => {
 		<el-dialog v-model="dialogVisible" :title="actionTitle()" width="520px">
 			<el-form label-width="100px">
 				<el-form-item v-if="dialogMode === 'claim'" label="处理人">
-					<span>{{ selfEmployee?.name || '—' }}<span style="color:#999;font-size:12px;margin-left:6px">(当前账号)</span></span>
+					<span>{{ currentUserLabel }}<span style="color:#999;font-size:12px;margin-left:6px">(当前账号)</span></span>
 				</el-form-item>
 				<el-form-item v-else-if="dialogMode === 'transfer'" label="处理人" required>
-					<el-select v-model="actionForm.assigneeId" style="width: 100%" filterable clearable>
-						<el-option
-							v-for="item in employees"
-							:key="item.id"
-							:label="item.name + (item.bindStatus === 'UNBOUND' ? '(未绑定账号)' : '')"
-							:value="item.id"
-							:disabled="item.bindStatus === 'UNBOUND'"
-						>
-							<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
-								<span>{{ item.name }} <span style="color:#999;font-size:12px">{{ item.empCode ? '(' + item.empCode + ')' : '' }}</span></span>
-								<el-tag v-if="item.bindStatus === 'UNBOUND'" size="small" type="warning">未绑定</el-tag>
-								<el-tag v-else-if="item.bindStatus === 'BOUND'" size="small" type="success">已绑定</el-tag>
-							</div>
+					<el-select v-model="actionForm.targetUserId" style="width: 100%" filterable clearable>
+						<el-option v-for="item in users" :key="item.id" :label="item.name" :value="item.id">
+							<span>{{ item.name }} <span style="color:#999;font-size:12px">{{ item.account ? '(' + item.account + ')' : '' }}</span></span>
 						</el-option>
 					</el-select>
 				</el-form-item>
 				<el-form-item v-if="dialogMode === 'submitVerification'" label="检验人" required>
-					<el-select v-model="actionForm.assigneeId" style="width: 100%" filterable clearable>
-						<el-option
-							v-for="item in employees"
-							:key="item.id"
-							:label="item.name + (item.bindStatus === 'UNBOUND' ? '(未绑定账号)' : '')"
-							:value="item.id"
-							:disabled="item.bindStatus === 'UNBOUND'"
-						>
-							<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
-								<span>{{ item.name }} <span style="color:#999;font-size:12px">{{ item.empCode ? '(' + item.empCode + ')' : '' }}</span></span>
-								<el-tag v-if="item.bindStatus === 'UNBOUND'" size="small" type="warning">未绑定</el-tag>
-								<el-tag v-else-if="item.bindStatus === 'BOUND'" size="small" type="success">已绑定</el-tag>
-							</div>
+					<el-select v-model="actionForm.targetUserId" style="width: 100%" filterable clearable>
+						<el-option v-for="item in users" :key="item.id" :label="item.name" :value="item.id">
+							<span>{{ item.name }} <span style="color:#999;font-size:12px">{{ item.account ? '(' + item.account + ')' : '' }}</span></span>
 						</el-option>
 					</el-select>
 				</el-form-item>

+ 4 - 18
Web/src/views/approvalFlow/center/components/PendingList.vue

@@ -76,27 +76,13 @@
 <script setup lang="ts">
 import { ref } from 'vue';
 import { useRouter } from 'vue-router';
-import { ElMessageBox } from 'element-plus';
-import { useUserInfo } from '/@/stores/userInfo';
-import { s8ExceptionApi } from '/@/views/aidop/s8/api/s8ExceptionApi';
 
 const router = useRouter();
-const userStore = useUserInfo();
 
-async function goToException(bizId: number | string) {
-	try {
-		const list = (await s8ExceptionApi.employees()) as Array<{ sysUserId?: number | null }>;
-		const uid = Number(userStore.userInfos?.id ?? 0);
-		const bound = uid && list.some((e) => Number(e.sysUserId ?? 0) === uid);
-		if (!bound) {
-			await ElMessageBox.alert('当前账号未绑定员工,找管理员处理', '无法处理异常', { type: 'warning' });
-			return;
-		}
-	} catch {
-		// 查询失败按未绑定处理,避免进入后无法操作
-		await ElMessageBox.alert('无法校验员工绑定状态,请联系管理员', '无法处理异常', { type: 'warning' });
-		return;
-	}
+// S8-SYSUSER-ONLY-1:原先跳转前要先拉员工列表校验"当前账号是否绑定员工",
+// 未绑定就弹「找管理员处理」并拦下。S8 的人现在就是系统账号,这道门已无对应概念 ——
+// 能收到这条待办本身就说明账号有效,再拦一次只会把人挡在自己的待办外面。
+function goToException(bizId: number | string) {
 	router.push(`/aidop/s8/exceptions/${bizId}`);
 }
 

+ 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.495</AssemblyVersion>
-    <FileVersion>1.0.495</FileVersion>
-    <Version>1.0.495</Version>
+    <AssemblyVersion>1.0.496</AssemblyVersion>
+    <FileVersion>1.0.496</FileVersion>
+    <Version>1.0.496</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -688,6 +688,9 @@
     <None Update="UpdateScripts\1.0.494.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.496.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 364 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.496.sql

@@ -0,0 +1,364 @@
+-- =====================================================================================
+-- 1.0.496  S8-SYSUSER-ONLY-1 · 人员身份统一为系统账号 + 旧运行数据 CLEAN RESET
+--
+-- 背景(真库取证,只读):
+--   · ado_s8_exception.assignee_id 34 个非空值里只有 20 个能解析到 EmployeeMaster.RecID,
+--     0 个能解析到 SysUser.Id,另有 14 行是 assignee_id = 1(两边都不存在的脏值)。
+--   · ado_s8_exception_timeline.operator_id 同一列混装两套 ID 空间:
+--     CLAIM 写 EmployeeMaster.RecID(19 行),START_PROGRESS / VERIFY_* / CREATE 写 SysUser.Id。
+--   · EmployeeMaster 1105 行只有 8 行绑定了 sys_user_id(0.7%),
+--     所有「经 EmployeeMaster.SysUserId 换算」的通知与审批流转办路径实际上长期静默失败。
+--
+-- 本次动作:物理列改名为显式 *_user_id,并清空旧运行数据(不做逐条身份迁移 —— 业务已确认
+-- 旧 S8 历史数据无保留要求;混装两套 ID 空间的历史值也没有可靠的迁移依据)。
+--
+-- 幂等:六个阶段全部可重复执行。第二次运行时 PHASE 3 清 0 行、PHASE 4 全部跳过。
+--
+-- 安全边界:
+--   · RESET 表为**硬编码白名单**,绝不使用 LIKE 'ado_s8_%'(否则将来新增的表会被无声删掉)。
+--   · 只清 S8 自己的运行数据。SysUser / SysRole / SysUserRole / SysOrg /
+--     DepartmentMaster / EmployeeMaster 一行不动。
+--   · 配置态表(exception_type / scene_config / notification_layer / role_permission_config /
+--     dashboard_cell_config / watch_rule 的参数列)一行不动。
+--   · 审批流表按 **BizType 白名单**定点删除 S8 自己发起的实例与任务,
+--     绝不整表清空(ApprovalFlowInstance / Task 由 S5/S6/S7 共用)。
+-- =====================================================================================
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 1 · PREFLIGHT
+--   目的:把「执行前长什么样」原样记下来。出问题时这张表是唯一的现场。
+-- ─────────────────────────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS `ado_s8_sysuser_only_reset_1_0_496` (
+  `id`          BIGINT       NOT NULL AUTO_INCREMENT,
+  `phase`       VARCHAR(16)  NOT NULL COMMENT 'BEFORE / AFTER',
+  `table_name`  VARCHAR(96)  NOT NULL,
+  `table_class` VARCHAR(32)  NOT NULL COMMENT 'RUNTIME / FLOW_RUNTIME / POLICY_RUNTIME_STATE',
+  `row_count`   BIGINT       NOT NULL,
+  `noted_at`    DATETIME     NOT NULL,
+  PRIMARY KEY (`id`),
+  KEY `idx_reset_1_0_496_phase` (`phase`, `table_name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='1.0.496 CLEAN RESET 前后行数留证';
+
+-- 重复执行时先清掉上一轮的留证,避免同一 phase 出现多份互相矛盾的计数。
+DELETE FROM `ado_s8_sysuser_only_reset_1_0_496`;
+
+-- ── RESET 白名单(就是下面这 7 张,没有第 8 张)──
+INSERT INTO `ado_s8_sysuser_only_reset_1_0_496` (`phase`,`table_name`,`table_class`,`row_count`,`noted_at`)
+SELECT 'BEFORE','ado_s8_exception','RUNTIME',COUNT(*),NOW() FROM `ado_s8_exception`
+UNION ALL SELECT 'BEFORE','ado_s8_exception_timeline','RUNTIME',COUNT(*),NOW() FROM `ado_s8_exception_timeline`
+UNION ALL SELECT 'BEFORE','ado_s8_detection_log','RUNTIME',COUNT(*),NOW() FROM `ado_s8_detection_log`
+UNION ALL SELECT 'BEFORE','ado_s8_rule_detection_state','RUNTIME',COUNT(*),NOW() FROM `ado_s8_rule_detection_state`
+UNION ALL SELECT 'BEFORE','ado_s8_notification_log','RUNTIME',COUNT(*),NOW() FROM `ado_s8_notification_log`
+UNION ALL SELECT 'BEFORE','ado_s8_evidence','RUNTIME',COUNT(*),NOW() FROM `ado_s8_evidence`
+UNION ALL SELECT 'BEFORE','ado_s8_decision_record','RUNTIME',COUNT(*),NOW() FROM `ado_s8_decision_record`;
+
+-- 审批流:只统计 S8 自己的三种 BizType,别的模块的行不在本次视野里。
+INSERT INTO `ado_s8_sysuser_only_reset_1_0_496` (`phase`,`table_name`,`table_class`,`row_count`,`noted_at`)
+SELECT 'BEFORE','ApprovalFlowInstance(S8 BizType)','FLOW_RUNTIME',COUNT(*),NOW()
+  FROM `ApprovalFlowInstance`
+ WHERE `BizType` IN ('EXCEPTION_REPORT','EXCEPTION_ESCALATION','EXCEPTION_CLOSURE')
+UNION ALL
+SELECT 'BEFORE','ApprovalFlowTask(S8 BizType)','FLOW_RUNTIME',COUNT(*),NOW()
+  FROM `ApprovalFlowTask` t
+  JOIN `ApprovalFlowInstance` i ON i.`Id` = t.`InstanceId`
+ WHERE i.`BizType` IN ('EXCEPTION_REPORT','EXCEPTION_ESCALATION','EXCEPTION_CLOSURE');
+
+-- 规则:只清运行态列,参数列(含租户已配的默认部门)保持不动,见 PHASE 3 说明。
+INSERT INTO `ado_s8_sysuser_only_reset_1_0_496` (`phase`,`table_name`,`table_class`,`row_count`,`noted_at`)
+SELECT 'BEFORE','ado_s8_watch_rule(last_run_at NOT NULL)','POLICY_RUNTIME_STATE',COUNT(*),NOW()
+  FROM `ado_s8_watch_rule` WHERE `last_run_at` IS NOT NULL;
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 2 · ARCHIVE / DIAGNOSTICS
+--   不做整表备份(业务已确认旧数据无保留要求,全量备份只会把混装 ID 的脏数据换个地方留着)。
+--   只留一份**证据摘要**:这次到底删掉了什么形状的身份数据。
+-- ─────────────────────────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS `ado_s8_idspace_evidence_1_0_496` (
+  `id`            BIGINT      NOT NULL AUTO_INCREMENT,
+  `metric`        VARCHAR(96) NOT NULL,
+  `metric_value`  BIGINT      NOT NULL,
+  `noted_at`      DATETIME    NOT NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='1.0.496 清理前的 ID 空间混用取证';
+
+DELETE FROM `ado_s8_idspace_evidence_1_0_496`;
+
+-- 只在旧列还在时取证;改过名之后这些指标已无处可取,跳过即可(幂等)。
+SET @has_old_assignee := (SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_exception' AND COLUMN_NAME = 'assignee_id');
+
+SET @sql := IF(@has_old_assignee = 1, '
+INSERT INTO `ado_s8_idspace_evidence_1_0_496` (`metric`,`metric_value`,`noted_at`)
+SELECT ''assignee_notnull'', COUNT(*), NOW() FROM `ado_s8_exception` WHERE `assignee_id` IS NOT NULL
+UNION ALL SELECT ''assignee_resolves_employee'', COUNT(*), NOW()
+  FROM `ado_s8_exception` e JOIN `EmployeeMaster` m ON m.`RecID` = e.`assignee_id`
+UNION ALL SELECT ''assignee_resolves_sysuser'', COUNT(*), NOW()
+  FROM `ado_s8_exception` e JOIN `SysUser` u ON u.`Id` = e.`assignee_id`
+UNION ALL SELECT ''assignee_dangling'', COUNT(*), NOW()
+  FROM `ado_s8_exception` e
+ WHERE e.`assignee_id` IS NOT NULL
+   AND NOT EXISTS (SELECT 1 FROM `EmployeeMaster` m WHERE m.`RecID` = e.`assignee_id`)
+   AND NOT EXISTS (SELECT 1 FROM `SysUser` u WHERE u.`Id` = e.`assignee_id`)
+', 'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 3 · RUNTIME DATA RESET
+--
+--   删除顺序:明细 → 主表 → 审批流。全部按白名单点名,没有一条通配。
+-- ─────────────────────────────────────────────────────────────────────────────────────
+
+-- ① S8 异常运行数据
+DELETE FROM `ado_s8_exception_timeline`;
+DELETE FROM `ado_s8_evidence`;
+DELETE FROM `ado_s8_decision_record`;
+DELETE FROM `ado_s8_exception`;
+
+-- ② 规则检测运行数据(不清会让规则以为"这些对象我已经报过了",重启后一条都建不出来)
+DELETE FROM `ado_s8_detection_log`;
+DELETE FROM `ado_s8_rule_detection_state`;
+
+-- ③ 通知投递日志(全部指向已删除的异常)
+DELETE FROM `ado_s8_notification_log`;
+
+-- ④ 审批流:**只删 S8 自己的三种 BizType**。
+--    ApprovalFlowInstance / ApprovalFlowTask 由 S5/S6/S7 共用,整表清空会毁掉别的模块。
+--    先删子表(Task)再删父表(Instance),否则 Task 会成为查不到来源的孤儿。
+DELETE t FROM `ApprovalFlowTask` t
+  JOIN `ApprovalFlowInstance` i ON i.`Id` = t.`InstanceId`
+ WHERE i.`BizType` IN ('EXCEPTION_REPORT','EXCEPTION_ESCALATION','EXCEPTION_CLOSURE');
+
+DELETE FROM `ApprovalFlowInstance`
+ WHERE `BizType` IN ('EXCEPTION_REPORT','EXCEPTION_ESCALATION','EXCEPTION_CLOSURE');
+
+-- ⑤ 规则的**运行态列**清零。
+--    刻意只清运行态、不动参数列,也不删行:
+--      · watch_rule 上没有任何人员身份列,本次改造不要求重建它;
+--      · 删行会连带删掉租户已配的默认部门(实测 2 个租户已配 245 / 253),
+--        而 Provisioning 只能把它们恢复成 NULL —— 那是纯粹的损失,换不来任何东西;
+--      · 也会删掉无代码定义的孤儿行,与 S8RuleProvisioningService「孤儿不删、不改、不启用」相矛盾。
+--    enabled 一列同样不碰:真库 11 行当前全部为 0,Provisioning 新建行也恒为 false,
+--    因此 reset 之后 Rule 01 仍然是停用状态 —— §十八 要的就是这个结果。
+UPDATE `ado_s8_watch_rule`
+   SET `last_run_at` = NULL,
+       `last_status` = NULL,
+       `last_error` = NULL,
+       `last_duration_ms` = NULL,
+       `last_run_id` = NULL,
+       `next_run_at` = NULL,
+       `lock_token` = NULL,
+       `locked_by` = NULL,
+       `lock_until` = NULL,
+       `running_started_at` = NULL,
+       `consecutive_failure_count` = 0
+ WHERE `last_run_at` IS NOT NULL
+    OR `last_status` IS NOT NULL
+    OR `last_run_id` IS NOT NULL
+    OR `lock_token` IS NOT NULL
+    OR `running_started_at` IS NOT NULL
+    OR `consecutive_failure_count` <> 0;
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 4 · SCHEMA IDENTITY CHANGE
+--
+--   三种到场状态都要能收敛(幂等):
+--     (a) 只有旧列        → CHANGE COLUMN 改名
+--     (b) 新旧列同时存在  → CodeFirst 已经把新列加进来了;此时数据已在 PHASE 3 清空,
+--                          直接丢掉旧列即可,不需要也不应该做数据搬运
+--     (c) 只有新列        → 已经改过,跳过
+-- ─────────────────────────────────────────────────────────────────────────────────────
+
+-- ── ado_s8_exception.assignee_id → assignee_user_id ──
+SET @old := (SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception' AND COLUMN_NAME='assignee_id');
+SET @new := (SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception' AND COLUMN_NAME='assignee_user_id');
+SET @sql := CASE
+  WHEN @old = 1 AND @new = 0 THEN 'ALTER TABLE `ado_s8_exception` CHANGE COLUMN `assignee_id` `assignee_user_id` BIGINT NULL COMMENT ''处理人账号Id(SysUser.Id)'''
+  WHEN @old = 1 AND @new = 1 THEN 'ALTER TABLE `ado_s8_exception` DROP COLUMN `assignee_id`'
+  ELSE 'DO 0' END;
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ── ado_s8_exception.verifier_id → verifier_user_id ──
+SET @old := (SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception' AND COLUMN_NAME='verifier_id');
+SET @new := (SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception' AND COLUMN_NAME='verifier_user_id');
+SET @sql := CASE
+  WHEN @old = 1 AND @new = 0 THEN 'ALTER TABLE `ado_s8_exception` CHANGE COLUMN `verifier_id` `verifier_user_id` BIGINT NULL COMMENT ''检验人账号Id(SysUser.Id)'''
+  WHEN @old = 1 AND @new = 1 THEN 'ALTER TABLE `ado_s8_exception` DROP COLUMN `verifier_id`'
+  ELSE 'DO 0' END;
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ── ado_s8_exception.reporter_id → reporter_user_id ──
+SET @old := (SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception' AND COLUMN_NAME='reporter_id');
+SET @new := (SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception' AND COLUMN_NAME='reporter_user_id');
+SET @sql := CASE
+  WHEN @old = 1 AND @new = 0 THEN 'ALTER TABLE `ado_s8_exception` CHANGE COLUMN `reporter_id` `reporter_user_id` BIGINT NULL COMMENT ''提报人账号Id(SysUser.Id)'''
+  WHEN @old = 1 AND @new = 1 THEN 'ALTER TABLE `ado_s8_exception` DROP COLUMN `reporter_id`'
+  ELSE 'DO 0' END;
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ── ado_s8_exception_timeline.operator_id → operator_user_id ──
+SET @old := (SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception_timeline' AND COLUMN_NAME='operator_id');
+SET @new := (SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception_timeline' AND COLUMN_NAME='operator_user_id');
+SET @sql := CASE
+  WHEN @old = 1 AND @new = 0 THEN 'ALTER TABLE `ado_s8_exception_timeline` CHANGE COLUMN `operator_id` `operator_user_id` BIGINT NULL COMMENT ''操作人账号Id(SysUser.Id);系统动作为 NULL'''
+  WHEN @old = 1 AND @new = 1 THEN 'ALTER TABLE `ado_s8_exception_timeline` DROP COLUMN `operator_id`'
+  ELSE 'DO 0' END;
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ── 索引重建 ──
+--
+-- 这里有一个**实测踩到过的坑**,不要简化成「索引名不存在才建」:
+-- CodeFirst 若先于本脚本把 assignee_user_id 建了出来,PHASE 4 会走 (b) DROP COLUMN 分支,
+-- 而 MySQL 在 DROP COLUMN 时会把该列**从复合索引里摘掉、索引本身保留**。
+-- 结果是 idx_s8_exception_assignee_status 退化成 (tenant_id, factory_id, status, is_deleted) ——
+-- 索引名还在,覆盖的列却少了最关键的那一个,「我的待办」从此走不到索引,
+-- 而且没有任何报错。因此判据必须是「索引里有没有那一列」,不是「索引名在不在」。
+SET @has_col := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception'
+    AND INDEX_NAME='idx_s8_exception_assignee_status' AND COLUMN_NAME='assignee_user_id');
+SET @has_idx := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception'
+    AND INDEX_NAME='idx_s8_exception_assignee_status');
+SET @sql := IF(@has_idx = 1 AND @has_col = 0,
+  'ALTER TABLE `ado_s8_exception` DROP INDEX `idx_s8_exception_assignee_status`', 'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+SET @has_idx := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception'
+    AND INDEX_NAME='idx_s8_exception_assignee_status');
+SET @sql := IF(@has_idx = 0,
+  'ALTER TABLE `ado_s8_exception` ADD INDEX `idx_s8_exception_assignee_status` (`tenant_id`,`factory_id`,`assignee_user_id`,`status`,`is_deleted`)',
+  'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @has_col := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception'
+    AND INDEX_NAME='idx_s8_exception_verifier_status' AND COLUMN_NAME='verifier_user_id');
+SET @has_idx := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception'
+    AND INDEX_NAME='idx_s8_exception_verifier_status');
+SET @sql := IF(@has_idx = 1 AND @has_col = 0,
+  'ALTER TABLE `ado_s8_exception` DROP INDEX `idx_s8_exception_verifier_status`', 'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+SET @has_idx := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception'
+    AND INDEX_NAME='idx_s8_exception_verifier_status');
+SET @sql := IF(@has_idx = 0,
+  'ALTER TABLE `ado_s8_exception` ADD INDEX `idx_s8_exception_verifier_status` (`tenant_id`,`factory_id`,`verifier_user_id`,`status`,`is_deleted`)',
+  'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 5 · POSTCHECK
+--   任何一条不成立就抛错中止,不让"半成功"通过。
+-- ─────────────────────────────────────────────────────────────────────────────────────
+INSERT INTO `ado_s8_sysuser_only_reset_1_0_496` (`phase`,`table_name`,`table_class`,`row_count`,`noted_at`)
+SELECT 'AFTER','ado_s8_exception','RUNTIME',COUNT(*),NOW() FROM `ado_s8_exception`
+UNION ALL SELECT 'AFTER','ado_s8_exception_timeline','RUNTIME',COUNT(*),NOW() FROM `ado_s8_exception_timeline`
+UNION ALL SELECT 'AFTER','ado_s8_detection_log','RUNTIME',COUNT(*),NOW() FROM `ado_s8_detection_log`
+UNION ALL SELECT 'AFTER','ado_s8_rule_detection_state','RUNTIME',COUNT(*),NOW() FROM `ado_s8_rule_detection_state`
+UNION ALL SELECT 'AFTER','ado_s8_notification_log','RUNTIME',COUNT(*),NOW() FROM `ado_s8_notification_log`
+UNION ALL SELECT 'AFTER','ado_s8_evidence','RUNTIME',COUNT(*),NOW() FROM `ado_s8_evidence`
+UNION ALL SELECT 'AFTER','ado_s8_decision_record','RUNTIME',COUNT(*),NOW() FROM `ado_s8_decision_record`;
+
+INSERT INTO `ado_s8_sysuser_only_reset_1_0_496` (`phase`,`table_name`,`table_class`,`row_count`,`noted_at`)
+SELECT 'AFTER','ApprovalFlowInstance(S8 BizType)','FLOW_RUNTIME',COUNT(*),NOW()
+  FROM `ApprovalFlowInstance`
+ WHERE `BizType` IN ('EXCEPTION_REPORT','EXCEPTION_ESCALATION','EXCEPTION_CLOSURE')
+UNION ALL
+SELECT 'AFTER','ApprovalFlowTask(S8 BizType)','FLOW_RUNTIME',COUNT(*),NOW()
+  FROM `ApprovalFlowTask` t
+  JOIN `ApprovalFlowInstance` i ON i.`Id` = t.`InstanceId`
+ WHERE i.`BizType` IN ('EXCEPTION_REPORT','EXCEPTION_ESCALATION','EXCEPTION_CLOSURE')
+UNION ALL
+SELECT 'AFTER','ado_s8_watch_rule(last_run_at NOT NULL)','POLICY_RUNTIME_STATE',COUNT(*),NOW()
+  FROM `ado_s8_watch_rule` WHERE `last_run_at` IS NOT NULL;
+
+-- ① 白名单表必须为 0
+SET @left := (SELECT COALESCE(SUM(`row_count`),0) FROM `ado_s8_sysuser_only_reset_1_0_496` WHERE `phase`='AFTER');
+DROP TABLE IF EXISTS `tmp_s8_496_guard`;
+CREATE TABLE `tmp_s8_496_guard` (
+  probe BIGINT NOT NULL,
+  CONSTRAINT s8_496_BLOCKED_reset_tables_not_empty CHECK (probe = 0)
+) COMMENT '1.0.496 断言门禁;通过后自动删除';
+INSERT INTO `tmp_s8_496_guard` (probe) VALUES (@left);
+DROP TABLE IF EXISTS `tmp_s8_496_guard`;
+
+-- ② 四个身份列必须已改名到位(新列在、旧列不在)
+SET @bad := (SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE()
+    AND ((TABLE_NAME='ado_s8_exception' AND COLUMN_NAME IN ('assignee_id','verifier_id','reporter_id'))
+      OR (TABLE_NAME='ado_s8_exception_timeline' AND COLUMN_NAME='operator_id')));
+DROP TABLE IF EXISTS `tmp_s8_496_guard`;
+CREATE TABLE `tmp_s8_496_guard` (
+  probe BIGINT NOT NULL,
+  CONSTRAINT s8_496_BLOCKED_old_identity_column_still_present CHECK (probe = 0)
+) COMMENT '1.0.496 断言门禁;通过后自动删除';
+INSERT INTO `tmp_s8_496_guard` (probe) VALUES (@bad);
+DROP TABLE IF EXISTS `tmp_s8_496_guard`;
+
+SET @ok := (SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE()
+    AND ((TABLE_NAME='ado_s8_exception' AND COLUMN_NAME IN ('assignee_user_id','verifier_user_id','reporter_user_id'))
+      OR (TABLE_NAME='ado_s8_exception_timeline' AND COLUMN_NAME='operator_user_id')));
+DROP TABLE IF EXISTS `tmp_s8_496_guard`;
+CREATE TABLE `tmp_s8_496_guard` (
+  probe BIGINT NOT NULL,
+  CONSTRAINT s8_496_BLOCKED_new_identity_column_missing CHECK (probe = 0)
+) COMMENT '1.0.496 断言门禁;通过后自动删除';
+INSERT INTO `tmp_s8_496_guard` (probe) VALUES ((4 - @ok));
+DROP TABLE IF EXISTS `tmp_s8_496_guard`;
+
+-- ③ 两个身份索引必须真的覆盖身份列(不是「索引名还在」就算数,见 PHASE 4 的坑)
+SET @idx_ok := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception'
+    AND ((INDEX_NAME='idx_s8_exception_assignee_status' AND COLUMN_NAME='assignee_user_id')
+      OR (INDEX_NAME='idx_s8_exception_verifier_status' AND COLUMN_NAME='verifier_user_id')));
+DROP TABLE IF EXISTS `tmp_s8_496_guard`;
+CREATE TABLE `tmp_s8_496_guard` (
+  probe BIGINT NOT NULL,
+  CONSTRAINT s8_496_BLOCKED_identity_index_lost_its_column CHECK (probe = 0)
+) COMMENT '1.0.496 断言门禁;通过后自动删除';
+INSERT INTO `tmp_s8_496_guard` (probe) VALUES ((2 - @idx_ok));
+DROP TABLE IF EXISTS `tmp_s8_496_guard`;
+
+-- ④ 平台主数据与配置态一行都不能少 —— 这是本脚本最重要的一条断言。
+--    RESET 允许清运行数据,但只要下面任何一张表被清空,就说明白名单被写错了。
+SET @masters := (SELECT (SELECT COUNT(*) FROM `SysUser`)
+                      + (SELECT COUNT(*) FROM `SysRole`)
+                      + (SELECT COUNT(*) FROM `EmployeeMaster`)
+                      + (SELECT COUNT(*) FROM `DepartmentMaster`));
+DROP TABLE IF EXISTS `tmp_s8_496_guard`;
+CREATE TABLE `tmp_s8_496_guard` (
+  probe BIGINT NOT NULL,
+  CONSTRAINT s8_496_BLOCKED_platform_master_wiped CHECK (probe = 0)
+) COMMENT '1.0.496 断言门禁;通过后自动删除';
+INSERT INTO `tmp_s8_496_guard` (probe) VALUES (IF(@masters = 0, 1, 0));
+DROP TABLE IF EXISTS `tmp_s8_496_guard`;
+
+SET @configs := (SELECT (SELECT COUNT(*) FROM `ado_s8_exception_type`)
+                      + (SELECT COUNT(*) FROM `ado_s8_scene_config`)
+                      + (SELECT COUNT(*) FROM `ado_s8_notification_layer`)
+                      + (SELECT COUNT(*) FROM `ado_s8_role_permission_config`)
+                      + (SELECT COUNT(*) FROM `ado_s8_watch_rule`));
+DROP TABLE IF EXISTS `tmp_s8_496_guard`;
+CREATE TABLE `tmp_s8_496_guard` (
+  probe BIGINT NOT NULL,
+  CONSTRAINT s8_496_BLOCKED_product_config_wiped CHECK (probe = 0)
+) COMMENT '1.0.496 断言门禁;通过后自动删除';
+INSERT INTO `tmp_s8_496_guard` (probe) VALUES (IF(@configs = 0, 1, 0));
+DROP TABLE IF EXISTS `tmp_s8_496_guard`;
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 6 · IDEMPOTENT RERUN GUARD
+--   第二次执行时:PHASE 3 的 DELETE 命中 0 行、PHASE 4 全部落到 'DO 0'、
+--   PHASE 5 的断言依旧成立。下面这条只是把「本脚本已收敛」这件事也记进留证表。
+-- ─────────────────────────────────────────────────────────────────────────────────────
+INSERT INTO `ado_s8_sysuser_only_reset_1_0_496` (`phase`,`table_name`,`table_class`,`row_count`,`noted_at`)
+VALUES ('AFTER','__migration_converged__','GUARD',1,NOW());

+ 25 - 49
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8MasterDataScopeGuardTests.cs

@@ -29,7 +29,6 @@ public class S8MasterDataScopeGuardTests
     private static readonly string PluginRoot =
         Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
 
-    private const string BindingSrc = "Service/S8/S8OperatorBindingService.cs";
     private const string MasterDataSrc = "Service/S8/S8MasterDataAdapter.cs";
 
     private static string ReadSource(string relativePath)
@@ -53,8 +52,14 @@ public class S8MasterDataScopeGuardTests
 
     // ───────────────────────── 控制器层 ─────────────────────────
 
+    /// <summary>
+    /// 仍在服务的控制器必须从可信作用域取租户。
+    ///
+    /// <para><c>AdoS8ConfigBindingsController</c> 已整体退役(全部 Action 返回 410),
+    /// 不再注入任何服务、也不再解析作用域 —— 一个不查库、不写库的墓碑入口
+    /// 要作用域解析器没有意义。它的守卫改由 <see cref="OperatorBinding_IsRetired"/> 承担。</para>
+    /// </summary>
     [Theory]
-    [InlineData(typeof(AdoS8ConfigBindingsController))]
     [InlineData(typeof(AdoS8MasterDataController))]
     public void Controller_InjectsTrustedScopeResolver(Type controller)
     {
@@ -85,12 +90,10 @@ public class S8MasterDataScopeGuardTests
 
     /// <summary>五个入口必须以 S8TrustedScope 作首参,且不得再出现 factoryRefId 形参。</summary>
     [Theory]
-    [InlineData(typeof(S8OperatorBindingService), "ListAsync")]
-    [InlineData(typeof(S8OperatorBindingService), "ListSysUsersAsync")]
-    [InlineData(typeof(S8OperatorBindingService), "BindAsync")]
-    [InlineData(typeof(S8OperatorBindingService), "UnbindAsync")]
     [InlineData(typeof(S8MasterDataAdapter), "GetDepartmentsAsync")]
-    [InlineData(typeof(S8MasterDataAdapter), "GetEmployeesAsync")]
+    // S8-SYSUSER-ONLY-1:GetEmployeesAsync → GetOperatorUsersAsync(员工下拉换成账号下拉);
+    // S8OperatorBindingService 四个入口随该服务一并退役,见 OperatorBinding_IsRetired。
+    [InlineData(typeof(S8MasterDataAdapter), "GetOperatorUsersAsync")]
     [InlineData(typeof(S8MasterDataAdapter), "GetLinesAsync")]
     public void ServiceEntryPoints_TakeTrustedScopeAndRejectClientFactory(Type service, string method)
     {
@@ -110,36 +113,23 @@ public class S8MasterDataScopeGuardTests
     // ───────────────────────── 服务层查询谓词 ─────────────────────────
 
     /// <summary>
-    /// 绑定服务里所有 ClearFilter 都必须收口到唯一的 ScopedEmployees 帮助方法,
-    /// 且该方法带 TenantId == scope.TenantId 强制边界(S8-TENANT-ONLY-BATCH6:由 FactoryRefId 改判)。
-    /// 出现第二处裸 ClearFilter 即视为回退。
+    /// S8-SYSUSER-ONLY-1:绑定服务整体退役,原先三条「ClearFilter 必须收口」「SysUser 查询必须带租户」
+    /// 「绑定/解绑必须按作用域绑行」的断言合并为一条 —— <b>这个写 EmployeeMaster 的写路径不得复活</b>。
+    ///
+    /// <para>那三条守的是同一件事:一个能把任意员工绑成本租户处理人的写入口,必须有租户边界。
+    /// 现在这个入口本身不存在了,守卫的对象也就从「边界写对没有」变成「入口有没有被引回来」。</para>
     /// </summary>
     [Fact]
-    public void OperatorBinding_ClearFilterIsFunneledThroughScopedHelper()
+    public void OperatorBinding_IsRetired()
     {
-        var lines = CodeLines(BindingSrc);
-
-        var clearFilterLines = lines.Where(l => l.Contains("ClearFilter()", StringComparison.Ordinal)).ToList();
-        Assert.Single(clearFilterLines);
-
-        var src = string.Join('\n', lines);
-        Assert.Contains("_empRep.AsQueryable().ClearFilter()", src);
-        Assert.Contains(".Where(x => x.TenantId == scope.TenantId)", src);
-        // 工厂判据不得复活:factory_ref_id = 1000 在真库横跨两个租户,按它绑边界即跨租户。
-        Assert.DoesNotContain("x.FactoryRefId == scope.FactoryId", src);
-
-        // 旧的「可选工厂谓词」写法必须消失。
-        Assert.DoesNotContain("WhereIF(factoryRefId.HasValue", src);
-        Assert.DoesNotContain("factoryRefId!.Value", src);
-    }
-
-    /// <summary>SysUser 相关查询必须按可信租户,不得裸主键集合。</summary>
-    [Fact]
-    public void OperatorBinding_SysUserQueriesAreTenantBound()
-    {
-        var src = string.Join('\n', CodeLines(BindingSrc));
-        Assert.Contains("u.TenantId == scope.TenantId", src);
-        Assert.Contains("x.TenantId == scope.TenantId", src);
+        Assert.False(File.Exists(Path.Combine(PluginRoot, "Service", "S8", "S8OperatorBindingService.cs")),
+            "S8OperatorBindingService 已退役,不得复活");
+
+        var src = string.Join('\n', CodeLines("Controllers/S8/AdoS8ConfigBindingsController.cs"));
+        Assert.Contains("Status410Gone", src);
+        // 退役后的入口不应该还保有写主数据的能力。
+        Assert.DoesNotContain("EmployeeMaster", src);
+        Assert.DoesNotContain("_empRep", src);
     }
 
     /// <summary>主数据三个入口的边界必须来自 scope 的租户,不得来自入参、更不得退回工厂。</summary>
@@ -148,7 +138,7 @@ public class S8MasterDataScopeGuardTests
     {
         var src = string.Join('\n', CodeLines(MasterDataSrc));
 
-        Assert.Contains("x.TenantId == scope.TenantId && x.IsActive", src);
+        Assert.Contains("x.TenantId == scope.TenantId", src);
         Assert.Contains("u.TenantId == scope.TenantId", src);
         Assert.DoesNotContain("x.FactoryRefId == scope.FactoryId", src);
 
@@ -157,18 +147,4 @@ public class S8MasterDataScopeGuardTests
         Assert.DoesNotContain("factoryRefId.HasValue", src);
     }
 
-    /// <summary>
-    /// 绑定 / 解绑必须先按可信作用域绑行再写。写路径上不得出现「只按主键取员工」的写法
-    /// (旧实现 <c>_empRep.GetFirstAsync(x =&gt; x.Id == dto.EmployeeId)</c> 正是如此)。
-    /// </summary>
-    [Fact]
-    public void BindAndUnbind_ResolveEmployeeThroughScopedLookup()
-    {
-        var src = string.Join('\n', CodeLines(BindingSrc));
-
-        Assert.Contains("ScopedEmployee(scope, dto.EmployeeId)", src);
-        Assert.Contains("ScopedEmployee(scope, employeeId)", src);
-        Assert.DoesNotContain("_empRep.GetFirstAsync(x => x.Id == dto.EmployeeId)", src);
-        Assert.DoesNotContain("_empRep.GetFirstAsync(x => x.Id == employeeId)", src);
-    }
 }

+ 425 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8SysUserOnlyIdentityTests.cs

@@ -0,0 +1,425 @@
+using System.Reflection;
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Controllers.S8;
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-SYSUSER-ONLY-1:S8 的「人」统一为系统账号(<c>SysUser.Id</c>)。
+///
+/// <para><b>本批要消灭的失败形态</b>(全部有真库取证):
+/// ① <c>assignee_id</c> 34 个非空值里 20 个是 EmployeeMaster.RecID、0 个是 SysUser.Id、
+///    14 个两边都不存在 —— 同一列长期装着两种不同含义的数字;
+/// ② <c>timeline.operator_id</c> 按动作分裂:CLAIM 写 Employee.RecID,其余写 SysUser.Id,
+///    于是「这一步是谁做的」在时间线上根本无法回答;
+/// ③ 所有「经 EmployeeMaster.SysUserId 换算」的路径(通知、审批流转办、工作台)
+///    在 1105 名员工只有 8 个绑定账号的现实下几乎总是走「换不到 → 静默跳过」分支。</para>
+///
+/// <para><b>本文件的断言以反向为主</b>:这类回归的共同特征是"加回一个换算就恢复原状、
+/// 而且全链路仍然报成功",正向断言抓不到。因此这里守的是
+/// <b>某些符号/类型/字段不得再出现</b>。</para>
+/// </summary>
+public class S8SysUserOnlyIdentityTests
+{
+    private const BindingFlags Instance =
+        BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
+
+    private static readonly string PluginRoot =
+        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    /// <summary>只取可执行代码行:注释里为留档会复述旧写法,不应算违规。</summary>
+    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>
+    /// 读迁移脚本的<b>可执行部分</b>。与 <see cref="CodeOnly"/> 同一原则:
+    /// 脚本注释里要说明「为什么不能用某种写法」,必然会复述那种写法本身,
+    /// 不剥注释的话这条守卫会被自己的说明文字触发。
+    /// </summary>
+    private static string MigrationSqlCodeOnly()
+    {
+        var full = Path.GetFullPath(Path.Combine(
+            PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.496.sql"));
+        Assert.True(File.Exists(full), $"迁移脚本不存在:{full}");
+        return string.Join('\n', File.ReadAllLines(full)
+            .Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
+    }
+
+    private static string SugarColumnName(Type type, string property)
+    {
+        var prop = type.GetProperty(property);
+        Assert.NotNull(prop);
+        var attr = prop!.GetCustomAttributes()
+            .Single(a => a.GetType().Name == "SugarColumn");
+        return (string)attr.GetType().GetProperty("ColumnName")!.GetValue(attr)!;
+    }
+
+    // ═══════════════════════════════════════════════════════════════════════════════
+    // T1–T5 · 四个身份字段全部是 SysUserId
+    // ═══════════════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// T1 / T2:处理人字段名与物理列名都显式带 <c>user</c>。
+    ///
+    /// <para>断言列名而不只断言属性名:属性改了但物理列还叫 <c>assignee_id</c> 的话,
+    /// 读库的人、写 SQL 的人、看 dump 的人依旧无从判断这里装的是哪种 ID。</para>
+    /// </summary>
+    [Fact]
+    public void T1_T2_Assignee_IsExplicitlyUserId()
+    {
+        Assert.NotNull(typeof(AdoS8Exception).GetProperty("AssigneeUserId"));
+        Assert.Null(typeof(AdoS8Exception).GetProperty("AssigneeId"));
+        Assert.Equal("assignee_user_id", SugarColumnName(typeof(AdoS8Exception), "AssigneeUserId"));
+    }
+
+    /// <summary>T3:检验人同上。</summary>
+    [Fact]
+    public void T3_Verifier_IsExplicitlyUserId()
+    {
+        Assert.NotNull(typeof(AdoS8Exception).GetProperty("VerifierUserId"));
+        Assert.Null(typeof(AdoS8Exception).GetProperty("VerifierId"));
+        Assert.Equal("verifier_user_id", SugarColumnName(typeof(AdoS8Exception), "VerifierUserId"));
+    }
+
+    /// <summary>T4:提报人同上。</summary>
+    [Fact]
+    public void T4_Reporter_IsExplicitlyUserId()
+    {
+        Assert.NotNull(typeof(AdoS8Exception).GetProperty("ReporterUserId"));
+        Assert.Null(typeof(AdoS8Exception).GetProperty("ReporterId"));
+        Assert.Equal("reporter_user_id", SugarColumnName(typeof(AdoS8Exception), "ReporterUserId"));
+    }
+
+    /// <summary>
+    /// T5:时间线操作人同上。这一条对应的是**实测已发生**的混装:
+    /// CLAIM 19 行是 Employee.RecID,VERIFY_SUBMITTED 里 7 行 Employee / 8 行 SysUser。
+    /// </summary>
+    [Fact]
+    public void T5_TimelineOperator_IsExplicitlyUserId()
+    {
+        Assert.NotNull(typeof(AdoS8ExceptionTimeline).GetProperty("OperatorUserId"));
+        Assert.Null(typeof(AdoS8ExceptionTimeline).GetProperty("OperatorId"));
+        Assert.Equal("operator_user_id", SugarColumnName(typeof(AdoS8ExceptionTimeline), "OperatorUserId"));
+    }
+
+    // ═══════════════════════════════════════════════════════════════════════════════
+    // T6–T9 · 账号校验:租户唯一边界,工厂不参与
+    // ═══════════════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// T6 / T7 / T8:校验判据是「主键 + 本租户 + 启用」三条,一条不少、一条不多。
+    ///
+    /// <para>用源码断言而非行为断言,是因为这三条的失效方式都是**放宽谓词**:
+    /// 少一条 <c>TenantId ==</c> 就能把别家账号设成本租户的处理人,
+    /// 少一条 <c>Status == Enable</c> 就能把已停用账号派单出去。
+    /// 两者都不会报错,只会静默扩大可选集合。</para>
+    /// </summary>
+    [Fact]
+    public void T6_T7_T8_UserValidation_RequiresTenantAndActive()
+    {
+        var code = CodeOnly("Infrastructure/S8/S8UserScopeValidator.cs");
+        Assert.Contains("x.TenantId == tenantId && x.Status == StatusEnum.Enable", code);
+        // 跨租户与不存在共用同一文案,避免账号探测。
+        Assert.Contains("不存在或已停用", code);
+    }
+
+    /// <summary>
+    /// T9:工厂与工厂号一律不参与人员判定。
+    ///
+    /// <para>真库取证:<c>factory_ref_id = 1000</c> 同时属于 824585161322565 与 797403760988229,
+    /// 按工厂校验会把另一租户的人判为合法。这一条断言的正是「不许再把它加回来」。</para>
+    /// </summary>
+    [Fact]
+    public void T9_UserValidation_HasNoFactoryPredicate()
+    {
+        var code = CodeOnly("Infrastructure/S8/S8UserScopeValidator.cs");
+        Assert.DoesNotContain("FactoryId", code);
+        Assert.DoesNotContain("FactoryRefId", code);
+    }
+
+    /// <summary>
+    /// 账号合法性判定必须<b>只有一份实现</b>。
+    ///
+    /// <para>与 <c>IS8DepartmentScopeValidator</c> 同一动机:认领 / 转派 / 复检 / 通知
+    /// 都要回答同一个问题,各写一套 SQL 迟早出现「转派检查通过、通知却发不出去」。</para>
+    /// </summary>
+    [Fact]
+    public void UserValidity_HasSingleImplementation()
+    {
+        var impls = typeof(IS8UserScopeValidator).Assembly.GetTypes()
+            .Where(t => t is { IsAbstract: false, IsInterface: false }
+                        && typeof(IS8UserScopeValidator).IsAssignableFrom(t))
+            .ToList();
+        Assert.Single(impls);
+        Assert.Equal(typeof(S8UserScopeValidator), impls[0]);
+
+        // 别处不得再手写「本租户 + 启用」的账号查询。
+        foreach (var f in new[]
+                 {
+                     "Service/S8/S8TaskFlowService.cs",
+                     "Service/S8/S8ExceptionService.cs",
+                     "Service/S8/S8ManualReportService.cs",
+                 })
+        {
+            Assert.DoesNotContain("Status == StatusEnum.Enable", CodeOnly(f));
+        }
+    }
+
+    // ═══════════════════════════════════════════════════════════════════════════════
+    // T10–T12 · Employee 已彻底退出人员主链
+    // ═══════════════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// T10 / T11 / T12 / T21:人员主链上不得再出现任何 Employee 换算。
+    ///
+    /// <para>这是本批最重要的一条。桥的危险不在于它错,而在于它**换不到时的行为是静默跳过** ——
+    /// 通知不发、审批流不转办、工作台整块显示"未绑定员工",而调用方一律看到成功。</para>
+    /// </summary>
+    [Theory]
+    [InlineData("Service/S8/S8TaskFlowService.cs")]        // T10 Claim / T11 Transfer / T12 Verify
+    [InlineData("Service/S8/S8ManualReportService.cs")]    // 提报人
+    [InlineData("Service/S8/S8ExceptionService.cs")]       // 列表/详情水合
+    [InlineData("Service/S8/ExceptionEscalationBizHandler.cs")] // 升级回调
+    [InlineData("Service/S8/S8MasterDataAdapter.cs")]      // 人员选择器
+    [InlineData("Workbench/AidopWorkbenchService.cs")]     // 我的待办
+    public void T10_T11_T12_T21_NoEmployeeBridgeInPersonChain(string relativePath)
+    {
+        var code = CodeOnly(relativePath);
+        Assert.DoesNotContain("AdoS0EmployeeMaster", code);
+        Assert.DoesNotContain("EmployeeMaster", code);
+        Assert.DoesNotContain("GetEmployeeSysUserId", code);
+        Assert.DoesNotContain("SysUserId ==", code);
+    }
+
+    /// <summary>
+    /// 工作台不得再因「未绑定员工」整块不可用。
+    /// 该分支在 1105 : 8 的真实绑定率下,对绝大多数账号恒为真。
+    /// </summary>
+    [Fact]
+    public void T21b_Workbench_HasNoEmployeeUnboundBranch()
+    {
+        var code = CodeOnly("Workbench/AidopWorkbenchService.cs");
+        Assert.DoesNotContain("EMPLOYEE_UNBOUND", code);
+    }
+
+    /// <summary>
+    /// T13:通知投递直接收账号 Id,不再有 employeeRecId 形参。
+    /// </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");
+        Assert.Null(typeof(S8TaskFlowService).GetMethod("NotifyAssigneeAsync", Instance));
+        Assert.Null(typeof(S8TaskFlowService).GetMethod("GetEmployeeSysUserIdAsync", Instance));
+    }
+
+    // ═══════════════════════════════════════════════════════════════════════════════
+    // T14–T15 · First Claim Wins
+    // ═══════════════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// T14 / T15:并发认领由**一条带前置条件的 UPDATE** 裁决,第二个认领者被拒。
+    ///
+    /// <para>断言的是"没有先读后写的窗口":谓词必须同时含
+    /// <c>Status == fromStatus</c> 与 <c>AssigneeUserId == null</c>,且影响行数 0 时报错。
+    /// 若改成 SELECT 判空 → UPDATE,两个人可以同时通过判空、同时写入,
+    /// 最终 assignee 是后写的那个,而两个人都收到"认领成功"。</para>
+    /// </summary>
+    [Fact]
+    public void T14_T15_Claim_IsAtomicFirstWins()
+    {
+        var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
+
+        Assert.Contains("x.Status == fromStatus", code);
+        Assert.Contains("x.AssigneeUserId == null", code);
+        Assert.Contains("ExecuteCommandAsync()", code);
+        Assert.Contains("affected == 0", code);
+        Assert.Contains("该异常已被其他人员认领", code);
+    }
+
+    /// <summary>
+    /// T14b:认领人只能是当前登录账号 —— 方法签名里<b>没有</b> assignee 形参。
+    ///
+    /// <para>旧签名收 <c>assigneeId</c>,意味着持 <c>ExceptionClaim</c>(弱权限)的人
+    /// 能做 <c>ExceptionAssign</c>(强权限)才该做的事:替别人认领。</para>
+    /// </summary>
+    [Fact]
+    public void T14b_Claim_TakesNoAssigneeFromClient()
+    {
+        var claim = typeof(S8TaskFlowService).GetMethod("ClaimAsync", Instance);
+        Assert.NotNull(claim);
+        Assert.DoesNotContain(claim!.GetParameters(),
+            p => p.Name!.Contains("assignee", StringComparison.OrdinalIgnoreCase));
+
+        // 请求体里也不能留一个"服务端会忽略"的字段。
+        Assert.Null(typeof(AdoS8ClaimBody).GetProperty("AssigneeId"));
+        Assert.Null(typeof(AdoS8ClaimBody).GetProperty("AssigneeUserId"));
+    }
+
+    // ═══════════════════════════════════════════════════════════════════════════════
+    // T22 · Timeline 单一 ID 空间
+    // ═══════════════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// T22:写时间线的形参叫 <c>operatorUserId</c>,且 CLAIM 写的是当前登录账号。
+    ///
+    /// <para>旧实现 CLAIM 传的是 <c>assigneeId</c>(Employee.RecID),
+    /// 其余动作传 <c>currentUserId</c>(SysUser.Id)—— 混装由此产生。</para>
+    /// </summary>
+    [Fact]
+    public void T22_Timeline_HasSingleIdSpace()
+    {
+        var insert = typeof(S8TaskFlowService).GetMethod("InsertTimelineAsync", Instance);
+        Assert.NotNull(insert);
+        Assert.Contains(insert!.GetParameters(), p => p.Name == "operatorUserId");
+        Assert.DoesNotContain(insert.GetParameters(), p => p.Name == "operatorId");
+
+        var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
+        Assert.Contains("\"CLAIM\", \"认领\", fromStatus, \"ASSIGNED\", currentUserId", code);
+    }
+
+    // ═══════════════════════════════════════════════════════════════════════════════
+    // T24 · RESET 白名单守卫
+    // ═══════════════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// T16 / T17 / T24:迁移的 RESET 范围必须是**硬编码白名单**。
+    ///
+    /// <para>最危险的写法是按表名前缀做通配删除 —— 它会把<b>将来才新增的表</b>
+    /// 也一并删掉,而写脚本的人当时根本看不到那些表。
+    /// 同样危险的是整表清空 <c>ApprovalFlowInstance/Task</c>:这两张表由 S5/S6/S7 共用。</para>
+    /// </summary>
+    [Fact]
+    public void T16_T17_T24_Migration_UsesExplicitResetWhitelist()
+    {
+        var sql = MigrationSqlCodeOnly();
+
+        // ① 不得有任何通配删除
+        Assert.DoesNotContain("LIKE 'ado_s8_%'", sql);
+        Assert.DoesNotContain("LIKE \"ado_s8_%\"", sql);
+
+        // ② 审批流必须按 BizType 定点删,不得整表清空
+        Assert.Contains("'EXCEPTION_REPORT','EXCEPTION_ESCALATION','EXCEPTION_CLOSURE'", sql);
+        Assert.DoesNotContain("DELETE FROM `ApprovalFlowInstance`;", sql);
+        Assert.DoesNotContain("DELETE FROM `ApprovalFlowTask`;", sql);
+        Assert.DoesNotContain("TRUNCATE", sql);
+
+        // ③ 平台主数据与配置态一行都不许删;脚本自带断言兜底
+        foreach (var master in new[] { "SysUser", "SysRole", "EmployeeMaster", "DepartmentMaster" })
+            Assert.DoesNotContain($"DELETE FROM `{master}`", sql);
+        foreach (var config in new[]
+                 {
+                     "ado_s8_exception_type", "ado_s8_scene_config",
+                     "ado_s8_notification_layer", "ado_s8_role_permission_config",
+                     "ado_s8_dashboard_cell_config", "ado_s8_watch_rule",
+                 })
+            Assert.DoesNotContain($"DELETE FROM `{config}`", sql);
+
+        Assert.Contains("s8_496_BLOCKED_platform_master_wiped", sql);
+        Assert.Contains("s8_496_BLOCKED_product_config_wiped", sql);
+
+        // ④ AutoVersionUpdate 对含该关键字的脚本一律拒绝执行,且它是**朴素子串匹配** ——
+        //    注释里出现也照样拦。所以这一条必须看整个文件,不能用剥注释后的版本。
+        Assert.DoesNotContain("DELIMITER", File.ReadAllText(Path.GetFullPath(Path.Combine(
+            PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.496.sql"))));
+    }
+
+    /// <summary>
+    /// T18 / T19:Provisioning 新建的规则行恒为停用,且不写任何人员字段。
+    ///
+    /// <para>reset 之后管理员必须重新配置再启用 —— 不得出现"清完库自己就跑起来了"。</para>
+    /// </summary>
+    [Fact]
+    public void T18_T19_Provisioning_CreatesDisabledRuleWithoutPeople()
+    {
+        var code = CodeOnly("Service/S8/S8RuleProvisioningService.cs");
+        Assert.Contains("Enabled = false", code);
+        foreach (var forbidden in new[] { "AssigneeUserId", "VerifierUserId", "HandlerUser", "SysUser" })
+            Assert.DoesNotContain(forbidden, code);
+    }
+
+    /// <summary>
+    /// T20:迁移可重复执行。三种到场状态(只有旧列 / 新旧都有 / 只有新列)都要能收敛,
+    /// 否则第二次启动会因 <c>ALTER</c> 报错而整个 AutoVersionUpdate 中断。
+    /// </summary>
+    [Fact]
+    public void T20_Migration_IsIdempotent()
+    {
+        var sql = MigrationSqlCodeOnly();
+
+        Assert.Contains("CREATE TABLE IF NOT EXISTS", sql);
+        Assert.Contains("information_schema.COLUMNS", sql);
+        // 三分支:改名 / 丢弃重复旧列 / 什么都不做
+        Assert.Contains("CHANGE COLUMN `assignee_id` `assignee_user_id`", sql);
+        Assert.Contains("DROP COLUMN `assignee_id`", sql);
+        Assert.Contains("'DO 0'", sql);
+    }
+
+    // ═══════════════════════════════════════════════════════════════════════════════
+    // 退役入口
+    // ═══════════════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 「操作员绑定」必须整体退役,且退役后的入口<b>不再具备写 EmployeeMaster 的能力</b>。
+    ///
+    /// <para>只把菜单藏起来是不够的:留着一个能写主数据的 API,
+    /// 下一个人照样会顺着它把 Employee 绑定重新接回人员主链。</para>
+    /// </summary>
+    [Fact]
+    public void OperatorBinding_IsFullyRetired()
+    {
+        Assert.Null(Type.GetType("Admin.NET.Plugin.AiDOP.Service.S8.S8OperatorBindingService, Admin.NET.Plugin.AiDOP"));
+        Assert.False(File.Exists(Path.Combine(PluginRoot, "Service", "S8", "S8OperatorBindingService.cs")));
+
+        var code = CodeOnly("Controllers/S8/AdoS8ConfigBindingsController.cs");
+        Assert.Contains("Status410Gone", code);
+        Assert.DoesNotContain("EmployeeMaster", code);
+        Assert.DoesNotContain("S8OperatorBindingService", code);
+    }
+
+    /// <summary>
+    /// 人员选择器只能列系统账号。
+    /// 旧的 <c>employees</c> 端点保留但返回 410,好让旧客户端拿到明确原因而不是 404。
+    /// </summary>
+    [Fact]
+    public void PersonPicker_ListsSysUsersOnly()
+    {
+        Assert.NotNull(typeof(S8MasterDataAdapter).GetMethod("GetOperatorUsersAsync"));
+        Assert.Null(typeof(S8MasterDataAdapter).GetMethod("GetEmployeesAsync"));
+
+        var code = CodeOnly("Controllers/S8/AdoS8MasterDataController.cs");
+        Assert.Contains("HttpGet(\"users\")", code);
+        Assert.Contains("Status410Gone", code);
+    }
+
+    /// <summary>
+    /// T23:本批不含任何指向共享库的写操作。
+    /// 迁移只在本地执行;共享 165 的执行时点由发布流程决定,不由代码触发。
+    /// </summary>
+    [Fact]
+    public void T23_NoSharedDbHostAppearsInMigrationOrCode()
+    {
+        // T23 刻意读<b>整个文件含注释</b>:共享库主机名出现在注释里同样是泄漏。
+        var sql = File.ReadAllText(Path.GetFullPath(Path.Combine(
+            PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.496.sql")));
+        Assert.DoesNotContain("123.60.180.165", sql);
+        Assert.DoesNotContain("aidopdev", sql);
+    }
+}

+ 37 - 16
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8TenantOnlyExceptionScopeTests.cs

@@ -203,20 +203,25 @@ public class S8TenantOnlyExceptionScopeTests
     }
 
     /// <summary>
-    /// T10:认领 / 转派解析处理人所属部门的硬边界必须是租户。
-    /// 按工厂解析会在真库跨租户(factory_ref_id=1000 横跨两个租户),
-    /// 于是「认领本租户异常」可能把另一租户的部门 RecID 写进 responsible_dept_id。
+    /// T10:认领 / 转派<b>不再</b>自动改写责任部门。
+    ///
+    /// <para>原实现是 <c>Employee.Department(字符串) → DepartmentMaster</c>,
+    /// 本批(S8-SYSUSER-ONLY-1)把处理人改成系统账号后这条链的第一跳没有了 ——
+    /// <c>SysUser</c> 侧不存在指向 <c>DepartmentMaster</c> 的关系
+    /// (<c>SysUser.OrgId</c> 指的是 <c>SysOrg</c> 公司/工厂树,是另一棵树)。</para>
+    ///
+    /// <para>原断言守的是「这条解析别按工厂做」。解析本身没了,守卫对象改为
+    /// <b>别用 SysOrg 硬凑一个近似值把它复活</b> —— 责任部门由建单时确定,
+    /// 之后只能由人显式修改,不再随处理人静默漂移。</para>
     /// </summary>
     [Fact]
-    public void T10_ResponsibleDeptResolution_IsTenantBound()
+    public void T10_ResponsibleDeptIsNotSilentlyRewrittenOnAssignment()
     {
-        var m = typeof(S8TaskFlowService).GetMethod("ResolveEmployeeResponsibleDeptIdAsync", Instance);
-        Assert.NotNull(m);
-        Assert.Equal(new[] { "assigneeId", "tenantId" }, m!.GetParameters().Select(p => p.Name).ToArray());
+        Assert.Null(typeof(S8TaskFlowService).GetMethod("ResolveEmployeeResponsibleDeptIdAsync", Instance));
 
         var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
-        Assert.Contains("x.Id == assigneeId && x.TenantId == tenantId", code);
-        Assert.DoesNotContain("x.FactoryRefId == factoryId", code);
+        Assert.DoesNotContain("ResponsibleDeptId =", code);
+        Assert.DoesNotContain("SysOrg", code);
     }
 
     /// <summary>T11:绑行查询只按 (Id, TenantId, !IsDeleted)。</summary>
@@ -282,22 +287,38 @@ public class S8TenantOnlyExceptionScopeTests
 
     // ───────────────── T16–T19:主数据(真实越权修复) ─────────────────
 
-    /// <summary>T16:主数据三个下拉的边界必须是租户,工厂判据不得残留。</summary>
+    /// <summary>
+    /// T16:主数据三个下拉的边界必须是租户,工厂判据不得残留。
+    ///
+    /// <para>S8-SYSUSER-ONLY-1 之后人员下拉改列 <c>SysUser</c>(谓词写作
+    /// <c>u.TenantId == scope.TenantId</c>),因此计数由 3 个 <c>x.</c> 变为
+    /// 2 个 <c>x.</c> + 1 个 <c>u.</c>。断言总数仍是 3 —— 三个下拉,一个都不能缺边界。</para>
+    /// </summary>
     [Fact]
     public void T16_MasterDataDropdowns_AreTenantBound()
     {
         var code = CodeOnly("Service/S8/S8MasterDataAdapter.cs");
         Assert.DoesNotContain("FactoryRefId", code);
-        Assert.Equal(3, CountOccurrences(code, "x.TenantId == scope.TenantId"));
+        Assert.Equal(3,
+            CountOccurrences(code, "x.TenantId == scope.TenantId")
+            + CountOccurrences(code, "u.TenantId == scope.TenantId"));
     }
 
-    /// <summary>T17:操作员绑定的员工硬边界同样收敛为租户。</summary>
+    /// <summary>
+    /// T17:操作员绑定已在 S8-SYSUSER-ONLY-1 整体退役,断言随之改为
+    /// <b>该服务不得复活</b>。
+    ///
+    /// <para>原断言检查的是「员工硬边界从工厂收敛为租户」。那道边界现在没有对象可保护了 ——
+    /// S8 不再把员工绑定成处理人。真正需要守的变成:这个能写 <c>EmployeeMaster</c> 的
+    /// 写路径不要被重新引回来。删掉断言等于放弃这道守卫,因此改判而不是删除。</para>
+    /// </summary>
     [Fact]
-    public void T17_OperatorBinding_IsTenantBound()
+    public void T17_OperatorBinding_IsRetiredNotResurrected()
     {
-        var code = CodeOnly("Service/S8/S8OperatorBindingService.cs");
-        Assert.Contains(".Where(x => x.TenantId == scope.TenantId)", code);
-        Assert.DoesNotContain("x.FactoryRefId == scope.FactoryId", code);
+        Assert.False(File.Exists(Path.Combine(PluginRoot, "Service", "S8", "S8OperatorBindingService.cs")));
+        var code = CodeOnly("Controllers/S8/AdoS8ConfigBindingsController.cs");
+        Assert.Contains("Status410Gone", code);
+        Assert.DoesNotContain("EmployeeMaster", code);
     }
 
     /// <summary>T18:异常列表 / 详情的部门名水合按租户查,不按工厂。</summary>

+ 32 - 41
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigBindingsController.cs

@@ -1,71 +1,62 @@
 using Admin.NET.Plugin.AiDOP.Const.S8;
-using Admin.NET.Plugin.AiDOP.Dto.S8;
-using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
-using Admin.NET.Plugin.AiDOP.Service.S8;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
 
 /// <summary>
-/// S8 配置页:操作员(员工)↔ 系统账号绑定 / 可绑账号查询。
-/// 入口收口在 /aidop/s8/config/roles,与 S8 配置中心同源。
+/// 「操作员绑定」配置入口 —— <b>已整体退役(S8-SYSUSER-ONLY-1)</b>。
 ///
-/// <para><b>S8-P0-3-MASTERDATA-TRUSTED-SCOPE-1</b>:本控制器原先是
-/// KNOWN-ISSUES I-001 里「6 个未接入可信作用域解析」之一,且是其中**唯一被实测利用**的越权面
-/// —— 普通租户账号调 <c>GET /config/operator-bindings</c>(不带任何参数)可拿到 500 条
-/// 其它租户员工的工号与真实姓名。现已接入 <see cref="S8TrustedScopeResolver"/>。</para>
+/// <para><b>它原来做什么</b>:维护 <c>EmployeeMaster.sys_user_id</c>,也就是把一个业务员工
+/// 绑定到一个系统账号,从而让这个员工"能被 S8 当成处理人使用"。</para>
 ///
-/// <para><b>入参语义</b>:<c>factoryRefId</c> 已从所有 Action 签名移除。客户端(含前端
-/// <c>withAidopTenantFactoryParams</c>)仍可能继续携带该 query/body 字段,ASP.NET 会直接丢弃,
-/// **不产生任何授权效果**。Body 里的 <c>AdoS8OperatorBindingCreateDto.FactoryRefId</c> 同理不被读取。</para>
+/// <para><b>为什么退役</b>:S8 的人现在<b>就是</b>系统账号,不再存在"这个员工对应哪个账号"这个问题。
+/// 保留一个绑定页面,只会让管理员继续以为"必须先绑定员工才能用 S8",
+/// 而实际上绑不绑都不影响任何 S8 行为 —— 这正是最容易造成误操作的那类残留入口。
+/// 真库现状也说明它从未真正被使用:1105 名员工里只有 8 个绑了账号。</para>
+///
+/// <para><b>为什么返回 410 而不是删掉路由</b>:410 Gone 让还在调用旧地址的客户端
+/// 立刻拿到「已退役 + 原因」,而 404 会被当成路径写错,引出一轮无谓的排查。
+/// 本控制器<b>不再依赖任何服务</b>,也不再触碰 <c>EmployeeMaster</c> ——
+/// 退役的入口不应该还保有写主数据的能力。</para>
+///
+/// <para><b>没有动的东西</b>:<c>EmployeeMaster</c> 表与其中已有的 8 条 <c>sys_user_id</c> 绑定
+/// 全部原样保留。它属于 S0 业务主数据,不归 S8 处置。</para>
 /// </summary>
 [ApiController]
 [Route("api/aidop/s8/config")]
 [NonUnify]
 public class AdoS8ConfigBindingsController : ControllerBase
 {
-    private readonly S8OperatorBindingService _svc;
-    private readonly S8TrustedScopeResolver _scope;
+    private const string RetiredMessage =
+        "「操作员绑定」已退役:S8 人员身份统一为系统账号,无需再把员工绑定到账号。"
+        + "请在系统账号管理中维护账号,并用 GET /api/aidop/s8/master-data/users 选择人员。";
 
-    public AdoS8ConfigBindingsController(S8OperatorBindingService svc, S8TrustedScopeResolver scope)
-    {
-        _svc = svc;
-        _scope = scope;
-    }
+    private IActionResult Retired() =>
+        StatusCode(StatusCodes.Status410Gone, new
+        {
+            message = RetiredMessage,
+            replacedBy = "/api/aidop/s8/master-data/users"
+        });
 
     [HttpGet("operator-bindings")]
     [S8Permission(S8PermissionCatalog.ConfigRead)]
-    public async Task<IActionResult> ListAsync(
-        [FromQuery] string? bindStatus = null,
-        [FromQuery] string? keyword = null)
-    {
-        try { return Ok(await _svc.ListAsync(await _scope.ResolveAsync(), bindStatus, keyword)); }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
-    }
+    public IActionResult ListRetired() => Retired();
 
     [HttpPost("operator-bindings")]
     [S8Permission(S8PermissionCatalog.ConfigOperatorBind)]
-    public async Task<IActionResult> BindAsync([FromBody] AdoS8OperatorBindingCreateDto body)
-    {
-        try { return Ok(await _svc.BindAsync(await _scope.ResolveAsync(), body)); }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
-    }
+    public IActionResult BindRetired() => Retired();
 
     [HttpDelete("operator-bindings/{employeeId:long}")]
     [S8Permission(S8PermissionCatalog.ConfigOperatorBind)]
-    public async Task<IActionResult> UnbindAsync(long employeeId)
+    public IActionResult UnbindRetired(long employeeId)
     {
-        try { await _svc.UnbindAsync(await _scope.ResolveAsync(), employeeId); return Ok(new { employeeId }); }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+        _ = employeeId;
+        return Retired();
     }
 
     [HttpGet("sys-users")]
     [S8Permission(S8PermissionCatalog.ConfigRead)]
-    public async Task<IActionResult> SysUsersAsync(
-        [FromQuery] string? keyword = null,
-        [FromQuery] long? excludeEmployeeId = null)
-    {
-        try { return Ok(await _svc.ListSysUsersAsync(await _scope.ResolveAsync(), keyword, excludeEmployeeId)); }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
-    }
+    public IActionResult SysUsersRetired() => Retired();
 }

+ 15 - 7
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ExceptionsController.cs

@@ -93,6 +93,11 @@ public class AdoS8ExceptionsController : ControllerBase
 
     [HttpPost("{id:long}/claim")]
     [S8Permission(S8PermissionCatalog.ExceptionClaim)]
+    /// <summary>
+    /// 认领。<b>处理人恒为当前登录账号</b>,请求体只带备注 ——
+    /// 「替别人指派」是 <c>POST {id}/transfer</c>(权限位 <c>ExceptionAssign</c>)。
+    /// 已被他人抢先认领时返回 400「该异常已被其他人员认领」。
+    /// </summary>
     public async Task<IActionResult> ClaimAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromBody] AdoS8ClaimBody? body = null)
     {
@@ -100,8 +105,8 @@ public class AdoS8ExceptionsController : ControllerBase
         try
         {
             var scope = await _scope.ResolveAsync();
-            var e = await _taskFlowSvc.ClaimAsync(id, scope.TenantId, body?.AssigneeId ?? 0, body?.Remark);
-            return Ok(new { id = e.Id, status = e.Status });
+            var e = await _taskFlowSvc.ClaimAsync(id, scope.TenantId, body?.Remark);
+            return Ok(new { id = e.Id, status = e.Status, assigneeUserId = e.AssigneeUserId });
         }
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
@@ -115,8 +120,8 @@ public class AdoS8ExceptionsController : ControllerBase
         try
         {
             var scope = await _scope.ResolveAsync();
-            var e = await _taskFlowSvc.TransferAsync(id, scope.TenantId, body?.AssigneeId ?? 0, body?.Remark);
-            return Ok(new { id = e.Id, assigneeId = e.AssigneeId });
+            var e = await _taskFlowSvc.TransferAsync(id, scope.TenantId, body?.AssigneeUserId ?? 0, body?.Remark);
+            return Ok(new { id = e.Id, assigneeUserId = e.AssigneeUserId });
         }
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
@@ -177,8 +182,8 @@ public class AdoS8ExceptionsController : ControllerBase
         {
             var scope = await _scope.ResolveAsync();
             var e = await _taskFlowSvc.SubmitVerificationAsync(
-                id, scope.TenantId, body?.VerifierId ?? 0, body?.Remark);
-            return Ok(new { id = e.Id, status = e.Status, verifierId = e.VerifierId });
+                id, scope.TenantId, body?.VerifierUserId ?? 0, body?.Remark);
+            return Ok(new { id = e.Id, status = e.Status, verifierUserId = e.VerifierUserId });
         }
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
@@ -229,8 +234,11 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 }
 
+/// <summary>
+/// 认领请求体。<b>刻意不含处理人字段</b> —— 处理人由服务端从登录上下文取,
+/// 留一个"服务端会忽略"的 assignee 字段只会让调用方以为自己能替别人认领。
+/// </summary>
 public class AdoS8ClaimBody
 {
-    public long AssigneeId { get; set; }
     public string? Remark { get; set; }
 }

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

@@ -1,3 +1,4 @@
+using Microsoft.AspNetCore.Http;
 using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
@@ -36,14 +37,29 @@ public class AdoS8MasterDataController : ControllerBase
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
 
-    [HttpGet("employees")]
+    /// <summary>S8 人员选择器:当前租户的启用系统账号。</summary>
+    [HttpGet("users")]
     [S8Permission(S8PermissionCatalog.ExceptionRead)]
-    public async Task<IActionResult> EmployeesAsync()
+    public async Task<IActionResult> UsersAsync()
     {
-        try { return Ok(await _svc.GetEmployeesAsync(await _scope.ResolveAsync())); }
+        try { return Ok(await _svc.GetOperatorUsersAsync(await _scope.ResolveAsync())); }
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
 
+    /// <summary>
+    /// 已退役(S8-SYSUSER-ONLY-1)。S8 不再有"员工"这个身份维度,请改用 <c>GET master-data/users</c>。
+    /// <para>保留路由并返回 410,是为了让还在调用旧地址的客户端<b>立刻拿到明确错误</b>,
+    /// 而不是收到 404 后以为只是路径写错。</para>
+    /// </summary>
+    [HttpGet("employees")]
+    [S8Permission(S8PermissionCatalog.ExceptionRead)]
+    public IActionResult EmployeesRetiredAsync() =>
+        StatusCode(StatusCodes.Status410Gone, new
+        {
+            message = "该接口已退役:S8 人员身份统一为系统账号,请改用 GET /api/aidop/s8/master-data/users",
+            replacedBy = "/api/aidop/s8/master-data/users"
+        });
+
     [HttpGet("lines")]
     [S8Permission(S8PermissionCatalog.ExceptionRead)]
     public async Task<IActionResult> LinesAsync()

+ 14 - 7
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8Dtos.cs

@@ -65,7 +65,8 @@ public class AdoS8ExceptionListItemDto
     public string? ResponsibleDeptName { get; set; }
     public long OccurrenceDeptId { get; set; }
     public string? OccurrenceDeptName { get; set; }
-    public long? AssigneeId { get; set; }
+    /// <summary>处理人账号 Id(<c>SysUser.Id</c>)。</summary>
+    public long? AssigneeUserId { get; set; }
     public string? AssigneeName { get; set; }
     public DateTime? SlaDeadline { get; set; }
     public bool TimeoutFlag { get; set; }
@@ -121,13 +122,15 @@ public class AdoS8ExceptionDetailDto : AdoS8ExceptionListItemDto
     public string? Description { get; set; }
     public string SourceType { get; set; } = string.Empty;
     public long? ResponsibleGroupId { get; set; }
-    public long? ReporterId { get; set; }
+    /// <summary>提报人账号 Id(<c>SysUser.Id</c>)。</summary>
+    public long? ReporterUserId { get; set; }
     public string? ReporterName { get; set; }
     public DateTime? AssignedAt { get; set; }
     public DateTime? UpdatedAt { get; set; }
     public long? ActiveFlowInstanceId { get; set; }
     public string? ActiveFlowBizType { get; set; }
-    public long? VerifierId { get; set; }
+    /// <summary>检验人账号 Id(<c>SysUser.Id</c>)。</summary>
+    public long? VerifierUserId { get; set; }
     public string? VerifierName { get; set; }
     public DateTime? VerificationAssignedAt { get; set; }
     public DateTime? VerifiedAt { get; set; }
@@ -146,7 +149,8 @@ public class AdoS8ManualReportCreateDto
     public string Severity { get; set; } = "FOLLOW";
     public long OccurrenceDeptId { get; set; }
     public long ResponsibleDeptId { get; set; }
-    public long? ReporterId { get; set; }
+    // S8-SYSUSER-ONLY-1:原有 ReporterId 入参已删除。提报人一律取服务端登录上下文,
+    // 保留一个"服务端永远忽略"的字段只会让调用方以为自己能指定提报人。
     /// <summary>关联对象编码:当前按订单项 / 类订单项对象理解,允许重复,可为空。最长 64 字符(与 ado_s8_exception.related_object_code 一致)。</summary>
     public string? RelatedObjectCode { get; set; }
 
@@ -169,7 +173,8 @@ public class AdoS8TimelineItemDto
     public string ActionLabel { get; set; } = string.Empty;
     public string? FromStatus { get; set; }
     public string? ToStatus { get; set; }
-    public long? OperatorId { get; set; }
+    /// <summary>操作人账号 Id(<c>SysUser.Id</c>)。系统自动动作为 null。</summary>
+    public long? OperatorUserId { get; set; }
     public string? OperatorName { get; set; }
     public string? ActionRemark { get; set; }
     public DateTime CreatedAt { get; set; }
@@ -205,7 +210,8 @@ public class AdoS8CommentDto
 
 public class AdoS8TransferDto
 {
-    public long AssigneeId { get; set; }
+    /// <summary>转派目标账号 Id(<c>SysUser.Id</c>)。必须是当前租户的启用账号。</summary>
+    public long AssigneeUserId { get; set; }
     public string? Remark { get; set; }
 }
 
@@ -470,7 +476,8 @@ public class AdoS8DomainTypeMetricItemDto
 
 public class AdoS8SubmitVerificationDto
 {
-    public long VerifierId { get; set; }
+    /// <summary>检验人账号 Id(<c>SysUser.Id</c>)。必须是当前租户的启用账号。</summary>
+    public long VerifierUserId { get; set; }
     public string? Remark { get; set; }
 }
 

+ 0 - 28
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8OperatorBindingDtos.cs

@@ -1,28 +0,0 @@
-namespace Admin.NET.Plugin.AiDOP.Dto.S8;
-
-public class AdoS8OperatorBindingRowDto
-{
-    public long EmployeeId { get; set; }
-    public string EmpCode { get; set; } = string.Empty;
-    public string? Name { get; set; }
-    public long FactoryRefId { get; set; }
-    public long? SysUserId { get; set; }
-    public string? SysUserName { get; set; }
-    public string BindStatus { get; set; } = "UNBOUND";
-}
-
-public class AdoS8OperatorBindingCreateDto
-{
-    public long EmployeeId { get; set; }
-    public long SysUserId { get; set; }
-    public long FactoryRefId { get; set; }
-}
-
-public class AdoS8ConfigSysUserRowDto
-{
-    public long Id { get; set; }
-    public string? Account { get; set; }
-    public string? RealName { get; set; }
-    public int Status { get; set; }
-    public long? AlreadyBoundEmployeeId { get; set; }
-}

+ 19 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8Exception.cs

@@ -17,8 +17,8 @@ namespace Admin.NET.Plugin.AiDOP.Entity.S8;
 [SugarIndex("idx_s8_exception_related_object_code", nameof(TenantId), OrderByType.Asc, nameof(FactoryId), OrderByType.Asc, nameof(RelatedObjectCode), OrderByType.Asc, nameof(IsDeleted), OrderByType.Asc)]
 [SugarIndex("idx_s8_exception_order_flow", nameof(TenantId), OrderByType.Asc, nameof(FactoryId), OrderByType.Asc, nameof(OrderFlowCode), OrderByType.Asc, nameof(Status), OrderByType.Asc, nameof(IsDeleted), OrderByType.Asc)]
 [SugarIndex("idx_s8_exception_related_flow", nameof(TenantId), OrderByType.Asc, nameof(FactoryId), OrderByType.Asc, nameof(RelatedObjectCode), OrderByType.Asc, nameof(OrderFlowCode), OrderByType.Asc)]
-[SugarIndex("idx_s8_exception_assignee_status", nameof(TenantId), OrderByType.Asc, nameof(FactoryId), OrderByType.Asc, nameof(AssigneeId), OrderByType.Asc, nameof(Status), OrderByType.Asc, nameof(IsDeleted), OrderByType.Asc)]
-[SugarIndex("idx_s8_exception_verifier_status", nameof(TenantId), OrderByType.Asc, nameof(FactoryId), OrderByType.Asc, nameof(VerifierId), OrderByType.Asc, nameof(Status), OrderByType.Asc, nameof(IsDeleted), OrderByType.Asc)]
+[SugarIndex("idx_s8_exception_assignee_status", nameof(TenantId), OrderByType.Asc, nameof(FactoryId), OrderByType.Asc, nameof(AssigneeUserId), OrderByType.Asc, nameof(Status), OrderByType.Asc, nameof(IsDeleted), OrderByType.Asc)]
+[SugarIndex("idx_s8_exception_verifier_status", nameof(TenantId), OrderByType.Asc, nameof(FactoryId), OrderByType.Asc, nameof(VerifierUserId), OrderByType.Asc, nameof(Status), OrderByType.Asc, nameof(IsDeleted), OrderByType.Asc)]
 public class AdoS8Exception
 {
     [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
@@ -67,11 +67,20 @@ public class AdoS8Exception
     [SugarColumn(ColumnName = "responsible_group_id", ColumnDataType = "bigint", IsNullable = true)]
     public long? ResponsibleGroupId { get; set; }
 
-    [SugarColumn(ColumnName = "assignee_id", ColumnDataType = "bigint", IsNullable = true)]
-    public long? AssigneeId { get; set; }
-
-    [SugarColumn(ColumnName = "reporter_id", ColumnDataType = "bigint", IsNullable = true)]
-    public long? ReporterId { get; set; }
+    /// <summary>
+    /// 处理人。<b>SYSUSER ID ONLY</b> —— 值域是 <c>SysUser.Id</c>,不是 <c>EmployeeMaster.RecID</c>。
+    ///
+    /// <para>列名从 <c>assignee_id</c> 显式改为 <c>assignee_user_id</c>(迁移 1.0.496)。
+    /// 旧列名两种解读都说得通,历史上也确实同时存在过两种写法:
+    /// 认领写 Employee.RecID、审批流回调写 SysUser.Id,实测 34 个非空值里 14 个两边都解析不出来。
+    /// 名字里带 <c>user</c> 是为了让下一次误用在<b>读代码时</b>就被发现,而不是等到线上通知发不出去。</para>
+    /// </summary>
+    [SugarColumn(ColumnName = "assignee_user_id", ColumnDataType = "bigint", IsNullable = true)]
+    public long? AssigneeUserId { get; set; }
+
+    /// <summary>提报人。<b>SYSUSER ID ONLY</b>,口径同 <see cref="AssigneeUserId"/>。</summary>
+    [SugarColumn(ColumnName = "reporter_user_id", ColumnDataType = "bigint", IsNullable = true)]
+    public long? ReporterUserId { get; set; }
 
     [SugarColumn(ColumnName = "sla_deadline", IsNullable = true)]
     public DateTime? SlaDeadline { get; set; }
@@ -134,9 +143,9 @@ public class AdoS8Exception
     [SugarColumn(ColumnName = "source_payload", Length = 4000, IsNullable = true)]
     public string? SourcePayload { get; set; }
 
-    /// <summary>检验人ID(提交复检时手选)</summary>
-    [SugarColumn(ColumnName = "verifier_id", ColumnDataType = "bigint", IsNullable = true)]
-    public long? VerifierId { get; set; }
+    /// <summary>检验人(提交复检时手选)。<b>SYSUSER ID ONLY</b>,口径同 <see cref="AssigneeUserId"/>。</summary>
+    [SugarColumn(ColumnName = "verifier_user_id", ColumnDataType = "bigint", IsNullable = true)]
+    public long? VerifierUserId { get; set; }
 
     /// <summary>提交复检时间</summary>
     [SugarColumn(ColumnName = "verification_assigned_at", IsNullable = true)]

+ 12 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8ExceptionTimeline.cs

@@ -21,8 +21,18 @@ public class AdoS8ExceptionTimeline
     [SugarColumn(ColumnName = "to_status", Length = 32, IsNullable = true)]
     public string? ToStatus { get; set; }
 
-    [SugarColumn(ColumnName = "operator_id", ColumnDataType = "bigint", IsNullable = true)]
-    public long? OperatorId { get; set; }
+    /// <summary>
+    /// 操作人。<b>SYSUSER ID ONLY</b> —— 值域是 <c>SysUser.Id</c>。
+    ///
+    /// <para>列名从 <c>operator_id</c> 显式改为 <c>operator_user_id</c>(迁移 1.0.496)。
+    /// 旧列<b>实测混装两套 ID 空间</b>:CLAIM 写的是 EmployeeMaster.RecID(19 行),
+    /// 而 START_PROGRESS / VERIFY_* / CREATE 写的是 SysUser.Id —— 同一列、同一张表、语义按动作而异。
+    /// 于是「谁做的这一步」这个最基本的问题,在时间线上无法回答。</para>
+    ///
+    /// <para>系统自动动作允许为 NULL(没有真人操作者),<b>但绝不允许伪造一个 ID 填进来</b>。</para>
+    /// </summary>
+    [SugarColumn(ColumnName = "operator_user_id", ColumnDataType = "bigint", IsNullable = true)]
+    public long? OperatorUserId { get; set; }
 
     [SugarColumn(ColumnName = "operator_name", Length = 128, IsNullable = true)]
     public string? OperatorName { get; set; }

+ 116 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8UserScopeValidator.cs

@@ -0,0 +1,116 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+/// <summary>
+/// S8 语境下的「一个人」。<b>唯一身份是 <see cref="UserId"/>(<c>SysUser.Id</c>)</b>,
+/// 其余字段只用于展示,不参与任何判定。
+/// </summary>
+public sealed record S8OperatorUser(long UserId, string Account, string? RealName)
+{
+    /// <summary>展示名:有真实姓名用姓名,否则回落账号。绝不回落成裸数字 ID。</summary>
+    public string DisplayName => string.IsNullOrWhiteSpace(RealName) ? Account : RealName!;
+}
+
+/// <summary>
+/// S8-SYSUSER-ONLY-1:「这个账号在当前租户下能不能作为 S8 的人」的<b>唯一</b>判定实现。
+///
+/// <para><b>为什么必须只有一份</b>:认领 / 转派 / 提交复检 / 通知 四条路径都要回答同一个问题。
+/// 各写一套 SQL 的直接后果在本仓已经发生过一次 —— <c>S8OperatorBindingService</c> 的
+/// <c>ListAsync</c> 用 <c>ClearFilter()</c> + 可选谓词、<c>BindAsync</c> 用另一套口径,
+/// 于是 <c>GET /config/operator-bindings</c> 曾返回 500 行全部属于其它租户。
+/// 判据必须物理同源,不是「几处写成一样」。</para>
+///
+/// <para><b>判据三条,缺一不可</b>:主键命中 · <c>TenantId</c> 等于传入租户 · <c>Status = Enable</c>。
+/// <c>SysUser</c> 继承 <c>EntityBaseTenantOrg</c>,<b>没有软删列</b>,因此不存在 <c>IsDelete</c> 判据;
+/// 停用账号一律按「不可作为 S8 处理人」处理。</para>
+///
+/// <para><b>Factory 不参与,且不得加回来</b>:真库 <c>factory_ref_id = 1000</c> 横跨
+/// 824585161322565 / 797403760988229 两个租户,按工厂校验会把别家的人判为合法。
+/// 租户是唯一安全边界。</para>
+///
+/// <para><b>跨租户一律按「不存在」处理</b>:返回 null / 抛「不存在或已停用」,
+/// 与本租户内真实不存在的情况文案完全一致,不泄漏他租户账号是否存在。</para>
+/// </summary>
+public interface IS8UserScopeValidator
+{
+    /// <summary>查找租户内的可用账号;不存在 / 跨租户 / 已停用一律返回 null。</summary>
+    Task<S8OperatorUser?> FindActiveUserAsync(long tenantId, long userId);
+
+    /// <summary>校验并返回;不通过抛 <see cref="S8BizException"/>(→ 400)。</summary>
+    Task<S8OperatorUser> EnsureActiveUserAsync(long tenantId, long userId, string subject);
+
+    /// <summary>
+    /// 批量校验。返回<b>通过校验的那些</b>账号,顺序不保证、不含重复。
+    /// 供后续「规则处理账号池」复用 —— 池成员的合法性判据必须与单人认领完全一致。
+    /// </summary>
+    Task<IReadOnlyList<S8OperatorUser>> ValidateUsersAsync(long tenantId, IEnumerable<long> userIds);
+
+    /// <summary>展示水合:账号 Id → 展示信息。<b>只用于显示</b>,缺失的键不出现在结果里。</summary>
+    Task<IReadOnlyDictionary<long, S8OperatorUser>> ResolveDisplayAsync(long tenantId, IEnumerable<long> userIds);
+}
+
+/// <summary>
+/// 正式实现。<b>生产代码里只有这一份</b> —— 接口的存在是为了给测试留 seam,
+/// 不是为了允许出现第二种判据。
+/// </summary>
+public sealed class S8UserScopeValidator : IS8UserScopeValidator, ITransient
+{
+    /// <summary>单次展示水合的上限。人员选择器与列表水合都不该无界扫全租户。</summary>
+    internal const int MaxHydrationKeys = 500;
+
+    private readonly SqlSugarRepository<SysUser> _userRep;
+
+    public S8UserScopeValidator(SqlSugarRepository<SysUser> userRep) => _userRep = userRep;
+
+    public async Task<S8OperatorUser?> FindActiveUserAsync(long tenantId, long userId)
+    {
+        if (tenantId <= 0 || userId <= 0) return null;
+        var row = await ActiveInTenant(tenantId)
+            .Where(x => x.Id == userId)
+            .Select(x => new { x.Id, x.Account, x.RealName })
+            .FirstAsync();
+        return row == null ? null : new S8OperatorUser(row.Id, row.Account, row.RealName);
+    }
+
+    public async Task<S8OperatorUser> EnsureActiveUserAsync(long tenantId, long userId, string subject)
+    {
+        if (userId <= 0) throw new S8BizException($"请选择{subject}");
+        return await FindActiveUserAsync(tenantId, userId)
+            // 跨租户与本租户不存在共用同一文案,见接口注释。
+            ?? throw new S8BizException($"{subject}不存在或已停用");
+    }
+
+    public async Task<IReadOnlyList<S8OperatorUser>> ValidateUsersAsync(long tenantId, IEnumerable<long> userIds)
+    {
+        var ids = Normalize(userIds);
+        if (tenantId <= 0 || ids.Count == 0) return Array.Empty<S8OperatorUser>();
+        var rows = await ActiveInTenant(tenantId)
+            .Where(x => ids.Contains(x.Id))
+            .Select(x => new { x.Id, x.Account, x.RealName })
+            .ToListAsync();
+        return rows.Select(x => new S8OperatorUser(x.Id, x.Account, x.RealName)).ToList();
+    }
+
+    public async Task<IReadOnlyDictionary<long, S8OperatorUser>> ResolveDisplayAsync(long tenantId, IEnumerable<long> userIds)
+    {
+        var users = await ValidateUsersAsync(tenantId, userIds);
+        return users.ToDictionary(u => u.UserId);
+    }
+
+    /// <summary>
+    /// 判据的唯一表达式。<c>ClearFilter()</c>:边界由本方法显式声明,
+    /// 结果与全局 multi-tenant AOP 是否开启无关(两者判据同为 tenant_id == 传入租户)。
+    /// </summary>
+    private ISugarQueryable<SysUser> ActiveInTenant(long tenantId) =>
+        _userRep.AsQueryable().ClearFilter()
+            .Where(x => x.TenantId == tenantId && x.Status == StatusEnum.Enable);
+
+    private static List<long> Normalize(IEnumerable<long>? userIds) =>
+        (userIds ?? Enumerable.Empty<long>())
+            .Where(x => x > 0)
+            .Distinct()
+            .Take(MaxHydrationKeys)
+            .ToList();
+}

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

@@ -83,7 +83,7 @@ public class ExceptionClosureBizHandler : IFlowBizHandler, ITransient
             ActionLabel = label,
             FromStatus = null,
             ToStatus = null,
-            OperatorId = approverId,
+            OperatorUserId = approverId,
             ActionRemark = remark,
             CreatedAt = DateTime.Now
         });

+ 11 - 24
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/ExceptionEscalationBizHandler.cs

@@ -1,6 +1,5 @@
 using Admin.NET.Plugin.ApprovalFlow;
 using Admin.NET.Plugin.ApprovalFlow.Service;
-using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
@@ -15,16 +14,13 @@ public class ExceptionEscalationBizHandler : IFlowBizHandler, ITransient
 
     private readonly SqlSugarRepository<AdoS8Exception> _rep;
     private readonly SqlSugarRepository<AdoS8ExceptionTimeline> _timelineRep;
-    private readonly SqlSugarRepository<AdoS0EmployeeMaster> _employeeRep;
 
     public ExceptionEscalationBizHandler(
         SqlSugarRepository<AdoS8Exception> rep,
-        SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep,
-        SqlSugarRepository<AdoS0EmployeeMaster> employeeRep)
+        SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep)
     {
         _rep = rep;
         _timelineRep = timelineRep;
-        _employeeRep = employeeRep;
     }
 
     public async Task OnFlowStarted(long bizId, long instanceId)
@@ -48,26 +44,17 @@ public class ExceptionEscalationBizHandler : IFlowBizHandler, ITransient
         if (finalStatus == FlowInstanceStatusEnum.Approved)
         {
             e.Status = "ASSIGNED";
-            // S8-ESCALATION-IDSPACE-1:lastApproverId 是 sysUser.Id 语义;e.AssigneeId 是 employee.RecID 语义。
-            // 通过 EmployeeMaster.SysUserId 反查映射:命中唯一员工 → 写 employee.RecID;
-            // 找不到绑定员工 → 保留原 AssigneeId 不变(不静默写 sysUserId 污染业务字段),并通过 timeline 备注暴露。
+            // S8-SYSUSER-ONLY-1:lastApproverId 与 AssigneeUserId 现在是同一个 ID 空间,直接写。
+            //
+            // 原实现要先经 EmployeeMaster.SysUserId 反查出 employee.RecID 才能落库,
+            // 反查不到就"保留原值 + 在 timeline 备注里说明"。真库 1105 名员工只有 8 个绑了账号,
+            // 也就是说升级审批通过后处理人几乎总是不变 —— 而页面上看到的是「升级已确认」。
+            // 桥没了,这条静默失败路径也就没了。
             string? assigneeNote = null;
-            if (lastApproverId.HasValue)
-            {
-                var emp = await _employeeRep.GetFirstAsync(x => x.SysUserId == lastApproverId.Value);
-                if (emp != null)
-                {
-                    e.AssigneeId = emp.Id;
-                }
-                else
-                {
-                    assigneeNote = $"审批人 sysUserId={lastApproverId.Value} 未绑定员工主数据,AssigneeId 保持原值";
-                }
-            }
+            if (lastApproverId is > 0)
+                e.AssigneeUserId = lastApproverId.Value;
             else
-            {
-                assigneeNote = "未提供审批人信息,AssigneeId 保持原值";
-            }
+                assigneeNote = "未提供审批人信息,处理人保持原值";
             await _rep.UpdateAsync(e);
             await InsertTimelineAsync(e.Id, "ESCALATE_APPROVED",
                 assigneeNote == null ? "升级已确认" : $"升级已确认({assigneeNote})",
@@ -121,7 +108,7 @@ public class ExceptionEscalationBizHandler : IFlowBizHandler, ITransient
             ActionLabel = label,
             FromStatus = from,
             ToStatus = to,
-            OperatorId = approverId,
+            OperatorUserId = approverId,
             ActionRemark = remark,
             CreatedAt = DateTime.Now
         });

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

@@ -43,7 +43,7 @@ public class S8DecisionService : ITransient
                 ActionLabel = x.ActionLabel,
                 FromStatus = x.FromStatus,
                 ToStatus = x.ToStatus,
-                OperatorId = x.OperatorId,
+                OperatorUserId = x.OperatorUserId,
                 OperatorName = x.OperatorName,
                 ActionRemark = x.ActionRemark,
                 CreatedAt = x.CreatedAt

+ 38 - 51
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionService.cs

@@ -11,8 +11,7 @@ public class S8ExceptionService : ITransient
 {
     private readonly SqlSugarRepository<AdoS8Exception> _rep;
     private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
-    private readonly SqlSugarRepository<AdoS0EmployeeMaster> _empRep;
-    private readonly SqlSugarRepository<SysUser> _sysUserRep;
+    private readonly IS8UserScopeValidator _userScope;
     private readonly S8ImpactMetricsService _impactMetricsService;
 
     // S8-DEMO-IMPACT-SORT-NOTICE-1:影响排序的候选集合上限。
@@ -25,14 +24,12 @@ public class S8ExceptionService : ITransient
     public S8ExceptionService(
         SqlSugarRepository<AdoS8Exception> rep,
         SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
-        SqlSugarRepository<AdoS0EmployeeMaster> empRep,
-        SqlSugarRepository<SysUser> sysUserRep,
+        IS8UserScopeValidator userScope,
         S8ImpactMetricsService impactMetricsService)
     {
         _rep = rep;
         _deptRep = deptRep;
-        _empRep = empRep;
-        _sysUserRep = sysUserRep;
+        _userScope = userScope;
         _impactMetricsService = impactMetricsService;
     }
 
@@ -119,7 +116,7 @@ public class S8ExceptionService : ITransient
                     ModuleCode = e.ModuleCode,
                     ResponsibleDeptId = e.ResponsibleDeptId,
                     OccurrenceDeptId = e.OccurrenceDeptId,
-                    AssigneeId = e.AssigneeId,
+                    AssigneeUserId = e.AssigneeUserId,
                     SlaDeadline = e.SlaDeadline,
                     TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED",
                     CreatedAt = e.CreatedAt,
@@ -204,7 +201,7 @@ public class S8ExceptionService : ITransient
                     ModuleCode = e.ModuleCode,
                     ResponsibleDeptId = e.ResponsibleDeptId,
                     OccurrenceDeptId = e.OccurrenceDeptId,
-                    AssigneeId = e.AssigneeId,
+                    AssigneeUserId = e.AssigneeUserId,
                     SlaDeadline = e.SlaDeadline,
                     TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED",
                     CreatedAt = e.CreatedAt,
@@ -335,8 +332,8 @@ public class S8ExceptionService : ITransient
                 OccurrenceDeptId = e.OccurrenceDeptId,
                 ResponsibleDeptId = e.ResponsibleDeptId,
                 ResponsibleGroupId = e.ResponsibleGroupId,
-                AssigneeId = e.AssigneeId,
-                ReporterId = e.ReporterId,
+                AssigneeUserId = e.AssigneeUserId,
+                ReporterUserId = e.ReporterUserId,
                 SlaDeadline = e.SlaDeadline,
                 // S8-SLA-TIMEOUT-RUNTIME-1(P3):详情展示 TimeoutFlag = 在线计算。
                 TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED",
@@ -346,7 +343,7 @@ public class S8ExceptionService : ITransient
                 UpdatedAt = e.UpdatedAt,
                 ActiveFlowInstanceId = e.ActiveFlowInstanceId,
                 ActiveFlowBizType = e.ActiveFlowBizType,
-                VerifierId = e.VerifierId,
+                VerifierUserId = e.VerifierUserId,
                 VerificationAssignedAt = e.VerificationAssignedAt,
                 VerifiedAt = e.VerifiedAt,
                 VerificationResult = e.VerificationResult,
@@ -409,14 +406,6 @@ public class S8ExceptionService : ITransient
             .Distinct()
             .ToList();
 
-        var empIds = list
-            .Select(x => x.AssigneeId ?? 0L)
-            .Concat(list.OfType<AdoS8ExceptionDetailDto>().Select(x => x.ReporterId ?? 0L))
-            .Concat(list.OfType<AdoS8ExceptionDetailDto>().Select(x => x.VerifierId ?? 0L))
-            .Where(x => x > 0)
-            .Distinct()
-            .ToList();
-
         // S8-DEPARTMENT-LOOKUP-CLOSURE-1(B-1):部门水合曾用**全局单值配置** S8MasterData.DepartmentFactoryRefId
         // 解析主数据 factory,在多租户下必然只能服务一个租户;已废除,改用当前请求的 trusted 边界。
         //
@@ -433,53 +422,51 @@ public class S8ExceptionService : ITransient
                 .ToListAsync())
                 .ToDictionary(x => x.Id, x => x.Name);
 
-        // EmployeeMaster.tenant_id 与 SysUser.TenantId 历史错位(797403760988229 ≠ 系统租户),
-        // 走全局 multi-tenant filter 会全过滤为空 → 详情页处理人/检验人姓名显示数字 ID。
-        // 与 S8MasterDataAdapter.GetEmployeesAsync / S8TaskFlowService.GetEmployeeSysUserIdAsync 同口径,
-        // 局部 ClearFilter + employee.Id 主键集合作为安全边界,无跨租户泄漏。
-        var empMap = empIds.Count == 0
-            ? new Dictionary<long, string>()
-            : (await _empRep.AsQueryable().ClearFilter()
-                .Where(x => empIds.Contains(x.Id))
-                .Select(x => new { x.Id, Name = x.Name ?? x.Employee })
-                .ToListAsync())
-                .ToDictionary(x => x.Id, x => x.Name);
-
-        // S8-REPORTER-IDSPACE-FIX-1(P0-B-1):ReporterId 新协议 = EmployeeMaster.RecID(与 assignee/verifier 同 idspace)。
-        // 水合优先级:EmployeeMaster.Name → fallback SysUser.RealName/Account(兼容旧数据 reporter=SysUser.Id)。
-        // 旧注释 “ReporterId 现在记录 sysUser.Id” 已失效,由本批协议替代。
-        var reporterUserIds = list.OfType<AdoS8ExceptionDetailDto>()
-            .Select(x => x.ReporterId ?? 0L)
+        // S8-SYSUSER-ONLY-1:处理人 / 检验人 / 提报人现在同为 SysUser.Id,一次水合三个字段。
+        //
+        // 原实现要查两张表:EmployeeMaster(assignee/verifier,且 ClearFilter 无租户谓词)
+        // 与 SysUser(reporter 的兼容兜底),并按"empMap 优先、userMap 兜底"合并 ——
+        // 那正是同一列曾经装过两套 ID 的证据。现在只有一个 ID 空间、一次查询、一个租户边界。
+        var personIds = list
+            .SelectMany(x => new[]
+            {
+                x.AssigneeUserId ?? 0L,
+                (x as AdoS8ExceptionDetailDto)?.VerifierUserId ?? 0L,
+                (x as AdoS8ExceptionDetailDto)?.ReporterUserId ?? 0L
+            })
             .Where(x => x > 0)
             .Distinct()
             .ToList();
-        var reporterUserMap = reporterUserIds.Count == 0
-            ? new Dictionary<long, string>()
-            : (await _sysUserRep.AsQueryable().ClearFilter()
-                .Where(u => reporterUserIds.Contains(u.Id))
-                .Select(u => new { u.Id, u.RealName, u.Account })
-                .ToListAsync())
-                .ToDictionary(
-                    u => u.Id,
-                    u => !string.IsNullOrWhiteSpace(u.RealName) ? u.RealName : u.Account);
+
+        // 判据与认领 / 转派 / 复检完全同源(见 S8UserScopeValidator):租户内 + 启用。
+        // 停用账号解析不出名字 → 回落 UnresolvedLookupLabel,与部门的处理一致。
+        var personMap = await _userScope.ResolveDisplayAsync(tenantId, personIds);
 
         foreach (var row in list)
         {
             row.ResponsibleDeptName = ResolveDeptName(row.ResponsibleDeptId, deptMap);
             row.OccurrenceDeptName = ResolveDeptName(row.OccurrenceDeptId, deptMap);
-            row.AssigneeName = row.AssigneeId.HasValue ? empMap.GetValueOrDefault(row.AssigneeId.Value) : null;
+            row.AssigneeName = ResolvePersonName(row.AssigneeUserId, personMap);
 
             if (row is AdoS8ExceptionDetailDto detail)
             {
-                // S8-REPORTER-IDSPACE-FIX-1:empMap(EmployeeMaster.RecID)优先,reporterUserMap(SysUser.Id)兜底兼容旧数据。
-                detail.ReporterName = detail.ReporterId.HasValue
-                    ? (empMap.GetValueOrDefault(detail.ReporterId.Value) ?? reporterUserMap.GetValueOrDefault(detail.ReporterId.Value))
-                    : null;
-                detail.VerifierName = detail.VerifierId.HasValue ? empMap.GetValueOrDefault(detail.VerifierId.Value) : null;
+                detail.ReporterName = ResolvePersonName(detail.ReporterUserId, personMap);
+                detail.VerifierName = ResolvePersonName(detail.VerifierUserId, personMap);
             }
         }
     }
 
+    /// <summary>
+    /// 人员名水合。空 = 未指派(返回 null,前端按"未指派"展示);
+    /// 有值但解析不到 = 账号已停用或已被移出本租户 → 与部门同口径回落业务可读文案,
+    /// <b>绝不回落成裸数字 ID</b>(那对业务用户没有任何意义)。
+    /// </summary>
+    private static string? ResolvePersonName(long? userId, IReadOnlyDictionary<long, S8OperatorUser> map)
+    {
+        if (userId is not > 0) return null;
+        return map.TryGetValue(userId.Value, out var u) ? u.DisplayName : UnresolvedLookupLabel;
+    }
+
     // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-3):dept fallback —— 0/缺失=未归属。
     // S8-EXCEPTION-FIRST-VIEW-CLOSURE-1(CC-2):解析不到主数据时不再回落「部门ID:{id}」——
     // 裸内部 ID 对业务用户无意义,且只可能出现在 FK 悬空的历史行上,统一显示为业务可读文案。

+ 46 - 81
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ManualReportService.cs

@@ -82,7 +82,6 @@ public class S8ManualReportService : ITransient
     private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
     private readonly SqlSugarRepository<AdoS0LineMaster> _lineRep;
     private readonly SqlSugarRepository<AdoS8ExceptionType> _typeRep;
-    private readonly SqlSugarRepository<AdoS0EmployeeMaster> _empRep;
     // S8-AUTO-WATCH-DEPT-RESOLVE-1(P2):自动建单部门 resolver 需读取 watch_rule.params_json 里的默认部门字段。
     private readonly SqlSugarRepository<AdoS8WatchRule> _ruleRep;
     private readonly ISqlSugarClient _db;
@@ -100,7 +99,6 @@ public class S8ManualReportService : ITransient
         SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
         SqlSugarRepository<AdoS0LineMaster> lineRep,
         SqlSugarRepository<AdoS8ExceptionType> typeRep,
-        SqlSugarRepository<AdoS0EmployeeMaster> empRep,
         SqlSugarRepository<AdoS8WatchRule> ruleRep,
         ISqlSugarClient db,
         UserManager userManager,
@@ -115,7 +113,6 @@ public class S8ManualReportService : ITransient
         _deptRep = deptRep;
         _lineRep = lineRep;
         _typeRep = typeRep;
-        _empRep = empRep;
         _ruleRep = ruleRep;
         _db = db;
         _userManager = userManager;
@@ -307,68 +304,39 @@ public class S8ManualReportService : ITransient
         return null;
     }
 
-    // S8-REPORTER-IDSPACE-FIX-1(P0-B-1):把当前登录 SysUser.Id 反查 EmployeeMaster.RecID。
-    // 用于 reporter_id idspace 与 assignee_id / verifier_id 对齐。
-    // 协议:sys_user_id + factory_ref_id 双键命中;EmployeeMaster.tenant_id 与 SysUser.TenantId 历史错位 →
-    // ClearFilter,由 factoryRefId 做硬边界(与 S8MasterDataAdapter / S8TaskFlowService 同口径)。
-    // 未绑定时返回 null,调用方负责诊断日志。
-    private async Task<long?> ResolveCurrentEmployeeIdAsync(long tenantId, long sysUserId)
-    {
-        if (sysUserId <= 0 || tenantId <= 0) return null;
-        var emp = await _empRep.AsQueryable().ClearFilter()
-            .Where(x => x.SysUserId == sysUserId && x.TenantId == tenantId && x.IsActive)
-            .Select(x => new { x.Id })
-            .FirstAsync();
-        return emp?.Id;
-    }
-
-    // S8-MANUAL-REPORT-DEFAULT-OCCURRENCE-DEPT-1(P1):取当前登录员工的业务部门作为默认发生部门。
-    // 链路:SysUser.Id → EmployeeMaster.sys_user_id → EmployeeMaster.Department(string)
-    //        + EmployeeMaster.tenant_id → DepartmentMaster.Department + tenant_id → RecID(S8-TENANT-ONLY-BATCH6)。
-    // 不绑定 / 部门为空 / 部门未匹配 / 命中多条:均返回 (null,null) + warning,绝不报错也不阻断页面。
-    // 仅作为前端 form 默认初值;用户仍可改,CreateAsync 的 dept=0 guard 不变。
-    private async Task<(long? DeptId, string? DeptName)> ResolveCurrentEmployeeDefaultDeptAsync(long tenantId, long sysUserId)
+    /// <summary>
+    /// 默认发生部门 = 该账号在本租户最近一次人工提报所选的发生部门(且该部门当前仍有效)。
+    /// 只作前端表单初值;无历史 / 部门已停用 / 跨租户一律返回 (null, null),不报错、不阻断页面。
+    /// </summary>
+    private async Task<(long? DeptId, string? DeptName)> ResolveLastReportedOccurrenceDeptAsync(long tenantId, long sysUserId)
     {
         if (sysUserId <= 0 || tenantId <= 0) return (null, null);
-        var emp = await _empRep.AsQueryable().ClearFilter()
-            .Where(x => x.SysUserId == sysUserId && x.TenantId == tenantId && x.IsActive)
-            .Select(x => new { x.Id, x.Department })
+
+        var lastDeptId = await _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId
+                        && x.ReporterUserId == sysUserId
+                        && x.SourceType == "MANUAL"
+                        && x.OccurrenceDeptId > 0
+                        && !x.IsDeleted)
+            .OrderByDescending(x => x.Id)
+            .Select(x => (long?)x.OccurrenceDeptId)
             .FirstAsync();
-        if (emp == null)
-        {
-            _logger.LogWarning(
-                "s8_manual_report_default_dept_unbound_employee sysUserId={SysUserId} tenantId={TenantId}",
-                sysUserId, tenantId);
-            return (null, null);
-        }
-        if (string.IsNullOrWhiteSpace(emp.Department))
-        {
-            _logger.LogWarning(
-                "s8_manual_report_default_dept_employee_dept_empty sysUserId={SysUserId} tenantId={TenantId} employeeId={EmployeeId}",
-                sysUserId, tenantId, emp.Id);
-            return (null, null);
-        }
-        var depts = await _deptRep.AsQueryable().ClearFilter()
-            .Where(x => x.Department == emp.Department && x.TenantId == tenantId && x.IsActive)
+        if (lastDeptId is not > 0) return (null, null);
+
+        // 部门可能事后被停用 / 归属改变;回填前按当前有效性复核一次。
+        // ClearFilter:判据由本查询显式声明(tenant_id == 可信租户),不依赖全局 multi-tenant AOP。
+        var dept = await _deptRep.AsQueryable().ClearFilter()
+            .Where(x => x.Id == lastDeptId.Value && x.TenantId == tenantId && x.IsActive)
             .Select(x => new { x.Id, x.Department, x.Descr })
-            .Take(2)
-            .ToListAsync();
-        if (depts.Count == 0)
-        {
-            _logger.LogWarning(
-                "s8_manual_report_default_dept_unmatched sysUserId={SysUserId} tenantId={TenantId} employeeId={EmployeeId} departmentCode={DeptCode}",
-                sysUserId, tenantId, emp.Id, emp.Department);
-            return (null, null);
-        }
-        if (depts.Count > 1)
+            .FirstAsync();
+        if (dept == null)
         {
-            _logger.LogWarning(
-                "s8_manual_report_default_dept_ambiguous sysUserId={SysUserId} tenantId={TenantId} employeeId={EmployeeId} departmentCode={DeptCode} matchedCount={Count}",
-                sysUserId, tenantId, emp.Id, emp.Department, depts.Count);
+            _logger.LogInformation(
+                "s8_manual_report_default_dept_stale sysUserId={SysUserId} tenantId={TenantId} deptId={DeptId}",
+                sysUserId, tenantId, lastDeptId.Value);
             return (null, null);
         }
-        var d = depts[0];
-        return (d.Id, string.IsNullOrWhiteSpace(d.Descr) ? d.Department : d.Descr);
+        return (dept.Id, string.IsNullOrWhiteSpace(dept.Descr) ? dept.Department : dept.Descr);
     }
 
     // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:维度字段辅助。
@@ -495,11 +463,19 @@ public class S8ManualReportService : ITransient
             .Select(x => new { value = x.Id, label = x.Describe ?? x.Line })
             .ToListAsync();
 
-        // S8-MANUAL-REPORT-DEFAULT-OCCURRENCE-DEPT-1(P1):默认发生部门 = 当前登录员工的业务部门。
-        // 未登录 / 未绑定 / 部门不可解析 → 返回 null,前端继续要求用户手动选择。
+        // S8-SYSUSER-ONLY-1:默认发生部门改为「本人上一次提报选的部门」。
+        //
+        // 原实现是 SysUser → EmployeeMaster.sys_user_id → Employee.Department(字符串) → DepartmentMaster,
+        // 整条链的第一跳就依赖员工绑定 —— 真库 1105 名员工只有 8 个绑了账号,
+        // 也就是说这个"默认值"对 99.3% 的人从来没生效过。SysUser 侧不存在指向 DepartmentMaster 的关系
+        // (SysUser.OrgId 指的是 SysOrg 公司/工厂树,与 DepartmentMaster 是两棵不相干的树),
+        // 因此不用 SysOrg 硬凑一个近似值。
+        //
+        // 新口径只依赖 S8 自己的数据,且必须**同租户 + 部门当前仍有效**才回填;
+        // 解析不到就返回 null,前端继续要求手动选择(与原行为一致,CreateAsync 的 dept>0 guard 不变)。
         var sysUserId = _userManager.UserId;
         var (defaultOccDeptId, defaultOccDeptName) = sysUserId > 0
-            ? await ResolveCurrentEmployeeDefaultDeptAsync(tenantId, sysUserId)
+            ? await ResolveLastReportedOccurrenceDeptAsync(tenantId, sysUserId)
             : (null, null);
 
         return new
@@ -538,20 +514,9 @@ public class S8ManualReportService : ITransient
         var severity = S8SeverityCode.Normalize(rawSeverity);
 
         // 提报人以服务端登录上下文为准,忽略前端传入;未登录上下文落 null。
-        // currentUserId 仍是 SysUser.Id,仅用作 timeline.OperatorId(与 S8TaskFlowService 同口径);
-        // S8-REPORTER-IDSPACE-FIX-1:ReporterId 写入改用 EmployeeMaster.RecID(与 assignee/verifier 同 idspace)。
+        // S8-SYSUSER-ONLY-1:reporter 与 timeline operator 现在是同一个值、同一个 ID 空间,
+        // 不再经 EmployeeMaster 换算(换不到就写 null 的那条静默失败路径随之消失)。
         var currentUserId = _userManager.UserId > 0 ? _userManager.UserId : (long?)null;
-        long? currentEmployeeId = null;
-        if (currentUserId.HasValue)
-        {
-            currentEmployeeId = await ResolveCurrentEmployeeIdAsync(tenantId, currentUserId.Value);
-            if (currentEmployeeId == null)
-            {
-                _logger.LogWarning(
-                    "manual_report_reporter_unbound sysUserId={SysUserId} tenantId={TenantId} title={Title}",
-                    currentUserId.Value, tenantId, dto.Title);
-            }
-        }
 
         // 主动提报无前端 type 字段,按场景兜底推断;保证不进"未分类"桶。
         var inferredType = await InferExceptionTypeCodeAsync(tenantId, dto.SceneCode.Trim());
@@ -590,8 +555,8 @@ public class S8ManualReportService : ITransient
             PriorityLevel = "P3",
             OccurrenceDeptId = dto.OccurrenceDeptId,
             ResponsibleDeptId = dto.ResponsibleDeptId,
-            // S8-REPORTER-IDSPACE-FIX-1:reporter_id idspace = EmployeeMaster.RecID(与 assignee/verifier 同)。
-            ReporterId = currentEmployeeId,
+            // S8-SYSUSER-ONLY-1:reporter_user_id idspace = SysUser.Id(与 assignee/verifier/timeline 同)。
+            ReporterUserId = currentUserId,
             ExceptionTypeCode = inferredType,
             ModuleCode = resolvedModule,
             // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段 process_node_code 留空,
@@ -620,7 +585,7 @@ public class S8ManualReportService : ITransient
                 ActionLabel = "创建",
                 FromStatus = null,
                 ToStatus = "NEW",
-                OperatorId = currentUserId,
+                OperatorUserId = currentUserId,
                 ActionRemark = "主动提报",
                 CreatedAt = DateTime.Now
             });
@@ -717,7 +682,7 @@ public class S8ManualReportService : ITransient
             // S8-AUTO-WATCH-DEPT-RESOLVE-1:经 resolver 严格校验后写入;不再使用 ?? 0 兜底。
             OccurrenceDeptId = deptResolution.OccurrenceDeptId.Value,
             ResponsibleDeptId = deptResolution.ResponsibleDeptId.Value,
-            ReporterId = null,
+            ReporterUserId = null,
             CreatedAt = now,
             // S8-SLA-TIMEOUT-RUNTIME-1:sla_deadline 由 exception_type.sla_minutes 决定(EQUIP_FAULT)。
             SlaDeadline = await ResolveSlaDeadlineAsync(tenantId, "EQUIP_FAULT", now),
@@ -757,7 +722,7 @@ public class S8ManualReportService : ITransient
                 ActionLabel = "创建",
                 FromStatus = null,
                 ToStatus = "NEW",
-                OperatorId = null,
+                OperatorUserId = null,
                 ActionRemark = "自动建单",
                 CreatedAt = DateTime.Now
             });
@@ -849,7 +814,7 @@ public class S8ManualReportService : ITransient
             // S8-AUTO-WATCH-DEPT-RESOLVE-1:经 resolver 严格校验后写入;不再使用 ?? 0 兜底。
             OccurrenceDeptId = deptResolution.OccurrenceDeptId.Value,
             ResponsibleDeptId = deptResolution.ResponsibleDeptId.Value,
-            ReporterId = null,
+            ReporterUserId = null,
             CreatedAt = now,
             // S8-SLA-TIMEOUT-RUNTIME-1:sla_deadline 来自 hit.ExceptionTypeCode 对应 sla_minutes。
             SlaDeadline = slaDeadline,
@@ -893,7 +858,7 @@ public class S8ManualReportService : ITransient
                 ActionLabel = "创建",
                 FromStatus = null,
                 ToStatus = "NEW",
-                OperatorId = null,
+                OperatorUserId = null,
                 ActionRemark = "自动建单(R2)",
                 CreatedAt = DateTime.Now
             });

+ 31 - 50
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8MasterDataAdapter.cs

@@ -1,6 +1,7 @@
 using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
 using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
 
@@ -11,28 +12,26 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8;
 /// **一律由 <see cref="S8TrustedScopeResolver"/> 从认证身份解析**,
 /// 客户端传入的 <c>factoryRefId</c> 只被视为筛选意图,**当前实现直接忽略、不承担授权作用**。</para>
 ///
-/// <para>修复前(实测可被普通租户账号利用):<c>GetEmployeesAsync</c> 是
-/// <c>ClearFilter()</c> + <c>FactoryRefId == 客户端传入值</c>,
-/// 于是 <c>GET /master-data/employees?factoryRefId=1000</c> 会返回 500 条**其它租户**员工的
-/// 工号与真实姓名。</para>
+/// <para><b>S8-SYSUSER-ONLY-1</b>:<c>GetEmployeesAsync</c> 已退役,由
+/// <see cref="GetOperatorUsersAsync"/> 取代 —— S8 的"人"只有系统账号一种。</para>
 /// </summary>
 public class S8MasterDataAdapter : ITransient
 {
     private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
-    private readonly SqlSugarRepository<AdoS0EmployeeMaster> _empRep;
     private readonly SqlSugarRepository<AdoS0LineMaster> _lineRep;
     private readonly SqlSugarRepository<SysUser> _sysUserRep;
+    private readonly IS8UserScopeValidator _userScope;
 
     public S8MasterDataAdapter(
         SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
-        SqlSugarRepository<AdoS0EmployeeMaster> empRep,
         SqlSugarRepository<AdoS0LineMaster> lineRep,
-        SqlSugarRepository<SysUser> sysUserRep)
+        SqlSugarRepository<SysUser> sysUserRep,
+        IS8UserScopeValidator userScope)
     {
         _deptRep = deptRep;
-        _empRep = empRep;
         _lineRep = lineRep;
         _sysUserRep = sysUserRep;
+        _userScope = userScope;
     }
 
     /// <summary>
@@ -54,50 +53,32 @@ public class S8MasterDataAdapter : ITransient
             .Select(x => new { id = x.Id, code = x.Department, name = x.Descr ?? x.Department })
             .ToListAsync();
 
-    public async Task<object> GetEmployeesAsync(S8TrustedScope scope)
+    /// <summary>
+    /// S8 人员选择器的<b>唯一</b>数据源:当前租户的<b>启用系统账号</b>。
+    ///
+    /// <para>S8-SYSUSER-ONLY-1:替代原 <c>GetEmployeesAsync</c>。原实现列的是
+    /// <c>EmployeeMaster</c>,每行还挂一个 <c>bindStatus: BOUND/UNBOUND</c> —— 也就是说,
+    /// 下拉框里绝大多数选项<b>选了也没用</b>:真库 1105 名员工只有 8 个绑了账号,
+    /// 选中未绑定的员工后通知发不出去、审批流转办静默跳过,而页面上没有任何提示。
+    /// 现在列的就是账号本身,<b>不存在"选了但用不了"的选项</b>。</para>
+    ///
+    /// <para>边界只有租户,与 <see cref="GetDepartmentsAsync"/> 同因:
+    /// <c>factory_ref_id = 1000</c> 横跨两个租户,工厂从来不是隔离维度。</para>
+    /// </summary>
+    public async Task<object> GetOperatorUsersAsync(S8TrustedScope scope)
     {
-        // ClearFilter 保留:不依赖全局 multi-tenant AOP 的当前行为,边界改由本方法显式声明,
-        // 结果与是否开启 AOP 过滤无关(两者判据同为 tenant_id == 可信租户)。
-        // 边界**只能**来自可信作用域的 TenantId + IsActive,绝不接受调用方传入的 factoryRefId。
-        var emps = await _empRep.AsQueryable().ClearFilter()
-            .Where(x => x.TenantId == scope.TenantId && x.IsActive)
-            .Take(500)
-            .Select(x => new { x.Id, x.Name, x.Employee, x.SysUserId })
-            .ToListAsync();
-
-        var sysUserIds = emps.Where(e => e.SysUserId.HasValue && e.SysUserId.Value > 0)
-            .Select(e => e.SysUserId!.Value).Distinct().ToList();
-
-        Dictionary<long, (string? RealName, string? Account)> userMap = new();
-        if (sysUserIds.Count > 0)
-        {
-            // 姓名水合:主键集合 + 可信租户双重边界(原实现只有主键集合)。
-            var users = await _sysUserRep.AsQueryable().ClearFilter()
-                .Where(u => sysUserIds.Contains(u.Id) && u.TenantId == scope.TenantId)
-                .Select(u => new { u.Id, u.RealName, u.Account })
-                .ToListAsync();
-            userMap = users.ToDictionary(u => u.Id, u => ((string?)u.RealName, (string?)u.Account));
-        }
+        var users = await _userScope.ValidateUsersAsync(
+            scope.TenantId,
+            await _sysUserRep.AsQueryable().ClearFilter()
+                .Where(u => u.TenantId == scope.TenantId)
+                .Take(S8UserScopeValidator.MaxHydrationKeys)
+                .Select(u => u.Id)
+                .ToListAsync());
 
-        return emps.Select(e =>
-        {
-            var bound = e.SysUserId.HasValue && e.SysUserId.Value > 0 && userMap.ContainsKey(e.SysUserId.Value);
-            string? sysUserName = null;
-            if (bound)
-            {
-                var (realName, account) = userMap[e.SysUserId!.Value];
-                sysUserName = !string.IsNullOrWhiteSpace(realName) ? realName : account;
-            }
-            return new
-            {
-                id = e.Id,
-                name = e.Name ?? e.Employee,
-                empCode = e.Employee,
-                sysUserId = e.SysUserId,
-                sysUserName,
-                bindStatus = bound ? "BOUND" : "UNBOUND"
-            };
-        }).ToList();
+        return users
+            .OrderBy(u => u.DisplayName, StringComparer.Ordinal)
+            .Select(u => new { id = u.UserId, name = u.DisplayName, account = u.Account })
+            .ToList();
     }
 
     /// <summary>产线下拉,边界同 <see cref="GetDepartmentsAsync"/>。</summary>

+ 0 - 193
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8OperatorBindingService.cs

@@ -1,193 +0,0 @@
-using Admin.NET.Plugin.AiDOP.Dto.S8;
-using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
-using Admin.NET.Plugin.AiDOP.Infrastructure;
-using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
-
-namespace Admin.NET.Plugin.AiDOP.Service.S8;
-
-/// <summary>
-/// S8 配置页:操作员(员工)↔ 系统账号绑定。
-/// 仅维护 EmployeeMaster.SysUserId 的 1:1 弱关联;不动 SysUser/SysRole/SysUserRole。
-///
-/// <para><b>S8-P0-3-MASTERDATA-TRUSTED-SCOPE-1 作用域合同(五个入口统一)</b>:
-/// 租户与工厂**一律由 <see cref="S8TrustedScopeResolver"/> 从认证身份解析**,
-/// 客户端传入的 <c>factoryRefId</c> / <c>tenantId</c> / <c>body.FactoryRefId</c>
-/// **只被视为筛选意图、不承担任何授权作用,且当前实现直接忽略**。</para>
-///
-/// <para>修复前(实测可被普通租户账号利用):<c>ListAsync</c> 是
-/// <c>ClearFilter()</c> + <c>WhereIF(factoryRefId.HasValue, …)</c> —— 谓词**可选**,
-/// 不传即全库无边界;`GET /config/operator-bindings` 返回 500 行且全部属于其它租户
-/// (工厂 1000 × 498 / 1329900200002 × 2,本租户 0 行)。
-/// <c>BindAsync</c> / <c>UnbindAsync</c> / <c>ListSysUsersAsync</c> 则各自用不同口径,
-/// 出现过「List 用 ClearFilter、Bind 不用」的不一致。</para>
-///
-/// <para>修复后:`EmployeeMaster` 的 <c>ClearFilter()</c> 保留(其 tenant_id 属 S0 业务域,
-/// 与登录 token 的系统租户历史错位,不清会全过滤为 0 行),但**必须**同时带
-/// <c>FactoryRefId == 可信工厂</c> 这一强制硬边界 —— 与 CURRENT.md 的
-/// 「ClearFilter 必须同时具备显式业务边界」铁律一致。</para>
-/// </summary>
-public class S8OperatorBindingService : ITransient
-{
-    private readonly SqlSugarRepository<AdoS0EmployeeMaster> _empRep;
-    private readonly SqlSugarRepository<SysUser> _sysUserRep;
-    private readonly UserManager _userManager;
-
-    public S8OperatorBindingService(
-        SqlSugarRepository<AdoS0EmployeeMaster> empRep,
-        SqlSugarRepository<SysUser> sysUserRep,
-        UserManager userManager)
-    {
-        _empRep = empRep;
-        _sysUserRep = sysUserRep;
-        _userManager = userManager;
-    }
-
-    /// <summary>按可信作用域列出本工厂员工及其绑定状态。factoryRefId 不再由调用方决定。</summary>
-    public async Task<List<AdoS8OperatorBindingRowDto>> ListAsync(S8TrustedScope scope, string? bindStatus, string? keyword)
-    {
-        var emps = await ScopedEmployees(scope)
-            .WhereIF(!string.IsNullOrWhiteSpace(keyword),
-                x => x.Employee.Contains(keyword!) || (x.Name != null && x.Name.Contains(keyword!)))
-            .Take(500)
-            .Select(x => new { x.Id, x.Name, x.Employee, x.FactoryRefId, x.SysUserId })
-            .ToListAsync();
-
-        var sysUserIds = emps.Where(e => e.SysUserId.HasValue && e.SysUserId.Value > 0)
-            .Select(e => e.SysUserId!.Value).Distinct().ToList();
-        var userMap = sysUserIds.Count == 0
-            ? new Dictionary<long, string?>()
-            : (await _sysUserRep.AsQueryable()
-                .Where(u => sysUserIds.Contains(u.Id) && u.TenantId == scope.TenantId)
-                .Select(u => new { u.Id, u.RealName, u.Account })
-                .ToListAsync())
-              .ToDictionary(u => u.Id, u => string.IsNullOrWhiteSpace(u.RealName) ? u.Account : u.RealName);
-
-        var rows = emps.Select(e =>
-        {
-            var bound = e.SysUserId.HasValue && e.SysUserId.Value > 0 && userMap.ContainsKey(e.SysUserId.Value);
-            return new AdoS8OperatorBindingRowDto
-            {
-                EmployeeId = e.Id,
-                EmpCode = e.Employee,
-                Name = e.Name,
-                FactoryRefId = e.FactoryRefId,
-                SysUserId = e.SysUserId,
-                SysUserName = bound ? userMap[e.SysUserId!.Value] : null,
-                BindStatus = bound ? "BOUND" : "UNBOUND"
-            };
-        });
-
-        if (!string.IsNullOrWhiteSpace(bindStatus) && (bindStatus == "BOUND" || bindStatus == "UNBOUND"))
-            rows = rows.Where(r => r.BindStatus == bindStatus);
-
-        return rows
-            .OrderBy(r => r.BindStatus == "BOUND" ? 0 : 1)
-            .ThenBy(r => r.EmpCode)
-            .ToList();
-    }
-
-    public async Task<List<AdoS8ConfigSysUserRowDto>> ListSysUsersAsync(S8TrustedScope scope, string? keyword, long? excludeEmployeeId)
-    {
-        var users = await _sysUserRep.AsQueryable()
-            .Where(u => u.TenantId == scope.TenantId)
-            .WhereIF(!string.IsNullOrWhiteSpace(keyword),
-                u => u.Account.Contains(keyword!) || u.RealName.Contains(keyword!))
-            .Take(200)
-            .Select(u => new { u.Id, u.Account, u.RealName, u.Status })
-            .ToListAsync();
-
-        if (users.Count == 0) return new();
-
-        // 标注哪些 sysUser 已被其它 employee 绑定(用于前端禁选/隐藏);
-        // 当前 employee 自己绑定的 sysUser 在 alreadyBoundEmployeeId == excludeEmployeeId 时会被前端放行。
-        // S8-P0-3:占用查询必须限定在可信工厂内,否则会把其它租户的占用情况泄漏出去。
-        var ids = users.Select(u => u.Id).ToList();
-        var bound = await ScopedEmployees(scope)
-            .Where(e => e.SysUserId.HasValue && ids.Contains(e.SysUserId!.Value))
-            .Select(e => new { e.Id, e.SysUserId })
-            .ToListAsync();
-        var boundMap = bound.GroupBy(b => b.SysUserId!.Value)
-            .ToDictionary(g => g.Key, g => g.First().Id);
-
-        return users.Select(u => new AdoS8ConfigSysUserRowDto
-        {
-            Id = u.Id,
-            Account = u.Account,
-            RealName = u.RealName,
-            Status = (int)u.Status,
-            AlreadyBoundEmployeeId = boundMap.TryGetValue(u.Id, out var empId) ? empId : null
-        }).ToList();
-    }
-
-    public async Task<AdoS8OperatorBindingRowDto> BindAsync(S8TrustedScope scope, AdoS8OperatorBindingCreateDto dto)
-    {
-        if (dto.EmployeeId <= 0) throw new S8BizException("员工 ID 不能为空");
-        if (dto.SysUserId <= 0) throw new S8BizException("系统账号 ID 不能为空");
-
-        // S8-P0-3:员工必须属于可信工厂。跨工厂 / 跨租户的 employeeId 一律按「不存在」处理,
-        // 文案与本租户内真实不存在的情况完全一致,避免被用来探测他租户员工是否存在。
-        var emp = await ScopedEmployee(scope, dto.EmployeeId)
-            ?? throw new S8BizException("员工不存在");
-
-        var user = await _sysUserRep.GetFirstAsync(x => x.Id == dto.SysUserId && x.TenantId == scope.TenantId)
-            ?? throw new S8BizException("系统账号不存在或不在当前租户");
-
-        // 同一 sysUser 不允许绑定到另一个 employee(冲突检测同样限定在可信工厂内)
-        var conflict = await ScopedEmployees(scope)
-            .Where(x => x.SysUserId == dto.SysUserId && x.Id != dto.EmployeeId)
-            .FirstAsync();
-        if (conflict != null)
-            throw new S8BizException($"系统账号 {user.Account} 已绑定至其它员工 {conflict.Name ?? conflict.Employee}");
-
-        emp.SysUserId = dto.SysUserId;
-        emp.UpdateTime = DateTime.Now;
-        emp.UpdateUser = _userManager.Account;
-        await _empRep.AsUpdateable(emp)
-            .UpdateColumns(it => new { it.SysUserId, it.UpdateTime, it.UpdateUser })
-            .ExecuteCommandAsync();
-
-        return new AdoS8OperatorBindingRowDto
-        {
-            EmployeeId = emp.Id,
-            EmpCode = emp.Employee,
-            Name = emp.Name,
-            FactoryRefId = emp.FactoryRefId,
-            SysUserId = dto.SysUserId,
-            SysUserName = string.IsNullOrWhiteSpace(user.RealName) ? user.Account : user.RealName,
-            BindStatus = "BOUND"
-        };
-    }
-
-    public async Task UnbindAsync(S8TrustedScope scope, long employeeId)
-    {
-        if (employeeId <= 0) throw new S8BizException("员工 ID 不能为空");
-        var emp = await ScopedEmployee(scope, employeeId)
-            ?? throw new S8BizException("员工不存在");
-        if (!emp.SysUserId.HasValue) return;
-
-        emp.SysUserId = null;
-        emp.UpdateTime = DateTime.Now;
-        emp.UpdateUser = _userManager.Account;
-        await _empRep.AsUpdateable(emp)
-            .UpdateColumns(it => new { it.SysUserId, it.UpdateTime, it.UpdateUser })
-            .ExecuteCommandAsync();
-    }
-
-    /// <summary>
-    /// S8-P0-3:EmployeeMaster 的唯一合法访问口径。
-    ///
-    /// <para><b>S8-TENANT-ONLY-BATCH6:硬边界由 FactoryRefId 改为 TenantId。</b>
-    /// 工厂号不是隔离维度 —— 真库 <c>factory_ref_id = 1000</c> 横跨两个租户
-    /// (824585161322565 / 797403760988229),原判据允许把另一租户的员工绑定成本租户的处理人。
-    /// 详见 <see cref="S8MasterDataAdapter.GetDepartmentsAsync"/> 的取证说明。</para>
-    ///
-    /// <para>ClearFilter 保留:边界由本方法显式声明,不依赖全局 multi-tenant AOP 的当前行为。
-    /// 谓词不可选、不接受调用方覆盖。</para>
-    /// </summary>
-    private ISugarQueryable<AdoS0EmployeeMaster> ScopedEmployees(S8TrustedScope scope) =>
-        _empRep.AsQueryable().ClearFilter()
-            .Where(x => x.TenantId == scope.TenantId);
-
-    private async Task<AdoS0EmployeeMaster?> ScopedEmployee(S8TrustedScope scope, long employeeId) =>
-        await ScopedEmployees(scope).Where(x => x.Id == employeeId).FirstAsync();
-}

+ 112 - 137
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs

@@ -1,5 +1,4 @@
 using Admin.NET.Core.Service;
-using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Admin.NET.Plugin.ApprovalFlow;
@@ -12,8 +11,7 @@ public class S8TaskFlowService : ITransient
 {
     private readonly SqlSugarRepository<AdoS8Exception> _rep;
     private readonly SqlSugarRepository<AdoS8ExceptionTimeline> _timelineRep;
-    private readonly SqlSugarRepository<AdoS0EmployeeMaster> _employeeRep;
-    private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
+    private readonly IS8UserScopeValidator _userScope;
     private readonly SqlSugarRepository<ApprovalFlowInstance> _flowInstanceRep;
     private readonly SqlSugarRepository<ApprovalFlowTask> _flowTaskRep;
     private readonly FlowEngineService _flowEngine;
@@ -25,8 +23,7 @@ public class S8TaskFlowService : ITransient
     public S8TaskFlowService(
         SqlSugarRepository<AdoS8Exception> rep,
         SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep,
-        SqlSugarRepository<AdoS0EmployeeMaster> employeeRep,
-        SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
+        IS8UserScopeValidator userScope,
         SqlSugarRepository<ApprovalFlowInstance> flowInstanceRep,
         SqlSugarRepository<ApprovalFlowTask> flowTaskRep,
         FlowEngineService flowEngine,
@@ -37,8 +34,7 @@ public class S8TaskFlowService : ITransient
     {
         _rep = rep;
         _timelineRep = timelineRep;
-        _employeeRep = employeeRep;
-        _deptRep = deptRep;
+        _userScope = userScope;
         _flowInstanceRep = flowInstanceRep;
         _flowTaskRep = flowTaskRep;
         _flowEngine = flowEngine;
@@ -48,48 +44,73 @@ public class S8TaskFlowService : ITransient
         _logger = logger;
     }
 
-    public async Task<AdoS8Exception> ClaimAsync(long id, long tenantId, long assigneeId, string? remark)
+    /// <summary>
+    /// 认领。<b>处理人 = 当前登录账号,客户端说了不算。</b>
+    ///
+    /// <para>旧签名收 <c>assigneeId</c>,意味着任何有认领权限的人都能替别人认领
+    /// —— 而「替别人指派」在业务上是另一个动作(<see cref="TransferAsync"/>),
+    /// 有自己的权限位 <c>ExceptionAssign</c>。两者混在一条 API 里,
+    /// 结果是弱权限位能干强权限位的事。</para>
+    ///
+    /// <para><b>First Claim Wins</b>:并发认领由<b>一条带前置条件的 UPDATE</b> 裁决,
+    /// 不靠先 SELECT 再 UPDATE —— 那两步之间的窗口正是两个人同时认领成功的地方。
+    /// 谓词里的 <c>assignee_user_id IS NULL</c> 与状态白名单由数据库在同一行锁内求值,
+    /// 影响行数 0 即表示已被别人抢先。</para>
+    /// </summary>
+    public async Task<AdoS8Exception> ClaimAsync(long id, long tenantId, string? remark)
     {
-        if (assigneeId <= 0) throw new S8BizException("认领需指定处理人 AssigneeId");
+        var currentUserId = GetCurrentUserId();
+        // 认领人必须是本租户的可用账号。当前登录态理论上必然满足,
+        // 但这条断言同时挡住「跨租户 scope 解析出错」这类上游故障。
+        await _userScope.EnsureActiveUserAsync(tenantId, currentUserId, "认领人");
+
         var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在");
         if (!S8StatusRules.IsAllowedTransition(e.Status, "ASSIGNED"))
             throw new S8BizException($"状态 {e.Status} 不可认领");
 
         var fromStatus = e.Status;
-        e.Status = "ASSIGNED";
-        e.AssigneeId = assigneeId;
-        e.AssignedAt = DateTime.Now;
-        e.UpdatedAt = DateTime.Now;
+        var now = DateTime.Now;
 
-        // S8-ASSIGNEE-RESP-DEPT-SYNC-1(P0-B-2):派单同步处理部门 = 处理人所属部门 RecID。
-        // 兜底策略:解析失败保留原 ResponsibleDeptId,仅 warn;避免误覆盖既有归属。
-        var resolvedDeptId = await ResolveEmployeeResponsibleDeptIdAsync(assigneeId, tenantId);
-        if (resolvedDeptId.HasValue)
-        {
-            e.ResponsibleDeptId = resolvedDeptId.Value;
-        }
-        else
+        // ── 原子裁决:整条 UPDATE 就是那把锁 ──
+        var affected = await _rep.Context.Updateable<AdoS8Exception>()
+            .SetColumns(x => new AdoS8Exception
+            {
+                Status = "ASSIGNED",
+                AssigneeUserId = currentUserId,
+                AssignedAt = now,
+                UpdatedAt = now
+            })
+            .Where(x => x.Id == e.Id
+                        && x.TenantId == tenantId
+                        && !x.IsDeleted
+                        && x.Status == fromStatus
+                        && x.AssigneeUserId == null)
+            .ExecuteCommandAsync();
+
+        if (affected == 0)
         {
-            _logger.LogWarning(
-                "s8_claim_dept_unresolved exceptionId={Id} assigneeId={AssigneeId} tenantId={TenantId}",
-                e.Id, assigneeId, tenantId);
+            _logger.LogInformation(
+                "s8_claim_lost_race exceptionId={Id} tenantId={TenantId} userId={UserId} fromStatus={From}",
+                e.Id, tenantId, currentUserId, fromStatus);
+            throw new S8BizException("该异常已被其他人员认领");
         }
 
-        await _rep.AsTenant().UseTranAsync(async () =>
-        {
-            await _rep.UpdateAsync(e);
-            await InsertTimelineAsync(e.Id, "CLAIM", "认领", fromStatus, "ASSIGNED", assigneeId, null, remark);
-        }, ex => throw ex);
+        await InsertTimelineAsync(e.Id, "CLAIM", "认领", fromStatus, "ASSIGNED", currentUserId, null, remark);
 
         // 注:认领仅承接异常,不视为审批流完成。审批流的"通过"在"开始处理"那一步触发。
-        await NotifyAssigneeAsync(e, assigneeId, "S8异常已分派给你");
-        return e;
+        await NotifyUserAsync(e, currentUserId, "S8异常已分派给你");
+        return await LoadAsync(id, tenantId) ?? e;
     }
 
-    public async Task<AdoS8Exception> TransferAsync(long id, long tenantId, long newAssigneeId, string? remark)
+    /// <summary>
+    /// 转派 / 指派。与 <see cref="ClaimAsync"/> 语义分离:这里<b>可以</b>指定别人,
+    /// 因此它挂的是 <c>ExceptionAssign</c> 权限位,而不是 <c>ExceptionClaim</c>。
+    /// 目标必须是<b>当前租户</b>的<b>启用</b>账号,工厂不参与判定。
+    /// </summary>
+    public async Task<AdoS8Exception> TransferAsync(long id, long tenantId, long newAssigneeUserId, string? remark)
     {
         var allowedStatuses = new HashSet<string> { "ASSIGNED", "IN_PROGRESS" };
-        if (newAssigneeId <= 0) throw new S8BizException("转派目标无效");
+        var target = await _userScope.EnsureActiveUserAsync(tenantId, newAssigneeUserId, "转派目标账号");
         var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在");
         if (e.ActiveFlowInstanceId.HasValue)
             throw new S8BizException("审批进行中不可转派");
@@ -100,38 +121,30 @@ public class S8TaskFlowService : ITransient
         if (!allowedStatuses.Contains(e.Status))
             throw new S8BizException($"状态 {e.Status} 不可转派");
 
-        e.AssigneeId = newAssigneeId;
+        e.AssigneeUserId = target.UserId;
         e.UpdatedAt = DateTime.Now;
 
-        // S8-ASSIGNEE-RESP-DEPT-SYNC-1(P0-B-2):转派同步处理部门 = 新处理人所属部门 RecID。
-        // 兜底策略:解析失败保留原 ResponsibleDeptId,仅 warn;避免误覆盖既有归属。
-        var resolvedDeptId = await ResolveEmployeeResponsibleDeptIdAsync(newAssigneeId, tenantId);
-        if (resolvedDeptId.HasValue)
-        {
-            e.ResponsibleDeptId = resolvedDeptId.Value;
-        }
-        else
-        {
-            _logger.LogWarning(
-                "s8_transfer_dept_unresolved exceptionId={Id} newAssigneeId={AssigneeId} tenantId={TenantId}",
-                e.Id, newAssigneeId, tenantId);
-        }
-
+        // S8-SYSUSER-ONLY-1:原「转派同步处理部门 = 新处理人所属部门」已退役。
+        // 那条链路是 EmployeeMaster.Department(字符串) → DepartmentMaster,**只有员工主数据才有部门**;
+        // SysUser 侧没有任何指向 DepartmentMaster 的关系(SysUser.OrgId 指的是 SysOrg 公司/工厂树,
+        // 与 DepartmentMaster 是两棵互不相干的树)。在 SysUser-only 模型下它无法成立,
+        // 因此不保留、也不用 SysOrg 硬凑一个近似值 —— 责任部门由建单时确定(规则默认部门 / 人工提报选择),
+        // 需要变更由人显式改,不再随处理人静默漂移。
         await _rep.AsTenant().UseTranAsync(async () =>
         {
             await _rep.UpdateAsync(e);
-            await InsertTimelineAsync(e.Id, "TRANSFER", "转派", e.Status, e.Status, newAssigneeId, null, remark);
+            await InsertTimelineAsync(e.Id, "TRANSFER", "转派", e.Status, e.Status, GetCurrentUserId(), null, remark);
         }, ex => throw ex);
 
-        await NotifyAssigneeAsync(e, newAssigneeId, "S8异常已转派给你");
+        await NotifyUserAsync(e, target.UserId, "S8异常已转派给你");
 
         // 双线合一:S8 转派 = TB001 任务转办给新处理人。
-        await TryTransferIntakeOnTransferAsync(e.Id, newAssigneeId, remark);
+        await TryTransferIntakeOnTransferAsync(e.Id, target.UserId, remark);
 
         return e;
     }
 
-    private async Task TryTransferIntakeOnTransferAsync(long exceptionId, long newAssigneeRecId, string? remark)
+    private async Task TryTransferIntakeOnTransferAsync(long exceptionId, long newAssigneeUserId, string? remark)
     {
         try
         {
@@ -150,26 +163,15 @@ public class S8TaskFlowService : ITransient
                 .FirstAsync();
             if (task == null) return;
 
-            // newAssigneeRecId 是 EmployeeMaster.RecID;FlowEngine.Transfer 要 SysUser.UserId。
-            var targetSysUserId = await _employeeRep.AsQueryable().ClearFilter()
-                .Where(x => x.Id == newAssigneeRecId && x.SysUserId != null)
-                .Select(x => x.SysUserId)
-                .FirstAsync();
-            if (targetSysUserId == null || targetSysUserId == 0)
-            {
-                _logger.LogWarning(
-                    "S8 转派联动审批流跳过:员工 {RecId} 未绑定 SysUser,TB001 任务保留原 assignee",
-                    newAssigneeRecId);
-                return;
-            }
-
-            await _flowEngine.Transfer(task.Id, targetSysUserId.Value, remark ?? "S8 转派(双线合一自动转办)");
+            // S8-SYSUSER-ONLY-1:S8 处理人与 FlowEngine assignee 现在是同一个 ID 空间,
+            // 原来的 EmployeeMaster.SysUserId 桥接(以及它「未绑定就静默跳过」的失败模式)已删除。
+            await _flowEngine.Transfer(task.Id, newAssigneeUserId, remark ?? "S8 转派(双线合一自动转办)");
         }
         catch (Exception ex)
         {
             _logger.LogWarning(ex,
-                "S8 转派时自动转办 TB001 任务失败 exceptionId={Id} newAssigneeRecId={AssigneeId}",
-                exceptionId, newAssigneeRecId);
+                "S8 转派时自动转办 TB001 任务失败 exceptionId={Id} newAssigneeUserId={AssigneeUserId}",
+                exceptionId, newAssigneeUserId);
         }
     }
 
@@ -230,7 +232,7 @@ public class S8TaskFlowService : ITransient
         {
             _logger.LogWarning(ex,
                 "S8 开始处理时自动同意 TB001 任务失败 exceptionId={Id} exceptionCode={Code} userId={UserId} assigneeId={Assignee} status={Status} instanceId={InstanceId} taskId={TaskId} err={Err}",
-                e.Id, e.ExceptionCode, currentUserId, e.AssigneeId, e.Status, instanceId, taskId, ex.Message);
+                e.Id, e.ExceptionCode, currentUserId, e.AssigneeUserId, e.Status, instanceId, taskId, ex.Message);
         }
     }
 
@@ -321,26 +323,24 @@ public class S8TaskFlowService : ITransient
         {
             _logger.LogWarning(ex,
                 "S8 驳回时自动拒绝 TB001 流程失败 exceptionId={Id} exceptionCode={Code} userId={UserId} assigneeId={Assignee} status={Status} instanceId={InstanceId} taskId={TaskId} err={Err}",
-                e.Id, e.ExceptionCode, currentUserId, e.AssigneeId, e.Status, instanceId, taskId, ex.Message);
+                e.Id, e.ExceptionCode, currentUserId, e.AssigneeUserId, e.Status, instanceId, taskId, ex.Message);
         }
     }
 
     public async Task<AdoS8Exception> SubmitVerificationAsync(
         long id, long tenantId,
-        long verifierId, string? remark)
+        long verifierUserId, string? remark)
     {
         var currentUserId = GetCurrentUserId();
         var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在");
-        await EnsureCurrentUserIsOperatorAsync(e.AssigneeId, currentUserId,
-            "只有当前处理人才能提交复检(或当前账号未绑定员工主数据)");
-        if (verifierId <= 0)
-            throw new S8BizException("请选择检验人");
+        EnsureCurrentUserIsOperator(e.AssigneeUserId, currentUserId, "只有当前处理人才能提交复检");
+        var verifier = await _userScope.EnsureActiveUserAsync(tenantId, verifierUserId, "检验人");
         if (!S8StatusRules.IsAllowedTransition(e.Status, "PENDING_VERIFICATION"))
             throw new S8BizException($"状态 {e.Status} 不可提交复检");
 
         var from = e.Status;
         e.Status = "PENDING_VERIFICATION";
-        e.VerifierId = verifierId;
+        e.VerifierUserId = verifier.UserId;
         e.VerificationAssignedAt = DateTime.Now;
         e.UpdatedAt = DateTime.Now;
 
@@ -353,12 +353,12 @@ public class S8TaskFlowService : ITransient
 
         // 双线合一:提交复检 = 启动 EXCEPTION_CLOSURE 流程,指派检验人。
         // 该流程定义复用,作为复检/关闭确认通用审批载体;handler 只做 ActiveFlowInstanceId 维护。
-        await TryStartVerificationFlowAsync(e, verifierId, remark);
+        await TryStartVerificationFlowAsync(e, verifier.UserId, remark);
 
         return await LoadAsync(id, tenantId) ?? e;
     }
 
-    private async Task TryStartVerificationFlowAsync(AdoS8Exception e, long verifierRecId, string? remark)
+    private async Task TryStartVerificationFlowAsync(AdoS8Exception e, long verifierUserId, string? remark)
     {
         try
         {
@@ -372,15 +372,15 @@ public class S8TaskFlowService : ITransient
                 BizData = new Dictionary<string, object>
                 {
                     ["sceneCode"] = e.SceneCode ?? string.Empty,
-                    ["verifierRecId"] = verifierRecId,
+                    ["verifierUserId"] = verifierUserId,
                 }
             });
         }
         catch (Exception ex)
         {
             _logger.LogWarning(ex,
-                "S8 提交复检时启动 EXCEPTION_CLOSURE 流程失败 exceptionId={Id} verifierRecId={VerifierId}",
-                e.Id, verifierRecId);
+                "S8 提交复检时启动 EXCEPTION_CLOSURE 流程失败 exceptionId={Id} verifierUserId={VerifierUserId}",
+                e.Id, verifierUserId);
         }
     }
 
@@ -390,8 +390,7 @@ public class S8TaskFlowService : ITransient
     {
         var currentUserId = GetCurrentUserId();
         var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在");
-        await EnsureCurrentUserIsOperatorAsync(e.VerifierId, currentUserId,
-            "只有指定检验人才能检验通过(或当前账号未绑定员工主数据)");
+        EnsureCurrentUserIsOperator(e.VerifierUserId, currentUserId, "只有指定检验人才能检验通过");
         if (!S8StatusRules.IsAllowedTransition(e.Status, "CLOSED"))
             throw new S8BizException($"状态 {e.Status} 不可检验通过");
 
@@ -503,8 +502,7 @@ public class S8TaskFlowService : ITransient
     {
         var currentUserId = GetCurrentUserId();
         var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在");
-        await EnsureCurrentUserIsOperatorAsync(e.VerifierId, currentUserId,
-            "只有指定检验人才能检验退回(或当前账号未绑定员工主数据)");
+        EnsureCurrentUserIsOperator(e.VerifierUserId, currentUserId, "只有指定检验人才能检验退回");
         if (!S8StatusRules.IsAllowedTransition(e.Status, "IN_PROGRESS"))
             throw new S8BizException($"状态 {e.Status} 不可检验退回");
         if (string.IsNullOrWhiteSpace(remark))
@@ -579,40 +577,18 @@ public class S8TaskFlowService : ITransient
         return currentUserId;
     }
 
-    // S8-ASSIGNEE-RESP-DEPT-SYNC-1(P0-B-2):处理人 RecID → 所属部门 RecID。
-    // 链路:EmployeeMaster.Department(codename) + tenant_id → DepartmentMaster.Department + tenant_id → RecID
-    // ClearFilter 同口径(S8MasterDataAdapter / GetEmployeeSysUserIdAsync),tenantId 做硬边界。
-    //
-    // S8-TENANT-ONLY-BATCH6:硬边界由 factoryId 改为 tenantId。按 factory 解析在真库里会跨租户——
-    // factory_ref_id = 1000 同时属于 824585161322565 与 797403760988229
-    // 于是「认领本租户异常」可能把另一租户的部门 RecID 写进 ResponsibleDeptId
-    //
-    // 解析失败返回 null —— 调用方按"保留原值 + warn"处理,绝不覆盖既有 ResponsibleDeptId。
-    private async Task<long?> ResolveEmployeeResponsibleDeptIdAsync(long assigneeId, long tenantId)
+    /// <summary>
+    /// 站内信通知某个<b>系统账号</b>
+    ///
+    /// <para>S8-SYSUSER-ONLY-1:原实现是 <c>NotifyAssigneeAsync(employeeRecId)</c> ——
+    /// 先经 <c>EmployeeMaster.SysUserId</c> 换算成账号才能投递,换不到就打一行
+    /// <c>s8_assignee_notice_unmapped</c> 然后<b>静默不发</b>。真库 1105 名员工里只有 8 个绑了账号
+    /// 也就是说这条路径在绝大多数情况下根本发不出通知,而调用方看到的是"成功"
+    /// 现在处理人本身就是账号,桥和它的失败模式一起删除。</para>
+    /// </summary>
+    private async Task NotifyUserAsync(AdoS8Exception exception, long sysUserId, string title)
     {
-        if (assigneeId <= 0 || tenantId <= 0) return null;
-        var emp = await _employeeRep.AsQueryable().ClearFilter()
-            .Where(x => x.Id == assigneeId && x.TenantId == tenantId)
-            .Select(x => new { x.Department })
-            .FirstAsync();
-        if (emp == null || string.IsNullOrWhiteSpace(emp.Department)) return null;
-        var deptId = await _deptRep.AsQueryable().ClearFilter()
-            .Where(x => x.Department == emp.Department && x.TenantId == tenantId && x.IsActive)
-            .Select(x => (long?)x.Id)
-            .FirstAsync();
-        return deptId;
-    }
-
-    private async Task NotifyAssigneeAsync(AdoS8Exception exception, long employeeRecId, string title)
-    {
-        var sysUserId = await GetEmployeeSysUserIdAsync(employeeRecId);
-        if (sysUserId is not > 0)
-        {
-            _logger.LogWarning(
-                "s8_assignee_notice_unmapped exceptionId={Id} employeeRecId={EmployeeRecId} title={Title}",
-                exception.Id, employeeRecId, title);
-            return;
-        }
+        if (sysUserId <= 0) return;
 
         var jump = $"/#/aidop/s8/exceptions/{exception.Id}";
         var content =
@@ -623,7 +599,7 @@ public class S8TaskFlowService : ITransient
             await _noticeService.PublishToUsersAsync(
                 title,
                 content,
-                new[] { sysUserId.Value },
+                new[] { sysUserId },
                 _userManager.UserId,
                 string.IsNullOrWhiteSpace(_userManager.RealName) ? "S8异常监控" : _userManager.RealName);
         }
@@ -633,28 +609,27 @@ public class S8TaskFlowService : ITransient
         }
     }
 
-    // 把异常上的处理人/检验人(employeeId) 经 EmployeeMaster.SysUserId 解析到系统账号 ID。
-    // EmployeeMaster.tenant_id 与 SysUser.TenantId 历史错位,必须 ClearFilter 跳过多租户全局 filter;
-    // 通过 employee.Id 主键精确查询作为安全边界,无跨租户泄漏风险。
-    private async Task<long?> GetEmployeeSysUserIdAsync(long? employeeId)
-    {
-        if (!employeeId.HasValue || employeeId.Value <= 0) return null;
-        var emp = await _employeeRep.AsQueryable().ClearFilter()
-            .Where(x => x.Id == employeeId.Value)
-            .FirstAsync();
-        return emp?.SysUserId;
-    }
-
-    // 鉴权统一入口:要求当前登录用户必须是 employeeId 解析后的 SysUserId。
-    private async Task EnsureCurrentUserIsOperatorAsync(long? employeeId, long currentUserId, string failMessage)
+    /// <summary>
+    /// 鉴权:当前登录账号必须就是单据上记录的那个人。
+    ///
+    /// <para>S8-SYSUSER-ONLY-1:原实现要先把 <c>employeeId</c> 经 <c>EmployeeMaster.SysUserId</c>
+    /// 解析成账号再比对,于是"未绑定员工主数据"和"你不是这个人"共用同一句报错 ——
+    /// 两种完全不同的问题给出同一个提示。现在两边同为 <c>SysUser.Id</c>,直接比较即可,
+    /// 不需要查库,也不再有第三种失败模式。</para>
+    /// </summary>
+    private static void EnsureCurrentUserIsOperator(long? ownerUserId, long currentUserId, string failMessage)
     {
-        var ownerSysUserId = await GetEmployeeSysUserIdAsync(employeeId);
-        if (ownerSysUserId != currentUserId)
+        if (ownerUserId != currentUserId)
             throw new S8BizException(failMessage);
     }
 
+    /// <summary>
+    /// 写时间线。<paramref name="operatorUserId"/> <b>只接受 <c>SysUser.Id</c></b>;
+    /// 系统自动动作传 null,<b>绝不允许伪造一个 ID</b>(历史上 CLAIM 传的是 EmployeeMaster.RecID,
+    /// 导致同一列混装两套 ID 空间,见 <see cref="AdoS8ExceptionTimeline.OperatorUserId"/>)。
+    /// </summary>
     private async Task InsertTimelineAsync(long exceptionId, string code, string label, string? from, string? to,
-        long? operatorId, string? operatorName, string? remark) =>
+        long? operatorUserId, string? operatorName, string? remark) =>
         await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
         {
             ExceptionId = exceptionId,
@@ -662,7 +637,7 @@ public class S8TaskFlowService : ITransient
             ActionLabel = label,
             FromStatus = from,
             ToStatus = to,
-            OperatorId = operatorId,
+            OperatorUserId = operatorUserId,
             OperatorName = operatorName,
             ActionRemark = remark,
             CreatedAt = DateTime.Now

+ 19 - 37
server/Plugins/Admin.NET.Plugin.AiDOP/Workbench/AidopWorkbenchService.cs

@@ -178,25 +178,19 @@ public sealed class AidopWorkbenchService : ITransient
 
     private async Task<AidopWorkbenchSectionDto> LoadS8Async(SmartOpsTrustedScope scope, int top, AidopWorkbenchCountsDto counts)
     {
-        var recIds = await _db.Queryable<AdoS0EmployeeMaster>().ClearFilter()
-            .Where(x => x.SysUserId == scope.UserId)
-            .Select(x => x.Id)
-            .ToListAsync();
-        if (recIds.Count == 0)
-        {
-            return new AidopWorkbenchSectionDto
-            {
-                Available = false,
-                ErrorCode = "EMPLOYEE_UNBOUND",
-                Message = "当前账号未绑定员工主数据,无法列出个人 S8 事项",
-            };
-        }
+        // S8-SYSUSER-ONLY-1:个人 S8 事项按<b>登录账号</b>取,不再经 EmployeeMaster 换算。
+        //
+        // 原实现先查 EmployeeMaster.sys_user_id,查不到就整段返回
+        // Available=false / EMPLOYEE_UNBOUND / "当前账号未绑定员工主数据"。
+        // 真库 1105 名员工只有 8 个绑了账号 —— 也就是说工作台的 S8 区块对绝大多数人
+        // 从来就是一句错误提示。现在处理人/检验人本身就是账号,这个分支不再存在。
+        var userId = scope.UserId;
 
         var assignedQ = _db.Queryable<AdoS8Exception>()
-            .Where(x => x.TenantId == scope.TenantId && !x.IsDeleted && recIds.Contains(x.AssigneeId ?? 0))
+            .Where(x => x.TenantId == scope.TenantId && !x.IsDeleted && x.AssigneeUserId == userId)
             .Where(x => x.Status != "CLOSED" && x.Status != "REJECTED" && x.Status != "RECOVERED");
         var verifyQ = _db.Queryable<AdoS8Exception>()
-            .Where(x => x.TenantId == scope.TenantId && !x.IsDeleted && recIds.Contains(x.VerifierId ?? 0))
+            .Where(x => x.TenantId == scope.TenantId && !x.IsDeleted && x.VerifierUserId == userId)
             .Where(x => x.Status == "PENDING_VERIFICATION");
         counts.S8Assigned = await assignedQ.Clone().CountAsync();
         counts.S8Verification = await verifyQ.Clone().CountAsync();
@@ -204,13 +198,13 @@ public sealed class AidopWorkbenchService : ITransient
         counts.DueToday += await _db.Queryable<AdoS8Exception>()
             .Where(x => x.TenantId == scope.TenantId && !x.IsDeleted)
             .Where(x => x.Status != "CLOSED" && x.Status != "REJECTED" && x.Status != "RECOVERED")
-            .Where(x => recIds.Contains(x.AssigneeId ?? 0) || recIds.Contains(x.VerifierId ?? 0))
+            .Where(x => x.AssigneeUserId == userId || x.VerifierUserId == userId)
             .Where(x => x.SlaDeadline != null && x.SlaDeadline >= today && x.SlaDeadline < today.AddDays(1))
             .CountAsync();
         counts.OverdueTotal += await _db.Queryable<AdoS8Exception>()
             .Where(x => x.TenantId == scope.TenantId && !x.IsDeleted)
             .Where(x => x.Status != "CLOSED" && x.Status != "REJECTED" && x.Status != "RECOVERED")
-            .Where(x => recIds.Contains(x.AssigneeId ?? 0) || recIds.Contains(x.VerifierId ?? 0))
+            .Where(x => x.AssigneeUserId == userId || x.VerifierUserId == userId)
             .Where(x => x.TimeoutFlag || (x.SlaDeadline != null && x.SlaDeadline < DateTime.Now))
             .CountAsync();
 
@@ -305,13 +299,11 @@ public sealed class AidopWorkbenchService : ITransient
                 .Take(top)
                 .ToListAsync();
 
-        var recIds = await ResolveEmployeeIds(scope.UserId);
+        var userId = scope.UserId;
         var reportedQ = _db.Queryable<AdoS8Exception>()
-            .Where(x => x.TenantId == scope.TenantId && !x.IsDeleted && recIds.Contains(x.ReporterId ?? 0));
-        var reportedCount = recIds.Count == 0 ? 0 : await reportedQ.Clone().CountAsync();
-        var reported = recIds.Count == 0
-            ? new List<AdoS8Exception>()
-            : await reportedQ.Clone().OrderByDescending(x => x.CreatedAt).Take(top).ToListAsync();
+            .Where(x => x.TenantId == scope.TenantId && !x.IsDeleted && x.ReporterUserId == userId);
+        var reportedCount = await reportedQ.Clone().CountAsync();
+        var reported = await reportedQ.Clone().OrderByDescending(x => x.CreatedAt).Take(top).ToListAsync();
 
         var flowQ = _db.Queryable<ApprovalFlowInstance>()
             .Where(x => x.InitiatorId == scope.UserId)
@@ -378,15 +370,13 @@ public sealed class AidopWorkbenchService : ITransient
             .Take(top)
             .ToListAsync();
 
-        var recIds = await ResolveEmployeeIds(scope.UserId);
+        var userId = scope.UserId;
         var closedQ = _db.Queryable<AdoS8Exception>()
             .Where(x => x.TenantId == scope.TenantId && !x.IsDeleted)
             .Where(x => x.Status == "CLOSED" || x.Status == "REJECTED" || x.Status == "RECOVERED")
-            .Where(x => recIds.Contains(x.AssigneeId ?? 0) || recIds.Contains(x.VerifierId ?? 0));
-        var closedCount = recIds.Count == 0 ? 0 : await closedQ.Clone().CountAsync();
-        var closed = recIds.Count == 0
-            ? new List<AdoS8Exception>()
-            : await closedQ.Clone().OrderByDescending(x => x.ClosedAt ?? x.UpdatedAt).Take(top).ToListAsync();
+            .Where(x => x.AssigneeUserId == userId || x.VerifierUserId == userId);
+        var closedCount = await closedQ.Clone().CountAsync();
+        var closed = await closedQ.Clone().OrderByDescending(x => x.ClosedAt ?? x.UpdatedAt).Take(top).ToListAsync();
 
         var donePage = await _flowTasks.MyDonePage(new TaskPageInput { Page = 1, PageSize = top });
         var doneCount = donePage.Total;
@@ -434,14 +424,6 @@ public sealed class AidopWorkbenchService : ITransient
         return new AidopWorkbenchSectionDto { Total = counts.CompletedTotal, Items = items };
     }
 
-    private async Task<List<long>> ResolveEmployeeIds(long userId)
-    {
-        return await _db.Queryable<AdoS0EmployeeMaster>().ClearFilter()
-            .Where(x => x.SysUserId == userId)
-            .Select(x => x.Id)
-            .ToListAsync();
-    }
-
     private static DateTime? ParseDate(string? value) =>
         DateTime.TryParse(value, out var parsed) ? parsed : null;