1
T-259
ila edited this page 2026-08-07 16:37:03 +08:00
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.

同步来源: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:

if (
    execution.safetyStopped &&
    !allowSafetyStoppedSync &&
    !reconciliationPending
) {
    return@withLock ExecutionSyncDecision.STOP   // 在 flush 之前返回
}
// flushExecutionOutbox(session) 在这之后,永远走不到

而 ProcurementExecutionService 只调用:

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。