|
|
@@ -0,0 +1,349 @@
|
|
|
+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
|
|
|
+ };
|
|
|
+}
|