feat(tasks): implement atomic claims and leases

This commit is contained in:
QiuSW
2026-07-26 16:16:36 +08:00
parent 49db5b8305
commit ce875af889
50 changed files with 7589 additions and 207 deletions
+3 -2
View File
@@ -53,8 +53,9 @@
当前已完成 Phase 0 和 Phase 1:Android 可运行、设备就绪、workflow、私有样本导入、
动态词搜索、最多 5 个候选截图采集、结构化需求提取、候选评估和人工确认停止点均已
验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web 和 T-204 最小鉴权
均已完成。下一步按编号开始 T-205,实现 App 原子领取、租约和任务状态机。
验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web、T-204 最小鉴权
和 T-205 原子领取/租约状态机均已完成。下一步按编号开始 T-206,把 Android
`HttpTaskSource`、前台服务和已确认的任务页面接到设备 API。
严格按以下顺序推进:
+16 -7
View File
@@ -15,7 +15,7 @@
## 二、用户角色
- **采购管理员**:创建、查看、取消尚未执行的任务并查看执行结果。
- **采购管理员**:创建、查看、取消未执行任务,或请求执行中任务安全停止,并查看结果。
- **采购执行员**:在 App 上检查设备状态、手动领取、执行和人工确认任务。
- **设备身份**:代表一台获得授权的 Android 设备领取任务和上报状态。
- **系统管理员/审核员**:目标架构角色,MVP 不提供完整管理界面。
@@ -38,7 +38,7 @@
| 功能 | 说明 | 阶段 |
| --- | --- | --- |
| 完整 RBAC | 采购管理员、执行员、审核员和系统管理员的细粒度权限。 | V2 |
| 多设备调度 | 设备心跳、任务队列、租约回收和容量调度。 | V2 |
| 多设备容量调度 | 在当前原子领取/租约基础上增加优先级、容量、运营监控和跨实例调度。 | V2 |
| 后台通知 | WebSocket/厂商推送只通知有任务,App 仍通过 claim 领取。 | V2 |
| 订单提交审批 | 在金额、店铺和品类规则内,经人工审批后允许提交订单。 | V2,需单独安全评审 |
| 多平台比价 | 淘宝、1688、京东等平台。 | V3 |
@@ -50,10 +50,11 @@
2. 数量必须是正整数;最高商品总预算如填写,必须大于零。总预算表示当前任务全部
数量的商品金额上限,不包含尚无法可靠确认的运费、优惠或支付金额。
3. SKU、数量和最高商品总预算以管理员输入为准,模型不得更改。
4. 一台设备同一时间最多有一条 `CLAIMED` 或 `RUNNING` 任务。
4. 一台设备同一时间最多有一条未过期 `CLAIMED` 或任意
`RUNNING/WAITING_CONFIRMATION` 任务。
5. 一条任务同一时间只能被一台设备持有;重复点击不能产生重复领取。
6. App 未就绪时不能开始:无障碍未授权、拼多多未安装、设备离线或已有运行任务都
必须说明原因。
6. App 未就绪时不能领取:无障碍未连接、拼多多未安装、设备 heartbeat 过期或已有
活跃任务都必须说明原因。
7. 遇到验证码、登录失效、风控提示、页面未知、预算不满足或模型低置信度时停止,
不猜测点击。
8. MVP 的“成功”表示完成验证闭环并得到人工确认的候选结果,
@@ -67,7 +68,14 @@
普通日志;禁用用户或设备后,已有凭证必须立即失效。
13. 管理登录和 App token 登录必须在高成本密码校验前按服务端观察到的来源地址限流;
超限返回通用错误和重试时间,不得泄露账号、角色或设备是否存在。
14. 管理员创建和取消任务时,任务事件必须记录真实 ADMIN actor;历史事件允许为空。
14. 管理员创建/取消和 App 状态迁移必须记录真实 user actor;设备操作还必须记录
device actor。heartbeat 不写高频事件;历史事件允许 actor 为空。
15. claim token 由 App 在请求前生成并安全保存,必须与幂等 key 独立;数据库只保存
SHA-256,API 和日志不得回显原值或 hash。
16. `CLAIMED` 租约过期后可以被原子回收;`RUNNING/WAITING_CONFIRMATION` 租约
过期后不得自动回到队列,避免失联设备与新设备重复执行。
17. 管理取消 `PENDING/CLAIMED` 可立即终态;执行中只设置取消请求,必须等 App 在
安全检查点确认停止后才能进入 `CANCELED`。
## 六、第一层本地样本约定
@@ -110,7 +118,8 @@ T-004 已固定首版规则:推荐私有目录为被 Git 忽略的 `private-fi
- F-002/US-002/IX-003:状态变化后管理页面能看到最新阶段、时间、设备、候选摘要
或结构化错误;无权限用户不可访问。
- F-003/US-003/IX-005:App 点击“获取任务”后原子领取一条任务;重复点击或多请求
不得领取第二条或把同一任务分配两次。
不得领取第二条或把同一任务分配两次。T-205 已完成后端 heartbeat、原子 claim、
client-generated token 安全重放、参考图授权和租约;Android 接入属于 T-206。
- F-004/US-004/IX-006:解析结果包含搜索词、识别属性、预算、数量、置信度和警告;
原始输入保留,硬约束与输入一致。T-103 已实现版本化 schema、0.75 置信阈值、
冲突转人工以及 SKU/数量的本地确定性回填;当前样本未提供预算,因此预算保持空。
+5 -2
View File
@@ -27,7 +27,7 @@
| App 鉴权 | BUYER 密码 + 预授权设备 secret + 1 小时 opaque token | T-204 已验证 | 首次原子绑定空闲设备;数据库只存 token SHA-256,不提供自助登记/refresh。 |
| VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 需求提取与候选评估已实现,供应商待定 | T-103/T-104 使用严格 JSON Schema、单候选单次调用和 2048 px 图片上限;GUI-Owl/MAI-UI 动作模型不具备需求提取能力。 |
| 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 |
| 后端测试 | 标准库 `testing` + `httptest` | T-204 已验证 | 当前 142 个测试覆盖配置、迁移、图片限制、任务事务、bcrypt、会话/令牌、设备原子绑定、登录限流、角色/CSRF 隔离、HTTP 生命周期和安全响应。 |
| 后端测试 | 标准库 `testing` + `httptest` | T-205 已验证 | 当前 192 个测试覆盖配置、迁移、图片限制、任务事务、鉴权隔离、设备就绪、原子领取、幂等重放、租约状态机、取消确认、跨连接与真实 TCP 并发。 |
| Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + 真实设备 smoke | Phase 1 探针已验证 | 166 次测试覆盖 runner、动态页面分类、受控证据、VLM schema、人工确认策略与隐私;OnePlus PKG110 上完成私有 fixture + 本机 mock 的需求提取和 5 候选评估 smoke。 |
| 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 |
@@ -127,6 +127,9 @@ docs/
T-203 已建立 `domain/usecase/repository/sqlite` 和 `transport/webui`,handler 不直接
执行 SQL,Web 与 JSON API 复用同一 usecase。
T-205 已在该分层上增加独立 `LifecycleService` 与 SQLite immediate transaction,
固定 client-generated claim token、设备 readiness、CLAIMED/运行租约、execution、
安全取消和 task-scoped 参考图授权;Android HTTP TaskSource 仍由 T-206 接入。
## 构建与运行命令
@@ -137,7 +140,7 @@ T-203 已建立 `domain/usecase/repository/sqlite` 和 `transport/webui`,handl
| Android 测试任务 | `android-buyer\gradlew.bat test --no-daemon` | 已验证;当前全工程 166 次测试通过 |
| Android 安装/启动 | `$env:RUN_START_COMMAND="1"; .\init.ps1` | 已在 Android 16 真机验证 |
| 后端依赖 | `$env:GOTOOLCHAIN="local"; go mod download`(在 `backend-api/`) | 已验证 |
| 后端测试 | `$env:GOTOOLCHAIN="local"; go test ./...; go vet ./...` | 已验证;99 个测试 |
| 后端测试 | `$env:GOTOOLCHAIN="local"; go test ./...; go test -race ./...; go vet ./...` | 已验证;192 个测试,全包 race 通过 |
| 后端构建 | `go build -o bin/cmroubao-api.exe ./cmd/api` 与 `./cmd/migrate` | 已验证 |
| 后端启动 | `go run ./cmd/api` | 已完成本机 HTTP smoke |
| 数据迁移 | `go run ./cmd/migrate up` | 已完成 `status/up/up/down/up` smoke |
+50 -13
View File
@@ -292,10 +292,16 @@ CLAIMED/RUNNING/WAITING_CONFIRMATION
规则:
- `SUCCEEDED` 是验证结果成功,不代表已下单;`order_submitted=false`。
- `CLAIMED` 的默认租约计划为 10 分钟,运行时每 30 秒心跳续租;数值进入配置。
- `CLAIMED` 默认租约为 10 分钟;`RUNNING/WAITING_CONFIRMATION` 默认租约为
90 秒,App 计划每 30 秒 heartbeat 续租。三个时长均由有上下界的环境变量配置。
- 只有当前设备和有效 claim token 能启动、续租、上报或结束任务。
- 终态不可回退;重试创建新的 execution attempt,不篡改历史证据。
- 取消正在自动化的任务时,App 应在下一个安全检查点停止。
- App 在 claim 前生成并安全保存 256 bit Raw URL token;后端只存 SHA-256,
token 与 `Idempotency-Key` 相互独立。
- 过期 `CLAIMED` 可以原子回收;过期 `RUNNING/WAITING_CONFIRMATION` 保持原状态,
不自动回队列。
- 管理取消 `PENDING/CLAIMED` 立即终态;执行中只设置停止请求,App 在下一个安全
检查点停止并调用 `cancel-ack` 后才进入 `CANCELED`。
### 4.2 Android 工作流状态
@@ -348,6 +354,9 @@ IDLE
| `android_version` | nullable | Android 系统版本 |
| `pdd_version` | nullable | 已验证拼多多版本 |
| `last_seen_at` | nullable | 最近心跳 |
| `readiness_reported_at` | nullable | 最近一次就绪上报的服务端时间 |
| `accessibility_enabled` | NOT NULL | 无障碍连接就绪位 |
| `pdd_installed` | NOT NULL | 拼多多安装就绪位 |
| `is_enabled` | NOT NULL | 后端开关 |
设备必须先由本地管理命令预授权。首次 BUYER 联合登录可以把 `bound_user_id` 为空的
@@ -378,17 +387,28 @@ IDLE
| `quantity` | `> 0` | 权威数量 |
| `max_budget` | `> 0`, nullable | 全部数量的权威最高商品总预算,币种为 CNY |
| `status` | NOT NULL | 任务状态枚举 |
| `created_by` | FK | 创建人 |
| `claimed_by_user_id` | FK, nullable | 当前采购员 |
| `claimed_by_device_id` | FK, nullable | 当前设备 |
| `claim_generation` | `>= 0` | 每次领取递增,释放后保留用于审计 |
| `claim_token_hash` | nullable | 64 位小写 SHA-256;永不保存原 token |
| `claim_issued_at` | nullable | 当前 claim 签发时间 |
| `claim_expires_at` | nullable | 租约到期时间 |
| `cancel_reason` | nullable | 管理取消原因 |
| `cancel_requested_at` | nullable | 执行中停止请求时间 |
| `cancel_requested_by_user_id` | FK, nullable | 请求停止的 ADMIN |
| `canceled_at` | nullable | 取消终态时间 |
| `version` | NOT NULL | 乐观锁/状态并发控制 |
| `created_at/updated_at` | NOT NULL | 审计时间 |
### `task_events`
- 事件只追加;T-204 已实现 `TASK_CREATED`、`TASK_CANCELED`。
- 事件只追加;T-205 已实现 `TASK_CREATED`、`TASK_CLAIMED`、
`TASK_RECLAIMED`、`TASK_RELEASED`、`TASK_STARTED`、
`TASK_CANCEL_REQUESTED`、`TASK_CANCELED`。
- `actor_user_id` 是指向 `users` 的可空外键。新管理操作必须写入真实 ADMIN,
T-203 历史事件保持为空。
- `actor_device_id` 是指向 `devices` 的可空外键;App 状态迁移同时记录用户和设备。
- 设备/任务 heartbeat 不写事件,避免高频审计膨胀。
- 管理任务详情 API 可返回 actor;密码、session、设备 secret 和 access token 永不
进入事件。
@@ -399,17 +419,28 @@ IDLE
| `id` | PK | 一次执行尝试 |
| `task_id` | FK | 所属任务 |
| `attempt_no` | UNIQUE(task, no) | 尝试序号 |
| `claim_generation` | UNIQUE(task, generation) | 对应的 claim 代次 |
| `device_id/user_id` | FK | 执行设备和人员 |
| `extracted_requirements` | JSON | 已校验的模型结果 |
| `candidate_result` | JSON, nullable | 候选及匹配理由 |
| `outcome` | nullable | `CANDIDATE_ACCEPTED`、`CANDIDATE_REJECTED`、`NO_MATCH` 或 `MANUAL_REQUIRED` |
| `current_step` | 1-64 bytes | 最近 heartbeat 的执行步骤 |
| `last_heartbeat_at` | NOT NULL | 最近运行 heartbeat |
| `order_submitted` | NOT NULL, false | MVP 数据库约束必须为 false |
| `error_code/error_message` | nullable | 结构化失败 |
| `started_at/finished_at` | nullable | 执行耗时 |
| `started_at/finished_at` | started 必填 | 执行耗时和安全结束 |
### `execution_events` 与 `assets`
候选、模型派生结果、outcome、结构化失败和 execution evidence 属于 T-207,不在
T-205 的最小 execution 表中提前伪造。
- `execution_events` 只追加,记录 step、事件类型、可读消息和时间;不保存密码或 token。
### `lifecycle_requests`
- 主键为 `user_id + device_id + operation + idempotency_key`。
- operation 为 `CLAIM_NEXT`、`START`、`RELEASE`、`CANCEL_ACK`;保存请求 SHA-256
与 task/generation/execution 结果引用。
- `NO_TASK` 也是稳定结果;同 key 重放不会因后来新增任务而改变。
- claim 原 token 不进入该表,请求 hash 只包含 token hash。
### `assets` 与后续 `execution_events`
- T-207 的 `execution_events` 只追加,记录 step、事件类型、可读消息和时间;不保存
密码或 token。
- `assets` 保存文件相对路径、媒体类型、大小、哈希、创建者和保留时间。
- 原图和截图必须通过鉴权接口读取,文件名不能直接作为公开 URL。
- 任务参考图上传可接受 JPEG/PNG/WebP,但后端必须先真实解码、限制字节与像素,再
@@ -419,10 +450,16 @@ IDLE
- 合约以 [`api.md`](api.md) 为准。
- `claim-next` 必须由 usecase 调用 repository,在一个数据库事务内完成
“选取 + 校验设备空闲 + 更新状态”。
- 创建、领取和完成接口支持 `Idempotency-Key`。
“幂等检查 + 就绪/单活校验 + FIFO 选取/过期回收 + 更新 + 事件”。
- 当前设备若有自己的过期 `CLAIMED`,优先原子回收该任务;没有时才在全局候选中
按 `created_at ASC, id ASC` 选择。该例外保持设备唯一归属并避免静默清理审计状态。
- SQLite DSN 使用 `_txlock=immediate`、WAL 和 busy timeout;跨两个独立数据库连接
的并发测试必须精确得到一个领取者。
- claim/start/release/cancel-ack 支持 `Idempotency-Key`;heartbeat 不需要幂等表。
- App 对网络超时不能假定失败;必须用任务详情确认服务端最终状态。
- 后端拒绝非法状态迁移,即使客户端 UI 隐藏了对应按钮。
- App 参考图通过 task-scoped URL 读取;每次校验当前 user/device/generation/token/
租约,不允许用可猜测 asset ID 越权读取其他任务。
- SQLite MVP 使用单后端进程;切换多实例前先迁移 PostgreSQL 并验证领取竞争。
- Gin handler 只做鉴权上下文、binding、调用 usecase 和响应映射。
+8 -2
View File
@@ -79,7 +79,12 @@
- 请求在边界完成类型、大小、媒体类型和业务规则校验。
- 领取任务必须使用事务;不得先 GET 再由客户端自行标记占用。
- 状态变化校验当前状态、设备归属、claim token、版本和租约。
- 创建、领取、完成和证据上传的重试路径必须幂等。
- App 必须在 claim 请求前生成并安全保存独立的 256 bit claim token 与幂等 key;
后端只存 token SHA-256,响应、事件和日志不得返回原值或 hash。
- claim/start/release/cancel-ack 和后续完成/证据上传的重试路径必须幂等;`NO_TASK`
也必须是稳定幂等结果。
- 设备/任务 heartbeat 使用服务端时钟且不写高频事件;过期 RUNNING 不得自动回到
队列。管理取消执行中任务必须等 App 安全确认,不能提前伪装为终态。
- 不向客户端返回 `password_hash`、`token_hash`、存储绝对路径或供应商密钥。
- 管理 Cookie 与 BUYER 设备 Bearer token 使用独立 middleware,不得互相替代;
每次鉴权都重新检查用户/设备启用、绑定、撤销和过期状态。
@@ -92,7 +97,8 @@
- 创建和取消任务必须从认证上下文传入真实 ADMIN actor 并写入只追加事件;共享
`local-admin` 只用于 MVP 资源可见范围,不能冒充操作者。
- 通用错误使用稳定 code 和可读 message;内部堆栈只进受控日志。
- 文件访问通过鉴权接口,防止路径遍历和猜测 URL。
- 文件访问通过鉴权接口,防止路径遍历和猜测 URL;App 参考图读取必须重新校验当前
task/user/device/generation/token/租约,不能只凭 asset UUID 授权。
- Go 命令固定 `GOTOOLCHAIN=local`;`go.mod` 不得出现更高 Go 版本或未固定的
`@latest` 依赖。
- 后端不得自动加载 `.env`、默认开启 CORS、使用包级数据库单例,或在库、handler、
+5 -2
View File
@@ -72,7 +72,9 @@
1. 设备就绪且有待处理任务时,点击后只出现一条已领取任务。
2. 没有任务时显示空状态,不把空队列当作错误。
3. 无障碍缺失、拼多多不可用、设备禁用或已有活跃任务时,说明原因并禁止领取。
4. 网络超时或重复点击后,刷新服务端状态不会产生重复领取。
4. 网络超时或重复点击后,复用请求前已保存的 claim token 和幂等 key 可确认同一
领取结果,不产生重复领取。
5. 只有持有当前 claim 的用户和设备能读取该任务的参考图、开始或续租。
## US-004 自动检索并得到候选
@@ -131,7 +133,8 @@
1. 验证码、风控、登录失效、未知页面和安全边界分别产生可区分错误。
2. 网络中断时保留本地待上传证据,恢复后用幂等方式补报。
3. 用户取消时工作流在安全检查点停止,任务不会继续在后台点击。
3. 用户或管理员取消时,执行中任务先收到停止请求;App 在安全检查点停止并确认后
才进入取消终态,不会继续在后台点击。
4. 重新打开终态任务可以看到相同结果和证据。
## US-007 建立受控会话和设备身份
+2 -1
View File
@@ -150,7 +150,8 @@
- 无任务:显示安静的空状态和手动刷新,不循环弹错。
- 权限缺失:提供打开对应系统设置的命令。
- 已有任务:显示“继续任务”,不再领取。
- 请求超时:先查询当前设备活跃任务,再允许重试。
- 请求超时:保留请求前已安全保存的 claim token/幂等 key,先用同一请求重放并通过
heartbeat 对比服务端活跃任务,再允许生成新请求。
- 租约到期:预览页提示任务已释放,返回任务页。
**可访问性**
+87 -18
View File
@@ -254,8 +254,10 @@ T-204 已实现的 `TASK_CREATED`、`TASK_CANCELED` 事件包含可空
}
```
T-203 只实现 `PENDING -> CANCELED`;其他状态返回 `409`。执行中设置取消请求及 App
安全检查点响应属于 T-205。
`PENDING/CLAIMED` 立即进入 `CANCELED`。`RUNNING/WAITING_CONFIRMATION` 只记录
取消请求并保持原状态;重复请求不重复写事件。App 的任务 heartbeat 会返回
`cancel_requested=true`,只有 App 在安全检查点调用 `cancel-ack` 后才进入
`CANCELED` 并结束 execution。
## 设备与领取
@@ -265,8 +267,8 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专
```json
{
"device_id": "f0c3d438-1c44-4f80-b898-c645afe7eaa8",
"app_version": "0.1.0",
"android_version": "16",
"pdd_version": "device-observed-value",
"readiness": {
"accessibility_enabled": true,
@@ -276,14 +278,20 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专
}
```
`device_id` 可以省略;若提供,必须与 Bearer token 中的设备一致。服务端时间是唯一
租约时钟。响应返回服务端认定的 `active_task_id`、`client_state_matches`、
readiness 上报时间和 `server_time`。heartbeat 超过配置 TTL 或任一就绪位为 false
时,claim 返回 `409 DEVICE_NOT_READY`。
### `POST /api/v1/tasks/claim-next`
由用户点击触发,在单个数据库事务中领取下一条任务。必须带 `Idempotency-Key`。
由用户点击触发,在单个 SQLite immediate transaction 中领取下一条任务。App 必须
在请求前生成并安全保存相互独立的 256 bit Raw URL `X-Claim-Token` 和
`Idempotency-Key`;网络结果不确定时复用同一组值。服务端只保存 token 的
SHA-256,响应和日志都不回显原 token 或 hash。
```json
{
"device_id": "f0c3d438-1c44-4f80-b898-c645afe7eaa8"
}
{}
```
有任务时:
@@ -297,22 +305,50 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专
"sku": "BLACK-20L",
"description": "容量约20L,外观接近参考图",
"image_asset_id": "2bbc1bf2-30f7-497e-a52b-bcb4c61d57c3",
"reference_image_url": "/api/v1/tasks/37c9c715-9b51-4ed5-984e-66dad2710c71/reference-image?claim_generation=1",
"quantity": 2,
"max_budget": "200.00"
"max_budget": "200.00",
"currency": "CNY",
"version": 2,
"claim_generation": 1,
"claim_issued_at": "2026-07-25T08:30:00Z",
"claim_expires_at": "2026-07-25T08:40:00Z"
},
"claim_token": "returned-once",
"claim_expires_at": "2026-07-25T08:40:00Z"
"replayed": false,
"server_time": "2026-07-25T08:30:00Z"
}
```
没有任务返回 `204`。设备未就绪或已有活跃任务返回 `409`。幂等重放返回同一领取
结果;claim token 的安全重放形式在实现前通过测试固定。
当前设备自己有已过期 `CLAIMED` 时优先回收该任务,避免设备唯一归属冲突;否则按
`created_at ASC, id ASC` 选择 `PENDING` 或已过期 `CLAIMED`。没有任务返回 `204`;
同 key 的无任务重放始终保持 `204`。同 key、同 token 的活跃 claim 重放返回同一
任务,即使状态已进入 `RUNNING/WAITING_CONFIRMATION`;请求不同返回
`409 IDEMPOTENCY_CONFLICT`;原 claim 已释放、取消或被其他领取回收时返回
`409 CLAIM_REPLAY_EXPIRED`。同一设备不能再领取第二条活跃任务。
### `GET /api/v1/tasks/{task_id}/reference-image`
claim 响应给出的受保护参考图地址。请求使用 BUYER Bearer token、
`X-Claim-Token` 和 URL 中的 `claim_generation`;只有当前用户、当前设备、匹配
generation/token 且租约未过期的活跃任务可以读取。成功返回匿名规范化 JPEG,
设置 `private, no-store`、`nosniff`、长度和 ETag,不返回存储路径。
### `POST /api/v1/tasks/{task_id}/start`
把当前设备持有的 `CLAIMED` 任务改为 `RUNNING` 并创建 execution。请求带
`X-Claim-Token` 和 `Idempotency-Key`。
```json
{
"claim_generation": 1,
"expected_version": 2
}
```
同 key、同请求重放返回同一 execution;错误用户/设备/token 返回 `403`,过期租约、
状态或版本冲突返回 `409`。成功响应包含更新后的 `task`、`execution`、`replayed`
和 `server_time`,运行租约使用配置的 running lease。
### `POST /api/v1/tasks/{task_id}/heartbeat`
运行时续租并返回是否请求取消:
@@ -320,21 +356,56 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专
```json
{
"execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"step": "SCAN_RESULTS",
"client_time": "2026-07-25T08:33:30Z"
"claim_generation": 1,
"step": "SCAN_RESULTS"
}
```
```json
{
"claim_expires_at": "2026-07-25T08:43:30Z",
"cancel_requested": false
"task": {
"id": "37c9c715-9b51-4ed5-984e-66dad2710c71",
"status": "RUNNING",
"version": 4,
"claim_generation": 1,
"claim_expires_at": "2026-07-25T08:35:00Z"
},
"execution": {
"id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"current_step": "SCAN_RESULTS",
"order_submitted": false
},
"cancel_requested": false,
"server_time": "2026-07-25T08:33:30Z"
}
```
只接受 1 至 64 字节的大写 ASCII step。heartbeat 使用服务端 UTC 更新 execution、
设备最近在线时间、任务 version 和租约,不写高频任务事件;过期运行租约拒绝续租,
任务保持原非终态且绝不回到领取队列。
### `POST /api/v1/tasks/{task_id}/release`
只允许尚未开始的 `CLAIMED` 任务释放回 `PENDING`。运行中使用取消/失败流程。
请求带 `X-Claim-Token`、`Idempotency-Key`,body 与 start 相同。成功后清除当前
用户、设备、token hash 和租约,但保留递增过的 `claim_generation` 供审计。
### `POST /api/v1/tasks/{task_id}/cancel-ack`
App 收到取消请求并在安全检查点停止后调用:
```json
{
"execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"claim_generation": 1,
"expected_version": 5
}
```
请求带 `X-Claim-Token` 和 `Idempotency-Key`。只有匹配的未结束 execution、有效
运行租约和已存在的管理取消请求可以确认;成功把任务置为 `CANCELED`、结束
execution、清除 claim 秘密并追加带用户/设备 actor 的事件。同 key 重放不产生第二
个事件。
## AI 合约
@@ -488,7 +559,5 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专
## 待实现前固定
- 上传大小、像素和保留期限的具体数值。
- access token、管理会话和 claim lease 的最终时长。
- claim token 幂等重放与轮换细节。
- VLM `confidence` 阈值、模型和提示词版本记录格式。
- 外部管理后台的服务账号认证方式和调用频率。
+24 -8
View File
@@ -5,30 +5,36 @@
## 当前快照
- 日期:2026-07-26
- 阶段:T-204 用户与设备最小鉴权完成,下一步 T-205
- 阶段:T-205 原子领取、租约和状态机完成,下一步 T-206
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-203
均已纳入 Git 历史;T-204 与本文同次提交
均已纳入 Git 历史;T-204 已提交,T-205 与本文同次提交
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片/任务业务、
SSR 管理 Web、ADMIN 会话、BUYER/预授权设备联合认证和 `authctl`
SSR 管理 Web、ADMIN/BUYER 联合认证、设备 readiness、原子 claim、租约状态机、
task-scoped 参考图和 `authctl`
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器
共 26 份报告、166 次测试,0 failure、0 error、0 skipped
- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 142 个测试通过;
- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 192 个测试通过;
全包 race、`go vet ./...`、API/migration/authctl Windows 构建和根 `init.ps1`
均通过
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
脚本错误或外部请求,Android 可见交互控件均不小于 44px
- 管理 Web:真实 Gin/SQLite 流程已完成图片上传、任务创建、列表、详情参考图和
待领取取消;ADMIN 登录/退出和安全返回路径已接入,同三种视口无横向溢出,
可见操作控件不小于 44px
非终态取消;执行中只显示“请求安全停止”,ADMIN 登录/退出和安全返回路径已接入,
同三种视口无横向溢出,可见任务操作控件不小于 44px
- 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret
均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App
登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停
- 生命周期:设备 heartbeat 记录三个版本、两个就绪位和服务端活跃任务;claim 使用
client-generated 256 bit token + 独立幂等 key、SQLite immediate transaction、
10 分钟 CLAIMED/90 秒运行租约和 2 分钟 readiness TTL。start/heartbeat/release/
cancel-ack、过期回收、跨连接并发唯一领取、管理安全停止与 task-scoped 参考图均
已实现;原 claim token/hash 不进入响应或日志
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
@@ -65,6 +71,7 @@
| `docs/tasks/T-202.md` | DONE | 生成并确认 P0 Web/App 低保真原型 |
| `docs/tasks/T-203.md` | DONE | 图片/任务 API、SQLite 业务层和 SSR 管理 Web |
| `docs/tasks/T-204.md` | DONE | 用户、管理会话和预授权设备联合身份 |
| `docs/tasks/T-205.md` | DONE | 原子 claim、租约、execution 和取消安全确认 |
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
@@ -75,9 +82,9 @@
## 任务摘要
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-204。
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-205。
- 正在进行:无。
- 下一个可领取任务:T-205 原子领取、租约和状态机。
- 下一个可领取任务:T-206 App 接入手动领取和执行进度。
## 当前可运行内容
@@ -132,6 +139,15 @@ TLS。真实 SQLite/Gin smoke 验证 migration up/down/up、账号/设备预置
验证无横向溢出,发现并修复退出路由漏装配及 T-203 旧 CSRF Cookie 路径兼容问题。
142 个 Go 测试、全包 race/vet、Windows 三入口构建与根 `init.ps1` 均通过。
2026-07-26 完成 T-205:新增设备 readiness、claim/execution/lifecycle migration,
实现 client-generated claim token 安全重放、跨连接原子领取、CLAIMED/运行租约、
start/heartbeat/release、管理取消请求、App cancel-ack 和当前 claim 参考图授权。
真实 TCP 并发测试验证同设备同时 claim 只有一个成功;迁移 CLI 完成
`status/up/up/down/up`。Playwright 连接真实服务验证管理任务详情在 1440×900、
390×844、360×800 无横向溢出、破图或取消弹窗重叠;执行中安全停止文案由 SSR
测试覆盖。192 个 Go 测试、全包 race/vet、Windows 三入口构建和根 `init.ps1`
全部通过。
## 维护规则
发生以下变化时覆盖更新本文:
+17
View File
@@ -30,6 +30,23 @@ Android 导航名称是逻辑目的地,具体 Compose/Fragment 形式待接入
| `task/{id}/result` | 任务结果 | 查看终态和同步状态 | US-002、US-006 | IX-007、IX-008 |
| `settings` | 设备设置 | 查看权限、版本、后端和模型连接状态 | US-003、US-007 | IX-004、IX-005 |
## App 后端路由
以下路由只接受有效 BUYER Bearer token,认证 principal 中的用户和设备是权威身份:
| 路由 | 职责 |
| --- | --- |
| `POST /api/v1/devices/heartbeat` | 上报版本、就绪位并核对服务端活跃任务 |
| `POST /api/v1/tasks/claim-next` | 用户点击后原子领取或重放领取结果 |
| `GET /api/v1/tasks/{id}/reference-image` | 用当前 claim 读取该任务匿名参考图 |
| `POST /api/v1/tasks/{id}/start` | `CLAIMED -> RUNNING` 并创建 execution |
| `POST /api/v1/tasks/{id}/heartbeat` | 更新 step、设备在线时间和运行租约 |
| `POST /api/v1/tasks/{id}/release` | 未开始时 `CLAIMED -> PENDING` |
| `POST /api/v1/tasks/{id}/cancel-ack` | 安全停止后确认 `CANCELED` 并结束 execution |
claim/start/release/cancel-ack 使用相应幂等规则;参考图、start、task heartbeat、
release 和 cancel-ack 都必须匹配当前 `X-Claim-Token` 与 `claim_generation`。
## 导航规则
- App 有活跃任务时启动后优先恢复该任务,不允许直接领取下一条。
+151
View File
@@ -0,0 +1,151 @@
---
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 严重度问题。