diff --git a/docs/tasks/T-302.md b/docs/tasks/T-302.md new file mode 100644 index 0000000..d83fa31 --- /dev/null +++ b/docs/tasks/T-302.md @@ -0,0 +1,86 @@ +--- +id: T-302 +title: 已授权任务原子领取与租约(F-005) +phase: 3 +deps: [T-301, T-203] +status: TODO +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/server/** + - admin/cmd/server/** + - admin/README.md + - docs/api.md + - docs/04-architecture.md +--- + + +## 问题 / 背景 + +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. 一个有界 SQLite 写事务内先重放 request,再次确认设备 ACTIVE,稳定选择最早的 `PENDING + ACTIVE + 未过期 + task/version/规格/数量/总价快照一致` 授权,创建 attempt/claim/request,并原子执行 authorization `ACTIVE→CLAIMED`、task `PENDING→CLAIMED` 且 version+1。任一步行数或约束不符全部回滚;并发设备只能一个成功。 +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 全部通过。 + +## 执行记录 + +(暂无) + + +## 边界 + +- 只领取 `PENDING` 且存在同版本 `ACTIVE`、未过期、完整快照一致的授权;领取事务必须再次验证 + 设备仍为 `ACTIVE`。任何缺失、畸形、并发冲突或存储异常都失败闭合,不能用应用层先读后写代替 + 数据库条件更新与唯一约束。 +- 一条授权最多创建一个 attempt;同一设备最多一个未关闭 claim。响应丢失、服务重启、同请求重放、 + 续租或同会话恢复均不得递增 generation、轮换 claim token、领取另一任务或创建第二条 attempt。 +- Claim token 是 attempt 归属凭据,不是采购授权,更不是提交许可。明文不得进入 SQLite、日志、错误、 + Git、Vikunja 或测试 fixture;HMAC secret 必须与管理员 session secret、设备 token 分离。错误 secret + 面对已有 claim 时必须拒绝启动,不能签发替代 token。 +- 租约和授权边界相等即过期,无宽限。过期租约不得续租、复活、自动关闭 attempt、释放授权或转给 + 另一设备;设备撤销、停止轮询和进程退出也不得触发自动接管。安全恢复归 T-207。 +- 设备身份认证仍必须在解析 Content-Type 或读取 body 前完成;claim/renew 事务还必须在线性化位置复核 + ACTIVE。认证、token、session、generation、当前租约或快照任一不匹配均不得产生业务写入。 +- 本任务只增加 claim/renew,并按 claim 所有权收紧已有证据上传;不得新增或提前实现 heartbeat、event、 + fail、submission-fence、result、客户端 HTTP 适配、截图 kind 或页面自动化。 +- 不接触拼多多页面判据、规格选择、数量、确认页或真机流程;不编写或引用点击“提交订单”的代码, + 不编写支付、免密支付、先用后付或任何扣款控件代码。响应不得返回自由动作脚本、坐标、选择器或 + `click_permitted`。