S8NotificationRecipientResolver.cs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. using Admin.NET.Core;
  2. using Admin.NET.Plugin.AiDOP.Const.S8;
  3. using Admin.NET.Plugin.AiDOP.Entity.S8;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  5. using Microsoft.Extensions.Logging;
  6. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  7. /// <summary>一次收件人解析的结果。</summary>
  8. public sealed class S8RecipientResolution
  9. {
  10. /// <summary>去重后的收件账号。<b>只可能是 <c>SysUser.Id</c>。</b></summary>
  11. public List<long> UserIds { get; set; } = new();
  12. /// <summary>本次实际使用的配置来源:<c>RECIPIENT_MODEL</c> / <c>LEGACY_LAYER</c> / <c>NONE</c>。</summary>
  13. public string Source { get; set; } = "NONE";
  14. /// <summary>每个收件人类型解析出多少人 —— 0 人的必须能被看见,不能混在总数里被抹平。</summary>
  15. public Dictionary<string, int> PerType { get; set; } = new();
  16. /// <summary>配置存在但一个人都解析不出来。<b>这不是"没配置",是"配了但发不出去"。</b></summary>
  17. public bool ConfiguredButEmpty => Source != "NONE" && UserIds.Count == 0;
  18. }
  19. /// <summary>
  20. /// S8-NOTIFY-RECIPIENT-1:「这个事件该通知谁」的<b>唯一</b>解析实现。
  21. ///
  22. /// <para><b>输出只认 <c>SysUserId</c></b>。所有个人通知最终都投递到系统账号 ——
  23. /// 不存在"通知某个员工"这条路径(S8-SYSUSER-ONLY-1 已把 EmployeeMaster 移出人员主链)。</para>
  24. ///
  25. /// <para><b>Authority 优先级(单一链路,不是两个平级来源)</b>:
  26. /// ① 新收件人模型的规则级配置 → ② 新模型的租户级默认(<c>rule_code = '*'</c>)
  27. /// → ③ 旧 <c>ado_s8_notification_layer</c>(LEGACY fallback)。
  28. /// 一旦新模型对该 (租户, 规则, 事件) 有任何配置,旧表就<b>完全不参与</b> ——
  29. /// 否则会出现"我明明删掉了收件人,怎么还在发"。</para>
  30. ///
  31. /// <para><b>HANDLER_POOL 直接复用 P1-C 的责任池</b>,不复制第二份人员配置:
  32. /// 两份人员名单迟早不一致,而不一致的那一刻没有任何报错。</para>
  33. ///
  34. /// <para><b>收件人由责任关系推导,不再让业务用户配技术枚举</b>(S8-RESPONSIBILITY-POOL-1):
  35. /// <c>HANDLER_POOL</c> / <c>ESCALATION_POOL</c> 直接复用规则的处理池与升级池;
  36. /// <c>REVIEWER</c> 优先取单据上的 <c>verifier_user_id</c>(提交复核时手选、真正能点通过/退回的人),
  37. /// 尚未提交复核时回落该规则的<b>复核账号池</b>。
  38. /// <b>不再解析审批流节点</b>——审批人来自流程定义的角色,与「谁有资格复核这条规则」
  39. /// 不是同一个问题,且会让通知链随流程节点结构悄悄变化。</para>
  40. ///
  41. /// <para><b>0 收件人必须可观测</b>:解析结果为空时返回 <c>ConfiguredButEmpty</c>,
  42. /// 由调用方落日志。绝不静默当作"发送成功"—— 那正是本模块反复出现的失败形态。</para>
  43. /// </summary>
  44. public interface IS8NotificationRecipientResolver
  45. {
  46. Task<S8RecipientResolution> ResolveAsync(long tenantId, string eventCode, AdoS8Exception exception);
  47. }
  48. /// <summary>正式实现。生产代码里只有这一份;接口只为测试留 seam。</summary>
  49. public sealed class S8NotificationRecipientResolver : IS8NotificationRecipientResolver, ITransient
  50. {
  51. private readonly SqlSugarRepository<AdoS8NotificationRecipient> _rep;
  52. private readonly IS8RuleHandlerPoolReader _handlerPool;
  53. private readonly IS8RuleResponsibilityReader _pools;
  54. private readonly IS8UserScopeValidator _userScope;
  55. private readonly ILogger<S8NotificationRecipientResolver> _logger;
  56. public S8NotificationRecipientResolver(
  57. SqlSugarRepository<AdoS8NotificationRecipient> rep,
  58. IS8RuleHandlerPoolReader handlerPool,
  59. IS8RuleResponsibilityReader pools,
  60. IS8UserScopeValidator userScope,
  61. ILogger<S8NotificationRecipientResolver> logger)
  62. {
  63. _rep = rep;
  64. _handlerPool = handlerPool;
  65. _pools = pools;
  66. _userScope = userScope;
  67. _logger = logger;
  68. }
  69. public async Task<S8RecipientResolution> ResolveAsync(long tenantId, string eventCode, AdoS8Exception exception)
  70. {
  71. var result = new S8RecipientResolution();
  72. if (!S8NotificationCatalog.IsKnownEvent(eventCode) || tenantId <= 0 || exception == null)
  73. return result;
  74. var ruleCode = string.IsNullOrWhiteSpace(exception.SourceRuleCode)
  75. ? S8NotificationCatalog.TenantDefaultRuleCode
  76. : exception.SourceRuleCode!;
  77. // ① 规则级配置优先;② 没有才回落租户级默认。
  78. // 刻意**不合并**两者:合并会让"我给这条规则单独配了收件人"变成"在默认之上又加了几个",
  79. // 管理员想缩小范围时会发现怎么也去不掉默认里的那些人。
  80. var rows = await LoadAsync(tenantId, ruleCode, eventCode);
  81. if (rows.Count == 0 && ruleCode != S8NotificationCatalog.TenantDefaultRuleCode)
  82. rows = await LoadAsync(tenantId, S8NotificationCatalog.TenantDefaultRuleCode, eventCode);
  83. if (rows.Count == 0) return result; // Source 保持 NONE,调用方回落 LEGACY
  84. result.Source = "RECIPIENT_MODEL";
  85. var ids = new HashSet<long>();
  86. foreach (var row in rows)
  87. {
  88. var resolved = await ResolveOneAsync(tenantId, row, exception);
  89. result.PerType[row.RecipientType] = resolved.Count;
  90. foreach (var id in resolved) ids.Add(id);
  91. }
  92. // 最终统一过一遍账号有效性:配置可能是几个月前配的,人可能已经停用或换了租户。
  93. var valid = await _userScope.ValidateUsersAsync(tenantId, ids);
  94. result.UserIds = valid.Select(u => u.UserId).ToList();
  95. if (result.ConfiguredButEmpty)
  96. _logger.LogWarning(
  97. "s8_notify_recipients_empty tenant={Tenant} rule={Rule} event={Event} perType={PerType}"
  98. + ";配置存在但解析不出任何有效账号,本次通知不会发给任何人",
  99. tenantId, ruleCode, eventCode, string.Join(',', result.PerType.Select(kv => $"{kv.Key}={kv.Value}")));
  100. return result;
  101. }
  102. private async Task<List<AdoS8NotificationRecipient>> LoadAsync(long tenantId, string ruleCode, string eventCode) =>
  103. await _rep.AsQueryable().ClearFilter()
  104. .Where(x => x.TenantId == tenantId && x.RuleCode == ruleCode && x.EventCode == eventCode)
  105. .ToListAsync();
  106. private async Task<List<long>> ResolveOneAsync(long tenantId, AdoS8NotificationRecipient row, AdoS8Exception e)
  107. {
  108. switch (row.RecipientType)
  109. {
  110. case S8RecipientType.HandlerPool:
  111. if (string.IsNullOrWhiteSpace(e.SourceRuleCode)) return new List<long>();
  112. return (await _handlerPool.GetMemberIdsAsync(tenantId, e.SourceRuleCode!)).ToList();
  113. case S8RecipientType.EscalationPool:
  114. // S8-RESPONSIBILITY-POOL-1:升级通知的收件来源是该规则的升级账号池。
  115. // 人工提报无来源规则 → 解析为 0 人,由 ConfiguredButEmpty 落 Warning,
  116. // **不回落到任何角色**:回落会让"没配升级人"看起来像"已有人跟进"。
  117. if (string.IsNullOrWhiteSpace(e.SourceRuleCode)) return new List<long>();
  118. return (await _pools.GetMemberIdsAsync(
  119. tenantId, e.SourceRuleCode!, S8ResponsibilityType.Escalation)).ToList();
  120. case S8RecipientType.Assignee:
  121. return e.AssigneeUserId is > 0 ? new List<long> { e.AssigneeUserId.Value } : new List<long>();
  122. case S8RecipientType.Reviewer:
  123. // 单据上已有检验人 = 真正能点通过/退回的那个人,最准确。
  124. if (e.VerifierUserId is > 0) return new List<long> { e.VerifierUserId.Value };
  125. // S8-RESPONSIBILITY-POOL-1:尚未提交复核 → 回落到该规则的**复核账号池**。
  126. //
  127. // 原实现回落到审批流节点解析出的审批人。那条路径有两个问题:
  128. // ① 审批人来自流程定义的角色,与「谁有资格复核这条规则」根本不是同一个问题;
  129. // ② 它把通知链耦合到 ApprovalFlow 的内部结构上,流程节点一改,通知就悄悄变了。
  130. // 复核池现在是 Rule 级的唯一 authority,通知与候选列表因此同源。
  131. if (string.IsNullOrWhiteSpace(e.SourceRuleCode)) return new List<long>();
  132. return (await _pools.GetMemberIdsAsync(
  133. tenantId, e.SourceRuleCode!, S8ResponsibilityType.Reviewer)).ToList();
  134. case S8RecipientType.SpecificUser:
  135. return S8RoleResolver.SplitTokens(row.RecipientUserIds)
  136. .Select(t => long.TryParse(t, out var v) ? v : 0)
  137. .Where(v => v > 0)
  138. .ToList();
  139. default:
  140. // 未知类型不猜、不放行 —— 配置里出现它本身就说明有人绕过了目录校验。
  141. _logger.LogWarning("s8_notify_unknown_recipient_type type={Type} rowId={Id}", row.RecipientType, row.Id);
  142. return new List<long>();
  143. }
  144. }
  145. }