Forráskód Böngészése

fix(mdp): 修复销售订单标准层当前快照语义与贴源层租户键

一、mdp_std_so 不是源的镜像,而是「历史累积」

贴源层 mdp_stg_so 是纯 upsert、从不淘汰行;标准层的 INSERT 又不带任何批次条件、
每轮重读整张贴源表。于是源侧被硬删除的订单行会永远留在标准层,并且每轮被重新
物化、sync_batch_id 与 sync_time 被刷成最新 —— 看时间戳根本认不出它们是陈旧数据。

实测租户 797403760988229:标准层 414 行,源侧实际只剩 28 行。其余 386 行中
361 行是源侧已删除的孤儿,25 行是 biz_key 分隔符由 ':' 改成 '#'(1.0.259)之后
被弃用的旧格式重复行。另外三个租户 339/14/9 行与源侧逐行一致。

影响面不止 S8:mdp_std_so 有 16 个读取点且无一带「当前行」判据 ——
dwd_ship_trans 以它为驱动表(每条孤儿凭空造出一条早已过期、必判 DELAYED 的发运行),
S1_L1_001/002/003、S1_L2_010/011/012、S7_L1_001/002/003、S9_L1_003 的分子分母
都被污染。

修法不新增「当前标记」列,而是恢复标准层的镜像语义,这样 16 个消费者一个都不用改:
标准层构建只取每个租户的最新一次 sync_batch_id;1.0.521 一次性清掉已沉淀的孤儿。

前提是该实体每轮都全量重读,故 1.0.521 同时把 S1_SEORDER / S1_SEORDER_ENTRY 的
sync_mode 由 INCR 改为 FULL —— MdpSyncWindowResolver 对 FULL 实体强制
ctx.FullRefresh=true,从而在任何调用路径上都跳过增量水位,包括 RunInboundAsync
与 MdpHotWatchService 这两条 fullRefresh=false 的路径。若维持 INCR,那两条路径会
产出「只含变更行」的部分批次,最新批次过滤会把未变更的存量行当成孤儿删掉,
后果比原缺陷更严重。代价可忽略:两个实体分别只有约 390 / 81 行,且 S1 全量作业
本来就每轮以 fullRefresh:true 全量重读它们,本次只是让声明与实际行为一致。

清理带安全闸门:仅当该租户在贴源层确实有数据时才淘汰,否则「最新批次」子查询返回
NULL 会把该租户的标准层整个删空。

二、mdp_stg_so 的唯一键缺 tenant_id,存在跨租户互相覆盖的写入路径

自 1.0.130 建表起 uk_source_key=(source_system, source_table, source_biz_key),
三个键列全部与租户无关,而同批建的 mdp_std_so 是含 tenant_id 的。贴源 upsert 的
ON DUPLICATE KEY UPDATE 里带 tenant_id=VALUES(tenant_id),所以冲突时不只是丢行 ——
该行的租户归属会被静默改写成后写者。

不是理论风险:单号生成器 SeOrderService.GenerateBillNoAsync 是每租户各自从 0001
起编的(前缀无租户分量),任意两个租户同一天各自建的第一张订单必然同为
SO<yyyymmdd>0001#1;源表只有 PRIMARY(Id),没有唯一约束兜底;实测
ado_contract_review 的 BillNo='CR2026040001' 已有 3 行分属 3 个租户,
而该实体 biz_key_expr 就是单独一个 BillNo。当前跨租户重复数为 0 是同义反复 ——
唯一索引本身就让重复不可观测,覆盖在原地发生、不留痕迹。

1.0.520 用单条原子 ALTER(DROP+ADD 同一语句)把 tenant_id 补进唯一键:
迁移执行器按 ';' 拆句且不带事务,两段式写法可能让表短暂没有唯一键,
upsert 会从「覆盖」退化成「无限追加」,比原缺陷更糟。

本地实测:迁移后租户 797 标准层 414→28,四个租户孤儿均为 0;连续三轮全量同步
孤儿保持 0,不再累积。

已知未处理:mdp_stg_ship_trans 由 CREATE TABLE LIKE mdp_stg_so 建出,继承同一
缺陷且暴露更大(986 行 / 5 个租户),需单独批次处理。

server 1.0.519 → 1.0.521(迁移 1.0.520.sql / 1.0.521.sql)

迁移取号:origin/master 已由 S0 批次占用 1.0.519(csproj 1.0.518 -> 1.0.519,
未附 SQL),故本批迁移按提交时刻重新取号为 1.0.520 / 1.0.521,
服务端版本随之为 1.0.521;csproj Copy 条目与脚本内自述号同批同步。
YY968XX 1 napja
szülő
commit
e60cb7e29c

+ 15 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.519</AssemblyVersion>
-    <FileVersion>1.0.519</FileVersion>
-    <Version>1.0.519</Version>
+    <AssemblyVersion>1.0.521</AssemblyVersion>
+    <FileVersion>1.0.521</FileVersion>
+    <Version>1.0.521</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -775,6 +775,18 @@
     <None Update="UpdateScripts\1.0.517.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.520.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.520.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.521.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.521.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 110 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.520.sql

