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

Closed
opened 2026-08-07 14:24:08 +08:00 by ila · 1 comment
Owner

基本信息

  • 类型:缺陷(状态机死锁)
  • 父级大工单:#14
  • 所属 MVP / 版本:#15 / MVP
  • 优先级:高,挡住 PDD 页的正常试用
  • 关联:#18(缺陷所在页面)、#16(collect_status 的引入)

要解决什么

复现步骤

  1. 打开 PDD 商品页,添加一个商品
  2. 勾选它,点「创建采集任务」→ 提示「已创建 1 个采集任务,等待客户端领取」
  3. 因为客户端还没实现 claim_next,没有人会来领这个任务
  4. 再点「创建采集任务」→ 「已创建 0 个采集任务(跳过 1 个采集中或已删除的)」
  5. 打开编辑弹窗点「重新采集」→ 同样的提示

实测输出:

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

根因

MarkCollecting 只在 pending / failed 时成功:

WHERE goods_id = ? AND deleted_at IS NULL
  AND collect_status IN ('pending', 'failed')

而全库只有四个地方会改 collect_status:

位置 改成
SetCollectResult collected
SetCollectFailed failed
EnsurePddProduct 复活分支 pending
MarkCollecting collecting

前两个只有客户端提交结果才会触发。所以一旦进了 collecting,
客户端不来提交就永远出不去,界面上没有任何入口能把它改回去。

为什么这是硬伤而不是小毛病

客户端离线、崩溃、任务被删——这些都是常态,不是异常。
每发生一次就永久废掉一个商品,只能改数据库救。

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

做什么 / 不做什么

做:

  1. collecting 超过一定时间视为已超时,允许重新创建采集任务
  2. 列表页把超时的显示成「采集中(超时)」,让操作员看得见
  3. 跳过时按原因分类计数,提示说人话

不做:

  • 不引入租约(lease)和心跳。这是被明确移除的设计
    (见 docs/client/00-glossary.md「为什么没有租约和心跳」),
    不得借这个工单偷偷加回来
  • 不引入后台清理协程
  • 不加「手工取消采集」按钮(理由见下)
  • 不改客户端、不改 tasks 表结构

怎么做

超时判定放在读取的那一刻现算,不要后台扫

WHERE goods_id = ? AND deleted_at IS NULL
  AND (   collect_status IN ('pending', 'failed')
       OR (collect_status = 'collecting' AND updated_at < ?) )

? 传 now - collectStaleAfter。

[必须] 不要写后台协程定期把超时的改回 pending。 现算没有调度、
没有并发窗口;后台扫要处理"扫到一半客户端正好提交了"的竞态。
本项目已经在并发上栽过两次(PRAGMA 没作用到连接池、BEGIN DEFERRED 死锁),
能不引入并发就不引入。

[必须] 字符串比时间是安全的,但依赖一个前提:model.NowISO() 永远产出
UTC 定宽格式(2026-08-07T06:10:19Z)。定宽 + 同时区 + 补零,
字典序才等于时间序。在 NowISO 上加一句注释说明有人依赖这个性质——
以后谁改成带时区偏移的本地时间,这里的比较会静默失效,不报错但判断全错。

超时阈值取 15 分钟

[必须] 定义成有名字的常量并写清理由,不要散落魔数:

// collectStaleAfter 是采集任务多久没动静就当它死了。
//
// 采集本身几十秒到两分钟;加上排队等客户端来领,15 分钟足够宽裕。
// 宁可短也不要长:采集是**只读**操作,多采一次没有任何副作用,
// 而卡死的代价是这个商品永久报废、只能改数据库救。
const collectStaleAfter = 15 * time.Minute

已知取舍:可能采两次

超时后允许重新建任务,但老任务还在队列里,客户端上线后可能把两个都领走,
采两次。这是可以接受的:

  • 采集是只读的,没有副作用
  • 后一次的结果覆盖前一次,数据仍然正确

[必须] 把这条取舍写进代码注释和 docs/admin/03-data-model.md,
不要让后来人以为是 bug 而去"修"成加锁或加租约。

列表页要看得见

[必须] 超时的显示「采集中(超时)」,不是「采集中」。

理由:操作员盯着「采集中」不知道它已经死了,会一直等。
显示超时他才知道可以重新采。

[必须] 状态不能只靠颜色区分(docs/admin/05 §10 可访问性),
要有文字。

[建议] 筛选下拉里不新增「已超时」选项。它不是一个真实的存储状态,
只是「采集中」的一种呈现;加进去会让筛选和 collect_status 的取值对不上。

跳过提示按原因分类

