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