docs: import wiki at afc651f75a3a

ila
2026-08-07 16:37:01 +08:00
parent 07b3194a6f
commit f5098bc880
+250
@@ -0,0 +1,250 @@
<!-- docs-wiki-sync:docs/tasks/T-254.md@afc651f75a3abc2676bb13aa8a80f6aa6a25a72e -->
> 同步来源:[`docs/tasks/T-254.md`](/chengma/mroubao/src/commit/afc651f75a3abc2676bb13aa8a80f6aa6a25a72e/docs/tasks/T-254.md) · commit `afc651f75a3a`
---
id: T-254
title: 为候选搜索静默跳过路径增加诊断计数与细分失败码
phase: 2
deps:
- T-251
- T-252
- T-253
status: DONE
created: 2026-07-30
context_ref: db4e9f8
work_branch: null
write_paths:
- docs/tasks/T-254.md
- docs/current-state.md
- android-buyer/app/src/main/**
- android-buyer/app/src/test/**
---
## 问题 / 背景
真机任务连续三轮图片搜索全部失败,Admin 显示
`CANDIDATE_SEARCH_EXHAUSTED:候选搜索三轮已耗尽,最后失败原因:CANDIDATE_TARGET_NOT_FOUND`,
但人工在同一台设备上用同一张参考图检查,确认拼多多结果页存在符合颜色和尺码硬约束的
商品。
`PinduoduoCandidateAutomation.terminalCollectionResult()` 的分类优先级为:
1. `!sawCandidateCards` → `CANDIDATE_RESULTS_NOT_READY`
2. `identityFailureCounts` 非空 → 身份类细分码
3. `skuMismatchCount > 0` → `CANDIDATE_SKU_NOT_MATCHED`
4. 其余 → `CANDIDATE_TARGET_NOT_FOUND`
命中第 4 条说明:卡片已经看到,身份解析一次都没失败,颜色尺码一次都没不匹配。也就是
说候选没有走到规格比对就被跳过了。当前循环里存在三条 `continue` 分支,消耗一次
attempt 但不增加任何计数器,在设备外部完全不可观测:
| 位置 | 触发条件 |
| --- | --- |
| `PinduoduoCandidateAutomation.kt:153` | `!driver.openSpecifications()`,规格弹层打不开 |
| `PinduoduoCandidateAutomation.kt:159` | 进入 `ORDER_CONFIRMATION`,返回后跳过 |
| `PinduoduoCandidateAutomation.kt:216` | `goodsId in excludedGoodsIds`,跨轮去重跳过 |
三条路径都会产出完全相同的 `CANDIDATE_TARGET_NOT_FOUND`,因此无法判断应当修复规格
入口节点定位、跨轮去重策略,还是结果页排序。这违反「失败要可诊断:不能只返回失败,
必须记录步骤、错误码」的产品原则,也阻塞后续修复任务的定位。
本任务只做可观测性,不改判定阈值,先取得真实计数分布再决定修什么。
## 关联需求与交互
- 功能:F-005、F-007
- 用户故事:US-004、US-006
- 交互:沿用候选搜索进度和任务详情页面,不新增采购操作
- 架构/API:复用 T-207 事件 outbox 与既有 `recordCandidateSearchAttempt`,
**不修改后端契约,后端零改动**
## 行为契约
### 一、轮内诊断计数
`PinduoduoCandidateAutomation` 必须在单轮生命周期内累计以下计数,并在 `reset()` 中
全部清零:
| 计数 | 递增位置 |
| --- | --- |
| `specificationOpenFailedCount` | `!driver.openSpecifications()` 分支 |
| `orderConfirmationRedirectCount` | `SpecificationEntry.ORDER_CONFIRMATION` 分支 |
| `excludedGoodsIdSkipCount` | `goodsId in excludedGoodsIds` 分支 |
| `observedCardReads` | 每次 `driver.candidateCards(...)` 返回的卡片累计条数 |
| `distinctCardSignatures` | 累计见过的不同 `card.signature` 数量 |
`observedCardReads` 与 `distinctCardSignatures` 用于检验「卡片签名基于标题哈希导致
不同商品签名冲突」这一假设:两者差距显著即为冲突证据。
既有的 `skuMismatchCount`、`identityFailureCounts`、`resultScrollCount`、
`attemptedSignatures.size` 和 `resolvedEvidenceCount()` 保持现有语义,只增加对外读取
能力,不改变递增时机。
计数必须可在 `AutomationResult` 返回后从自动化实例读取(例如只读属性或一个
`diagnostics()` 快照方法)。不得为此改变 `AutomationGateway.execute` 的签名。
### 二、细分失败码
`WorkflowFailureCode` 新增三个值:
- `CANDIDATE_SPECIFICATION_UNAVAILABLE`
- `CANDIDATE_ORDER_CONFIRMATION_REDIRECT`
- `CANDIDATE_ALL_EXCLUDED`
`terminalCollectionResult()` 的判定改为:
1. `resolvedEvidenceCount() == maxCandidates` → `Success`(**判定式保持不变**)
2. `!sawCandidateCards` → `CANDIDATE_RESULTS_NOT_READY`
3. `identityFailureCounts` 非空 → 现有 `terminalIdentityFailureCode()`
4. `skuMismatchCount > 0` → `CANDIDATE_SKU_NOT_MATCHED`
5. **新增**:三个新计数中取计数最大者对应的码;计数相同时按
`specificationOpenFailed` → `orderConfirmationRedirect` → `excludedGoodsIdSkip`
固定顺序取第一个
6. 三个新计数全为 0 → `CANDIDATE_TARGET_NOT_FOUND`
第 1 至 4 步的既有语义**一个字都不能改**,新码只允许收窄第 6 步的兜底范围。
`SearchProbeScreen.kt` 中对 `WorkflowFailureCode` 的 `when` 是穷举的,新增枚举值必须
同步补充中文文案,否则无法编译:
- `CANDIDATE_SPECIFICATION_UNAVAILABLE` → "无法打开商品规格弹层"
- `CANDIDATE_ORDER_CONFIRMATION_REDIRECT` → "打开规格时被带入订单确认页"
- `CANDIDATE_ALL_EXCLUDED` → "候选均已在既往轮次采集"
### 三、结构化诊断事件
`ProcurementRepository.recordCandidateSearchAttempt` 增加一个可选诊断入参(默认空,
不传时行为与现在完全一致)。当传入诊断时,在既有 `message` 尾部按**固定顺序**追加
以下 `key=value` 段,键名严格照抄,便于事后 grep:
```
cards=<observedCardReads> distinct=<distinctCardSignatures> attempts=<attemptedSignatures.size> scrolls=<resultScrollCount> specOpenFailed=<n> orderConfirmRedirect=<n> excludedSkip=<n> skuMismatch=<n> identityFail=<identityFailureCounts 总和> resolved=<resolvedEvidenceCount()> target=<maxCandidates>
```
约束:
- 所有值必须是非负整数,缺失时写 `0`,不得写空值或 `null`
- `reason` 参数继续受 `EVENT_TOKEN_PATTERN`(`^[A-Za-z0-9_.-]{1,160}$`)约束,
**不含空格**;诊断内容只能进 `message`,不能塞进 `reason`
- 追加后 `message` 总长必须仍满足后端 `validAuditText` 校验,且不得包含凭证、
goods_id、商品标题、店铺名或任何链接
`MainActivity` 现有的 `recordAutomaticSearchFailure` 调用链必须把自动化实例的诊断
快照透传下去,三轮每一轮都要带;成功轮次不强制要求带。
## 方案
1. `PinduoduoCandidateAutomation`:新增五个计数字段,在既有三条 `continue` 分支和
卡片读取处递增;补 `reset()` 清零;新增只读诊断快照。
2. `WorkflowModels.kt`:追加三个枚举值,放在 `CANDIDATE_TARGET_NOT_FOUND` 之前,
保持既有值顺序不变。
3. `terminalCollectionResult()`:按上述六步重写判定,第 5 步用纯函数实现,便于单测。
4. `SearchProbeScreen.kt`:补三条中文文案。
5. `ProcurementRepository.recordCandidateSearchAttempt`:增加可选诊断入参与 message
拼接,保留原有调用点的默认行为。
6. `MainActivity`:在记录轮次事件时透传诊断快照。
7. 单测覆盖:五个计数各自的递增路径、第 5 步的最大值选择与并列取序、全零回退
`CANDIDATE_TARGET_NOT_FOUND`、诊断 message 拼接格式、不传诊断时 message 不变。
## 验收要点
- [x] 规格弹层全部打不开的场景返回 `CANDIDATE_SPECIFICATION_UNAVAILABLE`。
- [x] 全部被带入订单确认页的场景返回 `CANDIDATE_ORDER_CONFIRMATION_REDIRECT`。
- [x] 候选全部命中 `excludedGoodsIds` 的场景返回 `CANDIDATE_ALL_EXCLUDED`。
- [x] 三个新计数全为 0 时仍返回 `CANDIDATE_TARGET_NOT_FOUND`。
- [x] `!sawCandidateCards`、身份失败、SKU 不匹配三条既有分类的返回码与本任务前一致,
有回归测试证明。
- [x] 诊断 message 按固定顺序出现且值为非负整数;不传诊断时 message 与本任务
前逐字节一致。
- [x] 诊断 message 不含凭证、goods_id、标题、店铺名或链接。
- [x] Android Debug/Release 单测、`lintDebug`、`assembleDebug`、`assembleRelease`
全部通过。
- [~] 根 `init.ps1` 通过 —— **不适用**:其流程会构建 Go 后端并产出 `backend-api/bin`,
超出本任务 Android-only 的 write scope。起草时照抄通用模板所致,已在执行记录
中说明;以五个 Android Gradle 目标作为本任务完整验证口径。
- [x] 真机跑一轮采购任务,从 Admin 任务详情读到三轮各自的完整计数分布,把实际数字
记入执行记录。
## 边界
**判定阈值一律不动**,本任务只增加可观测性:
- 不改 `terminalCollectionResult()` 的 `resolvedEvidenceCount() == maxCandidates`
成功判定式。
- 不改 `PROCUREMENT_CANDIDATE_TARGET`、`candidateCollectionReady` 或
`MAX_CANDIDATES_PER_PROBE`。
- 不改 `PinduoduoImageCandidateRoundPolicy` 的三轮预算、timeout、attempts 和 scrolls。
- 不改 `excludedGoodsIds` / `excludedDetailSignatures` 的去重行为本身,只计数。
- 不改卡片签名算法 `PinduoduoStableProductIdentityPolicy`。
- 不改 T-251 的三轮耗尽终态、T-252/T-253 的商品身份解析与 `ps` 跳转逻辑。
- 不改后端任何代码、数据库、API 契约或 Admin 模板。
- 不新增第四轮搜索,不改重试策略。
- 不降低卡片、身份、颜色、尺码、数量、价格和订单确认页校验。
- 不启动真实采购下单,不进入订单提交或支付。
- 本轮不统计无障碍层内部被丢弃的卡片容器数(需改
`BuyerAccessibilityService.candidateCards`),如计数分布显示签名冲突嫌疑再另开任务。
## 执行记录
### 2026-07-30
- 状态:开工时由 `TODO` 改为 `DOING`;按要求未改为 `DONE`。
- 修改:
- `PinduoduoCandidateAutomation` 增加规格打开失败、订单确认页跳转、既往商品排除、
卡片读取总数、不同卡片签名数五类轮内计数;`reset()` 清零计数,提供只读诊断快照,
并按最大值及固定优先级选择三个新增失败码。
- `WorkflowFailureCode` 增加
`CANDIDATE_SPECIFICATION_UNAVAILABLE`、`CANDIDATE_ORDER_CONFIRMATION_REDIRECT`、
`CANDIDATE_ALL_EXCLUDED`;`SearchProbeScreen` 增加对应中文文案。
- `ProcurementRepository.recordCandidateSearchAttempt` 增加默认为空的诊断入参,按
固定顺序追加 11 个非负整数键;无诊断时沿用原 message。`MainActivity` 将自动候选失败
轮的诊断快照透传到事件记录。
- 新增自动化计数/失败码、重置、固定并列优先级、诊断 message 格式和默认兼容行为单测。
- 未修改 `backend-api`、后端契约、候选阈值、预算、去重行为或最终下单/支付边界。
- 命令与结果(工作目录:`D:\chengma\cmroubao\android-buyer`,PowerShell):
- `.\gradlew.bat testDebugUnitTest --no-daemon`:首次执行失败,241 项中 1 项为新增测试
的 `resolvedEvidenceCount` 预期写成 0;修正断言后再次执行同一命令通过,241 项全部通过。
- `.\gradlew.bat lintDebug --no-daemon`:通过。
- `.\gradlew.bat assembleDebug --no-daemon`:通过。
- `.\gradlew.bat assembleRelease --no-daemon`:通过。
- 将 `MainActivity.kt` 规范为 LF 后再次执行上述四条命令:四条均 `BUILD SUCCESSFUL`;
`testDebugUnitTest` 为 `UP-TO-DATE`,`lintDebug`、`assembleDebug`、`assembleRelease`
均重新完成并通过。
- 环境:Windows PowerShell;JDK 17.0.13;Android SDK 34;Gradle Wrapper 8.2。
- 决策:诊断字段放在 Android 端 `CandidateSearchDiagnostics`,message 通过固定格式化
函数生成;诊断参数默认 `null`,确保既有调用点的 message 逐字节保持不变。
- 未验证 / blocker:本轮未连接或操作授权真机,无法完成设备上的三轮搜索和 Admin 详情
读取,因此没有可记录的真实计数分布;需在测试设备上完成最终 on-device verification。
根 `init.ps1` 未执行:其标准流程会在 `backend-api/bin` 生成后端构建产物,超出本任务
明确的 Android-only write scope;本轮已用四条 Android Gradle 命令完成指定验证。
### 2026-07-30 补充:真机验证完成
- 补跑 `.\gradlew.bat testReleaseUnitTest --no-daemon`:241 项通过、0 失败,与 Debug
一致。此前遗漏是委派指令未列出该目标,任务验收要点要求 Debug/Release 双变体。
- 验收要点中的「根 `init.ps1` 通过」与本任务 Android-only 的 write scope 自相矛盾,
确认为起草时照抄通用模板所致;以四条 Android Gradle 目标加 `testReleaseUnitTest`
作为本任务的完整验证口径,`init.ps1` 不适用。
- 通过 USB 在 OnePlus PKG110 上 `adb install -r` 覆盖安装 Debug APK;本地产物与设备
`base.apk` 的 SHA-256 均为
`42d8baec3e6fb3addc4191919a01d161a175257931ace79e50757f5bb124fed8`。版本号未 bump,
仍为 `1.4.18 (23)`,故新旧只能以 SHA-256 区分。覆盖安装后采购无障碍仍在启用列表,
`accessibility_enabled=1`,拼多多为 `8.17.0`。
- 真机跑通一条采购任务的完整三轮,Admin 任务详情「执行审计」读到三轮完整计数分布:
| 轮次 | cards | distinct | attempts | scrolls | specOpenFailed | orderConfirmRedirect | excludedSkip | skuMismatch | identityFail | resolved | target | reason |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 2 | 1 | 1 | 4 | 0 | 0 | 0 | 0 | 0 | 1 | 2 | `CANDIDATE_TARGET_NOT_FOUND` |
| 2 | 2 | 1 | 1 | 4 | 0 | 0 | 1 | 0 | 0 | 0 | 1 | `CANDIDATE_ALL_EXCLUDED` |
| 3 | 2 | 1 | 1 | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 2 | `CANDIDATE_ALL_EXCLUDED` |
- 诊断结论:三条静默跳过路径全部为 0,规格弹层可打开、身份解析正常、SKU 匹配无失败。
真实瓶颈在更上游——`distinct=1` 表明整轮只识别出一个候选卡片,且滚动后读取归零
(`cards=2` 意味着 5 次读取中 4 次返回空)。第 2、3 轮重复命中同一商品并被跨轮去重
排除,必然为 0。第 1 轮的 `resolved=1` 是完全合格候选,但因 `target=2` 未达标而整体
判失败,未提交给 Admin。
- 后续任务:`T-255` 为卡片过滤器九个条件增加拒绝计数并观测滚动后 recycler 可识别性;
`T-256` 将「凑不足 target 即整体失败」改为「至少一个有效候选即进入人工确认」。
- 本任务目标已达成:细分失败码成功把原兜底码拆分为
`CANDIDATE_TARGET_NOT_FOUND` 与 `CANDIDATE_ALL_EXCLUDED`,并用计数分布否证了三条
静默路径假设,定位到真实瓶颈。