S8ActiveFlowWatchService.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. using Admin.NET.Plugin.AiDOP.Entity.S8;
  2. using Admin.NET.Plugin.ApprovalFlow;
  3. using Microsoft.Extensions.Logging;
  4. using Microsoft.Extensions.Options;
  5. using SqlSugar;
  6. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  7. /// <summary>
  8. /// 扫描疑似卡死的 ActiveFlow 异常,并输出可检索告警。
  9. /// 当前只做发现,不做自动恢复。
  10. /// </summary>
  11. public class S8ActiveFlowWatchService : ITransient
  12. {
  13. public const string AlertChannel = "s8-active-flow-stuck";
  14. public const string AlertChannelOrphan = "s8-orphan-flow-instance";
  15. private static readonly string[] S8FlowBizTypes = new[] { "EXCEPTION_ESCALATION", "EXCEPTION_CLOSURE" };
  16. private readonly SqlSugarRepository<AdoS8Exception> _exceptionRep;
  17. private readonly SqlSugarRepository<AdoS8NotificationLog> _notificationLogRep;
  18. private readonly SqlSugarRepository<ApprovalFlowInstance> _flowInstanceRep;
  19. private readonly S8NotificationService _notificationService;
  20. private readonly S8ActiveFlowWatchOptions _options;
  21. private readonly ILogger<S8ActiveFlowWatchService> _logger;
  22. public S8ActiveFlowWatchService(
  23. SqlSugarRepository<AdoS8Exception> exceptionRep,
  24. SqlSugarRepository<AdoS8NotificationLog> notificationLogRep,
  25. SqlSugarRepository<ApprovalFlowInstance> flowInstanceRep,
  26. S8NotificationService notificationService,
  27. IOptions<S8ActiveFlowWatchOptions> options,
  28. ILogger<S8ActiveFlowWatchService> logger)
  29. {
  30. _exceptionRep = exceptionRep;
  31. _notificationLogRep = notificationLogRep;
  32. _flowInstanceRep = flowInstanceRep;
  33. _notificationService = notificationService;
  34. _options = options.Value;
  35. _logger = logger;
  36. }
  37. public async Task<int> ScanAsync(CancellationToken cancellationToken = default)
  38. {
  39. if (!_options.Enabled)
  40. return 0;
  41. var now = DateTime.Now;
  42. var thresholdHours = _options.ThresholdHours > 0 ? _options.ThresholdHours : 4;
  43. var cooldownMinutes = _options.AlertCooldownMinutes > 0 ? _options.AlertCooldownMinutes : 60;
  44. var batchSize = _options.BatchSize > 0 ? _options.BatchSize : 100;
  45. var staleBefore = now.AddHours(-thresholdHours);
  46. var alertedAfter = now.AddMinutes(-cooldownMinutes);
  47. var staleExceptions = await _exceptionRep.AsQueryable()
  48. .Where(x => !x.IsDeleted && x.ActiveFlowInstanceId.HasValue &&
  49. SqlFunc.IsNull(x.UpdatedAt, x.CreatedAt) < staleBefore)
  50. .OrderBy(x => x.UpdatedAt, OrderByType.Asc)
  51. .OrderBy(x => x.Id, OrderByType.Asc)
  52. .Take(batchSize)
  53. .ToListAsync();
  54. if (staleExceptions.Count == 0)
  55. return 0;
  56. var exceptionIds = staleExceptions.Select(x => x.Id).ToHashSet();
  57. var recentAlertLogs = await _notificationLogRep.AsQueryable()
  58. .Where(x => x.Channel == AlertChannel && x.CreatedAt >= alertedAfter && x.ExceptionId != null)
  59. .ToListAsync();
  60. var alertedIds = recentAlertLogs
  61. .Where(x => x.ExceptionId.HasValue && exceptionIds.Contains(x.ExceptionId.Value))
  62. .Select(x => x.ExceptionId!.Value)
  63. .ToHashSet();
  64. var alertCount = 0;
  65. foreach (var item in staleExceptions.Where(x => !alertedIds.Contains(x.Id)))
  66. {
  67. cancellationToken.ThrowIfCancellationRequested();
  68. var lastTouchedAt = item.UpdatedAt ?? item.CreatedAt;
  69. var payload = new
  70. {
  71. type = "S8_ACTIVE_FLOW_STUCK",
  72. message = "S8 异常存在进行中的审批实例,但已超过阈值未更新,请人工检查审批流状态与回调链路",
  73. exceptionId = item.Id,
  74. exceptionCode = item.ExceptionCode,
  75. title = item.Title,
  76. status = item.Status,
  77. activeFlowInstanceId = item.ActiveFlowInstanceId,
  78. activeFlowBizType = item.ActiveFlowBizType,
  79. tenantId = item.TenantId,
  80. factoryId = item.FactoryId,
  81. lastTouchedAt,
  82. thresholdHours,
  83. detectedAt = now
  84. };
  85. try
  86. {
  87. await _notificationService.SendAsync(item.TenantId, item.FactoryId, item.Id, AlertChannel, payload);
  88. _logger.LogWarning(
  89. "S8 ActiveFlow 疑似卡死: ExceptionId={ExceptionId}, Code={ExceptionCode}, Status={Status}, FlowInstanceId={FlowInstanceId}, LastTouchedAt={LastTouchedAt}, ThresholdHours={ThresholdHours}",
  90. item.Id,
  91. item.ExceptionCode,
  92. item.Status,
  93. item.ActiveFlowInstanceId,
  94. lastTouchedAt,
  95. thresholdHours);
  96. alertCount++;
  97. }
  98. catch (Exception ex)
  99. {
  100. _logger.LogError(
  101. ex,
  102. "S8 ActiveFlow 卡死告警写入失败: ExceptionId={ExceptionId}, Code={ExceptionCode}, FlowInstanceId={FlowInstanceId}",
  103. item.Id,
  104. item.ExceptionCode,
  105. item.ActiveFlowInstanceId);
  106. }
  107. }
  108. if (alertCount > 0)
  109. {
  110. _logger.LogInformation(
  111. "S8 ActiveFlow 卡死扫描完成,本轮新增告警 {AlertCount} 条,候选 {CandidateCount} 条",
  112. alertCount,
  113. staleExceptions.Count);
  114. }
  115. var orphanAlertCount = await ScanOrphanFlowInstancesAsync(staleBefore, alertedAfter, batchSize, now, thresholdHours, cancellationToken);
  116. return alertCount + orphanAlertCount;
  117. }
  118. /// <summary>
  119. /// N-2:扫描孤立的 S8 审批实例(FlowInstance 存在但无任何 AdoS8Exception 引用),疑似 OnFlowStarted 失败或回调链路丢失。
  120. /// </summary>
  121. private async Task<int> ScanOrphanFlowInstancesAsync(
  122. DateTime staleBefore,
  123. DateTime alertedAfter,
  124. int batchSize,
  125. DateTime now,
  126. int thresholdHours,
  127. CancellationToken cancellationToken)
  128. {
  129. var candidates = await _flowInstanceRep.AsQueryable()
  130. .Where(fi => S8FlowBizTypes.Contains(fi.BizType)
  131. && fi.Status == FlowInstanceStatusEnum.Running
  132. && fi.StartTime < staleBefore)
  133. .OrderBy(fi => fi.StartTime, OrderByType.Asc)
  134. .Take(batchSize)
  135. .ToListAsync();
  136. if (candidates.Count == 0)
  137. return 0;
  138. var bizIds = candidates.Select(fi => fi.BizId).ToHashSet();
  139. var instanceIds = candidates.Select(fi => fi.Id).ToHashSet();
  140. var linked = await _exceptionRep.AsQueryable()
  141. .Where(e => !e.IsDeleted
  142. && bizIds.Contains(e.Id)
  143. && e.ActiveFlowInstanceId.HasValue
  144. && instanceIds.Contains(e.ActiveFlowInstanceId!.Value))
  145. .Select(e => new { e.Id, FlowId = e.ActiveFlowInstanceId!.Value })
  146. .ToListAsync();
  147. var linkedPairs = linked.Select(x => (x.Id, x.FlowId)).ToHashSet();
  148. var orphans = candidates.Where(fi => !linkedPairs.Contains((fi.BizId, fi.Id))).ToList();
  149. if (orphans.Count == 0)
  150. return 0;
  151. var recentPayloads = await _notificationLogRep.AsQueryable()
  152. .Where(x => x.Channel == AlertChannelOrphan && x.CreatedAt >= alertedAfter)
  153. .Select(x => x.Payload)
  154. .ToListAsync();
  155. var alertedInstanceIds = new HashSet<long>();
  156. foreach (var p in recentPayloads)
  157. {
  158. var id = TryExtractFlowInstanceId(p);
  159. if (id.HasValue) alertedInstanceIds.Add(id.Value);
  160. }
  161. var alertCount = 0;
  162. foreach (var fi in orphans.Where(x => !alertedInstanceIds.Contains(x.Id)))
  163. {
  164. cancellationToken.ThrowIfCancellationRequested();
  165. var payload = new
  166. {
  167. type = "S8_ORPHAN_FLOW_INSTANCE",
  168. message = "S8 审批实例存在但未被任何业务异常单据引用,疑似 OnFlowStarted 失败或回调链路丢失",
  169. flowInstanceId = fi.Id,
  170. bizType = fi.BizType,
  171. bizId = fi.BizId,
  172. bizNo = fi.BizNo,
  173. flowStatus = fi.Status.ToString(),
  174. startTime = fi.StartTime,
  175. thresholdHours,
  176. detectedAt = now
  177. };
  178. try
  179. {
  180. await _notificationService.SendAsync(0, 0, null, AlertChannelOrphan, payload);
  181. _logger.LogWarning(
  182. "S8 孤立审批实例: FlowInstanceId={InstanceId}, BizType={BizType}, BizId={BizId}, StartTime={StartTime}",
  183. fi.Id, fi.BizType, fi.BizId, fi.StartTime);
  184. alertCount++;
  185. }
  186. catch (Exception ex)
  187. {
  188. _logger.LogError(ex,
  189. "S8 孤立审批实例告警写入失败: FlowInstanceId={InstanceId}, BizType={BizType}, BizId={BizId}",
  190. fi.Id, fi.BizType, fi.BizId);
  191. }
  192. }
  193. if (alertCount > 0)
  194. {
  195. _logger.LogInformation(
  196. "S8 孤立审批实例扫描完成,本轮新增告警 {AlertCount} 条,候选 {CandidateCount} 条",
  197. alertCount, orphans.Count);
  198. }
  199. return alertCount;
  200. }
  201. /// <summary>
  202. /// 从历史告警 payload JSON 中提取 flowInstanceId 用于冷却去重。失败返回 null。
  203. /// </summary>
  204. internal static long? TryExtractFlowInstanceId(string? payload)
  205. {
  206. if (string.IsNullOrWhiteSpace(payload))
  207. return null;
  208. try
  209. {
  210. using var doc = System.Text.Json.JsonDocument.Parse(payload);
  211. if (doc.RootElement.TryGetProperty("flowInstanceId", out var prop)
  212. && prop.ValueKind == System.Text.Json.JsonValueKind.Number
  213. && prop.TryGetInt64(out var id))
  214. {
  215. return id;
  216. }
  217. }
  218. catch
  219. {
  220. // payload 非法 JSON 时忽略,不影响后续告警。
  221. }
  222. return null;
  223. }
  224. }