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; /// 审批流节点上配置的一个审批人来源(原样呈现,不做任何"修正")。 public sealed class S8ReviewApproverNodeDto { public string NodeName { get; set; } = string.Empty; /// 原始 approverType(SpecificUser / Role / Department / Initiator / DepartmentLeader)。 public string ApproverType { get; set; } = string.Empty; /// 原始 approverIds,逐字回显。 public string ApproverIds { get; set; } = string.Empty; /// 当前租户下实际能解析出的账号(展示名)。 public List ResolvedUsers { get; set; } = new(); /// 解析结果为 0 人 —— 该节点在本租户下没有任何人可审批。 public bool ResolvesToNobody => ResolvedUsers.Count == 0; /// 配置里引用了不属于本租户的账号 / 角色。 public List CrossTenantRefs { get; set; } = new(); } /// 一个审核环节的真实来源。 public sealed class S8ReviewStageDto { /// 环节名(复核 / 升级)。 public string Stage { get; set; } = string.Empty; /// 这一环的审核人实际由什么决定——用业务语言,不用内部术语。 public string DecidedBy { get; set; } = string.Empty; /// 关联的审批流 BizType(没有则为空)。 public string? FlowBizType { get; set; } public string? FlowName { get; set; } public List Nodes { get; set; } = new(); /// 需要管理员注意的问题(跨租户引用 / 解析为 0 人 / 未配置流程)。 public List Warnings { get; set; } = new(); } /// /// S8-RULE-4TAB-1:只读解析「这条规则的异常,审核时到底由谁审」。 /// /// 本服务不改变任何审批语义:不写库、不改 ApproverType、不动流程节点、 /// 不新增 reviewer 配置。它唯一的职责是把当前事实如实呈现给管理员 —— /// 包括那些不好看的事实。 /// /// 为什么必须做这件事:规则配置页此前把审核人显示为「主管」, /// 而系统里根本不存在组织主管关系(SysOrg.DirectorId 与 /// SysUser.ManagerUserId 实测填充率均为 0)。管理员据此以为审核链是通的, /// 实际上:UAT 租户的复核流程 EXCEPTION_CLOSURE 配的是 /// approverType=Role, approverIds=1300000000101,1300000000888, /// 而这两个角色属于别的租户,在本租户下解析为 0 人; /// 升级流程 EXCEPTION_ESCALATION 三个节点全部指向 /// SpecificUser=789819678195781(Demo02,默认租户)。 /// 这些都不是本批要改的东西 —— 但管理员必须看得见。 /// public class S8ReviewSourceService : ITransient { private readonly SqlSugarRepository _flowRep; private readonly SqlSugarRepository _typeRep; private readonly IS8UserScopeValidator _userScope; private readonly S8RoleResolver _roleResolver; private readonly ILogger _logger; /// 复检流程的 BizType(与 S8TaskFlowService.TryStartVerificationFlowAsync 一致)。 private const string ClosureBizType = "EXCEPTION_CLOSURE"; /// 升级流程的 BizType(与 S8TaskFlowService.UpgradeAsync 一致)。 private const string EscalationBizType = "EXCEPTION_ESCALATION"; public S8ReviewSourceService( SqlSugarRepository flowRep, SqlSugarRepository typeRep, IS8UserScopeValidator userScope, S8RoleResolver roleResolver, ILogger logger) { _flowRep = flowRep; _typeRep = typeRep; _userScope = userScope; _roleResolver = roleResolver; _logger = logger; } /// 解析该租户下两个审核环节的真实来源。 public async Task> DescribeAsync(long tenantId) { var stages = new List(); // ── 复核 ── // 真正决定审核人的是 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; } /// /// 附带说明:异常类型上的 escalate_role_code。 /// 它是超时自动升级时用的角色,与上面的流程节点是两套东西; /// 真库实测 owner 与 escalate 角色几乎全部相同(8 组完全一致), /// 也就是"升级"实际上升给了同一批人。这同样只呈现、不修改。 /// public async Task> 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 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; } // 跨租户 / 已停用 —— 这里刻意查出真名并标注,因为这是给管理员排障用的 // 只读视图,不是对外接口;不说清楚他无从判断该改哪里。 var any = await _flowRep.Context.Queryable().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().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().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}(本租户无此角色)"); } } } /// /// 从流程定义 JSON 里抽出审批节点。 /// 结构由 ApprovalFlow 插件定义,本方法只做宽松读取:认不出的结构一律跳过, /// 绝不猜测。读不出来时上层会显示"无法确定审批人来源",而不是"没有审批人"。 /// 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(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; } } } }