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