using Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform; /// /// 采购交付 Authority(mdp_std_purchase_orderdwd_supplier_delivery)的生产契约。 /// /// 这条链是 S8 Rule01 的唯一数据来源。它曾同时坏在三个地方,且三处根因都落在同一段 /// STD 构建 SQL 上:丢了租户谓词(跨租户串值)、丢了批次作用域(孤儿只增不减)、 /// 以及有两个业务 API 绕过它直写下游 DWD。本文件守住修好之后的形状。 /// public class S3PurchaseAuthorityContractTests { private static string Source(params string[] parts) { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP"))) dir = dir.Parent; Assert.NotNull(dir); var full = Path.Combine(new[] { dir!.FullName, "Admin.NET.Plugin.AiDOP" }.Concat(parts).ToArray()); Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}"); return File.ReadAllText(full); } private static string S3Service() => Source("Supply", "S3MdpSyncTransformService.cs"); private static int At(string code, string anchor) { var i = code.IndexOf(anchor, StringComparison.Ordinal); Assert.True(i >= 0, $"未找到锚点:{anchor}"); return i; } /// mdp_std_purchase_order 那一条 INSERT 的 SQL 区间。 private static string StdInsertBlock() { var code = S3Service(); var start = At(code, "INSERT INTO mdp_std_purchase_order"); var end = code.IndexOf("\"\"\"", start, StringComparison.Ordinal); Assert.True(end > start, "mdp_std_purchase_order 的 INSERT 未正常收尾"); return code[start..end]; } /// /// STD 构建的两个 enrichment JOIN 必须自带租户谓词。 /// /// 为什么不能靠作用域注入兜底MdpSqlScope.InjectTenantFactory 的实现是 /// Regex.Replace(sql, @"\bWHERE\b", ...),它只改写 WHERE, /// 对 JOIN ... ON 零覆盖。这不是配置问题,是机制上够不到。 /// /// 后果不是行数膨胀:扇出会被 uk_po_line(tenant_id, po_no, po_line) 的 /// ON DUPLICATE KEY UPDATE 吸收,最终行数不变,但 item_name / supplier_code / order_date / buyer /// 这些 enrichment 值由 MySQL 任取一行决定 —— 症状是取值随机化,不是报错。 /// 实测单租户扇出 870/220 = 3.95 倍,item_code 跨租户重名 5529 组。 /// [Fact] public void StdBuild_EnrichmentJoins_CarryTenantPredicate() { var block = StdInsertBlock(); // 表头 enrichment:按 PurOrd 字符串匹配,必须限定同租户。 Assert.Contains("m ON m.tenant_id = d.tenant_id", block); Assert.Contains("m.pur_ord = JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.PurOrd'))", block); // 物料 enrichment:按 ItemNum 字符串匹配,同样必须限定同租户。 Assert.Contains("i ON i.tenant_id = d.tenant_id", block); Assert.Contains("i.item_num = JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.ItemNum'))", block); } /// /// DWD 构建的三个 JOIN 本来就是租户安全的,本批不得把它们改坏。 /// 它们已带 po.tenant_id=X.tenant_id AND po.factory_id=X.factory_id, /// 泄漏点在 STD 而不在这里 —— 这条断言是防止后续改动误伤。 /// [Fact] public void DwdBuild_JoinsRemainTenantAndFactoryScoped() { var code = S3Service(); var start = At(code, "INSERT INTO dwd_supplier_delivery"); var end = code.IndexOf("\"\"\"", start, StringComparison.Ordinal); var block = code[start..end]; Assert.Contains("ds ON po.tenant_id=ds.tenant_id AND po.factory_id=ds.factory_id", block); Assert.Contains("dr ON po.tenant_id=dr.tenant_id AND po.factory_id=dr.factory_id", block); Assert.Contains("s ON po.tenant_id=s.tenant_id AND po.factory_id=s.factory_id", block); } /// /// STD 构建必须按本轮批次收窄驱动行。 /// 少了它,INSERT 会把整张贴源历史重新物化,并给死行盖上最新批次戳 —— /// 实测租户 797 因此在 STD 沉淀了 336 行源侧已不存在的采购订单行(518 行中的 64.9%), /// 且全部 518 行共享同一个批次号,连时间戳都认不出哪些是死的。 /// [Fact] public void StdBuild_DrivingRows_ScopedToCurrentBatch() { var block = StdInsertBlock(); Assert.Contains("AND d.source_table='PurOrdDetail' AND d.sync_batch_id=@BatchId", block); } /// /// enrichment 必须按 (租户, 键) 去重取最新一行,且**不得**用 GROUP_CONCAT 实现。 /// 贴源层同时存在两代 source_biz_key(旧 Domain:PurOrd:Line 与新的裸 RecID), /// 两代永远不会互相 ODKU 覆盖,因此同一个 PurOrd/ItemNum 在同租户内有多行 —— 不去重就仍会扇出。 /// 禁用 GROUP_CONCAT 的理由是硬的:raw_data 是 JSON 大字段, /// 而 group_concat_max_len 实测仅 1024,拼接必然截断、首元素本身就可能超限。 /// [Fact] public void StdBuild_EnrichmentDeduplicated_WithRowNumberNotGroupConcat() { var block = StdInsertBlock(); Assert.Contains("ROW_NUMBER() OVER (", block); Assert.Contains("PARTITION BY x0.tenant_id, JSON_UNQUOTE(JSON_EXTRACT(x0.raw_data,'$.PurOrd'))", block); Assert.Contains("PARTITION BY y0.tenant_id, JSON_UNQUOTE(JSON_EXTRACT(y0.raw_data,'$.ItemNum'))", block); // 可执行 SQL 里不得出现 GROUP_CONCAT(注释里提到它是允许的)。 var sqlOnly = string.Join('\n', block.Split('\n').Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal))); Assert.DoesNotContain("GROUP_CONCAT", sqlOnly); } /// /// 每轮淘汰必须存在,且只在全量路径执行。 /// 只加批次过滤只能让死行「可识别」,不能「退役」—— 运行期链路里没有任何 DELETE。 /// 而增量批次只含变更行,按它淘汰会把未变更的存量行全删掉,所以必须按路径分流。 /// [Fact] public void StdBuild_RetiresStaleRows_OnlyOnFullPath() { var code = S3Service(); Assert.Contains("DELETE s FROM mdp_std_purchase_order s", code); Assert.Contains("IFNULL(s.sync_batch_id,'') <> @BatchId", code); // 安全闸门必须用 mdp_sync_log 的成功记录,而不是「贴源层有没有行」—— // 后者会把源侧合法清空的租户永久锁在幽灵行上。 Assert.Contains("FROM mdp_sync_log l", code); Assert.Contains("l.status='SUCCESS'", code); // 路径分流 Assert.Contains("BuildStandardCommands(string batchId, DateTime now, bool retireStale)", code); Assert.Contains("TransformStandardAsync(batchId, now, retireStale: true, cancellationToken)", code); Assert.Contains("TransformStandardAsync(batchId, now, retireStale: false, cancellationToken)", code); } /// 取一条语句的 SQL 区间(从锚点到原始字符串字面量的收尾 """)。 private static string StatementBlock(string startAnchor) { var code = S3Service(); var start = At(code, startAnchor); var end = code.IndexOf("\"\"\"", start, StringComparison.Ordinal); Assert.True(end > start, $"语句未正常收尾:{startAnchor}"); return code[start..end]; } /// /// 本文件新增/改写的每条 SQL,经 处理后 /// 都不得出现「注入到不存在的列上」。 /// /// 为什么必须用真注入器跑一遍,而不是比对源码文本:这个坑有两个入口, /// 光看源码任何一处都不显眼,只有跑一遍注入才暴露。二者本地沙箱都实测炸过: /// /// /// 空前缀 —— ResolveSourcePrefix 反向找最近的同层 FROM 表 别名。 /// 派生表外层是 FROM (,别名正则匹配不到括号,于是回退成空前缀,生成裸 /// factory_id;派生表 SELECT 列表里没有这一列 → 整轮转换失败。 /// 别名指向没有该列的表 —— 淘汰语句的 EXISTS 子查询若写成 /// l.tenant_id = s.tenant_id(关联外层、不含 @TenantId),会被注入 /// COALESCE(NULLIF(l.factory_id,0),1)=@FactoryId,而 mdp_sync_log 没有 /// factory_id 列 → 同样整轮失败。 /// /// /// 失败形态是整轮跑批 FAILED,不是少几行数据 —— 而 Authority 一轮不产出, /// S8 的恢复闸门就判 DEGRADED、整个租户的恢复停摆。所以这条守卫值得用真注入器跑。 /// [Theory] [InlineData("INSERT INTO mdp_std_purchase_order")] [InlineData("DELETE s FROM mdp_std_purchase_order s")] [InlineData("DELETE w FROM dwd_supplier_delivery w")] public void ScopeInjection_NeverTargetsAColumnThatDoesNotExist(string anchor) { var injected = MdpSqlScope.InjectTenantFactory(StatementBlock(anchor)); // ① 空前缀:注入器没能定位别名。在本文件里所有表都带别名,因此这必定是缺陷。 Assert.DoesNotContain("WHERE tenant_id=@TenantId", injected); // ② 前缀指向的表没有 factory_id:派生表别名(x/y/m/i)与 mdp_sync_log(l)都没有这一列。 foreach (var alias in new[] { "x", "y", "m", "i", "l" }) Assert.DoesNotContain($"NULLIF({alias}.factory_id", injected); } /// /// 驱动行必须**显式**带上租户与工厂谓词,不能依赖注入器补。 /// /// 历史上它确实是靠注入的:ResolveSourcePrefix 反向找到 FROM ... d /// 就能补出 d. 前缀。但两个 enrichment 派生表插进 FROM ... d 与本 /// WHERE 之间以后,反向扫描会先撞上派生表的 FROM (,别名正则匹配不到括号 /// → 回退成空前缀,生成裸 tenant_id。而派生表 m / i 也各自暴露 tenant_id, /// 该列立刻变成歧义列 —— 整轮转换 FAILED,不是少几行。 /// /// 所以这不是风格偏好:只要这条 INSERT 还带派生表,作用域就必须写死。 /// [Fact] public void DrivingRows_CarryTenantAndFactoryExplicitly() { Assert.Contains( "WHERE d.tenant_id=@TenantId AND COALESCE(NULLIF(d.factory_id,0),1)=@FactoryId", StatementBlock("INSERT INTO mdp_std_purchase_order")); } /// /// Rule01 Authority 链上的三条语句必须**完全自带**作用域 —— 注入对它们应当是恒等变换。 /// /// 这是本文件与注入器之间的边界声明:这三条 SQL 的作用域由字面 SQL 唯一决定, /// 不交给一个够不到 JOIN...ON、认不出派生表别名、且对 SQL 注释同样生效的正则去猜。 /// 恒等变换是这里唯一可接受的结果;任何非恒等都意味着有一个 WHERE 漏了 /// @TenantId,而它下一步会以整轮跑批失败的形式暴露。 /// [Theory] [InlineData("INSERT INTO mdp_std_purchase_order")] [InlineData("DELETE s FROM mdp_std_purchase_order s")] [InlineData("DELETE w FROM dwd_supplier_delivery w")] public void AuthorityStatements_AreFullySelfScoped(string anchor) { var raw = StatementBlock(anchor); Assert.Equal(raw, MdpSqlScope.InjectTenantFactory(raw)); } /// /// DWD 必须做同日淘汰,且**放在 INSERT 之后**。 /// 放之前会让快照短暂为空;此处无事务,并发的 Rule01 取数会读到空快照, /// 把全部在办异常判成已恢复 —— 那正是 Authority 健康门禁要防的形态。 /// 该淘汰同时保证 MAX(stat_date) 取到的是单一批次:实测同一天混入两个批次 /// 已发生 3 次(如 2026-06-07 的 178 行 + 7 行)。 /// [Fact] public void DwdBuild_RetiresSameDayStaleRows_AfterInsert() { var code = S3Service(); var insertAt = At(code, "INSERT INTO dwd_supplier_delivery"); var deleteAt = code.IndexOf("DELETE w FROM dwd_supplier_delivery w", insertAt, StringComparison.Ordinal); Assert.True(deleteAt > insertAt, "同日淘汰必须在 INSERT 之后,否则快照会短暂为空"); Assert.Contains("w.stat_date=@StatDate", code); Assert.Contains("IFNULL(w.sync_batch_id,'') <> @BatchId", code); Assert.Contains("BuildDwdAsync(batchId, now, retireStale: true, result, cancellationToken)", code); Assert.Contains("BuildDwdAsync(batchId, now, retireStale: false, result, cancellationToken)", code); } /// /// dwd_supplier_deliverymdp_std_purchase_order 在运行期只能有一个写入方。 /// /// 它们是 Rule01 的 Authority。此前另有两个业务 API 直写: /// ProcessOutsourceOrderServicedelivery_status 硬编码为 'OPEN' 并放进 /// ON DUPLICATE KEY UPDATE,一次建单就能把 MDP 刚判定的 COMPLETED / DELAYED 无条件翻回 OPEN; /// DeliveryScheduleService 则写出 CANCELLED / CLOSED / PARTIAL 三个没有任何读方认识的值。 /// /// 更严重的是它们能凭空造出一个「最新快照」:Rule01 取 MAX(stat_date) 那一天的全部行, /// 实测租户 797403760988229 的 2026-05-16 快照只有 2 行、且全部出自旁路写入 —— /// 中台停摆时,一次业务 API 调用就让 Rule01 在 2 行残缺数据上评估,而快照看上去还很新鲜。 /// /// 用「非注释行里出现写入语句」判定,避免被解释性注释里的表名误判。 /// [Fact] public void AuthorityTables_HaveExactlyOneRuntimeWriter() { // (文件, 表, 期望写入处数) var expectations = new[] { ("S3MdpSyncTransformService.cs", "dwd_supplier_delivery", 1), ("DeliveryScheduleService.cs", "dwd_supplier_delivery", 0), ("ProcessOutsourceOrderService.cs","dwd_supplier_delivery", 0), ("S3MdpSyncTransformService.cs", "mdp_std_purchase_order", 1), ("DeliveryScheduleService.cs", "mdp_std_purchase_order", 0), ("ProcessOutsourceOrderService.cs","mdp_std_purchase_order", 0) }; foreach (var (file, table, expected) in expectations) { var writes = Source("Supply", file).Split('\n') .Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal)) .Count(l => l.Contains($"INSERT INTO {table}", StringComparison.Ordinal) || l.Contains($"UPDATE {table}", StringComparison.Ordinal)); Assert.True(writes == expected, $"{file} 对 {table} 的运行期写入应为 {expected} 处,实测 {writes} 处"); } } /// /// 旁路写入方不得把 delivery_status 硬编码后写进 Authority。 /// 状态只能由正式 MDP 转换从源侧派生(COMPLETED / DELAYED / OPEN 三值)。 /// [Fact] public void BypassServices_NoLongerHardcodeDeliveryStatus() { foreach (var f in new[] { "DeliveryScheduleService.cs", "ProcessOutsourceOrderService.cs" }) { var code = Source("Supply", f); var sqlOnly = string.Join('\n', code.Split('\n') .Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal))); Assert.DoesNotContain("delivery_status=VALUES(delivery_status)", sqlOnly); Assert.DoesNotContain("'CANCELLED'", sqlOnly); Assert.DoesNotContain("'PARTIAL'", sqlOnly); } } /// /// 租户是唯一安全边界,不得被工厂替代。 /// 该链的源侧根本没有可用的数字工厂号(源表只有 varchar 的 Site/POSite, /// 三个写入方都写字面量 1),所以 enrichment JOIN 只加租户、不加工厂 —— /// 加了反而会在 factory_id 为 NULL 与 1 混存时把行丢掉。 /// [Fact] public void StdBuild_EnrichmentJoins_DoNotSubstituteFactoryForTenant() { var block = StdInsertBlock(); // 不得出现"只按工厂不按租户"的写法。 Assert.DoesNotContain("m.factory_id = d.factory_id", block); Assert.DoesNotContain("i.factory_id = d.factory_id", block); } }