Files
cmbuyer/docs/tasks/T-302.md
T

109 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: T-302
title: 已授权任务原子领取与租约(F-005)
phase: 3
deps: [T-301, T-203]
status: DOING
created: 2026-08-04
vikunja_task_id: 34
context_ref: 2331882
work_branch: task/t-302-atomic-claim
needs_device: false
needs_human_review: false
write_paths:
- docs/tasks/T-302.md
- admin/migrations/00005_task_claims.sql
- admin/internal/migrations/migrations_test.go
- admin/internal/taskclaim/**
- admin/internal/config/**
- admin/internal/storage/evidence/**
- admin/internal/taskdetail/store_test.go
- admin/internal/server/**
- admin/cmd/server/**
- admin/README.md
- docs/api.md
- docs/04-architecture.md
---
<!-- BEGIN VIKUNJA EXPORT id=34 synced=2026-08-04T13:15:59Z sha256=2764713b182c73ac7b7cf12dda4b4766d2474ec1d4fe79c5cd59ef0e9828c302 -->
## 问题 / 背景
T-203 已把管理员明确授权的任务原子转为 PENDING,T-301 已提供可撤销的真实设备身份,但采购工具还不能安全领取任务。T-302 建立服务端原子领取、幂等重放和租约续期边界,使同一授权只能产生一条可恢复 attempt;不实现客户端轮询、页面执行、事件、提交围栏或结果上报。
## 关联需求与交互
- 功能:F-005。
- 用户故事:US-003、US-005、US-007。
- 交互:为 IX-007、IX-008 提供服务端契约;本任务无新增管理页面。
- 依赖:T-301、T-203;后续消费者:T-303、T-205、T-208、T-306。
- API:`POST /api/v1/tasks/claim-next`、`POST /api/v1/tasks/{id}/lease/renew`。
## 方案
1. 新增 `00005_task_claims.sql`。用独立 claim、claim request、lease renewal 表绑定 task、authorization、attempt、设备、session、generation、随机 nonce、token hash 和租约;同一授权最多一个 attempt,同一设备最多一个未关闭 claim。迁移遇到无法推断归属的既有 attempt/submission/evidence 事实时拒绝升级,已有领取事实时拒绝降级。
2. Claim token 使用独立 `CMBUYER_CLAIM_TOKEN_SECRET`(64 位小写十六进制,解码为 32 字节),不复用管理员 session 或设备 token。每条 claim 生成 32 字节随机 nonce,用带版本域分隔的 HMAC-SHA256 绑定 device/task/authorization/attempt/generation/nonce;返回 64 位小写十六进制 token,数据库只保存 nonce 和 token SHA-256。服务重启后可重建同一 token;secret 不匹配已有 claim 时启动失败闭合。
3. 新增显式 `CMBUYER_CLAIM_LEASE_TTL`,必须为正且严格短于授权 TTL。领取和续租只使用服务端 UTC 时间;边界相等视为已过期,没有宽限或隐式续租。
4. `claim-next` JSON 只接受规范 UUIDv4 的 `session_id`、`claim_request_id`;设备 id 只来自 T-301 认证主体。严格校验 Content-Type、UTF-8、大小、未知字段和额外 JSON。设备认证仍先于请求体读取。
5. Claim/renew 的有界 SQLite 事务必须先取得写入线性化位置,并以条件更新确认设备仍为 ACTIVE(RowsAffected 必须为 1);只有在此之后才可读取或重放 request、返回 EMPTY/冲突、选择候选或续租。领取再稳定选择最早的 `PENDING + ACTIVE + 未过期 + task/version/规格/数量/总价快照一致` 授权,创建 attempt/claim/request,并原子执行 authorization `ACTIVE→CLAIMED`、task `PENDING→CLAIMED` 且 version+1。任一步行数或约束不符全部回滚;并发设备只能一个成功。SQLite 无行锁,不得用先 SELECT 或先返回 replay 代替该写入线性化。
6. 同一 claim request 同载荷在响应丢失和服务重启后返回原结果与同一 claim token;无候选也持久化 EMPTY 并稳定重放。同一设备已有未关闭 claim 时,同 session 且租约有效重放原 attempt;不同 session、租约过期或状态异常固定返回需人工处理,不转领、不创建第二个 attempt。
7. 续租请求绑定 renew_request_id、session、attempt、generation、claim token 和 expected_lease_expires_at。只允许原设备/会话/attempt,当前租约和授权都严格未过期,且 expected 值精确匹配;新到期时间为 `min(server_now + lease_ttl, authorization.expires_at)`。同键同载荷稳定重放,异载荷冲突;续租不改变 token、generation、任务版本或业务状态,过期租约不能复活。
8. 设备撤销与领取并发必须在线性化位置再次查 ACTIVE:撤销先提交则领取/续租失败,领取先提交后撤销不自动释放 claim。撤销、租约过期、停止轮询均不能证明手机已停止,另一设备不得自动接管;后续 T-207 负责人工安全恢复。
9. 用统一 claim 所有权校验收紧 T-204 证据存储:设备只能向自己当前 attempt 上传截图;不得因本任务扩大截图 kind、文件类型或隐私边界。
10. 本任务不新增 heartbeat/events/fail/fence/result,不实现 client HTTP、真机选择器、下单函数、提交订单点击或任何付款动作。claim token 永远不是提交许可,响应不得包含自由动作脚本、坐标、选择器或 `click_permitted`。
## 验收要点
- Migration 覆盖升级/重开、外键/唯一/partial index、既有事实拒绝升级和有领取事实拒绝降级。
- 覆盖领取 eligibility、快照一致、稳定排序、事务回滚、两设备并发唯一、同设备单开放 claim、成功/EMPTY/冲突幂等及服务重启重放。
- 覆盖 HMAC 域隔离、nonce 随机、数据库无明文、错误 secret 启动失败、跨设备/attempt/generation/token 拒绝和常量时间比较。
- 覆盖续租 CAS、授权到期封顶、无宽限、过期不复活、乱序/并发/同键异载荷,以及续租不改变业务状态。
- 覆盖撤销并发线性化、认证失败 body 零读取、设备 A 不能上传设备 B attempt 证据。
- HTTP 错误固定且不泄露 token、SQL、路径或候选任务;静态检查确认没有事件、围栏、提交、付款或页面自动化能力。
- `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、完整 init、上下文校验与 diff-check 全部通过。
## 执行记录
### 2026-08-04T13:03:26Z · ila
2026-08-04 开始 T-302:依赖 T-301、T-203 均已完成,任务定义提交 590c846。实现范围限于服务端原子 claim/renew、可恢复 HMAC claim token、租约 CAS、撤销并发线性化和证据 attempt 所有权收紧;不新增事件、围栏、提交、付款、客户端 HTTP 或真机能力。工作分支 task/t-302-atomic-claim。
### 2026-08-04T13:10:15Z · ila
2026-08-04 预实现审计收紧撤销线性化:claim/renew 事务必须先取得 SQLite 写入位置并条件确认设备 ACTIVE,之后才允许 request 重放、EMPTY/冲突返回或业务写入;只 SELECT 或先返回 replay 均不成立。同时补充 docs/current-state.md 为显式 write_path,避免实现后共享文档越界。
### 2026-08-04T13:11:10Z · ila
2026-08-04 write_path 更正:上下文门禁发现 T-103 仍为 DOING 且已拥有共享文档 docs/current-state.md,T-302 同时声明会违反唯一写入者规则,因此不纳入、不修改。T-302 完成事实先记录在本任务;待 T-103 释放路径后由项目级文档同步任务统一更新。撤销线性化 P1 收紧不变。
### 2026-08-04T13:15:18Z · ila
2026-08-04 预实现审计继续定值:claim 的 CLAIMED/EMPTY/需人工结果与 renew 成功都必须持久化幂等,同键重放不再次 CAS/延长;secret 启动时拒绝与 session 原始值或 key bytes 相同,并拒绝命中任何设备 token hash,所有 claim 逐行重建恒定时复核;证据首次写入要求同设备未关闭 claim,但已成功 upload_key 在 claim 关闭后仍先按原载荷稳定重放。HTTP JSON 上限 4096 bytes;claim 200、EMPTY 204,renew 200;400/413/415 固定错误,401/503 空,409 只用 idempotency_conflict、claim_requires_manual、claim_not_current。
<!-- END VIKUNJA EXPORT -->
## 边界
- 只领取 `PENDING` 且存在同版本 `ACTIVE`、未过期、完整快照一致的授权;领取事务必须再次验证
设备仍为 `ACTIVE`。任何缺失、畸形、并发冲突或存储异常都失败闭合,不能用应用层先读后写代替
数据库条件更新与唯一约束。
- 一条授权最多创建一个 attempt;同一设备最多一个未关闭 claim。响应丢失、服务重启、同请求重放、
续租或同会话恢复均不得递增 generation、轮换 claim token、领取另一任务或创建第二条 attempt。
- Claim token 是 attempt 归属凭据,不是采购授权,更不是提交许可。明文不得进入 SQLite、日志、错误、
Git、Vikunja 或测试 fixture;HMAC secret 必须与管理员 session secret、设备 token 分离。错误 secret
面对已有 claim 时必须拒绝启动,不能签发替代 token。
- Secret 分离必须由启动检查执行:claim secret 的原始配置或解码 key 不得等于 session secret,
其 SHA-256 不得命中任何设备 token hash;所有既有 claim(包括以后已关闭的)都必须用当前 secret
逐条重建并恒定时复核,不能只检查开放 claim,也不能为通过启动而改写旧 hash。
- 租约和授权边界相等即过期,无宽限。过期租约不得续租、复活、自动关闭 attempt、释放授权或转给
另一设备;设备撤销、停止轮询和进程退出也不得触发自动接管。安全恢复归 T-207。
- 设备身份认证仍必须在解析 Content-Type 或读取 body 前完成;claim/renew 事务的第一条数据库业务
语句还必须先取得 SQLite 写入线性化位置并条件确认设备 `ACTIVE`,之后才允许查询或重放 request、
返回 EMPTY/冲突或写业务事实。认证、token、session、generation、当前租约或快照任一不匹配均
不得产生业务写入。
- 本任务只增加 claim/renew,并按 claim 所有权收紧已有证据上传;不得新增或提前实现 heartbeat、event、
fail、submission-fence、result、客户端 HTTP 适配、截图 kind 或页面自动化。
- 证据首次写入必须属于当前认证设备的未关闭 claim;但相同设备与 `upload_key` 已成功落库的同载荷
在 claim 后续关闭后仍须先按 T-204 原结果稳定重放,不能把关闭 claim 变成幂等契约失效。
- 不接触拼多多页面判据、规格选择、数量、确认页或真机流程;不编写或引用点击“提交订单”的代码,
不编写支付、免密支付、先用后付或任何扣款控件代码。响应不得返回自由动作脚本、坐标、选择器或
`click_permitted`。