MdpDbPullExecutor.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. using System.Data;
  2. using System.Globalization;
  3. using System.Text.Json;
  4. using System.Text.Json.Serialization;
  5. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  6. using SqlSugar;
  7. namespace Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  8. /// <summary>
  9. /// 方式甲:从 mdp_source DB 源按 mdp_entity.source_table_name 增量抽数 → target_table_name(贴源)。
  10. /// </summary>
  11. public sealed class MdpDbPullExecutor : IMdpSourcePullExecutor, ITransient
  12. {
  13. public string SupportedType => "DB_SYNC";
  14. /// <summary>
  15. /// 贴源 raw_data 的时间统一按 MySQL 字面量格式输出;各域转换层用
  16. /// STR_TO_DATE(..., '%Y-%m-%d %H:%i:%s.%f') 解析,ISO 8601 的 T 分隔符会解析失败。
  17. /// </summary>
  18. private static readonly JsonSerializerOptions RawDataJsonOptions = new()
  19. {
  20. Converters =
  21. {
  22. new MysqlLiteralDateTimeConverter(),
  23. new MysqlLiteralDateTimeOffsetConverter()
  24. }
  25. };
  26. private readonly MdpSourceScopeFactory _scopeFactory;
  27. private readonly ISqlSugarClient _db;
  28. private readonly MdpStagingWriter _writer;
  29. public MdpDbPullExecutor(MdpSourceScopeFactory scopeFactory, ISqlSugarClient db, MdpStagingWriter writer)
  30. {
  31. _scopeFactory = scopeFactory;
  32. _db = db;
  33. _writer = writer;
  34. }
  35. public async Task<MdpPullResult> PullAsync(MdpSource source, MdpEntity entity, MdpPullContext ctx, CancellationToken cancellationToken = default)
  36. {
  37. if (string.IsNullOrWhiteSpace(entity.SourceTableName))
  38. throw new InvalidOperationException($"实体 {entity.EntityCode} 未配置 source_table_name");
  39. if (string.IsNullOrWhiteSpace(entity.TargetTableName))
  40. throw new InvalidOperationException($"实体 {entity.EntityCode} 未配置 target_table_name");
  41. var startedAt = DateTime.Now;
  42. var scope = await _scopeFactory.GetScopeAsync(source.SourceCode, cancellationToken);
  43. var batchSize = entity.BatchSize > 0 ? entity.BatchSize : 1000;
  44. var isSqlServer = string.Equals(source.DbType, "SQLSERVER", StringComparison.OrdinalIgnoreCase)
  45. || string.Equals(source.DbType, "MSSQL", StringComparison.OrdinalIgnoreCase);
  46. var sql = BuildSelectSql(entity, isSqlServer, batchSize, ctx);
  47. var parameters = new List<SugarParameter>();
  48. if (ctx.WindowFrom.HasValue)
  49. parameters.Add(new SugarParameter("@windowFrom", ctx.WindowFrom.Value));
  50. if (!ctx.FullRefresh
  51. && !string.Equals(ctx.SyncWindowType, "FULL", StringComparison.OrdinalIgnoreCase)
  52. && !string.IsNullOrWhiteSpace(entity.IncrColumn)
  53. && !string.IsNullOrWhiteSpace(entity.LastCursor)
  54. && !string.Equals(ctx.SyncWindowType, "ROLLING", StringComparison.OrdinalIgnoreCase))
  55. parameters.Add(new SugarParameter("@cursor", entity.LastCursor));
  56. // ROLLING:用窗口下界;若已有水位且水位晚于下界,则进一步用水位收窄
  57. if (!ctx.FullRefresh
  58. && string.Equals(ctx.SyncWindowType, "ROLLING", StringComparison.OrdinalIgnoreCase)
  59. && !string.IsNullOrWhiteSpace(entity.IncrColumn)
  60. && !string.IsNullOrWhiteSpace(entity.LastCursor)
  61. && ctx.WindowFrom.HasValue
  62. && DateTime.TryParse(entity.LastCursor, out var cursorDt)
  63. && cursorDt > ctx.WindowFrom.Value)
  64. parameters.Add(new SugarParameter("@cursor", entity.LastCursor));
  65. var table = await scope.Ado.GetDataTableAsync(sql, parameters);
  66. cancellationToken.ThrowIfCancellationRequested();
  67. var written = 0;
  68. string? maxCursor = entity.LastCursor;
  69. var now = DateTime.Now;
  70. foreach (DataRow row in table.Rows)
  71. {
  72. cancellationToken.ThrowIfCancellationRequested();
  73. var dict = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  74. foreach (DataColumn col in table.Columns)
  75. dict[col.ColumnName] = row[col] == DBNull.Value ? null : row[col];
  76. var sourceRowId = ResolveSourceRowId(dict);
  77. var rawJson = JsonSerializer.Serialize(dict, RawDataJsonOptions);
  78. if (!string.IsNullOrWhiteSpace(entity.IncrColumn) && dict.TryGetValue(entity.IncrColumn, out var incrVal) && incrVal != null)
  79. {
  80. var cursor = incrVal is DateTime dt ? dt.ToString("yyyy-MM-dd HH:mm:ss.fff") : incrVal.ToString();
  81. if (!string.IsNullOrEmpty(cursor) && (maxCursor == null || string.CompareOrdinal(cursor, maxCursor) > 0))
  82. maxCursor = cursor;
  83. }
  84. written += await _writer.UpsertAsync(
  85. source, entity, entity.SourceTableName!, dict, rawJson, sourceRowId, ctx);
  86. }
  87. if (!string.IsNullOrEmpty(maxCursor) && maxCursor != entity.LastCursor)
  88. {
  89. await _db.Updateable<MdpEntity>()
  90. .SetColumns(x => new MdpEntity
  91. {
  92. LastCursor = maxCursor,
  93. LastSyncTo = now,
  94. UpdateTime = now
  95. })
  96. .Where(x => x.Id == entity.Id)
  97. .ExecuteCommandAsync(cancellationToken);
  98. }
  99. await WriteSyncLogAsync(source, entity, ctx, startedAt, table.Rows.Count, written, null);
  100. var windowHint = ctx.SyncWindowType ?? (ctx.FullRefresh ? "FULL" : "INCR");
  101. return new MdpPullResult
  102. {
  103. RowsPulled = table.Rows.Count,
  104. RowsWritten = written,
  105. NewCursor = maxCursor,
  106. Message = $"OK window={windowHint}" + (ctx.WindowFrom.HasValue ? $" from={ctx.WindowFrom:yyyy-MM-dd HH:mm:ss}" : "")
  107. };
  108. }
  109. private static string BuildSelectSql(MdpEntity entity, bool isSqlServer, int batchSize, MdpPullContext ctx)
  110. {
  111. var table = entity.SourceTableName!.Trim();
  112. // 仅允许简单标识符/schema.table,防注入
  113. if (!System.Text.RegularExpressions.Regex.IsMatch(table, @"^[A-Za-z0-9_\.\[\]]+$"))
  114. throw new InvalidOperationException($"非法 source_table_name:{table}");
  115. var predicates = new List<string>();
  116. string orderBy;
  117. if (!string.IsNullOrWhiteSpace(entity.IncrColumn))
  118. {
  119. var incr = entity.IncrColumn.Trim();
  120. if (!System.Text.RegularExpressions.Regex.IsMatch(incr, @"^[A-Za-z0-9_]+$"))
  121. throw new InvalidOperationException($"非法 incr_column:{incr}");
  122. var incrExpr = isSqlServer ? incr : $"`{incr}`";
  123. orderBy = incrExpr;
  124. var isRolling = string.Equals(ctx.SyncWindowType, "ROLLING", StringComparison.OrdinalIgnoreCase);
  125. if (!ctx.FullRefresh && isRolling && ctx.WindowFrom.HasValue)
  126. predicates.Add($"{incrExpr} >= @windowFrom");
  127. var useCursor = !ctx.FullRefresh
  128. && !string.IsNullOrWhiteSpace(entity.LastCursor)
  129. && (!isRolling
  130. || (ctx.WindowFrom.HasValue
  131. && DateTime.TryParse(entity.LastCursor, out var cursorDt)
  132. && cursorDt > ctx.WindowFrom.Value));
  133. if (useCursor)
  134. predicates.Add($"{incrExpr} > @cursor");
  135. }
  136. else
  137. {
  138. orderBy = isSqlServer ? "(SELECT NULL)" : "1";
  139. }
  140. var where = predicates.Count > 0 ? " WHERE " + string.Join(" AND ", predicates) : "";
  141. var offset = ctx.Offset > 0 ? ctx.Offset : 0;
  142. if (isSqlServer)
  143. {
  144. // SQL Server:OFFSET 需 ORDER BY;无 incr 时用稳定键兜底
  145. if (string.IsNullOrWhiteSpace(entity.IncrColumn))
  146. orderBy = "(SELECT NULL)";
  147. return $"SELECT * FROM {table}{where} ORDER BY {orderBy} OFFSET {offset} ROWS FETCH NEXT {batchSize} ROWS ONLY";
  148. }
  149. return offset > 0
  150. ? $"SELECT * FROM {table}{where} ORDER BY {orderBy} LIMIT {batchSize} OFFSET {offset}"
  151. : $"SELECT * FROM {table}{where} ORDER BY {orderBy} LIMIT {batchSize}";
  152. }
  153. private static string ResolveSourceRowId(Dictionary<string, object?> dict)
  154. {
  155. foreach (var key in new[]
  156. {
  157. "id", "Id", "ID",
  158. "RecID", "RecId", "recid", "Recid",
  159. "noid", "billno", "BillNo", "djbh", "Djbh"
  160. })
  161. {
  162. if (dict.TryGetValue(key, out var v) && v != null)
  163. return v.ToString() ?? Guid.NewGuid().ToString("N");
  164. }
  165. return Guid.NewGuid().ToString("N");
  166. }
  167. private async Task WriteSyncLogAsync(MdpSource source, MdpEntity entity, MdpPullContext ctx, DateTime startedAt, int pulled, int written, string? error)
  168. {
  169. try
  170. {
  171. var endedAt = DateTime.Now;
  172. var syncType = ctx.FullRefresh || string.Equals(ctx.SyncWindowType, "FULL", StringComparison.OrdinalIgnoreCase)
  173. ? "FULL"
  174. : "INCR";
  175. await _db.Ado.ExecuteCommandAsync(@"
  176. INSERT INTO mdp_sync_log
  177. (tenant_id, entity_id, source_code, entity_name, sync_batch_id, sync_type, trigger_type,
  178. sync_start, sync_end, duration_ms, rows_read, rows_written, status, error_message)
  179. VALUES
  180. (@tenantId, @entityId, @sourceCode, @entityName, @batchId, @syncType, 'AUTO',
  181. @syncStart, @syncEnd, @durationMs, @rowsRead, @rowsWritten, @status, @error)",
  182. new SugarParameter("@tenantId", ctx.TenantId),
  183. new SugarParameter("@entityId", entity.Id),
  184. new SugarParameter("@sourceCode", source.SourceCode),
  185. new SugarParameter("@entityName", string.IsNullOrWhiteSpace(entity.EntityName) ? entity.EntityCode : entity.EntityName),
  186. new SugarParameter("@batchId", ctx.BatchId),
  187. new SugarParameter("@syncType", syncType),
  188. new SugarParameter("@syncStart", startedAt),
  189. new SugarParameter("@syncEnd", endedAt),
  190. new SugarParameter("@durationMs", (int)(endedAt - startedAt).TotalMilliseconds),
  191. new SugarParameter("@rowsRead", pulled),
  192. new SugarParameter("@rowsWritten", written),
  193. new SugarParameter("@status", string.IsNullOrEmpty(error) ? "SUCCESS" : "FAILED"),
  194. new SugarParameter("@error", error));
  195. }
  196. catch
  197. {
  198. // 日志表结构可能与种子不一致时不阻断主流程
  199. }
  200. }
  201. private sealed class MysqlLiteralDateTimeConverter : JsonConverter<DateTime>
  202. {
  203. public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
  204. => reader.GetDateTime();
  205. public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
  206. => writer.WriteStringValue(value.ToString("yyyy-MM-dd HH:mm:ss.ffffff", CultureInfo.InvariantCulture));
  207. }
  208. private sealed class MysqlLiteralDateTimeOffsetConverter : JsonConverter<DateTimeOffset>
  209. {
  210. public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
  211. => reader.GetDateTimeOffset();
  212. public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
  213. => writer.WriteStringValue(value.ToString("yyyy-MM-dd HH:mm:ss.ffffff", CultureInfo.InvariantCulture));
  214. }
  215. }