Просмотр исходного кода

fix(s8): 破坏性 migration 先归档再删除,并修复部分失败后不可恢复的缺陷

部署前专项复核 1.0.486 / 1.0.487 发现两类真实缺陷。两个脚本从未进入任何共享
环境(sys_db_migration_log 中 484~487 命中数 = 0,两张目标表与三个 Legacy 列
均完好),因此按纪律直接修当前本地脚本,不新增版本。

【缺陷一】待删数据存在人工处理痕迹,却直接物理删除
上一批把 34 条待删异常判为"仅创建事件、无真实处理链",依据是 timeline 行数比
(40/34≈1.18)——这个代理指标过粗。本轮逐列复核推翻该结论:
  · 2 条带完整 4 步人工流转链且**当前仍未闭环**(PENDING_VERIFICATION)
    EX-20260904-2F4715F1 / EX-20260904-DA45EB40
    CREATE → CLAIM(3172) → START_PROGRESS(838259724726341) → VERIFY_SUBMITTED
  · 1 条(id=391)带真实 recovered_at(2026-09-02 调度器 reconcile 写入)
备注为 "test"/"111",判断仍属 UAT 演练而非生产业务,故删除决定不变;但"有人
认领过、流程尚未闭环"这一事实本身不该被静默物理删除——出问题时无从解释。
改为归档后删除:删除行为不变,结果可解释、可追、可恢复。
  归档 watch_rule(132) + exception(34) + timeline(40) + notification_log(3)
       + decision(0) + evidence(0) + data_source(3) + alert_rule(1)
  刻意不归档 detection_log(327):纯遥测,且本库已有 weekly retention event
       在按 30 天滚动删除它;rule_detection_state(45):抗抖瞬时状态。

【缺陷二】部分失败后脚本永久无法重跑 —— 会导致应用再也起不来
执行器语义:只有 Status=Success 才跳过,Failed 下次启动**重跑整个脚本**且失败
会 throw 中断启动;而 MySQL DDL 隐式提交、执行器逐条 ExecuteNonQuery 无事务,
脚本天然不是原子的。两处会卡死:
  a) 原第 0 步用 `CREATE TEMPORARY TABLE ... AS SELECT ... WHERE data_access_mode
     IS NULL`。一旦 DROP COLUMN 之后才失败,重跑即 ERROR 1054 Unknown column。
     修法:temp 表改显式 schema(不依赖源表列),凡引用 data_access_mode 或对
     watch_rule 做 SELECT * 的语句由 @has_mode 探针守卫,列不存在时退化 DO 0。
     不变式:列还在 = 清理未走完;列没了 = 已走完,重跑空转。
  b) 归档写法 `CREATE TABLE IF NOT EXISTS x LIKE y` 在 y 已被 DROP 时**不会短路**
     ——实测 MySQL 8 仍报 ERROR 1146。故 data_source / alert_rule 两处归档均加
     @has_tbl 守卫。(1.0.487 第 3 步那 6 张源表不需要:只删行不删表。)

只读复核取证(当前数据库,非沿用历史数字)
  Legacy=132 / Standard=2 / Total=134,两条 STANDARD 均为 PURCHASE_DELIVERY
  且 enabled=0,实测不进入删除集合。
  反证:`data_access_mode <> 'STANDARD_DATASET'` 只命中 0 行(132 行全 NULL,
  三值逻辑 UNKNOWN),补集 + IS NULL 写法命中 132 行——证明现行写法必需。
  45 条 rule_detection_state 全属 Legacy,其中 34 条 active_exception_id 指向
  被删异常,随本步一并删除,不留悬挂指针。
  依赖面:全库外键 0、无相关触发器/视图/存储过程;两个 weekly event 仅引用
  SysLogOp 与 detection_log 的既有列,不受 DROP 影响。

