MdpDbPullExecutor.cs 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704
  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. private readonly MdpPullFieldMappingService _fieldMappings;
  30. public MdpDbPullExecutor(
  31. MdpSourceScopeFactory scopeFactory,
  32. ISqlSugarClient db,
  33. MdpStagingWriter writer,
  34. MdpPullFieldMappingService fieldMappings)
  35. {
  36. _scopeFactory = scopeFactory;
  37. _db = db;
  38. _writer = writer;
  39. _fieldMappings = fieldMappings;
  40. }
  41. public async Task<MdpPullResult> PullAsync(MdpSource source, MdpEntity entity, MdpPullContext ctx, CancellationToken cancellationToken = default)
  42. {
  43. if (string.IsNullOrWhiteSpace(entity.SourceTableName))
  44. throw new InvalidOperationException($"实体 {entity.EntityCode} 未配置 source_table_name");
  45. if (string.IsNullOrWhiteSpace(entity.TargetTableName))
  46. throw new InvalidOperationException($"实体 {entity.EntityCode} 未配置 target_table_name");
  47. var startedAt = DateTime.Now;
  48. var scope = await _scopeFactory.GetScopeAsync(source, cancellationToken);
  49. var batchSize = entity.BatchSize > 0 ? entity.BatchSize : 1000;
  50. var isSqlServer = string.Equals(source.DbType, "SQLSERVER", StringComparison.OrdinalIgnoreCase)
  51. || string.Equals(source.DbType, "MSSQL", StringComparison.OrdinalIgnoreCase);
  52. var useKeyset = ctx.UseKeysetCursor
  53. && !string.IsNullOrWhiteSpace(ctx.CursorColumn)
  54. && !string.IsNullOrWhiteSpace(ctx.TieBreakerColumn);
  55. string? tenantCol = null;
  56. string? factoryCol = null;
  57. if (ctx.TenantId > 0)
  58. {
  59. var cols = await TryGetSourceColumnsAsync(scope, entity.SourceTableName!, isSqlServer, cancellationToken);
  60. tenantCol = FindColumn(cols, "tenant_id", "TenantId", "TenantID");
  61. if (ctx.FactoryId > 0)
  62. factoryCol = FindColumn(cols, "factory_id", "FactoryId");
  63. }
  64. var sql = useKeyset
  65. ? BuildKeysetSelectSql(entity, isSqlServer, batchSize, ctx, tenantCol, factoryCol)
  66. : BuildSelectSql(entity, isSqlServer, batchSize, ctx, tenantCol, factoryCol);
  67. var parameters = new List<SugarParameter>();
  68. if (NeedsTenantParameter(tenantCol, factoryCol))
  69. parameters.Add(new SugarParameter("@scopeTenantId", ctx.TenantId));
  70. if (factoryCol != null)
  71. parameters.Add(new SugarParameter("@scopeFactoryId", ctx.FactoryId));
  72. if (useKeyset)
  73. {
  74. AddKeysetParameters(parameters, ctx);
  75. }
  76. else
  77. {
  78. if (ctx.WindowFrom.HasValue)
  79. parameters.Add(new SugarParameter("@windowFrom", ctx.WindowFrom.Value));
  80. if (!ctx.FullRefresh
  81. && !string.Equals(ctx.SyncWindowType, "FULL", StringComparison.OrdinalIgnoreCase)
  82. && !string.IsNullOrWhiteSpace(entity.IncrColumn)
  83. && !string.IsNullOrWhiteSpace(entity.LastCursor)
  84. && !string.Equals(ctx.SyncWindowType, "ROLLING", StringComparison.OrdinalIgnoreCase))
  85. parameters.Add(new SugarParameter("@cursor", entity.LastCursor));
  86. // ROLLING:用窗口下界;若已有水位且水位晚于下界,则进一步用水位收窄
  87. if (!ctx.FullRefresh
  88. && string.Equals(ctx.SyncWindowType, "ROLLING", StringComparison.OrdinalIgnoreCase)
  89. && !string.IsNullOrWhiteSpace(entity.IncrColumn)
  90. && !string.IsNullOrWhiteSpace(entity.LastCursor)
  91. && ctx.WindowFrom.HasValue
  92. && DateTime.TryParse(entity.LastCursor, out var cursorDt)
  93. && cursorDt > ctx.WindowFrom.Value)
  94. parameters.Add(new SugarParameter("@cursor", entity.LastCursor));
  95. }
  96. var table = await scope.Ado.GetDataTableAsync(sql, parameters);
  97. cancellationToken.ThrowIfCancellationRequested();
  98. var written = 0;
  99. var rowErrors = 0;
  100. var errorSamples = new List<string>();
  101. string? maxCursor = entity.LastCursor;
  102. var now = DateTime.Now;
  103. string? lastKeysetCursor = null;
  104. string? lastKeysetTie = null;
  105. var pending = new List<(IDictionary<string, object?> Row, string RawJson, string SourceRowId)>();
  106. var mappings = await _fieldMappings.GetAsync(entity.Id, cancellationToken);
  107. foreach (DataRow row in table.Rows)
  108. {
  109. cancellationToken.ThrowIfCancellationRequested();
  110. var dict = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  111. foreach (DataColumn col in table.Columns)
  112. dict[col.ColumnName] = row[col] == DBNull.Value ? null : row[col];
  113. var sourceRowId = ResolveSourceRowId(dict);
  114. var mapped = MdpDbSyncRules.Apply(dict, mappings);
  115. if (mapped.Errors.Count > 0)
  116. {
  117. rowErrors++;
  118. if (errorSamples.Count < 20)
  119. errorSamples.Add(MdpDbSyncRules.Redact(string.Join(";", mapped.Errors)));
  120. continue;
  121. }
  122. var rawJson = JsonSerializer.Serialize(mapped.Values, RawDataJsonOptions);
  123. // 游标推进必须留在逐行循环里:它依赖遍历顺序,挪到批量写入之后会算错。
  124. if (useKeyset)
  125. {
  126. lastKeysetCursor = FormatCursorValue(dict, ctx.CursorColumn!);
  127. lastKeysetTie = FormatCursorValue(dict, ctx.TieBreakerColumn!);
  128. if (!string.IsNullOrEmpty(lastKeysetTie))
  129. maxCursor = EncodeKeysetCursor(lastKeysetCursor, lastKeysetTie);
  130. }
  131. else if (!string.IsNullOrWhiteSpace(entity.IncrColumn) && dict.TryGetValue(entity.IncrColumn, out var incrVal) && incrVal != null)
  132. {
  133. var cursor = incrVal is DateTime dt ? dt.ToString("yyyy-MM-dd HH:mm:ss.fff") : incrVal.ToString();
  134. if (!string.IsNullOrEmpty(cursor) && (maxCursor == null || string.CompareOrdinal(cursor, maxCursor) > 0))
  135. maxCursor = cursor;
  136. }
  137. pending.Add((mapped.Values, rawJson, sourceRowId));
  138. }
  139. if (pending.Count > 0)
  140. {
  141. var results = await _writer.UpsertBatchAsync(
  142. source,
  143. entity,
  144. ResolveTransformSourceTable(entity),
  145. pending,
  146. ctx);
  147. written += results.Sum();
  148. }
  149. if (useKeyset && !string.IsNullOrEmpty(lastKeysetTie))
  150. {
  151. ctx.CursorValue = lastKeysetCursor;
  152. ctx.TieBreakerValue = lastKeysetTie;
  153. }
  154. if (!ctx.DeferCursorPersist
  155. && !string.IsNullOrEmpty(maxCursor)
  156. && maxCursor != entity.LastCursor)
  157. {
  158. await _db.Updateable<MdpEntity>()
  159. .SetColumns(x => new MdpEntity
  160. {
  161. LastCursor = maxCursor,
  162. LastSyncTo = now,
  163. UpdateTime = now
  164. })
  165. .Where(x => x.Id == entity.Id)
  166. .ExecuteCommandAsync(cancellationToken);
  167. }
  168. // 零行假成功守卫:本页 0 行时,用同谓词、去作用域的探针确认源侧是否本就无数据。
  169. // 源侧有数据而作用域后 0 行 ⇒ 全量被租户/工厂作用域过滤掉,不得记为无保留的 SUCCESS。
  170. var scopeMismatch = IsScopeMismatch(
  171. table.Rows.Count, ctx, tenantCol, factoryCol,
  172. sourceHasRowsOutsideScope: ShouldProbeScope(table.Rows.Count, ctx, tenantCol, factoryCol)
  173. && await SourceHasRowsOutsideScopeAsync(scope, entity, ctx, isSqlServer, useKeyset, parameters, tenantCol, cancellationToken));
  174. var diagnostic = scopeMismatch
  175. ? $"{ScopeMismatchCode}: 源表 {entity.SourceTableName} 在 tenantId={ctx.TenantId}/factoryId={ctx.FactoryId} 作用域外仍有数据,"
  176. + "但作用域过滤后 0 行;请核对该实体的工厂口径(factory_id 是否被写成 tenant_id 或真实工厂号与作用域不一致)。"
  177. : null;
  178. if (rowErrors > 0)
  179. {
  180. var sampleText = errorSamples.Count == 0 ? "" : " 样本:" + string.Join(" | ", errorSamples);
  181. var rowText = $"行错误 {rowErrors}{sampleText}";
  182. diagnostic = string.IsNullOrEmpty(diagnostic) ? rowText : diagnostic + ";" + rowText;
  183. }
  184. var status = rowErrors > 0 && written == 0
  185. ? "FAILED"
  186. : scopeMismatch || rowErrors > 0
  187. ? SyncStatusPartial
  188. : SyncStatusSuccess;
  189. await WriteSyncLogAsync(source, entity, ctx, startedAt, table.Rows.Count, written, rowErrors, diagnostic, status);
  190. var windowHint = useKeyset
  191. ? (ctx.NullTimePhase ? "KEYSET_NULL" : "KEYSET")
  192. : (ctx.SyncWindowType ?? (ctx.FullRefresh ? "FULL" : "INCR"));
  193. return new MdpPullResult
  194. {
  195. RowsPulled = table.Rows.Count,
  196. RowsWritten = written,
  197. RowsError = rowErrors,
  198. Status = status,
  199. NewCursor = maxCursor,
  200. Message = (scopeMismatch ? $"{ScopeMismatchCode} window=" : "OK window=") + windowHint
  201. + (ctx.WindowFrom.HasValue ? $" from={ctx.WindowFrom:yyyy-MM-dd HH:mm:ss}" : "")
  202. + (ctx.BootstrapFrom.HasValue ? $" bootstrapFrom={ctx.BootstrapFrom:yyyy-MM-dd HH:mm:ss}" : "")
  203. };
  204. }
  205. internal const string SyncStatusSuccess = "SUCCESS";
  206. /// <summary>mdp_sync_log.status 既有 enum 成员('RUNNING','SUCCESS','PARTIAL','FAILED'),无需改表。</summary>
  207. internal const string SyncStatusPartial = "PARTIAL";
  208. internal const string ScopeMismatchCode = "SCOPE_MISMATCH";
  209. /// <summary>
  210. /// 是否需要跑作用域探针。仅在 ①本页 0 行 ②确实发射了作用域谓词 ③非 OFFSET 续页 时才探。
  211. /// 第 ③ 条:ctx.Offset&gt;0 说明本轮前一页已经拉满了作用域内的数据,作用域显然没有过滤掉全部,
  212. /// 此时的 0 行是正常翻页到底,探针(不带 OFFSET)会误报。
  213. /// </summary>
  214. internal static bool ShouldProbeScope(int rowsRead, MdpPullContext ctx, string? tenantCol, string? factoryCol)
  215. => rowsRead == 0 && ctx.Offset <= 0 && NeedsTenantParameter(tenantCol, factoryCol);
  216. /// <summary>
  217. /// 最终判定:本页 0 行且探针证明"去掉作用域后源侧仍有数据" ⇒ 全量被作用域过滤,记 PARTIAL。
  218. /// 源侧本就无数据(合法空源 / 增量无新数据)时 <paramref name="sourceHasRowsOutsideScope"/> 为 false ⇒ 正常 SUCCESS。
  219. /// </summary>
  220. internal static bool IsScopeMismatch(
  221. int rowsRead, MdpPullContext ctx, string? tenantCol, string? factoryCol, bool sourceHasRowsOutsideScope)
  222. => ShouldProbeScope(rowsRead, ctx, tenantCol, factoryCol) && sourceHasRowsOutsideScope;
  223. private static async Task<bool> SourceHasRowsOutsideScopeAsync(
  224. ISqlSugarClient scope, MdpEntity entity, MdpPullContext ctx,
  225. bool isSqlServer, bool useKeyset, List<SugarParameter> parameters,
  226. string? tenantCol, CancellationToken cancellationToken)
  227. {
  228. cancellationToken.ThrowIfCancellationRequested();
  229. try
  230. {
  231. var keepTenant = !string.IsNullOrWhiteSpace(tenantCol);
  232. var probeSql = BuildScopeProbeSql(entity, isSqlServer, ctx, useKeyset, tenantCol);
  233. var probe = await scope.Ado.GetDataTableAsync(probeSql, StripScopeParameters(parameters, keepTenant));
  234. return probe.Rows.Count > 0;
  235. }
  236. catch
  237. {
  238. // 探针纯诊断用途,任何失败都不得影响主流程,按"无异常"处理。
  239. return false;
  240. }
  241. }
  242. /// <summary>
  243. /// 探针复用正式查询的参数,剔除探针 SQL 不再引用的作用域参数。
  244. /// <para><paramref name="keepTenantParameter"/> 必须与
  245. /// <see cref="BuildScopeProbeSql"/> 是否发射了租户谓词保持一致:
  246. /// 探针带租户谓词时要留下 <c>@scopeTenantId</c>,否则参数会缺失;
  247. /// 不带时要剔除,否则会多绑一个 SQL 里不存在的参数。</para>
  248. /// <para><c>@scopeFactoryId</c> 恒剔除 —— 探针的全部意义就是"去掉工厂口径再看一眼"。</para>
  249. /// </summary>
  250. internal static List<SugarParameter> StripScopeParameters(
  251. List<SugarParameter> parameters, bool keepTenantParameter = false)
  252. => parameters
  253. .Where(p => !IsStrippedScopeParameter(p.ParameterName, keepTenantParameter))
  254. .ToList();
  255. private static bool IsStrippedScopeParameter(string? name, bool keepTenantParameter)
  256. {
  257. var n = (name ?? "").TrimStart('@', ':', '?');
  258. if (string.Equals(n, "scopeFactoryId", StringComparison.OrdinalIgnoreCase)) return true;
  259. return !keepTenantParameter
  260. && string.Equals(n, "scopeTenantId", StringComparison.OrdinalIgnoreCase);
  261. }
  262. private static string RequireSourceTable(MdpEntity entity)
  263. {
  264. var table = entity.SourceTableName!.Trim();
  265. // 仅允许简单标识符/schema.table,防注入
  266. if (!System.Text.RegularExpressions.Regex.IsMatch(table, @"^[A-Za-z0-9_\.\[\]]+$"))
  267. throw new InvalidOperationException($"非法 source_table_name:{table}");
  268. return table;
  269. }
  270. internal static string ResolveTransformSourceTable(MdpEntity entity)
  271. {
  272. var logical = string.IsNullOrWhiteSpace(entity.TransformSourceTable)
  273. ? entity.SourceTableName
  274. : entity.TransformSourceTable;
  275. var value = (logical ?? "").Trim();
  276. if (!System.Text.RegularExpressions.Regex.IsMatch(value, @"^[A-Za-z0-9_\.\[\]]+$"))
  277. throw new InvalidOperationException($"非法 transform_source_table:{logical}");
  278. return value.Contains('.') ? value.Split('.').Last().Trim('[', ']') : value.Trim('[', ']');
  279. }
  280. /// <summary>增量/窗口谓词(不含租户/工厂作用域),供正式查询与作用域探针复用。</summary>
  281. private static List<string> BuildIncrementalPredicates(MdpEntity entity, bool isSqlServer, MdpPullContext ctx, out string orderBy)
  282. {
  283. var predicates = new List<string>();
  284. if (!string.IsNullOrWhiteSpace(entity.IncrColumn))
  285. {
  286. var incr = entity.IncrColumn.Trim();
  287. if (!System.Text.RegularExpressions.Regex.IsMatch(incr, @"^[A-Za-z0-9_]+$"))
  288. throw new InvalidOperationException($"非法 incr_column:{incr}");
  289. var incrExpr = isSqlServer ? incr : $"`{incr}`";
  290. orderBy = incrExpr;
  291. var isRolling = string.Equals(ctx.SyncWindowType, "ROLLING", StringComparison.OrdinalIgnoreCase);
  292. if (!ctx.FullRefresh && isRolling && ctx.WindowFrom.HasValue)
  293. predicates.Add($"{incrExpr} >= @windowFrom");
  294. var useCursor = !ctx.FullRefresh
  295. && !string.IsNullOrWhiteSpace(entity.LastCursor)
  296. && (!isRolling
  297. || (ctx.WindowFrom.HasValue
  298. && DateTime.TryParse(entity.LastCursor, out var cursorDt)
  299. && cursorDt > ctx.WindowFrom.Value));
  300. if (useCursor)
  301. predicates.Add($"{incrExpr} > @cursor");
  302. }
  303. else
  304. {
  305. orderBy = StableOrder(entity, isSqlServer);
  306. }
  307. return predicates;
  308. }
  309. internal static string BuildSelectSql(MdpEntity entity, bool isSqlServer, int batchSize, MdpPullContext ctx, string? tenantCol = null, string? factoryCol = null)
  310. {
  311. var table = RequireSourceTable(entity);
  312. var predicates = BuildIncrementalPredicates(entity, isSqlServer, ctx, out var orderBy);
  313. AppendScopePredicates(predicates, isSqlServer, tenantCol, factoryCol);
  314. var where = predicates.Count > 0 ? " WHERE " + string.Join(" AND ", predicates) : "";
  315. var offset = ctx.Offset > 0 ? ctx.Offset : 0;
  316. if (ctx.FullRefresh && !string.IsNullOrWhiteSpace(entity.TieBreakerColumn))
  317. orderBy = QuoteIdent(RequireIdent(entity.TieBreakerColumn, "TieBreakerColumn"), isSqlServer);
  318. if (isSqlServer)
  319. {
  320. if (string.IsNullOrWhiteSpace(orderBy))
  321. orderBy = StableOrder(entity, true);
  322. return $"SELECT * FROM {table}{where} ORDER BY {orderBy} OFFSET {offset} ROWS FETCH NEXT {batchSize} ROWS ONLY";
  323. }
  324. return offset > 0
  325. ? $"SELECT * FROM {table}{where} ORDER BY {orderBy} LIMIT {batchSize} OFFSET {offset}"
  326. : $"SELECT * FROM {table}{where} ORDER BY {orderBy} LIMIT {batchSize}";
  327. }
  328. /// <summary>keyset 谓词(不含租户/工厂作用域),供正式查询与作用域探针复用。</summary>
  329. internal static List<string> BuildKeysetPredicates(bool isSqlServer, MdpPullContext ctx, out string orderBy)
  330. {
  331. var cursorCol = RequireIdent(ctx.CursorColumn, "CursorColumn");
  332. var tieCol = RequireIdent(ctx.TieBreakerColumn, "TieBreakerColumn");
  333. var cursorExpr = isSqlServer ? cursorCol : $"`{cursorCol}`";
  334. var tieExpr = isSqlServer ? tieCol : $"`{tieCol}`";
  335. var predicates = new List<string>();
  336. if (ctx.NullTimePhase)
  337. {
  338. predicates.Add($"{cursorExpr} IS NULL");
  339. if (!string.IsNullOrWhiteSpace(ctx.TieBreakerValue))
  340. predicates.Add($"{tieExpr} > @tieBreaker");
  341. }
  342. else
  343. {
  344. predicates.Add($"{cursorExpr} IS NOT NULL");
  345. if (ctx.BootstrapFrom.HasValue)
  346. predicates.Add($"{cursorExpr} >= @bootstrapFrom");
  347. // keyset lower bound;旧单值水位首次升级时按主游标包含重读
  348. predicates.Add(ctx.LegacyCursorInclusive
  349. ? $"""
  350. (
  351. @cursorTime IS NULL
  352. OR {cursorExpr} >= @cursorTime
  353. )
  354. """
  355. : $"""
  356. (
  357. @cursorTime IS NULL
  358. OR {cursorExpr} > @cursorTime
  359. OR ({cursorExpr} = @cursorTime AND {tieExpr} > @tieBreaker)
  360. )
  361. """);
  362. if (!string.IsNullOrWhiteSpace(ctx.UpperCursorValue))
  363. {
  364. predicates.Add($"""
  365. (
  366. {cursorExpr} < @upperTime
  367. OR ({cursorExpr} = @upperTime AND {tieExpr} <= @upperTie)
  368. )
  369. """);
  370. }
  371. }
  372. orderBy = ctx.NullTimePhase ? tieExpr : $"{cursorExpr}, {tieExpr}";
  373. return predicates;
  374. }
  375. private static string BuildKeysetSelectSql(MdpEntity entity, bool isSqlServer, int batchSize, MdpPullContext ctx, string? tenantCol = null, string? factoryCol = null)
  376. {
  377. var table = RequireSourceTable(entity);
  378. var predicates = BuildKeysetPredicates(isSqlServer, ctx, out var orderBy);
  379. AppendScopePredicates(predicates, isSqlServer, tenantCol, factoryCol);
  380. var where = " WHERE " + string.Join(" AND ", predicates);
  381. if (isSqlServer)
  382. return $"SELECT * FROM {table}{where} ORDER BY {orderBy} OFFSET 0 ROWS FETCH NEXT {batchSize} ROWS ONLY";
  383. return $"SELECT * FROM {table}{where} ORDER BY {orderBy} LIMIT {batchSize}";
  384. }
  385. /// <summary>
  386. /// 作用域探针:与正式查询同一组增量/窗口/keyset 谓词,**保留租户谓词、只去掉工厂谓词**。
  387. /// 正式查询 0 行而探针有行 ⇒ 差异只可能来自工厂口径,可据此判定"本租户的数据被工厂过滤掉了"。
  388. /// 只做存在性判断(TOP 1 / LIMIT 1),不取数据。
  389. ///
  390. /// <para><b>租户谓词必须保留</b>。此前两个作用域谓词一起剥掉,探针退化成
  391. /// <c>SELECT 1 FROM 源表 LIMIT 1</c> —— 在多租户共享同一张源表的模型下,
  392. /// 它对**任何**租户都会命中别的租户的行,于是每一个合法空租户都被误报成 SCOPE_MISMATCH。
  393. /// 实测该误报在共享库上累计 291 条,且全部落在源表本就 0 行的租户上,
  394. /// 而真正被工厂谓词滤空的那两个租户反而一条都没报出来 —— 信号方向是反的。</para>
  395. ///
  396. /// <para>探针要回答的是「同一租户内,是否因工厂口径而取空」,
  397. /// 不是「整张表有没有数据」。丢掉租户维度,这个问题就问不出来了。</para>
  398. /// </summary>
  399. internal static string BuildScopeProbeSql(
  400. MdpEntity entity, bool isSqlServer, MdpPullContext ctx, bool useKeyset, string? tenantCol = null)
  401. {
  402. var table = RequireSourceTable(entity);
  403. var predicates = useKeyset
  404. ? BuildKeysetPredicates(isSqlServer, ctx, out _)
  405. : BuildIncrementalPredicates(entity, isSqlServer, ctx, out _);
  406. if (!string.IsNullOrWhiteSpace(tenantCol))
  407. predicates.Add($"{QuoteIdent(tenantCol, isSqlServer)} = @scopeTenantId");
  408. var where = predicates.Count > 0 ? " WHERE " + string.Join(" AND ", predicates) : "";
  409. return isSqlServer
  410. ? $"SELECT TOP 1 1 AS probe FROM {table}{where}"
  411. : $"SELECT 1 AS probe FROM {table}{where} LIMIT 1";
  412. }
  413. private static void AddKeysetParameters(List<SugarParameter> parameters, MdpPullContext ctx)
  414. {
  415. if (ctx.BootstrapFrom.HasValue)
  416. parameters.Add(new SugarParameter("@bootstrapFrom", ctx.BootstrapFrom.Value));
  417. if (ctx.NullTimePhase)
  418. {
  419. parameters.Add(new SugarParameter("@tieBreaker",
  420. string.IsNullOrWhiteSpace(ctx.TieBreakerValue) ? "0" : ctx.TieBreakerValue));
  421. return;
  422. }
  423. object cursorTime = string.IsNullOrWhiteSpace(ctx.CursorValue)
  424. ? DBNull.Value
  425. : (object)ctx.CursorValue!;
  426. parameters.Add(new SugarParameter("@cursorTime", cursorTime));
  427. parameters.Add(new SugarParameter("@tieBreaker",
  428. string.IsNullOrWhiteSpace(ctx.TieBreakerValue) ? "0" : ctx.TieBreakerValue));
  429. if (!string.IsNullOrWhiteSpace(ctx.UpperCursorValue))
  430. {
  431. parameters.Add(new SugarParameter("@upperTime", ctx.UpperCursorValue));
  432. parameters.Add(new SugarParameter("@upperTie",
  433. string.IsNullOrWhiteSpace(ctx.UpperTieBreakerValue) ? "0" : ctx.UpperTieBreakerValue));
  434. }
  435. }
  436. /// <summary>
  437. /// 租户/工厂作用域谓词。工厂维度走 <see cref="MdpFactoryScope"/> 的归一语义
  438. /// (factory_id 为 NULL / &lt;=0 / 等于 tenant_id 一律视作"无真实工厂"→默认工厂 1),
  439. /// 与 <see cref="MdpStagingWriter"/> 写入侧完全一致;真实工厂号仍逐值隔离。
  440. /// </summary>
  441. internal static void AppendScopePredicates(List<string> predicates, bool isSqlServer, string? tenantCol, string? factoryCol)
  442. {
  443. if (!string.IsNullOrWhiteSpace(tenantCol))
  444. predicates.Add($"{QuoteIdent(tenantCol, isSqlServer)} = @scopeTenantId");
  445. if (!string.IsNullOrWhiteSpace(factoryCol))
  446. {
  447. predicates.Add(MdpFactoryScope.SqlPredicate(
  448. QuoteIdent(factoryCol, isSqlServer), "@scopeTenantId", "@scopeFactoryId"));
  449. }
  450. }
  451. /// <summary>
  452. /// 工厂谓词引用 @scopeTenantId,故只要发射了任一作用域谓词就必须绑定该参数
  453. /// (源表只有 factory_id 而无 tenant_id 时,旧逻辑会漏绑导致 SQL 参数缺失)。
  454. /// </summary>
  455. internal static bool NeedsTenantParameter(string? tenantCol, string? factoryCol)
  456. => !string.IsNullOrWhiteSpace(tenantCol) || !string.IsNullOrWhiteSpace(factoryCol);
  457. private static string StableOrder(MdpEntity entity, bool isSqlServer)
  458. {
  459. if (!string.IsNullOrWhiteSpace(entity.TieBreakerColumn))
  460. return QuoteIdent(RequireIdent(entity.TieBreakerColumn, "TieBreakerColumn"), isSqlServer);
  461. return isSqlServer ? "(SELECT NULL)" : "1";
  462. }
  463. private static string QuoteIdent(string name, bool isSqlServer) => isSqlServer ? name : $"`{name}`";
  464. private static string? FindColumn(IReadOnlyCollection<string> columns, params string[] names)
  465. {
  466. foreach (var n in names)
  467. {
  468. var hit = columns.FirstOrDefault(c => string.Equals(c, n, StringComparison.OrdinalIgnoreCase));
  469. if (hit != null) return hit;
  470. }
  471. return null;
  472. }
  473. private static async Task<IReadOnlyCollection<string>> TryGetSourceColumnsAsync(
  474. ISqlSugarClient scope, string table, bool isSqlServer, CancellationToken cancellationToken)
  475. {
  476. try
  477. {
  478. var simple = table.Contains('.') ? table.Split('.').Last().Trim('[', ']') : table.Trim('[', ']');
  479. if (!System.Text.RegularExpressions.Regex.IsMatch(simple, @"^[A-Za-z0-9_]+$"))
  480. return Array.Empty<string>();
  481. if (isSqlServer)
  482. {
  483. var rows = await scope.Ado.SqlQueryAsync<string>(
  484. "SELECT name FROM sys.columns WHERE object_id = OBJECT_ID(@t)",
  485. new SugarParameter("@t", simple));
  486. return rows ?? new List<string>();
  487. }
  488. var mysql = await scope.Ado.SqlQueryAsync<string>(
  489. """
  490. SELECT COLUMN_NAME FROM information_schema.COLUMNS
  491. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t
  492. """,
  493. new SugarParameter("@t", simple));
  494. return mysql ?? new List<string>();
  495. }
  496. catch
  497. {
  498. return Array.Empty<string>();
  499. }
  500. }
  501. private static string RequireIdent(string? name, string label)
  502. {
  503. var v = (name ?? "").Trim();
  504. if (!System.Text.RegularExpressions.Regex.IsMatch(v, @"^[A-Za-z0-9_]+$"))
  505. throw new InvalidOperationException($"非法 {label}:{name}");
  506. return v;
  507. }
  508. private static string? FormatCursorValue(Dictionary<string, object?> dict, string column)
  509. {
  510. if (!dict.TryGetValue(column, out var raw) || raw == null || raw is DBNull)
  511. return null;
  512. return raw switch
  513. {
  514. DateTime dt => dt.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture),
  515. DateTimeOffset dto => dto.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture),
  516. _ => raw.ToString()
  517. };
  518. }
  519. internal static string EncodeKeysetCursor(string? cursor, string tieBreaker)
  520. => System.Text.Json.JsonSerializer.Serialize(new KeysetCursorDto
  521. {
  522. Cursor = cursor,
  523. TieBreaker = tieBreaker
  524. });
  525. internal static bool TryDecodeKeysetCursor(string? raw, out string? cursor, out string? tieBreaker)
  526. {
  527. cursor = null;
  528. tieBreaker = null;
  529. if (string.IsNullOrWhiteSpace(raw))
  530. return false;
  531. try
  532. {
  533. var dto = System.Text.Json.JsonSerializer.Deserialize<KeysetCursorDto>(raw);
  534. if (dto == null || string.IsNullOrWhiteSpace(dto.TieBreaker))
  535. return false;
  536. cursor = dto.Cursor;
  537. tieBreaker = dto.TieBreaker;
  538. return true;
  539. }
  540. catch
  541. {
  542. return false;
  543. }
  544. }
  545. private sealed class KeysetCursorDto
  546. {
  547. public string? Cursor { get; set; }
  548. public string? TieBreaker { get; set; }
  549. }
  550. private static string ResolveSourceRowId(Dictionary<string, object?> dict)
  551. {
  552. // InvTransHist 等表常有空字符串 ID,若优先取 ID 会导致 source_row_id="" 唯一键互相覆盖。
  553. // 跳过 null/空白后,再落到 RecID 等真实键。
  554. foreach (var key in new[]
  555. {
  556. "RecID", "RecId", "recid", "Recid",
  557. "id", "Id", "ID",
  558. "noid", "billno", "BillNo", "djbh", "Djbh"
  559. })
  560. {
  561. if (!dict.TryGetValue(key, out var v) || v is null || v is DBNull)
  562. continue;
  563. var s = v.ToString();
  564. if (!string.IsNullOrWhiteSpace(s))
  565. return s;
  566. }
  567. return Guid.NewGuid().ToString("N");
  568. }
  569. private async Task WriteSyncLogAsync(MdpSource source, MdpEntity entity, MdpPullContext ctx, DateTime startedAt, int pulled, int written, int rowsError, string? error, string? status = null)
  570. {
  571. var endedAt = DateTime.Now;
  572. var syncType = ctx.FullRefresh || string.Equals(ctx.SyncWindowType, "FULL", StringComparison.OrdinalIgnoreCase)
  573. ? "FULL"
  574. : "INCR";
  575. var trigger = string.Equals(ctx.TriggerType, "MANUAL", StringComparison.OrdinalIgnoreCase) ? "MANUAL" : "AUTO";
  576. try
  577. {
  578. await _db.Ado.ExecuteCommandAsync(@"
  579. INSERT INTO mdp_sync_log
  580. (tenant_id, entity_id, source_code, entity_name, sync_batch_id, sync_type, trigger_type,
  581. sync_start, sync_end, duration_ms, rows_read, rows_written, rows_error, status, error_message)
  582. VALUES
  583. (@tenantId, @entityId, @sourceCode, @entityName, @batchId, @syncType, @triggerType,
  584. @syncStart, @syncEnd, @durationMs, @rowsRead, @rowsWritten, @rowsError, @status, @error)",
  585. new SugarParameter("@tenantId", ctx.TenantId),
  586. new SugarParameter("@entityId", entity.Id),
  587. new SugarParameter("@sourceCode", source.SourceCode),
  588. new SugarParameter("@entityName", string.IsNullOrWhiteSpace(entity.EntityName) ? entity.EntityCode : entity.EntityName),
  589. new SugarParameter("@batchId", ctx.BatchId),
  590. new SugarParameter("@syncType", syncType),
  591. new SugarParameter("@triggerType", trigger),
  592. new SugarParameter("@syncStart", startedAt),
  593. new SugarParameter("@syncEnd", endedAt),
  594. new SugarParameter("@durationMs", (int)(endedAt - startedAt).TotalMilliseconds),
  595. new SugarParameter("@rowsRead", pulled),
  596. new SugarParameter("@rowsWritten", written),
  597. new SugarParameter("@rowsError", rowsError),
  598. new SugarParameter("@status", ResolveSyncStatus(status, error)),
  599. new SugarParameter("@error", error));
  600. }
  601. catch (Exception ex)
  602. {
  603. throw new InvalidOperationException(
  604. $"实体 {entity.EntityCode} 数据已处理,但同步日志写入失败:{ex.Message}", ex);
  605. }
  606. }
  607. /// <summary>
  608. /// 显式 status 优先(用于 PARTIAL 等非成功但非失败的场景);未指定时沿用"有 error 即 FAILED"。
  609. /// 注意:SCOPE_MISMATCH 走 status=PARTIAL + error_message 诊断,而不是 FAILED——
  610. /// 它是"读到 0 行且可疑"的告警,不应把正常调度打成失败。
  611. /// </summary>
  612. internal static string ResolveSyncStatus(string? status, string? error)
  613. {
  614. if (!string.IsNullOrWhiteSpace(status)) return status!;
  615. return string.IsNullOrEmpty(error) ? SyncStatusSuccess : "FAILED";
  616. }
  617. private sealed class MysqlLiteralDateTimeConverter : JsonConverter<DateTime>
  618. {
  619. public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
  620. => reader.GetDateTime();
  621. public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
  622. => writer.WriteStringValue(value.ToString("yyyy-MM-dd HH:mm:ss.ffffff", CultureInfo.InvariantCulture));
  623. }
  624. private sealed class MysqlLiteralDateTimeOffsetConverter : JsonConverter<DateTimeOffset>
  625. {
  626. public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
  627. => reader.GetDateTimeOffset();
  628. public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
  629. => writer.WriteStringValue(value.ToString("yyyy-MM-dd HH:mm:ss.ffffff", CultureInfo.InvariantCulture));
  630. }
  631. }