Files
cmautobuy/docs/task/20-admin-修复迁移被原地改写.md
T
chengmaandClaude Opus 5 3fdab7b2a8 docs: 记录 #16 #17 #19 #20 #24 验收通过
补上 #20 和 #24 的归档(此前只提交了代码),并把 #16 #17 #19
的状态改为验收通过。

#20 归档记录了工单中途被修改的经过:原工单写死「user_version=2 ⇒ 老结构」,
用户实测炸出 table already exists 后才发现 #16 的原地改写让这个版本号
对应两种结构,路径本来就是两条。按 CLAUDE.md §7.5 改工单而不是让实现硬凑。
也记录了变异测试第一轮漏掉两个的原因——架构角色的设计让收敛测试变成了
近乎同义反复。

#24 归档记录了四个变异全部被抓到,其中「破坏原子性判断」那个最关键:
加超时分支最容易顺手把原子抢占破坏掉。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:18:32 +08:00

11 KiB
Raw Blame History

20 Admin 修复迁移被原地改写导致老库缺表

  • 类型:缺陷(数据库迁移设计)
  • 父级大工单:#14
  • 所属 MVP / 版本:#15 / MVP
  • 关联:#16(缺陷来源)
  • 状态:验收通过
  • 日期:2026-08-07
  • Gitea 工单:#20

背景与目标

用户点 PDD 商品页报 HTTP 500「读取 PDD 商品列表失败」。

根因:#16 原地改写了 migration v1,没有新增迁移。 而 Migrate 是

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)