S8ImpactMetricsService.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. using Admin.NET.Plugin.AiDOP.Entity.S8;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  3. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  4. /// <summary>
  5. /// S8-DEMO-IMPACT-SORT-NOTICE-1:异常影响统计服务。
  6. /// 滚动 30 天窗口;归类键 = (tenant_id, factory_id, exception_type_code);
  7. /// 累计损失只统计已关闭异常(closed_at - created_at);未关闭不计入;
  8. /// 算法/阈值见 Compute 方法内常量;运行期计算,不落库。
  9. /// </summary>
  10. public class S8ImpactMetricsService : ITransient
  11. {
  12. private readonly SqlSugarRepository<AdoS8Exception> _rep;
  13. // 影响分权重上限(保护 score 不超过设计区间 0-140)
  14. private const int RepeatCap = 5;
  15. private const decimal LossHoursCap = 72m;
  16. private const decimal LossWeightCap = 30m;
  17. private const decimal SeverityWeightSerious = 40m;
  18. private const decimal SeverityWeightFollow = 10m;
  19. private const decimal TimeoutWeight = 20m;
  20. private const decimal RepeatUnitWeight = 10m;
  21. // 关注级别阈值
  22. private const int HighRepeatThreshold = 3;
  23. private const decimal HighLossThreshold = 24m;
  24. private const int MediumRepeatThreshold = 2;
  25. private const decimal MediumLossThreshold = 8m;
  26. public S8ImpactMetricsService(SqlSugarRepository<AdoS8Exception> rep)
  27. {
  28. _rep = rep;
  29. }
  30. /// <summary>
  31. /// 批量计算:传入候选异常子集(同一 tenant/factory),一次性按 exception_type_code 聚合 30 天窗口数据,
  32. /// 内存填充各行 6 个影响字段。无 N+1。candidates 为空或 typeCode 全空时不查库。
  33. /// </summary>
  34. public async Task FillBatchAsync(
  35. long tenantId,
  36. IReadOnlyList<ExceptionImpactRow> candidates)
  37. {
  38. if (candidates == null || candidates.Count == 0) return;
  39. var since = DateTime.Now.AddDays(-30);
  40. var typeCodes = candidates
  41. .Select(c => c.ExceptionTypeCode)
  42. .Where(c => !string.IsNullOrWhiteSpace(c))
  43. .Distinct()
  44. .ToList();
  45. var repeatMap = new Dictionary<string, int>(StringComparer.Ordinal);
  46. var lossMap = new Dictionary<string, decimal>(StringComparer.Ordinal);
  47. if (typeCodes.Count > 0)
  48. {
  49. // repeatCount30d:按 exception_type_code GROUP BY 计数全部行(含未关闭)。
  50. var repeatRows = await _rep.Context.Queryable<AdoS8Exception>()
  51. .Where(x => x.TenantId == tenantId
  52. && !x.IsDeleted
  53. && x.ExceptionTypeCode != null
  54. && typeCodes.Contains(x.ExceptionTypeCode)
  55. && x.CreatedAt >= since)
  56. .GroupBy(x => x.ExceptionTypeCode!)
  57. .Select(x => new
  58. {
  59. ExceptionTypeCode = x.ExceptionTypeCode!,
  60. RepeatCount = SqlFunc.AggregateCount(x.Id),
  61. })
  62. .ToListAsync();
  63. foreach (var r in repeatRows)
  64. {
  65. repeatMap[r.ExceptionTypeCode] = r.RepeatCount;
  66. }
  67. // cumulativeLossHours30d:仅已关闭异常 (closed_at - created_at) 分钟差累计。
  68. var lossRows = await _rep.Context.Queryable<AdoS8Exception>()
  69. .Where(x => x.TenantId == tenantId
  70. && !x.IsDeleted
  71. && x.ExceptionTypeCode != null
  72. && typeCodes.Contains(x.ExceptionTypeCode)
  73. && x.CreatedAt >= since
  74. && x.ClosedAt != null)
  75. .GroupBy(x => x.ExceptionTypeCode!)
  76. .Select(x => new
  77. {
  78. ExceptionTypeCode = x.ExceptionTypeCode!,
  79. LossMinutes = SqlFunc.AggregateSum(
  80. SqlFunc.DateDiff(DateType.Minute, x.CreatedAt, x.ClosedAt!.Value)),
  81. })
  82. .ToListAsync();
  83. foreach (var r in lossRows)
  84. {
  85. lossMap[r.ExceptionTypeCode] = Math.Round((decimal)r.LossMinutes / 60m, 1);
  86. }
  87. }
  88. foreach (var c in candidates)
  89. {
  90. int repeatCount;
  91. decimal lossHours;
  92. if (string.IsNullOrWhiteSpace(c.ExceptionTypeCode))
  93. {
  94. // exception_type_code 为空 → 视为孤例:自身计 1 次;若自身已关闭则累计自身损失
  95. repeatCount = 1;
  96. lossHours = (c.ClosedAt != null)
  97. ? Math.Round((decimal)(c.ClosedAt.Value - c.CreatedAt).TotalHours, 1)
  98. : 0m;
  99. }
  100. else
  101. {
  102. repeatCount = repeatMap.TryGetValue(c.ExceptionTypeCode, out var rc) ? rc : 1;
  103. lossHours = lossMap.TryGetValue(c.ExceptionTypeCode, out var lh) ? lh : 0m;
  104. }
  105. var (impactScore, level, label, reason) = Compute(c.Severity, repeatCount, lossHours, c.TimeoutFlag);
  106. c.RepeatCount30d = repeatCount;
  107. c.CumulativeLossHours30d = lossHours;
  108. c.ImpactScore = impactScore;
  109. c.SuggestedAttentionLevel = level;
  110. c.SuggestedAttentionLabel = label;
  111. c.ImpactReason = reason;
  112. }
  113. }
  114. /// <summary>
  115. /// 单异常计算:通知派发路径调用;exception_type_code / created_at / closed_at / severity / sla_deadline 从实体读取。
  116. /// 同样的 30 天窗口聚合,但只查询当前 typeCode 一条。
  117. /// </summary>
  118. public async Task<ExceptionImpactSnapshot> ComputeOneAsync(AdoS8Exception entity)
  119. {
  120. if (entity == null) throw new ArgumentNullException(nameof(entity));
  121. var since = DateTime.Now.AddDays(-30);
  122. var now = DateTime.Now;
  123. var timeoutFlag = entity.SlaDeadline != null
  124. && entity.SlaDeadline < now
  125. && entity.Status != "CLOSED"
  126. && entity.Status != "RECOVERED";
  127. int repeatCount;
  128. decimal lossHours;
  129. if (string.IsNullOrWhiteSpace(entity.ExceptionTypeCode))
  130. {
  131. repeatCount = 1;
  132. lossHours = (entity.ClosedAt != null)
  133. ? Math.Round((decimal)(entity.ClosedAt.Value - entity.CreatedAt).TotalHours, 1)
  134. : 0m;
  135. }
  136. else
  137. {
  138. var typeCode = entity.ExceptionTypeCode;
  139. // repeatCount30d:30 天窗口内同 typeCode 全量计数(含未关闭)。
  140. repeatCount = await _rep.Context.Queryable<AdoS8Exception>()
  141. // S8-TENANT-ONLY-BATCH6:同类累计口径按租户统计。
  142. // 保留 factory 会让「本批前后建的同类异常」被算成两个互不相干的群体,
  143. // 复发计数与累计损失同时失真。
  144. .Where(x => x.TenantId == entity.TenantId
  145. && !x.IsDeleted
  146. && x.ExceptionTypeCode == typeCode
  147. && x.CreatedAt >= since)
  148. .CountAsync();
  149. if (repeatCount == 0) repeatCount = 1; // 自身刚建尚未可见时回退 1。
  150. // cumulativeLossHours30d:仅已关闭异常 (closed_at - created_at)。
  151. var lossMinutesNullable = await _rep.Context.Queryable<AdoS8Exception>()
  152. .Where(x => x.TenantId == entity.TenantId
  153. && !x.IsDeleted
  154. && x.ExceptionTypeCode == typeCode
  155. && x.CreatedAt >= since
  156. && x.ClosedAt != null)
  157. .SumAsync(x => (int?)SqlFunc.DateDiff(DateType.Minute, x.CreatedAt, x.ClosedAt!.Value));
  158. var lossMinutes = lossMinutesNullable ?? 0;
  159. lossHours = Math.Round((decimal)lossMinutes / 60m, 1);
  160. }
  161. var (impactScore, level, label, reason) = Compute(entity.Severity, repeatCount, lossHours, timeoutFlag);
  162. return new ExceptionImpactSnapshot
  163. {
  164. RepeatCount30d = repeatCount,
  165. CumulativeLossHours30d = lossHours,
  166. ImpactScore = impactScore,
  167. SuggestedAttentionLevel = level,
  168. SuggestedAttentionLabel = label,
  169. ImpactReason = reason,
  170. };
  171. }
  172. /// <summary>
  173. /// 算法实现。Severity 已通过 S8SeverityCode.Normalize 统一为 FOLLOW / SERIOUS。
  174. /// score = severityWeight + min(repeat,5)*10 + min(loss,72)/72*30 + (timeout?20:0),理论范围 0-140。
  175. /// 关注级别按重复 / 损失 / 严重度 / 超时四因子判定。
  176. /// </summary>
  177. private static (decimal score, string level, string label, string reason) Compute(
  178. string? severity, int repeatCount, decimal lossHours, bool timeoutFlag)
  179. {
  180. var normalizedSeverity = S8SeverityCode.Normalize(severity);
  181. var severityWeight = normalizedSeverity switch
  182. {
  183. "SERIOUS" => SeverityWeightSerious,
  184. "FOLLOW" => SeverityWeightFollow,
  185. _ => 0m,
  186. };
  187. var cappedRepeat = Math.Min(repeatCount, RepeatCap);
  188. var repeatWeight = cappedRepeat * RepeatUnitWeight;
  189. var cappedLoss = Math.Min(lossHours, LossHoursCap);
  190. var lossWeight = cappedLoss / LossHoursCap * LossWeightCap;
  191. var timeoutWeight = timeoutFlag ? TimeoutWeight : 0m;
  192. var impactScore = Math.Round(severityWeight + repeatWeight + lossWeight + timeoutWeight, 1);
  193. string level;
  194. if (normalizedSeverity == "SERIOUS"
  195. || repeatCount >= HighRepeatThreshold
  196. || lossHours >= HighLossThreshold)
  197. {
  198. level = "HIGH";
  199. }
  200. else if (repeatCount >= MediumRepeatThreshold
  201. || lossHours >= MediumLossThreshold
  202. || timeoutFlag)
  203. {
  204. level = "MEDIUM";
  205. }
  206. else
  207. {
  208. level = "LOW";
  209. }
  210. var label = level switch
  211. {
  212. "HIGH" => "高",
  213. "MEDIUM" => "中",
  214. "LOW" => "低",
  215. _ => "—",
  216. };
  217. var reasonParts = new List<string>(4);
  218. if (repeatCount > 1) reasonParts.Add($"重复 {repeatCount} 次");
  219. if (lossHours > 0) reasonParts.Add($"累计损失 {lossHours.ToString("0.#")} 小时");
  220. if (normalizedSeverity == "SERIOUS") reasonParts.Add("严重");
  221. if (timeoutFlag) reasonParts.Add("当前超时");
  222. var reason = reasonParts.Count == 0 ? "无显著影响" : string.Join("/", reasonParts);
  223. return (impactScore, level, label, reason);
  224. }
  225. }
  226. /// <summary>
  227. /// S8-DEMO-IMPACT-SORT-NOTICE-1:批量影响计算的最小行契约。
  228. /// S8ExceptionService 投影时构造,FillBatchAsync 在内存回填 6 个 30d 字段后,再赋给 DTO。
  229. /// </summary>
  230. public sealed class ExceptionImpactRow
  231. {
  232. public long Id { get; set; }
  233. public string? ExceptionTypeCode { get; set; }
  234. public string? Severity { get; set; }
  235. public DateTime CreatedAt { get; set; }
  236. public DateTime? ClosedAt { get; set; }
  237. public bool TimeoutFlag { get; set; }
  238. public int RepeatCount30d { get; set; }
  239. public decimal CumulativeLossHours30d { get; set; }
  240. public decimal ImpactScore { get; set; }
  241. public string? SuggestedAttentionLevel { get; set; }
  242. public string? SuggestedAttentionLabel { get; set; }
  243. public string? ImpactReason { get; set; }
  244. }
  245. /// <summary>
  246. /// 单异常计算结果快照;通知派发路径使用。
  247. /// </summary>
  248. public sealed class ExceptionImpactSnapshot
  249. {
  250. public int RepeatCount30d { get; set; }
  251. public decimal CumulativeLossHours30d { get; set; }
  252. public decimal ImpactScore { get; set; }
  253. public string? SuggestedAttentionLevel { get; set; }
  254. public string? SuggestedAttentionLabel { get; set; }
  255. public string? ImpactReason { get; set; }
  256. }