# 20 Admin 修复迁移被原地改写导致老库缺表 - 类型:缺陷(数据库迁移设计) - 父级大工单:#14 - 所属 MVP / 版本:#15 / MVP - 关联:#16(缺陷来源) - 状态:验收通过 - 日期:2026-08-07 - Gitea 工单:http://ilaer.eicp.net:8418/chengma/cmautobuy/issues/20 ## 背景与目标 用户点 PDD 商品页报 **HTTP 500「读取 PDD 商品列表失败」**。 根因:**#16 原地改写了 migration v1,没有新增迁移。** 而 `Migrate` 是 ```go for v := current; v < len(migrations); v++ { ... } ``` 老库 `user_version` 已经是 2、`len(migrations)` 也是 2,**循环一次都不进**, 改写后的 v1 永远不会执行,`pdd_products` 表根本没被建出来。 `docs/task/16-*.md` 当时写的理由是「migrations 直接改 v1(当时无业务代码依赖), 不加新迁移」——**这个前提当时就不成立**,8 月 6 日已经存在数据库了。 比 500 更危险的是:程序**静默启动**,拿着一个和代码对不上的库。 错误要等操作员点到那个页面才暴露;换成写操作的页面就不是报错而是写坏数据。 ## 最终方案 ### v1 恢复原样,#16 的改动全部挪进 v3 **不是**给 v3 加一堆 `IF NOT EXISTS` 去兼容被改坏的 v1。那样新库和老库会走 **两条不同路径**,必须分别测试——而这次的缺陷根源恰恰就是只测了一条 (架构角色审查 #16 时全在全新库上跑)。 恢复 v1 之后两条路径收敛到同一个终点,测一次就够, 「迁移只追加」这条不变量也被恢复了。 ### v3 必须认两种 `user_version = 2` **实施过程中用户实测炸出的第二个问题:** ``` 迁移 v3 建 pdd_products 第 1 条语句失败: table pdd_products already exists ``` 因为 #16 的原地改写,`user_version = 2` 对应**两种不同结构**: | 建库时用的代码 | user_version | pdd_products | |---|---|---| | 原始 v1(#16 之前) | 2 | 无 | | #16 改写后的 v1 | 2 | **有,且已是最终结构** | 原工单写死了「v2 ⇒ 老结构」这个前提,**前提是错的**。任何在 998c06a 之后、 本工单之前建过库的机器都是第二种——用户就是,而且是架构角色让他删库重建 才变成这样的。 改法:`migrateV3` 每一步**先查实际结构再决定做不做** (`PRAGMA table_info` / `sqlite_master`),只有版本号推进是无条件的: | 步骤 | 执行条件 | |---|---| | 建 `pdd_products` + 索引 | 该表不存在时 | | 从 `shopee_products` 搬 PDD 数据 | 它还有 `pdd_data` 列时 | | 重建 `shopee_products` | 它还有那四个旧列中任意一个时 | | 重建 `sku_mappings` | 它还没有 `pdd_goods_id` 列时 | | `PRAGMA user_version = 3` | 无条件 | 已是最终结构的库只推版本号,日志也照实说 (`数据库迁移 v3:结构已是最新,仅更新版本号`),不谎称「新增 pdd_products」。 ### 数据迁移的三条硬规则 **① 必须按 `pdd_goods_id` 去重。** 多个蝦皮商品可以填同一个 PDD 链接—— 这正是 #16 要解决的问题之一。直接 `INSERT ... SELECT` 会撞 UNIQUE。 **② `collecting` 映射成 `pending`。** 原样保留会让 `MarkCollecting` 永远不成功, 那个商品**再也建不了采集任务**,界面上表现为按钮永远置灰且无法解开。 **③ 旧 `sku_mappings` 全部丢弃,但要打日志。** 新主键需要 `pdd_option_key`, 那是 Go 的 `OptionKey()` 用 `json.Marshal` 算的,**SQL 复现不了**。 硬凑一个键出来:键不对 → 映射静默失效;键碰巧撞上别的规格 → **静默买错东西**。后者正是 #16 存在的全部意义,不能在迁移里重新引入。 ### 另加两道闸 - **启动时 `CheckSchema`**:缺表即**拒绝启动**,不是警告后继续。 - **`admin/AGENTS.md` 写死**「migrations 只追加、不得修改已发布条目」。 ## 与建单方案的差异 1. **v3 没有放进 `migrations [][]string`**,而是单独写成 `migrateV3()` 函数 + `schemaVersion` 常量。原因:v3 需要在事务外切 `PRAGMA foreign_keys`、 需要在丢数据前用 Go 统计行数打日志,纯 SQL 顺序执行表达不了。 2. **未采纳 `[建议]` 的 `title` 用 `json_extract` 提取**(可选增强,未做以减少风险面)。 3. **改动了 `admin/main.go`**(不在预计文件表里):`CheckSchema` 必须有人调用, 否则验收项无法达成。只加 3 行。 4. **加了一行无条件的 v3 日志**。原设计只在丢弃旧映射时才打日志, 用真实的空数据库测试时启动日志完全看不出发生过迁移, 验证步骤「期望启动日志出现 v3 迁移」没法核对。 5. **v3 改为查实际结构**(见上,工单中途修正)。 ## 改了哪些 - `admin/repository/db.go`:v1 逐字恢复成 `7ad82b7` 的原样;新增 `schemaVersion` 和 `migrateV3()`;新增 `CheckSchema()`。 - `admin/repository/migrate_test.go`:新建。三起点收敛测试 + 数据场景 + 幂等 + `CheckSchema`。 - `admin/main.go`:`Migrate` 之后调 `CheckSchema`,失败 `log.Fatalf`。 - `admin/AGENTS.md`:「migrations 只追加」的 `[必须]` 条目。 - `docs/admin/03-data-model.md`:§2.3 迁移版本历史、§2.4 启动自检。 ## 验收结果 | 验收标准 | 结果 | |---|---| | v1 与 `7ad82b7` **逐字一致** | 通过(两边均 4380 字节,程序化 diff EQUAL) | | v3 建出的 `pdd_products` 与原 v1 定义一致 | 通过 | | **全新库 / v2 老结构库 / v2 新结构库,迁移后 schema 完全一致** | 通过 | | schema 比对覆盖表、索引、列定义、CHECK 约束 | 通过 | | 两个蝦皮商品指向同一 PDD 链接 → 只有 1 行 | 通过 | | `collecting` → `pending` | 通过 | | `no_link` → `pending`,不撞 CHECK | 通过 | | 丢弃旧映射时打日志,为 0 时不打 | 通过 | | `shopee_skus` 数据不被外键带走 | 通过(`PRAGMA foreign_key_check` 干净) | | 缺表时**拒绝启动**并给出提示 | 通过 | | **v2 新结构库连续调用两次 `Migrate` 不报错** | 通过 | | `admin/AGENTS.md` 有对应 `[必须]` 条目 | 通过 | | `go vet` / `gofmt` / `go test` 全过 | 通过 | ## 测试 架构角色亲自执行(Go 1.23.0,`admin/` 目录): ``` go vet ./... 无输出 gofmt -l . 无输出 go test ./... -count=1 ok repository / ok service,15 个迁移与自检测试全过 ``` ### 变异测试(架构角色补做,工单未要求) 第一轮实现交付后,对 `migrateV3` 里的定义故意改坏,看测试抓不抓得到: | 变异 | 第一轮 | 返工后 | |---|---|---| | `collect_status` 的 CHECK 放回 `no_link` | **全绿,没抓到** | 变红 | | 去掉 `goods_id` 的 `UNIQUE` | **全绿,没抓到** | 变红 | | `sku_mappings` 主键退回单列 | 靠 service 层用例才抓到 | 变红 | | 改掉一个列名(探针) | — | 变红 | 第一轮漏掉两个,是因为**架构角色的设计让收敛测试变成了近乎同义反复**—— 全新库也走 v3,两条路径共用同一份 v3 代码,v3 自己写错时两边会一起错。 补了「最终 schema 符合设计要求」的行为断言测试后,四个变异全部被抓到。 第四个变异是专门探那个新加的「引号归一化」正则会不会把比对弄瞎的。 正则是锚定的、只吃 `CREATE TABLE/INDEX` 后面那个标识符的外层引号, 碰不到列定义和 CHECK——探针变红印证了。 ### 真实库端到端 用 `git show 998c06a` 的代码原样造出与用户库结构完全一致的库 (`user_version=2` + `pdd_products` 已存在),跑修复后的二进制: ``` 第一次启动 数据库迁移 v3:结构已是最新,仅更新版本号 /pdd /shopee /syb /tasks /clients 全部 200 第二次启动 (无迁移日志,完全跳过) /pdd 200 最终 user_version = 3,PRAGMA foreign_key_check OK ``` 另用真正的老结构库(`backup-20260807-113030/admin.db`,`user_version=2`、 无 `pdd_products`)跑一遍:迁移日志三步全做,五页全 200,`sku_mappings` 主键已是 `(shopee_sku_id, pdd_goods_id)`,`shopee_products` 那四列消失。 缺表拒绝启动:删掉 `pdd_products` 后启动,打印 「数据库结构与本程序不匹配:缺少表 pdd_products…」并退出,服务未起。 **未验证到的部分:** - **Windows 环境未实测**(本机是 WSL/Linux)。改动的代码(结构探测、 FK 开关、RENAME 顺序)与平台无关,依赖是纯 Go 的 `modernc.org/sqlite`。 - 备份库里所有表都是 0 行,「两个蝦皮商品同链接」和「旧 `sku_mappings` 有数据」这两类场景**只在自动化测试的临时库里验证过**,没有用真实业务数据验证。 - 未验证并发调用 `Migrate`(两个进程同时首次启动争抢迁移),工单未要求。 ## 实现自己发现并修掉的两个问题 这两个都是**新测试逼出来的**,不是读代码看出来的: 1. **`ALTER TABLE ... RENAME TO` 会自动重写别的表里指向它的外键子句。** 最初为绕开引号问题改成「先把旧表 RENAME 让位」,结果 `shopee_skus` 的外键被悄悄改成指向一张马上被删掉的 `shopee_products_old`, `PRAGMA foreign_key_check` 直接报错。改回 SQLite 官方 12 步顺序解决。 2. **RENAME 会给表名套一层双引号。** `sqlite_master.sql` 变成 `CREATE TABLE "foo" (...)`,而从未 RENAME 过的表没有这层引号, 导致收敛比对被判定成不一致。用只针对 CREATE 语句开头标识符的正则归一化解决。 ## 审查过程 工单中途修改一次(**架构角色的设计前提错了,不是实现问题**)。 原工单写死「`user_version = 2` ⇒ 老结构」,并明确否掉了 `IF NOT EXISTS` 这类判断,理由是「会造成两条路径」。用户实测炸出 `table pdd_products already exists` 之后才发现:路径本来就是两条,是 #16 造成的既成事实,v3 必须两种都认。 按 `CLAUDE.md` §7.5「实现暴露了设计问题,架构要认」,改工单而不是让实现硬凑。 ## 遗留问题 1. Windows 环境未实测。 2. 真实业务数据(非空表)的迁移未验证。 ## 教训 同一个根因(#16 原地改写 v1)咬了三次:用户点页面 500、审查 #16 时没抓到、 修复时又漏了第三种状态。前两次是「只在全新库上测」,第三次是 **基于对现状的假设**而不是去查实际存在哪些库。 `admin/AGENTS.md` 已加「迁移只追加」。建议再补一条: **改迁移前先列出现实中存在哪些 schema 状态,一个个验。** ## 相关提交 - `d2de733` fix: 修复迁移被原地改写导致老库缺表 (#20)