S8ApprovalFlowRepairService.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. using System.Text.Json;
  2. using System.Text.Json.Nodes;
  3. using Admin.NET.Core;
  4. using Admin.NET.Plugin.AiDOP.Const.S8;
  5. using Admin.NET.Plugin.AiDOP.Entity.S8;
  6. using Microsoft.Extensions.Logging;
  7. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  8. /// <summary>修复结果。分项计数,运维要能看出「这次到底动了什么」。</summary>
  9. public sealed class S8ApprovalFlowRepairResult
  10. {
  11. public int FlowsInspected { get; set; }
  12. public int NodesInspected { get; set; }
  13. /// <summary>已经指向本租户角色、无需修改的节点。</summary>
  14. public int AlreadyOkCount { get; set; }
  15. /// <summary>本次改写的节点。</summary>
  16. public int RepairedCount { get; set; }
  17. /// <summary>该租户推导不出任何审核角色,无法修复 —— 必须人工处理。</summary>
  18. public int BlockedCount { get; set; }
  19. /// <summary>
  20. /// 本服务此前改过、但按<b>现在的判据</b>本不该改的节点,已按留证还原。
  21. /// <para>对账服务自己写下的东西,自己就该能撤回 —— 否则一次判据失误就变成永久损坏。</para>
  22. /// </summary>
  23. public int RestoredCount { get; set; }
  24. }
  25. /// <summary>
  26. /// S8-UAT-APPROVAL-REPAIR-1:修复 S8 两条审批流的审批人配置,让提交复核之后
  27. /// <b>真的有一个本租户账号接得住</b>。
  28. ///
  29. /// <para><b>本地取证到的失败形态</b>(这不是推测):UAT 租户 3 条
  30. /// <c>PENDING_VERIFICATION</c> 异常的 <c>verifier_user_id</c> 都是 UATQualityA,
  31. /// 而对应的 <c>EXCEPTION_CLOSURE</c> 实例 <b>Status=Running、tasks=0</b>、
  32. /// 异常上的 <c>active_flow_instance_id</c> 还是 NULL。原因是节点配的
  33. /// <c>approverType=Role, approverIds=1300000000101,1300000000888</c> 是<b>别租户的角色 Id</b>,
  34. /// <c>ResolveApprovers</c> 末尾的 <c>u.TenantId == effectiveTenantId</c> 把成员全过滤掉 →
  35. /// 返回 0 人 → <c>ProcessNextNode</c> 抛「审批人列表为空」→ 实例已落库但
  36. /// <c>OnFlowStarted</c> 从未被调用 → 流程永久悬挂,而 S8 侧一切显示正常。</para>
  37. ///
  38. /// <para><b>为什么改成 RoleCode 而不是 SpecificUser / 数字 RoleId</b>(以代码为证):
  39. /// <c>ResolveApprovers</c> 的三个分支租户安全性并不一致 ——
  40. /// <list type="bullet">
  41. /// <item><c>Role</c> + <b>非数字 token</b>:按 <c>SysRole.Code</c> 查且带
  42. /// <c>r.TenantId == effectiveTenantId</c>,<b>天然租户安全</b>;</item>
  43. /// <item><c>Role</c> + 数字 token:直接当 RoleId 用,<b>不校验角色租户</b>
  44. /// (只靠末尾的用户租户过滤兜底,结果是解析为空而非报错);</item>
  45. /// <item><c>SpecificUser</c> / <c>Department</c>:<b>完全没有显式租户谓词</b>,
  46. /// 行为取决于全局 multi-tenant AOP 的当前状态。</item>
  47. /// </list>
  48. /// 因此只有 RoleCode 形式能在不改 FlowEngine 的前提下保证租户正确。</para>
  49. ///
  50. /// <para><b>刻意不改 FlowEngine</b>:给 <c>SpecificUser</c> / <c>Department</c> 补租户谓词
  51. /// 会外溢到 S1/S5/S6/S7 —— 真库实测 74 个 SpecificUser 节点里有大量跨租户配置
  52. /// (<c>CONTRACT_REVIEW</c> / <c>ORDER_CHANGE_REVIEW</c> / <c>MATERIAL_SHORTAGE</c>
  53. /// 在三个租户里都指向租户 797 的 AIDopAdmin),一改就会把那些流程一起打空。
  54. /// 那是另一个批次的事,本批只修 S8 自己的两条链,并把该缺口如实登记为跨模块风险。</para>
  55. ///
  56. /// <para><b>只修坏的,不碰好的</b>:判据是「该节点在本租户解析不出任何审批人,
  57. /// 或引用了非本租户对象」。已经指向本租户角色的节点一律跳过 ——
  58. /// 每次启动无条件回写会覆盖管理员的手工配置,那是比配置错误更难发现的故障。
  59. /// 判据本身也保证了幂等:修好之后第二次运行就会命中「已 OK」而跳过。</para>
  60. /// </summary>
  61. public class S8ApprovalFlowRepairService : ITransient
  62. {
  63. /// <summary>复核流程。与 <c>S8TaskFlowService.TryStartVerificationFlowAsync</c> 一致。</summary>
  64. internal const string ClosureBizType = "EXCEPTION_CLOSURE";
  65. /// <summary>升级流程。与 <c>S8TaskFlowService.UpgradeAsync</c> 一致。</summary>
  66. internal const string EscalationBizType = "EXCEPTION_ESCALATION";
  67. private readonly SqlSugarRepository<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow> _flowRep;
  68. private readonly SqlSugarRepository<AdoS8ApprovalFlowRepairLog> _logRep;
  69. private readonly ILogger<S8ApprovalFlowRepairService> _logger;
  70. public S8ApprovalFlowRepairService(
  71. SqlSugarRepository<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow> flowRep,
  72. SqlSugarRepository<AdoS8ApprovalFlowRepairLog> logRep,
  73. ILogger<S8ApprovalFlowRepairService> logger)
  74. {
  75. _flowRep = flowRep;
  76. _logRep = logRep;
  77. _logger = logger;
  78. }
  79. public async Task<S8ApprovalFlowRepairResult> RepairAsync(CancellationToken ct = default)
  80. {
  81. var result = new S8ApprovalFlowRepairResult();
  82. var flows = await _flowRep.AsQueryable().ClearFilter()
  83. .Where(f => (f.BizType == ClosureBizType || f.BizType == EscalationBizType)
  84. && !f.IsDelete && f.TenantId != null)
  85. .ToListAsync(ct);
  86. result.FlowsInspected = flows.Count;
  87. if (flows.Count == 0) return result;
  88. var tenantIds = flows.Select(f => f.TenantId!.Value).Distinct().ToList();
  89. var verifyRoles = await RoleCodesByPermissionAsync(tenantIds, S8PermissionCatalog.VerificationApprove, ct);
  90. var upgradeRoles = await RoleCodesByPermissionAsync(tenantIds, S8PermissionCatalog.ExceptionUpgrade, ct);
  91. // 先读上一轮留证:本服务写下的改动,必须能按现在的判据重新审视并撤回。
  92. // 判据本身也会出错(第一版把「SpecificUser 指向本租户账号」误判为不安全,
  93. // 改坏了默认租户一条本来正确的配置),没有回退能力就等于一次失误永久生效。
  94. var priorLogs = (await _logRep.AsQueryable().ClearFilter()
  95. .Where(x => x.Outcome == "REPAIRED")
  96. .ToListAsync(ct))
  97. .GroupBy(x => (x.FlowId, x.NodeName))
  98. .ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.Id).First());
  99. var logs = new List<AdoS8ApprovalFlowRepairLog>();
  100. var now = DateTime.Now;
  101. foreach (var flow in flows)
  102. {
  103. ct.ThrowIfCancellationRequested();
  104. var tenantId = flow.TenantId!.Value;
  105. // 升级流程优先用该租户自己的「升级」角色;没有就回落「审核」角色。
  106. // 回落必须在留证里写清楚 —— 否则将来没人知道升级审批为什么是质检在审。
  107. var isEscalation = flow.BizType == EscalationBizType;
  108. var wanted = isEscalation
  109. ? (upgradeRoles.GetValueOrDefault(tenantId) ?? verifyRoles.GetValueOrDefault(tenantId))
  110. : verifyRoles.GetValueOrDefault(tenantId);
  111. var fellBack = isEscalation
  112. && (upgradeRoles.GetValueOrDefault(tenantId)?.Count ?? 0) == 0
  113. && (wanted?.Count ?? 0) > 0;
  114. var tenantScope = await TenantScopeAsync(tenantId, ct);
  115. JsonNode? root;
  116. try { root = JsonNode.Parse(flow.FlowJson ?? string.Empty); }
  117. catch (Exception ex)
  118. {
  119. _logger.LogWarning(ex, "s8_flow_repair_parse_failed flowId={FlowId} bizType={BizType}", flow.Id, flow.BizType);
  120. continue;
  121. }
  122. if (root?["nodes"] is not JsonArray nodes) continue;
  123. var changed = false;
  124. foreach (var node in nodes.OfType<JsonObject>())
  125. {
  126. if (node["properties"] is not JsonObject props) continue;
  127. if (props["approverType"] is null) continue;
  128. result.NodesInspected++;
  129. var nodeName = props["nodeName"]?.GetValue<string>() ?? "审批节点";
  130. var beforeType = props["approverType"]?.ToString();
  131. var beforeIds = props["approverIds"]?.ToString();
  132. // ── 自我纠错:本服务写下的改动,按现在的判据重新审视一次 ──
  133. if (priorLogs.TryGetValue((flow.Id, nodeName), out var prior)
  134. && string.Equals(prior.AfterType, beforeType, StringComparison.Ordinal)
  135. && string.Equals(prior.AfterIds, beforeIds, StringComparison.Ordinal)
  136. && IsAlreadyTenantSafe(prior.BeforeType, prior.BeforeIds, tenantScope))
  137. {
  138. props["approverType"] = prior.BeforeType;
  139. props["approverIds"] = prior.BeforeIds;
  140. props["approverNames"] = prior.BeforeIds;
  141. changed = true;
  142. result.RestoredCount++;
  143. logs.Add(Log(tenantId, flow, nodeName, beforeType, beforeIds,
  144. prior.BeforeType, prior.BeforeIds,
  145. "上一轮判据把「指向本租户账号的 SpecificUser」误判为不安全;按留证还原原配置",
  146. "RESTORED", now));
  147. _logger.LogWarning(
  148. "s8_flow_repair_restored tenant={Tenant} bizType={BizType} node={Node};"
  149. + "还原为 {Type}:{Ids}",
  150. tenantId, flow.BizType, nodeName, prior.BeforeType, prior.BeforeIds);
  151. continue;
  152. }
  153. // 引用的对象全部属于本租户 → 不碰。
  154. if (IsAlreadyTenantSafe(beforeType, beforeIds, tenantScope))
  155. {
  156. result.AlreadyOkCount++;
  157. logs.Add(Log(tenantId, flow, nodeName, beforeType, beforeIds, beforeType, beforeIds,
  158. "所引用的对象全部属于本租户,未修改", "SKIPPED_ALREADY_OK", now));
  159. continue;
  160. }
  161. if (wanted == null || wanted.Count == 0)
  162. {
  163. // 推导不出角色就<b>不猜</b>:宁可留着坏配置并大声报出来,
  164. // 也不要塞一个"看起来能用"的角色进去 —— 后者会让人以为审核链已经通了。
  165. result.BlockedCount++;
  166. logs.Add(Log(tenantId, flow, nodeName, beforeType, beforeIds, beforeType, beforeIds,
  167. $"本租户没有任何角色被授予 {(isEscalation ? S8PermissionCatalog.ExceptionUpgrade : S8PermissionCatalog.VerificationApprove)},无法派生审批人,保持原样",
  168. "BLOCKED_NO_ROLE", now));
  169. _logger.LogWarning(
  170. "s8_flow_repair_blocked tenant={Tenant} bizType={BizType} node={Node};"
  171. + "该租户推导不出审核角色,审批人仍为空,需人工配置",
  172. tenantId, flow.BizType, nodeName);
  173. continue;
  174. }
  175. var afterIds = string.Join(',', wanted);
  176. props["approverType"] = nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.Role);
  177. props["approverIds"] = afterIds;
  178. // approverNames 只是设计器的显示文案;同步成角色编码,避免页面继续显示
  179. // 「系统管理员」这种早已不成立的名字。
  180. props["approverNames"] = afterIds;
  181. changed = true;
  182. result.RepairedCount++;
  183. var reason = $"原配置在本租户解析不出任何审批人({beforeType}:{beforeIds});"
  184. + $"改为本租户 RoleCode(ResolveApprovers 的 code 分支带 SysRole.TenantId 校验)"
  185. + (fellBack ? ";本租户无专门的升级审批角色,回落使用审核角色" : string.Empty);
  186. logs.Add(Log(tenantId, flow, nodeName, beforeType, beforeIds,
  187. nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.Role), afterIds, reason, "REPAIRED", now));
  188. }
  189. if (!changed) continue;
  190. await _flowRep.Context.Updateable<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow>()
  191. .SetColumns(f => new Admin.NET.Plugin.ApprovalFlow.ApprovalFlow { FlowJson = root.ToJsonString() })
  192. .Where(f => f.Id == flow.Id)
  193. .ExecuteCommandAsync(ct);
  194. }
  195. if (logs.Count > 0)
  196. {
  197. // 留证表每次全量重写:它描述的是「最近一次对账看到了什么」,
  198. // 累积历史只会让人分不清哪一行是当前事实。
  199. await _logRep.AsDeleteable().Where(x => true).ExecuteCommandAsync();
  200. await _logRep.AsInsertable(logs).ExecuteCommandAsync();
  201. }
  202. _logger.LogInformation(
  203. "s8_flow_repair_done flows={Flows} nodes={Nodes} repaired={Repaired} restored={Restored} alreadyOk={Ok} blocked={Blocked}",
  204. result.FlowsInspected, result.NodesInspected, result.RepairedCount, result.RestoredCount,
  205. result.AlreadyOkCount, result.BlockedCount);
  206. return result;
  207. }
  208. // ────────────────────────────────────────────────────────────────
  209. /// <summary>某个租户里可被审批人配置引用的对象集合。</summary>
  210. internal sealed record S8TenantApproverScope(
  211. IReadOnlySet<string> RoleCodes,
  212. IReadOnlySet<long> RoleIds,
  213. IReadOnlySet<long> UserIds,
  214. IReadOnlySet<long> OrgIds);
  215. /// <summary>
  216. /// 「已经租户安全」的判据:<b>该节点引用的每一个对象都属于本租户</b>。
  217. ///
  218. /// <para><b>判据是"引用对象的归属",不是"用了哪种 approverType"。</b>
  219. /// 第一版把所有 <c>SpecificUser</c> 一律判为不安全,结果把默认租户一条<b>本来正确</b>
  220. /// 的配置(<c>SpecificUser=789819678195781</c>,该账号正属于默认租户)也改写了 ——
  221. /// 那是把"这种写法在别的租户下不安全"错当成"这个配置错了"。
  222. /// 修复服务只该动<b>真的指向别人</b>的配置。</para>
  223. ///
  224. /// <para><c>Initiator</c> 由运行时按发起人解析,天然不跨租户,一律视为安全。</para>
  225. /// </summary>
  226. internal static bool IsAlreadyTenantSafe(string? approverType, string? approverIds, S8TenantApproverScope scope)
  227. {
  228. if (string.IsNullOrWhiteSpace(approverType)) return false;
  229. if (string.Equals(approverType, nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.Initiator), StringComparison.Ordinal))
  230. return true;
  231. if (string.IsNullOrWhiteSpace(approverIds)) return false;
  232. var tokens = approverIds.Split(',', StringSplitOptions.RemoveEmptyEntries)
  233. .Select(t => t.Trim()).Where(t => t.Length > 0).ToList();
  234. if (tokens.Count == 0) return false;
  235. if (string.Equals(approverType, nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.Role), StringComparison.Ordinal))
  236. return tokens.All(t => long.TryParse(t, out var id)
  237. ? scope.RoleIds.Contains(id)
  238. : scope.RoleCodes.Contains(t));
  239. if (string.Equals(approverType, nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.SpecificUser), StringComparison.Ordinal))
  240. return tokens.All(t => long.TryParse(t, out var id) && scope.UserIds.Contains(id));
  241. if (string.Equals(approverType, nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.Department), StringComparison.Ordinal))
  242. return tokens.All(t => long.TryParse(t, out var id) && scope.OrgIds.Contains(id));
  243. // 认不出的类型不敢断言安全,但也不改 —— 交给下面的 BLOCKED 分支大声报出来。
  244. return false;
  245. }
  246. /// <summary>某能力码在各租户下由哪些<b>本租户</b>角色持有(返回 RoleCode)。</summary>
  247. private async Task<Dictionary<long, List<string>>> RoleCodesByPermissionAsync(
  248. List<long> tenantIds, string permission, CancellationToken ct)
  249. {
  250. var rows = await _flowRep.Context.Queryable<SysRole>().ClearFilter()
  251. .InnerJoin<SysRoleMenu>((r, rm) => r.Id == rm.RoleId)
  252. .InnerJoin<SysMenu>((r, rm, m) => rm.MenuId == m.Id)
  253. .Where((r, rm, m) => r.TenantId != null
  254. && tenantIds.Contains(r.TenantId.Value)
  255. && r.Status == StatusEnum.Enable
  256. && m.Type == MenuTypeEnum.Btn
  257. && m.Permission == permission
  258. && r.Code != null && r.Code != "")
  259. .Select((r, rm, m) => new { TenantId = r.TenantId!.Value, r.Code })
  260. .ToListAsync(ct);
  261. return rows
  262. .GroupBy(x => x.TenantId)
  263. .ToDictionary(g => g.Key, g => g.Select(x => x.Code!).Distinct().OrderBy(x => x, StringComparer.Ordinal).ToList());
  264. }
  265. private async Task<S8TenantApproverScope> TenantScopeAsync(long tenantId, CancellationToken ct)
  266. {
  267. var roles = await _flowRep.Context.Queryable<SysRole>().ClearFilter()
  268. .Where(r => r.TenantId == tenantId)
  269. .Select(r => new { r.Id, r.Code })
  270. .ToListAsync(ct);
  271. var userIds = await _flowRep.Context.Queryable<SysUser>().ClearFilter()
  272. .Where(u => u.TenantId == tenantId)
  273. .Select(u => u.Id)
  274. .ToListAsync(ct);
  275. var orgIds = await _flowRep.Context.Queryable<SysOrg>().ClearFilter()
  276. .Where(o => o.TenantId == tenantId)
  277. .Select(o => o.Id)
  278. .ToListAsync(ct);
  279. return new S8TenantApproverScope(
  280. roles.Where(r => !string.IsNullOrWhiteSpace(r.Code)).Select(r => r.Code!).ToHashSet(StringComparer.Ordinal),
  281. roles.Select(r => r.Id).ToHashSet(),
  282. userIds.ToHashSet(),
  283. orgIds.ToHashSet());
  284. }
  285. private static AdoS8ApprovalFlowRepairLog Log(
  286. long tenantId, Admin.NET.Plugin.ApprovalFlow.ApprovalFlow flow, string nodeName,
  287. string? beforeType, string? beforeIds, string? afterType, string? afterIds,
  288. string reason, string outcome, DateTime now) => new()
  289. {
  290. TenantId = tenantId,
  291. FlowId = flow.Id,
  292. BizType = flow.BizType ?? string.Empty,
  293. NodeName = nodeName,
  294. BeforeType = beforeType,
  295. BeforeIds = beforeIds,
  296. AfterType = afterType,
  297. AfterIds = afterIds,
  298. Reason = reason,
  299. Outcome = outcome,
  300. RepairedAt = now
  301. };
  302. }