Files
cmautobuy/docs/task/24-admin-商品卡在采集中无法恢复.md
T
chengmaandClaude Opus 5 10b724f62a docs: 归档 #38;改正六份归档里未经验证的 Go 版本说法
#38 归档记录了打回一次的经过(ParseSpec 两个失败分支无测试覆盖,
其中 size=="" 就是真实数据里 2 行走的分支),以及架构角色第一轮
变异打在死分支上、差点误报「测试没牙」的过程。

改正:/usr/local/go 从 2026-07-02 起一直是 1.26.5,而六份归档都写着
「架构角色亲自执行(Go 1.23.0)」——那是照项目固定版本抄的,
没有实际验证过工具链,违反 CLAUDE.md §9「说验证过必须真的跑过」。
措辞改为不宣称具体版本。已用 GOTOOLCHAIN=go1.23.0 补验,结论不变。

admin/AGENTS.md 加两条 [必须]:
- 交付前至少跑一次带 GOTOOLCHAIN=go1.23.0 的验证。开发机装的可能更新,
  Go 会默默用它编译,测过的不是要交付的那个版本。新加依赖时尤其要跑。
- 改数据库时不要只测全新库,先列出现实中存在哪些 schema 状态再一个个验(来自 #20)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 09:59:34 +08:00

8.6 KiB
Raw Blame History

24 Admin 商品卡在「采集中」无法恢复

  • 类型:缺陷(状态机死锁)
  • 父级大工单:#14
  • 所属 MVP / 版本:#15 / MVP
  • 关联:#18(缺陷所在页面)、#16(collect_status 的引入)
  • 状态:验收通过
  • 日期:2026-08-07
  • Gitea 工单:#24

背景与目标

在 PDD 商品页点「创建采集任务」之后,商品进入 collecting, 再也出不来:

第 1 次点        已创建 1 个采集任务,等待客户端领取
第 2 次点        已创建 0 个采集任务(跳过 1 个采集中或已删除的)
弹窗「重新采集」  同上
商品状态: collecting     ← 永远卡在这里

根因:MarkCollecting 只在 pending / failed 时成功,而全库只有四个地方 会改 collect_status,其中把 collecting 改走的两个 (SetCollectResult / SetCollectFailed)只有客户端提交结果才会触发。

客户端离线、崩溃、任务被删都是常态,不是异常。每发生一次就永久废掉一个商品, 只能改数据库救。而当时 Client 的 claim_next 还没实现,等于每点一次就废一个。

提示「跳过 1 个采集中或已删除的」对操作员毫无信息量: 到底是在采、被删了、还是卡死了,看不出来,也不知道该等还是该做点什么。

最终方案

超时在读取那一刻现算,不开后台协程

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 分钟,写成有名字的常量

// 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 对不上。

跳过提示按原因分类:

改前  已创建 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)