@@ -0,0 +1,110 @@
+-- =====================================================================================
+-- 1.0.520 · S1 · mdp_stg_so.uk_source_key 补 tenant_id,关闭跨租户互相覆盖的写入路径
+--
+-- ── 缺陷 ────────────────────────────────────────────────────────────────────────────
+-- mdp_stg_so 自 1.0.130 建表起,唯一键就是
+--     uk_source_key (source_system, source_table, source_biz_key)      ← 不含 tenant_id
+-- 而同批建的标准层 mdp_std_so 是
+--     uk_source_key (tenant_id, source_system, source_table, source_biz_key)
+-- 两层口径不一致,且贴源层是「少了」隔离列的那一层。
+--
+-- 贴源写入走 DataPlatform/Executors/MdpStagingWriter.cs 的
+--     INSERT ... ON DUPLICATE KEY UPDATE ... raw_data=VALUES(raw_data), tenant_id=VALUES(tenant_id)
+-- 冲突判定用的就是上面这个唯一键。三个键列全都与租户无关:
+--   · source_system = mdp_source.source_code,实测全部 tenant_id=0 的全局行(AIDOPDEV_MYSQL 等),不区分租户
+--   · source_table  = 源表名,全租户同名
+--   · source_biz_key= mdp_entity.biz_key_expr 取值后用 # 连接,取的是业务单号,本身不含租户
+-- ⇒ 租户 A 的 SO001#1 与租户 B 的 SO001#1 落在同一行。后写者覆盖 raw_data,
+--    并且把 tenant_id 一起改写成自己 —— 该行的租户归属被静默篡改,A 的数据无痕消失。
+--
+-- ── 为什么这不是「理论风险」────────────────────────────────────────────────────────
+-- ① 单号生成器本身就是每租户各自从 0001 起编的:
+--    Order/SeOrderService.cs:379 GenerateBillNoAsync = "SO" + yyyyMMdd + 4 位流水,
+--    取最大值时带 `u.TenantId == tenantId` 过滤,前缀里没有任何租户分量。
+--    ⇒ 任意两个租户在同一天各自建的第一张订单,bill_no 必然同为 SO<yyyymmdd>0001。
+--    entry_seq 是 1 起的行号(SeOrderService.cs:339),⇒ biz_key 必然同为 SO<yyyymmdd>0001#1。
+-- ② 源表没有任何唯一约束兜底:crm_seorder / crm_seorderentry 实测只有 PRIMARY(Id)。
+-- ③ 实测已经存在同号跨租户行:ado_contract_review 的 BillNo='CR2026040001' 有 3 行,
+--    分属 tenant_id = NULL / 1300000000001 / 797403760988229,而该实体的
+--    biz_key_expr 就是单独一个 'BillNo'。
+-- ④ mdp_stg_so 早已是多租户共存:实测 4 个租户、crm_seorderentry 与 crm_seorder
+--    两张源表各自都横跨全部 4 个租户,共用 source_system='AIDOPDEV_MYSQL'。
+--
+-- ⚠️ 「今天查不到重复」不能作为安全证据:唯一键本身就使重复无法存在,
+--    覆盖是就地发生的,不留任何痕迹。要判的是结构契约,不是当前数据。
+--
+-- ── 为什么不需要先去重 ──────────────────────────────────────────────────────────────
+-- 本批是把唯一键「加宽」(3 列 → 4 列)。任何在 (ss,st,sbk) 上唯一的行集,
+-- 在 (tenant_id,ss,st,sbk) 上必然也唯一 —— 加宽只会放松约束,不可能新增冲突。
+-- 故与 1.0.356 那次「同宽度重排 + 清脏租户」不同,本批的 ALTER 不存在因存量行失败的可能。
+-- 实测双库均为 0 组冲突(aidopdev 39583 行 / aidopdev_s8_local 42122 行)。
+-- 下面第 1 步的去重因此在两个库上都是 0 行的空操作,保留它只为覆盖一种情况:
+-- 某个库的 uk_source_key 曾被人手工 DROP 掉(此时存量可能已有重复,直接 ADD UNIQUE 会失败)。
+--
+-- ── 为什么第 2 步必须是「一条 ALTER」────────────────────────────────────────────────
+-- AutoVersionUpdate 按 ; 拆句逐条执行且全程无事务(Update/AutoVersionUpdate.cs)。
+-- 若拆成 DROP INDEX 与 ADD UNIQUE 两句,中间一旦失败,表会停在「完全没有唯一键」的状态,
+-- 比原缺陷更危险。合成一条 ALTER 后,该步要么整体成功、要么整体不发生。
+-- 索引名保持 uk_source_key 不变:1.0.356 里的 IGNORE INDEX (`uk_source_key`) 仍需按名命中,
+-- 且与 mdp_std_so 的同名键保持对称。
+--
+-- ── 不在本批范围 ────────────────────────────────────────────────────────────────────
+-- mdp_stg_ship_trans 由 1.0.130 用 CREATE TABLE ... LIKE mdp_stg_so 建出,继承了同一缺陷,
+-- 实测 986 行横跨 5 个租户。本批不动它:其上下游消费方未在本次审计范围内,
+-- 加宽唯一键会改变它的 upsert 归并行为,须单独评估后另开批次。
+-- =====================================================================================
+
+SET @tbl := (SELECT COUNT(*) FROM information_schema.TABLES
+             WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mdp_stg_so');
+
+-- 当前 uk_source_key 的列序列。索引不存在时取空串,便于下面三种分支互斥判定。
+SET @uk := IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX)
+                   FROM information_schema.STATISTICS
+                   WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mdp_stg_so'
+                     AND INDEX_NAME = 'uk_source_key'), '');
+
+-- ── 1 · 防御性去重(唯一键已在位时恒为 0 行,见上方论证)────────────────────────────
+-- 每个 (tenant_id, source_system, source_table, source_biz_key) 组只保留最新一行,
+-- 口径与 1.0.356 一致:update_time DESC, id DESC。
+-- 不用 IGNORE INDEX:本步恰恰要覆盖「索引已被人工删除」的库,此时该 hint 会直接报错。
+SET @sql := IF(
+  @tbl = 1 AND @uk <> 'tenant_id,source_system,source_table,source_biz_key',
+  'DELETE FROM `mdp_stg_so` WHERE `id` IN (
+     SELECT `id` FROM (
+       SELECT `id`,
+              ROW_NUMBER() OVER (
+                PARTITION BY `tenant_id`,`source_system`,`source_table`,`source_biz_key`
+                ORDER BY `update_time` DESC,`id` DESC) AS `rn`
+       FROM `mdp_stg_so`
+     ) duplicate_rows
+     WHERE `rn` > 1
+   )',
+  'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ── 2 · 旧的三列窄键 → 四列租户安全键(单条 ALTER,原子)────────────────────────────
+SET @sql := IF(
+  @tbl = 1 AND @uk = 'source_system,source_table,source_biz_key',
+  'ALTER TABLE `mdp_stg_so`
+     DROP INDEX `uk_source_key`,
+     ADD UNIQUE KEY `uk_source_key` (`tenant_id`,`source_system`,`source_table`,`source_biz_key`)',
+  'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ── 3 · 索引整个不存在(曾被人工 DROP)→ 直接补建 ───────────────────────────────────
+-- 走到这里时第 1 步已经把存量去重过,ADD UNIQUE 不会因重复失败。
+SET @sql := IF(
+  @tbl = 1 AND @uk = '',
+  'ALTER TABLE `mdp_stg_so`
+     ADD UNIQUE KEY `uk_source_key` (`tenant_id`,`source_system`,`source_table`,`source_biz_key`)',
+  'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 幂等性:重跑时 @uk 已是四列目标值 ⇒ 三步全部落 'DO 0',不改任何东西。
+
+-- ── 回滚(注释,非可执行)──
+-- 回滚会重新打开跨租户覆盖路径,且此前已按租户分开落库的行会在窄键下冲突、导致 ALTER 失败,
+-- 需先按 (source_system, source_table, source_biz_key) 去重才能退回。故此处不提供可直接执行的回滚。
+-- ALTER TABLE `mdp_stg_so`
+--   DROP INDEX `uk_source_key`,
+--   ADD UNIQUE KEY `uk_source_key` (`source_system`,`source_table`,`source_biz_key`)

+ 57 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.520.verify.sql

@@ -0,0 +1,57 @@
+-- 1.0.520.verify.sql — 每条 SELECT 都必须返回真值(0 或 NULL 即判失败并中断启动)
+
+-- ── 1 · 新键到位:四列、列序正确 ─────────────────────────────────────────────────────
+-- 与 1.0.517.verify 同法,用 GROUP_CONCAT 比列序列字符串,不对 information_schema 施加 COLLATE
+-- (部分 MySQL 版本上它是 utf8mb3,强加 utf8mb4_bin 会让 verify 脚本自己把启动打挂)。
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mdp_stg_so'
+                 AND INDEX_NAME = 'uk_source_key'), '')
+       = 'tenant_id,source_system,source_table,source_biz_key';
+
+-- tenant_id 必须是第 1 列:贴源的绝大多数读法都以 tenant_id 打头,
+-- 放在末列虽然唯一性等价,却让这个索引无法为租户内检索提供前缀。
+SELECT (SELECT SEQ_IN_INDEX FROM information_schema.STATISTICS
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mdp_stg_so'
+          AND INDEX_NAME = 'uk_source_key' AND COLUMN_NAME = 'tenant_id') = 1;
+
+-- 必须仍然是 UNIQUE:若退化成普通索引,upsert 的 ON DUPLICATE KEY 会整个失效,
+-- 贴源从「覆盖」变成「无限追加」,比原缺陷更糟。
+SELECT (SELECT COUNT(*) FROM information_schema.STATISTICS
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mdp_stg_so'
+          AND INDEX_NAME = 'uk_source_key' AND NON_UNIQUE = 0) = 4;
+
+-- ── 2 · 跨租户覆盖路径已关闭 ─────────────────────────────────────────────────────────
+-- 本条是本批的核心断言:mdp_stg_so 上不得再存在任何「不含 tenant_id 的唯一约束」。
+-- 只钉 uk_source_key 是不够的 —— 任何一个漏了 tenant_id 的唯一索引都会重新变成
+-- ON DUPLICATE KEY 的冲突判据,把跨租户覆盖路径再打开一次。
+SELECT (SELECT COUNT(*) FROM (
+          SELECT INDEX_NAME FROM information_schema.STATISTICS
+          WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mdp_stg_so'
+            AND NON_UNIQUE = 0 AND INDEX_NAME <> 'PRIMARY'
+          GROUP BY INDEX_NAME
+          HAVING SUM(COLUMN_NAME = 'tenant_id') = 0) x) = 0;
+
+-- 存量在新键下无冲突(唯一索引在位时必然成立,作为落库后的兜底自检)。
+SELECT (SELECT COUNT(*) FROM (
+          SELECT 1 FROM `mdp_stg_so`
+          GROUP BY `tenant_id`,`source_system`,`source_table`,`source_biz_key`
+          HAVING COUNT(*) > 1) x) = 0;
+
+-- ── 3 · 贴源层与标准层口径已对齐 ─────────────────────────────────────────────────────
+-- 本批的意义就是消除两层不一致,故把 mdp_std_so 的键一起钉住,防止有人反向「对齐」到窄键。
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mdp_std_so'
+                 AND INDEX_NAME = 'uk_source_key' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,source_system,source_table,source_biz_key';
+
+-- ── 4 · 1.0.356 建立的作用域 CHECK 未被本批动过 ──────────────────────────────────────
+-- 该 CHECK 保证 tenant_id 非空且不是平台/默认租户,是「键里有 tenant_id」能真正隔离的前提:
+-- 若 tenant_id 可为 NULL,MySQL 唯一索引会把多个 NULL 视为互不相同,隔离与去重会同时失效。
+SELECT (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
+        WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'mdp_stg_so'
+          AND CONSTRAINT_NAME = 'ck_mdp_stg_so_valid_scope') = 1;
+
+-- 存量 tenant_id 必须真的可用:无 NULL、无 0/1/默认租户。
+-- 键里有 tenant_id 但该列是空的,隔离等于没做。
+SELECT (SELECT COUNT(*) FROM `mdp_stg_so`
+        WHERE `tenant_id` IS NULL OR `tenant_id` IN (0, 1, 1300000000001)) = 0;

