Преглед изворни кода

feat(s8): auto escalate overdue exceptions

YY968XX пре 3 месеци
родитељ
комит
02a73290fc

+ 43 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S8TimeoutAutoEscalationJob.cs

@@ -0,0 +1,43 @@
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Furion.Schedule;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Job;
+
+/// <summary>
+/// S8-TIMEOUT-AUTO-ESCALATION-JOB-1(P4-1):每 5 分钟扫描 sla_deadline 已超时的异常并触发 ApprovalFlow 升级。
+/// 实现位于 <see cref="S8TimeoutAutoEscalationService.RunOnceAsync"/>,便于管理员接口或测试复用。
+/// 与 <see cref="ApprovalFlow.FlowTimeoutJob"/> 同模式(IServiceScopeFactory + Furion Schedule)。
+/// </summary>
+[JobDetail("job_s8_timeout_auto_escalation",
+    Description = "S8 超时自动升级(扫描 sla_deadline 已过期的异常并启动 EXCEPTION_ESCALATION)",
+    GroupName = "default", Concurrent = false)]
+[Period(300000, TriggerId = "trigger_s8_timeout_auto_escalation", Description = "每5分钟执行")]
+public class S8TimeoutAutoEscalationJob : IJob
+{
+    private readonly IServiceScopeFactory _scopeFactory;
+    private readonly ILogger _logger;
+
+    public S8TimeoutAutoEscalationJob(IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
+    {
+        _scopeFactory = scopeFactory;
+        _logger = loggerFactory.CreateLogger("S8TimeoutAutoEscalationJob");
+    }
+
+    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
+    {
+        using var scope = _scopeFactory.CreateScope();
+        var svc = scope.ServiceProvider.GetRequiredService<S8TimeoutAutoEscalationService>();
+        try
+        {
+            var processed = await svc.RunOnceAsync(50, stoppingToken);
+            if (processed > 0)
+                _logger.LogInformation("S8TimeoutAutoEscalationJob 本轮升级了 {Count} 个超时异常", processed);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "S8TimeoutAutoEscalationJob 执行失败");
+        }
+    }
+}

+ 143 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TimeoutAutoEscalationService.cs

