Files
cmautobuy/docs/task/20-admin-修复迁移被原地改写.md
T
chengmaandClaude Opus 5 10b724f62a docs: 归档 #38;改正六份归档里未经验证的 Go 版本说法
#38 归档记录了打回一次的经过(ParseSpec 两个失败分支无测试覆盖,
其中 size=="" 就是真实数据里 2 行走的分支),以及架构角色第一轮
变异打在死分支上、差点误报「测试没牙」的过程。

改正:/usr/local/go 从 2026-07-02 起一直是 1.26.5,而六份归档都写着
「架构角色亲自执行(Go 1.23.0)」——那是照项目固定版本抄的,
没有实际验证过工具链,违反 CLAUDE.md §9「说验证过必须真的跑过」。
措辞改为不宣称具体版本。已用 GOTOOLCHAIN=go1.23.0 补验,结论不变。

admin/AGENTS.md 加两条 [必须]:
- 交付前至少跑一次带 GOTOOLCHAIN=go1.23.0 的验证。开发机装的可能更新,
  Go 会默默用它编译,测过的不是要交付的那个版本。新加依赖时尤其要跑。
- 改数据库时不要只测全新库,先列出现实中存在哪些 schema 状态再一个个验(来自 #20)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 09:59:34 +08:00

228 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 全过 | 通过 |
## 测试
架构角色亲自执行(`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)