# 17 Admin claim 支持领取无主任务 - 类型:需求(含接口契约变更) - 父级大工单:#14 - 所属 MVP / 版本:#15 / MVP - 状态:验收通过 - 日期:2026-08-07 - Gitea 工单:http://ilaer.eicp.net:8418/chengma/cmautobuy/issues/17 ## 背景与目标 PDD 商品页要能「创建采集任务」而**不指定客户端**——采集只是浏览商品页, 没有副作用,哪台设备采都一样,没必要每次都挑一台。 但原来 `claim` 的查询是: ```sql WHERE assigned_client = ? AND status = 'assigned' ``` **无主任务(`assigned_client IS NULL` + `status='pending'`)永远不会被任何客户端领到**, 建出来就是死的。 本工单**推翻了一条已定案的规则**:`docs/client/04-admin-api-contract.md` §5.1 原写「Admin 只把任务分配给指定的 Client」。 ## 最终方案 ### 查询同时覆盖两种任务 ```sql WHERE ( (assigned_client = ? AND status = 'assigned') -- 指定给本机的 OR (assigned_client IS NULL AND status = 'pending') ) -- 无主的 ORDER BY (assigned_client IS NULL), priority DESC, created_at ``` **指定给本机的优先于无主的**。`(assigned_client IS NULL)` 求值为 0/1,0 排前面。 理由:显式分配是人为决定,应当先兑现;无主任务谁抢都一样,可以等。 ### 原子抢占合成一条语句 ```sql UPDATE tasks SET status = 'claimed', assigned_client = ?, claimed_at = ?, updated_at = ? WHERE task_id = ? AND ( (status = 'assigned' AND assigned_client = ?) OR (status = 'pending' AND assigned_client IS NULL) ) ``` 对「指定给我的」那种,写 `assigned_client` 是写同一个值,无副作用; 对无主的,这一步就是**「谁领到就标记谁」**。检查影响行数防并发, 为 0 说明被抢先了,换下一条候选。 ### 分配策略 | 任务类型 | 分配 | 理由 | |---|---|---| | 采集 | **不指定** | 只是浏览商品页,无副作用,哪台设备采都一样 | | 采购 | **可指定,允许留空** | 涉及钱和账号——不同设备可能登着不同的拼多多账号,需要指定时能指定;留空即接受「谁先抢到谁去下单」 | Client 侧对这两种**没有任何区别**:调 `claim`,拿到任务就做,做完提交, 不需要知道任务原来有没有主。 ### 未改动 表结构不用动——`assigned_client` 本来可空,`status` 的 `pending` 也已存在。 ## 与建单方案的差异 无。按工单实施。 ## 改了哪些 - `admin/repository/task.go`:`ClaimNextTask` 的查询条件、排序和原子更新。 - `admin/service/claim_unassigned_test.go`:新建,7 个测试。 - `docs/client/04-admin-api-contract.md`:§5.1 改写为两种任务并存并说明各自适用场景; §10「已定案」那条同步。 - `docs/admin/04-client-api.md`:§2 处理步骤、SQL 示例、§9 实现清单。 ## 验收结果 | 验收标准 | 结果 | |---|---| | 无主的采集任务能被领到 | 通过 | | 领到后 `assigned_client` 被写成领取者编号 | 通过 | | `status` 变 `claimed`,`claimed_at` 有值 | 通过 | | **指定给本机的优先于无主的** | 通过 | | 仍然领不到指定给别的客户端的任务 | 通过 | | `supported_types` 过滤仍生效 | 通过 | | **并发:多客户端抢同一无主任务,只有一个拿到** | 通过 | | 领取后 `task_claims` 有对应记录 | 通过 | | 两侧契约文档已更新,不再声称「只分配给指定 Client」 | 通过(全库无残留) | | `go vet` / `gofmt` / `go test` 全过 | 通过 | ## 测试 执行的命令(Go 1.23.0,`admin/` 目录): ``` go vet ./... 无输出 gofmt -l . 无输出 go test ./... -count=1 ok,62 个测试全 PASS(新增 7 个) go test ./service/ -run '并发抢无主' -count=20 ok ``` 新增的 7 个测试: ``` TestClaim_无主任务能被领到并标记领取者 TestClaim_无主任务只能被领一次 TestClaim_指定给本机的优先于无主的 TestClaim_指定给别人的仍然领不到 TestClaim_无主任务也受supported_types约束 TestClaim_并发抢无主任务只有一个拿到 TestClaim_无主任务领取后也记领取历史 ``` 优先级那条特意构造成「无主任务的 `created_at` 更早」——没有优先级规则的话, 按 `created_at` 排序会先拿到无主的,测试就会红。 并发那条断言:8 个客户端抢同一条无主任务,**正好 1 个拿到**, 且库里 `assigned_client` 记的就是那个赢家。重复 20 次稳定。 **未验证到的部分:** - 未做 HTTP 层端到端验证(只验证了 service 层)。`claim` 接口本身未改, 改动全在 repository 的查询里。 - 未与真实 Client 联调——Client 的 `HttpAdminGateway` 目前只实现了 `register_client`,`claim_next` 尚未实现。 ## 遗留问题 **`tasks` 表没有字段标记「该任务需要真实下单」。** 契约要求「不向只声明 `dry_run` 的 Client 分配需要真实下单的任务」, 但没有这个字段就无法执行。本工单把无主任务开放给所有客户端后, 这条风险被放大:一台声明 `dry_run` 的客户端可能抢到本该真实下单的任务。 当前真实下单开关默认关闭、全部走演练模式,不会出问题。 **开启真实下单前必须补该字段**,与采购安全门禁(`docs/admin/06` §3)一并处理。 用户已确认暂不为此单独建工单,留在此处与 #14 Epic 的全局风险中记录。 ## 相关提交 - `dd387e3` feat: claim 支持领取无主任务 (#17)