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
};
}