From f5098bc880f9f9d2c650ed57ab3aa9dfe8d461ec Mon Sep 17 00:00:00 2001 From: ila Date: Fri, 7 Aug 2026 16:37:01 +0800 Subject: [PATCH] docs: import wiki at afc651f75a3a --- T-254.-.md | 250 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 T-254.-.md diff --git a/T-254.-.md b/T-254.-.md new file mode 100644 index 0000000..73d9a24 --- /dev/null +++ b/T-254.-.md @@ -0,0 +1,250 @@ + +> 同步来源:[`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= distinct= attempts= scrolls= specOpenFailed= orderConfirmRedirect= excludedSkip= skuMismatch= identityFail= resolved= target= +``` + +约束: + +- 所有值必须是非负整数,缺失时写 `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`,并用计数分布否证了三条 + 静默路径假设,定位到真实瓶颈。