同步来源:
docs/tasks/T-254.md· commitafc651f75a3a
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() 的分类优先级为:
!sawCandidateCards→CANDIDATE_RESULTS_NOT_READYidentityFailureCounts非空 → 身份类细分码skuMismatchCount > 0→CANDIDATE_SKU_NOT_MATCHED- 其余 →
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_UNAVAILABLECANDIDATE_ORDER_CONFIRMATION_REDIRECTCANDIDATE_ALL_EXCLUDED
terminalCollectionResult() 的判定改为:
resolvedEvidenceCount() == maxCandidates→Success(判定式保持不变)!sawCandidateCards→CANDIDATE_RESULTS_NOT_READYidentityFailureCounts非空 → 现有terminalIdentityFailureCode()skuMismatchCount > 0→CANDIDATE_SKU_NOT_MATCHED- 新增:三个新计数中取计数最大者对应的码;计数相同时按
specificationOpenFailed→orderConfirmationRedirect→excludedGoodsIdSkip固定顺序取第一个 - 三个新计数全为 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 调用链必须把自动化实例的诊断
快照透传下去,三轮每一轮都要带;成功轮次不强制要求带。
方案
PinduoduoCandidateAutomation:新增五个计数字段,在既有三条continue分支和 卡片读取处递增;补reset()清零;新增只读诊断快照。WorkflowModels.kt:追加三个枚举值,放在CANDIDATE_TARGET_NOT_FOUND之前, 保持既有值顺序不变。terminalCollectionResult():按上述六步重写判定,第 5 步用纯函数实现,便于单测。SearchProbeScreen.kt:补三条中文文案。ProcurementRepository.recordCandidateSearchAttempt:增加可选诊断入参与 message 拼接,保留原有调用点的默认行为。MainActivity:在记录轮次事件时透传诊断快照。- 单测覆盖:五个计数各自的递增路径、第 5 步的最大值选择与并列取序、全零回退
CANDIDATE_TARGET_NOT_FOUND、诊断 message 拼接格式、不传诊断时 message 不变。
验收要点
- 规格弹层全部打不开的场景返回
CANDIDATE_SPECIFICATION_UNAVAILABLE。 - 全部被带入订单确认页的场景返回
CANDIDATE_ORDER_CONFIRMATION_REDIRECT。 - 候选全部命中
excludedGoodsIds的场景返回CANDIDATE_ALL_EXCLUDED。 - 三个新计数全为 0 时仍返回
CANDIDATE_TARGET_NOT_FOUND。 !sawCandidateCards、身份失败、SKU 不匹配三条既有分类的返回码与本任务前一致, 有回归测试证明。- 诊断 message 按固定顺序出现且值为非负整数;不传诊断时 message 与本任务 前逐字节一致。
- 诊断 message 不含凭证、goods_id、标题、店铺名或链接。
- Android Debug/Release 单测、
lintDebug、assembleDebug、assembleRelease全部通过。 - [~] 根
init.ps1通过 —— 不适用:其流程会构建 Go 后端并产出backend-api/bin, 超出本任务 Android-only 的 write scope。起草时照抄通用模板所致,已在执行记录 中说明;以五个 Android Gradle 目标作为本任务完整验证口径。 - 真机跑一轮采购任务,从 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_FOUND2 2 1 1 4 0 0 1 0 0 0 1 CANDIDATE_ALL_EXCLUDED3 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,并用计数分布否证了三条 静默路径假设,定位到真实瓶颈。