using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
using Microsoft.Extensions.Logging;
namespace Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
///
/// 出站分发:扫描 mdp_outbox,按 source_type 路由到 API / DB 执行器。
/// WP10 S4b / D12:退避 1m/5m/15m/30m/60m/120m,MaxRetry=6;配置类失败不烧重试。
///
public sealed class MdpTargetPushDispatcher : ITransient
{
public const int DefaultTake = 500;
private const int MaxRetry = 6;
/// D12:按 retry_count 下标取退避分钟数(clamp 到末项)。
private static readonly int[] BackoffMinutes = [1, 5, 15, 30, 60, 120];
private static readonly string[] PermanentErrorPrefixes =
[
"PAYLOAD_INVALID",
"CONSTRAINT_VIOLATION",
"SQL_CONSTRAINT",
"UNSUPPORTED_OP",
];
private readonly ISqlSugarClient _db;
private readonly MdpApiPushExecutor _api;
private readonly MdpDbPushExecutor _dbPush;
private readonly ILogger _logger;
public MdpTargetPushDispatcher(
ISqlSugarClient db,
MdpApiPushExecutor api,
MdpDbPushExecutor dbPush,
ILoggerFactory loggerFactory)
{
_db = db;
_api = api;
_dbPush = dbPush;
_logger = loggerFactory.CreateLogger(nameof(MdpTargetPushDispatcher));
}
public async Task<(int success, int failed, int skipped)> PushPendingAsync(
int take = DefaultTake, CancellationToken cancellationToken = default)
{
var now = DateTime.Now;
var pending = await _db.Queryable()
.Where(x => x.Status == 0
&& x.RetryCount < MaxRetry
&& (x.NextRetryTime == null || x.NextRetryTime <= now))
.OrderBy(x => x.Id)
.Take(take > 0 ? take : DefaultTake)
.ToListAsync(cancellationToken);
var success = 0;
var failed = 0;
var skipped = 0;
foreach (var item in pending)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var sources = await _db.Queryable()
.Where(x => x.SourceCode == item.TargetSourceCode && x.Status == 1)
.Take(1)
.ToListAsync(cancellationToken);
var source = sources.FirstOrDefault();
if (source == null)
{
// 配置类失败:保持 pending,不烧 retry_count,便于配置修复后被 60s 兜底作业捡起
await MarkConfigIssueAsync(
item,
"SOURCE_DISABLED",
$"目标源 {item.TargetSourceCode} 未启用",
cancellationToken);
skipped++;
continue;
}
if (!TryResolve(source, out var executor, out var routeError))
{
await MarkConfigIssueAsync(item, "SOURCE_ROUTE_UNRESOLVED", routeError!, cancellationToken);
skipped++;
continue;
}
_logger.LogInformation(
"[MdpTargetPushDispatcher] outbox id={Id} source={Source} type={SourceType} via={Executor}",
item.Id, source.SourceCode, source.SourceType, executor!.SupportedType);
var result = await executor.PushAsync(source, item, cancellationToken);
if (result.Success)
{
await MarkAsync(item, 1, result.ResponseJson, null, null, null, cancellationToken);
success++;
}
else
{
await ApplyFailureAsync(item, result.ResponseJson, result.ErrorMessage, cancellationToken);
if (item.Status == 2) failed++; else skipped++;
}
}
catch (Exception ex)
{
await ApplyFailureAsync(item, null, ex.Message, cancellationToken);
if (item.Status == 2) failed++; else skipped++;
_logger.LogWarning(ex, "[MdpTargetPushDispatcher] outbox id={Id} failed", item.Id);
}
}
return (success, failed, skipped);
}
private async Task ApplyFailureAsync(
MdpOutbox item, string? responseJson, string? errorMessage, CancellationToken ct)
{
var msg = Truncate(errorMessage, 900);
var (errorCode, permanent) = ClassifyFailure(msg);
if (permanent)
{
// 立即死信,不递增 retry_count
item.NextRetryTime = null;
item.LastErrorCode = errorCode;
await MarkAsync(item, 2, responseJson, msg, null, errorCode, ct);
return;
}
item.RetryCount++;
item.LastErrorCode = errorCode;
if (item.RetryCount >= MaxRetry)
{
item.NextRetryTime = null;
await MarkAsync(item, 2, responseJson, msg, null, errorCode, ct);
}
else
{
item.NextRetryTime = DateTime.Now.AddMinutes(GetBackoffMinutes(item.RetryCount));
await MarkAsync(item, 0, responseJson, msg, item.NextRetryTime, errorCode, ct);
}
}
///
/// 可重试 vs 不可重试。配置类走 ,不进本方法。
///
private static (string? errorCode, bool permanent) ClassifyFailure(string? error)
{
if (string.IsNullOrWhiteSpace(error))
return ("UNKNOWN", false);
var trimmed = error.Trim();
foreach (var prefix in PermanentErrorPrefixes)
{
if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return (prefix, true);
}
// payload 反序列化 / 解析失败 → 永久
if (trimmed.Contains("payload 解析失败", StringComparison.OrdinalIgnoreCase)
|| trimmed.Contains("payload parse", StringComparison.OrdinalIgnoreCase)
|| trimmed.Contains("JsonException", StringComparison.OrdinalIgnoreCase)
|| trimmed.Contains("反序列化", StringComparison.OrdinalIgnoreCase))
return ("PAYLOAD_INVALID", true);
// SQL 约束 / 唯一冲突 → 永久
if (ContainsAny(trimmed,
"unique constraint", "unique key", "duplicate key", "duplicate entry",
"primary key", "violation of unique", "violation of primary",
"cannot insert duplicate", "约束", "唯一索引", "主键冲突"))
return ("CONSTRAINT_VIOLATION", true);
if (trimmed.Contains("不支持的 op", StringComparison.OrdinalIgnoreCase))
return ("UNSUPPORTED_OP", true);
// 连接 / 超时 → 可重试
if (ContainsAny(trimmed,
"timeout", "timed out", "connection", "network", "could not open",
"unable to connect", "transport-level", "broken pipe", "socket",
"连接", "超时", "无法连接"))
return ("TRANSIENT", false);
return ("RETRYABLE", false);
}
private static bool ContainsAny(string haystack, params string[] needles)
{
foreach (var n in needles)
{
if (haystack.Contains(n, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// retry_count 从 1 起对应 Backoff[0];越界 clamp 到末项。
private static int GetBackoffMinutes(int retryCount)
{
var idx = Math.Max(0, retryCount - 1);
if (idx >= BackoffMinutes.Length)
idx = BackoffMinutes.Length - 1;
return BackoffMinutes[idx];
}
///
/// 按 source_type / 连接信息解析执行器;无法解析时返回可诊断错误(不再静默落到 API)。
///
private bool TryResolve(MdpSource source, out IMdpTargetPushExecutor? executor, out string? errorMessage)
{
var t = (source.SourceType ?? "").Trim().ToUpperInvariant();
if (t is "DB" or "SQLSERVER" or "MYSQL" or "DATABASE")
{
executor = _dbPush;
errorMessage = null;
return true;
}
if (t == "API" || t == "HTTP")
{
executor = _api;
errorMessage = null;
return true;
}
// 未标注类型时:有库连接信息则走 DB,否则不再猜 API
if (!string.IsNullOrWhiteSpace(source.DbHost) && !string.IsNullOrWhiteSpace(source.DbName))
{
executor = _dbPush;
errorMessage = null;
return true;
}
_logger.LogWarning(
"[MdpTargetPushDispatcher] 路由未解析 source={Code} source_type='{Type}' DbHost='{Host}' DbName='{Name}' ApiBaseUrl='{Api}'",
source.SourceCode, source.SourceType, source.DbHost, source.DbName, source.ApiBaseUrl);
executor = null;
errorMessage =
$"SOURCE_ROUTE_UNRESOLVED: 源 {source.SourceCode} 既未标注 source_type,也无 DbHost/DbName";
return false;
}
/// 配置类问题:仅写 ErrorMsg/LastErrorCode/UpdateTime,status 保持 0,retry_count 不变。
private async Task MarkConfigIssueAsync(MdpOutbox item, string errorCode, string error, CancellationToken ct)
{
item.ErrorMsg = Truncate(error, 900);
item.LastErrorCode = Truncate(errorCode, 64);
item.UpdateTime = DateTime.Now;
await _db.Updateable(item)
.UpdateColumns(x => new { x.ErrorMsg, x.LastErrorCode, x.UpdateTime })
.ExecuteCommandAsync(ct);
}
private async Task MarkAsync(
MdpOutbox item,
int status,
string? responseJson,
string? error,
DateTime? nextRetryTime,
string? lastErrorCode,
CancellationToken ct)
{
item.Status = status;
item.ResponseJson = Truncate(responseJson, 4000);
item.ErrorMsg = error;
item.NextRetryTime = nextRetryTime;
item.LastErrorCode = Truncate(lastErrorCode, 64);
item.UpdateTime = DateTime.Now;
await _db.Updateable(item)
.UpdateColumns(x => new
{
x.Status,
x.RetryCount,
x.ResponseJson,
x.ErrorMsg,
x.NextRetryTime,
x.LastErrorCode,
x.UpdateTime
})
.ExecuteCommandAsync(ct);
}
private static string? Truncate(string? s, int max) =>
string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max]);
}