1
T-268
ila edited this page 2026-08-07 16:37:09 +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-268.md · commit afc651f75a3a


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 的既有输出。

方案

  1. 抽出无副作用的纯函数产出门控快照数据类,输入为既有条件所依赖的状态。
  2. ProcurementExecutionCycle(T-259 抽出的单次循环)在评估门控处产出快照。
  3. 变化检测与 10 分钟保底写入,经既有事件 outbox 上报。
  4. 单测覆盖: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 写入的 outbox EVENTS 载荷 step/type/message 符合契约;用带敏感 标题/goods_id/价格的任务和候选验证载荷完全不包含这些内容;无有效任务/ 执行时安全失败(不抛异常、不写入)。
    • ProcurementExecutionCycleGateEventTest.kt:以 ProcurementExecutionCycle 为入口的端到端验证——门控关闭场景下连续两次相同求值只写一次、时间推进 10 分钟后必写第二次,且驱动函数调用次数全程为 0(证明诊断上报不影响 实际下单预演/提交的调用);门控打开场景下驱动函数按原有条件正常触发, 事件消息含 gateOpen=1;仅设备就绪条件不满足时讲道理关闭门(gateOpen=0 且 pddInstalled=0),而 shouldRunOrderDryRun 本身仍为真——证明两类 条件独立可观测。
  • docs/current-state.md:追加 T-268 一行快照说明。

关键决策

  1. 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 个 键的表述一致。
  2. type 字段取值:契约只锁定了 step,未提及 type。选用 ORDER_DRY_RUN_GATE_SNAPSHOT / ORDER_SUBMISSION_GATE_SNAPSHOT (均为 ASCII 大写 + 下划线,满足后端 validLifecycleStep 校验),与既有 type 命名习惯(如 CANDIDATE_SEARCH_SUCCEEDED)保持同一形态。
  3. 诊断只读设备就绪快照与实际门控判定分离:canStartPinduoduoAutomation() (决定是否真的调用 runOrderDryRun/runOrderSubmission 的既有布尔函数) 保持原样、原调用方式不变;新增的 readinessSnapshotForGateDiagnostics() 是每轮循环额外多做的一次只读查询,只用于诊断展开 pddInstalled/a11yEnabled/a11yConnected/loginBlocker,不会替换、 也不会影响两个真实 if 语句的求值结果。DeviceReadinessChecker.snapshot() 本身无副作用(PackageManager/AccessibilityManager 只读查询 + DeviceObservationStore.snapshot() 只读 @Volatile 读取),多调用一次 不改变 DeviceObservationStore 的更新时机。
  4. 节流器状态位置与生命周期:GateEventThrottle 实例持有在 ProcurementExecutionCycle 内(每个 step 一个),随服务生命周期存在, 不落盘——服务重启会重新以「首次必写」的方式建立基线,符合「用于证明循环 仍在评估」的用途,且不引入需要持久化的新状态。
  5. 测试构造器新增 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。