S8ReviewSourceService.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. using System.Text.Json;
  2. using Admin.NET.Core;
  3. using Admin.NET.Plugin.AiDOP.Entity.S8;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  5. using Admin.NET.Plugin.ApprovalFlow;
  6. using Microsoft.Extensions.Logging;
  7. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  8. /// <summary>审批流节点上配置的一个审批人来源(原样呈现,不做任何"修正")。</summary>
  9. public sealed class S8ReviewApproverNodeDto
  10. {
  11. public string NodeName { get; set; } = string.Empty;
  12. /// <summary>原始 approverType(SpecificUser / Role / Department / Initiator / DepartmentLeader)。</summary>
  13. public string ApproverType { get; set; } = string.Empty;
  14. /// <summary>原始 approverIds,逐字回显。</summary>
  15. public string ApproverIds { get; set; } = string.Empty;
  16. /// <summary>在<b>当前租户</b>下实际能解析出的账号(展示名)。</summary>
  17. public List<string> ResolvedUsers { get; set; } = new();
  18. /// <summary>解析结果为 0 人 —— 该节点在本租户下没有任何人可审批。</summary>
  19. public bool ResolvesToNobody => ResolvedUsers.Count == 0;
  20. /// <summary>配置里引用了不属于本租户的账号 / 角色。</summary>
  21. public List<string> CrossTenantRefs { get; set; } = new();
  22. }
  23. /// <summary>一个审核环节的真实来源。</summary>
  24. public sealed class S8ReviewStageDto
  25. {
  26. /// <summary>环节名(复核 / 升级)。</summary>
  27. public string Stage { get; set; } = string.Empty;
  28. /// <summary>这一环的审核人<b>实际由什么决定</b>——用业务语言,不用内部术语。</summary>
  29. public string DecidedBy { get; set; } = string.Empty;
  30. /// <summary>关联的审批流 BizType(没有则为空)。</summary>
  31. public string? FlowBizType { get; set; }
  32. public string? FlowName { get; set; }
  33. public List<S8ReviewApproverNodeDto> Nodes { get; set; } = new();
  34. /// <summary>需要管理员注意的问题(跨租户引用 / 解析为 0 人 / 未配置流程)。</summary>
  35. public List<string> Warnings { get; set; } = new();
  36. }
  37. /// <summary>
  38. /// S8-RULE-4TAB-1:<b>只读</b>解析「这条规则的异常,审核时到底由谁审」。
  39. ///
  40. /// <para><b>本服务不改变任何审批语义</b>:不写库、不改 ApproverType、不动流程节点、
  41. /// 不新增 reviewer 配置。它唯一的职责是把<b>当前事实</b>如实呈现给管理员 ——
  42. /// 包括那些不好看的事实。</para>
  43. ///
  44. /// <para><b>为什么必须做这件事</b>:规则配置页此前把审核人显示为「主管」,
  45. /// 而系统里根本不存在组织主管关系(<c>SysOrg.DirectorId</c> 与
  46. /// <c>SysUser.ManagerUserId</c> 实测填充率均为 0)。管理员据此以为审核链是通的,
  47. /// 实际上:UAT 租户的复核流程 <c>EXCEPTION_CLOSURE</c> 配的是
  48. /// <c>approverType=Role, approverIds=1300000000101,1300000000888</c>,
  49. /// 而这两个角色<b>属于别的租户</b>,在本租户下解析为 0 人;
  50. /// 升级流程 <c>EXCEPTION_ESCALATION</c> 三个节点全部指向
  51. /// <c>SpecificUser=789819678195781(Demo02,默认租户)</c>。
  52. /// 这些都不是本批要改的东西 —— 但管理员必须看得见。</para>
  53. /// </summary>
  54. public class S8ReviewSourceService : ITransient
  55. {
  56. private readonly SqlSugarRepository<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow> _flowRep;
  57. private readonly SqlSugarRepository<AdoS8ExceptionType> _typeRep;
  58. private readonly IS8UserScopeValidator _userScope;
  59. private readonly S8RoleResolver _roleResolver;
  60. private readonly ILogger<S8ReviewSourceService> _logger;
  61. /// <summary>复检流程的 BizType(与 <c>S8TaskFlowService.TryStartVerificationFlowAsync</c> 一致)。</summary>
  62. private const string ClosureBizType = "EXCEPTION_CLOSURE";
  63. /// <summary>升级流程的 BizType(与 <c>S8TaskFlowService.UpgradeAsync</c> 一致)。</summary>
  64. private const string EscalationBizType = "EXCEPTION_ESCALATION";
  65. public S8ReviewSourceService(
  66. SqlSugarRepository<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow> flowRep,
  67. SqlSugarRepository<AdoS8ExceptionType> typeRep,
  68. IS8UserScopeValidator userScope,
  69. S8RoleResolver roleResolver,
  70. ILogger<S8ReviewSourceService> logger)
  71. {
  72. _flowRep = flowRep;
  73. _typeRep = typeRep;
  74. _userScope = userScope;
  75. _roleResolver = roleResolver;
  76. _logger = logger;
  77. }
  78. /// <summary>解析该租户下两个审核环节的真实来源。</summary>
  79. public async Task<List<S8ReviewStageDto>> DescribeAsync(long tenantId)
  80. {
  81. var stages = new List<S8ReviewStageDto>();
  82. // ── 复核 ──
  83. // 真正决定审核人的是 S8 自己的状态机:提交复核时手选一个本租户账号写入
  84. // verifier_user_id。EXCEPTION_CLOSURE 流程是"双线合一"的影子,它的审批人配置
  85. // 并不决定谁能点"检验通过"。这一点必须说清楚,否则管理员会去改流程配置,
  86. // 然后发现毫无效果。
  87. var closure = await DescribeFlowAsync(tenantId, ClosureBizType, "复核");
  88. closure.DecidedBy = "提交复核时由处理人手选一名本租户账号(写入 verifier_user_id),"
  89. + "只有该账号能执行「检验通过 / 检验退回」。";
  90. if (closure.Nodes.Count > 0)
  91. closure.Warnings.Add(
  92. "下方审批流节点仅用于同步流程状态,**不决定**谁能点检验通过;改它不会改变实际审核人。");
  93. stages.Add(closure);
  94. // ── 升级 ──
  95. var escalation = await DescribeFlowAsync(tenantId, EscalationBizType, "升级");
  96. escalation.DecidedBy = escalation.Nodes.Count > 0
  97. ? "由升级审批流程的节点配置决定。"
  98. : "本租户尚未配置升级审批流程,升级将无人可审。";
  99. stages.Add(escalation);
  100. return stages;
  101. }
  102. /// <summary>
  103. /// 附带说明:异常类型上的 <c>escalate_role_code</c>。
  104. /// <para>它是超时自动升级时用的角色,与上面的流程节点是<b>两套东西</b>;
  105. /// 真库实测 owner 与 escalate 角色几乎全部相同(8 组完全一致),
  106. /// 也就是"升级"实际上升给了同一批人。这同样只呈现、不修改。</para>
  107. /// </summary>
  108. public async Task<List<string>> DescribeEscalateRolesAsync(long tenantId, string? sceneCode)
  109. {
  110. var rows = await _typeRep.AsQueryable().ClearFilter()
  111. .Where(x => x.TenantId == tenantId)
  112. .WhereIF(!string.IsNullOrWhiteSpace(sceneCode), x => x.SceneCode == sceneCode)
  113. .Select(x => new { x.TypeCode, x.OwnerRoleCode, x.EscalateRoleCode })
  114. .ToListAsync();
  115. return rows
  116. .Where(r => !string.IsNullOrWhiteSpace(r.EscalateRoleCode))
  117. .Select(r => r.OwnerRoleCode == r.EscalateRoleCode
  118. ? $"{r.TypeCode}:{r.EscalateRoleCode}(与责任角色相同,升级后仍是同一批人)"
  119. : $"{r.TypeCode}:{r.EscalateRoleCode}")
  120. .Distinct()
  121. .ToList();
  122. }
  123. // ────────────────────────────────────────────────────────────────
  124. private async Task<S8ReviewStageDto> DescribeFlowAsync(long tenantId, string bizType, string stageName)
  125. {
  126. var dto = new S8ReviewStageDto { Stage = stageName, FlowBizType = bizType };
  127. var flow = await _flowRep.AsQueryable().ClearFilter()
  128. .Where(x => x.BizType == bizType && x.TenantId == tenantId && !x.IsDelete)
  129. .OrderByDescending(x => x.Id)
  130. .FirstAsync();
  131. if (flow == null)
  132. {
  133. dto.Warnings.Add($"本租户没有 {bizType} 审批流程。");
  134. return dto;
  135. }
  136. dto.FlowName = flow.Name;
  137. List<(string NodeName, int Type, string Ids)> nodes;
  138. try
  139. {
  140. nodes = ParseApproverNodes(flow.FlowJson);
  141. }
  142. catch (Exception ex)
  143. {
  144. // 解析失败要说出来,不能装作"这个流程没有审批人"。
  145. _logger.LogWarning(ex, "s8_review_source_parse_failed bizType={BizType} tenant={Tenant}", bizType, tenantId);
  146. dto.Warnings.Add("流程定义无法解析,无法确定审批人来源。");
  147. return dto;
  148. }
  149. foreach (var (nodeName, type, ids) in nodes)
  150. {
  151. var node = new S8ReviewApproverNodeDto
  152. {
  153. NodeName = nodeName,
  154. ApproverType = ((ApproverTypeEnum)type).ToString(),
  155. ApproverIds = ids
  156. };
  157. switch ((ApproverTypeEnum)type)
  158. {
  159. case ApproverTypeEnum.SpecificUser:
  160. await ResolveSpecificUsersAsync(tenantId, ids, node);
  161. break;
  162. case ApproverTypeEnum.Role:
  163. await ResolveRolesAsync(tenantId, ids, node);
  164. break;
  165. default:
  166. // Department / Initiator / DepartmentLeader 由流程引擎在运行时按发起人上下文解析,
  167. // 静态解析不出确定人选。**不猜**,如实说明。
  168. node.ResolvedUsers.Add($"(运行时按发起人解析:{node.ApproverType})");
  169. break;
  170. }
  171. if (node.ResolvesToNobody)
  172. dto.Warnings.Add($"节点「{nodeName}」在本租户下解析不出任何审批人。");
  173. if (node.CrossTenantRefs.Count > 0)
  174. dto.Warnings.Add($"节点「{nodeName}」引用了不属于本租户的对象:{string.Join('、', node.CrossTenantRefs)}。");
  175. dto.Nodes.Add(node);
  176. }
  177. return dto;
  178. }
  179. private async Task ResolveSpecificUsersAsync(long tenantId, string ids, S8ReviewApproverNodeDto node)
  180. {
  181. var tokens = S8RoleResolver.SplitTokens(ids);
  182. foreach (var t in tokens)
  183. {
  184. if (!long.TryParse(t, out var uid)) { node.CrossTenantRefs.Add($"{t}(非法账号 Id)"); continue; }
  185. var user = await _userScope.FindActiveUserAsync(tenantId, uid);
  186. if (user != null) { node.ResolvedUsers.Add($"{user.DisplayName} ({user.Account})"); continue; }
  187. // 跨租户 / 已停用 —— 这里刻意查出真名并标注,因为这是给<b>管理员排障</b>用的
  188. // 只读视图,不是对外接口;不说清楚他无从判断该改哪里。
  189. var any = await _flowRep.Context.Queryable<SysUser>().ClearFilter()
  190. .Where(u => u.Id == uid).Select(u => new { u.Account, u.TenantId, u.Status }).FirstAsync();
  191. node.CrossTenantRefs.Add(any == null
  192. ? $"{t}(账号不存在)"
  193. : $"{any.Account}({(any.TenantId == tenantId ? "已停用" : "属于其它租户")})");
  194. }
  195. }
  196. private async Task ResolveRolesAsync(long tenantId, string ids, S8ReviewApproverNodeDto node)
  197. {
  198. var tokens = S8RoleResolver.SplitTokens(ids);
  199. var userIds = await _roleResolver.ResolveUserIdsAsync(tenantId, tokens);
  200. var users = await _userScope.ValidateUsersAsync(tenantId, userIds);
  201. node.ResolvedUsers.AddRange(users.Select(u => $"{u.DisplayName} ({u.Account})"));
  202. if (node.ResolvedUsers.Count > 0) return;
  203. // 解析为 0 人时要指出**为什么**:token 是本租户不存在的角色,还是角色没有成员。
  204. foreach (var t in tokens)
  205. {
  206. var byCode = await _flowRep.Context.Queryable<SysRole>().ClearFilter()
  207. .Where(r => r.Code == t && r.TenantId == tenantId).AnyAsync();
  208. if (byCode) continue;
  209. if (long.TryParse(t, out var rid))
  210. {
  211. var role = await _flowRep.Context.Queryable<SysRole>().ClearFilter()
  212. .Where(r => r.Id == rid).Select(r => new { r.Code, r.TenantId }).FirstAsync();
  213. node.CrossTenantRefs.Add(role == null
  214. ? $"{t}(角色不存在)"
  215. : $"{role.Code}(属于其它租户)");
  216. }
  217. else
  218. {
  219. node.CrossTenantRefs.Add($"{t}(本租户无此角色)");
  220. }
  221. }
  222. }
  223. /// <summary>
  224. /// 从流程定义 JSON 里抽出审批节点。
  225. /// <para>结构由 ApprovalFlow 插件定义,本方法只做<b>宽松读取</b>:认不出的结构一律跳过,
  226. /// 绝不猜测。读不出来时上层会显示"无法确定审批人来源",而不是"没有审批人"。</para>
  227. /// </summary>
  228. internal static List<(string NodeName, int Type, string Ids)> ParseApproverNodes(string? flowJson)
  229. {
  230. var result = new List<(string, int, string)>();
  231. if (string.IsNullOrWhiteSpace(flowJson)) return result;
  232. using var doc = JsonDocument.Parse(flowJson);
  233. Walk(doc.RootElement);
  234. return result;
  235. void Walk(JsonElement el)
  236. {
  237. switch (el.ValueKind)
  238. {
  239. case JsonValueKind.Object:
  240. if (el.TryGetProperty("approverType", out var at))
  241. {
  242. var name = el.TryGetProperty("nodeName", out var nn) && nn.ValueKind == JsonValueKind.String
  243. ? nn.GetString() ?? "审批节点"
  244. : "审批节点";
  245. var type = at.ValueKind switch
  246. {
  247. JsonValueKind.Number when at.TryGetInt32(out var n) => n,
  248. JsonValueKind.String when Enum.TryParse<ApproverTypeEnum>(at.GetString(), true, out var e) => (int)e,
  249. _ => -1
  250. };
  251. var ids = el.TryGetProperty("approverIds", out var ai) && ai.ValueKind == JsonValueKind.String
  252. ? ai.GetString() ?? string.Empty
  253. : string.Empty;
  254. if (type >= 0) result.Add((name, type, ids));
  255. }
  256. foreach (var p in el.EnumerateObject()) Walk(p.Value);
  257. break;
  258. case JsonValueKind.Array:
  259. foreach (var item in el.EnumerateArray()) Walk(item);
  260. break;
  261. }
  262. }
  263. }
  264. }