S8NotificationLayerService.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  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. /// <summary>
  137. /// S8-CFG-NOTIFY-NONDEFAULT-ROLE-OPTIONS-FIX-1:通知分层的**目标角色候选**。
  138. ///
  139. /// <para>原实现按**默认租户的物理 Id** 筛选
  140. /// (<c>Id BETWEEN 1329908000101 AND 1329908000107</c> 外加 <c>1300000000101</c>)。
  141. /// 这些 Id 全部属于默认租户 <c>1300000000001</c>;而 <c>AidopTenantInitService</c>
  142. /// 是**按租户各复制一份**这 7 个角色的——同 Code、不同 Id。
  143. /// 于是在任何非默认租户下,仓储的租户过滤把这些 Id 全部滤掉,候选列表恒为空,
  144. /// 配置页无法查看或编辑目标角色。(UAT 实测:自有 7 个 canonical 角色,候选却返回 0。)</para>
  145. ///
  146. /// <para>修正为**按 canonical Role Code 白名单在可信租户作用域内查**:
  147. /// Code 是逻辑身份、跨租户稳定,Id 是租户本地物理身份。
  148. /// 这里**不显式传 tenantId**——租户边界由 <c>SqlSugarRepository&lt;SysRole&gt;</c> 的
  149. /// 全局租户过滤按认证身份施加,与 S8 其余读路径同一机制;为这一个方法另开旁路
  150. /// 反而会绕过可信作用域。</para>
  151. ///
  152. /// <para>返回形状保持 <c>{ value, label }</c> 不变(<c>value = Code</c>):
  153. /// <c>ado_s8_notification_layer.target_role_ids</c> 存的就是 Code,
  154. /// 改成物理 Id 会当场废掉既有配置与 <c>S8RoleResolver</c> 的解析口径。
  155. /// 顺带的好处是**出参里根本没有 Id**,也就不存在把默认租户物理 Id 泄漏给其它租户的可能。</para>
  156. ///
  157. /// <para>缺失行为(有意):canonical 白名单里某个 Code 在当前租户不存在时,
  158. /// **只是不出现这一项**——不回落默认租户、不自动建角色、不报错。</para>
  159. /// </summary>
  160. public async Task<List<object>> GetRoleOptionsAsync()
  161. {
  162. var codes = S8CanonicalRole.BusinessRoleCodes;
  163. var roles = await _roleRep.AsQueryable()
  164. .Where(x => codes.Contains(x.Code))
  165. .Select(x => new { x.Id, x.Code, x.Name })
  166. .ToListAsync();
  167. return ShapeRoleOptions(roles.Select(x => (x.Id, x.Code, x.Name)))
  168. // 附加 id 仅供追溯(证明选中的是本租户物理行);value 仍是 Code,形状向后兼容。
  169. .Select(x => (object)new { value = x.Code, label = x.Name, id = x.Id })
  170. .ToList();
  171. }
  172. /// <summary>
  173. /// role-options 的**纯整形逻辑**(去重 + 排序),与 DB 解耦以便直接单测。
  174. ///
  175. /// <para>去重:同租户同 Code 重复目前实测为 0(无 DB 唯一索引,属已登记技术债)。
  176. /// 取 Id 最小者是为了保证**确定性**,不是为了把重复数据藏起来——
  177. /// 重复本身仍应由数据治理批次处理。</para>
  178. ///
  179. /// <para>排序:由 canonical <c>OrderNo</c> 决定,**不由物理 Id 决定**;
  180. /// 否则同一份配置在不同租户会呈现不同顺序。</para>
  181. /// </summary>
  182. public static List<(long Id, string Code, string Name)> ShapeRoleOptions(
  183. IEnumerable<(long Id, string Code, string Name)> rows) =>
  184. rows.GroupBy(x => x.Code, StringComparer.Ordinal)
  185. .Select(g => g.OrderBy(x => x.Id).First())
  186. .OrderBy(x => S8CanonicalRole.OrderOf(x.Code))
  187. .ThenBy(x => x.Code, StringComparer.Ordinal)
  188. .ToList();
  189. // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 body.TenantId / body.FactoryId。
  190. public async Task<AdoS8NotificationLayer> CreateAsync(AdoS8NotificationLayer body, S8TrustedScope scope)
  191. {
  192. if (string.IsNullOrWhiteSpace(body.SceneCode) || string.IsNullOrWhiteSpace(body.Severity) || string.IsNullOrWhiteSpace(body.LevelCode))
  193. throw new S8BizException("场景、严重度、层级必填");
  194. if (string.IsNullOrWhiteSpace(body.TargetRoleIds))
  195. throw new S8BizException("目标角色必填");
  196. if (string.IsNullOrWhiteSpace(body.NotifyChannel))
  197. throw new S8BizException("通知渠道至少选一个");
  198. // S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:新建一律要求 canonical 词表,无 legacy 豁免(unchangedFrom = null)。
  199. // 严重度必须在 Normalize **之前**校验,否则兜底分支会把非法值静默降级成 FOLLOW,门禁永远命中不了。
  200. ValidateSceneCode(body.SceneCode, unchangedFrom: null);
  201. ValidateSeverity(body.Severity, unchangedFrom: null);
  202. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:归一为 FOLLOW/SERIOUS 后再校验/写入。
  203. body.Severity = S8SeverityCode.Normalize(body.Severity);
  204. // S8-STEP6C-CFG-NOTIFY-CLOSURE-1:层级与渠道走白名单,杜绝保存消费方无法识别的配置。
  205. ValidateVocabulary(body);
  206. body.TenantId = scope.TenantId;
  207. body.FactoryId = scope.FactoryId;
  208. var exists = await _rep.AsQueryable()
  209. .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId &&
  210. x.SceneCode == body.SceneCode && x.Severity == body.Severity && x.LevelCode == body.LevelCode);
  211. if (exists) throw new S8BizException("同场景+严重度+层级已存在");
  212. body.Id = 0;
  213. body.CreatedAt = DateTime.Now;
  214. return await _rep.InsertReturnEntityAsync(body);
  215. }
  216. // S8-TENANT-FACTORY-P0-CLOSURE-1:按 Id + 可信作用域绑行;越权 Id 视为不存在,归属不可被 body 改写。
  217. public async Task<AdoS8NotificationLayer> UpdateAsync(long id, AdoS8NotificationLayer body, S8TrustedScope scope)
  218. {
  219. var e = await LoadScopedAsync(id, scope);
  220. if (string.IsNullOrWhiteSpace(body.SceneCode) || string.IsNullOrWhiteSpace(body.Severity) || string.IsNullOrWhiteSpace(body.LevelCode))
  221. throw new S8BizException("场景、严重度、层级必填");
  222. if (string.IsNullOrWhiteSpace(body.TargetRoleIds))
  223. throw new S8BizException("目标角色必填");
  224. if (string.IsNullOrWhiteSpace(body.NotifyChannel))
  225. throw new S8BizException("通知渠道至少选一个");
  226. // S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:编辑允许保留「与库中现值逐字相同」的 legacy 场景 / 严重度,
  227. // 否则 S2S6_PRODUCTION、S8_DEMO_DEFAULT 等历史行会连改渠道都保存不了;改成新值则必须 canonical。
  228. ValidateSceneCode(body.SceneCode, unchangedFrom: e.SceneCode);
  229. ValidateSeverity(body.Severity, unchangedFrom: e.Severity);
  230. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:归一为 FOLLOW/SERIOUS 后再校验/写入。
  231. body.Severity = S8SeverityCode.Normalize(body.Severity);
  232. // S8-STEP6C-CFG-NOTIFY-CLOSURE-1:层级与渠道走白名单,杜绝保存消费方无法识别的配置。
  233. ValidateVocabulary(body);
  234. var exists = await _rep.AsQueryable()
  235. .AnyAsync(x => x.Id != id && x.TenantId == e.TenantId && x.FactoryId == e.FactoryId &&
  236. x.SceneCode == body.SceneCode && x.Severity == body.Severity && x.LevelCode == body.LevelCode);
  237. if (exists) throw new S8BizException("同场景+严重度+层级已存在");
  238. body.Id = id;
  239. body.TenantId = e.TenantId;
  240. body.FactoryId = e.FactoryId;
  241. body.CreatedAt = e.CreatedAt;
  242. body.UpdatedAt = DateTime.Now;
  243. await _rep.UpdateAsync(body);
  244. return body;
  245. }
  246. // S8-TENANT-FACTORY-P0-CLOSURE-1:删除必须先按可信作用域绑行,禁止裸 DeleteByIdAsync(id)。
  247. public async Task DeleteAsync(long id, S8TrustedScope scope)
  248. {
  249. var e = await LoadScopedAsync(id, scope);
  250. await _rep.DeleteByIdAsync(e.Id);
  251. }
  252. private async Task<AdoS8NotificationLayer> LoadScopedAsync(long id, S8TrustedScope scope) =>
  253. await _rep.AsQueryable()
  254. .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
  255. .FirstAsync() ?? throw new S8NotFoundException();
  256. }