diff --git a/T-268.-.md b/T-268.-.md new file mode 100644 index 0000000..8311cca --- /dev/null +++ b/T-268.-.md @@ -0,0 +1,330 @@ + +> 同步来源:[`docs/tasks/T-268.md`](/chengma/mroubao/src/commit/afc651f75a3abc2676bb13aa8a80f6aa6a25a72e/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` 的单次循环: + +```kotlin +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= 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`。