+ 70 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.521.sql

@@ -0,0 +1,70 @@
+-- =====================================================================================
+-- 1.0.521 · S1 · 让 mdp_std_so 成为销售订单行的真实镜像(淘汰源侧已删除的孤儿)
+--
+-- ── 缺陷 ────────────────────────────────────────────────────────────────────────────
+-- 贴源层 mdp_stg_so 是纯 upsert、从不淘汰行;标准层 mdp_std_so 的 INSERT 又不带任何
+-- 批次条件、每轮重读整张贴源表。于是源侧被硬删除的订单行会:
+--   ① 永远留在 mdp_stg_so;
+--   ② 每轮被重新物化进 mdp_std_so,并且 sync_batch_id / sync_time 被刷成最新
+--      —— 所以「看时间戳」根本认不出它们是陈旧数据。
+--
+-- 实测(aidopdev,2026-09-08)租户 797403760988229:
+--   mdp_std_so 414 行 = 28 行真实存活 + 25 行 biz_key 旧分隔符(':')的重复行
+--                      + 361 行源侧已删除的孤儿
+--   其余三个租户 339/14/9 行,与源侧逐行一致,孤儿为 0。
+--
+-- ── 影响面不止 S8 ───────────────────────────────────────────────────────────────────
+-- mdp_std_so 有 16 个读取点,且没有任何一个带「当前行」判据:
+--   · dwd_ship_trans 以它为驱动表 —— 每条孤儿凭空造出一条发运行,日期早已过期,
+--     必然被判 DELAYED / 高风险;
+--   · S1_L1_001/002/003、S1_L2_010/011/012、S7_L1_001/002/003、S9_L1_003 等 KPI
+--     的分子分母都被孤儿污染;
+--   · S8 Rule02(订单交付延期预警)若改读中台,孤儿会直接变成凭空的异常单。
+--
+-- ── 修法 ────────────────────────────────────────────────────────────────────────────
+-- 不新增「当前标记」列,而是让标准层恢复「镜像」语义,这样 16 个消费者一个都不用改:
+--   ① 本脚本把 S1_SEORDER / S1_SEORDER_ENTRY 的 sync_mode 由 INCR 改为 FULL;
+--   ② 代码侧(S1MdpSyncTransformService 的 mdp_std_so 构建)只取每个租户的
+--      最新一次 sync_batch_id;
+--   ③ 本脚本一次性清掉标准层里已经沉淀的孤儿。
+--
+-- 为什么 ① 是 ② 成立的前提:MdpSyncWindowResolver.Apply 对 sync_mode='FULL' 的实体
+-- 会强制 ctx.FullRefresh=true(:34-35),因而在**任何**调用路径上都跳过增量水位——
+-- 包括 RunInboundAsync 与 MdpHotWatchService 这两条 fullRefresh=false 的路径。
+-- 若维持 INCR,那两条路径会产出「只含变更行」的部分批次,② 会把未变更的存量行
+-- 当成孤儿删掉,后果比现在的缺陷更严重。
+--
+-- 代价可忽略:这两个实体实测分别只有 ~390 / ~81 行,且 S1 全量作业本来就每轮
+-- 以 fullRefresh:true 全量重读它们 —— 本次只是让「声明」与「实际行为」一致。
+-- =====================================================================================
+
+-- ── ① 让声明与实际行为一致:这两个实体每轮都是全量重读 ─────────────────────────────
+UPDATE `mdp_entity`
+   SET `sync_mode` = 'FULL',
+       `incr_column` = NULL,
+       `update_time` = NOW()
+ WHERE `entity_code` IN ('S1_SEORDER', 'S1_SEORDER_ENTRY')
+   AND `sync_mode` <> 'FULL';
+
+-- ── ② 一次性清除标准层已沉淀的孤儿 ──────────────────────────────────────────────────
+-- 判据:该 (tenant_id, source_biz_key) 不在贴源层「该租户最新一次 sync_batch_id」里。
+--
+-- ⚠️ 安全闸门 —— `EXISTS (该租户在贴源层确实有数据)`:
+--    若某租户在 mdp_stg_so 里一行都没有(从未同步 / 贴源被清过),
+--    「最新批次」子查询会返回 NULL,NOT EXISTS 对该租户的每一行都成立,
+--    没有这道闸门就会把该租户的标准层整个删空。这里宁可不删,也不冒清空的风险。
+DELETE s FROM `mdp_std_so` s
+ WHERE s.`source_table` = 'crm_seorderentry'
+   AND EXISTS (SELECT 1 FROM `mdp_stg_so` g
+                WHERE g.`source_table` = 'crm_seorderentry'
+                  AND g.`tenant_id` = s.`tenant_id`)
+   AND NOT EXISTS (
+        SELECT 1 FROM `mdp_stg_so` lb
+         WHERE lb.`source_table` = 'crm_seorderentry'
+           AND lb.`tenant_id` = s.`tenant_id`
+           AND lb.`source_biz_key` = s.`source_biz_key`
+           AND lb.`sync_batch_id` = (SELECT x.`sync_batch_id` FROM `mdp_stg_so` x
+                                      WHERE x.`source_table` = 'crm_seorderentry'
+                                        AND x.`tenant_id` = s.`tenant_id`
+                                      ORDER BY x.`sync_time` DESC, x.`id` DESC
+                                      LIMIT 1));