已验证:新脚本按执行器同规则解析为 12 / 59 条良构语句、引号配平;@has_mode 与
@has_tbl 两条降级路径在会话内实测均完全空转 0 行;归档 SELECT * 列数匹配且
INSERT IGNORE 重复执行不增行。全部验证只用临时表与只读查询,未在共享库执行任何
DELETE / DROP / UPDATE / CREATE。

不升版本:脚本尚未部署,内容变更由执行器 SHA256 校验覆盖(未 Success 记录,
不触发"禁止静默改写"分支)。
YY968XX 2 дней назад
Родитель
Сommit
9208b289df

+ 23 - 1
server/Admin.NET.Web.Entry/UpdateScripts/1.0.486.sql

@@ -27,5 +27,27 @@ DELETE FROM SysMenu
  WHERE Name = 'aidopS8AlertRulesConfig'
    AND Path = '/aidop/s8/config/alert-rules';
 
--- ── 2. 删除表 ───────────────────────────────────────────────────────────────
+-- ── 2. 归档后删除表 ─────────────────────────────────────────────────────────
+-- 表内仅 1 行测试配置(G01_TEST_ALERT,场景码 S2S6_PRODUCTION 已废弃),无人工业务价值。
+-- 仍然先归档,只为与 1.0.487 保持同一条口径:**破坏性删除前一律归档**。
+-- DROP 之后内容不可恢复 —— 1.0.349 当年建的 *_bak_1_0_349 系列备份表在本库已不复存在,
+-- 说明"以后总能从别处找回来"这个假设并不成立。
+--
+-- ⚠️ 必须用 @has_tbl 守卫,不能裸写 `CREATE TABLE IF NOT EXISTS x LIKE y`:
+--    实测(MySQL 8)IF NOT EXISTS **不会短路**,源表 y 已被 DROP 时仍报 ERROR 1146。
+--    执行器语义是「Failed 下次启动重跑整个脚本」,一旦 DROP 成功后本脚本才失败,
+--    裸写法会在重跑时永久失败 → 应用再也起不来。
+-- 幂等:守卫 + CREATE IF NOT EXISTS + INSERT IGNORE + DROP IF EXISTS。
+SET @has_tbl := (
+  SELECT COUNT(*) FROM information_schema.TABLES
+   WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_alert_rule');
+
+SET @sql := IF(@has_tbl > 0,
+  'CREATE TABLE IF NOT EXISTS ado_s8_alert_rule_bak_1_0_486 LIKE ado_s8_alert_rule', 'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := IF(@has_tbl > 0,
+  'INSERT IGNORE INTO ado_s8_alert_rule_bak_1_0_486 SELECT * FROM ado_s8_alert_rule', 'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
 DROP TABLE IF EXISTS ado_s8_alert_rule;

+ 138 - 31
server/Admin.NET.Web.Entry/UpdateScripts/1.0.487.sql

@@ -5,17 +5,16 @@
 --                 → Watch Rule → Evaluator → Detection → Exception
 -- S8 不维护物理数据源、不执行任意 SQL、不保留 Legacy Rule。
 --
--- 本脚本是**破坏性**的,按依赖顺序执行。执行前的实测基线(2026-09-05):
---   ado_s8_watch_rule    : 134 行 = Legacy 132(data_access_mode IS NULL)+ Standard 2
+-- 本脚本是**破坏性**的。部署前复核(2026-09-06 实测,非沿用历史数字):
+--   ado_s8_watch_rule    : 134 行 = Legacy 132(data_access_mode 全为 NULL)+ Standard 2
 --   Legacy 中 enabled=1  : 1 行(id=86 RULE_S1_ORDER_DELIVERY_DUE_GRAY30_DATE_DELAY)
---   ado_s8_data_source   : 3 行(无 FK / 无触发器 / 无视图 / 除 PRIMARY 无索引)
---   Legacy 规则的运行产物: detection_log 327 行、exception 34 行
---                          (34 条中 32 条状态 NEW 从未被认领,2 条为 UAT 测试单;
---                            40 条 timeline ≈ 每单 1.2 条,即仅创建事件,无真实处理链)
+--   ado_s8_data_source   : 3 行(零 FK / 零触发器 / 零视图 / 零存储过程 / 零事件引用 / 除 PRIMARY 无索引)
+--   Legacy 运行产物      : exception 34 · timeline 40 · notification_log 3 · decision 0 · evidence 0
+--                          detection_log 327 · rule_detection_state 45
 --
--- ⚠️ 判定 Legacy 用「非 STANDARD_DATASET」补集写法,且必须显式写 IS NULL
---    `data_access_mode <> 'STANDARD_DATASET'` 对 NULL 求值为 UNKNOWN,
---    会漏掉全部 132 条历史行。这是本脚本最容易写错的一处
+-- ⚠️ 判定 Legacy 用「非 STANDARD_DATASET」补集写法,且必须显式写 IS NULL
+--    实测反证:`data_access_mode <> 'STANDARD_DATASET'` 只命中 **0 行**
+--    (132 行全为 NULL,三值逻辑求值为 UNKNOWN),正确写法命中 132 行
 --    补集写法同时覆盖脏值(如 'SQL'),与代码侧「非 STANDARD 即无运行资格」对齐。
 --
 -- ⚠️⚠️ rule_code 跨租户重名,且 Rule 01 正在其中:
@@ -30,29 +29,114 @@
 -- 保留:Rule 01(1329909460023)与诊断规则(1329909460024),
 --       两者均为 STANDARD_DATASET / PURCHASE_DELIVERY / enabled=0,本脚本不触碰。
 --
--- 幂等:DELETE 带条件、DROP 带 IF EXISTS、ALTER 前查 information_schema。
---       重复执行影响行数为 0。
+-- ════════════════════════════════════════════════════════════════════════════
+-- 部署前复核新增的两项设计(PRE-DEPLOY REVIEW,2026-09-06)
+--
+-- 【一】先归档、再删除
+--   复核发现待删的 34 条异常单中,有 2 条带**完整人工流转链**且当前仍未闭环:
+--     EX-20260904-2F4715F1 / EX-20260904-DA45EB40(均为 PENDING_VERIFICATION)
+--     CREATE → CLAIM(3172) → START_PROGRESS(838259724726341) → VERIFY_SUBMITTED
+--   另有 1 条(id=391)带真实 recovered_at(2026-09-02 由调度器 reconcile 写入)。
+--   备注内容为 "test"/"111",判断属 UAT 演练而非生产业务;但「有人认领过、
+--   流程尚未闭环」这一事实本身就不该被静默物理删除 —— 出问题时无从解释。
+--   故本脚本改为**归档后删除**:删除行为不变,但结果可解释、可追、可恢复。
+--
+--   归档范围只取「删了就再也拼不回来」的部分:
+--     watch_rule           —— 不archive 的话 exception.source_rule_id 会指向虚空,归档失去意义
+--     exception + 4 张子表 —— 人工痕迹本体
+--   刻意**不归档**:
+--     detection_log        —— 纯遥测,且本库已有 weekly retention event 在按 30 天滚动删除它
+--     rule_detection_state —— 抗抖瞬时状态,无历史解释价值
+--
+-- 【二】列删除后仍可重跑(部分失败可恢复)
+--   执行器语义(AutoVersionUpdate):只有 Status=Success 才跳过,Failed 会在下次
+--   启动**重跑整个脚本**,且失败会 throw 中断应用启动。而 MySQL 的 DDL 是隐式提交,
+--   执行器逐条 ExecuteNonQuery、无显式事务,故本脚本天然不是原子的。
+--
+--   原实现存在**不可恢复**缺陷:第 0 步用 `WHERE data_access_mode IS NULL ...` 建集合。
+--   一旦第 4 步把该列删掉之后脚本才失败,重跑时第 0 步会 ERROR 1054 Unknown column,
+--   于是永远失败 → 应用永远起不来 → 只能人工改库。
+--
+--   修法:temp 表改为**显式 schema**(不 CREATE ... AS SELECT,故不依赖源表列),
+--   凡引用 data_access_mode 或对 watch_rule 做 `SELECT *` 的语句,一律由
+--   @has_mode 探针守卫,列不存在时退化为 `DO 0`。
+--   不变式:**列还在 = Legacy 清理尚未走完;列没了 = 已经走完**,重跑即空转。
+-- ════════════════════════════════════════════════════════════════════════════
+--
+-- 幂等:DELETE 带条件、DROP 带 IF EXISTS、CREATE 带 IF NOT EXISTS、
+--       INSERT 用 IGNORE、ALTER 前查 information_schema。重复执行影响行数为 0。
 
 -- ────────────────────────────────────────────────────────────────────────────
--- 0. 固定 Legacy 规则 id 集合(后续步骤全部引用它,避免各步判定漂移)
+-- 0. 列存在性探针(整个 Legacy 清理块的守卫开关)
+-- ────────────────────────────────────────────────────────────────────────────
+SET @has_mode := (
+  SELECT COUNT(*) FROM information_schema.COLUMNS
+   WHERE TABLE_SCHEMA = DATABASE()
+     AND TABLE_NAME = 'ado_s8_watch_rule'
+     AND COLUMN_NAME = 'data_access_mode');
+
+-- ────────────────────────────────────────────────────────────────────────────
+-- 1. 固定 Legacy 规则 id 集合(后续步骤全部引用它,避免各步判定漂移)
+--    显式 schema:不用 CREATE ... AS SELECT,从而不依赖 ado_s8_watch_rule 的列。
 -- ────────────────────────────────────────────────────────────────────────────
 DROP TEMPORARY TABLE IF EXISTS tmp_s8_legacy_rule;
-CREATE TEMPORARY TABLE tmp_s8_legacy_rule AS
-SELECT id, tenant_id, factory_id, rule_code
-  FROM ado_s8_watch_rule
- WHERE data_access_mode IS NULL
-    OR TRIM(data_access_mode) = ''
-    OR UPPER(TRIM(data_access_mode)) <> 'STANDARD_DATASET';
+CREATE TEMPORARY TABLE tmp_s8_legacy_rule (
+  id         BIGINT      NOT NULL PRIMARY KEY,
+  tenant_id  BIGINT      NULL,
+  factory_id BIGINT      NULL,
+  rule_code  VARCHAR(64) NULL
+);
+
+SET @sql := IF(@has_mode > 0,
+  'INSERT INTO tmp_s8_legacy_rule (id, tenant_id, factory_id, rule_code)
+     SELECT id, tenant_id, factory_id, rule_code FROM ado_s8_watch_rule
+      WHERE data_access_mode IS NULL
+         OR TRIM(data_access_mode) = ''''
+         OR UPPER(TRIM(data_access_mode)) <> ''STANDARD_DATASET''',
+  'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
 
 -- ────────────────────────────────────────────────────────────────────────────
--- 1. Legacy 规则产生的异常单及其子表
---    无 FK,必须手工按父子顺序删;子表一律经 exception_id 关联。
+-- 2. Legacy 规则产生的异常单集合
 -- ────────────────────────────────────────────────────────────────────────────
 DROP TEMPORARY TABLE IF EXISTS tmp_s8_legacy_exception;
-CREATE TEMPORARY TABLE tmp_s8_legacy_exception AS
-SELECT e.id FROM ado_s8_exception e
-  JOIN tmp_s8_legacy_rule r ON r.id = e.source_rule_id;
+CREATE TEMPORARY TABLE tmp_s8_legacy_exception (id BIGINT NOT NULL PRIMARY KEY);
+INSERT INTO tmp_s8_legacy_exception (id)
+  SELECT e.id FROM ado_s8_exception e JOIN tmp_s8_legacy_rule r ON r.id = e.source_rule_id;
 
+-- ────────────────────────────────────────────────────────────────────────────
+-- 3. 归档(先于任何删除)
+--    watch_rule 用 SELECT * 且其列会在第 7 步被改,故同样由 @has_mode 守卫;
+--    其余 5 张表结构不受本脚本影响,列数恒定匹配,无需守卫。
+-- ────────────────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS ado_s8_watch_rule_bak_1_0_487         LIKE ado_s8_watch_rule;
+CREATE TABLE IF NOT EXISTS ado_s8_exception_bak_1_0_487          LIKE ado_s8_exception;
+CREATE TABLE IF NOT EXISTS ado_s8_exception_timeline_bak_1_0_487 LIKE ado_s8_exception_timeline;
+CREATE TABLE IF NOT EXISTS ado_s8_decision_record_bak_1_0_487    LIKE ado_s8_decision_record;
+CREATE TABLE IF NOT EXISTS ado_s8_evidence_bak_1_0_487           LIKE ado_s8_evidence;
+CREATE TABLE IF NOT EXISTS ado_s8_notification_log_bak_1_0_487   LIKE ado_s8_notification_log;
+
+SET @sql := IF(@has_mode > 0,
+  'INSERT IGNORE INTO ado_s8_watch_rule_bak_1_0_487
+     SELECT w.* FROM ado_s8_watch_rule w JOIN tmp_s8_legacy_rule r ON r.id = w.id',
+  'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+INSERT IGNORE INTO ado_s8_exception_bak_1_0_487
+  SELECT e.* FROM ado_s8_exception e JOIN tmp_s8_legacy_exception x ON x.id = e.id;
+INSERT IGNORE INTO ado_s8_exception_timeline_bak_1_0_487
+  SELECT t.* FROM ado_s8_exception_timeline t JOIN tmp_s8_legacy_exception x ON x.id = t.exception_id;
+INSERT IGNORE INTO ado_s8_decision_record_bak_1_0_487
+  SELECT d.* FROM ado_s8_decision_record d JOIN tmp_s8_legacy_exception x ON x.id = d.exception_id;
+INSERT IGNORE INTO ado_s8_evidence_bak_1_0_487
+  SELECT v.* FROM ado_s8_evidence v JOIN tmp_s8_legacy_exception x ON x.id = v.exception_id;
+INSERT IGNORE INTO ado_s8_notification_log_bak_1_0_487
+  SELECT n.* FROM ado_s8_notification_log n JOIN tmp_s8_legacy_exception x ON x.id = n.exception_id;
+
+-- ────────────────────────────────────────────────────────────────────────────
+-- 4. Legacy 规则产生的异常单及其子表
+--    无 FK(全库外键数 = 0),必须手工按父子顺序删;子表一律经 exception_id 关联。
+-- ────────────────────────────────────────────────────────────────────────────
 DELETE t FROM ado_s8_exception_timeline t JOIN tmp_s8_legacy_exception x ON x.id = t.exception_id;
 DELETE d FROM ado_s8_decision_record   d JOIN tmp_s8_legacy_exception x ON x.id = d.exception_id;
 DELETE v FROM ado_s8_evidence          v JOIN tmp_s8_legacy_exception x ON x.id = v.exception_id;
@@ -60,21 +144,24 @@ DELETE n FROM ado_s8_notification_log  n JOIN tmp_s8_legacy_exception x ON x.id
 DELETE e FROM ado_s8_exception         e JOIN tmp_s8_legacy_exception x ON x.id = e.id;
 
 -- ────────────────────────────────────────────────────────────────────────────
--- 2. Legacy 规则的检测轨迹与抗抖状态
+-- 5. Legacy 规则的检测轨迹与抗抖状态
 --    detection_log 有 rule_id,可精确关联;
 --    rule_detection_state 只有 rule_code,故必须三键(tenant + factory + code)限定 ——
 --    否则会连带删掉 Rule 01 的抗抖状态(见文件头的重名说明)。
+--
+--    复核实测:45 条 state 行全部属于 Legacy 规则,其中 34 条的 active_exception_id
+--    指向第 4 步删掉的异常单 —— 随本步一并删除,不会留下悬挂指针。
 -- ────────────────────────────────────────────────────────────────────────────
 DELETE d FROM ado_s8_detection_log d JOIN tmp_s8_legacy_rule r ON r.id = d.rule_id;
 
 DELETE s FROM ado_s8_rule_detection_state s
   JOIN tmp_s8_legacy_rule r
-    ON r.tenant_id = s.tenant_id
+    ON r.tenant_id  = s.tenant_id
    AND r.factory_id = s.factory_id
-   AND r.rule_code = s.rule_code;
+   AND r.rule_code  = s.rule_code;
 
 -- ────────────────────────────────────────────────────────────────────────────
--- 3. Legacy 规则本体
+-- 6. Legacy 规则本体
 -- ────────────────────────────────────────────────────────────────────────────
 DELETE w FROM ado_s8_watch_rule w JOIN tmp_s8_legacy_rule r ON r.id = w.id;
 
@@ -82,9 +169,11 @@ DROP TEMPORARY TABLE IF EXISTS tmp_s8_legacy_exception;
 DROP TEMPORARY TABLE IF EXISTS tmp_s8_legacy_rule;
 
 -- ────────────────────────────────────────────────────────────────────────────
--- 4. 规则表的 Legacy 列
---    必须在第 3 步之后:data_access_mode 正是第 0 步的判定依据。
+-- 7. 规则表的 Legacy 列
+--    必须在第 6 步之后:data_access_mode 正是第 1 步的判定依据。
 --    用 information_schema 预检使脚本可重复执行(MySQL 的 DROP COLUMN 无 IF EXISTS)。
+--    data_access_mode 刻意放在三者最后:它是 @has_mode 守卫的开关,
+--    只要它还在,重跑就能从头正常空转。
 -- ────────────────────────────────────────────────────────────────────────────
 SET @sql := (
   SELECT IF(COUNT(*) > 0, 'ALTER TABLE ado_s8_watch_rule DROP COLUMN data_source_id', 'DO 0')
@@ -105,13 +194,31 @@ SET @sql := (
 PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
 
 -- ────────────────────────────────────────────────────────────────────────────
--- 5. 数据源整表
+-- 8. 数据源整表
 --    该表 endpoint 列存的是含凭据的真实连接串;S8 已不再维护物理连接,直接删除。
+--    删前先归档:3 行配置里有可用于排障的历史连接标识(密码段虽是明文,
+--    但归档表与原表同库同权限,不扩大暴露面;如需彻底销毁另行处理)。
 -- ────────────────────────────────────────────────────────────────────────────
+--    ⚠️ 必须用 @has_tbl 守卫,不能裸写 `CREATE TABLE IF NOT EXISTS x LIKE y`:
+--    实测(MySQL 8)IF NOT EXISTS **不会短路**,源表 y 已被 DROP 时仍报 ERROR 1146。
+--    一旦 DROP 成功后本脚本才失败,裸写法会在重跑时永久失败 → 应用再也起不来。
+--    (第 3 步那 6 张归档源表不需要此守卫:本脚本只删它们的行、不删表本身。)
+SET @has_tbl := (
+  SELECT COUNT(*) FROM information_schema.TABLES
+   WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_data_source');
+
+SET @sql := IF(@has_tbl > 0,
+  'CREATE TABLE IF NOT EXISTS ado_s8_data_source_bak_1_0_487 LIKE ado_s8_data_source', 'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := IF(@has_tbl > 0,
+  'INSERT IGNORE INTO ado_s8_data_source_bak_1_0_487 SELECT * FROM ado_s8_data_source', 'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
 DROP TABLE IF EXISTS ado_s8_data_source;
 
 -- ────────────────────────────────────────────────────────────────────────────
--- 6. 菜单与授权
+-- 9. 菜单与授权
 --    先清 SysRoleMenu 授权行,再删菜单,避免留下悬挂授权。
 --    「维护数据源」按钮权限点(s8:config:data-source)一并清除;
 --    目录侧保留一条 Deprecated 墓碑,防止该码被复用。