@@ -0,0 +1,143 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// S8-TIMEOUT-AUTO-ESCALATION-JOB-1(P4-1):扫描 sla_deadline 已超时且未关闭/未已升级的异常,
+/// 通过 <see cref="S8TaskFlowService.UpgradeAsync"/> 启动 EXCEPTION_ESCALATION ApprovalFlow,与人工升级链路 100% 等价。
+///
+/// 设计要点:
+/// - 不依赖 timeout_flag;扫描公式与读端 IsCurrentlyTimeout 一致:sla_deadline IS NOT NULL AND sla_deadline &lt; now
+///   AND status NOT IN ('CLOSED','RECOVERED','ESCALATED')。
+/// - status 进一步限制在 ASSIGNED / IN_PROGRESS(与 <see cref="Infrastructure.S8.S8StatusRules"/> 允许 ESCALATED 的转移一致);
+///   NEW / PENDING_VERIFICATION / REJECTED 等状态不通过本 Job 自动升级。
+/// - 已有 active_flow_instance_id 的异常跳过(防重);UpgradeAsync 内部还会再校验一次,双层保险。
+/// - exception_type.escalate_role_code 为空 / 非法时跳过并 LogInformation;不写脏数据,不补默认。
+/// - 状态 / Timeline 由 <see cref="ExceptionEscalationBizHandler"/>.OnFlowStarted 写入;本服务不重复维护。
+/// - 通知层走 <see cref="S8NotificationLayerResolver"/>;当前 baseline notify_channel="log",无外部副作用。
+/// </summary>
+public class S8TimeoutAutoEscalationService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8Exception> _rep;
+    private readonly SqlSugarRepository<AdoS8ExceptionType> _typeRep;
+    private readonly S8TaskFlowService _taskFlow;
+    private readonly S8NotificationLayerResolver _layerResolver;
+    private readonly ILogger<S8TimeoutAutoEscalationService> _logger;
+
+    // S8StatusRules 中 ESCALATED 仅允许从 ASSIGNED / IN_PROGRESS 转入。
+    private static readonly string[] EscalatableStatuses = { "ASSIGNED", "IN_PROGRESS" };
+
+    public S8TimeoutAutoEscalationService(
+        SqlSugarRepository<AdoS8Exception> rep,
+        SqlSugarRepository<AdoS8ExceptionType> typeRep,
+        S8TaskFlowService taskFlow,
+        S8NotificationLayerResolver layerResolver,
+        ILogger<S8TimeoutAutoEscalationService> logger)
+    {
+        _rep = rep;
+        _typeRep = typeRep;
+        _taskFlow = taskFlow;
+        _layerResolver = layerResolver;
+        _logger = logger;
+    }
+
+    /// <summary>
+    /// 扫描一次。返回成功触发升级的异常数量;调用方负责调度 / 限流。
+    /// </summary>
+    public async Task<int> RunOnceAsync(int batchSize = 50, CancellationToken ct = default)
+    {
+        var now = DateTime.Now;
+        var candidates = await _rep.AsQueryable()
+            .Where(x => !x.IsDeleted
+                && x.SlaDeadline != null
+                && x.SlaDeadline < now
+                && EscalatableStatuses.Contains(x.Status)
+                && (x.ActiveFlowInstanceId == null || x.ActiveFlowInstanceId == 0))
+            .OrderBy(x => x.SlaDeadline)
+            .Take(batchSize)
+            .ToListAsync();
+
+        if (candidates.Count == 0) return 0;
+
+        // exception_type.escalate_role_code 批量映射(factory 优先以与现有 ResolveModuleCodeAsync 一致)
+        var typeCodes = candidates
+            .Select(c => c.ExceptionTypeCode)
+            .Where(c => !string.IsNullOrWhiteSpace(c))
+            .Distinct()
+            .Select(c => c!)
+            .ToList();
+        var typeMap = typeCodes.Count == 0
+            ? new Dictionary<string, AdoS8ExceptionType>()
+            : (await _typeRep.AsQueryable().ClearFilter()
+                .Where(t => typeCodes.Contains(t.TypeCode))
+                .OrderByDescending(t => t.FactoryId)
+                .ToListAsync())
+                .GroupBy(t => t.TypeCode)
+                .ToDictionary(g => g.Key, g => g.First());
+
+        var processed = 0;
+        foreach (var e in candidates)
+        {
+            ct.ThrowIfCancellationRequested();
+            if (string.IsNullOrWhiteSpace(e.ExceptionTypeCode)
+                || !typeMap.TryGetValue(e.ExceptionTypeCode, out var type)
+                || string.IsNullOrWhiteSpace(type.EscalateRoleCode))
+            {
+                _logger.LogInformation(
+                    "s8_timeout_auto_escalate_skip exceptionId={Id} exceptionCode={Code} reason=escalate_role_empty typeCode={TypeCode}",
+                    e.Id, e.ExceptionCode, e.ExceptionTypeCode);
+                continue;
+            }
+
+            try
+            {
+                // UpgradeAsync 已内置 ActiveFlowInstanceId / IsAllowedTransition 二次校验;
+                // 与 manual upgrade 100% 等价,状态/timeline 由 OnFlowStarted 写入。
+                var remark = $"[AUTO] SLA deadline exceeded; auto escalation triggered. sla_deadline={e.SlaDeadline:yyyy-MM-dd HH:mm:ss}; escalate_role_code={type.EscalateRoleCode}";
+                await _taskFlow.UpgradeAsync(e.Id, e.TenantId, e.FactoryId, remark);
+                processed++;
+                _logger.LogInformation(
+                    "s8_timeout_auto_escalate_started exceptionId={Id} exceptionCode={Code} typeCode={TypeCode} escalateRoleCode={Role}",
+                    e.Id, e.ExceptionCode, e.ExceptionTypeCode, type.EscalateRoleCode);
+
+                await TryDispatchAsync(e);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogWarning(ex,
+                    "s8_timeout_auto_escalate_failed exceptionId={Id} status={Status}", e.Id, e.Status);
+            }
+        }
+
+        if (processed > 0 || candidates.Count > 0)
+            _logger.LogInformation(
+                "s8_timeout_auto_escalate_summary processed={Processed} candidates={Total}", processed, candidates.Count);
+        return processed;
+    }
+
+    private async Task TryDispatchAsync(AdoS8Exception e)
+    {
+        try
+        {
+            await _layerResolver.DispatchByLayerAsync(new S8NotificationLayerResolver.DispatchByLayerInput
+            {
+                TenantId = e.TenantId,
+                FactoryId = e.FactoryId,
+                ExceptionId = e.Id,
+                ExceptionNo = e.ExceptionCode,
+                // 优先 module_code(S1-S7 严格基线),保持与 NotificationLayer baseline 同口径。
+                SceneCode = string.IsNullOrWhiteSpace(e.ModuleCode) ? e.SceneCode : e.ModuleCode!,
+                Severity = e.Severity,
+                Status = "ESCALATED",
+                Title = $"[AUTO] 异常升级 - {e.ExceptionCode}",
+                Content = "SLA 已超时,系统自动触发升级。",
+                SourceRuleCode = e.SourceRuleCode,
+            });
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex, "s8_timeout_auto_escalate_dispatch_failed exceptionId={Id}", e.Id);
+        }
+    }
+}