+ 51 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.521.verify.sql

@@ -0,0 +1,51 @@
+-- 1.0.521.verify.sql — 每条 SELECT 都必须返回 1
+
+-- ── ① 两个销售订单实体已声明为 FULL(这是「最新批次 = 源侧现存」成立的前提)──────────
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+         WHERE `entity_code` IN ('S1_SEORDER','S1_SEORDER_ENTRY')
+           AND `sync_mode` = 'FULL') = 2;
+
+-- 声明为 FULL 后不得再残留增量水位列,否则语义自相矛盾
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+         WHERE `entity_code` IN ('S1_SEORDER','S1_SEORDER_ENTRY')
+           AND `incr_column` IS NOT NULL) = 0;
+
+-- ── ② 标准层不得再有「不在贴源最新批次里」的订单行(孤儿已清空)────────────────────
+-- 与迁移同一判据,且同样带「该租户贴源确实有数据」的闸门。
+SELECT (SELECT COUNT(*) FROM `mdp_std_so` s
+         WHERE s.`source_table` = 'crm_seorderentry'
+           AND EXISTS (SELECT 1 FROM `mdp_stg_so` g
+                        WHERE g.`source_table` = 'crm_seorderentry'
+                          AND g.`tenant_id` = s.`tenant_id`)
+           AND NOT EXISTS (
+                SELECT 1 FROM `mdp_stg_so` lb
+                 WHERE lb.`source_table` = 'crm_seorderentry'
+                   AND lb.`tenant_id` = s.`tenant_id`
+                   AND lb.`source_biz_key` = s.`source_biz_key`
+                   AND lb.`sync_batch_id` = (SELECT x.`sync_batch_id` FROM `mdp_stg_so` x
+                                              WHERE x.`source_table` = 'crm_seorderentry'
+                                                AND x.`tenant_id` = s.`tenant_id`
+                                              ORDER BY x.`sync_time` DESC, x.`id` DESC
+                                              LIMIT 1))) = 0;
+
+-- ── ③ 清理不得误伤:贴源最新批次里的行必须都还在标准层 ──────────────────────────────
+-- 反向断言,防止「把孤儿连同存活行一起删干净」也能通过 ② 的情况。
+SELECT (SELECT COUNT(*) FROM `mdp_stg_so` lb
+         WHERE lb.`source_table` = 'crm_seorderentry'
+           AND lb.`sync_batch_id` = (SELECT x.`sync_batch_id` FROM `mdp_stg_so` x
+                                      WHERE x.`source_table` = 'crm_seorderentry'
+                                        AND x.`tenant_id` = lb.`tenant_id`
+                                      ORDER BY x.`sync_time` DESC, x.`id` DESC
+                                      LIMIT 1)
+           AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(lb.`raw_data`,'$.bill_no')),'') <> ''
+           AND NOT EXISTS (SELECT 1 FROM `mdp_std_so` s
+                            WHERE s.`source_table` = 'crm_seorderentry'
+                              AND s.`tenant_id` = lb.`tenant_id`
+                              AND s.`source_biz_key` = lb.`source_biz_key`)) = 0;
+
+-- ── ④ 标准层的唯一键未被本批动过(隔离列必须仍在)──────────────────────────────────
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX)
+                 FROM information_schema.STATISTICS
+                WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mdp_std_so'
+                  AND INDEX_NAME = 'uk_source_key' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,source_system,source_table,source_biz_key';

