Files
cmroubao/docs/tasks/T-205.md
T

152 lines
9.5 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-205
title: 实现原子 claim、租约和状态机
phase: 2
deps:
- T-203
- T-204
status: DONE
created: 2026-07-26
context_ref: 49db5b8
work_branch: main
write_paths:
- README.md
- backend-api/**
- docs/00-ai-start-here.md
- docs/02-requirements.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/05-coding-rules.md
- docs/07-user-stories.md
- docs/08-interaction-checklist.md
- docs/api.md
- docs/current-state.md
- docs/routes.md
- docs/tasks/T-205.md
- progress.md
---
## 问题 / 背景
T-203 已能创建 `PENDING` 任务,T-204 已建立 BUYER + 预授权设备联合身份,但 App
还不能安全领取任务。现有正式文档只给出接口草图,未解决 claim token 只返回一次时
如何在响应丢失后安全重放,也没有固定设备就绪新鲜度、运行中租约过期、取消确认和
并发领取的精确语义。T-206 在这些合约固定前不能接入 HTTP TaskSource。
## 关联需求与交互
- 功能:F-003、F-007。
- 用户故事:US-003、US-006。
- 交互:IX-005、IX-008;本任务只实现后端,Android 页面接入属于 T-206。
- 架构/API:任务状态机、`devices/heartbeat`、`claim-next`、`start`、任务 heartbeat、
`reference-image`、`release` 和取消安全停止。
## 已定合约
1. 所有设备接口只接受有效 BUYER Bearer token;认证上下文中的 `user_id/device_id`
是权威身份。请求中的 `device_id` 必须与上下文一致,不能代表其他设备。
2. App 在发起 claim 前生成独立的 256 bit Raw URL `X-Claim-Token` 并先安全保存,
同时生成独立 `Idempotency-Key`。服务只保存 claim token 的 SHA-256,响应不回显
原值;网络结果不确定时必须复用同一组值。
3. claim 幂等记录按用户、设备、操作和 key 隔离,请求 hash 包含 claim token hash。
每次成功领取递增 `claim_generation`。同 key 同请求在同一 generation 的租约仍
属于该设备时返回同一任务;同 key 不同请求返回 `409`;原领取已释放、取消或被
过期回收时返回稳定 `409 CLAIM_REPLAY_EXPIRED`。无任务的首次结果记录为
`NO_TASK`,同 key 重放仍返回 `204`。
4. 设备 heartbeat 记录服务端时间、App/Android/拼多多版本和两个确定性就绪位:
无障碍已连接、拼多多已安装。客户端上报的活跃任务只用于一致性检查,服务端任务
归属是权威。heartbeat 超过 2 分钟或任一就绪位为 false 时禁止领取。
5. 领取在单个 SQLite immediate transaction 内完成。若当前设备自己仍有一条已过期
`CLAIMED`,先回收该任务,避免设备唯一归属冲突;否则在 `PENDING` 或已过期
`CLAIMED` 中按创建时间最早、ID 最小选择。一个任务只能归属一台设备;同一设备
只允许一个未过期 `CLAIMED` 或任意 `RUNNING/WAITING_CONFIRMATION` 任务。
6. 初始 claim 租约默认 10 分钟。只有当前用户、当前设备、匹配 token hash 且租约
未过期时可以 start、release、heartbeat 或确认取消。所有到期计算只使用服务端
UTC 时钟,不信任 `client_time`。
7. `start` 只允许 `CLAIMED -> RUNNING`,在同一事务创建唯一 execution attempt,
并把运行租约改为默认 90 秒;请求携带 claim 响应的 `expected_version`,
`Idempotency-Key` 同请求重放返回同一 execution。
8. 运行 heartbeat 每 30 秒计划调用,只有 `RUNNING/WAITING_CONFIRMATION` 且
execution/generation 匹配时才更新 step、`last_seen_at` 和 90 秒运行租约。
过期运行租约拒绝续租和后续自动操作,任务保持当前非终态且绝不自动回到队列,
防止失联设备与新设备重复执行;恢复/失败归档后置。
9. `release` 只允许未过期 `CLAIMED -> PENDING`,请求必须匹配 `expected_version`,
并清除用户、设备、token 和租约。已开始任务不能 release。过期 `CLAIMED` 可由
下一次 claim 原子回收。
10. 管理取消 `PENDING/CLAIMED` 时立即进入 `CANCELED`;`RUNNING` 或
`WAITING_CONFIRMATION` 只设置取消请求,不伪装为已停止。任务 heartbeat 返回
`cancel_requested=true`,App 在安全检查点以 `expected_version` 调用
`cancel-ack` 后才进入 `CANCELED` 并结束 execution。
11. 每次状态迁移递增 `version` 并追加带 actor 的任务事件;heartbeat 只更新租约和
当前 step,不追加高频事件。claim/start/heartbeat/transition 响应都返回当前
`version`、`claim_generation`、服务端时间和租约到期时间。终态不可回退,非法
状态、错误设备、错误 token、execution 不匹配和过期租约使用彼此稳定但不泄露
其他任务内容的错误码。
12. 三个时长通过配置提供安全默认值和上下界:claim lease 10 分钟、运行 lease
90 秒、设备 heartbeat TTL 2 分钟。T-205 不启动后台定时清理器。
13. 参考图不暴露资产存储路径。App 只能通过 claim 响应中的 task-scoped URL,
携带当前 BUYER token、设备身份、`X-Claim-Token` 和 `claim_generation` 读取;
服务端同时校验当前状态和未过期租约,并返回 `private, no-store` JPEG。
## 方案
1. 新增 `00004_claims_and_lifecycle.sql`,扩展任务 claim/cancel 字段、设备就绪字段,
增加 execution 与生命周期幂等表,并扩展任务事件枚举;迁移必须可空兼容历史数据。
2. 在 domain 增加 claim、execution、状态迁移和就绪实体;在独立
`LifecycleService` 中完成输入校验、token hash、服务端时钟、错误映射和响应模型。
3. SQLite repository 使用显式事务完成 claim/reclaim、start、release、heartbeat、
取消请求/确认和幂等重放;所有条件更新同时校验状态、归属、token、租约和版本。
4. Gin 增加独立 BUYER 设备路由组,复用 T-204 Bearer middleware;handler 只解析
header/body、读取认证 principal、调用 usecase 和映射稳定响应。
5. 管理取消从仅 PENDING 扩展为上述安全语义;管理详情返回 claim、execution 和
取消请求的非秘密摘要,永不返回 claim token/hash。
6. claim 响应只给出 task-scoped 参考图 URL;图片 handler 在读取本地资产前重新
授权当前 claim,避免长期资产 URL 或跨任务枚举。
## 验收要点
- [x] migration 可 up/down/up,历史任务保持可读,数据库不存在原始 claim token。
- [x] heartbeat 身份、版本长度、就绪位和新鲜度校验正确,客户端不能冒充设备。
- [x] 多 goroutine/多连接并发 claim 同一任务时精确一个成功,不重复分配。
- [x] 同一设备不能领取第二条活跃任务;不同设备可以领取不同任务。
- [x] claim 首次响应丢失后用同 key/token 重放得到同一任务;冲突/过期重放被拒绝。
- [x] 无任务的幂等重放保持 204,不因稍后新增任务改变旧请求结果。
- [x] start 精确创建一个 execution;重复 start 同 key 返回同一 execution。
- [x] 错误设备、错误 token、过期 CLAIMED 租约、非法状态均不能 start/release。
- [x] 当前 claim 才能读取参考图,错误 token/generation/设备或过期租约均被拒绝。
- [x] RUNNING heartbeat 续租并返回取消标志;过期运行租约不会回队列或被其他设备领取。
- [x] 管理取消活跃任务只请求停止;App 安全确认后才进入 CANCELED。
- [x] 事件 actor、version、execution attempt 和状态迁移可审计,响应/日志不泄露秘密。
- [x] Go test/race/vet/gofmt、迁移/HTTP 并发 smoke、根脚本全部通过。
## 边界
- 不实现 Android HTTP TaskSource、token 安全存储、前台服务或页面;属于 T-206。
- 不实现候选、执行证据、批量事件、完成/失败结果归档;属于 T-207。
- 不实现后台通知、WebSocket、跨实例锁、自动重试或多设备调度运营页面。
- 不把过期 `RUNNING` 自动回到 `PENDING`,也不允许任何路径提交订单或支付。
- 不提交运行数据库、claim/access token、真实设备心跳或私有订单样本。
## 执行记录
### 2026-07-26:任务开始
- 基于提交 `49db5b8` 开始,工作区干净;T-203/T-204 依赖均已完成。
- codebase-memory MCP 本轮仍未暴露 graph 工具,按项目规则回退到 `rg` 和定点读取。
- 先固定 client-generated claim token、安全幂等重放、就绪 TTL、租约过期与取消确认
语义,避免在迁移和 handler 写完后再改变跨端协议。
### 2026-07-26:实现与验证完成
- 新增 migration 00004、生命周期领域/用例/SQLite 仓储和 BUYER Gin 路由;管理端
取消扩展为 `PENDING/CLAIMED` 立即取消、`RUNNING/WAITING_CONFIRMATION`
请求安全停止,并在任务详情展示非秘密 claim/execution 摘要。
- `go test -count=1 ./...` 共 192 个测试通过;全包 `go test -race -count=1 ./...`、
`go vet ./...`、`gofmt -d .`、三入口 Windows 构建和根 `init.ps1` 均通过。
- migration CLI 完成 `status -> up -> up -> down -> up`,第二次 up 无重复应用;
两个独立数据库连接和真实 TCP 并发 claim 都验证同设备精确一个成功。
- Playwright 连接真实 Gin/SQLite 流程验证任务详情在 1440x900、390x844、360x800
无横向溢出、破图或弹窗重叠;运行中“请求安全停止”分支由 SSR 测试覆盖。
- 独立审查发现并修复 start 后 claim 幂等重放被误判过期的问题;同设备优先恢复
自己过期 CLAIMED 的亲和策略已补充文档和测试。未发现 High 严重度问题。