docs: import wiki at afc651f75a3a

ila
2026-08-07 16:37:09 +08:00
parent f1e49ab906
commit a64cc3686f
+330
@@ -0,0 +1,330 @@
<!-- docs-wiki-sync:docs/tasks/T-268.md@afc651f75a3abc2676bb13aa8a80f6aa6a25a72e -->
> 同步来源:[`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=<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`。