MdpDbPullExecutor.cs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. using System.Data;
  2. using System.Text.Json;
  3. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  4. using SqlSugar;
  5. namespace Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  6. /// <summary>
  7. /// 方式甲:从 mdp_source DB 源按 mdp_entity.source_table_name 增量抽数 → target_table_name(贴源)。
  8. /// </summary>
  9. public sealed class MdpDbPullExecutor : IMdpSourcePullExecutor, ITransient
  10. {
  11. public string SupportedType => "DB_SYNC";
  12. private readonly MdpSourceScopeFactory _scopeFactory;
  13. private readonly ISqlSugarClient _db;
  14. private readonly MdpStagingWriter _writer;
  15. public MdpDbPullExecutor(MdpSourceScopeFactory scopeFactory, ISqlSugarClient db, MdpStagingWriter writer)
  16. {
  17. _scopeFactory = scopeFactory;
  18. _db = db;
  19. _writer = writer;
  20. }
  21. public async Task<MdpPullResult> PullAsync(MdpSource source, MdpEntity entity, MdpPullContext ctx, CancellationToken cancellationToken = default)
  22. {
  23. if (string.IsNullOrWhiteSpace(entity.SourceTableName))
  24. throw new InvalidOperationException($"实体 {entity.EntityCode} 未配置 source_table_name");
  25. if (string.IsNullOrWhiteSpace(entity.TargetTableName))
  26. throw new InvalidOperationException($"实体 {entity.EntityCode} 未配置 target_table_name");
  27. var scope = await _scopeFactory.GetScopeAsync(source.SourceCode, cancellationToken);
  28. var batchSize = entity.BatchSize > 0 ? entity.BatchSize : 1000;
  29. var isSqlServer = string.Equals(source.DbType, "SQLSERVER", StringComparison.OrdinalIgnoreCase)
  30. || string.Equals(source.DbType, "MSSQL", StringComparison.OrdinalIgnoreCase);
  31. var sql = BuildSelectSql(entity, isSqlServer, batchSize, ctx);
  32. var parameters = new List<SugarParameter>();
  33. if (ctx.WindowFrom.HasValue)
  34. parameters.Add(new SugarParameter("@windowFrom", ctx.WindowFrom.Value));
  35. if (!ctx.FullRefresh
  36. && !string.Equals(ctx.SyncWindowType, "FULL", StringComparison.OrdinalIgnoreCase)
  37. && !string.IsNullOrWhiteSpace(entity.IncrColumn)
  38. && !string.IsNullOrWhiteSpace(entity.LastCursor)
  39. && !string.Equals(ctx.SyncWindowType, "ROLLING", StringComparison.OrdinalIgnoreCase))
  40. parameters.Add(new SugarParameter("@cursor", entity.LastCursor));
  41. // ROLLING:用窗口下界;若已有水位且水位晚于下界,则进一步用水位收窄
  42. if (!ctx.FullRefresh
  43. && string.Equals(ctx.SyncWindowType, "ROLLING", StringComparison.OrdinalIgnoreCase)
  44. && !string.IsNullOrWhiteSpace(entity.IncrColumn)
  45. && !string.IsNullOrWhiteSpace(entity.LastCursor)
  46. && ctx.WindowFrom.HasValue
  47. && DateTime.TryParse(entity.LastCursor, out var cursorDt)
  48. && cursorDt > ctx.WindowFrom.Value)
  49. parameters.Add(new SugarParameter("@cursor", entity.LastCursor));
  50. var table = await scope.Ado.GetDataTableAsync(sql, parameters);
  51. cancellationToken.ThrowIfCancellationRequested();
  52. var written = 0;
  53. string? maxCursor = entity.LastCursor;
  54. var now = DateTime.Now;
  55. foreach (DataRow row in table.Rows)
  56. {
  57. cancellationToken.ThrowIfCancellationRequested();
  58. var dict = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  59. foreach (DataColumn col in table.Columns)
  60. dict[col.ColumnName] = row[col] == DBNull.Value ? null : row[col];
  61. var sourceRowId = ResolveSourceRowId(dict);
  62. var rawJson = JsonSerializer.Serialize(dict);
  63. if (!string.IsNullOrWhiteSpace(entity.IncrColumn) && dict.TryGetValue(entity.IncrColumn, out var incrVal) && incrVal != null)
  64. {
  65. var cursor = incrVal is DateTime dt ? dt.ToString("yyyy-MM-dd HH:mm:ss.fff") : incrVal.ToString();
  66. if (!string.IsNullOrEmpty(cursor) && (maxCursor == null || string.CompareOrdinal(cursor, maxCursor) > 0))
  67. maxCursor = cursor;
  68. }
  69. written += await _writer.UpsertAsync(
  70. source, entity, entity.SourceTableName!, dict, rawJson, sourceRowId, ctx);
  71. }
  72. if (!string.IsNullOrEmpty(maxCursor) && maxCursor != entity.LastCursor)
  73. {
  74. await _db.Updateable<MdpEntity>()
  75. .SetColumns(x => new MdpEntity
  76. {
  77. LastCursor = maxCursor,
  78. LastSyncTo = now,
  79. UpdateTime = now
  80. })
  81. .Where(x => x.Id == entity.Id)
  82. .ExecuteCommandAsync(cancellationToken);
  83. }
  84. await WriteSyncLogAsync(entity, ctx, table.Rows.Count, written, null);
  85. var windowHint = ctx.SyncWindowType ?? (ctx.FullRefresh ? "FULL" : "INCR");
  86. return new MdpPullResult
  87. {
  88. RowsPulled = table.Rows.Count,
  89. RowsWritten = written,
  90. NewCursor = maxCursor,
  91. Message = $"OK window={windowHint}" + (ctx.WindowFrom.HasValue ? $" from={ctx.WindowFrom:yyyy-MM-dd HH:mm:ss}" : "")
  92. };
  93. }
  94. private static string BuildSelectSql(MdpEntity entity, bool isSqlServer, int batchSize, MdpPullContext ctx)
  95. {
  96. var table = entity.SourceTableName!.Trim();
  97. // 仅允许简单标识符/schema.table,防注入
  98. if (!System.Text.RegularExpressions.Regex.IsMatch(table, @"^[A-Za-z0-9_\.\[\]]+$"))
  99. throw new InvalidOperationException($"非法 source_table_name:{table}");
  100. var predicates = new List<string>();
  101. string orderBy;
  102. if (!string.IsNullOrWhiteSpace(entity.IncrColumn))
  103. {
  104. var incr = entity.IncrColumn.Trim();
  105. if (!System.Text.RegularExpressions.Regex.IsMatch(incr, @"^[A-Za-z0-9_]+$"))
  106. throw new InvalidOperationException($"非法 incr_column:{incr}");
  107. var incrExpr = isSqlServer ? incr : $"`{incr}`";
  108. orderBy = incrExpr;
  109. var isRolling = string.Equals(ctx.SyncWindowType, "ROLLING", StringComparison.OrdinalIgnoreCase);
  110. if (!ctx.FullRefresh && isRolling && ctx.WindowFrom.HasValue)
  111. predicates.Add($"{incrExpr} >= @windowFrom");
  112. var useCursor = !ctx.FullRefresh
  113. && !string.IsNullOrWhiteSpace(entity.LastCursor)
  114. && (!isRolling
  115. || (ctx.WindowFrom.HasValue
  116. && DateTime.TryParse(entity.LastCursor, out var cursorDt)
  117. && cursorDt > ctx.WindowFrom.Value));
  118. if (useCursor)
  119. predicates.Add($"{incrExpr} > @cursor");
  120. }
  121. else
  122. {
  123. orderBy = isSqlServer ? "(SELECT NULL)" : "1";
  124. }
  125. var where = predicates.Count > 0 ? " WHERE " + string.Join(" AND ", predicates) : "";
  126. var offset = ctx.Offset > 0 ? ctx.Offset : 0;
  127. if (isSqlServer)
  128. {
  129. // SQL Server:OFFSET 需 ORDER BY;无 incr 时用稳定键兜底
  130. if (string.IsNullOrWhiteSpace(entity.IncrColumn))
  131. orderBy = "(SELECT NULL)";
  132. return $"SELECT * FROM {table}{where} ORDER BY {orderBy} OFFSET {offset} ROWS FETCH NEXT {batchSize} ROWS ONLY";
  133. }
  134. return offset > 0
  135. ? $"SELECT * FROM {table}{where} ORDER BY {orderBy} LIMIT {batchSize} OFFSET {offset}"
  136. : $"SELECT * FROM {table}{where} ORDER BY {orderBy} LIMIT {batchSize}";
  137. }
  138. private static string ResolveSourceRowId(Dictionary<string, object?> dict)
  139. {
  140. foreach (var key in new[]
  141. {
  142. "id", "Id", "ID",
  143. "RecID", "RecId", "recid", "Recid",
  144. "noid", "billno", "BillNo", "djbh", "Djbh"
  145. })
  146. {
  147. if (dict.TryGetValue(key, out var v) && v != null)
  148. return v.ToString() ?? Guid.NewGuid().ToString("N");
  149. }
  150. return Guid.NewGuid().ToString("N");
  151. }
  152. private async Task WriteSyncLogAsync(MdpEntity entity, MdpPullContext ctx, int pulled, int written, string? error)
  153. {
  154. try
  155. {
  156. await _db.Ado.ExecuteCommandAsync(@"
  157. INSERT INTO mdp_sync_log
  158. (tenant_id, entity_id, entity_code, sync_batch_id, status, source_rows, target_rows, error_message, create_time, update_time)
  159. VALUES
  160. (@tenantId, @entityId, @entityCode, @batchId, @status, @sourceRows, @targetRows, @error, @now, @now)",
  161. new SugarParameter("@tenantId", ctx.TenantId),
  162. new SugarParameter("@entityId", entity.Id),
  163. new SugarParameter("@entityCode", entity.EntityCode),
  164. new SugarParameter("@batchId", ctx.BatchId),
  165. new SugarParameter("@status", string.IsNullOrEmpty(error) ? "SUCCESS" : "FAILED"),
  166. new SugarParameter("@sourceRows", pulled),
  167. new SugarParameter("@targetRows", written),
  168. new SugarParameter("@error", error),
  169. new SugarParameter("@now", DateTime.Now));
  170. }
  171. catch
  172. {
  173. // 日志表结构可能与种子不一致时不阻断主流程
  174. }
  175. }
  176. }