| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280 |
- 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));
- }
- /// <summary>
- /// S8-CFG-NOTIFY-NONDEFAULT-ROLE-OPTIONS-FIX-1:通知分层的**目标角色候选**。
- ///
- /// <para>原实现按**默认租户的物理 Id** 筛选
- /// (<c>Id BETWEEN 1329908000101 AND 1329908000107</c> 外加 <c>1300000000101</c>)。
- /// 这些 Id 全部属于默认租户 <c>1300000000001</c>;而 <c>AidopTenantInitService</c>
- /// 是**按租户各复制一份**这 7 个角色的——同 Code、不同 Id。
- /// 于是在任何非默认租户下,仓储的租户过滤把这些 Id 全部滤掉,候选列表恒为空,
- /// 配置页无法查看或编辑目标角色。(UAT 实测:自有 7 个 canonical 角色,候选却返回 0。)</para>
- ///
- /// <para>修正为**按 canonical Role Code 白名单在可信租户作用域内查**:
- /// Code 是逻辑身份、跨租户稳定,Id 是租户本地物理身份。
- /// 这里**不显式传 tenantId**——租户边界由 <c>SqlSugarRepository<SysRole></c> 的
- /// 全局租户过滤按认证身份施加,与 S8 其余读路径同一机制;为这一个方法另开旁路
- /// 反而会绕过可信作用域。</para>
- ///
- /// <para>返回形状保持 <c>{ value, label }</c> 不变(<c>value = Code</c>):
- /// <c>ado_s8_notification_layer.target_role_ids</c> 存的就是 Code,
- /// 改成物理 Id 会当场废掉既有配置与 <c>S8RoleResolver</c> 的解析口径。
- /// 顺带的好处是**出参里根本没有 Id**,也就不存在把默认租户物理 Id 泄漏给其它租户的可能。</para>
- ///
- /// <para>缺失行为(有意):canonical 白名单里某个 Code 在当前租户不存在时,
- /// **只是不出现这一项**——不回落默认租户、不自动建角色、不报错。</para>
- /// </summary>
- public async Task<List<object>> GetRoleOptionsAsync()
- {
- var codes = S8CanonicalRole.BusinessRoleCodes;
- var roles = await _roleRep.AsQueryable()
- .Where(x => codes.Contains(x.Code))
- .Select(x => new { x.Id, x.Code, x.Name })
- .ToListAsync();
- return ShapeRoleOptions(roles.Select(x => (x.Id, x.Code, x.Name)))
- // 附加 id 仅供追溯(证明选中的是本租户物理行);value 仍是 Code,形状向后兼容。
- .Select(x => (object)new { value = x.Code, label = x.Name, id = x.Id })
- .ToList();
- }
- /// <summary>
- /// role-options 的**纯整形逻辑**(去重 + 排序),与 DB 解耦以便直接单测。
- ///
- /// <para>去重:同租户同 Code 重复目前实测为 0(无 DB 唯一索引,属已登记技术债)。
- /// 取 Id 最小者是为了保证**确定性**,不是为了把重复数据藏起来——
- /// 重复本身仍应由数据治理批次处理。</para>
- ///
- /// <para>排序:由 canonical <c>OrderNo</c> 决定,**不由物理 Id 决定**;
- /// 否则同一份配置在不同租户会呈现不同顺序。</para>
- /// </summary>
- public static List<(long Id, string Code, string Name)> ShapeRoleOptions(
- IEnumerable<(long Id, string Code, string Name)> rows) =>
- rows.GroupBy(x => x.Code, StringComparer.Ordinal)
- .Select(g => g.OrderBy(x => x.Id).First())
- .OrderBy(x => S8CanonicalRole.OrderOf(x.Code))
- .ThenBy(x => x.Code, StringComparer.Ordinal)
- .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();
- }
|