MdpRebuildContracts.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. using System.Text.RegularExpressions;
  2. namespace Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild;
  3. public sealed record MdpRebuildScope(string ModuleCode, long TenantId, long FactoryId)
  4. {
  5. public static readonly HashSet<string> RebuildModules = new(StringComparer.OrdinalIgnoreCase)
  6. {
  7. "S1", "S2", "S3", "S4", "S5", "S6", "S7"
  8. };
  9. public static readonly HashSet<string> SnapshotModules = new(StringComparer.OrdinalIgnoreCase)
  10. {
  11. "S1", "S2", "S3", "S4", "S5", "S6", "S7", "S9"
  12. };
  13. public static MdpRebuildScope Create(string moduleCode, long tenantId, long factoryId)
  14. {
  15. var mc = NormalizeModule(moduleCode);
  16. if (!RebuildModules.Contains(mc))
  17. throw new InvalidOperationException($"模块 {mc} 不支持全量数据重算");
  18. if (tenantId <= 0)
  19. throw new InvalidOperationException($"{mc} 全量重算必须指定有效 tenantId,禁止 tenantId=0");
  20. if (factoryId <= 0)
  21. throw new InvalidOperationException($"{mc} 全量重算必须指定有效 factoryId");
  22. if (factoryId == tenantId)
  23. throw new InvalidOperationException($"{mc} 全量重算的 factoryId 不能等于 tenantId");
  24. return new MdpRebuildScope(mc, tenantId, factoryId);
  25. }
  26. public static string NormalizeModule(string? moduleCode)
  27. {
  28. var mc = (moduleCode ?? string.Empty).Trim().ToUpperInvariant();
  29. if (mc.Length == 0) throw new InvalidOperationException("moduleCode 不能为空");
  30. return mc;
  31. }
  32. public string LockName => $"{ModuleCode}_MDP_FULL:{TenantId}:{FactoryId}";
  33. public string ScopeKey => $"{ModuleCode}:{TenantId}:{FactoryId}";
  34. }
  35. public static class ModuleRebuildStatus
  36. {
  37. public const string Queued = "QUEUED";
  38. public const string Running = "RUNNING";
  39. public const string Success = "SUCCESS";
  40. public const string Failed = "FAILED";
  41. public const string Cancelled = "CANCELLED";
  42. }
  43. /// <summary>
  44. /// <c>ado_module_dashboard_rebuild_job.trigger_type</c> 取值。
  45. ///
  46. /// <para><b>新增自动类触发器时必须同步检查领取过滤</b>:
  47. /// <c>ModuleRebuildJobStore.ClaimNextQueuedAsync</c> 对非执行机采用**白名单**——
  48. /// 只领 <see cref="Manual"/> 或带 <c>requested_by</c> 的行。
  49. /// 因此新加的自动触发类型天然不会被非执行机领走,这是刻意设计,勿改成黑名单。</para>
  50. /// </summary>
  51. public static class ModuleRebuildTriggerType
  52. {
  53. /// <summary>页面「数据重算」按钮。非执行机也可领取。</summary>
  54. public const string Manual = "MANUAL";
  55. /// <summary>定时任务入队。只有执行机可领取。</summary>
  56. public const string Auto = "AUTO";
  57. /// <summary>首次建库/冷启动灌数。只有执行机可领取。</summary>
  58. public const string Bootstrap = "BOOTSTRAP";
  59. /// <summary>每日 03:40 夜间串行全量兜底。穿透 AUTO 冷却窗口,只有执行机可领取。</summary>
  60. public const string AutoNightly = "AUTO_NIGHTLY";
  61. /// <summary>方式甲数据库同步完成后的数据刷新。穿透 AUTO 冷却窗口,只有执行机可领取。</summary>
  62. public const string DbSync = "DB_SYNC";
  63. public static bool BypassesCooldown(string triggerType)
  64. => string.Equals(triggerType, AutoNightly, StringComparison.OrdinalIgnoreCase)
  65. || string.Equals(triggerType, DbSync, StringComparison.OrdinalIgnoreCase);
  66. /// <summary>
  67. /// 只有小时级 AUTO 走增量拉数;夜间兜底、启动引导、人工重算一律全量。
  68. /// 用于把 <c>triggerType</c> 翻译成 <c>MdpPullContext.FullRefresh</c>。
  69. /// </summary>
  70. public static bool ShouldPullFull(string triggerType)
  71. => !string.Equals(triggerType, Auto, StringComparison.OrdinalIgnoreCase);
  72. /// <summary>
  73. /// 非执行机能否领取该任务——即「这是不是一个人工触发的任务」。
  74. ///
  75. /// <para>这是该语义的**规范定义**。<c>ModuleRebuildJobStore.ClaimNextQueuedAsync</c> 里
  76. /// 的 <c>WhereIF</c> 条件必须与本函数等价(表达式树要翻译成 SQL,无法直接复用本函数)。
  77. /// 改动任一处都必须同步另一处,<c>ModuleRebuildClaimFilterTests</c> 会同时守住两边。</para>
  78. /// </summary>
  79. public static bool IsManualTrigger(string triggerType, long? requestedBy)
  80. => string.Equals(triggerType, Manual, StringComparison.OrdinalIgnoreCase) || requestedBy != null;
  81. }
  82. public static class ModuleRebuildStages
  83. {
  84. public const string Queued = "QUEUED";
  85. public const string AcquiringLock = "ACQUIRING_LOCK";
  86. public const string Preparing = "PREPARING";
  87. public const string Staging = "STAGING";
  88. public const string Standard = "STANDARD";
  89. public const string Dwd = "DWD";
  90. public const string Kpi = "KPI";
  91. public const string Atomic = "ATOMIC";
  92. public const string T8Inbound = "T8_INBOUND";
  93. public const string KpiPreparing = "KPI_PREPARING";
  94. public const string KpiCalculating = "KPI_CALCULATING";
  95. public const string Finalizing = "FINALIZING";
  96. public const string Success = "SUCCESS";
  97. public const string Failed = "FAILED";
  98. public const string Cancelled = "CANCELLED";
  99. public static int StageTotal(string moduleCode) => Normalize(moduleCode) switch
  100. {
  101. "S4" => 4,
  102. "S5" or "S6" or "S7" => 4,
  103. _ => 5
  104. };
  105. public static int ToStageIndex(string moduleCode, string stage)
  106. {
  107. var mc = Normalize(moduleCode);
  108. if (mc is "S5" or "S6" or "S7")
  109. {
  110. return stage switch
  111. {
  112. T8Inbound => 1,
  113. KpiPreparing => 2,
  114. KpiCalculating => 3,
  115. Atomic or Finalizing or Success => 4,
  116. _ => 0
  117. };
  118. }
  119. if (mc == "S4")
  120. {
  121. return stage switch
  122. {
  123. Staging => 1,
  124. Standard => 2,
  125. Dwd => 3,
  126. Kpi or Finalizing or Success => 4,
  127. _ => 0
  128. };
  129. }
  130. return stage switch
  131. {
  132. Staging => 1,
  133. Standard => 2,
  134. Dwd => 3,
  135. Kpi => 4,
  136. Atomic or Finalizing or Success => 5,
  137. _ => 0
  138. };
  139. }
  140. public static string ToChinese(string? stage) => stage switch
  141. {
  142. Queued => "排队中",
  143. AcquiringLock => "等待资源",
  144. Preparing => "准备运行环境",
  145. Staging => "拉取源数据",
  146. Standard => "标准化数据",
  147. Dwd => "生成 DWD 明细",
  148. Kpi => "重算 KPI",
  149. Atomic => "重建原子数据",
  150. T8Inbound => "T8 基表入站",
  151. KpiPreparing => "准备 KPI 计算",
  152. KpiCalculating => "计算 KPI",
  153. Finalizing => "收尾",
  154. Success => "已完成",
  155. Failed => "失败",
  156. Cancelled => "已取消",
  157. _ => stage ?? ""
  158. };
  159. public static string ConfirmTitle(string moduleCode) => $"确认重算 {Normalize(moduleCode)} 数据?";
  160. private static string Normalize(string? moduleCode) =>
  161. string.IsNullOrWhiteSpace(moduleCode) ? "" : moduleCode.Trim().ToUpperInvariant();
  162. }
  163. public sealed record ModuleProgressUpdate(
  164. string Stage,
  165. int StageIndex,
  166. int ProgressPercent,
  167. string Message,
  168. int? Rows = null,
  169. string? CompletedStage = null,
  170. string? DetailJson = null);
  171. public sealed class ModuleRebuildResult
  172. {
  173. public string BatchId { get; set; } = string.Empty;
  174. public long RunLogId { get; set; }
  175. public int StageRows { get; set; }
  176. public int StandardRows { get; set; }
  177. public int DwdRows { get; set; }
  178. public int KpiRows { get; set; }
  179. public int AtomicRows { get; set; }
  180. public string? DetailJson { get; set; }
  181. }
  182. /// <summary>管理员在阶段边界请求取消。由 <c>RunClaimedAsync</c> 捕获并收口为 CANCELLED。</summary>
  183. public sealed class ModuleRebuildCancelledException : Exception
  184. {
  185. public ModuleRebuildCancelledException() : base("已由管理员取消") { }
  186. /// <summary>
  187. /// 已开跑的那一轮 transform run log。进度回调拿不到它(它在各模块服务内部),
  188. /// 由 <c>RunClaimedAsync</c> 在异常冒泡时按 batchId 回填,收口时写成 ABORTED。
  189. /// </summary>
  190. public long RunLogId { get; set; }
  191. }
  192. public sealed class ModuleRebuildAlreadyRunningException : Exception
  193. {
  194. public ModuleRebuildAlreadyRunningException(string moduleCode)
  195. : base($"{moduleCode} 数据重算正在执行,请勿重复提交")
  196. {
  197. ModuleCode = moduleCode;
  198. }
  199. public string ModuleCode { get; }
  200. }
  201. public static class MdpSqlScope
  202. {
  203. private static readonly Regex FromAlias = new(
  204. @"\bFROM\s+(?<table>[`.\w]+)\s+(?:AS\s+)?(?<alias>[`\w]+)",
  205. RegexOptions.IgnoreCase | RegexOptions.Compiled);
  206. /// <summary>
  207. /// 不带租户列的全局字典表,注入器必须原样放过。
  208. /// <c>mdp_source_table_registry</c> 只有 (id, source_table, source_system, remark),
  209. /// 它由 <c>MdpSourceIdentity.Resolve</c> 拼成的标量子查询会出现在 S2/S3/S4 的每条 STD 语句里;
  210. /// 给它补上 <c>r.tenant_id=@TenantId</c> 就是 Unknown column,整轮跑批 FAILED。
  211. /// </summary>
  212. private static readonly HashSet<string> ScopelessTables = new(StringComparer.OrdinalIgnoreCase)
  213. {
  214. "mdp_source_table_registry"
  215. };
  216. /// <summary>扫到这些词就算当前 WHERE 子句结束。</summary>
  217. private static readonly string[] ClauseEnders =
  218. ["GROUP", "HAVING", "ORDER", "LIMIT", "UNION", "ON"];
  219. public static string InjectTenantFactory(string sql)
  220. => Inject(sql, includeFactory: true);
  221. public static string InjectTenantOnly(string sql)
  222. => Inject(sql, includeFactory: false);
  223. private static string Inject(string sql, bool includeFactory)
  224. {
  225. if (string.IsNullOrWhiteSpace(sql)) return sql;
  226. return Regex.Replace(sql, @"\bWHERE\b", m =>
  227. {
  228. if (ClauseHasTenantParam(sql, m.Index + m.Length)) return m.Value;
  229. var (prefix, scopeless) = ResolveSource(sql, m.Index);
  230. if (scopeless) return m.Value;
  231. var factoryFilter = includeFactory
  232. ? $"COALESCE(NULLIF({prefix}factory_id,0),1)=@FactoryId AND "
  233. : string.Empty;
  234. return $"WHERE {prefix}tenant_id=@TenantId AND {factoryFilter}";
  235. }, RegexOptions.IgnoreCase);
  236. }
  237. /// <summary>
  238. /// 本条 WHERE 子句自己是否已经写了租户条件。只认同层级的 <c>@TenantId</c>:
  239. /// 嵌套子查询自带的租户条件不能替外层背书,否则外层就漏过滤了。
  240. ///
  241. /// <para>这里原先是「往后数 240 个字符里有没有 @TenantId」。那个窗口既够不到写在后面的
  242. /// 条件(该跳过的没跳过),又会把邻近语句里不相干的 <c>@TenantId</c> 算进来(不该跳过的跳过了),
  243. /// 同一段 SQL 加不加租户条件取决于文本长度。<c>mdp_source_table_registry</c> 的子查询
  244. /// 在 S4 正好被窗口盖住而幸免,在 S3 就被注入成 <c>r.tenant_id=@TenantId</c> 并报 Unknown column。</para>
  245. /// </summary>
  246. private static bool ClauseHasTenantParam(string sql, int start)
  247. {
  248. var depth = 0;
  249. for (var i = start; i < sql.Length; i++)
  250. {
  251. var c = sql[i];
  252. if (c == '\'')
  253. {
  254. i = SkipQuoted(sql, i);
  255. continue;
  256. }
  257. if (c == '(')
  258. {
  259. depth++;
  260. continue;
  261. }
  262. if (c == ')')
  263. {
  264. if (depth == 0) return false; // 退出了所在子查询,子句到此为止
  265. depth--;
  266. continue;
  267. }
  268. if (depth > 0) continue;
  269. if (c == '@' && IsWord(sql, i, "@TenantId")) return true;
  270. foreach (var ender in ClauseEnders)
  271. if (IsWord(sql, i, ender)) return false;
  272. }
  273. return false;
  274. }
  275. /// <summary>从 <paramref name="openIndex"/> 处的单引号跳到配对的结束引号;支持 '' 与反斜杠转义。</summary>
  276. private static int SkipQuoted(string sql, int openIndex)
  277. {
  278. for (var i = openIndex + 1; i < sql.Length; i++)
  279. {
  280. if (sql[i] == '\\') { i++; continue; }
  281. if (sql[i] != '\'') continue;
  282. if (i + 1 < sql.Length && sql[i + 1] == '\'') { i++; continue; }
  283. return i;
  284. }
  285. return sql.Length;
  286. }
  287. /// <summary><paramref name="sql"/> 的 <paramref name="at"/> 处是否是一个完整的 <paramref name="word"/> 而非某个标识符的片段。</summary>
  288. private static bool IsWord(string sql, int at, string word)
  289. {
  290. if (!sql.AsSpan(at).StartsWith(word, StringComparison.OrdinalIgnoreCase)) return false;
  291. if (at > 0 && (char.IsLetterOrDigit(sql[at - 1]) || sql[at - 1] is '_' or '.' or '@' or '`')) return false;
  292. var after = at + word.Length;
  293. return after >= sql.Length || !(char.IsLetterOrDigit(sql[after]) || sql[after] == '_');
  294. }
  295. private static (string Prefix, bool Scopeless) ResolveSource(string sql, int whereIndex)
  296. {
  297. var depth = 0;
  298. for (var i = whereIndex - 1; i >= 0; i--)
  299. {
  300. if (sql[i] == ')')
  301. {
  302. depth++;
  303. continue;
  304. }
  305. if (sql[i] == '(')
  306. {
  307. if (depth > 0) depth--;
  308. continue;
  309. }
  310. if (depth != 0 || i < 3) continue;
  311. if (!sql.AsSpan(i - 3, 4).Equals("FROM", StringComparison.OrdinalIgnoreCase)) continue;
  312. if (i > 3 && (char.IsLetterOrDigit(sql[i - 4]) || sql[i - 4] == '_')) continue;
  313. var match = FromAlias.Match(sql, i - 3);
  314. if (!match.Success || match.Index != i - 3) return (string.Empty, false);
  315. var table = match.Groups["table"].Value.Trim('`');
  316. var scopeless = ScopelessTables.Contains(table)
  317. || table.StartsWith("information_schema.", StringComparison.OrdinalIgnoreCase);
  318. var alias = match.Groups["alias"].Value.Trim('`');
  319. var prefix = alias is "LEFT" or "RIGHT" or "INNER" or "CROSS" or "JOIN" or "ON" or "AS" or "WHERE"
  320. ? string.Empty
  321. : alias + ".";
  322. return (prefix, scopeless);
  323. }
  324. return (string.Empty, false);
  325. }
  326. }
  327. public interface IModuleRebuildCapability
  328. {
  329. bool IsEnabled(string moduleCode);
  330. /// <summary>单进程并发上限:本实例的 <c>ModuleRebuildWorker</c> 最多同时跑几个 scope。</summary>
  331. int MaxParallelScopes { get; }
  332. /// <summary>
  333. /// 全库并发上限:**所有**连同一个库的实例加起来最多同时跑几个 scope,
  334. /// 由 <c>ModuleRebuildJobStore.ClaimNextQueuedAsync</c> 在抢占时收口。
  335. /// <para>只有 <see cref="MaxParallelScopes"/> 时它约束的是单进程,多实例共库会成倍放大:
  336. /// 2026-09-24 取证里配置值是 1,而实测同一瞬间有 2–3 个重算在跑。</para>
  337. /// </summary>
  338. int GlobalMaxParallelScopes { get; }
  339. /// <summary>
  340. /// AUTO / BOOTSTRAP 入队的最小间隔小时数。MANUAL 与 AUTO_NIGHTLY 不受此限。
  341. /// 取 0 表示关闭冷却,仅供排障时临时恢复旧行为。
  342. /// </summary>
  343. int AutoMinIntervalHours { get; }
  344. }
  345. public sealed class ModuleRebuildCapability : IModuleRebuildCapability, ISingleton
  346. {
  347. public bool IsEnabled(string moduleCode)
  348. {
  349. var mc = MdpRebuildScope.NormalizeModule(moduleCode);
  350. try
  351. {
  352. return Furion.App.GetConfig<bool?>($"AiDOP:MdpRebuild:Modules:{mc}:Enabled", true) ?? false;
  353. }
  354. catch
  355. {
  356. return false;
  357. }
  358. }
  359. public int MaxParallelScopes
  360. {
  361. get
  362. {
  363. try
  364. {
  365. return Math.Clamp(Furion.App.GetConfig<int?>("AiDOP:MdpRebuild:MaxParallelScopes", true) ?? 2, 1, 8);
  366. }
  367. catch
  368. {
  369. return 2;
  370. }
  371. }
  372. }
  373. public int GlobalMaxParallelScopes
  374. {
  375. get
  376. {
  377. try
  378. {
  379. // 缺省回落到单进程上限:未显式配置时行为等价于「只有一个实例在跑」,
  380. // 而不是悄悄放开成无上限。
  381. var v = Furion.App.GetConfig<int?>("AiDOP:MdpRebuild:GlobalMaxParallelScopes", true);
  382. return Math.Clamp(v ?? MaxParallelScopes, 1, 16);
  383. }
  384. catch
  385. {
  386. return MaxParallelScopes;
  387. }
  388. }
  389. }
  390. public int AutoMinIntervalHours
  391. {
  392. get
  393. {
  394. try
  395. {
  396. return Math.Clamp(Furion.App.GetConfig<int?>("AiDOP:MdpRebuild:AutoMinIntervalHours", true) ?? 6, 0, 168);
  397. }
  398. catch
  399. {
  400. return 6;
  401. }
  402. }
  403. }
  404. }
  405. public sealed class AlwaysOnModuleRebuildCapability : IModuleRebuildCapability
  406. {
  407. public bool IsEnabled(string moduleCode) => true;
  408. public int MaxParallelScopes => 2;
  409. public int GlobalMaxParallelScopes => 2;
  410. public int AutoMinIntervalHours => 6;
  411. }