fix: 商品卡在「采集中」无法恢复 (#24)
MarkCollecting 原本只在 pending/failed 时成功,而全库只有客户端提交结果 才会把 collecting 改走。客户端离线、崩溃、任务被删都是常态——一旦发生, 这个商品就永久报废,界面上没有任何入口能救,只能改数据库。 改成 collecting 超过 15 分钟视为已超时,允许重新创建采集任务。 判定在读取那一刻现算,仍是同一条原子 UPDATE,不加后台清理协程: 后台扫要处理"扫到一半客户端正好提交了"的竞态,本项目已经在并发上 栽过两次,能不引入并发就不引入。 15 分钟写成有名字的常量并注明理由:采集本身几十秒到两分钟,加上排队 等客户端来领。宁可短也不要长——采集是只读的,多采一次没有副作用, 而卡死的代价是商品永久报废。 已知取舍:超时后老任务还在队列里,客户端上线可能两个都领走、采两次。 可接受(只读,后一次覆盖前一次),已写进代码注释和 03-data-model.md, 免得后来人当成 bug 去"修"成加锁或加租约——租约和心跳是被明确移除的设计。 时间比较用字符串,依赖 NowISO 产出定宽 UTC。已在 NowISO 上加注释: 改成带时区偏移的本地时间会让这个比较静默失效,不报错但判断全错。 界面两处:超时的显示「采集中(超时)」,否则操作员盯着「采集中」 不知道它已经死了;跳过原因按正在采集/已采集/已删除分类计数, 一个都没建成时告诉操作员还要等多久。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -299,6 +299,56 @@ CREATE INDEX idx_pdd_products_status ON pdd_products(collect_status);
|
||||
所以存 `artifact_ref`(形如 `client-001:artifacts/PDD-0001/attempt-xxx/`),
|
||||
告诉操作员去哪台机器的哪个目录捞。
|
||||
|
||||
**采集超时:`collecting` 怎么才不会永久卡死**
|
||||
|
||||
客户端离线、崩溃、任务被删——这些都是常态,不是异常。原来的规则是
|
||||
"只有 `pending` / `failed` 才允许建采集任务",而**只有客户端提交结果**才会
|
||||
把状态从 `collecting` 改走。于是上面任何一种情况发生后,这个商品的
|
||||
`collect_status` 就永远卡在 `collecting`,界面上没有任何入口能救回来,
|
||||
只能改数据库(见 #24)。
|
||||
|
||||
修复:`collecting` 状态超过 `model.CollectStaleAfter`(15 分钟)**也允许**
|
||||
重新建采集任务:
|
||||
|
||||
```sql
|
||||
-- repository.MarkCollecting,简化版
|
||||
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 < ?) )
|
||||
```
|
||||
|
||||
`[必须]` 15 分钟为什么是这个数:采集本身几十秒到两分钟,加上排队等客户端来领,
|
||||
15 分钟足够宽裕。宁可短也不要长——采集是**只读**操作,多采一次没有任何副作用,
|
||||
而卡死的代价是这个商品永久报废、只能改数据库救。定义成常量 `model.CollectStaleAfter`,
|
||||
不要把 15 分钟当魔数散落在各处判断里。
|
||||
|
||||
`[必须]` 超时判定在**读取的这一刻现算**(拼进上面这条 UPDATE 的 WHERE 里),
|
||||
**不是**后台协程定期扫描把超时的状态改回 `pending`。本项目已经在并发上栽过两次
|
||||
(PRAGMA 没作用到连接池、`BEGIN DEFERRED` 死锁),能不引入并发就不引入;
|
||||
现算没有调度、没有窗口期,天然不会有"扫到一半客户端正好提交了结果"这种竞态。
|
||||
同理,**没有引入租约(lease)或心跳**——那是被明确移除的设计,
|
||||
见 [Client 术语表](../client/00-glossary.md)「为什么没有租约和心跳」一节。
|
||||
|
||||
`[必须]` 时间比较用**字符串比较** `updated_at < ?`,不解析成 `time.Time` 再比。
|
||||
这依赖 `model.NowISO()` 永远产出定宽 UTC 格式(如 `2026-08-07T06:10:19Z`):
|
||||
定宽 + 同一时区 + 补零,字符串的字典序才等于时间先后顺序。
|
||||
谁把它改成带时区偏移的本地时间(比如 `+08:00`),这个比较会**静默失效**
|
||||
——不会报错,但超时判断全错,见 `model.NowISO` 的注释。
|
||||
|
||||
**已知取舍:可能采两次**
|
||||
|
||||
超时后允许重新建任务,但**老任务还留在队列里**,客户端上线后可能把新旧两个
|
||||
任务都领走,同一个商品被采两次。这是可以接受的:
|
||||
|
||||
- 采集是只读操作,没有任何副作用;
|
||||
- 后一次的结果覆盖前一次(`SetCollectResult` 按 `goods_id` 整体覆盖),数据仍然正确。
|
||||
|
||||
`[必须]` **不要看到"可能采两次"就去加锁或加租约"修"它**——那正是被明确移除的设计,
|
||||
加回来会重新引入本项目已经吃过两次亏的并发复杂度,而这里换来的收益(避免极少数情况下
|
||||
多采一次)远小于代价。
|
||||
|
||||
### 4.2 `skus_json` 的结构
|
||||
|
||||
由 Client 采集后原样提交,Admin **不做转换**:
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
| 商品 ID | `goods_id` |
|
||||
| 标题 | 采集回来的;未采集时显示"(未采集,采集后自动回填)" |
|
||||
| PDD 链接 | 截断显示,可点开(`target="_blank"` 要带 `rel="noopener noreferrer"`) |
|
||||
| 采集状态 | 中文文字,**不能只靠颜色**;失败时在下面补一行原因 |
|
||||
| 采集状态 | 中文文字,**不能只靠颜色**;失败时在下面补一行原因;`collecting` 超过 15 分钟没动静显示「**采集中(超时)**」,见下 |
|
||||
| 规格数 | 从 `skus_json` 算 |
|
||||
| 采集时间 | 本地时区;未采集显示 `—` |
|
||||
| 更新时间 | 本地时区 |
|
||||
@@ -184,6 +184,19 @@
|
||||
|
||||
`[必须]` 空状态分两种文案:从没创建过 → 引导去点「创建」;筛选无结果 → 给"查看全部"的入口。
|
||||
|
||||
`[必须]` **`collecting` 超过 15 分钟没有更新,显示「采集中(超时)」,不是「采集中」。**
|
||||
客户端离线、崩溃、任务被删都是常态,不是异常——一旦发生,操作员盯着「采集中」
|
||||
不知道它已经死了,会一直傻等;显示超时他才知道可以重新点「创建采集任务」。
|
||||
判定在**读取列表的这一刻现算**,不依赖任何后台任务,见
|
||||
[03 数据模型](03-data-model.md) §4「采集超时」。
|
||||
|
||||
`[必须]` 状态照旧**不能只靠颜色区分**,必须有文字——超时也是文字的一部分,
|
||||
不能只是把这一行的底色改深。
|
||||
|
||||
`[建议]` 筛选下拉里**不新增**「已超时」这个选项。它不是数据库里真实存在的
|
||||
一种 `collect_status` 取值,只是「采集中」在读取那一刻的一种呈现;
|
||||
加进筛选会让下拉框的取值和 `collect_status` 对不上。
|
||||
|
||||
### 5.3 创建弹窗
|
||||
|
||||
`[必须]` **只填 PDD 链接**,其余字段全靠采集回填。
|
||||
@@ -251,7 +264,10 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新
|
||||
指定了反而会在那台机器关着的时候干等。
|
||||
|
||||
- 勾选多行 → 按 `goods_id` 去重 → 建任务;
|
||||
- `collect_status` 已是 `collecting` 的**跳过**;
|
||||
- `collect_status` 是 `collecting` 且**没超时**(15 分钟内)的**跳过**;
|
||||
已超时的当作可以重建,不再跳过——这是 #24 修的死锁,
|
||||
见 [03 数据模型](03-data-model.md) §4「采集超时」;
|
||||
- 已经是 `collected` 的**跳过**(本来就不需要重新采集);
|
||||
- 建成功后把状态置为 `collecting`;
|
||||
- 任务的 `pdd_goods_url` 和 `pdd_goods_id` 必须填(Client 契约要求 `goods_url` 必填)。
|
||||
|
||||
@@ -259,10 +275,18 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新
|
||||
不是立刻去采——真正的采集要等 Client 来领、去手机上跑,可能几秒也可能几分钟。
|
||||
点完页面上只有状态从"未采集"变成"采集中",文案不说清楚操作员会以为没生效。
|
||||
|
||||
`[必须]` 结果在状态条明确提示,**跳过了几个也要说**:
|
||||
`[必须]` 结果在状态条明确提示,**跳过原因要分开说**,不能只给一句笼统的
|
||||
"跳过 N 个"——那样操作员不知道该等还是该做点什么:
|
||||
|
||||
```text
|
||||
已创建 3 个采集任务,等待客户端领取(跳过 1 个采集中或已删除的)
|
||||
已创建 2 个采集任务,等待客户端领取(跳过 1 个正在采集的、1 个已删除的)
|
||||
```
|
||||
|
||||
`[必须]` **一个都没建成时不要只说「已创建 0 个」**,要让操作员知道下一步该干嘛,
|
||||
比如还要等多久:
|
||||
|
||||
```text
|
||||
没有创建任何任务:1 个正在采集中(还需等待约 12 分钟才可重试)
|
||||
```
|
||||
|
||||
静默跳过的话,操作员会以为任务都建上了,等半天没动静也不知道为什么。
|
||||
|
||||
Reference in New Issue
Block a user