-- S8-TENANT-ONLY-BATCH6:把 S8 异常单据的唯一身份从 (Tenant, Factory, Code) 收敛为 (Tenant, Code)。 -- -- 本脚本与同批代码是**一个不可分割的单元**,但两边的耦合方向与 1.0.493 不同: -- 代码侧本批已把全部异常读路径改成只按 tenant_id 过滤,因此**只跑代码不跑脚本不会立刻出错**, -- 只是唯一约束不再表达真实不变量。真正的问题在于它会**默许**一种错误: -- 本批之后新建异常的 factory_id 恒为 0,而历史行 factory_id ≠ 0 -- (实测 5 个不同取值:797403760988231 / 838257237676101 / 838257186320453 / 1 / 838257212858437)。 -- 旧唯一键在这种混合状态下允许同一租户内出现两条相同 exception_code —— 一条旧工厂、一条 0。 -- 业务上 exception_code 是对外单号,同租户重号即错。 -- -- 变更(DDL 在 MySQL 下自动提交,脚本按"每步可独立重跑"设计,不依赖事务回滚): -- ① preflight:(tenant_id, exception_code) 是否已存在重复 —— 有则整脚本中止,不自动挑一条 -- ② 唯一键 uk(tenant_id, factory_id, exception_code) → uk(tenant_id, exception_code) -- ③ 复验:新唯一键必须存在且旧唯一键必须消失 -- -- 明确**不做**(每一条都是刻意的,不是遗漏): -- · 不 DROP 任何 factory_id 列。本批移除的是"隔离语义",不是 schema 美化; -- 历史行的 factory_id 仍是可解释的血缘信息。 -- · 不批量重写 ado_s8_exception.factory_id。把历史行改成 0 会抹掉"这条异常当年属于哪个工厂", -- 而这既非必要(读路径已不看它)也不可逆。 -- · 不动其余 10 个 (tenant_id, factory_id, ...) 辅助索引。它们的前导列是 tenant_id, -- 新查询仍能走前缀,不存在性能塌陷;为了整洁重建 10 个索引与本批风险不相称。 -- · 不动 1.0.486 / 1.0.487 / 1.0.493 三个已冻结脚本,也不动它们建立的任何归档表 -- (ado_s8_exception_bak_1_0_487 / *_dedup_bak_1_0_493 是还原点,改它们等于毁掉备份)。 -- · 不动 ado_s8_scene_config / ado_s8_notification_layer 等配置表的 factory_id 值。 -- 配置侧的"租户覆盖 > 平台默认"改判在代码层完成,(0,0) 平台默认哨兵语义保持不变。 -- -- 迁移前实测(2026-09-06,共享库 aidopdev 只读查询): -- ado_s8_exception 现有唯一键 : uk_ado_s8_exception_tenant_factory_code (tenant_id, factory_id, exception_code) -- (tenant_id, exception_code) 重复组 : 0 -- factory_id 取值分布 : 797403760988231(42) / 838257237676101(39) / 838257186320453(21) / 1(14) / 838257212858437(1) -- ============================================================================ -- ① PREFLIGHT:重号门禁 -- -- 若同租户下已存在重复 exception_code(历史上靠 factory 区分过), -- 收紧唯一键会直接失败在 ALTER 上,报错文案指向索引而不是业务原因。 -- 这里提前挡住,并把明细落表,让运维知道**具体哪几个单号**撞了。 -- -- 门禁实现说明:AutoVersionUpdate 对全文做子串匹配拒绝自定义语句分隔符指令, -- 连注释里出现那个关键字都会被拦,因此本文件通篇不出现该词,也就用不了存储过程 + SIGNAL。 -- 改用**具名 CHECK 约束**:约束名本身携带失败原因,MySQL 报错文案会带上它, -- 而该文案会被写进 sys_db_migration_log.error_message,运维在日志里直接看得到为什么被挡。 -- ============================================================================ SET @dup_exception_code := ( SELECT COUNT(*) FROM ( SELECT tenant_id, exception_code, COUNT(*) c FROM ado_s8_exception GROUP BY 1, 2 HAVING c > 1) z); DROP TABLE IF EXISTS ado_s8_tenant_only_dupcode_1_0_494; CREATE TABLE ado_s8_tenant_only_dupcode_1_0_494 ( tenant_id BIGINT NULL, exception_code VARCHAR(64) NULL, factory_ids VARCHAR(512) NULL, row_count INT NULL ) COMMENT 'S8-TENANT-ONLY-BATCH6 同租户重复 exception_code 明细(为空表示无重复)'; INSERT INTO ado_s8_tenant_only_dupcode_1_0_494 (tenant_id, exception_code, factory_ids, row_count) SELECT tenant_id, exception_code, GROUP_CONCAT(DISTINCT factory_id), COUNT(*) FROM ado_s8_exception GROUP BY 1, 2 HAVING COUNT(*) > 1; -- 门禁本体:probe 非 0 即违反 CHECK,脚本在**任何 DDL 之前**失败。 DROP TABLE IF EXISTS tmp_s8_494_preflight_guard; CREATE TABLE tmp_s8_494_preflight_guard ( probe INT NOT NULL, CONSTRAINT s8_tenant_only_BLOCKED_see_dupcode_table_1_0_494 CHECK (probe = 0) ) COMMENT 'S8-TENANT-ONLY-BATCH6 preflight 门禁;成功后自动删除'; INSERT INTO tmp_s8_494_preflight_guard (probe) VALUES (@dup_exception_code); DROP TABLE IF EXISTS tmp_s8_494_preflight_guard; -- ============================================================================ -- ② 收紧唯一键 -- -- 顺序是先建新、后删旧:中间态多一个索引没有正确性问题, -- 而"先删旧"若在建新时失败,表会短暂失去单号唯一性保护。 -- 用 information_schema 判存在性:MySQL 无 CREATE/DROP INDEX IF EXISTS。 -- ============================================================================ SET @has_new_uk := (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_exception' AND INDEX_NAME = 'uk_ado_s8_exception_tenant_code'); SET @sql := IF(@has_new_uk = 0, 'ALTER TABLE ado_s8_exception ADD UNIQUE INDEX uk_ado_s8_exception_tenant_code (tenant_id, exception_code)', 'DO 0'); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @has_old_uk := (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_exception' AND INDEX_NAME = 'uk_ado_s8_exception_tenant_factory_code'); SET @sql := IF(@has_old_uk > 0, 'ALTER TABLE ado_s8_exception DROP INDEX uk_ado_s8_exception_tenant_factory_code', 'DO 0'); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- ============================================================================ -- ③ POSTCHECK:新键在、旧键不在 -- -- 只做 DDL 不复验,等于把"改没改成"交给下一次故障去告诉你。 -- ============================================================================ SET @post_new_uk := (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_exception' AND INDEX_NAME = 'uk_ado_s8_exception_tenant_code' AND NON_UNIQUE = 0); SET @post_old_uk := (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_exception' AND INDEX_NAME = 'uk_ado_s8_exception_tenant_factory_code'); DROP TABLE IF EXISTS tmp_s8_494_postcheck_guard; CREATE TABLE tmp_s8_494_postcheck_guard ( probe INT NOT NULL, CONSTRAINT s8_tenant_only_POSTCHECK_FAILED_1_0_494 CHECK (probe = 0) ) COMMENT 'S8-TENANT-ONLY-BATCH6 postcheck 门禁;成功后自动删除'; -- 期望:新唯一键恰好 2 个列条目(tenant_id, exception_code),旧唯一键 0 个条目。 INSERT INTO tmp_s8_494_postcheck_guard (probe) VALUES ((CASE WHEN @post_new_uk = 2 THEN 0 ELSE 1 END) + @post_old_uk); DROP TABLE IF EXISTS tmp_s8_494_postcheck_guard; -- preflight 明细表为空即无重复;保留空表作为"本次迁移确实检查过"的留痕, -- 与 1.0.493 的 collision 表同一处理方式(不自动 DROP,交运维按需清理)。