using System.Text.Json; using System.Text.Json.Nodes; using Admin.NET.Core; using Admin.NET.Plugin.AiDOP.Const.S8; using Admin.NET.Plugin.AiDOP.Entity.S8; using Microsoft.Extensions.Logging; namespace Admin.NET.Plugin.AiDOP.Service.S8; /// 修复结果。分项计数,运维要能看出「这次到底动了什么」。 public sealed class S8ApprovalFlowRepairResult { public int FlowsInspected { get; set; } public int NodesInspected { get; set; } /// 已经指向本租户角色、无需修改的节点。 public int AlreadyOkCount { get; set; } /// 本次改写的节点。 public int RepairedCount { get; set; } /// 该租户推导不出任何审核角色,无法修复 —— 必须人工处理。 public int BlockedCount { get; set; } /// /// 本服务此前改过、但按现在的判据本不该改的节点,已按留证还原。 /// 对账服务自己写下的东西,自己就该能撤回 —— 否则一次判据失误就变成永久损坏。 /// public int RestoredCount { get; set; } } /// /// S8-UAT-APPROVAL-REPAIR-1:修复 S8 两条审批流的审批人配置,让提交复核之后 /// 真的有一个本租户账号接得住。 /// /// 本地取证到的失败形态(这不是推测):UAT 租户 3 条 /// PENDING_VERIFICATION 异常的 verifier_user_id 都是 UATQualityA, /// 而对应的 EXCEPTION_CLOSURE 实例 Status=Running、tasks=0、 /// 异常上的 active_flow_instance_id 还是 NULL。原因是节点配的 /// approverType=Role, approverIds=1300000000101,1300000000888别租户的角色 Id, /// ResolveApprovers 末尾的 u.TenantId == effectiveTenantId 把成员全过滤掉 → /// 返回 0 人 → ProcessNextNode 抛「审批人列表为空」→ 实例已落库但 /// OnFlowStarted 从未被调用 → 流程永久悬挂,而 S8 侧一切显示正常。 /// /// 为什么改成 RoleCode 而不是 SpecificUser / 数字 RoleId(以代码为证): /// ResolveApprovers 的三个分支租户安全性并不一致 —— /// /// Role + 非数字 token:按 SysRole.Code 查且带 /// r.TenantId == effectiveTenantId天然租户安全 /// Role + 数字 token:直接当 RoleId 用,不校验角色租户 /// (只靠末尾的用户租户过滤兜底,结果是解析为空而非报错); /// SpecificUser / Department完全没有显式租户谓词, /// 行为取决于全局 multi-tenant AOP 的当前状态。 /// /// 因此只有 RoleCode 形式能在不改 FlowEngine 的前提下保证租户正确。 /// /// 刻意不改 FlowEngine:给 SpecificUser / Department 补租户谓词 /// 会外溢到 S1/S5/S6/S7 —— 真库实测 74 个 SpecificUser 节点里有大量跨租户配置 /// (CONTRACT_REVIEW / ORDER_CHANGE_REVIEW / MATERIAL_SHORTAGE /// 在三个租户里都指向租户 797 的 AIDopAdmin),一改就会把那些流程一起打空。 /// 那是另一个批次的事,本批只修 S8 自己的两条链,并把该缺口如实登记为跨模块风险。 /// /// 只修坏的,不碰好的:判据是「该节点在本租户解析不出任何审批人, /// 或引用了非本租户对象」。已经指向本租户角色的节点一律跳过 —— /// 每次启动无条件回写会覆盖管理员的手工配置,那是比配置错误更难发现的故障。 /// 判据本身也保证了幂等:修好之后第二次运行就会命中「已 OK」而跳过。 /// public class S8ApprovalFlowRepairService : ITransient { /// 复核流程。与 S8TaskFlowService.TryStartVerificationFlowAsync 一致。 internal const string ClosureBizType = "EXCEPTION_CLOSURE"; /// 升级流程。与 S8TaskFlowService.UpgradeAsync 一致。 internal const string EscalationBizType = "EXCEPTION_ESCALATION"; private readonly SqlSugarRepository _flowRep; private readonly SqlSugarRepository _logRep; private readonly ILogger _logger; public S8ApprovalFlowRepairService( SqlSugarRepository flowRep, SqlSugarRepository logRep, ILogger logger) { _flowRep = flowRep; _logRep = logRep; _logger = logger; } public async Task RepairAsync(CancellationToken ct = default) { var result = new S8ApprovalFlowRepairResult(); var flows = await _flowRep.AsQueryable().ClearFilter() .Where(f => (f.BizType == ClosureBizType || f.BizType == EscalationBizType) && !f.IsDelete && f.TenantId != null) .ToListAsync(ct); result.FlowsInspected = flows.Count; if (flows.Count == 0) return result; var tenantIds = flows.Select(f => f.TenantId!.Value).Distinct().ToList(); var verifyRoles = await RoleCodesByPermissionAsync(tenantIds, S8PermissionCatalog.VerificationApprove, ct); var upgradeRoles = await RoleCodesByPermissionAsync(tenantIds, S8PermissionCatalog.ExceptionUpgrade, ct); // 先读上一轮留证:本服务写下的改动,必须能按现在的判据重新审视并撤回。 // 判据本身也会出错(第一版把「SpecificUser 指向本租户账号」误判为不安全, // 改坏了默认租户一条本来正确的配置),没有回退能力就等于一次失误永久生效。 var priorLogs = (await _logRep.AsQueryable().ClearFilter() .Where(x => x.Outcome == "REPAIRED") .ToListAsync(ct)) .GroupBy(x => (x.FlowId, x.NodeName)) .ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.Id).First()); var logs = new List(); var now = DateTime.Now; foreach (var flow in flows) { ct.ThrowIfCancellationRequested(); var tenantId = flow.TenantId!.Value; // 升级流程优先用该租户自己的「升级」角色;没有就回落「审核」角色。 // 回落必须在留证里写清楚 —— 否则将来没人知道升级审批为什么是质检在审。 var isEscalation = flow.BizType == EscalationBizType; var wanted = isEscalation ? (upgradeRoles.GetValueOrDefault(tenantId) ?? verifyRoles.GetValueOrDefault(tenantId)) : verifyRoles.GetValueOrDefault(tenantId); var fellBack = isEscalation && (upgradeRoles.GetValueOrDefault(tenantId)?.Count ?? 0) == 0 && (wanted?.Count ?? 0) > 0; var tenantScope = await TenantScopeAsync(tenantId, ct); JsonNode? root; try { root = JsonNode.Parse(flow.FlowJson ?? string.Empty); } catch (Exception ex) { _logger.LogWarning(ex, "s8_flow_repair_parse_failed flowId={FlowId} bizType={BizType}", flow.Id, flow.BizType); continue; } if (root?["nodes"] is not JsonArray nodes) continue; var changed = false; foreach (var node in nodes.OfType()) { if (node["properties"] is not JsonObject props) continue; if (props["approverType"] is null) continue; result.NodesInspected++; var nodeName = props["nodeName"]?.GetValue() ?? "审批节点"; var beforeType = props["approverType"]?.ToString(); var beforeIds = props["approverIds"]?.ToString(); // ── 自我纠错:本服务写下的改动,按现在的判据重新审视一次 ── if (priorLogs.TryGetValue((flow.Id, nodeName), out var prior) && string.Equals(prior.AfterType, beforeType, StringComparison.Ordinal) && string.Equals(prior.AfterIds, beforeIds, StringComparison.Ordinal) && IsAlreadyTenantSafe(prior.BeforeType, prior.BeforeIds, tenantScope)) { props["approverType"] = prior.BeforeType; props["approverIds"] = prior.BeforeIds; props["approverNames"] = prior.BeforeIds; changed = true; result.RestoredCount++; logs.Add(Log(tenantId, flow, nodeName, beforeType, beforeIds, prior.BeforeType, prior.BeforeIds, "上一轮判据把「指向本租户账号的 SpecificUser」误判为不安全;按留证还原原配置", "RESTORED", now)); _logger.LogWarning( "s8_flow_repair_restored tenant={Tenant} bizType={BizType} node={Node};" + "还原为 {Type}:{Ids}", tenantId, flow.BizType, nodeName, prior.BeforeType, prior.BeforeIds); continue; } // 引用的对象全部属于本租户 → 不碰。 if (IsAlreadyTenantSafe(beforeType, beforeIds, tenantScope)) { result.AlreadyOkCount++; logs.Add(Log(tenantId, flow, nodeName, beforeType, beforeIds, beforeType, beforeIds, "所引用的对象全部属于本租户,未修改", "SKIPPED_ALREADY_OK", now)); continue; } if (wanted == null || wanted.Count == 0) { // 推导不出角色就不猜:宁可留着坏配置并大声报出来, // 也不要塞一个"看起来能用"的角色进去 —— 后者会让人以为审核链已经通了。 result.BlockedCount++; logs.Add(Log(tenantId, flow, nodeName, beforeType, beforeIds, beforeType, beforeIds, $"本租户没有任何角色被授予 {(isEscalation ? S8PermissionCatalog.ExceptionUpgrade : S8PermissionCatalog.VerificationApprove)},无法派生审批人,保持原样", "BLOCKED_NO_ROLE", now)); _logger.LogWarning( "s8_flow_repair_blocked tenant={Tenant} bizType={BizType} node={Node};" + "该租户推导不出审核角色,审批人仍为空,需人工配置", tenantId, flow.BizType, nodeName); continue; } var afterIds = string.Join(',', wanted); props["approverType"] = nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.Role); props["approverIds"] = afterIds; // approverNames 只是设计器的显示文案;同步成角色编码,避免页面继续显示 // 「系统管理员」这种早已不成立的名字。 props["approverNames"] = afterIds; changed = true; result.RepairedCount++; var reason = $"原配置在本租户解析不出任何审批人({beforeType}:{beforeIds});" + $"改为本租户 RoleCode(ResolveApprovers 的 code 分支带 SysRole.TenantId 校验)" + (fellBack ? ";本租户无专门的升级审批角色,回落使用审核角色" : string.Empty); logs.Add(Log(tenantId, flow, nodeName, beforeType, beforeIds, nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.Role), afterIds, reason, "REPAIRED", now)); } if (!changed) continue; await _flowRep.Context.Updateable() .SetColumns(f => new Admin.NET.Plugin.ApprovalFlow.ApprovalFlow { FlowJson = root.ToJsonString() }) .Where(f => f.Id == flow.Id) .ExecuteCommandAsync(ct); } if (logs.Count > 0) { // 留证表每次全量重写:它描述的是「最近一次对账看到了什么」, // 累积历史只会让人分不清哪一行是当前事实。 await _logRep.AsDeleteable().Where(x => true).ExecuteCommandAsync(); await _logRep.AsInsertable(logs).ExecuteCommandAsync(); } _logger.LogInformation( "s8_flow_repair_done flows={Flows} nodes={Nodes} repaired={Repaired} restored={Restored} alreadyOk={Ok} blocked={Blocked}", result.FlowsInspected, result.NodesInspected, result.RepairedCount, result.RestoredCount, result.AlreadyOkCount, result.BlockedCount); return result; } // ──────────────────────────────────────────────────────────────── /// 某个租户里可被审批人配置引用的对象集合。 internal sealed record S8TenantApproverScope( IReadOnlySet RoleCodes, IReadOnlySet RoleIds, IReadOnlySet UserIds, IReadOnlySet OrgIds); /// /// 「已经租户安全」的判据:该节点引用的每一个对象都属于本租户。 /// /// 判据是"引用对象的归属",不是"用了哪种 approverType"。 /// 第一版把所有 SpecificUser 一律判为不安全,结果把默认租户一条本来正确 /// 的配置(SpecificUser=789819678195781,该账号正属于默认租户)也改写了 —— /// 那是把"这种写法在别的租户下不安全"错当成"这个配置错了"。 /// 修复服务只该动真的指向别人的配置。 /// /// Initiator 由运行时按发起人解析,天然不跨租户,一律视为安全。 /// internal static bool IsAlreadyTenantSafe(string? approverType, string? approverIds, S8TenantApproverScope scope) { if (string.IsNullOrWhiteSpace(approverType)) return false; if (string.Equals(approverType, nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.Initiator), StringComparison.Ordinal)) return true; if (string.IsNullOrWhiteSpace(approverIds)) return false; var tokens = approverIds.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(t => t.Trim()).Where(t => t.Length > 0).ToList(); if (tokens.Count == 0) return false; if (string.Equals(approverType, nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.Role), StringComparison.Ordinal)) return tokens.All(t => long.TryParse(t, out var id) ? scope.RoleIds.Contains(id) : scope.RoleCodes.Contains(t)); if (string.Equals(approverType, nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.SpecificUser), StringComparison.Ordinal)) return tokens.All(t => long.TryParse(t, out var id) && scope.UserIds.Contains(id)); if (string.Equals(approverType, nameof(Admin.NET.Plugin.ApprovalFlow.ApproverTypeEnum.Department), StringComparison.Ordinal)) return tokens.All(t => long.TryParse(t, out var id) && scope.OrgIds.Contains(id)); // 认不出的类型不敢断言安全,但也不改 —— 交给下面的 BLOCKED 分支大声报出来。 return false; } /// 某能力码在各租户下由哪些本租户角色持有(返回 RoleCode)。 private async Task>> RoleCodesByPermissionAsync( List tenantIds, string permission, CancellationToken ct) { var rows = await _flowRep.Context.Queryable().ClearFilter() .InnerJoin((r, rm) => r.Id == rm.RoleId) .InnerJoin((r, rm, m) => rm.MenuId == m.Id) .Where((r, rm, m) => r.TenantId != null && tenantIds.Contains(r.TenantId.Value) && r.Status == StatusEnum.Enable && m.Type == MenuTypeEnum.Btn && m.Permission == permission && r.Code != null && r.Code != "") .Select((r, rm, m) => new { TenantId = r.TenantId!.Value, r.Code }) .ToListAsync(ct); return rows .GroupBy(x => x.TenantId) .ToDictionary(g => g.Key, g => g.Select(x => x.Code!).Distinct().OrderBy(x => x, StringComparer.Ordinal).ToList()); } private async Task TenantScopeAsync(long tenantId, CancellationToken ct) { var roles = await _flowRep.Context.Queryable().ClearFilter() .Where(r => r.TenantId == tenantId) .Select(r => new { r.Id, r.Code }) .ToListAsync(ct); var userIds = await _flowRep.Context.Queryable().ClearFilter() .Where(u => u.TenantId == tenantId) .Select(u => u.Id) .ToListAsync(ct); var orgIds = await _flowRep.Context.Queryable().ClearFilter() .Where(o => o.TenantId == tenantId) .Select(o => o.Id) .ToListAsync(ct); return new S8TenantApproverScope( roles.Where(r => !string.IsNullOrWhiteSpace(r.Code)).Select(r => r.Code!).ToHashSet(StringComparer.Ordinal), roles.Select(r => r.Id).ToHashSet(), userIds.ToHashSet(), orgIds.ToHashSet()); } private static AdoS8ApprovalFlowRepairLog Log( long tenantId, Admin.NET.Plugin.ApprovalFlow.ApprovalFlow flow, string nodeName, string? beforeType, string? beforeIds, string? afterType, string? afterIds, string reason, string outcome, DateTime now) => new() { TenantId = tenantId, FlowId = flow.Id, BizType = flow.BizType ?? string.Empty, NodeName = nodeName, BeforeType = beforeType, BeforeIds = beforeIds, AfterType = afterType, AfterIds = afterIds, Reason = reason, Outcome = outcome, RepairedAt = now }; }