diff --git a/docs/task/16-admin-pdd-商品数据独立成表.md b/docs/task/16-admin-pdd-商品数据独立成表.md index f0ce232..a6f1f4c 100644 --- a/docs/task/16-admin-pdd-商品数据独立成表.md +++ b/docs/task/16-admin-pdd-商品数据独立成表.md @@ -3,7 +3,7 @@ - 类型:重构(数据模型) - 父级大工单:#14 - 所属 MVP / 版本:#15 / MVP -- 状态:待验收 +- 状态:验收通过 - 日期:2026-08-07 - Gitea 工单:http://ilaer.eicp.net:8418/chengma/cmautobuy/issues/16 diff --git a/docs/task/17-admin-claim-支持领取无主任务.md b/docs/task/17-admin-claim-支持领取无主任务.md index 38005ca..d5d97fb 100644 --- a/docs/task/17-admin-claim-支持领取无主任务.md +++ b/docs/task/17-admin-claim-支持领取无主任务.md @@ -3,7 +3,7 @@ - 类型:需求(含接口契约变更) - 父级大工单:#14 - 所属 MVP / 版本:#15 / MVP -- 状态:待验收 +- 状态:验收通过 - 日期:2026-08-07 - Gitea 工单:http://ilaer.eicp.net:8418/chengma/cmautobuy/issues/17 diff --git a/docs/task/19-admin-采集采购页.md b/docs/task/19-admin-采集采购页.md index ea65163..e28bad7 100644 --- a/docs/task/19-admin-采集采购页.md +++ b/docs/task/19-admin-采集采购页.md @@ -4,7 +4,7 @@ - 父级大工单:#14 - 所属 MVP / 版本:#15 / MVP - 关联:#18(缺陷暴露于该工单交付后) -- 状态:待验收 +- 状态:验收通过 - 日期:2026-08-07 - Gitea 工单:http://ilaer.eicp.net:8418/chengma/cmautobuy/issues/19 diff --git a/docs/task/20-admin-修复迁移被原地改写.md b/docs/task/20-admin-修复迁移被原地改写.md new file mode 100644 index 0000000..7fea01f --- /dev/null +++ b/docs/task/20-admin-修复迁移被原地改写.md @@ -0,0 +1,227 @@ +# 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) diff --git a/docs/task/24-admin-商品卡在采集中无法恢复.md b/docs/task/24-admin-商品卡在采集中无法恢复.md new file mode 100644 index 0000000..3b9d0ae --- /dev/null +++ b/docs/task/24-admin-商品卡在采集中无法恢复.md @@ -0,0 +1,195 @@ +# 24 Admin 商品卡在「采集中」无法恢复 + +- 类型:缺陷(状态机死锁) +- 父级大工单:#14 +- 所属 MVP / 版本:#15 / MVP +- 关联:#18(缺陷所在页面)、#16(`collect_status` 的引入) +- 状态:验收通过 +- 日期:2026-08-07 +- Gitea 工单:http://ilaer.eicp.net:8418/chengma/cmautobuy/issues/24 + +## 背景与目标 + +在 PDD 商品页点「创建采集任务」之后,商品进入 `collecting`, +**再也出不来**: + +```text +第 1 次点 已创建 1 个采集任务,等待客户端领取 +第 2 次点 已创建 0 个采集任务(跳过 1 个采集中或已删除的) +弹窗「重新采集」 同上 +商品状态: collecting ← 永远卡在这里 +``` + +根因:`MarkCollecting` 只在 `pending` / `failed` 时成功,而全库只有四个地方 +会改 `collect_status`,其中把 `collecting` 改走的两个 +(`SetCollectResult` / `SetCollectFailed`)**只有客户端提交结果才会触发**。 + +客户端离线、崩溃、任务被删都是常态,不是异常。每发生一次就**永久废掉一个商品**, +只能改数据库救。而当时 Client 的 `claim_next` 还没实现,等于每点一次就废一个。 + +提示「跳过 1 个采集中或已删除的」对操作员**毫无信息量**: +到底是在采、被删了、还是卡死了,看不出来,也不知道该等还是该做点什么。 + +## 最终方案 + +### 超时在读取那一刻现算,不开后台协程 + +```sql +UPDATE pdd_products + SET collect_status = 'collecting', updated_at = ? + WHERE goods_id = ? AND deleted_at IS NULL + AND ( collect_status IN ('pending', 'failed') + OR (collect_status = 'collecting' AND updated_at < ?) ) +``` + +`[必须]` **仍是同一条原子 `UPDATE`**,靠影响行数判断是否抢到。 +加超时分支最容易顺手把原子抢占破坏掉。 + +`[必须]` **不写后台协程定期把超时的改回 `pending`。** 后台扫要处理 +「扫到一半客户端正好提交了」的竞态;现算没有调度、没有并发窗口。 +本项目已经在并发上栽过两次(PRAGMA 没作用到连接池、`BEGIN DEFERRED` 死锁), +能不引入并发就不引入。 + +### 阈值 15 分钟,写成有名字的常量 + +```go +// CollectStaleAfter 是采集任务多久没动静就当它死了。 +// +// 采集本身几十秒到两分钟;加上排队等客户端来领,15 分钟足够宽裕。 +// 宁可短也不要长:采集是**只读**操作,多采一次没有任何副作用, +// 而卡死的代价是这个商品永久报废、只能改数据库救。 +const CollectStaleAfter = 15 * time.Minute +``` + +### 字符串比时间依赖 `NowISO` 的格式 + +`[必须]` `updated_at < ?` 用的是字符串比较,只有在**定宽 + 统一 UTC + 补零** +(`2026-08-07T06:10:19Z`)时字典序才等于时间序。 + +已在 `NowISO` 上加注释说明有代码依赖这个性质——改成带时区偏移的本地时间 +会让这个判断**静默失效**:不报错,但判断全错。 + +### 已知取舍:可能采两次 + +超时后允许重新建任务,但**老任务还在队列里**,客户端上线后可能把两个都领走。 +可接受:采集是只读的,后一次结果覆盖前一次,数据仍然正确。 + +`[必须]` 这条取舍写进了代码注释和 `docs/admin/03-data-model.md` §4.1, +并明写「**不要看到"可能采两次"就加锁或加租约去"修"它**——租约和心跳是 +被明确移除过的设计」。 + +### 为什么不加「手工取消采集」按钮 + +超时自动回退已经覆盖了这个场景,而且**不需要操作员判断**—— +他并不知道客户端是不是还活着、该不该取消。多一个按钮就多一处要维护、 +多一处可能被误点。 + +### 界面两处 + +**列表和弹窗**:超时的显示「**采集中(超时)**」,不是「采集中」。 +操作员盯着「采集中」不知道它已经死了,会一直等。 + +**筛选下拉不新增「已超时」**:它不是数据库里真实存在的 `collect_status` 取值, +只是「采集中」在读取那一刻的一种呈现,加进去会让下拉取值和 `collect_status` 对不上。 + +**跳过提示按原因分类**: + +```text +改前 已创建 0 个采集任务(跳过 1 个采集中或已删除的) +改后 没有创建任何任务:1 个正在采集中(还需等待约 15 分钟才可重试) +``` + +## 与建单方案的差异 + +1. **常量必须导出**(`model.CollectStaleAfter`)——`repository` 和 `service` + 都要用,Go 不允许跨包访问未导出名。工单里的小写名是示意。 +2. **`CreatePddCollectTasks` 签名改成返回结构体**,用来携带分类计数。 +3. **多加一个「已采集的」跳过桶**。工单示例只列了「正在采集的」「已删除的」两类, + 原先已 `collected` 的行会被含糊地并进同一桶,单独标注更准确。 +4. **「采集中(超时)」也用到详情弹窗**。工单只要求列表页;不这样做会出现 + 列表说超时、弹窗说采集中,自相矛盾。 +5. `admin/templates/pdd/list.html` **未改动**——文案通过已有的 `{{.StatusText}}` + 绑定自动生效,比工单预计的改动更小。 + +## 改了哪些 + +- `admin/model/model.go`:`CollectStaleAfter` 常量;`NowISO` 加注释。 +- `admin/repository/pdd.go`:`MarkCollecting` 增加超时分支(仍是单条原子 UPDATE)。 +- `admin/service/pdd.go`:`statusTextFor` / `isCollectingStale`(列表与详情共用); + `CollectTaskResult` 分类计数;提示文案组装。 +- `admin/handler/web/pdd.go`:改调 `FormatCollectTaskMessage`。 +- `admin/service/pdd_page_test.go`:超时、边界、并发、分类计数、文案共 8 个新用例。 +- `docs/admin/03-data-model.md` §4.1、`05-ui-specification.md` §5.2/§5.5。 + +## 验收结果 + +| 验收标准 | 结果 | +|---|---| +| `collecting` 且超过 15 分钟 → 能重新创建 | 通过 | +| `collecting` 但**没到** 15 分钟 → 仍然跳过 | 通过 | +| `pending` / `failed` → 照常能建 | 通过 | +| 已软删除的 → 仍然跳过 | 通过 | +| 阈值是有名字的常量并写清理由 | 通过 | +| **多个请求同时对同一「已超时」商品建任务,只产生 1 条** | 通过 | +| 列表页超时显示「采集中(超时)」 | 通过 | +| 状态有文字区分,不只靠颜色 | 通过 | +| 筛选下拉未新增「已超时」 | 通过(仍是 5 项) | +| 跳过原因分类计数 | 通过 | +| 一个都没建成时告诉操作员还要等多久 | 通过 | +| `NowISO` 有格式依赖的注释 | 通过 | +| 「可能采两次」写进代码注释和文档 | 通过 | +| **没有后台协程、没有租约或心跳** | 通过(生产代码零 `go func`) | +| `go vet` / `gofmt` / `go test` 全过 | 通过 | + +## 测试 + +架构角色亲自执行(Go 1.23.0,`admin/` 目录): + +``` +go vet ./... 无输出 +gofmt -l . 无输出 +go test ./... -count=1 ok repository / ok service +go test ./service/ -run '并发' -count=20 ok(稳定) +``` + +### 变异测试(架构角色补做,工单未要求) + +| 变异 | 结果 | +|---|---| +| 去掉超时限制(`collecting` 一律允许重建) | 4 个用例变红 | +| 去掉超时分支(退回卡死行为) | 2 个变红 | +| **破坏原子性判断**(`n == 1` → `n >= 0`) | **5 个变红** | +| 比较方向反了(`<` → `>`) | 5 个变红 | + +第三个是重点——加超时分支最容易把原子抢占破坏掉,测试能抓到说明有牙。 + +### 端到端 + +``` +① 第一次点 已创建 1 个采集任务,等待客户端领取 列表: 采集中 +② 立刻再点 没有创建任何任务:1 个正在采集中 + (还需等待约 15 分钟才可重试) 列表: 采集中 +③ updated_at 往前调 20 分钟 列表: 采集中(超时) + 弹窗: 采集中(超时) +④ 再点 已创建 1 个采集任务,等待客户端领取 +⑤ 任务表 2 条(已知取舍:老任务还在队列里) +``` + +筛选下拉仍是 5 项,页面里「已超时」出现 0 次。 + +**未验证到的部分:** + +- **15 分钟的精确边界未单独测**。测的是 20 分钟(超时)和 5 分钟(未超时)。 + 当前是严格 `<`,即「正好卡在 15 分钟那一刻」算未超时。 +- Windows 环境未实测(本机 WSL/Linux)。 +- 未与真实 Client 联调确认「超时后的第二个任务被领走、其结果正确覆盖第一个」—— + 工单验证步骤明确说不需要真机和客户端。 + +## 遗留问题 + +1. 15 分钟边界的精确行为未单独测试。 +2. 「可能采两次」在真实客户端上线后的表现未验证。 + +## 相关提交 + +- `e40ce5a` fix: 商品卡在「采集中」无法恢复 (#24)