+ 187 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/MdpStagingSoTenantKeyContractTests.cs

@@ -0,0 +1,187 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
+
+/// <summary>
+/// mdp_stg_so 贴源唯一键必须含 tenant_id 的契约。
+///
+/// <para>背景:该表自 1.0.130 建表起,唯一键是 (source_system, source_table, source_biz_key),
+/// 三列全部与租户无关,而贴源写入是 <c>ON DUPLICATE KEY UPDATE ... tenant_id=VALUES(tenant_id)</c>
+/// (MdpStagingWriter.cs)。⇒ 两个租户的同名单号会落在同一行,后写者覆盖 raw_data
+/// 并把 tenant_id 一并改写成自己,租户归属被静默篡改且不留痕迹。
+/// 单号生成器 <c>SeOrderService.GenerateBillNoAsync</c> 是「每租户各自从 0001 起编」,
+/// 前缀无租户分量 ⇒ 同日建单的两个租户必然撞号,这不是理论风险。</para>
+///
+/// <para>为什么是源文本契约测试而不是跑库集成测试:本仓 DataPlatform/ 下既有测试
+/// (MdpStandardUpsertContractTests / T8KpiTenantIsolationContractTests /
+/// S1RequirementExamineDwdContractTests)一律用这种方式守护数据中台 SQL,
+/// 单测环境不连库。这里沿用同一范式。</para>
+/// </summary>
+public class MdpStagingSoTenantKeyContractTests
+{
+    private const string TenantSafeKey =
+        "ADD UNIQUE KEY `uk_source_key` (`tenant_id`,`source_system`,`source_table`,`source_biz_key`)";
+
+    private static string ScriptsDir()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null)
+        {
+            var scripts = Path.Combine(dir.FullName, "server", "Admin.NET.Web.Entry", "UpdateScripts");
+            if (Directory.Exists(scripts)) return scripts;
+            dir = dir.Parent;
+        }
+        throw new DirectoryNotFoundException("找不到 UpdateScripts 目录");
+    }
+
+    /// <summary>
+    /// 按<b>内容</b>而不是按文件名定位本批迁移:本仓版本号是「提交那一刻才取号」的,
+    /// 文件名在合并前后都可能顺延,写死 1.0.NNN.sql 的测试会因与被测契约无关的原因集体变红。
+    /// </summary>
+    private static string MigrationPath()
+    {
+        // 注意:通配 "1.0.*.sql" 也会命中 "1.0.NNN.verify.sql"(它同样以 .sql 结尾),必须排除。
+        var hit = Directory.GetFiles(ScriptsDir(), "1.0.*.sql")
+            .Where(f => !Path.GetFileName(f).EndsWith(".verify.sql", StringComparison.Ordinal))
+            .Where(f => File.ReadAllText(f).Contains("mdp_stg_so", StringComparison.Ordinal)
+                     && File.ReadAllText(f).Contains(TenantSafeKey, StringComparison.Ordinal))
+            .OrderBy(f => f, StringComparer.Ordinal)
+            .LastOrDefault();
+        if (hit == null)
+            throw new FileNotFoundException("找不到把 mdp_stg_so.uk_source_key 改为租户安全键的迁移脚本");
+        return hit;
+    }
+
+    /// <summary>
+    /// verify 脚本按<b>路径约定</b>取,与 AutoVersionUpdate 自己的取法一致
+    /// (Update/AutoVersionUpdate.cs 用 <c>Path.ChangeExtension(FilePath, ".verify.sql")</c>)。
+    /// 不按内容找:verify 里不会出现建索引的 DDL 原文。
+    /// </summary>
+    private static string VerifyPath()
+    {
+        var path = Path.ChangeExtension(MigrationPath(), ".verify.sql");
+        if (!File.Exists(path))
+            throw new FileNotFoundException($"迁移缺少配套 verify 脚本:{path}");
+        return path;
+    }
+
+    private static string Migration() => File.ReadAllText(MigrationPath());
+
+    private static string Verify() => File.ReadAllText(VerifyPath());
+
+    private static string Writer() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", "Executors", "MdpStagingWriter.cs"));
+
+    private static string FindFile(params string[] parts)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null)
+        {
+            var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
+            if (File.Exists(candidate)) return candidate;
+            dir = dir.Parent;
+        }
+        throw new FileNotFoundException($"找不到 {string.Join('/', parts)}");
+    }
+
+    // ── 1 · 新键必须含 tenant_id,且 tenant_id 在最前 ────────────────────────────────
+    // 列序不是可有可无的:tenant_id 放末列虽然唯一性等价,
+    // 却让该索引无法为「租户内检索」提供最左前缀。
+    [Fact]
+    public void Migration_RekeysStagingSo_WithTenantIdFirst()
+    {
+        var migration = Migration();
+
+        Assert.Contains(TenantSafeKey, migration);
+        Assert.Contains("DROP INDEX `uk_source_key`", migration);
+    }
+
+    // ── 2 · DROP 与 ADD 必须在同一条 ALTER 里 ────────────────────────────────────────
+    // AutoVersionUpdate 按 ; 拆句逐条执行且全程无事务。若拆成两句,中间一旦失败,
+    // 表会停在「完全没有唯一键」的状态 —— upsert 的 ON DUPLICATE KEY 整个失效,
+    // 贴源从「覆盖」退化为「无限追加」,比原缺陷更糟。
+    [Fact]
+    public void Migration_DropAndAdd_AreASingleAtomicStatement()
+    {
+        var migration = Migration();
+
+        var drop = migration.IndexOf("DROP INDEX `uk_source_key`", StringComparison.Ordinal);
+        var add = migration.IndexOf(TenantSafeKey, StringComparison.Ordinal);
+        Assert.True(drop >= 0 && add > drop, "应先 DROP 再 ADD,且同处一条 ALTER");
+
+        var between = migration.Substring(drop, add - drop);
+        Assert.DoesNotContain(";", between);
+    }
+
+    // ── 3 · 幂等 / 有护栏 ────────────────────────────────────────────────────────────
+    // 重跑时必须整体落空操作,而不是再 DROP 一次(索引已不是旧形状,DROP 会报错并打挂启动)。
+    [Fact]
+    public void Migration_IsGuardedAndIdempotent()
+    {
+        var migration = Migration();
+
+        // 先探当前索引形状,再决定走哪条分支
+        Assert.Contains("information_schema.STATISTICS", migration);
+        Assert.Contains("INDEX_NAME = 'uk_source_key'", migration);
+        // 不满足前提时必须落空操作
+        Assert.Contains("'DO 0'", migration);
+        Assert.Contains("PREPARE stmt FROM @sql", migration);
+    }
+
+    // ── 4 · 加宽唯一键前必须先处理存量 ───────────────────────────────────────────────
+    // 正常路径下(旧窄键仍在)加宽不可能失败,去重是 0 行空操作;
+    // 但对「唯一键曾被人工 DROP 掉」的库,存量可能已有重复,缺了这步 ADD UNIQUE 会直接失败。
+    [Fact]
+    public void Migration_DedupesByNewKey_BeforeAddingUniqueIndex()
+    {
+        var migration = Migration();
+
+        var dedup = migration.IndexOf("ROW_NUMBER() OVER", StringComparison.Ordinal);
+        Assert.True(dedup >= 0, "应包含按新键去重的兜底");
+
+        // 去重必须按「新键」分组,含 tenant_id;按旧窄键去重会跨租户删掉正当数据
+        Assert.Contains("PARTITION BY `tenant_id`,`source_system`,`source_table`,`source_biz_key`", migration);
+
+        Assert.True(dedup < migration.IndexOf(TenantSafeKey, StringComparison.Ordinal),
+            "去重必须发生在 ADD UNIQUE 之前");
+    }
+
+    // ── 5 · verify 必须真的钉住隔离,而不只是钉住这一个索引名 ────────────────────────
+    // 任何一个漏了 tenant_id 的唯一索引都会重新成为 ON DUPLICATE KEY 的冲突判据,
+    // 把跨租户覆盖路径再打开一次。
+    [Fact]
+    public void Verify_AssertsNoTenantlessUniqueIndexRemains()
+    {
+        var verify = Verify();
+
+        Assert.Contains("'tenant_id,source_system,source_table,source_biz_key'", verify);
+        Assert.Contains("NON_UNIQUE = 0", verify);
+        Assert.Contains("SUM(COLUMN_NAME = 'tenant_id') = 0", verify);
+
+        // tenant_id 可为 NULL 会让唯一索引把多个 NULL 视为互不相同,隔离与去重同时失效,
+        // 故 1.0.356 建立的作用域 CHECK 必须仍在。
+        Assert.Contains("ck_mdp_stg_so_valid_scope", verify);
+    }
+
+    // ── 6 · 贴源写入契约仍要求 tenant_id 列存在 ──────────────────────────────────────
+    // 键里有 tenant_id 但列被移除/不再写入,隔离等于没做。
+    [Fact]
+    public void StagingWriter_StillRequiresTenantIdColumn()
+    {
+        var writer = Writer();
+
+        Assert.Contains("\"tenant_id\"", writer);
+        Assert.Contains("RequiredCanonicalColumns", writer);
+        // upsert 冲突时会改写 tenant_id —— 正因如此,冲突键必须含 tenant_id
+        Assert.Contains("tenant_id=VALUES(tenant_id)", writer);
+    }
+
+    // ── 7 · 标准层不得被反向「对齐」到窄键 ───────────────────────────────────────────
+    // 本批的意义是消除贴源层与标准层的口径不一致;mdp_std_so 从 1.0.130 起就是对的,
+    // 不能有人为了「统一」把它改窄。
+    [Fact]
+    public void Verify_PinsStandardLayerKeyToo()
+    {
+        Assert.Contains("mdp_std_so", Verify());
+    }
+}

