Table of Contents
同步来源:
docs/tasks/T-271.md· commitafc651f75a3a
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 个条件:
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 不再作为否决条件。卡片判定改为要求以下全部成立:
- className 为
FrameLayout/ViewGroup(不变); - 宽度占 recycler 的
MIN_CARD_WIDTH_PERCENT–MAX_CARD_WIDTH_PERCENT(不变); - 高度不低于屏幕 1/8(不变);
- 可见面积达标(不变);
- 语义文本数不低于
MIN_CANDIDATE_TEXTS(不变); - 存在价格语义(不变);
- 找得到安全点击目标(不变);
- 能算出稳定签名(不变)。
其余七个条件的判定逻辑与阈值常量一律不改,仅移除图片这一条的否决权。
二、图片状态仍需记录
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 中写明该口径变更。
方案
- 候选卡片过滤移除
hasImage的否决权,保留其取值并记入PinduoduoCandidateCard。 rejImage改为「降级但未否决」计数,调整恒等式与相关单测。- 单测覆盖:真机 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(RecyclerViewbounds[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。
- 4 张
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。
偏离任务文档之处及理由:
- 任务「方案」一节未显式要求把候选过滤逻辑抽成独立纯函数,只说"移除
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/ 点击目标查找等)未受影响。 - 新纯函数里的宽度/高度/可见面积等阈值百分比作为参数从
BuyerAccessibilityService的私有companion object常量原样传入,而不是 在新文件里重新声明同名常量,避免两处定义漂移;测试文件里为了独立验证 边界值,用私有companion object字面量复刻了35/60/80/2四个数值 (因为它们在生产代码里是private const val,测试模块无法直接 import)——这是任务文档本身在“真机 dump 数据”一节已经写明的已知阈值, 不是本任务新引入的数值。