IMdpSourcePullExecutor.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  2. using Admin.NET.Plugin.AiDOP.DataPlatform;
  3. namespace Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  4. /// <summary>统一入站抽数执行器(方式甲 DB / 方式乙 API)。</summary>
  5. public interface IMdpSourcePullExecutor
  6. {
  7. /// <summary>DB_SYNC / API_PULL</summary>
  8. string SupportedType { get; }
  9. Task<MdpPullResult> PullAsync(MdpSource source, MdpEntity entity, MdpPullContext ctx, CancellationToken cancellationToken = default);
  10. }
  11. public sealed class MdpPullContext
  12. {
  13. public long TenantId { get; set; }
  14. public string BatchId { get; set; } = "";
  15. /// <summary>强制全量(忽略水位与滚动下界)。</summary>
  16. public bool FullRefresh { get; set; }
  17. /// <summary>工厂作用域;&gt;0 时入站写入与源过滤按工厂对齐。</summary>
  18. public long FactoryId { get; set; }
  19. /// <summary>为 true 时禁止把无租户源行归给当前上下文租户;源租户不匹配则跳过。</summary>
  20. public bool RequireMatchingSourceTenant { get; set; }
  21. /// <summary>关联 mdp_sync_task.task_code,用于读取调度上的同步窗口。</summary>
  22. public string? TaskCode { get; set; }
  23. /// <summary>FULL / INCR / ROLLING(可由调度表覆盖)。</summary>
  24. public string? SyncWindowType { get; set; }
  25. /// <summary>如 7d / 24h / 2026-01-01。</summary>
  26. public string? SyncWindowValue { get; set; }
  27. /// <summary>ROLLING 解析后的下界(含)。</summary>
  28. public DateTime? WindowFrom { get; set; }
  29. /// <summary>全量分页偏移(仅 FullRefresh / 无 incr 时由 PullAll 推进)。</summary>
  30. public int Offset { get; set; }
  31. /// <summary>启用复合 keyset 游标(库存冷链等场景);未设置时保持既有 OFFSET/单列游标行为。</summary>
  32. public bool UseKeysetCursor { get; set; }
  33. /// <summary>时间/主游标列,如 UpdateTime / CreateTime。</summary>
  34. public string? CursorColumn { get; set; }
  35. /// <summary>唯一 tie-breaker 列,如 RecID。</summary>
  36. public string? TieBreakerColumn { get; set; }
  37. /// <summary>本页起始游标时间(ISO/字面量)。</summary>
  38. public string? CursorValue { get; set; }
  39. /// <summary>本页起始 tie-breaker。</summary>
  40. public string? TieBreakerValue { get; set; }
  41. /// <summary>冻结上界时间。</summary>
  42. public string? UpperCursorValue { get; set; }
  43. /// <summary>冻结上界 RecID。</summary>
  44. public string? UpperTieBreakerValue { get; set; }
  45. /// <summary>bootstrap 下界(含),如近 12 个月。</summary>
  46. public DateTime? BootstrapFrom { get; set; }
  47. /// <summary>为 true 时本页不写实体 LastCursor,由编排在整轮成功后持久化。</summary>
  48. public bool DeferCursorPersist { get; set; }
  49. /// <summary>LocationDetail 等 NULL 时间段:仅拉 CursorColumn IS NULL 且按 TieBreaker 推进。</summary>
  50. public bool NullTimePhase { get; set; }
  51. /// <summary>为 true 时不从实体 LastCursor 回填起始 keyset(bootstrap/reconcile 首刷用)。</summary>
  52. public bool SkipPersistedKeysetCursor { get; set; }
  53. /// <summary>旧单值水位首次升级:同一时间戳整段重读,由贴源幂等吸收重复行。</summary>
  54. public bool LegacyCursorInclusive { get; set; }
  55. /// <summary>方式甲配置入口才强制稳定排序列。既有模块调用缺列时保持原单列水位。</summary>
  56. public bool RequireConfiguredDbSync { get; set; }
  57. /// <summary>MANUAL / AUTO。</summary>
  58. public string TriggerType { get; set; } = "AUTO";
  59. /// <summary>
  60. /// 贴源归属租户与业务租户分离(165 库存一次贴源、再按库位投影)。
  61. /// 为 true 时不要求该租户已登记本实体的来源系统。
  62. /// </summary>
  63. public bool StagingOwnerPull { get; set; }
  64. }
  65. public sealed class MdpPullResult
  66. {
  67. public int RowsPulled { get; set; }
  68. public int RowsWritten { get; set; }
  69. public int RowsError { get; set; }
  70. public string? NewCursor { get; set; }
  71. public string? Status { get; set; }
  72. public string? Message { get; set; }
  73. }
  74. /// <summary>按实体/源类型选择 DB 或 API 执行器。</summary>
  75. public sealed class MdpSourcePullDispatcher : ITransient
  76. {
  77. private readonly MdpDbPullExecutor _dbExecutor;
  78. private readonly MdpApiPullExecutor _apiExecutor;
  79. private readonly ISqlSugarClient _db;
  80. public MdpSourcePullDispatcher(
  81. MdpDbPullExecutor dbExecutor,
  82. MdpApiPullExecutor apiExecutor,
  83. ISqlSugarClient db)
  84. {
  85. _dbExecutor = dbExecutor;
  86. _apiExecutor = apiExecutor;
  87. _db = db;
  88. }
  89. public async Task<MdpPullResult> PullByEntityCodeAsync(string entityCode, MdpPullContext ctx, CancellationToken cancellationToken = default)
  90. {
  91. var query = _db.Queryable<MdpEntity>()
  92. .Where(x => x.EntityCode == entityCode && x.Status == 1)
  93. .WhereIF(ctx.TenantId > 0, x => x.TenantId == ctx.TenantId || x.TenantId == 0)
  94. .WhereIF(ctx.TenantId <= 0, x => x.TenantId == 0)
  95. .OrderByDescending(x => x.TenantId);
  96. var entity = await query
  97. .FirstAsync(cancellationToken)
  98. ?? throw new InvalidOperationException($"mdp_entity 未找到启用实体:{entityCode}");
  99. return await PullLoadedAsync(entity, ctx, cancellationToken);
  100. }
  101. public async Task<MdpPullResult> PullByEntityIdAsync(long entityId, MdpPullContext ctx, CancellationToken cancellationToken = default)
  102. {
  103. var entity = await _db.Queryable<MdpEntity>()
  104. .Where(x => x.Id == entityId && x.Status == 1)
  105. .FirstAsync(cancellationToken)
  106. ?? throw new InvalidOperationException($"mdp_entity 未找到启用实体:{entityId}");
  107. if (ctx.TenantId > 0 && entity.TenantId != 0 && entity.TenantId != ctx.TenantId)
  108. throw new InvalidOperationException($"不能运行其他租户的实体 {entity.EntityCode}");
  109. return await PullLoadedAsync(entity, ctx, cancellationToken);
  110. }
  111. public async Task<MdpPullResult> PullAllByEntityIdAsync(
  112. long entityId,
  113. MdpPullContext ctx,
  114. CancellationToken cancellationToken = default,
  115. int maxPages = 500)
  116. {
  117. var entity = await _db.Queryable<MdpEntity>().Where(x => x.Id == entityId).FirstAsync(cancellationToken)
  118. ?? throw new InvalidOperationException($"mdp_entity 未找到实体:{entityId}");
  119. if (ctx.TenantId > 0 && entity.TenantId != 0 && entity.TenantId != ctx.TenantId)
  120. throw new InvalidOperationException($"不能运行其他租户的实体 {entity.EntityCode}");
  121. return await PullAllAsync(entity.Id, entity.EntityCode, ctx, cancellationToken, maxPages);
  122. }
  123. private async Task<MdpPullResult> PullLoadedAsync(MdpEntity entity, MdpPullContext ctx, CancellationToken cancellationToken)
  124. {
  125. var source = await _db.Queryable<MdpSource>()
  126. .Where(x => x.Id == entity.SourceId && x.Status == 1)
  127. .FirstAsync(cancellationToken)
  128. ?? throw new InvalidOperationException($"mdp_source id={entity.SourceId} 未找到或未启用");
  129. if (entity.TenantId == 0 && ctx.TenantId > 0 && !ctx.StagingOwnerPull)
  130. {
  131. var neutral = MdpSourceIdentity.NeutralCode(source.SystemCode, source.SourceCode);
  132. if (!string.Equals(neutral, MdpSourceIdentity.Native, StringComparison.OrdinalIgnoreCase)
  133. && !string.Equals(neutral, "AIDOPDEV_MYSQL", StringComparison.OrdinalIgnoreCase))
  134. {
  135. var allowed = await _db.Ado.GetIntAsync(
  136. """
  137. SELECT COUNT(*) FROM mdp_tenant_std_source
  138. WHERE tenant_id=@t AND source_system=@s
  139. """,
  140. new SugarParameter("@t", ctx.TenantId),
  141. new SugarParameter("@s", neutral));
  142. if (allowed == 0)
  143. throw new InvalidOperationException(
  144. $"租户 {ctx.TenantId} 未登记来源 {neutral},拒绝拉取全局实体 {entity.EntityCode}");
  145. }
  146. }
  147. if (string.IsNullOrWhiteSpace(ctx.BatchId))
  148. ctx.BatchId = $"MDP_PULL_{DateTime.Now:yyyyMMddHHmmss}";
  149. ApplyDbSyncDefaults(entity, source, ctx);
  150. await ApplyScheduleWindowAsync(ctx, cancellationToken);
  151. MdpSyncWindowResolver.Apply(ctx);
  152. if (string.Equals(source.SourceType, "FILE_EXCEL", StringComparison.OrdinalIgnoreCase))
  153. throw new InvalidOperationException("文件源不支持 Pull,请使用文件导入接口");
  154. IMdpSourcePullExecutor executor =
  155. !string.IsNullOrWhiteSpace(entity.SourceApiPath)
  156. || string.Equals(source.SourceType, "API", StringComparison.OrdinalIgnoreCase)
  157. ? _apiExecutor
  158. : _dbExecutor;
  159. return await executor.PullAsync(source, entity, ctx, cancellationToken);
  160. }
  161. /// <summary>
  162. /// 游标没有前进时必须改走 OFFSET,否则空游标增量会反复读第一页。
  163. /// 全量本来就用 OFFSET。keyset 自己带翻页谓词,不叠加 OFFSET。
  164. /// </summary>
  165. internal static bool ShouldPageByOffset(bool fullRefresh, bool useKeyset, string? cursorAtPageStart, string? newCursor)
  166. {
  167. if (useKeyset)
  168. return false;
  169. if (fullRefresh)
  170. return true;
  171. return string.IsNullOrWhiteSpace(newCursor)
  172. || string.Equals(newCursor, cursorAtPageStart, StringComparison.Ordinal);
  173. }
  174. /// <summary>
  175. /// 分页抽尽:FullRefresh 用 OFFSET;增量依赖实体 LastCursor 在页间推进;
  176. /// 游标为空或本页没有把游标推前时退化为 OFFSET,避免空游标只抽到第一页或反复读同一页。
  177. /// UseKeysetCursor 时用复合 keyset,达 maxPages 且末页仍满批则抛错。
  178. /// </summary>
  179. public Task<MdpPullResult> PullAllByEntityCodeAsync(
  180. string entityCode,
  181. MdpPullContext ctx,
  182. CancellationToken cancellationToken = default,
  183. int maxPages = 500)
  184. => PullAllAsync(null, entityCode, ctx, cancellationToken, maxPages);
  185. private async Task<MdpEntity?> FindPagedEntityAsync(
  186. long? entityId,
  187. string? entityCode,
  188. long tenantId,
  189. CancellationToken cancellationToken)
  190. {
  191. if (entityId is > 0)
  192. return await _db.Queryable<MdpEntity>()
  193. .Where(x => x.Id == entityId && x.Status == 1)
  194. .FirstAsync(cancellationToken);
  195. return await _db.Queryable<MdpEntity>()
  196. .Where(x => x.EntityCode == entityCode && x.Status == 1)
  197. .WhereIF(tenantId > 0, x => x.TenantId == tenantId || x.TenantId == 0)
  198. .WhereIF(tenantId <= 0, x => x.TenantId == 0)
  199. .OrderByDescending(x => x.TenantId)
  200. .FirstAsync(cancellationToken);
  201. }
  202. private async Task<MdpPullResult> PullAllAsync(
  203. long? entityId,
  204. string? entityCode,
  205. MdpPullContext ctx,
  206. CancellationToken cancellationToken,
  207. int maxPages)
  208. {
  209. var totalPulled = 0;
  210. var totalWritten = 0;
  211. string? lastCursor = null;
  212. string? lastMessage = null;
  213. ctx.Offset = 0;
  214. if (ctx.UseKeysetCursor
  215. && !ctx.SkipPersistedKeysetCursor
  216. && string.IsNullOrWhiteSpace(ctx.CursorValue)
  217. && string.IsNullOrWhiteSpace(ctx.TieBreakerValue)
  218. && !ctx.NullTimePhase)
  219. {
  220. var entity0 = await FindPagedEntityAsync(entityId, entityCode, ctx.TenantId, cancellationToken);
  221. if (entity0 != null
  222. && MdpDbPullExecutor.TryDecodeKeysetCursor(entity0.LastCursor, out var c, out var t))
  223. {
  224. ctx.CursorValue = c;
  225. ctx.TieBreakerValue = t;
  226. }
  227. }
  228. for (var page = 0; page < maxPages; page++)
  229. {
  230. cancellationToken.ThrowIfCancellationRequested();
  231. var cursorAtPageStart = lastCursor;
  232. var pageResult = entityId is > 0
  233. ? await PullByEntityIdAsync(entityId.Value, ctx, cancellationToken)
  234. : await PullByEntityCodeAsync(entityCode!, ctx, cancellationToken);
  235. totalPulled += pageResult.RowsPulled;
  236. totalWritten += pageResult.RowsWritten;
  237. lastCursor = pageResult.NewCursor ?? lastCursor;
  238. lastMessage = pageResult.Message;
  239. var entity = await FindPagedEntityAsync(entityId, entityCode, ctx.TenantId, cancellationToken);
  240. var batchSize = entity?.BatchSize > 0 ? entity.BatchSize : 1000;
  241. if (pageResult.RowsPulled <= 0 || pageResult.RowsPulled < batchSize)
  242. break;
  243. if (ctx.UseKeysetCursor)
  244. {
  245. if (page == maxPages - 1)
  246. throw new InvalidOperationException(
  247. $"实体 {entity?.EntityCode ?? entityCode} keyset 拉取达到 maxPages={maxPages} 且末页仍满批,禁止静默截断");
  248. // 下一页游标已由执行器写回 ctx.CursorValue/TieBreakerValue
  249. continue;
  250. }
  251. if (ShouldPageByOffset(ctx.FullRefresh, ctx.UseKeysetCursor, cursorAtPageStart, pageResult.NewCursor))
  252. ctx.Offset += pageResult.RowsPulled;
  253. // 游标已前进:实体 LastCursor 已在执行器内更新,下一页用 > cursor,Offset 保持 0
  254. }
  255. if (ctx.UseKeysetCursor && ctx.DeferCursorPersist && !string.IsNullOrEmpty(lastCursor))
  256. {
  257. var entity = await FindPagedEntityAsync(entityId, entityCode, ctx.TenantId, cancellationToken);
  258. if (entity != null)
  259. {
  260. var now = DateTime.Now;
  261. await _db.Updateable<MdpEntity>()
  262. .SetColumns(x => new MdpEntity
  263. {
  264. LastCursor = lastCursor,
  265. LastSyncTo = now,
  266. UpdateTime = now
  267. })
  268. .Where(x => x.Id == entity.Id)
  269. .ExecuteCommandAsync(cancellationToken);
  270. }
  271. }
  272. return new MdpPullResult
  273. {
  274. RowsPulled = totalPulled,
  275. RowsWritten = totalWritten,
  276. NewCursor = lastCursor,
  277. Message = $"OK pages pulled={totalPulled} written={totalWritten}; {lastMessage}"
  278. };
  279. }
  280. /// <summary>若上下文带 TaskCode 且未显式指定窗口,则从 mdp_sync_task_schedule 读取。</summary>
  281. private async Task ApplyScheduleWindowAsync(MdpPullContext ctx, CancellationToken cancellationToken)
  282. {
  283. if (ctx.FullRefresh) return;
  284. if (string.IsNullOrWhiteSpace(ctx.TaskCode)) return;
  285. if (!string.IsNullOrWhiteSpace(ctx.SyncWindowType)) return;
  286. var schedules = await _db.Queryable<MdpSyncTaskSchedule>()
  287. .Where(x => x.TaskCode == ctx.TaskCode && (x.TenantId == ctx.TenantId || x.TenantId == 0))
  288. .OrderByDescending(x => x.TenantId)
  289. .Take(1)
  290. .ToListAsync(cancellationToken);
  291. var schedule = schedules.FirstOrDefault();
  292. if (schedule == null) return;
  293. ctx.SyncWindowType = schedule.SyncWindowType;
  294. ctx.SyncWindowValue = schedule.SyncWindowValue;
  295. }
  296. internal static void ApplyDbSyncDefaults(MdpEntity entity, MdpSource source, MdpPullContext ctx)
  297. {
  298. var isDb = string.Equals(source.SourceType, "DB", StringComparison.OrdinalIgnoreCase)
  299. && string.IsNullOrWhiteSpace(entity.SourceApiPath);
  300. if (!isDb)
  301. return;
  302. if (ctx.UseKeysetCursor)
  303. {
  304. if (Conflicts(ctx.CursorColumn, entity.IncrColumn) || Conflicts(ctx.TieBreakerColumn, entity.TieBreakerColumn))
  305. throw new InvalidOperationException(
  306. $"实体 {entity.EntityCode} 的增量列或稳定排序列与调用方不一致,已拒绝执行");
  307. return;
  308. }
  309. var mode = ctx.FullRefresh
  310. ? "FULL"
  311. : (ctx.SyncWindowType ?? entity.SyncMode ?? "INCR").Trim().ToUpperInvariant();
  312. if (mode is "INCR" or "ROLLING")
  313. {
  314. if (ctx.RequireConfiguredDbSync)
  315. {
  316. MdpDbSyncRules.RequireIdentifier(entity.IncrColumn, "incr_column");
  317. MdpDbSyncRules.RequireIdentifier(entity.TieBreakerColumn, "tie_breaker_column");
  318. }
  319. if (string.IsNullOrWhiteSpace(entity.IncrColumn) || string.IsNullOrWhiteSpace(entity.TieBreakerColumn))
  320. return;
  321. ctx.UseKeysetCursor = true;
  322. ctx.CursorColumn = entity.IncrColumn.Trim();
  323. ctx.TieBreakerColumn = entity.TieBreakerColumn.Trim();
  324. HydrateCursor(entity, ctx);
  325. }
  326. else if (mode == "FULL" && ctx.RequireConfiguredDbSync)
  327. {
  328. MdpDbSyncRules.RequireIdentifier(entity.TieBreakerColumn, "tie_breaker_column");
  329. }
  330. }
  331. private static bool Conflicts(string? caller, string? configured) =>
  332. !string.IsNullOrWhiteSpace(caller)
  333. && !string.IsNullOrWhiteSpace(configured)
  334. && !string.Equals(caller.Trim(), configured.Trim(), StringComparison.OrdinalIgnoreCase);
  335. private static void HydrateCursor(MdpEntity entity, MdpPullContext ctx)
  336. {
  337. if (ctx.SkipPersistedKeysetCursor || ctx.NullTimePhase)
  338. return;
  339. if (!string.IsNullOrWhiteSpace(ctx.CursorValue) || !string.IsNullOrWhiteSpace(ctx.TieBreakerValue))
  340. {
  341. if (!string.IsNullOrWhiteSpace(ctx.TieBreakerValue))
  342. ctx.LegacyCursorInclusive = false;
  343. return;
  344. }
  345. if (MdpDbPullExecutor.TryDecodeKeysetCursor(entity.LastCursor, out var cursor, out var tie))
  346. {
  347. ctx.CursorValue = cursor;
  348. ctx.TieBreakerValue = tie;
  349. ctx.LegacyCursorInclusive = false;
  350. return;
  351. }
  352. if (!string.IsNullOrWhiteSpace(entity.LastCursor))
  353. {
  354. ctx.CursorValue = entity.LastCursor;
  355. ctx.LegacyCursorInclusive = true;
  356. }
  357. }
  358. }