1
T-271
ila edited this page 2026-08-07 16:37:10 +08:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

同步来源: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 个条件:

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 数据”一节已经写明的已知阈值, 不是本任务新引入的数值。