Files
cmautobuy/docs/task/24-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

196 lines
8.6 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.
# 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)