using Admin.NET.Core.Service; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Admin.NET.Plugin.ApprovalFlow; using Admin.NET.Plugin.ApprovalFlow.Service; using Microsoft.Extensions.Logging; namespace Admin.NET.Plugin.AiDOP.Service.S8; public class S8TaskFlowService : ITransient { private readonly SqlSugarRepository _rep; private readonly SqlSugarRepository _timelineRep; private readonly IS8UserScopeValidator _userScope; private readonly SqlSugarRepository _flowInstanceRep; private readonly SqlSugarRepository _flowTaskRep; private readonly FlowEngineService _flowEngine; private readonly UserManager _userManager; private readonly S8NotificationLayerResolver _notificationLayerResolver; private readonly SysNoticeService _noticeService; private readonly ILogger _logger; public S8TaskFlowService( SqlSugarRepository rep, SqlSugarRepository timelineRep, IS8UserScopeValidator userScope, SqlSugarRepository flowInstanceRep, SqlSugarRepository flowTaskRep, FlowEngineService flowEngine, UserManager userManager, S8NotificationLayerResolver notificationLayerResolver, SysNoticeService noticeService, ILogger logger) { _rep = rep; _timelineRep = timelineRep; _userScope = userScope; _flowInstanceRep = flowInstanceRep; _flowTaskRep = flowTaskRep; _flowEngine = flowEngine; _userManager = userManager; _notificationLayerResolver = notificationLayerResolver; _noticeService = noticeService; _logger = logger; } /// /// 认领。处理人 = 当前登录账号,客户端说了不算。 /// /// 旧签名收 assigneeId,意味着任何有认领权限的人都能替别人认领 /// —— 而「替别人指派」在业务上是另一个动作(), /// 有自己的权限位 ExceptionAssign。两者混在一条 API 里, /// 结果是弱权限位能干强权限位的事。 /// /// First Claim Wins:并发认领由一条带前置条件的 UPDATE 裁决, /// 不靠先 SELECT 再 UPDATE —— 那两步之间的窗口正是两个人同时认领成功的地方。 /// 谓词里的 assignee_user_id IS NULL 与状态白名单由数据库在同一行锁内求值, /// 影响行数 0 即表示已被别人抢先。 /// public async Task ClaimAsync(long id, long tenantId, string? remark) { 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; var now = DateTime.Now; // ── 原子裁决:整条 UPDATE 就是那把锁 ── var affected = await _rep.Context.Updateable() .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.LogInformation( "s8_claim_lost_race exceptionId={Id} tenantId={TenantId} userId={UserId} fromStatus={From}", e.Id, tenantId, currentUserId, fromStatus); throw new S8BizException("该异常已被其他人员认领"); } await InsertTimelineAsync(e.Id, "CLAIM", "认领", fromStatus, "ASSIGNED", currentUserId, null, remark); // 注:认领仅承接异常,不视为审批流完成。审批流的"通过"在"开始处理"那一步触发。 await NotifyUserAsync(e, currentUserId, "S8异常已分派给你"); return await LoadAsync(id, tenantId) ?? e; } /// /// 转派 / 指派。与 语义分离:这里可以指定别人, /// 因此它挂的是 ExceptionAssign 权限位,而不是 ExceptionClaim。 /// 目标必须是当前租户启用账号,工厂不参与判定。 /// public async Task TransferAsync(long id, long tenantId, long newAssigneeUserId, string? remark) { var allowedStatuses = new HashSet { "ASSIGNED", "IN_PROGRESS" }; var target = await _userScope.EnsureActiveUserAsync(tenantId, newAssigneeUserId, "转派目标账号"); var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在"); if (e.ActiveFlowInstanceId.HasValue) throw new S8BizException("审批进行中不可转派"); if (e.Status == "ESCALATED") throw new S8BizException("升级审批中不可转派"); if (S8StatusRules.IsTerminal(e.Status)) throw new S8BizException("已关闭不可转派"); if (!allowedStatuses.Contains(e.Status)) throw new S8BizException($"状态 {e.Status} 不可转派"); e.AssigneeUserId = target.UserId; e.UpdatedAt = DateTime.Now; // 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, GetCurrentUserId(), null, remark); }, ex => throw ex); await NotifyUserAsync(e, target.UserId, "S8异常已转派给你"); // 双线合一:S8 转派 = TB001 任务转办给新处理人。 await TryTransferIntakeOnTransferAsync(e.Id, target.UserId, remark); return e; } private async Task TryTransferIntakeOnTransferAsync(long exceptionId, long newAssigneeUserId, string? remark) { try { var instance = await _flowInstanceRep.AsQueryable() .Where(x => x.BizType == "EXCEPTION_REPORT" && x.BizId == exceptionId && x.Status == FlowInstanceStatusEnum.Running) .FirstAsync(); if (instance == null) return; var currentUserId = _userManager.UserId; var task = await _flowTaskRep.AsQueryable() .Where(x => x.InstanceId == instance.Id && x.AssigneeId == currentUserId && x.Status == FlowTaskStatusEnum.Pending) .FirstAsync(); if (task == null) return; // 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} newAssigneeUserId={AssigneeUserId}", exceptionId, newAssigneeUserId); } } public async Task StartProgressAsync(long id, long tenantId, string? remark) { var currentUserId = GetCurrentUserId(); var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在"); if (!S8StatusRules.IsAllowedTransition(e.Status, "IN_PROGRESS")) throw new S8BizException($"状态 {e.Status} 不可开始处理"); var fromStatus = e.Status; e.Status = "IN_PROGRESS"; e.UpdatedAt = DateTime.Now; await _rep.AsTenant().UseTranAsync(async () => { await _rep.UpdateAsync(e); await InsertTimelineAsync(e.Id, "START_PROGRESS", "开始处理", fromStatus, "IN_PROGRESS", currentUserId, null, remark); }, ex => throw ex); // 双线合一:开始处理 = TB001 异常提报审批通过。 // 当前用户必须是 TB001 task 的 AssigneeId,FlowEngine 强校验。 await TryApproveIntakeOnStartProgressAsync(e, currentUserId); return e; } private async Task TryApproveIntakeOnStartProgressAsync(AdoS8Exception e, long currentUserId) { long? instanceId = null, taskId = null; try { // S8-S1-EXCEPTION-FLOW-SYNC-FIX-1:实例/任务查询同样清数据范围过滤(ApprovalFlowInstance/Task 继承 EntityBaseOrg)。 // 非超管用户在 DataScope=Self 时被「CreateUserId==当前用户」过滤、Dept/DeptChild 时被 OrgId 过滤,会查不到 // 由他人(或系统)创建的 flow,双线合一静默失效。ClearFilter() 清全部数据范围过滤;BizType+BizId+assignee 已限定, // 无跨 BizType 误伤;ApprovalFlowInstance/Task 无软删与租户过滤。 var instance = await _flowInstanceRep.AsQueryable() .ClearFilter() .Where(x => x.BizType == "EXCEPTION_REPORT" && x.BizId == e.Id && x.Status == FlowInstanceStatusEnum.Running) .FirstAsync(); if (instance == null) return; instanceId = instance.Id; var task = await _flowTaskRep.AsQueryable() .ClearFilter() .Where(x => x.InstanceId == instance.Id && x.AssigneeId == currentUserId && x.Status == FlowTaskStatusEnum.Pending) .FirstAsync(); if (task == null) return; taskId = task.Id; await _flowEngine.Approve(task.Id, "S8 已开始处理(双线合一自动同意)"); } catch (Exception ex) { _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.AssigneeUserId, e.Status, instanceId, taskId, ex.Message); } } public async Task UpgradeAsync(long id, long tenantId, string? remark) { var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在"); if (e.ActiveFlowInstanceId.HasValue) throw new S8BizException("该异常已有进行中的审批流程,请等待审批完成"); if (!S8StatusRules.IsAllowedTransition(e.Status, "ESCALATED")) throw new S8BizException($"状态 {e.Status} 不可升级"); // S8-EXCEPTION-FLOW-TENANT-CONTEXT-1:UpgradeAsync 本身已持有可信 tenantId(人工点击=HTTP 走 // S8TrustedScopeResolver;S8TimeoutAutoEscalationJob 自动升级=后台 Job 显式传参),统一走 // 受信任重载,不依赖 _userManager.TenantId(后台 Job 场景下恒为 0)。 await _flowEngine.StartFlow(new StartFlowInput { BizType = "EXCEPTION_ESCALATION", BizId = e.Id, Title = $"异常升级审批 - {e.ExceptionCode}", Comment = remark, BizData = new Dictionary { ["severity"] = e.Severity, ["sceneCode"] = e.SceneCode, ["priorityLevel"] = e.PriorityLevel, } }, tenantId); // 状态和时间线由 ExceptionEscalationBizHandler.OnFlowStarted 回调更新 return await LoadAsync(id, tenantId) ?? e; } public async Task RejectAsync(long id, long tenantId, string? remark) { var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在"); if (!S8StatusRules.IsAllowedTransition(e.Status, "REJECTED")) throw new S8BizException($"状态 {e.Status} 不可驳回"); var from = e.Status; e.Status = "REJECTED"; e.UpdatedAt = DateTime.Now; await _rep.AsTenant().UseTranAsync(async () => { await _rep.UpdateAsync(e); await InsertTimelineAsync(e.Id, "REJECT", "驳回", from, "REJECTED", null, null, remark); }, ex => throw ex); // 双线合一:S8 驳回 = TB001 流程整体拒绝(取消所有 pending 任务、Instance 终止)。 await TryRejectIntakeOnRejectAsync(e, remark); return e; } private async Task TryRejectIntakeOnRejectAsync(AdoS8Exception e, string? remark) { var currentUserId = _userManager.UserId; long? instanceId = null, taskId = null; try { // S8-S1-EXCEPTION-FLOW-SYNC-FIX-1:实例/任务查询同样清数据范围过滤(ApprovalFlowInstance/Task 继承 EntityBaseOrg)。 // 非超管用户在 DataScope=Self 时被「CreateUserId==当前用户」过滤、Dept/DeptChild 时被 OrgId 过滤,会查不到 // 由他人(或系统)创建的 flow,双线合一静默失效。ClearFilter() 清全部数据范围过滤;BizType+BizId+assignee 已限定, // 无跨 BizType 误伤;ApprovalFlowInstance/Task 无软删与租户过滤。 var instance = await _flowInstanceRep.AsQueryable() .ClearFilter() .Where(x => x.BizType == "EXCEPTION_REPORT" && x.BizId == e.Id && x.Status == FlowInstanceStatusEnum.Running) .FirstAsync(); if (instance == null) return; instanceId = instance.Id; var task = await _flowTaskRep.AsQueryable() .ClearFilter() .Where(x => x.InstanceId == instance.Id && x.AssigneeId == currentUserId && x.Status == FlowTaskStatusEnum.Pending) .FirstAsync(); if (task == null) return; taskId = task.Id; await _flowEngine.Reject(task.Id, remark ?? "S8 已驳回(双线合一自动拒绝)"); } catch (Exception ex) { _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.AssigneeUserId, e.Status, instanceId, taskId, ex.Message); } } public async Task SubmitVerificationAsync( long id, long tenantId, long verifierUserId, string? remark) { var currentUserId = GetCurrentUserId(); var e = await LoadAsync(id, tenantId) ?? 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.VerifierUserId = verifier.UserId; e.VerificationAssignedAt = DateTime.Now; e.UpdatedAt = DateTime.Now; await _rep.AsTenant().UseTranAsync(async () => { await _rep.UpdateAsync(e); await InsertTimelineAsync(e.Id, "VERIFY_SUBMITTED", "提交复检", from, "PENDING_VERIFICATION", currentUserId, null, remark); }, ex => throw ex); // 双线合一:提交复检 = 启动 EXCEPTION_CLOSURE 流程,指派检验人。 // 该流程定义复用,作为复检/关闭确认通用审批载体;handler 只做 ActiveFlowInstanceId 维护。 await TryStartVerificationFlowAsync(e, verifier.UserId, remark); return await LoadAsync(id, tenantId) ?? e; } private async Task TryStartVerificationFlowAsync(AdoS8Exception e, long verifierUserId, string? remark) { try { await _flowEngine.StartFlow(new StartFlowInput { BizType = "EXCEPTION_CLOSURE", BizId = e.Id, BizNo = e.ExceptionCode, Title = $"异常复检 - {e.ExceptionCode}", Comment = remark, BizData = new Dictionary { ["sceneCode"] = e.SceneCode ?? string.Empty, ["verifierUserId"] = verifierUserId, } }); } catch (Exception ex) { _logger.LogWarning(ex, "S8 提交复检时启动 EXCEPTION_CLOSURE 流程失败 exceptionId={Id} verifierUserId={VerifierUserId}", e.Id, verifierUserId); } } public async Task ApproveVerificationAsync( long id, long tenantId, string? remark) { var currentUserId = GetCurrentUserId(); var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在"); EnsureCurrentUserIsOperator(e.VerifierUserId, currentUserId, "只有指定检验人才能检验通过"); if (!S8StatusRules.IsAllowedTransition(e.Status, "CLOSED")) throw new S8BizException($"状态 {e.Status} 不可检验通过"); var from = e.Status; e.Status = "CLOSED"; e.VerifiedAt = DateTime.Now; e.VerificationResult = "APPROVED"; e.VerificationRemark = remark; e.ClosedAt = DateTime.Now; e.UpdatedAt = DateTime.Now; await _rep.AsTenant().UseTranAsync(async () => { await _rep.UpdateAsync(e); await InsertTimelineAsync(e.Id, "VERIFY_APPROVED", "检验通过", from, "CLOSED", currentUserId, null, remark); }, ex => throw ex); // 双线合一:检验通过 = EXCEPTION_CLOSURE 复检流程审批通过。 await TryApproveVerificationFlowAsync(e.Id, currentUserId); // S8-R03-OVERDUE-CLOSE-NOTICE-1:闭环及时性提醒。 // 触发条件:closedAt > slaDeadline;与 TimeoutFlag 运行时口径分离,仅对已关闭异常做闭环回顾。 // 主流程已 commit(含状态机 + Timeline),异常隔离仅 LogWarning,不影响 CloseAsync。 await TryDispatchOverdueCloseNotificationAsync(e); return e; } /// /// S8-R03-OVERDUE-CLOSE-NOTICE-1:异常关闭后若 closedAt > slaDeadline 触发一条独立"超时关闭"通知。 /// 复用 分层链路,不写 ApprovalFlowNotifyLog。 /// 与 (未关闭超时升级)语义分离:本钩子仅在已关闭后回顾闭环及时性。 /// 任何异常仅 LogWarning,不抛回 ApproveVerificationAsync 主流程。 /// private async Task TryDispatchOverdueCloseNotificationAsync(AdoS8Exception e) { if (e == null || e.Id <= 0) return; if (e.ClosedAt == null || e.SlaDeadline == null) return; if (e.ClosedAt.Value <= e.SlaDeadline.Value) return; try { var overdueCloseHours = Math.Round( (decimal)(e.ClosedAt.Value - e.SlaDeadline.Value).TotalHours, 1); var sceneCode = string.IsNullOrWhiteSpace(e.SceneCode) ? "S8_DEMO_DEFAULT" : e.SceneCode; // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:派发前 Normalize 落 FOLLOW/SERIOUS。 var severity = S8SeverityCode.Normalize(e.Severity); var content = $"异常 {e.ExceptionCode} 已关闭,但关闭时间超过 SLA 截止时间,超时关闭 {overdueCloseHours.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture)} 小时,请关注闭环及时性。"; await _notificationLayerResolver.DispatchByLayerAsync(new S8NotificationLayerResolver.DispatchByLayerInput { TenantId = e.TenantId, ExceptionId = e.Id, ExceptionNo = e.ExceptionCode, SceneCode = sceneCode, Severity = severity, Title = $"【超时关闭】{e.ExceptionCode}", Content = content, Status = e.Status, SourceRuleCode = e.SourceRuleCode, JumpUrl = $"/aidop/s8/exceptions/{e.Id}", OverdueClosed = true, ClosedAt = e.ClosedAt, SlaDeadlineRef = e.SlaDeadline, OverdueCloseHours = overdueCloseHours, }); } catch (Exception ex) { _logger.LogWarning(ex, "notify_overdue_close_dispatch_throw exceptionId={Id}", e.Id); } } private async Task TryApproveVerificationFlowAsync(long exceptionId, long currentUserId) { try { var instance = await _flowInstanceRep.AsQueryable() .Where(x => x.BizType == "EXCEPTION_CLOSURE" && x.BizId == exceptionId && x.Status == FlowInstanceStatusEnum.Running) .FirstAsync(); if (instance == null) return; var task = await _flowTaskRep.AsQueryable() .Where(x => x.InstanceId == instance.Id && x.AssigneeId == currentUserId && x.Status == FlowTaskStatusEnum.Pending) .FirstAsync(); if (task == null) return; await _flowEngine.Approve(task.Id, "S8 检验通过(双线合一自动同意)"); } catch (Exception ex) { _logger.LogWarning(ex, "S8 检验通过时自动同意 EXCEPTION_CLOSURE 流程失败 exceptionId={Id} userId={UserId}", exceptionId, currentUserId); } } public async Task RejectVerificationAsync( long id, long tenantId, string remark) { var currentUserId = GetCurrentUserId(); var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在"); EnsureCurrentUserIsOperator(e.VerifierUserId, currentUserId, "只有指定检验人才能检验退回"); if (!S8StatusRules.IsAllowedTransition(e.Status, "IN_PROGRESS")) throw new S8BizException($"状态 {e.Status} 不可检验退回"); if (string.IsNullOrWhiteSpace(remark)) throw new S8BizException("检验退回必须填写退回原因"); var from = e.Status; e.Status = "IN_PROGRESS"; e.VerifiedAt = DateTime.Now; e.VerificationResult = "REJECTED"; e.VerificationRemark = remark; e.UpdatedAt = DateTime.Now; await _rep.AsTenant().UseTranAsync(async () => { await _rep.UpdateAsync(e); await InsertTimelineAsync(e.Id, "VERIFY_REJECTED", "检验退回", from, "IN_PROGRESS", currentUserId, null, remark); }, ex => throw ex); // 双线合一:检验退回 = EXCEPTION_CLOSURE 复检流程整体拒绝。 await TryRejectVerificationFlowAsync(e.Id, currentUserId, remark); return e; } private async Task TryRejectVerificationFlowAsync(long exceptionId, long currentUserId, string? remark) { try { var instance = await _flowInstanceRep.AsQueryable() .Where(x => x.BizType == "EXCEPTION_CLOSURE" && x.BizId == exceptionId && x.Status == FlowInstanceStatusEnum.Running) .FirstAsync(); if (instance == null) return; var task = await _flowTaskRep.AsQueryable() .Where(x => x.InstanceId == instance.Id && x.AssigneeId == currentUserId && x.Status == FlowTaskStatusEnum.Pending) .FirstAsync(); if (task == null) return; await _flowEngine.Reject(task.Id, remark ?? "S8 检验退回(双线合一自动拒绝)"); } catch (Exception ex) { _logger.LogWarning(ex, "S8 检验退回时自动拒绝 EXCEPTION_CLOSURE 流程失败 exceptionId={Id} userId={UserId}", exceptionId, currentUserId); } } // S8-TENANT-FACTORY-P0-CLOSURE-1:补充说明同样按可信作用域绑行,禁止裸 Id 写他租户时间线。 public async Task CommentAsync(long id, long tenantId, string? remark) { var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在"); await InsertTimelineAsync(e.Id, "COMMENT", "补充说明", e.Status, e.Status, null, null, remark); } private Task LoadAsync(long id, long tenantId) => _rep.GetFirstAsync(x => x.Id == id && x.TenantId == tenantId && !x.IsDeleted); // 统一复用框架登录上下文,避免业务身份继续信任前端传参。 private long GetCurrentUserId() { var currentUserId = _userManager.UserId; if (currentUserId <= 0) throw new S8BizException("未获取到当前登录用户"); return currentUserId; } /// /// 站内信通知某个系统账号。 /// /// S8-SYSUSER-ONLY-1:原实现是 NotifyAssigneeAsync(employeeRecId) —— /// 先经 EmployeeMaster.SysUserId 换算成账号才能投递,换不到就打一行 /// s8_assignee_notice_unmapped 然后静默不发。真库 1105 名员工里只有 8 个绑了账号, /// 也就是说这条路径在绝大多数情况下根本发不出通知,而调用方看到的是"成功"。 /// 现在处理人本身就是账号,桥和它的失败模式一起删除。 /// private async Task NotifyUserAsync(AdoS8Exception exception, long sysUserId, string title) { if (sysUserId <= 0) return; var jump = $"/#/aidop/s8/exceptions/{exception.Id}"; var content = $"

{exception.ExceptionCode} {exception.Title}

" + $"

查看异常详情

"; try { await _noticeService.PublishToUsersAsync( title, content, new[] { sysUserId }, _userManager.UserId, string.IsNullOrWhiteSpace(_userManager.RealName) ? "S8异常监控" : _userManager.RealName); } catch (Exception ex) { _logger.LogWarning(ex, "s8_assignee_notice_failed exceptionId={Id} sysUserId={UserId}", exception.Id, sysUserId); } } /// /// 鉴权:当前登录账号必须就是单据上记录的那个人。 /// /// S8-SYSUSER-ONLY-1:原实现要先把 employeeIdEmployeeMaster.SysUserId /// 解析成账号再比对,于是"未绑定员工主数据"和"你不是这个人"共用同一句报错 —— /// 两种完全不同的问题给出同一个提示。现在两边同为 SysUser.Id,直接比较即可, /// 不需要查库,也不再有第三种失败模式。 /// private static void EnsureCurrentUserIsOperator(long? ownerUserId, long currentUserId, string failMessage) { if (ownerUserId != currentUserId) throw new S8BizException(failMessage); } /// /// 写时间线。 只接受 SysUser.Id; /// 系统自动动作传 null,绝不允许伪造一个 ID(历史上 CLAIM 传的是 EmployeeMaster.RecID, /// 导致同一列混装两套 ID 空间,见 )。 /// private async Task InsertTimelineAsync(long exceptionId, string code, string label, string? from, string? to, long? operatorUserId, string? operatorName, string? remark) => await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline { ExceptionId = exceptionId, ActionCode = code, ActionLabel = label, FromStatus = from, ToStatus = to, OperatorUserId = operatorUserId, OperatorName = operatorName, ActionRemark = remark, CreatedAt = DateTime.Now }); }