现在不管什么原因都并成一句「跳过 N 个采集中或已删除的」。改成分类:

已创建 2 个采集任务,等待客户端领取
(跳过 1 个正在采集的、1 个已删除的)

[必须] 「正在采集」指没超时的那些。超时的现在会被正常创建,不再计入跳过。

[必须] 一个都没建成时不要只说「已创建 0 个」,要让操作员知道下一步该干嘛,
例如:没有创建任何任务:1 个正在采集中(还需等待约 12 分钟才可重试)。

为什么不加「手工取消采集」按钮

超时自动回退已经覆盖了这个场景,而且不需要操作员判断——
他并不知道客户端是不是还活着、该不该取消。多一个按钮就多一处要维护、
多一处要写文档、多一处可能被误点。

如果将来发现 15 分钟太久、确实需要立刻重试,再单独开工单加。

预计修改文件

文件 改什么
admin/repository/pdd.go MarkCollecting 增加超时分支;列表查询带出「是否超时」
admin/model/model.go collectStaleAfter 常量;NowISO 加注释说明有人依赖定宽 UTC
admin/service/pdd.go 状态文案区分超时;跳过原因分类计数;提示文案
admin/handler/web/pdd.go 提示文案组装
admin/templates/pdd/list.html 超时状态显示
docs/admin/03-data-model.md 超时规则、15 分钟的理由、可能采两次的取舍
docs/admin/05-ui-specification.md §5.2 状态列、§5.5 跳过提示

验收标准

超时判定

  • collecting 且 updated_at 超过 15 分钟 → 能重新创建采集任务
  • collecting 但没到 15 分钟 → 仍然跳过,不重复建
  • pending / failed → 照常能建(原有行为不变)
  • 已软删除的 → 仍然跳过
  • 阈值是有名字的常量,注释写清为什么是 15 分钟

并发

  • 多个请求同时对同一个「已超时」商品建任务,只产生 1 条
    (原有的原子抢占不能因为加了超时分支而失效)

界面

  • 列表页超时的显示「采集中(超时)」,未超时的显示「采集中」
  • 状态有文字区分,不只靠颜色
  • 筛选下拉未新增「已超时」选项

提示

  • 跳过原因分类计数(正在采集的 / 已删除的)
  • 一个都没建成时,提示告诉操作员还要等多久

其他

  • NowISO 上有注释说明「有代码依赖它产出定宽 UTC,改格式会让时间比较静默失效」
  • 「可能采两次」的取舍写进了代码注释和 03-data-model.md
  • 没有引入后台协程、没有引入租约或心跳
  • go vet / gofmt -l . / go test ./... 全过
  • 五个页面均 200

怎么验证

cd D:\chengma\cmautobuy\admin
go vet ./...
gofmt -l .
go test ./... -count=1

端到端(不需要真机、不需要客户端):

  1. 添加一个商品,点「创建采集任务」→ 应提示已创建 1 个
  2. 再点一次 → 应跳过,并提示还要等多久
  3. 直接改库把该商品的 updated_at 往前调 20 分钟
  4. 再点「创建采集任务」→ 应该成功建出第二个任务
  5. 刷新列表 → 第 3 步之后、第 4 步之前应显示「采集中(超时)」

风险和回退

风险 应对
超时分支破坏原有的原子抢占,并发下建出多条 已列为验收项,必须有并发测试
有人后来把 NowISO 改成本地时间,时间比较静默失效 在 NowISO 上加注释;测试用固定时间戳而非 time.Now()
15 分钟被当成魔数散落 要求定义成有名字的常量

回退:git revert。无表结构变更。

