S8NotificationLayerService.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. using Admin.NET.Core;
  2. using Admin.NET.Plugin.AiDOP.Entity.S8;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  5. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  6. public class S8NotificationLayerService : ITransient
  7. {
  8. private readonly SqlSugarRepository<AdoS8NotificationLayer> _rep;
  9. private readonly SqlSugarRepository<SysRole> _roleRep;
  10. public S8NotificationLayerService(
  11. SqlSugarRepository<AdoS8NotificationLayer> rep,
  12. SqlSugarRepository<SysRole> roleRep)
  13. {
  14. _rep = rep;
  15. _roleRep = roleRep;
  16. }
  17. /// <summary>
  18. /// S8-STEP6C-CFG-NOTIFY-CLOSURE-1:生效配置视图,**严格镜像 S8NotificationLayerResolver**。
  19. ///
  20. /// 原实现只查精确 (tenantId, factoryId),而 14 条基线种在 (0,0),导致任何真实租户打开页面都是空表;
  21. /// 与此同时 resolver 在运行时**确实**用这 14 条基线派发通知 —— 即「配置页看不见、通知照发」。
  22. ///
  23. /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:merge 不再在本方法内实现,改为与 resolver 共用
  24. /// <see cref="S8NotificationLayerMerge.Effective"/>(覆盖粒度 = scene + severity + level)。
  25. /// 两处共用同一函数是硬性要求:各写一份相似 GroupBy 必然分叉成
  26. /// 「页面显示一种生效配置、运行时按另一种派发」。
  27. /// </summary>
  28. public async Task<List<AdoS8NotificationLayer>> ListAsync(long tenantId, long factoryId)
  29. {
  30. var all = await _rep.AsQueryable()
  31. .Where(x => (x.TenantId == S8ConfigScope.GlobalTenantId && x.FactoryId == S8ConfigScope.GlobalFactoryId)
  32. || (x.TenantId == tenantId && x.FactoryId == factoryId))
  33. .ToListAsync();
  34. var globalKeys = all
  35. .Where(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))
  36. .Select(S8NotificationLayerMerge.KeyOf)
  37. .ToHashSet();
  38. var effective = S8NotificationLayerMerge.Effective(all);
  39. foreach (var row in effective)
  40. row.HasGlobalDefault = globalKeys.Contains(S8NotificationLayerMerge.KeyOf(row));
  41. return effective;
  42. }
  43. /// <summary>
  44. /// S8-STEP6C-CFG-NOTIFY-CLOSURE-1:层级词表。原先服务端只校验非空,任意字符串可入库
  45. /// (实测库中存在 level_code = '' 的历史行);前端固定三档,此处与之对齐为单一事实源。
  46. /// </summary>
  47. private static readonly string[] AllowedLevelCodes = { "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR" };
  48. /// <summary>
  49. /// S8-STEP6C-CFG-NOTIFY-CLOSURE-1:通知渠道词表 —— 只允许当前真正可消费的渠道。
  50. ///
  51. /// <para><b>log</b>:<b>逻辑空操作 / 诊断渠道,不产生任何投递、也不写通知日志。</b>
  52. /// S8NotificationPushAdapter 的投递循环遍历的是**已注册 pusher 集合**并按 wanted 过滤
  53. /// (<c>foreach (var pusher in registered) { if (!wanted.Contains(pusher.Channel)) continue; ... }</c>),
  54. /// 而 <c>ado_s8_notification_log</c> 的两个写入点都在该循环体内;
  55. /// 全仓 5 个 INotifyPusher 实现的 Channel 为 SignalR / Email / Sms / DingTalk / WorkWeixin,
  56. /// **没有 Channel == "log" 的实现**,因此 log 永远进不了循环,只在收尾处打一行
  57. /// 「has no registered pusher, skip」的 ILogger 记录。
  58. /// ⚠️ 前一版本注释把 log 写成「等价于只落库」是**错的**:它一行都不落。</para>
  59. ///
  60. /// <para><b>SignalR</b>:进程内 hub 推送给已认证的在线连接,无第三方 / 公网 egress;
  61. /// 这是当前唯一会**真实投递**并因此**写一条投递审计日志**的渠道
  62. /// (无在线连接时 pusher 早退返回 Ok(0),仍写一条 targetCount=0 的日志)。</para>
  63. ///
  64. /// <para>故 <c>notify_channel = "log,SignalR"</c> 每次派发恰好落 <b>1</b> 条日志(SignalR 那条),
  65. /// log 贡献 0 条。</para>
  66. ///
  67. /// 刻意不含 Email / Sms / DingTalk / WorkWeixin:这四个渠道当前在 ApprovalFlow.json 全为 false、
  68. /// ApprovalFlowNotifyConfig 表 0 行,属「就位不启用」;暴露为可选项会造成「看似可用」,
  69. /// 且 push adapter 在 cfg == null 时会跳过开关检查放行已注册 pusher,届时即成真实外发。
  70. /// 要启用须先完成渠道凭据与开关的独立评审。
  71. /// </summary>
  72. private static readonly string[] AllowedChannels = { "log", "SignalR" };
  73. /// <summary>
  74. /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:场景词表校验。
  75. ///
  76. /// 新建一律要求 canonical 单模块场景 S1–S7(判定复用 <see cref="S8ModuleCode.IsValid"/>,
  77. /// 它是仓内唯一「严格 S1–S7、拒 legacy 复合场景」的现成实现,不另造第二套)。
  78. /// 原先服务端对 scene_code **零词表校验**,任意字符串都能入库;
  79. /// 而前端下拉来自租户自建的 ado_s8_scene_config 且开着 allow-create,等于对外开放自由文本。
  80. ///
  81. /// 编辑时若 scene 与库中现值**逐字相同**则放行:库里存在 S2S6_PRODUCTION、S8_DEMO_DEFAULT 等
  82. /// 历史行,若无条件强制 canonical,这些行会变成「连改渠道都保存不了」的砖块。
  83. /// 判据刻意用「与现值逐字相等」而非「在 legacy 常量表内」——S8_DEMO_DEFAULT 不在任何常量表里。
  84. /// </summary>
  85. private static void ValidateSceneCode(string sceneCode, string? unchangedFrom)
  86. {
  87. if (S8ModuleCode.IsValid(sceneCode)) return;
  88. if (unchangedFrom != null && string.Equals(sceneCode, unchangedFrom, StringComparison.Ordinal)) return;
  89. throw new S8BizException(
  90. "不支持的场景编码:" + sceneCode + ";当前仅支持 " + string.Join(" / ", S8ModuleCode.All));
  91. }
  92. /// <summary>
  93. /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:严重度词表校验,**必须在 Normalize 之前调用**。
  94. ///
  95. /// S8SeverityCode.Normalize 的兜底分支是 <c>_ =&gt; Follow</c>:任何垃圾串都会被静默降级成 FOLLOW
  96. /// 而不报错——用户把 SERIOUS 拼错就会得到一条「保存成功但严重度悄悄变了」的配置。
  97. /// 因此这里先做严格判定再归一。
  98. ///
  99. /// 也刻意**不复用** S8SeverityCode.IsValid:那是宽松版,
  100. /// FOLLOW / SERIOUS / LOW / MEDIUM / HIGH / CRITICAL 六值全放行(供 legacy 查询参数兼容用),
  101. /// 拿来当写入门禁会直接放过 legacy 值。
  102. ///
  103. /// 编辑时同样允许「与库中现值逐字相同」的 legacy 值通过,理由同 <see cref="ValidateSceneCode"/>。
  104. /// </summary>
  105. private static void ValidateSeverity(string severity, string? unchangedFrom)
  106. {
  107. if (string.Equals(severity, S8SeverityCode.Follow, StringComparison.Ordinal)
  108. || string.Equals(severity, S8SeverityCode.Serious, StringComparison.Ordinal)) return;
  109. if (unchangedFrom != null && string.Equals(severity, unchangedFrom, StringComparison.Ordinal)) return;
  110. throw new S8BizException(
  111. "不支持的严重度:" + severity + ";当前仅支持 "
  112. + S8SeverityCode.Follow + " / " + S8SeverityCode.Serious);
  113. }
  114. private static void ValidateVocabulary(AdoS8NotificationLayer body)
  115. {
  116. if (!AllowedLevelCodes.Contains(body.LevelCode, StringComparer.OrdinalIgnoreCase))
  117. throw new S8BizException(
  118. "不支持的层级编码:" + body.LevelCode + ";当前仅支持 " + string.Join(" / ", AllowedLevelCodes));
  119. var channels = (body.NotifyChannel ?? string.Empty)
  120. .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
  121. .ToList();
  122. if (channels.Count == 0)
  123. throw new S8BizException("通知渠道至少选一个");
  124. var bad = channels
  125. .Where(c => !AllowedChannels.Contains(c, StringComparer.OrdinalIgnoreCase))
  126. .Distinct(StringComparer.OrdinalIgnoreCase)
  127. .ToList();
  128. if (bad.Count > 0)
  129. throw new S8BizException(
  130. "不支持的通知渠道:" + string.Join(" / ", bad) + ";当前仅支持 " + string.Join(" / ", AllowedChannels));
  131. // 归一大小写并去重,避免同一渠道以不同大小写重复写入。
  132. body.NotifyChannel = string.Join(",", channels
  133. .Select(c => AllowedChannels.First(a => string.Equals(a, c, StringComparison.OrdinalIgnoreCase)))
  134. .Distinct(StringComparer.Ordinal));
  135. }
  136. public async Task<List<object>> GetRoleOptionsAsync()
  137. {
  138. var roles = await _roleRep.AsQueryable()
  139. .Where(x => (x.Id >= 1329908000101L && x.Id <= 1329908000107L) || x.Id == 1300000000101L)
  140. .OrderBy(x => x.Id)
  141. .Select(x => new { value = x.Code, label = x.Name })
  142. .ToListAsync();
  143. return roles.Cast<object>().ToList();
  144. }
  145. // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 body.TenantId / body.FactoryId。
  146. public async Task<AdoS8NotificationLayer> CreateAsync(AdoS8NotificationLayer body, S8TrustedScope scope)
  147. {
  148. if (string.IsNullOrWhiteSpace(body.SceneCode) || string.IsNullOrWhiteSpace(body.Severity) || string.IsNullOrWhiteSpace(body.LevelCode))
  149. throw new S8BizException("场景、严重度、层级必填");
  150. if (string.IsNullOrWhiteSpace(body.TargetRoleIds))
  151. throw new S8BizException("目标角色必填");
  152. if (string.IsNullOrWhiteSpace(body.NotifyChannel))
  153. throw new S8BizException("通知渠道至少选一个");
  154. // S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:新建一律要求 canonical 词表,无 legacy 豁免(unchangedFrom = null)。
  155. // 严重度必须在 Normalize **之前**校验,否则兜底分支会把非法值静默降级成 FOLLOW,门禁永远命中不了。
  156. ValidateSceneCode(body.SceneCode, unchangedFrom: null);
  157. ValidateSeverity(body.Severity, unchangedFrom: null);
  158. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:归一为 FOLLOW/SERIOUS 后再校验/写入。
  159. body.Severity = S8SeverityCode.Normalize(body.Severity);
  160. // S8-STEP6C-CFG-NOTIFY-CLOSURE-1:层级与渠道走白名单,杜绝保存消费方无法识别的配置。
  161. ValidateVocabulary(body);
  162. body.TenantId = scope.TenantId;
  163. body.FactoryId = scope.FactoryId;
  164. var exists = await _rep.AsQueryable()
  165. .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId &&
  166. x.SceneCode == body.SceneCode && x.Severity == body.Severity && x.LevelCode == body.LevelCode);
  167. if (exists) throw new S8BizException("同场景+严重度+层级已存在");
  168. body.Id = 0;
  169. body.CreatedAt = DateTime.Now;
  170. return await _rep.InsertReturnEntityAsync(body);
  171. }
  172. // S8-TENANT-FACTORY-P0-CLOSURE-1:按 Id + 可信作用域绑行;越权 Id 视为不存在,归属不可被 body 改写。
  173. public async Task<AdoS8NotificationLayer> UpdateAsync(long id, AdoS8NotificationLayer body, S8TrustedScope scope)
  174. {
  175. var e = await LoadScopedAsync(id, scope);
  176. if (string.IsNullOrWhiteSpace(body.SceneCode) || string.IsNullOrWhiteSpace(body.Severity) || string.IsNullOrWhiteSpace(body.LevelCode))
  177. throw new S8BizException("场景、严重度、层级必填");
  178. if (string.IsNullOrWhiteSpace(body.TargetRoleIds))
  179. throw new S8BizException("目标角色必填");
  180. if (string.IsNullOrWhiteSpace(body.NotifyChannel))
  181. throw new S8BizException("通知渠道至少选一个");
  182. // S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:编辑允许保留「与库中现值逐字相同」的 legacy 场景 / 严重度,
  183. // 否则 S2S6_PRODUCTION、S8_DEMO_DEFAULT 等历史行会连改渠道都保存不了;改成新值则必须 canonical。
  184. ValidateSceneCode(body.SceneCode, unchangedFrom: e.SceneCode);
  185. ValidateSeverity(body.Severity, unchangedFrom: e.Severity);
  186. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:归一为 FOLLOW/SERIOUS 后再校验/写入。
  187. body.Severity = S8SeverityCode.Normalize(body.Severity);
  188. // S8-STEP6C-CFG-NOTIFY-CLOSURE-1:层级与渠道走白名单,杜绝保存消费方无法识别的配置。
  189. ValidateVocabulary(body);
  190. var exists = await _rep.AsQueryable()
  191. .AnyAsync(x => x.Id != id && x.TenantId == e.TenantId && x.FactoryId == e.FactoryId &&
  192. x.SceneCode == body.SceneCode && x.Severity == body.Severity && x.LevelCode == body.LevelCode);
  193. if (exists) throw new S8BizException("同场景+严重度+层级已存在");
  194. body.Id = id;
  195. body.TenantId = e.TenantId;
  196. body.FactoryId = e.FactoryId;
  197. body.CreatedAt = e.CreatedAt;
  198. body.UpdatedAt = DateTime.Now;
  199. await _rep.UpdateAsync(body);
  200. return body;
  201. }
  202. // S8-TENANT-FACTORY-P0-CLOSURE-1:删除必须先按可信作用域绑行,禁止裸 DeleteByIdAsync(id)。
  203. public async Task DeleteAsync(long id, S8TrustedScope scope)
  204. {
  205. var e = await LoadScopedAsync(id, scope);
  206. await _rep.DeleteByIdAsync(e.Id);
  207. }
  208. private async Task<AdoS8NotificationLayer> LoadScopedAsync(long id, S8TrustedScope scope) =>
  209. await _rep.AsQueryable()
  210. .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
  211. .FirstAsync() ?? throw new S8NotFoundException();
  212. }