同步来源:
docs/tasks/T-268.md· commitafc651f75a3a
id: T-268 title: 为下单门控增加逐条件可观测诊断 phase: 2 deps:
- T-265 status: DOING created: 2026-08-01 context_ref: 7c8c4d2 work_branch: null write_paths:
- docs/tasks/T-268.md
- docs/current-state.md
- android-buyer/app/src/main/**
- android-buyer/app/src/test/**
问题 / 背景
真机任务 09240616-5d87-4ab1-a37a-57949725f8b2 首次走通到人工确认并签发下单授权,但
dry-run 卡在 PREPARING 超过 33 分钟,期间没有任何可观测线索。
后端实况(2026-07-31):
| 项 | 值 |
|---|---|
| 授权 | EXECUTING,delivered_at 09:27:48、acknowledged_at 09:27:48 |
order_dry_runs.status |
PREPARING(started_at 09:27:48,33 分钟未变) |
| dry-run 业务字段 | card_signature / observed_title / selected_sku / quantity / unit_price_cents / evidence_asset_id 全为 None |
order_submissions |
0 行 |
task_executions.last_heartbeat_at |
10:00:33(23 秒前)——循环存活 |
| App 自有标签日志 | 零条 |
即:服务循环心跳正常,但 dry-run 一个字段都没产出,也没有任何失败记录。
根因:门控不满足时完全静默
ProcurementExecutionService 的单次循环:
if (repository.shouldRunOrderDryRun && readiness.snapshot().canStartProbe) {
runCatching { orderDryRun.runOnce() }
.onFailure { Log.w(TAG, "Authorized order dry-run stopped safely", error) }
}
条件为 false 时不写日志、不写事件、不更新 dry-run 状态,直接进入下一次等待。
onFailure 只覆盖「已进入 runOnce() 后抛异常」,覆盖不到「压根没进去」。
13 个子条件全部不可观测
shouldRunOrderDryRun(9 条):
execution != null / orderCommand != null / storageFailure == null
command.acknowledged / !execution.safetyStopped / !execution.isExpired()
dryRun?.status != READY / != READY_LOCAL / attemptCount < MAX_ORDER_DRY_RUN_ATTEMPTS
canStartProbe(blockers.isEmpty(),4 条):
pinduoduo.installed / accessibilityEnabled / accessibilityConnected
loginBlocker ∉ {LOGIN_REQUIRED, VERIFICATION_REQUIRED, RISK_CONTROL}
这些值全部位于 procurement_secure_state.xml(Keystore 加密)或进程内缓存,
外部无法读取。排障时只能列举全部可能性,无法定位。
一个已定位但本任务不修的相关缺陷
DeviceObservationStore.loginBlocker 是粘滞缓存:只在拼多多前台被扫描时更新
(recordPinduoduoPage),且从不重置。DeviceObservation 保存了
pinduoduoObservedAtMillis,但 blockers 计算完全没有使用它——一次陈旧的
LOGIN_REQUIRED 观测会无限期阻塞 canStartProbe。
本任务只让该情况可见;是否引入过期机制由后续任务依据实测数据决定。
同类教训已出现四次
| 任务 | 修复的静默点 |
|---|---|
| T-254 | 候选搜索三条静默 continue |
| T-257 | queueProcurementCandidates 六个裸 return false |
| T-262 | 支付边界只知触发、不知原因 |
| T-268 | 下单门控 13 个条件全不可见 |
前三次均依靠先加观测再定位真因,无一例靠推断解决。下单是整条链路最敏感的一段, 其门控不可观测的代价高于前三者。
关联需求与交互
- 功能:F-007、F-010
- 用户故事:US-006、US-010
- 交互:沿用任务详情执行事件展示,不新增采购操作
- 架构/API:复用 T-207 事件 outbox,后端零改动
行为契约
一、下单预演门控可观测
每次评估门控时产出逐条件快照,经既有执行事件机制上报,step 固定为
ORDER_DRY_RUN_GATE,message 按固定顺序包含以下键(键名严格照抄,便于 grep):
gateOpen=<0|1> execution=<0|1> command=<0|1> storageOk=<0|1> cmdAck=<0|1> safetyStopped=<0|1> expired=<0|1> dryRunStatus=<状态名|NONE> attempts=<n> canStart=<0|1> pddInstalled=<0|1> a11yEnabled=<0|1> a11yConnected=<0|1> loginBlocker=<枚举名>
dryRunStatus 与 loginBlocker 取本项目自定义枚举名(ASCII 大写),属自有常量而非
页面内容,可以记录;无值时分别记 NONE 与 UNKNOWN。其余均为 0/1 或非负整数。
二、订单提交门控同样处理
shouldRunOrderSubmission 存在同类问题。step 固定为 ORDER_SUBMISSION_GATE:
gateOpen=<0|1> dryRunReady=<0|1> submissionStatus=<状态名|NONE> reconciliationPending=<0|1> safetyStopped=<0|1> expired=<0|1> storageOk=<0|1>
三、节流,不得刷屏
- 快照仅在取值发生变化时写入事件,连续相同结果不重复写。
- 无论是否变化,每 10 分钟至少写一条,用于证明循环仍在评估而非卡死。
- 写事件不得影响 heartbeat 周期,也不得阻塞循环。
四、脱敏
只记布尔量、计数与自有枚举名。不得记录商品标题、店铺名、goods_id、链接、价格、 收件人、手机号、地址或任何页面原文。沿用 T-255 起的脱敏口径。
五、纯观测,不改判定
这是硬边界。 13 个子条件的取值、组合方式与短路顺序一律不改;
DeviceObservationStore 的更新时机与 loginBlocker 粘滞行为不动;
runOnce() 的调用条件与既有 onFailure 日志保持原样。
必须有回归测试证明:相同输入下门控的开合结果与本任务前逐项一致。
六、既有诊断不变
T-254/T-255/T-257/T-260/T-261/T-262/T-263/T-264/T-265 既有诊断键的顺序、名称与取值
逐字节不变。本任务新增的是独立执行事件(新 step),不修改
CandidateSearchDiagnostics 的既有输出。
方案
- 抽出无副作用的纯函数产出门控快照数据类,输入为既有条件所依赖的状态。
ProcurementExecutionCycle(T-259 抽出的单次循环)在评估门控处产出快照。- 变化检测与 10 分钟保底写入,经既有事件 outbox 上报。
- 单测覆盖:13 个条件各自为 false 时对应键为 0;全满足时
gateOpen=1; 取值不变不重复写;超过 10 分钟保底写一条;提交门控同样覆盖; 门控开合结果与本任务前一致的回归测试;message 不含任何页面原文。
验收要点
ORDER_DRY_RUN_GATE事件出现在 Admin 执行事件列表,含全部 14 个键。- 13 个子条件各自为 false 的场景取值正确,有单测。
- 全部满足时
gateOpen=1。 ORDER_SUBMISSION_GATE事件同样产出并覆盖其条件。- 取值不变时不重复写事件;超过 10 分钟保底写一条。
- 门控开合结果与本任务前逐项一致,有回归测试。
- 事件 message 不含标题、店铺名、goods_id、链接、价格、收件人、手机号或地址。
- 既有诊断键与
CandidateSearchDiagnostics输出未改变。 - Android Debug/Release 单测、
lintDebug、assembleDebug、assembleRelease全部通过。 - 真机验证:复现 dry-run 卡在
PREPARING的状态,从 Admin 读到门控事件并 确认是哪一个条件为 false,记入执行记录。
边界
- 纯观测,不改任何判定逻辑:13 个子条件、
canStartProbe、blockers计算方式与 短路顺序一律不动。 - 不改
DeviceObservationStore更新时机,不为loginBlocker增加过期机制—— 待本任务取得数据后另开任务决定。 - 不改
runOnce()的实现、调用条件或既有onFailure日志。 - 不改授权、下单、提交、对账、支付边界的任何行为。
- 不改 T-261/T-262/T-263/T-264/T-265 的安全判据。
- 不改 T-266/T-267 的 token 与 session 有效期。
- 不改后端代码、数据库、API 契约或 Admin 模板。
- 不改 heartbeat 周期与执行授权时长。
执行记录
开工后记录修改、命令、结果、环境、决策和 blocker。
2026-08-01 实现
改动文件
android-buyer/app/src/main/java/com/roubao/autopilot/procurement/OrderGateSnapshot.kt(新增):纯函数与数据类。OrderDryRunGateDiagnostics/OrderSubmissionGateDiagnostics是只读诊断数据;buildOrderDryRunGateSnapshot/buildOrderSubmissionGateSnapshot是无副作用纯函数,把既有布尔值和原始状态 拼装成快照;OrderDryRunGateSnapshot.toEventMessage()/OrderSubmissionGateSnapshot.toEventMessage()按契约固定顺序输出key=value字符串;GateEventThrottle实现「取值不变不重复写、10 分钟保底 写一条」。android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt: 新增两个只读计算属性orderDryRunGateDiagnostics/orderSubmissionGateDiagnostics(各自独立读取persisted的既有字段, 不引用、不复用shouldRunOrderDryRun/shouldRunOrderSubmission的表达式); 新增两个 suspend 方法recordOrderDryRunGateEvent(message)/recordOrderSubmissionGateEvent(message),复用 T-207 outbox (enqueueEventLocked),step固定为ORDER_DRY_RUN_GATE/ORDER_SUBMISSION_GATE,type固定为ORDER_DRY_RUN_GATE_SNAPSHOT/ORDER_SUBMISSION_GATE_SNAPSHOT(新增的独立执行事件,未改动CandidateSearchDiagnostics或既有step/type常量);internal 测试构造器 新增可选storageFailure形参(默认null,不影响任何既有调用方)以便单测 覆盖storageOk=0分支。android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementExecutionService.kt:ProcurementExecutionCycle新增两个默认参数readinessSnapshotForGateDiagnostics(默认DeviceReadinessSnapshot.empty()) 和nowEpochMillis(默认System.currentTimeMillis());runOnce()中shouldRunOrderDryRun && canStartPinduoduoAutomation()与shouldRunOrderSubmission && canStartPinduoduoAutomation()这两行逐字节保持 原样,只在其前各插入一次快照上报调用(emitOrderDryRunGateSnapshot/emitOrderSubmissionGateSnapshot),二者只读repository.shouldRunOrderDryRun/repository.shouldRunOrderSubmission/repository.orderDryRunGateDiagnostics/repository.orderSubmissionGateDiagnostics和一次额外的只读设备就绪快照, 经节流器判断后调用新增的recordOrderDryRunGateEvent/recordOrderSubmissionGateEvent,用runCatching包裹避免异常影响循环。onStartCommand中按既有写法把readiness.snapshot()接到新参数。- 新增测试:
OrderGateSnapshotTest.kt:纯函数/节流器单测(28 个用例),覆盖 13 个 子条件各自为 false、dryRunStatus/loginBlocker/submissionStatus枚举映射、gateOpen的四种组合、消息键序精确匹配、脱敏关键字缺失、 节流器首次必写/未变不写/变化必写/10 分钟保底写/写入后计时重置。ProcurementOrderGateRegressionTest.kt:直接对ProcurementRepository.shouldRunOrderDryRun/shouldRunOrderSubmission逐条件构造状态并断言,证明本任务前后两个既有 属性的开合结果逐项一致(本任务未改动这两个属性的任何字符)。ProcurementOrderGateEventTest.kt:验证recordOrderDryRunGateEvent/recordOrderSubmissionGateEvent写入的 outboxEVENTS载荷step/type/message符合契约;用带敏感 标题/goods_id/价格的任务和候选验证载荷完全不包含这些内容;无有效任务/ 执行时安全失败(不抛异常、不写入)。ProcurementExecutionCycleGateEventTest.kt:以ProcurementExecutionCycle为入口的端到端验证——门控关闭场景下连续两次相同求值只写一次、时间推进 10 分钟后必写第二次,且驱动函数调用次数全程为 0(证明诊断上报不影响 实际下单预演/提交的调用);门控打开场景下驱动函数按原有条件正常触发, 事件消息含gateOpen=1;仅设备就绪条件不满足时讲道理关闭门(gateOpen=0且pddInstalled=0),而shouldRunOrderDryRun本身仍为真——证明两类 条件独立可观测。
docs/current-state.md:追加 T-268 一行快照说明。
关键决策
gateOpen的定义:文档把 13 个子条件(shouldRunOrderDryRun的 9 个 +canStartProbe的 4 个)放进同一条ORDER_DRY_RUN_GATE事件、只给一个gateOpen键,因此实现为shouldRunOrderDryRun && canStartProbe——即ProcurementExecutionCycle里实际决定是否调用runOrderDryRun()的那个 组合布尔值,而不是只反映shouldRunOrderDryRun单独的值。ORDER_SUBMISSION_GATE同理,gateOpen = shouldRunOrderSubmission && canStartProbe;但该事件不重复 展开pddInstalled/a11yEnabled/a11yConnected/loginBlocker四个键——它们已经在同一轮循环产出的ORDER_DRY_RUN_GATE事件里出现, 避免同一份就绪状态在两条事件里重复上报,也与契约里提交门控事件只列 7 个 键的表述一致。type字段取值:契约只锁定了step,未提及type。选用ORDER_DRY_RUN_GATE_SNAPSHOT/ORDER_SUBMISSION_GATE_SNAPSHOT(均为 ASCII 大写 + 下划线,满足后端validLifecycleStep校验),与既有type命名习惯(如CANDIDATE_SEARCH_SUCCEEDED)保持同一形态。- 诊断只读设备就绪快照与实际门控判定分离:
canStartPinduoduoAutomation()(决定是否真的调用runOrderDryRun/runOrderSubmission的既有布尔函数) 保持原样、原调用方式不变;新增的readinessSnapshotForGateDiagnostics()是每轮循环额外多做的一次只读查询,只用于诊断展开pddInstalled/a11yEnabled/a11yConnected/loginBlocker,不会替换、 也不会影响两个真实if语句的求值结果。DeviceReadinessChecker.snapshot()本身无副作用(PackageManager/AccessibilityManager只读查询 +DeviceObservationStore.snapshot()只读@Volatile读取),多调用一次 不改变DeviceObservationStore的更新时机。 - 节流器状态位置与生命周期:
GateEventThrottle实例持有在ProcurementExecutionCycle内(每个step一个),随服务生命周期存在, 不落盘——服务重启会重新以「首次必写」的方式建立基线,符合「用于证明循环 仍在评估」的用途,且不引入需要持久化的新状态。 - 测试构造器新增
storageFailure可选参数:为了单测覆盖storageOk=0(storageFailure != null)分支,给 internal 测试专用构造器 新增一个默认为null的可选形参,纯粹是测试可达性改动,不改变任何既有 调用方行为。
验证命令与结果(Windows,cmd.exe + gradlew.bat)
D:\chengma\cmroubao\android-buyer> gradlew.bat testDebugUnitTest --no-daemon --console=plain
BUILD SUCCESSFUL in 33s
28 actionable tasks: 7 executed, 21 up-to-date
Debug 单测:392 项全部通过(新增 OrderGateSnapshotTest 28、
ProcurementOrderGateRegressionTest 20、ProcurementOrderGateEventTest 4、
ProcurementExecutionCycleGateEventTest 4,合计新增 56 项)。
D:\chengma\cmroubao\android-buyer> gradlew.bat testReleaseUnitTest --no-daemon --console=plain
BUILD SUCCESSFUL in 31s
29 actionable tasks: 8 executed, 21 up-to-date
Release 单测:392 项全部通过(与 Debug 一致)。
D:\chengma\cmroubao\android-buyer> gradlew.bat lintDebug --no-daemon --console=plain
BUILD SUCCESSFUL in 58s
28 actionable tasks: 5 executed, 23 up-to-date
D:\chengma\cmroubao\android-buyer> gradlew.bat assembleDebug --no-daemon --console=plain
BUILD SUCCESSFUL in 11s
41 actionable tasks: 6 executed, 35 up-to-date
D:\chengma\cmroubao\android-buyer> gradlew.bat assembleRelease --no-daemon --console=plain
BUILD SUCCESSFUL in 49s
51 actionable tasks: 10 executed, 41 up-to-date
五个目标全部通过。
待完成(需人工/真机)
- 真机复现
09240616-5d87-4ab1-a37a-57949725f8b2场景下 dry-run 卡在PREPARING的状态,从 Admin 执行事件列表读取ORDER_DRY_RUN_GATE事件, 确认具体是哪一个子条件为0(当前根因文档推测是loginBlocker粘滞缓存 导致canStart=0,需真机数据验证)。 - 确认 Admin 执行事件列表能正常展示新
step(ORDER_DRY_RUN_GATE/ORDER_SUBMISSION_GATE)而不需要模板改动(后端与 Admin 模板本任务未改, 沿用既有事件列表渲染逻辑)。 - 因此任务
status保持DOING,不改为DONE。