## 基本信息 - 类型:缺陷(状态机死锁) - 父级大工单:#14 - 所属 MVP / 版本:#15 / MVP - 优先级:**高**,挡住 PDD 页的正常试用 - 关联:#18(缺陷所在页面)、#16(`collect_status` 的引入) ## 要解决什么 ### 复现步骤 1. 打开 PDD 商品页,添加一个商品 2. 勾选它,点「创建采集任务」→ 提示「已创建 1 个采集任务,等待客户端领取」 3. 因为客户端还没实现 `claim_next`,没有人会来领这个任务 4. 再点「创建采集任务」→ **「已创建 0 个采集任务(跳过 1 个采集中或已删除的)」** 5. 打开编辑弹窗点「重新采集」→ **同样的提示** 实测输出: ```text 第 1 次点 已创建 1 个采集任务,等待客户端领取 第 2 次点 已创建 0 个采集任务(跳过 1 个采集中或已删除的) 弹窗「重新采集」 已创建 0 个采集任务(跳过 1 个采集中或已删除的) 商品状态: collecting ← 永远卡在这里 ``` ### 根因 `MarkCollecting` 只在 `pending` / `failed` 时成功: ```sql WHERE goods_id = ? AND deleted_at IS NULL AND collect_status IN ('pending', 'failed') ``` 而全库只有四个地方会改 `collect_status`: | 位置 | 改成 | |---|---| | `SetCollectResult` | `collected` | | `SetCollectFailed` | `failed` | | `EnsurePddProduct` 复活分支 | `pending` | | `MarkCollecting` | `collecting` | 前两个**只有客户端提交结果才会触发**。所以一旦进了 `collecting`, 客户端不来提交就永远出不去,**界面上没有任何入口能把它改回去**。 ### 为什么这是硬伤而不是小毛病 客户端离线、崩溃、任务被删——这些都是常态,不是异常。 每发生一次就永久废掉一个商品,只能改数据库救。 而且提示「跳过 1 个采集中或已删除的」对操作员**毫无信息量**: 到底是在采、还是被删了、还是卡死了,看不出来,也不知道该等还是该做点什么。 ## 做什么 / 不做什么 做: 1. `collecting` 超过一定时间视为**已超时**,允许重新创建采集任务 2. 列表页把超时的显示成「采集中(超时)」,让操作员看得见 3. 跳过时按原因分类计数,提示说人话 不做: - **不引入租约(lease)和心跳**。这是被明确移除的设计 (见 `docs/client/00-glossary.md`「为什么没有租约和心跳」), 不得借这个工单偷偷加回来 - **不引入后台清理协程** - 不加「手工取消采集」按钮(理由见下) - 不改客户端、不改 `tasks` 表结构 ## 怎么做 ### 超时判定放在读取的那一刻现算,不要后台扫 ```sql WHERE goods_id = ? AND deleted_at IS NULL AND ( collect_status IN ('pending', 'failed') OR (collect_status = 'collecting' AND updated_at < ?) ) ``` `?` 传 `now - collectStaleAfter`。 `[必须]` **不要写后台协程定期把超时的改回 `pending`。** 现算没有调度、 没有并发窗口;后台扫要处理"扫到一半客户端正好提交了"的竞态。 本项目已经在并发上栽过两次(PRAGMA 没作用到连接池、`BEGIN DEFERRED` 死锁), 能不引入并发就不引入。 `[必须]` 字符串比时间是**安全的,但依赖一个前提**:`model.NowISO()` 永远产出 UTC 定宽格式(`2026-08-07T06:10:19Z`)。定宽 + 同时区 + 补零, 字典序才等于时间序。**在 `NowISO` 上加一句注释说明有人依赖这个性质**—— 以后谁改成带时区偏移的本地时间,这里的比较会**静默失效**,不报错但判断全错。 ### 超时阈值取 15 分钟 `[必须]` 定义成有名字的常量并写清理由,不要散落魔数: ```go // collectStaleAfter 是采集任务多久没动静就当它死了。 // // 采集本身几十秒到两分钟;加上排队等客户端来领,15 分钟足够宽裕。 // 宁可短也不要长:采集是**只读**操作,多采一次没有任何副作用, // 而卡死的代价是这个商品永久报废、只能改数据库救。 const collectStaleAfter = 15 * time.Minute ``` ### 已知取舍:可能采两次 超时后允许重新建任务,但**老任务还在队列里**,客户端上线后可能把两个都领走, 采两次。这是可以接受的: - 采集是只读的,没有副作用 - 后一次的结果覆盖前一次,数据仍然正确 `[必须]` 把这条取舍写进代码注释和 `docs/admin/03-data-model.md`, 不要让后来人以为是 bug 而去"修"成加锁或加租约。 ### 列表页要看得见 `[必须]` 超时的显示「**采集中(超时)**」,不是「采集中」。 理由:操作员盯着「采集中」不知道它已经死了,会一直等。 显示超时他才知道可以重新采。 `[必须]` **状态不能只靠颜色区分**(`docs/admin/05` §10 可访问性), 要有文字。 `[建议]` 筛选下拉里不新增「已超时」选项。它不是一个真实的存储状态, 只是「采集中」的一种呈现;加进去会让筛选和 `collect_status` 的取值对不上。 ### 跳过提示按原因分类 现在不管什么原因都并成一句「跳过 N 个采集中或已删除的」。改成分类: ```text 已创建 2 个采集任务,等待客户端领取 (跳过 1 个正在采集的、1 个已删除的) ``` `[必须]` 「正在采集」指**没超时**的那些。超时的现在会被正常创建,不再计入跳过。 `[必须]` 一个都没建成时不要只说「已创建 0 个」,要让操作员知道下一步该干嘛, 例如:`没有创建任何任务:1 个正在采集中(还需等待约 12 分钟才可重试)`。 ### 为什么不加「手工取消采集」按钮 超时自动回退已经覆盖了这个场景,而且**不需要操作员判断**—— 他并不知道客户端是不是还活着、该不该取消。多一个按钮就多一处要维护、 多一处要写文档、多一处可能被误点。 如果将来发现 15 分钟太久、确实需要立刻重试,再单独开工单加。 ## 预计修改文件 | 文件 | 改什么 | |---|---| | `admin/repository/pdd.go` | `MarkCollecting` 增加超时分支;列表查询带出「是否超时」 | | `admin/model/model.go` | `collectStaleAfter` 常量;`NowISO` 加注释说明有人依赖定宽 UTC | | `admin/service/pdd.go` | 状态文案区分超时;跳过原因分类计数;提示文案 | | `admin/handler/web/pdd.go` | 提示文案组装 | | `admin/templates/pdd/list.html` | 超时状态显示 | | `docs/admin/03-data-model.md` | 超时规则、15 分钟的理由、可能采两次的取舍 | | `docs/admin/05-ui-specification.md` | §5.2 状态列、§5.5 跳过提示 | ## 验收标准 **超时判定** - [ ] `collecting` 且 `updated_at` 超过 15 分钟 → 能重新创建采集任务 - [ ] `collecting` 但**没到** 15 分钟 → 仍然跳过,不重复建 - [ ] `pending` / `failed` → 照常能建(原有行为不变) - [ ] 已软删除的 → 仍然跳过 - [ ] 阈值是有名字的常量,注释写清为什么是 15 分钟 **并发** - [ ] **多个请求同时对同一个「已超时」商品建任务,只产生 1 条** (原有的原子抢占不能因为加了超时分支而失效) **界面** - [ ] 列表页超时的显示「采集中(超时)」,未超时的显示「采集中」 - [ ] 状态有文字区分,不只靠颜色 - [ ] 筛选下拉未新增「已超时」选项 **提示** - [ ] 跳过原因分类计数(正在采集的 / 已删除的) - [ ] 一个都没建成时,提示告诉操作员还要等多久 **其他** - [ ] `NowISO` 上有注释说明「有代码依赖它产出定宽 UTC,改格式会让时间比较静默失效」 - [ ] 「可能采两次」的取舍写进了代码注释和 `03-data-model.md` - [ ] 没有引入后台协程、没有引入租约或心跳 - [ ] `go vet` / `gofmt -l .` / `go test ./...` 全过 - [ ] 五个页面均 200 ## 怎么验证 ```powershell cd D:\chengma\cmautobuy\admin go vet ./... gofmt -l . go test ./... -count=1 ``` 端到端(不需要真机、不需要客户端): 1. 添加一个商品,点「创建采集任务」→ 应提示已创建 1 个 2. 再点一次 → 应跳过,并提示还要等多久 3. 直接改库把该商品的 `updated_at` 往前调 20 分钟 4. 再点「创建采集任务」→ **应该成功建出第二个任务** 5. 刷新列表 → 第 3 步之后、第 4 步之前应显示「采集中(超时)」 ## 风险和回退 | 风险 | 应对 | |---|---| | 超时分支破坏原有的原子抢占,并发下建出多条 | 已列为验收项,必须有并发测试 | | 有人后来把 `NowISO` 改成本地时间,时间比较静默失效 | 在 `NowISO` 上加注释;测试用固定时间戳而非 `time.Now()` | | 15 分钟被当成魔数散落 | 要求定义成有名字的常量 | 回退:`git revert`。无表结构变更。
Author
Owner

验收通过,关闭

用户确认验收通过。归档状态已更新(3fdab7b)。

  • 实现提交: e40ce5a
  • 归档: docs/task/24-admin-商品卡在采集中无法恢复.md

遗留:15 分钟边界的精确行为未单独测试;「可能采两次」在真实客户端上线后的表现未验证。

## 验收通过,关闭 用户确认验收通过。归档状态已更新(`3fdab7b`)。 - **实现提交:** `e40ce5a` - **归档:** `docs/task/24-admin-商品卡在采集中无法恢复.md` 遗留:15 分钟边界的精确行为未单独测试;「可能采两次」在真实客户端上线后的表现未验证。
ila closed this issue 2026-08-07 17:18:49 +08:00
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: chengma/cmautobuy#24