补上 #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:
@@ -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)
|
||||
Reference in New Issue
Block a user