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 的责任池,不复制第二份人员配置:
/// 两份人员名单迟早不一致,而不一致的那一刻没有任何报错。
///
/// REVIEWER 不是新建的 Reviewer Pool:它解析当前事实 ——
/// 优先取单据上的 verifier_user_id(提交复核时手选、真正能点通过/退回的人),
/// 尚未提交复核时回落审批流节点解析出的审批人。本批不发明 Rule 级审核池。
///
/// 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 IS8UserScopeValidator _userScope;
private readonly S8ReviewSourceService _reviewSource;
private readonly ILogger _logger;
public S8NotificationRecipientResolver(
SqlSugarRepository rep,
IS8RuleHandlerPoolReader handlerPool,
IS8UserScopeValidator userScope,
S8ReviewSourceService reviewSource,
ILogger logger)
{
_rep = rep;
_handlerPool = handlerPool;
_userScope = userScope;
_reviewSource = reviewSource;
_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.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 };
// 尚未提交复核 → 回落审批流节点解析出的审批人(解析当前事实,不发明 pool)。
return await ResolveFlowApproversAsync(tenantId);
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();
}
}
/// 从复核审批流节点解析实际审批人。解析不出就是解析不出,不回落到任何"兜底人"。
private async Task> ResolveFlowApproversAsync(long tenantId)
{
var stages = await _reviewSource.DescribeAsync(tenantId);
var verify = stages.FirstOrDefault(s => s.Stage == "复核");
if (verify == null) return new List();
// DescribeAsync 输出的是展示名,这里需要 Id —— 复用同一份租户账号集合做反查,
// 保证"页面上看到的审核人"与"实际收到通知的人"是同一批。
var tenantUsers = await _userScope.ValidateUsersAsync(tenantId,
await _rep.Context.Queryable().ClearFilter()
.Where(u => u.TenantId == tenantId)
.Select(u => u.Id).ToListAsync());
var display = verify.Nodes.SelectMany(n => n.ResolvedUsers).ToHashSet();
return tenantUsers
.Where(u => display.Contains($"{u.DisplayName} ({u.Account})"))
.Select(u => u.UserId)
.ToList();
}
}