| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- using System.Text.Json;
- using Admin.NET.Core;
- using Admin.NET.Plugin.AiDOP.Entity.S8;
- using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
- using Admin.NET.Plugin.ApprovalFlow;
- using Microsoft.Extensions.Logging;
- namespace Admin.NET.Plugin.AiDOP.Service.S8;
- /// <summary>审批流节点上配置的一个审批人来源(原样呈现,不做任何"修正")。</summary>
- public sealed class S8ReviewApproverNodeDto
- {
- public string NodeName { get; set; } = string.Empty;
- /// <summary>原始 approverType(SpecificUser / Role / Department / Initiator / DepartmentLeader)。</summary>
- public string ApproverType { get; set; } = string.Empty;
- /// <summary>原始 approverIds,逐字回显。</summary>
- public string ApproverIds { get; set; } = string.Empty;
- /// <summary>在<b>当前租户</b>下实际能解析出的账号(展示名)。</summary>
- public List<string> ResolvedUsers { get; set; } = new();
- /// <summary>解析结果为 0 人 —— 该节点在本租户下没有任何人可审批。</summary>
- public bool ResolvesToNobody => ResolvedUsers.Count == 0;
- /// <summary>配置里引用了不属于本租户的账号 / 角色。</summary>
- public List<string> CrossTenantRefs { get; set; } = new();
- }
- /// <summary>一个审核环节的真实来源。</summary>
- public sealed class S8ReviewStageDto
- {
- /// <summary>环节名(复核 / 升级)。</summary>
- public string Stage { get; set; } = string.Empty;
- /// <summary>这一环的审核人<b>实际由什么决定</b>——用业务语言,不用内部术语。</summary>
- public string DecidedBy { get; set; } = string.Empty;
- /// <summary>关联的审批流 BizType(没有则为空)。</summary>
- public string? FlowBizType { get; set; }
- public string? FlowName { get; set; }
- public List<S8ReviewApproverNodeDto> Nodes { get; set; } = new();
- /// <summary>需要管理员注意的问题(跨租户引用 / 解析为 0 人 / 未配置流程)。</summary>
- public List<string> Warnings { get; set; } = new();
- }
- /// <summary>
- /// S8-RULE-4TAB-1:<b>只读</b>解析「这条规则的异常,审核时到底由谁审」。
- ///
- /// <para><b>本服务不改变任何审批语义</b>:不写库、不改 ApproverType、不动流程节点、
- /// 不新增 reviewer 配置。它唯一的职责是把<b>当前事实</b>如实呈现给管理员 ——
- /// 包括那些不好看的事实。</para>
- ///
- /// <para><b>为什么必须做这件事</b>:规则配置页此前把审核人显示为「主管」,
- /// 而系统里根本不存在组织主管关系(<c>SysOrg.DirectorId</c> 与
- /// <c>SysUser.ManagerUserId</c> 实测填充率均为 0)。管理员据此以为审核链是通的,
- /// 实际上:UAT 租户的复核流程 <c>EXCEPTION_CLOSURE</c> 配的是
- /// <c>approverType=Role, approverIds=1300000000101,1300000000888</c>,
- /// 而这两个角色<b>属于别的租户</b>,在本租户下解析为 0 人;
- /// 升级流程 <c>EXCEPTION_ESCALATION</c> 三个节点全部指向
- /// <c>SpecificUser=789819678195781(Demo02,默认租户)</c>。
- /// 这些都不是本批要改的东西 —— 但管理员必须看得见。</para>
- /// </summary>
- public class S8ReviewSourceService : ITransient
- {
- private readonly SqlSugarRepository<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow> _flowRep;
- private readonly SqlSugarRepository<AdoS8ExceptionType> _typeRep;
- private readonly IS8UserScopeValidator _userScope;
- private readonly S8RoleResolver _roleResolver;
- private readonly ILogger<S8ReviewSourceService> _logger;
- /// <summary>复检流程的 BizType(与 <c>S8TaskFlowService.TryStartVerificationFlowAsync</c> 一致)。</summary>
- private const string ClosureBizType = "EXCEPTION_CLOSURE";
- /// <summary>升级流程的 BizType(与 <c>S8TaskFlowService.UpgradeAsync</c> 一致)。</summary>
- private const string EscalationBizType = "EXCEPTION_ESCALATION";
- public S8ReviewSourceService(
- SqlSugarRepository<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow> flowRep,
- SqlSugarRepository<AdoS8ExceptionType> typeRep,
- IS8UserScopeValidator userScope,
- S8RoleResolver roleResolver,
- ILogger<S8ReviewSourceService> logger)
- {
- _flowRep = flowRep;
- _typeRep = typeRep;
- _userScope = userScope;
- _roleResolver = roleResolver;
- _logger = logger;
- }
- /// <summary>解析该租户下两个审核环节的真实来源。</summary>
- public async Task<List<S8ReviewStageDto>> DescribeAsync(long tenantId)
- {
- var stages = new List<S8ReviewStageDto>();
- // ── 复核 ──
- // 真正决定审核人的是 S8 自己的状态机:提交复核时手选一个本租户账号写入
- // verifier_user_id。EXCEPTION_CLOSURE 流程是"双线合一"的影子,它的审批人配置
- // 并不决定谁能点"检验通过"。这一点必须说清楚,否则管理员会去改流程配置,
- // 然后发现毫无效果。
- var closure = await DescribeFlowAsync(tenantId, ClosureBizType, "复核");
- closure.DecidedBy = "提交复核时由处理人手选一名本租户账号(写入 verifier_user_id),"
- + "只有该账号能执行「检验通过 / 检验退回」。";
- if (closure.Nodes.Count > 0)
- closure.Warnings.Add(
- "下方审批流节点仅用于同步流程状态,**不决定**谁能点检验通过;改它不会改变实际审核人。");
- stages.Add(closure);
- // ── 升级 ──
- var escalation = await DescribeFlowAsync(tenantId, EscalationBizType, "升级");
- escalation.DecidedBy = escalation.Nodes.Count > 0
- ? "由升级审批流程的节点配置决定。"
- : "本租户尚未配置升级审批流程,升级将无人可审。";
- stages.Add(escalation);
- return stages;
- }
- /// <summary>
- /// 附带说明:异常类型上的 <c>escalate_role_code</c>。
- /// <para>它是超时自动升级时用的角色,与上面的流程节点是<b>两套东西</b>;
- /// 真库实测 owner 与 escalate 角色几乎全部相同(8 组完全一致),
- /// 也就是"升级"实际上升给了同一批人。这同样只呈现、不修改。</para>
- /// </summary>
- public async Task<List<string>> DescribeEscalateRolesAsync(long tenantId, string? sceneCode)
- {
- var rows = await _typeRep.AsQueryable().ClearFilter()
- .Where(x => x.TenantId == tenantId)
- .WhereIF(!string.IsNullOrWhiteSpace(sceneCode), x => x.SceneCode == sceneCode)
- .Select(x => new { x.TypeCode, x.OwnerRoleCode, x.EscalateRoleCode })
- .ToListAsync();
- return rows
- .Where(r => !string.IsNullOrWhiteSpace(r.EscalateRoleCode))
- .Select(r => r.OwnerRoleCode == r.EscalateRoleCode
- ? $"{r.TypeCode}:{r.EscalateRoleCode}(与责任角色相同,升级后仍是同一批人)"
- : $"{r.TypeCode}:{r.EscalateRoleCode}")
- .Distinct()
- .ToList();
- }
- // ────────────────────────────────────────────────────────────────
- private async Task<S8ReviewStageDto> DescribeFlowAsync(long tenantId, string bizType, string stageName)
- {
- var dto = new S8ReviewStageDto { Stage = stageName, FlowBizType = bizType };
- var flow = await _flowRep.AsQueryable().ClearFilter()
- .Where(x => x.BizType == bizType && x.TenantId == tenantId && !x.IsDelete)
- .OrderByDescending(x => x.Id)
- .FirstAsync();
- if (flow == null)
- {
- dto.Warnings.Add($"本租户没有 {bizType} 审批流程。");
- return dto;
- }
- dto.FlowName = flow.Name;
- List<(string NodeName, int Type, string Ids)> nodes;
- try
- {
- nodes = ParseApproverNodes(flow.FlowJson);
- }
- catch (Exception ex)
- {
- // 解析失败要说出来,不能装作"这个流程没有审批人"。
- _logger.LogWarning(ex, "s8_review_source_parse_failed bizType={BizType} tenant={Tenant}", bizType, tenantId);
- dto.Warnings.Add("流程定义无法解析,无法确定审批人来源。");
- return dto;
- }
- foreach (var (nodeName, type, ids) in nodes)
- {
- var node = new S8ReviewApproverNodeDto
- {
- NodeName = nodeName,
- ApproverType = ((ApproverTypeEnum)type).ToString(),
- ApproverIds = ids
- };
- switch ((ApproverTypeEnum)type)
- {
- case ApproverTypeEnum.SpecificUser:
- await ResolveSpecificUsersAsync(tenantId, ids, node);
- break;
- case ApproverTypeEnum.Role:
- await ResolveRolesAsync(tenantId, ids, node);
- break;
- default:
- // Department / Initiator / DepartmentLeader 由流程引擎在运行时按发起人上下文解析,
- // 静态解析不出确定人选。**不猜**,如实说明。
- node.ResolvedUsers.Add($"(运行时按发起人解析:{node.ApproverType})");
- break;
- }
- if (node.ResolvesToNobody)
- dto.Warnings.Add($"节点「{nodeName}」在本租户下解析不出任何审批人。");
- if (node.CrossTenantRefs.Count > 0)
- dto.Warnings.Add($"节点「{nodeName}」引用了不属于本租户的对象:{string.Join('、', node.CrossTenantRefs)}。");
- dto.Nodes.Add(node);
- }
- return dto;
- }
- private async Task ResolveSpecificUsersAsync(long tenantId, string ids, S8ReviewApproverNodeDto node)
- {
- var tokens = S8RoleResolver.SplitTokens(ids);
- foreach (var t in tokens)
- {
- if (!long.TryParse(t, out var uid)) { node.CrossTenantRefs.Add($"{t}(非法账号 Id)"); continue; }
- var user = await _userScope.FindActiveUserAsync(tenantId, uid);
- if (user != null) { node.ResolvedUsers.Add($"{user.DisplayName} ({user.Account})"); continue; }
- // 跨租户 / 已停用 —— 这里刻意查出真名并标注,因为这是给<b>管理员排障</b>用的
- // 只读视图,不是对外接口;不说清楚他无从判断该改哪里。
- var any = await _flowRep.Context.Queryable<SysUser>().ClearFilter()
- .Where(u => u.Id == uid).Select(u => new { u.Account, u.TenantId, u.Status }).FirstAsync();
- node.CrossTenantRefs.Add(any == null
- ? $"{t}(账号不存在)"
- : $"{any.Account}({(any.TenantId == tenantId ? "已停用" : "属于其它租户")})");
- }
- }
- private async Task ResolveRolesAsync(long tenantId, string ids, S8ReviewApproverNodeDto node)
- {
- var tokens = S8RoleResolver.SplitTokens(ids);
- var userIds = await _roleResolver.ResolveUserIdsAsync(tenantId, tokens);
- var users = await _userScope.ValidateUsersAsync(tenantId, userIds);
- node.ResolvedUsers.AddRange(users.Select(u => $"{u.DisplayName} ({u.Account})"));
- if (node.ResolvedUsers.Count > 0) return;
- // 解析为 0 人时要指出**为什么**:token 是本租户不存在的角色,还是角色没有成员。
- foreach (var t in tokens)
- {
- var byCode = await _flowRep.Context.Queryable<SysRole>().ClearFilter()
- .Where(r => r.Code == t && r.TenantId == tenantId).AnyAsync();
- if (byCode) continue;
- if (long.TryParse(t, out var rid))
- {
- var role = await _flowRep.Context.Queryable<SysRole>().ClearFilter()
- .Where(r => r.Id == rid).Select(r => new { r.Code, r.TenantId }).FirstAsync();
- node.CrossTenantRefs.Add(role == null
- ? $"{t}(角色不存在)"
- : $"{role.Code}(属于其它租户)");
- }
- else
- {
- node.CrossTenantRefs.Add($"{t}(本租户无此角色)");
- }
- }
- }
- /// <summary>
- /// 从流程定义 JSON 里抽出审批节点。
- /// <para>结构由 ApprovalFlow 插件定义,本方法只做<b>宽松读取</b>:认不出的结构一律跳过,
- /// 绝不猜测。读不出来时上层会显示"无法确定审批人来源",而不是"没有审批人"。</para>
- /// </summary>
- internal static List<(string NodeName, int Type, string Ids)> ParseApproverNodes(string? flowJson)
- {
- var result = new List<(string, int, string)>();
- if (string.IsNullOrWhiteSpace(flowJson)) return result;
- using var doc = JsonDocument.Parse(flowJson);
- Walk(doc.RootElement);
- return result;
- void Walk(JsonElement el)
- {
- switch (el.ValueKind)
- {
- case JsonValueKind.Object:
- if (el.TryGetProperty("approverType", out var at))
- {
- var name = el.TryGetProperty("nodeName", out var nn) && nn.ValueKind == JsonValueKind.String
- ? nn.GetString() ?? "审批节点"
- : "审批节点";
- var type = at.ValueKind switch
- {
- JsonValueKind.Number when at.TryGetInt32(out var n) => n,
- JsonValueKind.String when Enum.TryParse<ApproverTypeEnum>(at.GetString(), true, out var e) => (int)e,
- _ => -1
- };
- var ids = el.TryGetProperty("approverIds", out var ai) && ai.ValueKind == JsonValueKind.String
- ? ai.GetString() ?? string.Empty
- : string.Empty;
- if (type >= 0) result.Add((name, type, ids));
- }
- foreach (var p in el.EnumerateObject()) Walk(p.Value);
- break;
- case JsonValueKind.Array:
- foreach (var item in el.EnumerateArray()) Walk(item);
- break;
- }
- }
- }
- }
|