+ 23 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Order/S1MdpSyncTransformService.cs

@@ -1070,6 +1070,9 @@ public class S1MdpSyncTransformService : ITransient
             FROM mdp_stg_so e
             LEFT JOIN mdp_stg_so h ON h.source_table='crm_seorder'
                 AND h.tenant_id = e.tenant_id
+                AND h.sync_batch_id = (SELECT lb.sync_batch_id FROM mdp_stg_so lb
+                                       WHERE lb.source_table='crm_seorder' AND lb.tenant_id=@TenantId
+                                       ORDER BY lb.sync_time DESC, lb.id DESC LIMIT 1)
                 AND JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.Id')) = JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.seorder_id'))
             LEFT JOIN mdp_stg_so r ON r.source_table='ado_contract_review'
                 AND r.tenant_id = e.tenant_id
@@ -1077,6 +1080,26 @@ public class S1MdpSyncTransformService : ITransient
             WHERE e.source_table='crm_seorderentry'
               AND e.tenant_id=@TenantId
               AND COALESCE(NULLIF(e.factory_id, 0), 1)=@FactoryId
+              -- ── 只取「最新一次 sync_batch_id」,使 mdp_std_so 成为源的真实镜像 ────────────────
+              -- 贴源层是纯 upsert、从不淘汰:源侧硬删除的订单行会永远留在 mdp_stg_so 里,
+              -- 而本 INSERT 原先不带任何批次条件、每轮重读整张贴源表,于是把这些孤儿一并
+              -- 物化进标准层。实测租户 797403760988229:贴源 414 行,源侧实际只剩 28 行,
+              -- 其余 386 行中 361 行是源侧已删除的孤儿、25 行是 biz_key 分隔符从 ':' 改成 '#'
+              -- 之后被弃用的旧格式重复行(1.0.259 改的 biz_key_expr)。
+              --
+              -- 这些孤儿不是只影响 S8:mdp_std_so 同时驱动 dwd_ship_trans(每条孤儿凭空
+              -- 造出一条早已过期、必然判 DELAYED 的发运行)与 S1/S7/S9 的多项 KPI。
+              --
+              -- 该等价关系(不在最新批次 = 源侧已删除)成立的前提是该 entity 每轮都全量重读。
+              -- 本批已把 S1_SEORDER / S1_SEORDER_ENTRY 的 sync_mode 由 INCR 改为 FULL
+              -- (见 UpdateScripts/1.0.521.sql):MdpSyncWindowResolver 对 FULL 实体会强制
+              -- ctx.FullRefresh=true,从而在**任何**调用路径上都跳过增量水位(含
+              -- RunInboundAsync 与 MdpHotWatchService 这两条 fullRefresh=false 的路径)。
+              -- ⚠️ 若哪天把它们改回 INCREMENTAL,本过滤会把「未变更的存量行」误当孤儿丢掉,
+              --    届时必须回到贴源层整体替换的正解,不能只改这里。
+              AND e.sync_batch_id = (SELECT lb.sync_batch_id FROM mdp_stg_so lb
+                                     WHERE lb.source_table='crm_seorderentry' AND lb.tenant_id=@TenantId
+                                     ORDER BY lb.sync_time DESC, lb.id DESC LIMIT 1)
               AND IFNULL(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.bill_no')), JSON_UNQUOTE(JSON_EXTRACT(h.raw_data,'$.bill_no'))), '') <> ''
               AND COALESCE(NULLIF(e.tenant_id, 0), NULLIF(h.tenant_id, 0),
                          CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(e.raw_data,'$.tenant_id')) REGEXP '^[0-9]+$'