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

Closed
opened 2026-08-07 11:36:50 +08:00 by ila · 1 comment
Owner

基本信息

  • 类型:缺陷(数据库迁移设计)
  • 父级大工单:#14
  • 所属 MVP / 版本:#15 / MVP
  • 优先级:高,挡住 #16/#17/#18 的验收
  • 关联:#16(缺陷来源)

要解决什么

复现步骤

  1. 用 8 月 6 日(#16 之前)的程序建库并跑一次,得到 user_version = 2
  2. 拉到当前代码,go run .
  3. 服务正常启动,无任何警告
  4. 点导航「PDD 商品」→ HTTP 500「读取 PDD 商品列表失败」

实测该库的表清单里没有 pdd_products。

根因

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

for v := current; v < len(migrations); v++ { ... }

老库 user_version 已经是 2,len(migrations) 也是 2,循环一次都不进,
改写后的 v1 永远不会执行。

docs/task/16-*.md 写的理由是「migrations 直接改 v1(当时无业务代码依赖),
不加新迁移」——这个前提当时就不成立,8 月 6 日已经存在数据库了。

比 500 更危险的一点

程序静默启动,拿着一个和代码对不上的库。错误要等操作员点到那个页面才暴露。
换成写操作的页面,就不是报错而是写坏数据。

做什么 / 不做什么

做:

  1. v1 恢复成 #16 之前的原样,#16 的全部结构改动挪进新的 v3
  2. v3 负责把老库的数据搬到新结构
  3. 新增迁移收敛测试:不同起点迁移到最新,schema 必须完全一致
  4. 新增启动时 schema 自检,对不上就拒绝启动并说清怎么办
  5. 在 admin/AGENTS.md 写死「迁移只追加、不改已发布的」

不做:

  • 不改任何业务逻辑、界面、接口
  • 不改 v2
  • 不做 schema 降级/回滚工具

怎么做

为什么是「恢复 v1 + 新增 v3」,而不是给 v3 加兼容判断

给 v3 套一堆 CREATE TABLE IF NOT EXISTS 去兼容当前这个被改坏的 v1 也能跑通,
但那样新库和老库会走两条不同的路径,必须分别测试。

这次的缺陷根源恰恰就是只测了一条路径。 恢复 v1 之后两条路径收敛到
同一个终点,测一次就够,而且「迁移只追加」这条不变量也被恢复了。

[必须] v1 恢复后的内容要与 git show 7ad82b7:admin/repository/db.go 完全一致,
逐字对照,不要凭记忆重写。

v3 要做的事

① 建 pdd_products 和索引(内容照搬当前 v1 里的定义,含全部注释)

② 把老 shopee_products 上的 PDD 数据搬过去

[必须] 必须去重。多个蝦皮商品可以填同一个 PDD 链接——
这正是 #16 要解决的问题之一。直接 INSERT ... SELECT 会撞 goods_id 的 UNIQUE 约束。

INSERT INTO pdd_products
    (goods_id, url, title, skus_json, collect_status,
     collect_msg, collected_at, created_at, updated_at)
SELECT
    pdd_goods_id,
    MIN(COALESCE(pdd_goods_url, '')),
    NULL,
    MAX(pdd_data),
    CASE MAX(collect_status)
        WHEN 'collected' THEN 'collected'
        WHEN 'failed'    THEN 'failed'
        ELSE 'pending'
    END,
    MAX(collect_error),
    MAX(collected_at),
    MIN(created_at), MAX(updated_at)
FROM shopee_products
WHERE pdd_goods_id IS NOT NULL AND pdd_goods_id <> ''
GROUP BY pdd_goods_id;

[必须] collecting 映射成 pending 而不是原样保留。
原来正在采的那次,客户端早就不在了;保留 collecting 会让这个商品
永远建不了新的采集任务(MarkCollecting 只在 pending/failed 时成功),
在界面上表现为「一直转圈、按钮永远置灰」,而且没有任何东西能把它解开。

[必须] no_link 映射成 pending。新 CHECK 里没有 no_link,原样写会撞约束。

[建议] title 可以用 json_extract(pdd_data, '$.title') 带出来,
但要包 json_valid(pdd_data) 判断——老数据不保证是合法 JSON。取不到就留 NULL。

③ 重建 shopee_products

去掉 pdd_data / collect_status / collect_error / collected_at,
删掉 idx_shopee_products_status,加上 idx_shopee_products_pdd。

按 SQLite 的建表-搬数据-删旧表-改名流程做。

④ 重建 sku_mappings

[必须] 旧数据全部丢弃,不要试图搬过去。 理由:

新主键需要 pdd_goods_id 和 pdd_option_key 两个旧表没有的列。
pdd_option_key 是 Go 的 service.OptionKey() 用 json.Marshal 算出来的,
SQL 复现不了。硬凑一个键出来会有两种后果:
键不对 → 映射静默失效,人工白匹配一遍;
键碰巧撞上别的规格 → 静默买错东西,事后查不出来。
后者正是 #16 存在的全部意义,不能在迁移里重新引入。

规格匹配的界面还没做(属于后续工单),所以此刻不可能存在真实映射数据。

[必须] 丢弃的行数 要打日志,不能静默丢:

迁移 v3:丢弃了 N 条旧规格映射(缺少 pdd_goods_id / pdd_option_key,无法安全迁移,请重新匹配)

N 为 0 时不打,避免每次启动都刷一行没用的日志。

[必须] 重建前后都要 PRAGMA foreign_keys 的处理符合 SQLite 官方的
12 步表重建流程,避免外键把 shopee_skus 的数据带走。

迁移收敛测试

[必须] 这是本工单最重要的交付物——它才是能防住下次的东西。

func TestMigrate_不同起点最终schema一致(t *testing.T) {
    // A: 全新库,一路迁到最新
    // B: 只跑 migrations[0:2] 造出一个 v2 老库,再 Migrate 到最新
    // 断言:两者的 sqlite_master 内容完全一致(表、索引、列、约束)
}

[必须] 比对要覆盖表、索引、列定义和 CHECK 约束,不能只比表名。
只比表名的话,sku_mappings 主键不同这种问题照样漏过去。

[必须] 再加一条带数据的用例:老库里塞两个蝦皮商品指向同一个
PDD 链接,迁移后断言 pdd_products 里只有 1 行,且 collect_status 合法。

启动时 schema 自检

Migrate 成功后,检查代码依赖的表是否都在。缺了就返回错误、拒绝启动:

数据库结构与本程序不匹配:缺少表 pdd_products。
这通常是数据库比程序旧、而迁移没有覆盖到。
请备份 data/admin.db 后删除它让程序重建,或联系维护者。

[必须] 是拒绝启动,不是打个警告继续跑。
静默启动正是这次要等到点页面才发现问题的原因。

[建议] 只查表名即可,不必逐列校验——够抓住这一类问题,代价也低。

写进 AGENTS.md

在 admin/AGENTS.md 的数据库章节加一条 [必须]:

`[必须]` migrations 只追加,**不得修改已经发布过的条目**。
改了的话,已经建过库的机器版本号已经越过它,永远不会重跑,
程序会拿着对不上的库静默启动(见 #20)。
需要改结构就加新的一条。

预计修改文件

文件 改什么
admin/repository/db.go v1 恢复原样;新增 v3;Migrate 后加 schema 自检
admin/repository/migrate_test.go 新建:收敛测试 + 带数据的去重测试
admin/AGENTS.md 迁移只追加的规则
docs/admin/03-data-model.md 迁移版本说明,写清 v3 做了什么、为什么丢弃旧映射

验收标准

  • v1 内容与 git show 7ad82b7:admin/repository/db.go 逐字一致
  • v3 建出的 pdd_products 与当前 v1 里的定义一致(含 UNIQUE、CHECK、软删除列)
  • 全新库迁移后的 schema,与 v2 老库迁移后的 schema 完全一致
  • schema 比对覆盖表、索引、列定义、CHECK 约束
  • 老库里两个蝦皮商品指向同一 PDD 链接 → 迁移后 pdd_products 只有 1 行
  • 老库里 collect_status = 'collecting' → 迁移后是 pending
  • 老库里 collect_status = 'no_link' → 迁移后是 pending,不撞 CHECK
  • 旧 sku_mappings 有数据时,丢弃行数被打进日志
  • shopee_products 迁移后不再有那四个字段,shopee_skus 数据未被外键带走
  • 缺表时启动失败并给出上面那段提示,不是警告后继续
  • admin/AGENTS.md 有「迁移只追加」的 [必须] 条目
  • go vet / gofmt -l . / go test ./... 全过
  • 用 8 月 6 日的老库实测:迁移后点 PDD 商品页返回 200

怎么验证

cd D:\chengma\cmautobuy\admin
go vet ./...
gofmt -l .
go test ./... -count=1
go test ./repository/ -run Migrate -v -count=1

老库实测(仓库里有一份 8 月 6 日的备份):

copy admin\data\backup-20260807-113030\admin.db admin\data\admin.db
del admin\data\admin.db-wal, admin\data\admin.db-shm
go run .
# 期望:启动日志出现 v3 迁移;打开 /pdd 返回 200

风险和回退

风险 应对
表重建时外键把 shopee_skus 带走 按 SQLite 12 步流程;验收项里单独断言
恢复 v1 时手抄出错 要求逐字对照 git show,不许凭记忆
collecting 卡死 已明确映射为 pending 并写明理由

回退:git revert 即可。已建的库若已迁到 v3,回退程序会因
「数据库版本高于本程序支持的」而拒绝启动——这是 Migrate 已有的正确行为。

## 基本信息 - 类型:缺陷(数据库迁移设计) - 父级大工单:#14 - 所属 MVP / 版本:#15 / MVP - 优先级:**高**,挡住 #16/#17/#18 的验收 - 关联:#16(缺陷来源) ## 要解决什么 ### 复现步骤 1. 用 8 月 6 日(#16 之前)的程序建库并跑一次,得到 `user_version = 2` 2. 拉到当前代码,`go run .` 3. **服务正常启动,无任何警告** 4. 点导航「PDD 商品」→ **HTTP 500「读取 PDD 商品列表失败」** 实测该库的表清单里**没有 `pdd_products`**。 ### 根因 #16 **原地改写了 migration v1**,没有新增迁移。而 `Migrate` 是: ```go for v := current; v < len(migrations); v++ { ... } ``` 老库 `user_version` 已经是 2,`len(migrations)` 也是 2,**循环一次都不进**, 改写后的 v1 永远不会执行。 `docs/task/16-*.md` 写的理由是「migrations 直接改 v1(当时无业务代码依赖), 不加新迁移」——**这个前提当时就不成立**,8 月 6 日已经存在数据库了。 ### 比 500 更危险的一点 程序**静默启动**,拿着一个和代码对不上的库。错误要等操作员点到那个页面才暴露。 换成写操作的页面,就不是报错而是写坏数据。 ## 做什么 / 不做什么 做: 1. **v1 恢复成 #16 之前的原样**,#16 的全部结构改动挪进新的 **v3** 2. v3 负责把老库的数据搬到新结构 3. 新增**迁移收敛测试**:不同起点迁移到最新,schema 必须完全一致 4. 新增**启动时 schema 自检**,对不上就拒绝启动并说清怎么办 5. 在 `admin/AGENTS.md` 写死「迁移只追加、不改已发布的」 不做: - 不改任何业务逻辑、界面、接口 - 不改 v2 - 不做 schema 降级/回滚工具 ## 怎么做 ### 为什么是「恢复 v1 + 新增 v3」,而不是给 v3 加兼容判断 给 v3 套一堆 `CREATE TABLE IF NOT EXISTS` 去兼容当前这个被改坏的 v1 也能跑通, 但那样**新库和老库会走两条不同的路径**,必须分别测试。 **这次的缺陷根源恰恰就是只测了一条路径。** 恢复 v1 之后两条路径收敛到 同一个终点,测一次就够,而且「迁移只追加」这条不变量也被恢复了。 `[必须]` v1 恢复后的内容要与 `git show 7ad82b7:admin/repository/db.go` 完全一致, 逐字对照,不要凭记忆重写。 ### v3 要做的事 **① 建 `pdd_products` 和索引**(内容照搬当前 v1 里的定义,含全部注释) **② 把老 `shopee_products` 上的 PDD 数据搬过去** `[必须]` **必须去重**。多个蝦皮商品可以填同一个 PDD 链接—— 这正是 #16 要解决的问题之一。直接 `INSERT ... SELECT` 会撞 `goods_id` 的 UNIQUE 约束。 ```sql INSERT INTO pdd_products (goods_id, url, title, skus_json, collect_status, collect_msg, collected_at, created_at, updated_at) SELECT pdd_goods_id, MIN(COALESCE(pdd_goods_url, '')), NULL, MAX(pdd_data), CASE MAX(collect_status) WHEN 'collected' THEN 'collected' WHEN 'failed' THEN 'failed' ELSE 'pending' END, MAX(collect_error), MAX(collected_at), MIN(created_at), MAX(updated_at) FROM shopee_products WHERE pdd_goods_id IS NOT NULL AND pdd_goods_id <> '' GROUP BY pdd_goods_id; ``` `[必须]` `collecting` 映射成 **`pending`** 而不是原样保留。 原来正在采的那次,客户端早就不在了;保留 `collecting` 会让这个商品 **永远建不了新的采集任务**(`MarkCollecting` 只在 `pending`/`failed` 时成功), 在界面上表现为「一直转圈、按钮永远置灰」,而且没有任何东西能把它解开。 `[必须]` `no_link` 映射成 `pending`。新 CHECK 里没有 `no_link`,原样写会撞约束。 `[建议]` `title` 可以用 `json_extract(pdd_data, '$.title')` 带出来, 但要包 `json_valid(pdd_data)` 判断——老数据不保证是合法 JSON。取不到就留 NULL。 **③ 重建 `shopee_products`** 去掉 `pdd_data` / `collect_status` / `collect_error` / `collected_at`, 删掉 `idx_shopee_products_status`,加上 `idx_shopee_products_pdd`。 按 SQLite 的建表-搬数据-删旧表-改名流程做。 **④ 重建 `sku_mappings`** `[必须]` **旧数据全部丢弃,不要试图搬过去。** 理由: 新主键需要 `pdd_goods_id` 和 `pdd_option_key` 两个旧表没有的列。 `pdd_option_key` 是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的, **SQL 复现不了**。硬凑一个键出来会有两种后果: 键不对 → 映射静默失效,人工白匹配一遍; 键碰巧撞上别的规格 → **静默买错东西,事后查不出来**。 后者正是 #16 存在的全部意义,不能在迁移里重新引入。 规格匹配的界面还没做(属于后续工单),所以此刻不可能存在真实映射数据。 `[必须]` 丢弃的行数 **要打日志**,不能静默丢: ```text 迁移 v3:丢弃了 N 条旧规格映射(缺少 pdd_goods_id / pdd_option_key,无法安全迁移,请重新匹配) ``` N 为 0 时不打,避免每次启动都刷一行没用的日志。 `[必须]` 重建前后都要 `PRAGMA foreign_keys` 的处理符合 SQLite 官方的 12 步表重建流程,避免外键把 `shopee_skus` 的数据带走。 ### 迁移收敛测试 `[必须]` 这是本工单**最重要的交付物**——它才是能防住下次的东西。 ```go func TestMigrate_不同起点最终schema一致(t *testing.T) { // A: 全新库,一路迁到最新 // B: 只跑 migrations[0:2] 造出一个 v2 老库,再 Migrate 到最新 // 断言:两者的 sqlite_master 内容完全一致(表、索引、列、约束) } ``` `[必须]` 比对要覆盖**表、索引、列定义和 CHECK 约束**,不能只比表名。 只比表名的话,`sku_mappings` 主键不同这种问题照样漏过去。 `[必须]` 再加一条**带数据**的用例:老库里塞两个蝦皮商品指向**同一个** PDD 链接,迁移后断言 `pdd_products` 里只有 1 行,且 `collect_status` 合法。 ### 启动时 schema 自检 `Migrate` 成功后,检查代码依赖的表是否都在。缺了就返回错误、**拒绝启动**: ```text 数据库结构与本程序不匹配:缺少表 pdd_products。 这通常是数据库比程序旧、而迁移没有覆盖到。 请备份 data/admin.db 后删除它让程序重建,或联系维护者。 ``` `[必须]` 是**拒绝启动**,不是打个警告继续跑。 静默启动正是这次要等到点页面才发现问题的原因。 `[建议]` 只查表名即可,不必逐列校验——够抓住这一类问题,代价也低。 ### 写进 AGENTS.md 在 `admin/AGENTS.md` 的数据库章节加一条 `[必须]`: ```text `[必须]` migrations 只追加,**不得修改已经发布过的条目**。 改了的话,已经建过库的机器版本号已经越过它,永远不会重跑, 程序会拿着对不上的库静默启动(见 #20)。 需要改结构就加新的一条。 ``` ## 预计修改文件 | 文件 | 改什么 | |---|---| | `admin/repository/db.go` | v1 恢复原样;新增 v3;`Migrate` 后加 schema 自检 | | `admin/repository/migrate_test.go` | 新建:收敛测试 + 带数据的去重测试 | | `admin/AGENTS.md` | 迁移只追加的规则 | | `docs/admin/03-data-model.md` | 迁移版本说明,写清 v3 做了什么、为什么丢弃旧映射 | ## 验收标准 - [ ] v1 内容与 `git show 7ad82b7:admin/repository/db.go` 逐字一致 - [ ] v3 建出的 `pdd_products` 与当前 v1 里的定义一致(含 UNIQUE、CHECK、软删除列) - [ ] **全新库迁移后的 schema,与 v2 老库迁移后的 schema 完全一致** - [ ] schema 比对覆盖表、索引、列定义、CHECK 约束 - [ ] 老库里两个蝦皮商品指向同一 PDD 链接 → 迁移后 `pdd_products` 只有 1 行 - [ ] 老库里 `collect_status = 'collecting'` → 迁移后是 `pending` - [ ] 老库里 `collect_status = 'no_link'` → 迁移后是 `pending`,不撞 CHECK - [ ] 旧 `sku_mappings` 有数据时,丢弃行数被打进日志 - [ ] `shopee_products` 迁移后不再有那四个字段,`shopee_skus` 数据未被外键带走 - [ ] 缺表时启动**失败**并给出上面那段提示,不是警告后继续 - [ ] `admin/AGENTS.md` 有「迁移只追加」的 `[必须]` 条目 - [ ] `go vet` / `gofmt -l .` / `go test ./...` 全过 - [ ] **用 8 月 6 日的老库实测**:迁移后点 PDD 商品页返回 200 ## 怎么验证 ```powershell cd D:\chengma\cmautobuy\admin go vet ./... gofmt -l . go test ./... -count=1 go test ./repository/ -run Migrate -v -count=1 ``` 老库实测(仓库里有一份 8 月 6 日的备份): ```powershell copy admin\data\backup-20260807-113030\admin.db admin\data\admin.db del admin\data\admin.db-wal, admin\data\admin.db-shm go run . # 期望:启动日志出现 v3 迁移;打开 /pdd 返回 200 ``` ## 风险和回退 | 风险 | 应对 | |---|---| | 表重建时外键把 `shopee_skus` 带走 | 按 SQLite 12 步流程;验收项里单独断言 | | 恢复 v1 时手抄出错 | 要求逐字对照 `git show`,不许凭记忆 | | `collecting` 卡死 | 已明确映射为 `pending` 并写明理由 | 回退:`git revert` 即可。已建的库若已迁到 v3,回退程序会因 「数据库版本高于本程序支持的」而拒绝启动——这是 `Migrate` 已有的正确行为。
Author
Owner

验收通过,关闭

用户确认验收通过。归档状态已更新(3fdab7b)。

  • 实现提交: d2de733
  • 归档: docs/task/20-admin-修复迁移被原地改写.md

遗留:Windows 环境未实测;真实业务数据(非空表)的迁移未验证。

## 验收通过,关闭 用户确认验收通过。归档状态已更新(`3fdab7b`)。 - **实现提交:** `d2de733` - **归档:** `docs/task/20-admin-修复迁移被原地改写.md` 遗留:Windows 环境未实测;真实业务数据(非空表)的迁移未验证。
ila closed this issue 2026-08-07 17:18:45 +08:00
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: chengma/cmautobuy#20