From bf111002d8c1ec3cccf267640ee847cf8eab7d0e Mon Sep 17 00:00:00 2001 From: ila Date: Fri, 7 Aug 2026 16:37:10 +0800 Subject: [PATCH] docs: import wiki at afc651f75a3a --- T-271.-.md | 317 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 T-271.-.md diff --git a/T-271.-.md b/T-271.-.md new file mode 100644 index 0000000..37a8953 --- /dev/null +++ b/T-271.-.md @@ -0,0 +1,317 @@ + +> 同步来源:[`docs/tasks/T-271.md`](/chengma/mroubao/src/commit/afc651f75a3abc2676bb13aa8a80f6aa6a25a72e/docs/tasks/T-271.md) · commit `afc651f75a3a` + +--- +id: T-271 +title: 放宽候选卡片的图片条件避免图未加载即否决 +phase: 2 +deps: + - T-269 +status: DOING +created: 2026-08-01 +context_ref: 1286ee3 +work_branch: null +write_paths: + - docs/tasks/T-271.md + - docs/current-state.md + - android-buyer/app/src/main/** + - android-buyer/app/src/test/** +--- + +## 问题 / 背景 + +真机任务三轮全部 `CANDIDATE_RESULTS_NOT_READY`,`cards=0`,且三轮数字完全一致: + +``` +containers=550 rejClass=250 rejHeight=100 rejImage=200 +``` + +### 算术还原 + +`awaitCandidateCards` 最多轮询 `candidateReadyPollLimit = 50` 次。 + +``` +550 ÷ 50 = 11 每次读取 recycler 有 11 个子节点 +250 ÷ 50 = 5 每次 5 个因 className 被拒 +100 ÷ 50 = 2 每次 2 个因高度被拒 +200 ÷ 50 = 4 每次 4 个因缺少图片被拒 +5 + 2 + 4 = 11 ✓ 全部被拒,零卡片,轮询 50 次(10 秒)后超时 +``` + +### 真机对照:那 4 个正是真实商品卡 + +在图片搜索结果页做 `uiautomator dump`(126 节点,全页仅一个可滚动 +`RecyclerView`,8 个子节点,占满屏),逐个核对九道过滤条件: + +| # | 类型 | 尺寸 | class | 宽度 | 高度 | 图片 | 价格 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 0 | LinearLayout | 1080×168 | ✗ | ✗ | ✗ | ✗ | ✗ | +| 1 | RecyclerView | 1080×120 | ✗ | ✗ | ✗ | ✗ | ✗ | +| **2** | FrameLayout | **535×764** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **3** | FrameLayout | **535×764** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **4** | FrameLayout | **535×764** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **5** | FrameLayout | **535×764** | ✓ | ✓ | ✓ | ✓ | ✓ | +| 6 | FrameLayout | 535×182 | ✓ | ✓ | ✗ | ✓ | ✗ | +| 7 | FrameLayout | 535×182 | ✓ | ✓ | ✗ | ✓ | ✗ | + +四个 `535×764` 的容器带有商品标题、券后价、收藏数,是真实商品卡,**在页面加载完成 +后能通过全部九道过滤**。 + +失败轮次的 `rejImage=4`(每次读取)与此处的 4 个真实商品卡**数量吻合**:说明那 4 张卡 +当时确实存在且文本已可读,**唯独死在图片条件上**。 + +### 直接原因 + +`BuyerAccessibilityService` 候选卡片过滤的第 6 个条件: + +```kotlin +val hasImage = descendants.any { descendant -> + descendant.isVisibleToUser && + descendant.isEnabled && + descendant.className?.toString()?.endsWith("ImageView") == true +} +``` + +商品图尚未加载完成时,卡片内的 `ImageView` 还不满足 `isVisibleToUser && isEnabled`, +整张卡被否决。就绪轮询窗口仅 `50 × 200ms = 10` 秒,**图片未在该窗口内加载完即三轮 +全败**。 + +### 该条件本身缺乏价值 + +一个宽度占 recycler 35–60%、高度不低于屏幕 1/8、可见面积达标、拥有多条语义文本、 +含价格语义且能找到安全点击目标的容器,**已经足以判定为商品卡**。额外要求「必须存在 +已加载的 ImageView」不增加任何安全性,只把结果绑定到网络速度和图片加载时机上。 + +### 这是同类问题第三次出现 + +| 次序 | 症状 | 真因 | +| --- | --- | --- | +| 1 | 支付边界误报、详情页认不出(T-262/T-269) | `clickable` 取在了错误的节点层级 | +| 2 | 规格入口 100% 缺失(T-264/T-265) | 移除了当前版本唯一的入口 | +| 3 | **卡片零通过(本任务)** | **图片加载完成前即否决** | + +三次都表现为「需要更强的感知能力」,实际都是单条规则细节。 + +## 关联需求与交互 + +- 功能:F-005 +- 用户故事:US-004 +- 交互:不新增采购操作 +- 架构/API:沿用既有诊断机制,**后端零改动** + +## 行为契约 + +### 一、图片降为加分项而非硬性条件 + +候选卡片过滤中,`hasImage` **不再作为否决条件**。卡片判定改为要求以下全部成立: + +1. className 为 `FrameLayout` / `ViewGroup`(不变); +2. 宽度占 recycler 的 `MIN_CARD_WIDTH_PERCENT`–`MAX_CARD_WIDTH_PERCENT`(不变); +3. 高度不低于屏幕 1/8(不变); +4. 可见面积达标(不变); +5. 语义文本数不低于 `MIN_CANDIDATE_TEXTS`(不变); +6. 存在价格语义(不变); +7. 找得到安全点击目标(不变); +8. 能算出稳定签名(不变)。 + +**其余七个条件的判定逻辑与阈值常量一律不改**,仅移除图片这一条的否决权。 + +### 二、图片状态仍需记录 + +`PinduoduoCandidateCard` 保留 `hasImage` 字段,如实记录该卡当时是否已有可见图片, +供后续评估与证据审计使用。 + +诊断计数调整:`rejImage` 语义改为「**因缺少图片而被降级但未被否决**的卡片数」, +即变为观测项而非拒绝项。恒等式相应调整为: + +``` +containers - (rejClass + rejWidth + rejHeight + rejVisible + rejTexts + rejPrice + rejClick + rejSignature) = 返回卡片数 +``` + +`rejImage` 不再计入该等式,必须有单测保证新恒等式成立。 + +### 三、不得因此放宽后续校验 + +放宽的只是「进入候选列表」的门槛。**后续所有校验一律不变**: + +- 颜色、尺码硬约束判定不变; +- 商品身份解析(goods_id / 规范链接)不变; +- 双证据截图(`DETAIL` + `SPECIFICATION`)仍为强制,缺一不可; +- 支付边界、订单确认页、登录、验证码、风控的判定与优先级不变; +- T-265 落到结算页的跳过逻辑不变。 + +即:图片未加载的卡片可以被**打开检查**,但**能否成为合格候选仍由既有硬约束决定**。 + +### 四、既有诊断不变 + +T-254/T-255/T-257/T-260/T-261/T-262/T-263/T-264/T-265/T-268/T-269 既有诊断键的顺序与 +名称**保持不变**;仅 `rejImage` 的语义按第二节调整,须在本任务文档与 +`docs/current-state.md` 中写明该口径变更。 + +## 方案 + +1. 候选卡片过滤移除 `hasImage` 的否决权,保留其取值并记入 `PinduoduoCandidateCard`。 +2. `rejImage` 改为「降级但未否决」计数,调整恒等式与相关单测。 +3. 单测覆盖:真机 dump 的四张 `535×764` 商品卡在**无图片**时仍被接受;两个 + `535×182` 的矮容器仍因高度被拒;`LinearLayout` / `RecyclerView` 仍因 className + 被拒;新恒等式成立;`hasImage` 取值如实记录;其余七个条件的拒绝行为逐项不变。 + +## 验收要点 + +- [ ] 图片未加载的商品卡仍被接受为候选,有基于真机 dump 特征的单测。 +- [ ] 其余七个过滤条件的判定逻辑、阈值常量与拒绝行为逐项不变,有回归测试。 +- [ ] 新恒等式 `containers - Σ(除 rejImage 外的拒绝) = 返回卡片数` 有单测保证。 +- [ ] `rejImage` 如实记录「缺图但未否决」的数量。 +- [ ] `PinduoduoCandidateCard.hasImage` 取值正确。 +- [ ] 颜色/尺码硬约束、身份解析、双证据、支付边界与订单确认页判定均未放宽,有回归测试。 +- [ ] 既有诊断键顺序与名称不变;`rejImage` 口径变更已写入任务文档与 `current-state`。 +- [ ] Android Debug/Release 单测、`lintDebug`、`assembleDebug`、`assembleRelease` 全部通过。 +- [ ] 真机验证:在图片尚未完全加载的图搜结果页跑任务,`cards` 不再为 0,能进入候选 + 采集;从 Admin 读取 `cards` / `distinct` / `rejImage` 记入执行记录。 + +## 边界 + +- **只移除图片这一条的否决权**,其余八个条件(含签名)的逻辑与阈值一律不动。 +- 不改 `MIN_CARD_WIDTH_PERCENT`、`MAX_CARD_WIDTH_PERCENT`、`MIN_VISIBLE_CARD_PERCENT`、 + `MIN_CANDIDATE_TEXTS`、`MIN_MAIN_RECYCLER_NODES`、`MIN_MAIN_RECYCLER_WIDTH_PERCENT` + 或最小高度算式。 +- 不改 `candidateReadyPollLimit` 与就绪等待时长——本任务通过降低误否决解决问题, + 不靠延长等待。 +- 不改颜色/尺码硬约束、身份解析、双证据要求、跨轮去重或三轮预算。 +- 不改支付边界、订单确认页、登录、验证码、风控的任何判定与优先级。 +- 不改 T-265 规格入口优先级、T-268 门控诊断、T-269 详情页识别与归位策略。 +- 不改后端代码、数据库、API 契约或 Admin 模板。 +- 不处理价格解析(前缀与节点拆分)问题,另行开任务。 +- 不引入 VLM 或截图判断——本任务证明当前失败是规则问题,不是感知能力问题。 + +## 执行记录 + +开工后记录修改、命令、结果、环境、决策和 blocker。 + +### 2026-08-01 实现 + +**改动文件** + +- `android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateContainerFilter.kt` + (新增):把候选容器"进入候选列表"的八项判定抽成不依赖 + `AccessibilityNodeInfo` 的纯函数 `PinduoduoCandidateContainerFilter.evaluate()`, + 短路顺序为 CLASS → WIDTH/HEIGHT → VISIBLE → TEXTS → PRICE → CLICK → + SIGNATURE(`hasImage` 不再出现在否决链条里,只用于填充返回卡片的 + `hasImage` 字段)。抽取的目的:`candidateNodes()` 直接操作 + `AccessibilityNodeInfo`,仓库现有测试栈没有 Robolectric/Mockito,无法在 + JVM 单测里构造该类型的夹具;抽成纯函数后可以直接用真机 dump 的数值 + (类名、宽高、语义文本、价格/点击/签名布尔量)构造第 8 节要求的回归测试, + 同时不引入 VLM/截图,也不改变任何判定逻辑本身。 +- `android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt`: + `candidateNodes()` 改为从无障碍树读出原始度量后调用上面的纯函数,把返回的 + `Accepted`/`Rejected` 落回既有 `rejected*` 计数与候选列表;`rejectedImage` + 语义改为「已接受但 hasImage=false」的观测计数(在 `Accepted` 分支里判断 + `!verdict.card.hasImage` 才 +1,不再 `continue`);`PinduoduoCandidateCard` + 的 `hasImage` 字段改为如实传入 `hasImage`(此前放宽前该分支恒为 + `true`,因为不满足就已经被拒绝跳过了)。className/宽度/高度/可见面积/ + 语义文本数/价格语义/安全点击目标/稳定签名的判定逻辑、 + `MIN_CARD_WIDTH_PERCENT`/`MAX_CARD_WIDTH_PERCENT`/`MIN_VISIBLE_CARD_PERCENT`/ + `MIN_CANDIDATE_TEXTS`/`MIN_MAIN_RECYCLER_NODES`/`MIN_MAIN_RECYCLER_WIDTH_PERCENT` + 和最小高度算式(`rootBounds.height() / 8`)字面量与位置均未改动, + 只是把判定语句搬到了纯函数里、原样传参调用。 +- `android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateModels.kt`: + `PinduoduoCandidateCardRead` 的 `init` 恒等式移除 `rejectedImage`,新恒等式 + 为 `containers - (rejClass+rejWidth+rejHeight+rejVisible+rejTexts+rejPrice+ + rejClick+rejSignature) = cards.size`;非负校验列表里保留 `rejectedImage` + (仍需 ≥0)。字段旁追加口径变更注释。 +- `android-buyer/app/src/main/java/com/roubao/autopilot/procurement/CandidateSearchDiagnostics.kt`: + `rejectedImage` 字段旁追加口径变更注释(键名、位置、`auditSuffix()` 输出 + 格式未改,`CandidateSearchDiagnosticsTest` 的既有断言未改也仍然通过)。 +- `android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateContainerFilterTest.kt` + (新增):基于真机图搜结果页 dump(`RecyclerView` bounds `[0,132][1080,2328]`, + 宽 1080,根节点高 2376,最小卡片高 297)构造 8 个子节点夹具,覆盖: + - 4 张 `535×764` 真实商品卡在 `hasImage=false` 时仍被接受(含四个不同标题 + 算出四个互不相同的稳定签名); + - 2 个 `535×182` 矮容器仍因 HEIGHT 被拒; + - `LinearLayout`/`RecyclerView` 两个非卡片容器仍因 CLASS 被拒; + - 汇总全部 8 个节点复刻 `containers=8 rejClass=2 rejHeight=2 cards=4 + rejImage=4`,并用这份数据构造 `PinduoduoCandidateCardRead` 验证新恒等式 + 成立; + - 另外 11 个独立回归测试:基线(缺图仍接受)、宽度过窄、宽度过宽、高度 + 不足、可见面积不足、与 recycler 不相交、文本不足、缺价格语义、缺安全 + 点击目标、缺稳定签名的拒绝行为,以及 `hasImage=true` 时卡片如实记录 + `true`。 +- `android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateCardReadTest.kt`: + `each rejection bucket is one short circuit attribution` 移除 + `rejectedImage` 分支(它不再是一个「否决桶」);把原先的 + `container identity leaves exactly the returned card count` 改造为 + `... excluding rejectedImage`,构造 2 张卡片(一张有图一张无图)验证 + `containers=10` 减去其余八个拒绝计数各 1 后剩下 2 张;新增 + `rejectedImage counts an accepted card that lacked an image` 单独验证该 + 计数与「已接受但缺图」的对应关系。 +- `docs/tasks/T-271.md`:status 从 `TODO` 改为 `DOING`;本节。 +- `docs/current-state.md`:阶段列表追加 T-271 DOING;追加 T-271 叙述段落 + (含 `rejImage` 口径变更说明);任务表追加 T-271 行。 + +**未改动(按边界要求核对)**:`backend-api/**` 未碰; +`candidateReadyPollLimit`(仍为 50)与轮询间隔(仍为 200ms)未改; +颜色/尺码硬约束、商品身份解析、双证据、支付边界、订单确认页、登录/验证码/ +风控判定与 T-265 规格入口优先级/落到结算页跳过逻辑/T-268 门控诊断/T-269 +详情页识别与归位策略均未触碰;价格解析(前缀/节点拆分)未处理。 + +**命令与结果(均在 Windows 侧执行,WSL 无 Android SDK/JDK)** + +``` +cmd.exe /c "cd /d D:\chengma\cmroubao\android-buyer && gradlew.bat testDebugUnitTest --no-daemon --console=plain" +BUILD SUCCESSFUL;Debug 单测 433 项,0 failures / 0 errors +(含新增 PinduoduoCandidateContainerFilterTest 15 项、 +PinduoduoCandidateCardReadTest 由 3 项增至 4 项) + +cmd.exe /c "cd /d D:\chengma\cmroubao\android-buyer && gradlew.bat testReleaseUnitTest --no-daemon --console=plain" +BUILD SUCCESSFUL;Release 单测 433 项,0 failures / 0 errors + +cmd.exe /c "cd /d D:\chengma\cmroubao\android-buyer && gradlew.bat lintDebug --no-daemon --console=plain" +BUILD SUCCESSFUL + +cmd.exe /c "cd /d D:\chengma\cmroubao\android-buyer && gradlew.bat assembleDebug --no-daemon --console=plain" +BUILD SUCCESSFUL;app-debug.apk 已生成 + +cmd.exe /c "cd /d D:\chengma\cmroubao\android-buyer && gradlew.bat assembleRelease --no-daemon --console=plain" +BUILD SUCCESSFUL;app-release-unsigned.apk 已生成 +``` + +**八个子节点夹具断言结果**(`PinduoduoCandidateContainerFilterTest`): + +| # | 类型 | 尺寸 | 断言结果 | +| --- | --- | --- | --- | +| 0 | LinearLayout | 1080×168 | `Rejected(CLASS)` | +| 1 | RecyclerView | 1080×120 | `Rejected(CLASS)` | +| 2..5 | FrameLayout | 535×764,`hasImage=false` | `Accepted`,`card.hasImage=false`,四张签名互不相同 | +| 6..7 | FrameLayout | 535×182 | `Rejected(HEIGHT)` | + +汇总:`containers=8 rejClass=2 rejHeight=2 rejImage=4 cards=4`,代入新恒等式 +`containers - (rejClass+rejWidth+rejHeight+rejVisible+rejTexts+rejPrice+ +rejClick+rejSignature) = cards.size` 即 `8-2-2=4`,与 `cards.size=4` 一致 +(`rejImage=4` 不参与该式)。 + +**待人工完成(不在本次改动范围内)**:真机验证——在图片尚未完全加载的 +图搜结果页跑任务,确认 `cards` 不再为 0、能进入候选采集,并从 Admin 读取 +`cards`/`distinct`/`rejImage` 记入本节;完成前 status 保持 `DOING`。 + +**偏离任务文档之处及理由**: + +1. 任务「方案」一节未显式要求把候选过滤逻辑抽成独立纯函数,只说"移除 + `hasImage` 的否决权"。之所以新增 + `PinduoduoCandidateContainerFilter`,是因为验收要点明确要求「有基于真机 + dump 特征的单测」,而 `candidateNodes()` 直接操作 + `android.view.accessibility.AccessibilityNodeInfo`,仓库 `app/build.gradle.kts` + 的 `testImplementation` 里没有 Robolectric、也没有 Mockito/MockK,无法在 + JVM 单测里构造该框架类型的夹具或调用私有方法。抽成不依赖 + `AccessibilityNodeInfo` 的纯函数是在不新增测试框架依赖、不引入 + Robolectric(会改变构建配置和体积)的前提下,唯一能让"真机 dump 数值" + 直接驱动单测的办法。抽取只是**照抄原判定语句的顺序和阈值参数**,未改变 + 任何逻辑、常量或短路顺序,`candidateNodes()` 里的其余代码(recycler + 查找、`collectNodes`/`collectSemanticTexts`/`findSafeCandidateClickTarget`/ + 点击目标查找等)未受影响。 +2. 新纯函数里的宽度/高度/可见面积等阈值百分比作为参数从 + `BuyerAccessibilityService` 的私有 `companion object` 常量原样传入,而不是 + 在新文件里重新声明同名常量,避免两处定义漂移;测试文件里为了独立验证 + 边界值,用私有 `companion object` 字面量复刻了 `35`/`60`/`80`/`2` 四个数值 + (因为它们在生产代码里是 `private const val`,测试模块无法直接 + import)——这是任务文档本身在“真机 dump 数据”一节已经写明的已知阈值, + 不是本任务新引入的数值。