docs: import wiki at afc651f75a3a

ila
2026-08-07 16:37:10 +08:00
parent 7bd29fd4bc
commit bf111002d8
+317
@@ -0,0 +1,317 @@
<!-- docs-wiki-sync:docs/tasks/T-271.md@afc651f75a3abc2676bb13aa8a80f6aa6a25a72e -->
> 同步来源:[`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 数据”一节已经写明的已知阈值,
不是本任务新引入的数值。