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>
This commit is contained in:
chengma
2026-08-07 17:18:32 +08:00
co-authored by Claude Opus 5
parent 61d8d1b263
commit 3fdab7b2a8
5 changed files with 425 additions and 3 deletions
@@ -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)