diff --git a/T-216.-.md b/T-216.-.md new file mode 100644 index 0000000..3008565 --- /dev/null +++ b/T-216.-.md @@ -0,0 +1,162 @@ + +> 同步来源:[`docs/tasks/T-216.md`](/chengma/mroubao/src/commit/afc651f75a3abc2676bb13aa8a80f6aa6a25a72e/docs/tasks/T-216.md) · commit `afc651f75a3a` + +--- +id: T-216 +title: 设备下单命令投递、确认与恢复 +phase: 2 +deps: + - T-215 +status: DONE +created: 2026-07-28 +context_ref: 827afc7 +work_branch: null +write_paths: + - docs/tasks/T-216.md + - docs/00-ai-start-here.md + - docs/02-requirements.md + - docs/04-architecture.md + - docs/06-tasks.md + - docs/08-interaction-checklist.md + - docs/api.md + - docs/current-state.md + - docs/routes.md + - backend-api/migrations/** + - backend-api/internal/domain/** + - backend-api/internal/usecase/** + - backend-api/internal/repository/sqlite/** + - backend-api/internal/transport/httpapi/** + - android-buyer/app/src/main/** + - android-buyer/app/src/test/** +--- + +## 问题 / 背景 + +T-215 只生成 `PENDING_DELIVERY` 下单授权。Android 仍停留在旧的手机人工选择流程, +也没有可靠方式知道 Admin 已授权哪个 candidate。直接在 Admin 浏览器推送或收到 HTTP +响应后立即执行都无法处理 Android 后台限制、断网、进程重启和响应丢失。 + +本任务把授权作为同一 execution 的可重放设备命令,由 Roubao 的现有前台服务主动 +拉取。App 必须先把完整命令写入 Keystore-backed 加密状态,再确认收妥。T-216 不 +执行拼多多页面动作。 + +## 冻结合约 + +### 拉取而非服务端推送 + +1. App 在非终态 execution 的 30 秒 heartbeat 后,以及用户点击“立即同步”时调用 + `POST /api/v1/tasks/{task_id}/commands/next`。不增加 WebSocket、FCM 或浏览器到 + 手机的直连通道。 +2. 请求必须携带 BUYER bearer、`X-Claim-Token`,并提交当前 `device_id`、 + `execution_id`、`claim_generation`。服务端校验 task、user、device、execution、 + generation、token、有效租约、`WAITING_CONFIRMATION` 和未请求取消。 +3. 没有命令返回 `204`。当前 active authorization 为 + `PENDING_DELIVERY/DELIVERED/ACKNOWLEDGED` 时返回同一命令;一个 execution 不会 + 返回第二条 active 命令。 + +### 命令内容与持久身份 + +命令 `schema_version=1`、`type=CREATE_PENDING_ORDER`,只包含服务端快照和受控 +observation,不包含第三方 URL: + +```json +{ + "id": "authorization-uuid", + "schema_version": 1, + "type": "CREATE_PENDING_ORDER", + "authorization_version": 1, + "task_id": "uuid", + "execution_id": "uuid", + "task_content_sha256": "64-char-hex", + "original_sku": "雾霾蓝 / L", + "quantity": 2, + "candidate": { + "candidate_key": "64-char-hex", + "observed_ordinal": 1, + "title": "实际观测标题", + "sku_text": "雾霾蓝 / L", + "price_text": "39.80", + "card_signature": "64-char-hex", + "detail_signature": "64-char-hex", + "detail_evidence_sha256": "64-char-hex", + "specification_evidence_sha256": "64-char-hex" + }, + "command_sha256": "64-char-hex", + "authorization_status": "DELIVERED" +} +``` + +1. `observed_ordinal` 只用于重新定位提示,不能直接作为点击索引;T-217 必须重新核对 + card/detail/SKU/证据。 +2. `command_sha256` 是 schema v1 除自身和投递状态外的 canonical JSON SHA-256。 + 首次投递在事务内写入该 hash、`delivered_at` 和 + `ORDER_AUTHORIZATION_DELIVERED` 事件;重投不新增事件。 +3. 已 `ACKNOWLEDGED` 但 App 本地状态丢失时仍允许同一身份重新拉取,以便安全恢复; + 不允许因此改写 hash 或创建新命令。 + +### App 先持久化后确认 + +1. 新增加密 `PendingOrderCommand`,保存完整快照、command hash、ACK 幂等键和确认 + 状态。先验证 schema/type、task/execution/content hash、数量和所有指纹,再一次 + `store.save`;保存失败不得发送 ACK。 +2. 保存成功后调用 + `POST /api/v1/tasks/{task_id}/commands/{command_id}/ack`,请求包含 execution、 + generation 和 command hash,并带 claim token 与 `Idempotency-Key`。 +3. ACK 在事务内把 `DELIVERED -> ACKNOWLEDGED`,写 `acknowledged_at`、幂等记录和 + `ORDER_AUTHORIZATION_ACKNOWLEDGED` 事件。相同 key/body 或同 command/hash 重试 + 返回相同结果;hash、归属、状态或身份不匹配返回冲突。 +4. HTTP ACK 响应丢失时 App 保留同一 ACK key 重试。只有后端确认后本地 + `acknowledged=true`,UI 显示“后台已授权,等待订单核验”;进程重启继续 ACK, + 不能重新采集或领取其他任务。 + +### 旧手机人工确认收口 + +1. 后台任务非空候选上传成功后,App 进入 `WAITING_ADMIN_CONFIRMATION`,不再显示 + 本地接受/拒绝候选按钮,也不提交旧的 `HUMAN_REVIEW + COMPLETE`。 +2. 空候选仍可按现有安全终态回传;独立本地探针不受后台任务模式影响。 +3. 前台服务在等待 Admin 时继续 heartbeat、同步 outbox 和拉取命令;租约到期、 + 取消、认证失效或命令校验失败时保持安全停止。 + +## 数据模型 + +v9: + +- `order_authorizations` 增加不可变 `command_sha256` 和投递计数/最后投递时间; +- `device_order_command_ack_requests` 保存 device、幂等键、请求 hash、command 和时间; +- `task_events` 增加 `ORDER_AUTHORIZATION_DELIVERED` 与 + `ORDER_AUTHORIZATION_ACKNOWLEDGED`。 + +存在命令投递/ACK 数据或新事件时 destructive down 必须失败。 + +## 验收要点 + +- [x] 只有原 user/device/execution/generation/token 且租约有效时能拉取命令。 +- [x] 首次拉取原子进入 `DELIVERED`;网络重放返回相同 command/hash 且不重复事件。 +- [x] App 严格校验并先加密持久化,存储失败不 ACK,重启可恢复同一命令。 +- [x] ACK 幂等进入 `ACKNOWLEDGED`,错误 hash/command/设备和并发请求被拒绝。 +- [x] 后台候选流程等待 Admin,不再允许手机本地选择并提前完成任务。 +- [x] App 等待/已授权状态可见,但不打开拼多多、不选择 SKU/数量、不提交订单。 +- [x] v9 migration、Go test/race/vet、Android test/Debug/Release 和根验证通过。 + +## 边界 + +- 不重新打开图片搜索或定位 Admin 选择的商品;属于 T-217。 +- 不选择 SKU/数量、进入确认订单页或比较最终金额;属于 T-217。 +- 不点击确认购买、读取订单号或处理付款;属于 T-218/T-219。 +- 不新增 FCM/WebSocket,不把第三方 URL 当命令,不授权支付。 + +## 执行记录 + +- 2026-07-28:T-215 实现提交 `827afc7` 后领取。复用现有 30 秒前台 heartbeat 和 + Keystore-backed 状态,选择 pull + durable-before-ACK,避免 Android 后台推送和 + HTTP 不确定结果造成命令丢失。 +- 2026-07-28:新增 v9、命令 pull/ACK API、同一授权重放和投递/确认审计事件。 + HTTP 集成覆盖错误 hash、响应丢失后的相同 key 重试、ACK 后恢复拉取、事件不重复 + 及带数据禁止降级。 +- 2026-07-28:Android `1.4.13 (18)` 增加命令 canonical hash 校验、加密持久化、 + 固定 ACK key、重启恢复和等待/已授权 UI。后台任务有候选后不再出现手机接受/ + 拒绝入口,也禁止再次采集;空候选仍保留安全终态。 +- 2026-07-28:Go/Kotlin 固定哈希向量一致;Android 测试证明存储失败时 ACK 调用数 + 为 0。`go test ./...`、`go test -race ./...`、`go vet ./...`、Android `test`、 + Debug/Release assemble 和根 `.\init.ps1` 全部通过。APK 已构建,未在本任务中 + 覆盖安装或执行拼多多页面动作。