docs: import wiki at afc651f75a3a

ila
2026-08-07 16:37:03 +08:00
parent 09067a8684
commit b1ab455686
+231
@@ -0,0 +1,231 @@
<!-- docs-wiki-sync:docs/tasks/T-259.md@afc651f75a3abc2676bb13aa8a80f6aa6a25a72e -->
> 同步来源:[`docs/tasks/T-259.md`](/chengma/mroubao/src/commit/afc651f75a3abc2676bb13aa8a80f6aa6a25a72e/docs/tasks/T-259.md) · commit `afc651f75a3a`
---
id: T-259
title: 安全停止后补报 outbox 待传结果
phase: 2
deps:
- T-257
- T-258
status: DOING
created: 2026-07-31
context_ref: 3cc7da0
work_branch: null
write_paths:
- docs/tasks/T-259.md
- docs/current-state.md
- android-buyer/app/src/main/**
- android-buyer/app/src/test/**
---
## 问题 / 背景
真机 execution `9c634321-7f77-49d9-9444-3ccd2104c104` 已采集到一个完全合格的候选
(`goodsId=970618634860`、`EXACT_MATCH`、双证据齐全),但 30 分钟离线执行授权到期后,
候选**永久滞留在设备 outbox 里传不出去**,任务在 Admin 上永远停在 `RUNNING`。
`ProcurementRepository.kt:364-370`:
```kotlin
if (
execution.safetyStopped &&
!allowSafetyStoppedSync &&
!reconciliationPending
) {
return@withLock ExecutionSyncDecision.STOP // 在 flush 之前返回
}
// flushExecutionOutbox(session) 在这之后,永远走不到
```
而 `ProcurementExecutionService` 只调用:
```kotlin
suspend fun synchronizeRunning(): ExecutionSyncDecision =
synchronize(allowSafetyStoppedSync = false) // 硬编码 false
```
`allowSafetyStoppedSync` 这个参数存在,说明设计上考虑过该场景,但**代码库中没有任何
调用方传 `true`**。
### 死锁链条
```
授权到期 → safetyStopped=true 并持久化
↓
服务循环调 synchronizeRunning() → 撞早退 → STOP → stopSelf()
↓
App 冷启动 → App.kt 检测 hasRunningExecution → 重启服务
↓
又撞同一个早退 → 又 STOP
```
实测:重启后端、强制停止 Roubao、重新打开,Admin 仍为 `RUNNING`,App 仍提示
「授权到期,已停止」。**重启多少次都是同一结果,没有任何自动或人工恢复路径。**
### 设计缺陷的本质
「安全停止」把两件语义不同的事混在了同一个开关里:
| 动作 | 授权到期后应当 |
| --- | --- |
| 拼多多 UI 自动化、下单、支付 | **必须停止**——这是安全边界 |
| 向自己的管理后端补报已产生的结果 | **应当继续**——这是数据完整性 |
业务规则 18 要求「到期后必须安全停止」,指的是停止对拼多多的外部动作;US-009 验收
场景 5 明确要求「离线完成的结果恢复联网后幂等补报」。当前实现把后者也一并停掉了,
与需求相悖。
后端侧不存在障碍:`candidate_decision_repository` 与 `execution_result_repository`
对到期后到达的数据只打 `received_after_execution_expiry` 标记,**并不拒绝**。
## 关联需求与交互
- 功能:F-007、F-009
- 用户故事:US-006、US-009
- 交互:Roubao 执行页需显示待补传条数,不新增采购操作
- 架构/API:沿用既有 outbox 与 `received_after_execution_expiry` 语义,**后端零改动**
## 行为契约
### 一、安全停止后执行一次终态补报
`synchronize` 在 `execution.safetyStopped` 早退分支返回 `STOP` **之前**,必须先尝试
一次 `flushExecutionOutbox`,前提是 session 存在且有效。
补报完成(outbox 清空)或本次尝试失败后,**仍然返回 `STOP`**,不得让服务循环继续。
这样可以保证外部自动化没有任何机会被重新触发。
session 缺失或已失效时跳过补报直接 `STOP`,行为与现状一致。
### 二、绝不因补报而恢复任何外部动作
这是本任务的**硬边界**:
- 不得让 `synchronize` 在 `safetyStopped` 时返回 `CONTINUE`。
- 不得触发 `shouldRunOrderDryRun` 或 `shouldRunOrderSubmission` 对应的协调器。
- 不得恢复候选搜索、规格选择、下单 dry-run、订单提交或任何拼多多 UI 操作。
- 不得续租、不得重新领取任务、不得延长执行授权。
补报只允许发生 HTTP 回传,不允许任何设备端自动化动作。必须有单测证明安全停止后
补报路径不会调用任何拼多多驱动。
### 三、有界重试
单次 `synchronize` 调用最多尝试一轮 outbox flush,不得在其内部无限循环。
由于返回 `STOP` 后服务会退出,恢复节奏为:**App 每次冷启动获得一次补报机会**
(`App.kt` 已有 `hasRunningExecution` 判断会重启服务)。这一节奏是可接受的,不要求
本任务实现后台无限重试。
### 四、待补传可见
Roubao 执行页当前只显示「授权到期,已停止」,用户无法得知数据仍在本地、也不知道
如何补救。要求:
1. outbox 非空时,状态文案追加待补传条数,例如
「离线执行授权已到期,自动化已安全停止;有 N 条结果待补传」。
2. 补报成功且 outbox 清空后,文案更新为已回传完成。
3. 文案只显示条数,**不得显示商品标题、店铺名、goods_id、链接或价格**。
### 五、幂等与既有语义不变
- 补报复用既有 outbox 幂等 key 与 `received_after_execution_expiry` 语义,不得重复
写入或伪装成授权期内到达。
- `reconciliationPending` 分支、订单对账路径、`OrderSubmissionStatus.RECONCILED`
早退、session 失效分支的现有行为一律不变。
- 不改 `ExecutionSyncDecision` 既有取值的语义。
## 方案
1. `ProcurementRepository.synchronize`:在 `safetyStopped` 早退分支返回 `STOP` 前,
若 session 有效则调用一次 `flushExecutionOutbox`,随后仍返回 `STOP`。
2. outbox 待传条数暴露为只读状态,供 UI 展示。
3. 执行页文案追加待补传条数,补报成功后更新。
4. 单测覆盖:安全停止且 outbox 非空时会尝试补报并最终返回 `STOP`;补报成功后 outbox
清空;session 无效时跳过补报直接 `STOP`;**补报路径不调用任何拼多多驱动**;
`reconciliationPending` 与订单对账分支行为不变;补报幂等不重复提交。
## 验收要点
- [ ] 安全停止且 outbox 非空时,`synchronize` 会尝试一次补报并返回 `STOP`。
- [ ] 补报成功后 outbox 清空,再次调用不重复提交。
- [ ] session 无效时跳过补报直接 `STOP`,行为与本任务前一致。
- [ ] 有单测证明补报路径**不调用任何拼多多驱动**,不触发下单 dry-run 或订单提交。
- [ ] `reconciliationPending`、`RECONCILED` 早退和 session 失效分支行为不变,有回归测试。
- [ ] 执行页在 outbox 非空时显示待补传条数,文案不含标题、店铺名、goods_id、链接或价格。
- [ ] Android Debug/Release 单测、`lintDebug`、`assembleDebug`、`assembleRelease` 全部通过。
- [ ] 真机验证:安装新 APK 后打开 Roubao,execution
`9c634321-7f77-49d9-9444-3ccd2104c104` 的滞留候选自动补传成功,任务从 `RUNNING`
收敛到 `WAITING_CONFIRMATION`,候选 `goodsId=970618634860` 出现在 Admin
候选确认区,执行事件标注为授权到期后补报。
## 边界
- **不放松任何安全停止语义**:拼多多 UI 自动化、下单、支付在授权到期后一律不得恢复。
- 不续租、不重新领取任务、不延长执行授权、不新增第四轮搜索。
- 不改 `ExecutionSyncDecision` 既有取值语义,不让 `safetyStopped` 返回 `CONTINUE`。
- 不改订单对账、授权价格策略、`OrderSubmissionStatus` 流转或支付边界。
- 不改 T-254/T-255/T-257 的诊断字段、T-256 的候选提交门槛、T-258 的后端校验。
- 不改后端代码、数据库、API 契约或 Admin 模板。
- 不实现后台无限重试或定时补报——本任务只保证每次 App 冷启动有一次补报机会。
- 不改候选识别、签名算法、跨轮去重或价格解析。
## 执行记录
开工后记录修改、命令、结果、环境、决策和 blocker。
### 2026-07-31
- 状态:开工时由 `TODO` 改为 `DOING`;按任务要求和真机 blocker 未改为 `DONE`。
- 修改范围:仅修改本任务 `write_paths` 内的 Android 主代码、Android 单测、
`docs/current-state.md` 和本任务文件;未修改 `backend-api`,未运行 `init.ps1`,
未执行 git commit 或 push,未触碰工作区既有无关未跟踪文件。
- 实现:
- `ProcurementRepository.synchronizeRunning()` 在普通 `safetyStopped` 早退中先验证
既有 session;有效时只调用一轮既有 `flushExecutionOutbox`,成功、清空或失败后均
返回 `ExecutionSyncDecision.STOP`。session 缺失/失效仍直接 `STOP`。
- `reconciliationPending` 继续绕过普通安全停止早退并保留订单对账 `CONTINUE`;
`OrderSubmissionStatus.RECONCILED` 仍在 session/outbox 之前早退;未修改
`ExecutionSyncDecision`、heartbeat、租约、claim 或授权语义。
- 冷启动恢复条件只增加“安全停止且仍有实际待上传 outbox 项”;补传清空后不再启动
服务,失败时仅在下一次 App 冷启动再尝试。已上传且仅用于本地引用替换的 evidence
记录不计入待补传数量。
- 将服务单次循环边界提取为可注入协调器的 `ProcurementExecutionCycle`;同步返回
`STOP` 后立即退出,单测中的拼多多 dry-run 与订单提交假驱动调用数均为 0。
- `ProcurementUiState` 暴露只读 `pendingResultCount`;授权到期页面仅显示“有 N 条结果
待补传”或“结果已回传完成”,未加入商品标题、店铺、`goods_id`、链接或价格。
- 为 JVM repository 单测增加仅测试可用的 files directory 构造入口;生产构造与文件
位置保持不变。同步失败日志继续使用原诊断内容,并兼容 JVM Android stub。
- 单测覆盖:
- 安全停止且 outbox 非空时上传、清空、返回 `STOP`,第二次调用不重复上传。
- 上传失败只尝试一次并返回 `STOP`;session 失效跳过上传。
- `RECONCILED` 早退跳过上传;`reconciliationPending` 保持 `CONTINUE` 且不发 heartbeat。
- 服务收到 `STOP` 后 dry-run/订单提交协调器均为零调用;状态文案只含待补传数量。
- 命令与结果(Windows PowerShell):
- 在 `android-buyer` 运行
`.\gradlew.bat :app:testDebugUnitTest --tests "com.roubao.autopilot.procurement.ProcurementSafetyStoppedSyncTest" --no-daemon`:
退出码 0,最初 5 项定向单测通过。
- 增加失败补传测试后运行
`.\gradlew.bat :app:testDebugUnitTest --tests "com.roubao.autopilot.procurement.ProcurementSafetyStoppedSyncTest" --no-daemon --console=plain`:
首次退出码 1,6 项中 1 项因 JVM 的 `android.util.Log.w` stub 未实现而失败;未涉及
业务断言。日志调用增加 JVM 安全包装后以完全相同命令重跑,退出码 0,6 项全部通过。
- 在 `android-buyer` 运行
`.\gradlew.bat testDebugUnitTest testReleaseUnitTest lintDebug assembleDebug assembleRelease --no-daemon --console=plain`:
退出码 0;五项全部通过。Debug/Release 各 262 项单测、0 failure、0 error;
`lintDebug` 成功;Debug APK 与 Release unsigned APK 均生成。输出只有既有 Android
SDK XML 版本兼容 warning。
- 在仓库根目录运行
`git diff --check`:退出码 0,无 whitespace error。
- 在仓库根目录运行
`$paths = @('docs/tasks/T-259.md','docs/current-state.md','android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementExecutionService.kt','android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementModels.kt','android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt','android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/ProcurementScreen.kt','android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ProcurementSafetyStoppedSyncTest.kt'); $crlf = @($paths | Where-Object { $bytes = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $_)); for ($i = 0; $i -lt $bytes.Length - 1; $i++) { if ($bytes[$i] -eq 13 -and $bytes[$i + 1] -eq 10) { return $true } }; return $false }); if ($crlf.Count -gt 0) { $crlf; exit 1 } else { 'LF_ONLY' }`:
退出码 0,输出 `LF_ONLY`。
- 最终范围审计运行 `git diff --name-only`、`git diff --name-only -- backend-api` 和
`git status --short`:任务修改仅位于声明的 `write_paths`,`backend-api` 无 tracked
diff;既有无关未跟踪文件保持未改动。
- 环境:Windows 工作区 `D:\chengma\cmroubao`,JDK 17 / Android SDK 34;所有 Gradle
命令均在 `android-buyer` 执行。
- 未验证 / blocker:无法执行最终真机验收,因此尚未确认安装 APK 后 execution
`9c634321-7f77-49d9-9444-3ccd2104c104` 的滞留候选补传、任务收敛到
`WAITING_CONFIRMATION`、Admin 显示候选及事件的授权到期后补报标记。必须由采购人员
在已授权测试设备完成;本任务保持 `DOING`。