From b1ab455686dc2a3185765dbf720d36ebda4078ed Mon Sep 17 00:00:00 2001 From: ila Date: Fri, 7 Aug 2026 16:37:03 +0800 Subject: [PATCH] docs: import wiki at afc651f75a3a --- T-259.-.md | 231 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 T-259.-.md diff --git a/T-259.-.md b/T-259.-.md new file mode 100644 index 0000000..e0ca223 --- /dev/null +++ b/T-259.-.md @@ -0,0 +1,231 @@ + +> 同步来源:[`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`。