| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- using Admin.NET.Core;
- using Admin.NET.Plugin.AiDOP.Entity.S8;
- using Admin.NET.Plugin.AiDOP.Infrastructure;
- using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
- namespace Admin.NET.Plugin.AiDOP.Service.S8;
- public class S8NotificationLayerService : ITransient
- {
- private readonly SqlSugarRepository<AdoS8NotificationLayer> _rep;
- private readonly SqlSugarRepository<SysRole> _roleRep;
- public S8NotificationLayerService(
- SqlSugarRepository<AdoS8NotificationLayer> rep,
- SqlSugarRepository<SysRole> roleRep)
- {
- _rep = rep;
- _roleRep = roleRep;
- }
- /// <summary>
- /// S8-STEP6C-CFG-NOTIFY-CLOSURE-1:生效配置视图,**严格镜像 S8NotificationLayerResolver**。
- ///
- /// 原实现只查精确 (tenantId, factoryId),而 14 条基线种在 (0,0),导致任何真实租户打开页面都是空表;
- /// 与此同时 resolver 在运行时**确实**用这 14 条基线派发通知 —— 即「配置页看不见、通知照发」。
- ///
- /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:merge 不再在本方法内实现,改为与 resolver 共用
- /// <see cref="S8NotificationLayerMerge.Effective"/>(覆盖粒度 = scene + severity + level)。
- /// 两处共用同一函数是硬性要求:各写一份相似 GroupBy 必然分叉成
- /// 「页面显示一种生效配置、运行时按另一种派发」。
- /// </summary>
- public async Task<List<AdoS8NotificationLayer>> ListAsync(long tenantId, long factoryId)
- {
- var all = await _rep.AsQueryable()
- .Where(x => (x.TenantId == S8ConfigScope.GlobalTenantId && x.FactoryId == S8ConfigScope.GlobalFactoryId)
- || (x.TenantId == tenantId && x.FactoryId == factoryId))
- .ToListAsync();
- var globalKeys = all
- .Where(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))
- .Select(S8NotificationLayerMerge.KeyOf)
- .ToHashSet();
- var effective = S8NotificationLayerMerge.Effective(all);
- foreach (var row in effective)
- row.HasGlobalDefault = globalKeys.Contains(S8NotificationLayerMerge.KeyOf(row));
- return effective;
- }
- /// <summary>
- /// S8-STEP6C-CFG-NOTIFY-CLOSURE-1:层级词表。原先服务端只校验非空,任意字符串可入库
- /// (实测库中存在 level_code = '' 的历史行);前端固定三档,此处与之对齐为单一事实源。
- /// </summary>
- private static readonly string[] AllowedLevelCodes = { "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR" };
- /// <summary>
- /// S8-STEP6C-CFG-NOTIFY-CLOSURE-1:通知渠道词表 —— 只允许当前真正可消费的渠道。
- ///
- /// <para><b>log</b>:<b>逻辑空操作 / 诊断渠道,不产生任何投递、也不写通知日志。</b>
- /// S8NotificationPushAdapter 的投递循环遍历的是**已注册 pusher 集合**并按 wanted 过滤
- /// (<c>foreach (var pusher in registered) { if (!wanted.Contains(pusher.Channel)) continue; ... }</c>),
- /// 而 <c>ado_s8_notification_log</c> 的两个写入点都在该循环体内;
- /// 全仓 5 个 INotifyPusher 实现的 Channel 为 SignalR / Email / Sms / DingTalk / WorkWeixin,
- /// **没有 Channel == "log" 的实现**,因此 log 永远进不了循环,只在收尾处打一行
- /// 「has no registered pusher, skip」的 ILogger 记录。
- /// ⚠️ 前一版本注释把 log 写成「等价于只落库」是**错的**:它一行都不落。</para>
- ///
- /// <para><b>SignalR</b>:进程内 hub 推送给已认证的在线连接,无第三方 / 公网 egress;
- /// 这是当前唯一会**真实投递**并因此**写一条投递审计日志**的渠道
- /// (无在线连接时 pusher 早退返回 Ok(0),仍写一条 targetCount=0 的日志)。</para>
- ///
- /// <para>故 <c>notify_channel = "log,SignalR"</c> 每次派发恰好落 <b>1</b> 条日志(SignalR 那条),
- /// log 贡献 0 条。</para>
- ///
- /// 刻意不含 Email / Sms / DingTalk / WorkWeixin:这四个渠道当前在 ApprovalFlow.json 全为 false、
- /// ApprovalFlowNotifyConfig 表 0 行,属「就位不启用」;暴露为可选项会造成「看似可用」,
- /// 且 push adapter 在 cfg == null 时会跳过开关检查放行已注册 pusher,届时即成真实外发。
- /// 要启用须先完成渠道凭据与开关的独立评审。
- /// </summary>
- private static readonly string[] AllowedChannels = { "log", "SignalR" };
- /// <summary>
- /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:场景词表校验。
- ///
- /// 新建一律要求 canonical 单模块场景 S1–S7(判定复用 <see cref="S8ModuleCode.IsValid"/>,
- /// 它是仓内唯一「严格 S1–S7、拒 legacy 复合场景」的现成实现,不另造第二套)。
- /// 原先服务端对 scene_code **零词表校验**,任意字符串都能入库;
- /// 而前端下拉来自租户自建的 ado_s8_scene_config 且开着 allow-create,等于对外开放自由文本。
- ///
- /// 编辑时若 scene 与库中现值**逐字相同**则放行:库里存在 S2S6_PRODUCTION、S8_DEMO_DEFAULT 等
- /// 历史行,若无条件强制 canonical,这些行会变成「连改渠道都保存不了」的砖块。
- /// 判据刻意用「与现值逐字相等」而非「在 legacy 常量表内」——S8_DEMO_DEFAULT 不在任何常量表里。
- /// </summary>
- private static void ValidateSceneCode(string sceneCode, string? unchangedFrom)
- {
- if (S8ModuleCode.IsValid(sceneCode)) return;
- if (unchangedFrom != null && string.Equals(sceneCode, unchangedFrom, StringComparison.Ordinal)) return;
- throw new S8BizException(
- "不支持的场景编码:" + sceneCode + ";当前仅支持 " + string.Join(" / ", S8ModuleCode.All));
- }
- /// <summary>
- /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:严重度词表校验,**必须在 Normalize 之前调用**。
- ///
- /// S8SeverityCode.Normalize 的兜底分支是 <c>_ => Follow</c>:任何垃圾串都会被静默降级成 FOLLOW
- /// 而不报错——用户把 SERIOUS 拼错就会得到一条「保存成功但严重度悄悄变了」的配置。
- /// 因此这里先做严格判定再归一。
- ///
- /// 也刻意**不复用** S8SeverityCode.IsValid:那是宽松版,
- /// FOLLOW / SERIOUS / LOW / MEDIUM / HIGH / CRITICAL 六值全放行(供 legacy 查询参数兼容用),
- /// 拿来当写入门禁会直接放过 legacy 值。
- ///
- /// 编辑时同样允许「与库中现值逐字相同」的 legacy 值通过,理由同 <see cref="ValidateSceneCode"/>。
- /// </summary>
- private static void ValidateSeverity(string severity, string? unchangedFrom)
- {
- if (string.Equals(severity, S8SeverityCode.Follow, StringComparison.Ordinal)
- || string.Equals(severity, S8SeverityCode.Serious, StringComparison.Ordinal)) return;
- if (unchangedFrom != null && string.Equals(severity, unchangedFrom, StringComparison.Ordinal)) return;
- throw new S8BizException(
- "不支持的严重度:" + severity + ";当前仅支持 "
- + S8SeverityCode.Follow + " / " + S8SeverityCode.Serious);
- }
- private static void ValidateVocabulary(AdoS8NotificationLayer body)
- {
- if (!AllowedLevelCodes.Contains(body.LevelCode, StringComparer.OrdinalIgnoreCase))
- throw new S8BizException(
- "不支持的层级编码:" + body.LevelCode + ";当前仅支持 " + string.Join(" / ", AllowedLevelCodes));
- var channels = (body.NotifyChannel ?? string.Empty)
- .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
- .ToList();
- if (channels.Count == 0)
- throw new S8BizException("通知渠道至少选一个");
- var bad = channels
- .Where(c => !AllowedChannels.Contains(c, StringComparer.OrdinalIgnoreCase))
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .ToList();
- if (bad.Count > 0)
- throw new S8BizException(
- "不支持的通知渠道:" + string.Join(" / ", bad) + ";当前仅支持 " + string.Join(" / ", AllowedChannels));
- // 归一大小写并去重,避免同一渠道以不同大小写重复写入。
- body.NotifyChannel = string.Join(",", channels
- .Select(c => AllowedChannels.First(a => string.Equals(a, c, StringComparison.OrdinalIgnoreCase)))
- .Distinct(StringComparer.Ordinal));
- }
- public async Task<List<object>> GetRoleOptionsAsync()
- {
- var roles = await _roleRep.AsQueryable()
- .Where(x => (x.Id >= 1329908000101L && x.Id <= 1329908000107L) || x.Id == 1300000000101L)
- .OrderBy(x => x.Id)
- .Select(x => new { value = x.Code, label = x.Name })
- .ToListAsync();
- return roles.Cast<object>().ToList();
- }
- // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 body.TenantId / body.FactoryId。
- public async Task<AdoS8NotificationLayer> CreateAsync(AdoS8NotificationLayer body, S8TrustedScope scope)
- {
- if (string.IsNullOrWhiteSpace(body.SceneCode) || string.IsNullOrWhiteSpace(body.Severity) || string.IsNullOrWhiteSpace(body.LevelCode))
- throw new S8BizException("场景、严重度、层级必填");
- if (string.IsNullOrWhiteSpace(body.TargetRoleIds))
- throw new S8BizException("目标角色必填");
- if (string.IsNullOrWhiteSpace(body.NotifyChannel))
- throw new S8BizException("通知渠道至少选一个");
- // S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:新建一律要求 canonical 词表,无 legacy 豁免(unchangedFrom = null)。
- // 严重度必须在 Normalize **之前**校验,否则兜底分支会把非法值静默降级成 FOLLOW,门禁永远命中不了。
- ValidateSceneCode(body.SceneCode, unchangedFrom: null);
- ValidateSeverity(body.Severity, unchangedFrom: null);
- // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:归一为 FOLLOW/SERIOUS 后再校验/写入。
- body.Severity = S8SeverityCode.Normalize(body.Severity);
- // S8-STEP6C-CFG-NOTIFY-CLOSURE-1:层级与渠道走白名单,杜绝保存消费方无法识别的配置。
- ValidateVocabulary(body);
- body.TenantId = scope.TenantId;
- body.FactoryId = scope.FactoryId;
- var exists = await _rep.AsQueryable()
- .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId &&
- x.SceneCode == body.SceneCode && x.Severity == body.Severity && x.LevelCode == body.LevelCode);
- if (exists) throw new S8BizException("同场景+严重度+层级已存在");
- body.Id = 0;
- body.CreatedAt = DateTime.Now;
- return await _rep.InsertReturnEntityAsync(body);
- }
- // S8-TENANT-FACTORY-P0-CLOSURE-1:按 Id + 可信作用域绑行;越权 Id 视为不存在,归属不可被 body 改写。
- public async Task<AdoS8NotificationLayer> UpdateAsync(long id, AdoS8NotificationLayer body, S8TrustedScope scope)
- {
- var e = await LoadScopedAsync(id, scope);
- if (string.IsNullOrWhiteSpace(body.SceneCode) || string.IsNullOrWhiteSpace(body.Severity) || string.IsNullOrWhiteSpace(body.LevelCode))
- throw new S8BizException("场景、严重度、层级必填");
- if (string.IsNullOrWhiteSpace(body.TargetRoleIds))
- throw new S8BizException("目标角色必填");
- if (string.IsNullOrWhiteSpace(body.NotifyChannel))
- throw new S8BizException("通知渠道至少选一个");
- // S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:编辑允许保留「与库中现值逐字相同」的 legacy 场景 / 严重度,
- // 否则 S2S6_PRODUCTION、S8_DEMO_DEFAULT 等历史行会连改渠道都保存不了;改成新值则必须 canonical。
- ValidateSceneCode(body.SceneCode, unchangedFrom: e.SceneCode);
- ValidateSeverity(body.Severity, unchangedFrom: e.Severity);
- // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:归一为 FOLLOW/SERIOUS 后再校验/写入。
- body.Severity = S8SeverityCode.Normalize(body.Severity);
- // S8-STEP6C-CFG-NOTIFY-CLOSURE-1:层级与渠道走白名单,杜绝保存消费方无法识别的配置。
- ValidateVocabulary(body);
- var exists = await _rep.AsQueryable()
- .AnyAsync(x => x.Id != id && x.TenantId == e.TenantId && x.FactoryId == e.FactoryId &&
- x.SceneCode == body.SceneCode && x.Severity == body.Severity && x.LevelCode == body.LevelCode);
- if (exists) throw new S8BizException("同场景+严重度+层级已存在");
- body.Id = id;
- body.TenantId = e.TenantId;
- body.FactoryId = e.FactoryId;
- body.CreatedAt = e.CreatedAt;
- body.UpdatedAt = DateTime.Now;
- await _rep.UpdateAsync(body);
- return body;
- }
- // S8-TENANT-FACTORY-P0-CLOSURE-1:删除必须先按可信作用域绑行,禁止裸 DeleteByIdAsync(id)。
- public async Task DeleteAsync(long id, S8TrustedScope scope)
- {
- var e = await LoadScopedAsync(id, scope);
- await _rep.DeleteByIdAsync(e.Id);
- }
- private async Task<AdoS8NotificationLayer> LoadScopedAsync(long id, S8TrustedScope scope) =>
- await _rep.AsQueryable()
- .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
- .FirstAsync() ?? throw new S8NotFoundException();
- }
|