S8ActiveFlowWatchService.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  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. public 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<List<S8TenantFactoryScope>> ListActiveScopesAsync()
  38. {
  39. return await _exceptionRep.Context.Ado.SqlQueryAsync<S8TenantFactoryScope>(
  40. """
  41. SELECT DISTINCT e.tenant_id AS TenantId, e.factory_id AS FactoryId
  42. FROM ado_s8_exception e
  43. INNER JOIN SysTenant t ON t.Id = e.tenant_id AND t.Status = 1
  44. WHERE e.is_deleted = 0
  45. AND e.active_flow_instance_id IS NOT NULL
  46. AND e.tenant_id > 0
  47. AND e.factory_id > 0
  48. ORDER BY e.tenant_id, e.factory_id
  49. """);
  50. }
  51. public async Task<int> ScanAsync(long tenantId, long factoryId, CancellationToken cancellationToken = default)
  52. {
  53. if (!_options.Enabled)
  54. return 0;
  55. var now = DateTime.Now;
  56. var thresholdHours = _options.ThresholdHours > 0 ? _options.ThresholdHours : 4;
  57. var cooldownMinutes = _options.AlertCooldownMinutes > 0 ? _options.AlertCooldownMinutes : 60;
  58. var batchSize = _options.BatchSize > 0 ? _options.BatchSize : 100;
  59. var staleBefore = now.AddHours(-thresholdHours);
  60. var alertedAfter = now.AddMinutes(-cooldownMinutes);
  61. var staleExceptions = await _exceptionRep.AsQueryable()
  62. .Where(x => !x.IsDeleted && x.ActiveFlowInstanceId.HasValue &&
  63. x.TenantId == tenantId && x.FactoryId == factoryId &&
  64. SqlFunc.IsNull(x.UpdatedAt, x.CreatedAt) < staleBefore)
  65. .OrderBy(x => x.UpdatedAt, OrderByType.Asc)
  66. .OrderBy(x => x.Id, OrderByType.Asc)
  67. .Take(batchSize)
  68. .ToListAsync();
  69. if (staleExceptions.Count == 0)
  70. return 0;
  71. var exceptionIds = staleExceptions.Select(x => x.Id).ToHashSet();
  72. var recentAlertLogs = await _notificationLogRep.AsQueryable()
  73. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId &&
  74. x.Channel == AlertChannel && x.CreatedAt >= alertedAfter && x.ExceptionId != null)
  75. .ToListAsync();
  76. var alertedIds = recentAlertLogs
  77. .Where(x => x.ExceptionId.HasValue && exceptionIds.Contains(x.ExceptionId.Value))
  78. .Select(x => x.ExceptionId!.Value)
  79. .ToHashSet();
  80. var alertCount = 0;
  81. foreach (var item in staleExceptions.Where(x => !alertedIds.Contains(x.Id)))
  82. {
  83. cancellationToken.ThrowIfCancellationRequested();
  84. var lastTouchedAt = item.UpdatedAt ?? item.CreatedAt;
  85. var payload = new
  86. {
  87. type = "S8_ACTIVE_FLOW_STUCK",
  88. message = "S8 异常存在进行中的审批实例,但已超过阈值未更新,请人工检查审批流状态与回调链路",
  89. exceptionId = item.Id,
  90. exceptionCode = item.ExceptionCode,
  91. title = item.Title,
  92. status = item.Status,
  93. activeFlowInstanceId = item.ActiveFlowInstanceId,
  94. activeFlowBizType = item.ActiveFlowBizType,
  95. tenantId = item.TenantId,
  96. factoryId = item.FactoryId,
  97. lastTouchedAt,
  98. thresholdHours,
  99. detectedAt = now
  100. };
  101. try
  102. {
  103. await _notificationService.SendAsync(item.TenantId, item.FactoryId, item.Id, AlertChannel, payload);
  104. _logger.LogWarning(
  105. "S8 ActiveFlow 疑似卡死: ExceptionId={ExceptionId}, Code={ExceptionCode}, Status={Status}, FlowInstanceId={FlowInstanceId}, LastTouchedAt={LastTouchedAt}, ThresholdHours={ThresholdHours}",
  106. item.Id,
  107. item.ExceptionCode,
  108. item.Status,
  109. item.ActiveFlowInstanceId,
  110. lastTouchedAt,
  111. thresholdHours);
  112. alertCount++;
  113. }
  114. catch (Exception ex)
  115. {
  116. _logger.LogError(
  117. ex,
  118. "S8 ActiveFlow 卡死告警写入失败: ExceptionId={ExceptionId}, Code={ExceptionCode}, FlowInstanceId={FlowInstanceId}",
  119. item.Id,
  120. item.ExceptionCode,
  121. item.ActiveFlowInstanceId);
  122. }
  123. }
  124. if (alertCount > 0)
  125. {
  126. _logger.LogInformation(
  127. "S8 ActiveFlow 卡死扫描完成,本轮新增告警 {AlertCount} 条,候选 {CandidateCount} 条",
  128. alertCount,
  129. staleExceptions.Count);
  130. }
  131. var orphanAlertCount = await ScanOrphanFlowInstancesAsync(staleBefore, alertedAfter, batchSize, now, thresholdHours, cancellationToken);
  132. return alertCount + orphanAlertCount;
  133. }
  134. /// <summary>
  135. /// N-2:扫描孤立的 S8 审批实例(FlowInstance 存在但无任何 AdoS8Exception 引用),疑似 OnFlowStarted 失败或回调链路丢失。
  136. /// </summary>
  137. private async Task<int> ScanOrphanFlowInstancesAsync(
  138. DateTime staleBefore,
  139. DateTime alertedAfter,
  140. int batchSize,
  141. DateTime now,
  142. int thresholdHours,
  143. CancellationToken cancellationToken)
  144. {
  145. // 本地副本:SqlSugar 表达式树不允许直接引用静态/私有字段,必须先拷到 lambda 闭包变量。
  146. var bizTypes = S8FlowBizTypes;
  147. var candidates = await _flowInstanceRep.AsQueryable()
  148. .Where(fi => bizTypes.Contains(fi.BizType)
  149. && fi.Status == FlowInstanceStatusEnum.Running
  150. && fi.StartTime < staleBefore)
  151. .OrderBy(fi => fi.StartTime, OrderByType.Asc)
  152. .Take(batchSize)
  153. .ToListAsync();
  154. if (candidates.Count == 0)
  155. return 0;
  156. var bizIds = candidates.Select(fi => fi.BizId).ToHashSet();
  157. var instanceIds = candidates.Select(fi => fi.Id).ToHashSet();
  158. var linked = await _exceptionRep.AsQueryable()
  159. .Where(e => !e.IsDeleted
  160. && bizIds.Contains(e.Id)
  161. && e.ActiveFlowInstanceId.HasValue
  162. && instanceIds.Contains(e.ActiveFlowInstanceId!.Value))
  163. .Select(e => new { e.Id, FlowId = e.ActiveFlowInstanceId!.Value })
  164. .ToListAsync();
  165. var linkedPairs = linked.Select(x => (x.Id, x.FlowId)).ToHashSet();
  166. var orphans = candidates.Where(fi => !linkedPairs.Contains((fi.BizId, fi.Id))).ToList();
  167. if (orphans.Count == 0)
  168. return 0;
  169. var recentPayloads = await _notificationLogRep.AsQueryable()
  170. .Where(x => x.Channel == AlertChannelOrphan && x.CreatedAt >= alertedAfter)
  171. .Select(x => x.Payload)
  172. .ToListAsync();
  173. var alertedInstanceIds = new HashSet<long>();
  174. foreach (var p in recentPayloads)
  175. {
  176. var id = TryExtractFlowInstanceId(p);
  177. if (id.HasValue) alertedInstanceIds.Add(id.Value);
  178. }
  179. var alertCount = 0;
  180. foreach (var fi in orphans.Where(x => !alertedInstanceIds.Contains(x.Id)))
  181. {
  182. cancellationToken.ThrowIfCancellationRequested();
  183. var payload = new
  184. {
  185. type = "S8_ORPHAN_FLOW_INSTANCE",
  186. message = "S8 审批实例存在但未被任何业务异常单据引用,疑似 OnFlowStarted 失败或回调链路丢失",
  187. flowInstanceId = fi.Id,
  188. bizType = fi.BizType,
  189. bizId = fi.BizId,
  190. bizNo = fi.BizNo,
  191. flowStatus = fi.Status.ToString(),
  192. startTime = fi.StartTime,
  193. thresholdHours,
  194. detectedAt = now
  195. };
  196. try
  197. {
  198. await _notificationService.SendAsync(0, 0, null, AlertChannelOrphan, payload);
  199. _logger.LogWarning(
  200. "S8 孤立审批实例: FlowInstanceId={InstanceId}, BizType={BizType}, BizId={BizId}, StartTime={StartTime}",
  201. fi.Id, fi.BizType, fi.BizId, fi.StartTime);
  202. alertCount++;
  203. }
  204. catch (Exception ex)
  205. {
  206. _logger.LogError(ex,
  207. "S8 孤立审批实例告警写入失败: FlowInstanceId={InstanceId}, BizType={BizType}, BizId={BizId}",
  208. fi.Id, fi.BizType, fi.BizId);
  209. }
  210. }
  211. if (alertCount > 0)
  212. {
  213. _logger.LogInformation(
  214. "S8 孤立审批实例扫描完成,本轮新增告警 {AlertCount} 条,候选 {CandidateCount} 条",
  215. alertCount, orphans.Count);
  216. }
  217. return alertCount;
  218. }
  219. /// <summary>
  220. /// 从历史告警 payload JSON 中提取 flowInstanceId 用于冷却去重。失败返回 null。
  221. /// </summary>
  222. internal static long? TryExtractFlowInstanceId(string? payload)
  223. {
  224. if (string.IsNullOrWhiteSpace(payload))
  225. return null;
  226. try
  227. {
  228. using var doc = System.Text.Json.JsonDocument.Parse(payload);
  229. if (doc.RootElement.TryGetProperty("flowInstanceId", out var prop)
  230. && prop.ValueKind == System.Text.Json.JsonValueKind.Number
  231. && prop.TryGetInt64(out var id))
  232. {
  233. return id;
  234. }
  235. }
  236. catch
  237. {
  238. // payload 非法 JSON 时忽略,不影响后续告警。
  239. }
  240. return null;
  241. }
  242. }