-- ===================================================================================== -- 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 必然同为 SO0001。 -- entry_seq 是 1 起的行号(SeOrderService.cs:339),⇒ biz_key 必然同为 SO0001#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`)