# 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` 全过 | 通过 | ## 测试 架构角色亲自执行(`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)