| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349 |
- 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;
- /// <summary>修复结果。分项计数,运维要能看出「这次到底动了什么」。</summary>
- public sealed class S8ApprovalFlowRepairResult
- {
- public int FlowsInspected { get; set; }
- public int NodesInspected { get; set; }
- /// <summary>已经指向本租户角色、无需修改的节点。</summary>
- public int AlreadyOkCount { get; set; }
- /// <summary>本次改写的节点。</summary>
- public int RepairedCount { get; set; }
- /// <summary>该租户推导不出任何审核角色,无法修复 —— 必须人工处理。</summary>
- public int BlockedCount { get; set; }
- /// <summary>
- /// 本服务此前改过、但按<b>现在的判据</b>本不该改的节点,已按留证还原。
- /// <para>对账服务自己写下的东西,自己就该能撤回 —— 否则一次判据失误就变成永久损坏。</para>
- /// </summary>
- public int RestoredCount { get; set; }
- }
- /// <summary>
- /// S8-UAT-APPROVAL-REPAIR-1:修复 S8 两条审批流的审批人配置,让提交复核之后
- /// <b>真的有一个本租户账号接得住</b>。
- ///
- /// <para><b>本地取证到的失败形态</b>(这不是推测):UAT 租户 3 条
- /// <c>PENDING_VERIFICATION</c> 异常的 <c>verifier_user_id</c> 都是 UATQualityA,
- /// 而对应的 <c>EXCEPTION_CLOSURE</c> 实例 <b>Status=Running、tasks=0</b>、
- /// 异常上的 <c>active_flow_instance_id</c> 还是 NULL。原因是节点配的
- /// <c>approverType=Role, approverIds=1300000000101,1300000000888</c> 是<b>别租户的角色 Id</b>,
- /// <c>ResolveApprovers</c> 末尾的 <c>u.TenantId == effectiveTenantId</c> 把成员全过滤掉 →
- /// 返回 0 人 → <c>ProcessNextNode</c> 抛「审批人列表为空」→ 实例已落库但
- /// <c>OnFlowStarted</c> 从未被调用 → 流程永久悬挂,而 S8 侧一切显示正常。</para>
- ///
- /// <para><b>为什么改成 RoleCode 而不是 SpecificUser / 数字 RoleId</b>(以代码为证):
- /// <c>ResolveApprovers</c> 的三个分支租户安全性并不一致 ——
- /// <list type="bullet">
- /// <item><c>Role</c> + <b>非数字 token</b>:按 <c>SysRole.Code</c> 查且带
- /// <c>r.TenantId == effectiveTenantId</c>,<b>天然租户安全</b>;</item>
- /// <item><c>Role</c> + 数字 token:直接当 RoleId 用,<b>不校验角色租户</b>
- /// (只靠末尾的用户租户过滤兜底,结果是解析为空而非报错);</item>
- /// <item><c>SpecificUser</c> / <c>Department</c>:<b>完全没有显式租户谓词</b>,
- /// 行为取决于全局 multi-tenant AOP 的当前状态。</item>
- /// </list>
- /// 因此只有 RoleCode 形式能在不改 FlowEngine 的前提下保证租户正确。</para>
- ///
- /// <para><b>刻意不改 FlowEngine</b>:给 <c>SpecificUser</c> / <c>Department</c> 补租户谓词
- /// 会外溢到 S1/S5/S6/S7 —— 真库实测 74 个 SpecificUser 节点里有大量跨租户配置
- /// (<c>CONTRACT_REVIEW</c> / <c>ORDER_CHANGE_REVIEW</c> / <c>MATERIAL_SHORTAGE</c>
- /// 在三个租户里都指向租户 797 的 AIDopAdmin),一改就会把那些流程一起打空。
- /// 那是另一个批次的事,本批只修 S8 自己的两条链,并把该缺口如实登记为跨模块风险。</para>
- ///
- /// <para><b>只修坏的,不碰好的</b>:判据是「该节点在本租户解析不出任何审批人,
- /// 或引用了非本租户对象」。已经指向本租户角色的节点一律跳过 ——
- /// 每次启动无条件回写会覆盖管理员的手工配置,那是比配置错误更难发现的故障。
- /// 判据本身也保证了幂等:修好之后第二次运行就会命中「已 OK」而跳过。</para>
- /// </summary>
- public class S8ApprovalFlowRepairService : ITransient
- {
- /// <summary>复核流程。与 <c>S8TaskFlowService.TryStartVerificationFlowAsync</c> 一致。</summary>
- internal const string ClosureBizType = "EXCEPTION_CLOSURE";
- /// <summary>升级流程。与 <c>S8TaskFlowService.UpgradeAsync</c> 一致。</summary>
- internal const string EscalationBizType = "EXCEPTION_ESCALATION";
- private readonly SqlSugarRepository<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow> _flowRep;
- private readonly SqlSugarRepository<AdoS8ApprovalFlowRepairLog> _logRep;
- private readonly ILogger<S8ApprovalFlowRepairService> _logger;
- public S8ApprovalFlowRepairService(
- SqlSugarRepository<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow> flowRep,
- SqlSugarRepository<AdoS8ApprovalFlowRepairLog> logRep,
- ILogger<S8ApprovalFlowRepairService> logger)
- {
- _flowRep = flowRep;
- _logRep = logRep;
- _logger = logger;
- }
- public async Task<S8ApprovalFlowRepairResult> 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<AdoS8ApprovalFlowRepairLog>();
- 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<JsonObject>())
- {
- if (node["properties"] is not JsonObject props) continue;
- if (props["approverType"] is null) continue;
- result.NodesInspected++;
- var nodeName = props["nodeName"]?.GetValue<string>() ?? "审批节点";
- 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)
- {
- // 推导不出角色就<b>不猜</b>:宁可留着坏配置并大声报出来,
- // 也不要塞一个"看起来能用"的角色进去 —— 后者会让人以为审核链已经通了。
- 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<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow>()
- .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;
- }
- // ────────────────────────────────────────────────────────────────
- /// <summary>某个租户里可被审批人配置引用的对象集合。</summary>
- internal sealed record S8TenantApproverScope(
- IReadOnlySet<string> RoleCodes,
- IReadOnlySet<long> RoleIds,
- IReadOnlySet<long> UserIds,
- IReadOnlySet<long> OrgIds);
- /// <summary>
- /// 「已经租户安全」的判据:<b>该节点引用的每一个对象都属于本租户</b>。
- ///
- /// <para><b>判据是"引用对象的归属",不是"用了哪种 approverType"。</b>
- /// 第一版把所有 <c>SpecificUser</c> 一律判为不安全,结果把默认租户一条<b>本来正确</b>
- /// 的配置(<c>SpecificUser=789819678195781</c>,该账号正属于默认租户)也改写了 ——
- /// 那是把"这种写法在别的租户下不安全"错当成"这个配置错了"。
- /// 修复服务只该动<b>真的指向别人</b>的配置。</para>
- ///
- /// <para><c>Initiator</c> 由运行时按发起人解析,天然不跨租户,一律视为安全。</para>
- /// </summary>
- 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;
- }
- /// <summary>某能力码在各租户下由哪些<b>本租户</b>角色持有(返回 RoleCode)。</summary>
- private async Task<Dictionary<long, List<string>>> RoleCodesByPermissionAsync(
- List<long> tenantIds, string permission, CancellationToken ct)
- {
- var rows = await _flowRep.Context.Queryable<SysRole>().ClearFilter()
- .InnerJoin<SysRoleMenu>((r, rm) => r.Id == rm.RoleId)
- .InnerJoin<SysMenu>((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<S8TenantApproverScope> TenantScopeAsync(long tenantId, CancellationToken ct)
- {
- var roles = await _flowRep.Context.Queryable<SysRole>().ClearFilter()
- .Where(r => r.TenantId == tenantId)
- .Select(r => new { r.Id, r.Code })
- .ToListAsync(ct);
- var userIds = await _flowRep.Context.Queryable<SysUser>().ClearFilter()
- .Where(u => u.TenantId == tenantId)
- .Select(u => u.Id)
- .ToListAsync(ct);
- var orgIds = await _flowRep.Context.Queryable<SysOrg>().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
- };
- }
|