using Admin.NET.Core; using Admin.NET.Plugin.AiDOP.Const.S8; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Microsoft.Extensions.Logging; namespace Admin.NET.Plugin.AiDOP.Service.S8; /// 配置页的一行:一个事件 + 它当前配置的收件人类型。 public sealed class S8NotifyEventRowDto { public string EventCode { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; public string TriggerHint { get; set; } = string.Empty; public int OrderNo { get; set; } public List RecipientTypes { get; set; } = new(); /// SPECIFIC_USER 选中的账号。 public List SpecificUserIds { get; set; } = new(); /// 本事件尚未配置任何收件人 —— 会回落 LEGACY 分层,页面必须说明。 public bool NotConfigured => RecipientTypes.Count == 0; } /// /// 「通知设置」页的一行:一个事件 + 它提醒谁(业务文案)+ 是否启用。 /// 页面上不出现任何收件人技术枚举 —— 对象由责任关系推导。 /// public sealed class S8NotifyEventSwitchDto { public string EventCode { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; public string TriggerHint { get; set; } = string.Empty; public int OrderNo { get; set; } /// 是否已在新模型里启用通知。 public bool Enabled { get; set; } /// 提醒谁(业务文案,如「处理人员」「当前复核人」)。只读展示。 public string RecipientLabel { get; set; } = string.Empty; /// 当前配置是否就是代码级默认推导。false = 历史手工配置与默认不同,页面需提示。 public bool MatchesDerivedDefault { get; set; } = true; } /// /// S8-NOTIFY-RECIPIENT-1:收件人配置的读写。 /// /// 写入侧只认目录内的事件与类型,并对 SPECIFIC_USER 逐个校验账号 /// (同租户 + 启用,复用 )—— /// 让「配得进去、发的时候又发不出」这种状态在写入那一刻就不可能产生。 /// public class S8NotificationRecipientConfigService : ITransient { private readonly SqlSugarRepository _rep; private readonly IS8UserScopeValidator _userScope; private readonly UserManager _userManager; private readonly ILogger _logger; public S8NotificationRecipientConfigService( SqlSugarRepository rep, IS8UserScopeValidator userScope, UserManager userManager, ILogger logger) { _rep = rep; _userScope = userScope; _userManager = userManager; _logger = logger; } /// 某条规则(或租户默认)的事件矩阵。目录里每个事件都出一行,未配置的也要出现。 public async Task> GetMatrixAsync(long tenantId, string ruleCode) { var rows = await _rep.AsQueryable().ClearFilter() .Where(x => x.TenantId == tenantId && x.RuleCode == ruleCode) .ToListAsync(); var byEvent = rows.GroupBy(x => x.EventCode, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); return S8NotificationCatalog.Events.OrderBy(e => e.OrderNo).Select(e => { var cfg = byEvent.GetValueOrDefault(e.Code) ?? new List(); var specific = cfg.FirstOrDefault(c => c.RecipientType == S8RecipientType.SpecificUser); return new S8NotifyEventRowDto { EventCode = e.Code, DisplayName = e.DisplayName, TriggerHint = e.TriggerHint, OrderNo = e.OrderNo, RecipientTypes = cfg.Select(c => c.RecipientType).Distinct().ToList(), SpecificUserIds = S8RoleResolver.SplitTokens(specific?.RecipientUserIds) .Select(t => long.TryParse(t, out var v) ? v : 0).Where(v => v > 0).ToList() }; }).ToList(); } /// /// S8-RESPONSIBILITY-POOL-1:业务视角的事件开关矩阵。 /// /// 业务用户只回答「这件事要不要提醒」;提醒谁由责任关系推导 /// (见 ), /// 页面上不出现 HANDLER_POOL / ASSIGNEE 这类技术枚举。 /// public async Task> GetEventSwitchesAsync(long tenantId, string ruleCode) { var matrix = await GetMatrixAsync(tenantId, ruleCode); var byEvent = matrix.ToDictionary(m => m.EventCode, StringComparer.OrdinalIgnoreCase); return S8NotificationCatalog.Events.OrderBy(e => e.OrderNo).Select(e => { var derived = S8NotificationCatalog.ResolveDefaultRecipientType(e.Code); var cfg = byEvent.GetValueOrDefault(e.Code); var configured = cfg?.RecipientTypes ?? new List(); // 「已启用」= 该事件在新模型里有任何收件人配置。 // 未启用时刻意**不**声称"不会通知"——旧 LEGACY 分层仍可能兜底, // 页面据此显示"未启用(可能仍走旧配置)",而不是给一个会骗人的关闭态。 return new S8NotifyEventSwitchDto { EventCode = e.Code, DisplayName = e.DisplayName, TriggerHint = e.TriggerHint, OrderNo = e.OrderNo, Enabled = configured.Count > 0, RecipientLabel = S8NotificationCatalog.DescribeRecipientType( configured.FirstOrDefault() ?? derived), // 已配置的类型与代码级默认不一致 —— 通常是上一版手工配过。 // 不静默改写,只标出来让管理员知道这一行与默认推导不同。 MatchesDerivedDefault = configured.Count == 0 || (configured.Count == 1 && string.Equals( configured[0], derived, StringComparison.OrdinalIgnoreCase)), }; }).ToList(); } /// /// 按事件开关写入 / 移除收件人配置。收件人类型由目录推导,调用方不传。 /// /// 这是「通知设置」页唯一的写入口:业务用户点的是开关, /// 落库的仍是既有的收件人模型 —— 内部实现保留,只是不再暴露给页面。 /// public async Task SetEventEnabledAsync(S8TrustedScope scope, string ruleCode, string eventCode, bool enabled) { if (!S8NotificationCatalog.IsKnownEvent(eventCode)) throw new S8BizException($"未知事件码:{eventCode}"); var derived = S8NotificationCatalog.ResolveDefaultRecipientType(eventCode) ?? throw new S8BizException($"事件 {eventCode} 没有默认收件人推导规则,无法按开关配置"); await SetAsync(scope, ruleCode, eventCode, enabled ? new[] { derived } : Array.Empty(), null); } /// 全量替换某事件的收件人配置。 public async Task SetAsync(S8TrustedScope scope, string ruleCode, string eventCode, IEnumerable? recipientTypes, IEnumerable? specificUserIds) { if (string.IsNullOrWhiteSpace(ruleCode)) throw new S8BizException("规则编码不能为空"); if (!S8NotificationCatalog.IsKnownEvent(eventCode)) throw new S8BizException($"未知事件码:{eventCode}"); var types = (recipientTypes ?? Enumerable.Empty()) .Where(t => !string.IsNullOrWhiteSpace(t)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var unknown = types.Where(t => !S8NotificationCatalog.IsKnownRecipientType(t)).ToList(); if (unknown.Count > 0) throw new S8BizException($"未知收件人类型:{string.Join('、', unknown)}"); var users = (specificUserIds ?? Enumerable.Empty()).Where(x => x > 0).Distinct().ToList(); if (types.Contains(S8RecipientType.SpecificUser, StringComparer.OrdinalIgnoreCase)) { if (users.Count == 0) throw new S8BizException("已选择「指定账号」,请至少选择一个账号"); var valid = (await _userScope.ValidateUsersAsync(scope.TenantId, users)).Select(u => u.UserId).ToHashSet(); if (users.Any(id => !valid.Contains(id))) // 与人员校验同口径:跨租户与不存在共用同一文案,不泄漏存在性。 throw new S8BizException("所选账号不存在或已停用"); } else { // 没勾「指定账号」就不该留下账号列表 —— 留着会在下次勾选时悄悄复活一批旧人选。 users.Clear(); } await _rep.AsDeleteable() .Where(x => x.TenantId == scope.TenantId && x.RuleCode == ruleCode && x.EventCode == eventCode) .ExecuteCommandAsync(); if (types.Count > 0) { var now = DateTime.Now; await _rep.AsInsertable(types.Select(t => new AdoS8NotificationRecipient { TenantId = scope.TenantId, RuleCode = ruleCode, EventCode = eventCode, RecipientType = t, RecipientUserIds = t.Equals(S8RecipientType.SpecificUser, StringComparison.OrdinalIgnoreCase) ? string.Join(',', users) : null, CreatedAt = now, CreatedBy = _userManager.Account }).ToList()).ExecuteCommandAsync(); } _logger.LogInformation( "s8_notify_recipient_updated tenant={Tenant} rule={Rule} event={Event} types={Types} users={Users}", scope.TenantId, ruleCode, eventCode, string.Join(',', types), users.Count); } }