MdpTargetPushDispatcher.cs 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  2. using Microsoft.Extensions.Logging;
  3. namespace Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  4. /// <summary>
  5. /// 出站分发:扫描 mdp_outbox,按 source_type 路由到 API / DB 执行器。
  6. /// </summary>
  7. public sealed class MdpTargetPushDispatcher : ITransient
  8. {
  9. public const int DefaultTake = 500;
  10. private const int MaxRetry = 3;
  11. private readonly ISqlSugarClient _db;
  12. private readonly MdpApiPushExecutor _api;
  13. private readonly MdpDbPushExecutor _dbPush;
  14. private readonly ILogger _logger;
  15. public MdpTargetPushDispatcher(
  16. ISqlSugarClient db,
  17. MdpApiPushExecutor api,
  18. MdpDbPushExecutor dbPush,
  19. ILoggerFactory loggerFactory)
  20. {
  21. _db = db;
  22. _api = api;
  23. _dbPush = dbPush;
  24. _logger = loggerFactory.CreateLogger(nameof(MdpTargetPushDispatcher));
  25. }
  26. public async Task<(int success, int failed, int skipped)> PushPendingAsync(
  27. int take = DefaultTake, CancellationToken cancellationToken = default)
  28. {
  29. var pending = await _db.Queryable<MdpOutbox>()
  30. .Where(x => x.Status == 0 && x.RetryCount < MaxRetry)
  31. .OrderBy(x => x.Id)
  32. .Take(take > 0 ? take : DefaultTake)
  33. .ToListAsync(cancellationToken);
  34. var success = 0;
  35. var failed = 0;
  36. var skipped = 0;
  37. foreach (var item in pending)
  38. {
  39. cancellationToken.ThrowIfCancellationRequested();
  40. try
  41. {
  42. var sources = await _db.Queryable<MdpSource>()
  43. .Where(x => x.SourceCode == item.TargetSourceCode && x.Status == 1)
  44. .Take(1)
  45. .ToListAsync(cancellationToken);
  46. var source = sources.FirstOrDefault();
  47. if (source == null)
  48. {
  49. // 配置类失败:保持 pending,不烧 retry_count,便于配置修复后被 60s 兜底作业捡起
  50. await MarkConfigIssueAsync(
  51. item,
  52. $"目标源 {item.TargetSourceCode} 未启用",
  53. cancellationToken);
  54. skipped++;
  55. continue;
  56. }
  57. if (!TryResolve(source, out var executor, out var routeError))
  58. {
  59. await MarkConfigIssueAsync(item, routeError!, cancellationToken);
  60. skipped++;
  61. continue;
  62. }
  63. _logger.LogInformation(
  64. "[MdpTargetPushDispatcher] outbox id={Id} source={Source} type={SourceType} via={Executor}",
  65. item.Id, source.SourceCode, source.SourceType, executor!.SupportedType);
  66. var result = await executor.PushAsync(source, item, cancellationToken);
  67. if (result.Success)
  68. {
  69. await MarkAsync(item, 1, result.ResponseJson, null, cancellationToken);
  70. success++;
  71. }
  72. else
  73. {
  74. item.RetryCount++;
  75. var status = item.RetryCount >= MaxRetry ? 2 : 0;
  76. await MarkAsync(item, status, result.ResponseJson, Truncate(result.ErrorMessage, 900), cancellationToken);
  77. if (status == 2) failed++; else skipped++;
  78. }
  79. }
  80. catch (Exception ex)
  81. {
  82. item.RetryCount++;
  83. var status = item.RetryCount >= MaxRetry ? 2 : 0;
  84. await MarkAsync(item, status, null, Truncate(ex.Message, 900), cancellationToken);
  85. if (status == 2) failed++; else skipped++;
  86. _logger.LogWarning(ex, "[MdpTargetPushDispatcher] outbox id={Id} failed", item.Id);
  87. }
  88. }
  89. return (success, failed, skipped);
  90. }
  91. /// <summary>
  92. /// 按 source_type / 连接信息解析执行器;无法解析时返回可诊断错误(不再静默落到 API)。
  93. /// </summary>
  94. private bool TryResolve(MdpSource source, out IMdpTargetPushExecutor? executor, out string? errorMessage)
  95. {
  96. var t = (source.SourceType ?? "").Trim().ToUpperInvariant();
  97. if (t is "DB" or "SQLSERVER" or "MYSQL" or "DATABASE")
  98. {
  99. executor = _dbPush;
  100. errorMessage = null;
  101. return true;
  102. }
  103. if (t == "API" || t == "HTTP")
  104. {
  105. executor = _api;
  106. errorMessage = null;
  107. return true;
  108. }
  109. // 未标注类型时:有库连接信息则走 DB,否则不再猜 API
  110. if (!string.IsNullOrWhiteSpace(source.DbHost) && !string.IsNullOrWhiteSpace(source.DbName))
  111. {
  112. executor = _dbPush;
  113. errorMessage = null;
  114. return true;
  115. }
  116. _logger.LogWarning(
  117. "[MdpTargetPushDispatcher] 路由未解析 source={Code} source_type='{Type}' DbHost='{Host}' DbName='{Name}' ApiBaseUrl='{Api}'",
  118. source.SourceCode, source.SourceType, source.DbHost, source.DbName, source.ApiBaseUrl);
  119. executor = null;
  120. errorMessage =
  121. $"SOURCE_ROUTE_UNRESOLVED: 源 {source.SourceCode} 既未标注 source_type,也无 DbHost/DbName";
  122. return false;
  123. }
  124. /// <summary>配置类问题:仅写 ErrorMsg/UpdateTime,status 保持 0,retry_count 不变。</summary>
  125. private async Task MarkConfigIssueAsync(MdpOutbox item, string error, CancellationToken ct)
  126. {
  127. item.ErrorMsg = Truncate(error, 900);
  128. item.UpdateTime = DateTime.Now;
  129. await _db.Updateable(item)
  130. .UpdateColumns(x => new { x.ErrorMsg, x.UpdateTime })
  131. .ExecuteCommandAsync(ct);
  132. }
  133. private async Task MarkAsync(MdpOutbox item, int status, string? responseJson, string? error, CancellationToken ct)
  134. {
  135. item.Status = status;
  136. item.ResponseJson = Truncate(responseJson, 4000);
  137. item.ErrorMsg = error;
  138. item.UpdateTime = DateTime.Now;
  139. await _db.Updateable(item)
  140. .UpdateColumns(x => new { x.Status, x.RetryCount, x.ResponseJson, x.ErrorMsg, x.UpdateTime })
  141. .ExecuteCommandAsync(ct);
  142. }
  143. private static string? Truncate(string? s, int max) =>
  144. string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max]);
  145. }