| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294 |
- using Admin.NET.Plugin.AiDOP.Entity.S8;
- using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
- namespace Admin.NET.Plugin.AiDOP.Service.S8;
- /// <summary>
- /// S8-DEMO-IMPACT-SORT-NOTICE-1:异常影响统计服务。
- /// 滚动 30 天窗口;归类键 = (tenant_id, factory_id, exception_type_code);
- /// 累计损失只统计已关闭异常(closed_at - created_at);未关闭不计入;
- /// 算法/阈值见 Compute 方法内常量;运行期计算,不落库。
- /// </summary>
- public class S8ImpactMetricsService : ITransient
- {
- private readonly SqlSugarRepository<AdoS8Exception> _rep;
- // 影响分权重上限(保护 score 不超过设计区间 0-140)
- private const int RepeatCap = 5;
- private const decimal LossHoursCap = 72m;
- private const decimal LossWeightCap = 30m;
- private const decimal SeverityWeightSerious = 40m;
- private const decimal SeverityWeightFollow = 10m;
- private const decimal TimeoutWeight = 20m;
- private const decimal RepeatUnitWeight = 10m;
- // 关注级别阈值
- private const int HighRepeatThreshold = 3;
- private const decimal HighLossThreshold = 24m;
- private const int MediumRepeatThreshold = 2;
- private const decimal MediumLossThreshold = 8m;
- public S8ImpactMetricsService(SqlSugarRepository<AdoS8Exception> rep)
- {
- _rep = rep;
- }
- /// <summary>
- /// 批量计算:传入候选异常子集(同一 tenant/factory),一次性按 exception_type_code 聚合 30 天窗口数据,
- /// 内存填充各行 6 个影响字段。无 N+1。candidates 为空或 typeCode 全空时不查库。
- /// </summary>
- public async Task FillBatchAsync(
- long tenantId,
- long factoryId,
- IReadOnlyList<ExceptionImpactRow> candidates)
- {
- if (candidates == null || candidates.Count == 0) return;
- var since = DateTime.Now.AddDays(-30);
- var typeCodes = candidates
- .Select(c => c.ExceptionTypeCode)
- .Where(c => !string.IsNullOrWhiteSpace(c))
- .Distinct()
- .ToList();
- var repeatMap = new Dictionary<string, int>(StringComparer.Ordinal);
- var lossMap = new Dictionary<string, decimal>(StringComparer.Ordinal);
- if (typeCodes.Count > 0)
- {
- // repeatCount30d:按 exception_type_code GROUP BY 计数全部行(含未关闭)。
- var repeatRows = await _rep.Context.Queryable<AdoS8Exception>()
- .Where(x => x.TenantId == tenantId
- && x.FactoryId == factoryId
- && !x.IsDeleted
- && x.ExceptionTypeCode != null
- && typeCodes.Contains(x.ExceptionTypeCode)
- && x.CreatedAt >= since)
- .GroupBy(x => x.ExceptionTypeCode!)
- .Select(x => new
- {
- ExceptionTypeCode = x.ExceptionTypeCode!,
- RepeatCount = SqlFunc.AggregateCount(x.Id),
- })
- .ToListAsync();
- foreach (var r in repeatRows)
- {
- repeatMap[r.ExceptionTypeCode] = r.RepeatCount;
- }
- // cumulativeLossHours30d:仅已关闭异常 (closed_at - created_at) 分钟差累计。
- var lossRows = await _rep.Context.Queryable<AdoS8Exception>()
- .Where(x => x.TenantId == tenantId
- && x.FactoryId == factoryId
- && !x.IsDeleted
- && x.ExceptionTypeCode != null
- && typeCodes.Contains(x.ExceptionTypeCode)
- && x.CreatedAt >= since
- && x.ClosedAt != null)
- .GroupBy(x => x.ExceptionTypeCode!)
- .Select(x => new
- {
- ExceptionTypeCode = x.ExceptionTypeCode!,
- LossMinutes = SqlFunc.AggregateSum(
- SqlFunc.DateDiff(DateType.Minute, x.CreatedAt, x.ClosedAt!.Value)),
- })
- .ToListAsync();
- foreach (var r in lossRows)
- {
- lossMap[r.ExceptionTypeCode] = Math.Round((decimal)r.LossMinutes / 60m, 1);
- }
- }
- foreach (var c in candidates)
- {
- int repeatCount;
- decimal lossHours;
- if (string.IsNullOrWhiteSpace(c.ExceptionTypeCode))
- {
- // exception_type_code 为空 → 视为孤例:自身计 1 次;若自身已关闭则累计自身损失
- repeatCount = 1;
- lossHours = (c.ClosedAt != null)
- ? Math.Round((decimal)(c.ClosedAt.Value - c.CreatedAt).TotalHours, 1)
- : 0m;
- }
- else
- {
- repeatCount = repeatMap.TryGetValue(c.ExceptionTypeCode, out var rc) ? rc : 1;
- lossHours = lossMap.TryGetValue(c.ExceptionTypeCode, out var lh) ? lh : 0m;
- }
- var (impactScore, level, label, reason) = Compute(c.Severity, repeatCount, lossHours, c.TimeoutFlag);
- c.RepeatCount30d = repeatCount;
- c.CumulativeLossHours30d = lossHours;
- c.ImpactScore = impactScore;
- c.SuggestedAttentionLevel = level;
- c.SuggestedAttentionLabel = label;
- c.ImpactReason = reason;
- }
- }
- /// <summary>
- /// 单异常计算:通知派发路径调用;exception_type_code / created_at / closed_at / severity / sla_deadline 从实体读取。
- /// 同样的 30 天窗口聚合,但只查询当前 typeCode 一条。
- /// </summary>
- public async Task<ExceptionImpactSnapshot> ComputeOneAsync(AdoS8Exception entity)
- {
- if (entity == null) throw new ArgumentNullException(nameof(entity));
- var since = DateTime.Now.AddDays(-30);
- var now = DateTime.Now;
- var timeoutFlag = entity.SlaDeadline != null
- && entity.SlaDeadline < now
- && entity.Status != "CLOSED"
- && entity.Status != "RECOVERED";
- int repeatCount;
- decimal lossHours;
- if (string.IsNullOrWhiteSpace(entity.ExceptionTypeCode))
- {
- repeatCount = 1;
- lossHours = (entity.ClosedAt != null)
- ? Math.Round((decimal)(entity.ClosedAt.Value - entity.CreatedAt).TotalHours, 1)
- : 0m;
- }
- else
- {
- var typeCode = entity.ExceptionTypeCode;
- // repeatCount30d:30 天窗口内同 typeCode 全量计数(含未关闭)。
- repeatCount = await _rep.Context.Queryable<AdoS8Exception>()
- .Where(x => x.TenantId == entity.TenantId
- && x.FactoryId == entity.FactoryId
- && !x.IsDeleted
- && x.ExceptionTypeCode == typeCode
- && x.CreatedAt >= since)
- .CountAsync();
- if (repeatCount == 0) repeatCount = 1; // 自身刚建尚未可见时回退 1。
- // cumulativeLossHours30d:仅已关闭异常 (closed_at - created_at)。
- var lossMinutesNullable = await _rep.Context.Queryable<AdoS8Exception>()
- .Where(x => x.TenantId == entity.TenantId
- && x.FactoryId == entity.FactoryId
- && !x.IsDeleted
- && x.ExceptionTypeCode == typeCode
- && x.CreatedAt >= since
- && x.ClosedAt != null)
- .SumAsync(x => (int?)SqlFunc.DateDiff(DateType.Minute, x.CreatedAt, x.ClosedAt!.Value));
- var lossMinutes = lossMinutesNullable ?? 0;
- lossHours = Math.Round((decimal)lossMinutes / 60m, 1);
- }
- var (impactScore, level, label, reason) = Compute(entity.Severity, repeatCount, lossHours, timeoutFlag);
- return new ExceptionImpactSnapshot
- {
- RepeatCount30d = repeatCount,
- CumulativeLossHours30d = lossHours,
- ImpactScore = impactScore,
- SuggestedAttentionLevel = level,
- SuggestedAttentionLabel = label,
- ImpactReason = reason,
- };
- }
- /// <summary>
- /// 算法实现。Severity 已通过 S8SeverityCode.Normalize 统一为 FOLLOW / SERIOUS。
- /// score = severityWeight + min(repeat,5)*10 + min(loss,72)/72*30 + (timeout?20:0),理论范围 0-140。
- /// 关注级别按重复 / 损失 / 严重度 / 超时四因子判定。
- /// </summary>
- private static (decimal score, string level, string label, string reason) Compute(
- string? severity, int repeatCount, decimal lossHours, bool timeoutFlag)
- {
- var normalizedSeverity = S8SeverityCode.Normalize(severity);
- var severityWeight = normalizedSeverity switch
- {
- "SERIOUS" => SeverityWeightSerious,
- "FOLLOW" => SeverityWeightFollow,
- _ => 0m,
- };
- var cappedRepeat = Math.Min(repeatCount, RepeatCap);
- var repeatWeight = cappedRepeat * RepeatUnitWeight;
- var cappedLoss = Math.Min(lossHours, LossHoursCap);
- var lossWeight = cappedLoss / LossHoursCap * LossWeightCap;
- var timeoutWeight = timeoutFlag ? TimeoutWeight : 0m;
- var impactScore = Math.Round(severityWeight + repeatWeight + lossWeight + timeoutWeight, 1);
- string level;
- if (normalizedSeverity == "SERIOUS"
- || repeatCount >= HighRepeatThreshold
- || lossHours >= HighLossThreshold)
- {
- level = "HIGH";
- }
- else if (repeatCount >= MediumRepeatThreshold
- || lossHours >= MediumLossThreshold
- || timeoutFlag)
- {
- level = "MEDIUM";
- }
- else
- {
- level = "LOW";
- }
- var label = level switch
- {
- "HIGH" => "高",
- "MEDIUM" => "中",
- "LOW" => "低",
- _ => "—",
- };
- var reasonParts = new List<string>(4);
- if (repeatCount > 1) reasonParts.Add($"重复 {repeatCount} 次");
- if (lossHours > 0) reasonParts.Add($"累计损失 {lossHours.ToString("0.#")} 小时");
- if (normalizedSeverity == "SERIOUS") reasonParts.Add("严重");
- if (timeoutFlag) reasonParts.Add("当前超时");
- var reason = reasonParts.Count == 0 ? "无显著影响" : string.Join("/", reasonParts);
- return (impactScore, level, label, reason);
- }
- }
- /// <summary>
- /// S8-DEMO-IMPACT-SORT-NOTICE-1:批量影响计算的最小行契约。
- /// S8ExceptionService 投影时构造,FillBatchAsync 在内存回填 6 个 30d 字段后,再赋给 DTO。
- /// </summary>
- public sealed class ExceptionImpactRow
- {
- public long Id { get; set; }
- public string? ExceptionTypeCode { get; set; }
- public string? Severity { get; set; }
- public DateTime CreatedAt { get; set; }
- public DateTime? ClosedAt { get; set; }
- public bool TimeoutFlag { get; set; }
- public int RepeatCount30d { get; set; }
- public decimal CumulativeLossHours30d { get; set; }
- public decimal ImpactScore { get; set; }
- public string? SuggestedAttentionLevel { get; set; }
- public string? SuggestedAttentionLabel { get; set; }
- public string? ImpactReason { get; set; }
- }
- /// <summary>
- /// 单异常计算结果快照;通知派发路径使用。
- /// </summary>
- public sealed class ExceptionImpactSnapshot
- {
- public int RepeatCount30d { get; set; }
- public decimal CumulativeLossHours30d { get; set; }
- public decimal ImpactScore { get; set; }
- public string? SuggestedAttentionLevel { get; set; }
- public string? SuggestedAttentionLabel { get; set; }
- public string? ImpactReason { get; set; }
- }
|