1
T-262
ila edited this page 2026-08-07 16:37:05 +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-262.md · commit afc651f75a3a


id: T-262 title: 为支付边界判定增加命中归因诊断 phase: 2 deps:

  • T-261 status: DOING created: 2026-07-31 context_ref: 3b8a201 work_branch: null write_paths:
  • docs/tasks/T-262.md
  • docs/current-state.md
  • android-buyer/app/src/main/**
  • android-buyer/app/src/test/**

问题 / 背景

真机任务三轮全部在第一步 pdd_open_app 被 PAYMENT_BOUNDARY 拦下,所有计数为 0, 自动化一步都没跑。设备取证显示拼多多当时停在 NewPageActivity(普通商品页), 可见文本中包含:

使用#微信支付,更换先用后付可0元下单      → 命中标记「微信支付」
选择尺码后,提交订单                       → 命中标记「提交订单」

判定逻辑(PinduoduoPageClassifier):

private fun Collection<String>.containsAny(markers: Collection<String>): Boolean =
    any { text -> markers.any(text::contains) }      // 子串匹配

if (hasCheckoutRoot || visibleTexts.containsAny(paymentMarkers)) → PAYMENT_BOUNDARY

hasCheckoutRoot(resourceId == "order_checkout")未命中,仅子串匹配命中。也就是 说:商品页上的营销横幅和「先选尺码」引导文案,被判成了结算页。

纠正 T-261 中的错误推断

T-261 背景中写「paymentMarkers 不含『立即购买』,推断第一轮选规格时点进了订单确认 页」。该推断不成立——当时只考虑了按钮文字是否命中,忽略了普通文案中的子串同样会 命中。实际全程未进入结算页。T-261 已实现的清理逻辑本身无害,但它并非本故障的解法。

影响面

误报会让任何含支付字样文案的页面触发安全停止。拼多多商品页、活动页乃至首页 banner 都可能出现「微信支付」「先用后付」「提交订单」之类文案,自动化因此无法推进。

同时 T-261 的清理不会执行——isSafeForCleanup() 要求 safetyStopReason == null, 安全停止路径本就不清理。

为什么先诊断而不是直接改判定

paymentMarkers 是资金安全的最后一道防线。可能的收紧方向有多种:要求命中文本所在 元素可点击、要求精确等于而非包含、要求 hasCheckoutRoot 与文本同时成立等。选哪种 取决于真实结算页上这些标记以什么形态出现,目前没有样本。

盲目放松有让真实结算页漏判的风险,因此本任务只采集证据,不改任何判定。

关联需求与交互

  • 功能:F-005、F-007
  • 用户故事:US-004、US-006
  • 交互:沿用候选搜索进度与任务详情,不新增采购操作
  • 架构/API:沿用 T-254/T-255/T-257/T-260/T-261 诊断机制,后端零改动

行为契约

一、支付边界命中归因

当分类结果为 PAYMENT_BOUNDARY 时,记录本次判定的归因,并入 CandidateSearchDiagnostics,按固定顺序追加在既有键之后:

payCheckoutRoot=<0|1> payMarkerIndex=<-1|0..7> payMarkerHits=<n> payClickable=<0|1> payExact=<0|1>
键 含义
payCheckoutRoot resourceId == "order_checkout" 是否命中
payMarkerIndex 首个命中的标记在 paymentMarkers 中的下标;无文本命中记 -1
payMarkerHits 命中文本标记的可见文本条数
payClickable 首个命中标记所在元素是否 clickable
payExact 该元素文本是否精确等于标记(1)还是仅包含(0)

paymentMarkers 的下标顺序即其当前定义顺序,必须在本任务文档中固定记录以便解读:

0=确认订单 1=提交订单 2=确认支付 3=立即支付
4=收银台   5=微信支付 6=找好友支付 7=更多支付方式

若后续调整该列表顺序,必须同步更新本表。

二、只记归因,不记页面原文

严禁记录命中文本的原文——只记标记下标、布尔量和计数。商品标题、店铺名、goods_id、 链接、价格和任何页面文本一律不得进入诊断 message。沿用 T-255 起的脱敏口径。

标记下标指向的是本项目自己定义的常量,不是页面内容,因此可以记录。

三、判定行为完全不变

这是硬边界。 PinduoduoPageClassifier 的判定条件、paymentMarkers 内容与顺序、 containsAny 实现、hasCheckoutRoot 规则、安全停止的触发时机与后续行为一律不改。

本任务只在既有判定得出 PAYMENT_BOUNDARY 之后额外产出一份归因,不影响任何返回值。 必须有回归测试证明:相同输入下分类结果与本任务前逐项一致。

四、阻塞在 pdd_open_app 时也要能看到

本故障发生在候选自动化启动之前,PinduoduoCandidateAutomation 的计数全为 0。因此归因 必须能在安全停止发生于任意步骤时进入诊断 message,包括 pdd_open_app。

未发生 PAYMENT_BOUNDARY 时,五个键取默认值(payMarkerIndex=-1,其余为 0), 不得缺省或写空。

五、既有诊断不变

T-254、T-255、T-257、T-260、T-261 既有诊断键的顺序、名称与取值逐字节不变, 新键一律追加在末尾。

方案

  1. 在分类得出 PAYMENT_BOUNDARY 处额外产出归因数据,随 PinduoduoUiSnapshot 暴露; 判定表达式本身不动。
  2. 归因传递到诊断快照,确保安全停止发生在任意工作流步骤时都能带出。
  3. CandidateSearchDiagnostics 追加五个字段与 auditSuffix() 输出段。
  4. 单测覆盖:仅 hasCheckoutRoot 命中、仅文本子串命中、精确等于标记命中、 可点击与不可点击元素命中、多条文本命中时的计数、未触发时的默认值、 以及分类结果与本任务前一致的回归测试。

验收要点

  • 五个新键出现在诊断 message 末尾且取值符合定义。
  • 仅 hasCheckoutRoot 命中时 payMarkerIndex=-1、payCheckoutRoot=1。
  • 仅文本子串命中时 payCheckoutRoot=0、payExact=0 且下标正确。
  • 文本精确等于标记时 payExact=1。
  • 命中元素可点击与否分别反映在 payClickable。
  • 未触发 PAYMENT_BOUNDARY 时五个键取默认值,不缺省。
  • 安全停止发生在 pdd_open_app 时归因同样出现在 message 中。
  • 有回归测试证明分类结果与本任务前逐项一致。
  • 诊断 message 不含任何页面文本或商品信息。
  • T-254/T-255/T-257/T-260/T-261 既有诊断键顺序、名称、取值不变。
  • Android Debug/Release 单测、lintDebug、assembleDebug、assembleRelease 全部通过。
  • 真机验证:复现一次 PAYMENT_BOUNDARY 阻塞,从 Admin 读到五个归因值并记入执行 记录;另在真实订单确认页上采集一组对照值,供后续判定规则收紧参考。

边界

  • 不改任何判定逻辑:PinduoduoPageClassifier 判定条件、paymentMarkers 内容与 顺序、containsAny 实现、hasCheckoutRoot 规则一律不动。
  • 不放松、不收紧安全停止的触发条件;本任务不修复误报,只产出定位依据。
  • 不改 T-261 的清理逻辑与 isSafeForCleanup 判定。
  • 不改 T-256 提交门槛、T-258 后端校验、T-259 安全停止补报、T-260 节奏配置。
  • 不改候选卡片识别、签名算法、跨轮去重、SKU 硬约束或价格解析。
  • 不改后端代码、数据库、API 契约或 Admin 模板。
  • 不启动真实下单,不进入订单提交或支付。

执行记录

开工后记录修改、命令、结果、环境、决策和 blocker。

2026-07-31 实现与自动化验证

环境:WSL 无 Android SDK/JDK,全部构建与单测经 /mnt/c/Windows/System32/cmd.exe /c "cd /d D:\chengma\cmroubao\android-buyer && gradlew.bat <目标> --no-daemon --console=plain" 在 Windows 侧执行;未运行 init.ps1。

改动文件

  • android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowModels.kt: 新增 PaymentBoundaryAttribution 数据类(checkoutRootHit / markerIndex (-1..7)/ markerHitCount / markerClickable / markerExact,默认值即 “未触发”状态);AutomationResult.Blocked 追加 attribution 字段(默认值, 兼容既有单参数构造);WorkflowReport 追加 paymentBoundaryAttribution 字段 (默认值)。
  • android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowRunner.kt: Blocked 分支与 terminalReport() 透传 attribution/paymentBoundaryAttribution, 未改变任何状态机分支或重试逻辑。
  • android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifier.kt: PinduoduoUiSnapshot 追加 paymentBoundaryAttribution 字段(默认值);classify() 中既有的 safetyStop 判定表达式与 paymentMarkers/containsAny/hasCheckoutRoot 逐字节未动,只在其后新增一段只读局部变量:当 safetyStop == PAYMENT_BOUNDARY 时调用新增的私有函数 computePaymentBoundaryAttribution() 产出归因,否则使用默认值。归因计算:payCheckoutRoot 取自既有的 hasCheckoutRoot;markerIndex 按 paymentMarkers 当前顺序(0=确认订单 1=提交订单 2=确认支付 3=立即支付 4=收银台 5=微信支付 6=找好友支付 7=更多支付方式)扫描,取第一个在任意可见元素 text/contentDescription 中命中的标记下标;markerHitCount 统计命中任一标记的可见文本条数; markerClickable/markerExact 取自该首个命中标记所在元素。只使用下标、 布尔量与计数,未记录任何页面文本。
  • android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoSearchAutomation.kt、 PinduoduoImageSearchAutomation.kt、PinduoduoCandidateAutomation.kt:三处 safetyResult() 从 snapshot.safetyStopReason?.let(AutomationResult::Blocked) 改为显式传入 snapshot.paymentBoundaryAttribution,使归因在任意工作流步骤 (含 pdd_open_app)触发安全停止时都能带出;判定触发时机和后续行为未变。
  • android-buyer/app/src/main/java/com/roubao/autopilot/procurement/CandidateSearchDiagnostics.kt: 末尾追加 payCheckoutRoot/payMarkerIndex/payMarkerHits/payClickable/ payExact 五个字段(默认值 0/-1/0/0/0)及对应 require 校验;新增 withPaymentBoundaryAttribution();auditSuffix() 在既有 cleanupFailures= 之后追加五个键,既有键顺序、名称、取值未动。
  • android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt:候选搜索 成功与失败两处上报 recordCandidateSearchAttempt 的 diagnostics 构建链上 追加 .withPaymentBoundaryAttribution(report.paymentBoundaryAttribution), 使阻塞发生在 pdd_open_app(此时 PinduoduoCandidateAutomation 计数全为 0) 时归因仍能进入诊断 message。
  • 测试:PinduoduoPageClassifierTest.kt(新增 9 个用例,覆盖仅 checkoutRoot 命中、仅子串命中、精确匹配、可点击/不可点击、多条命中计数、默认值、登录 拦截优先级下的默认值,以及一个显式回归用例复核既有支付边界/HOME 场景的 page/safetyStopReason 与本任务前一致)、CandidateSearchDiagnosticsTest.kt (更新 3 处精确匹配断言以包含新增尾部默认键,新增 4 个用例覆盖归因追加、 仅 checkoutRoot 默认下标、越界校验和默认值不缺省)、 WorkflowRunnerTest.kt(新增归因透传用例,并给既有“all safety reasons” 用例追加默认归因断言)、PinduoduoSearchAutomationTest.kt(新增 pdd_open_app 阻塞即带出归因、以及非支付边界安全停止保持默认归因两个用例)、 PinduoduoCandidateAutomationTest.kt(FakeCandidateDriver 新增可选归因参数, 新增一个用例验证候选自动化内部安全停止同样透传归因)。

命令与结果

cmd.exe /c "cd /d D:\chengma\cmroubao\android-buyer && gradlew.bat testDebugUnitTest --no-daemon --console=plain"
BUILD SUCCESSFUL;Debug 单测 299 项,0 失败、0 错误。

cmd.exe /c "cd /d D:\chengma\cmroubao\android-buyer && gradlew.bat testReleaseUnitTest --no-daemon --console=plain"
BUILD SUCCESSFUL;Release 单测 299 项,0 失败、0 错误。

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。

cmd.exe /c "cd /d D:\chengma\cmroubao\android-buyer && gradlew.bat assembleRelease --no-daemon --console=plain"
BUILD SUCCESSFUL。

五个目标全部通过。

偏离任务文档之处及理由

  1. PaymentBoundaryAttribution 定义在 workflow 包而非 pinduoduo 包。任务文档 只写“随 PinduoduoUiSnapshot 暴露”,未指定归属包。既有依赖方向是 pinduoduo 依赖 workflow(如 SafetyStopReason),若把该类型放进 pinduoduo 包会让 workflow.AutomationResult.Blocked/WorkflowReport 反向依赖 pinduoduo,与既有架构方向相反。放进 workflow 包可保持单向依赖, 且 PinduoduoUiSnapshot 正常 import 使用,不影响契约。
  2. payMarkerHits 的口径取“命中任一标记的可见文本条数”(与既有 containsAny 的判定口径一致),而非“首个命中标记专属的文本条数”。任务 文档原文“命中文本标记的可见文本条数”存在两种读法;选择前者是因为它更能 反映误判噪声的规模(验收点“多条文本命中时的计数”未强制要求按单一标记 过滤),且与 containsAny 本身的判定语义直接对应,便于人工核对。
  3. 归因只在 PinduoduoPageClassifier.classify() 自身判定得出 PAYMENT_BOUNDARY 时产出;PinduoduoCandidateAutomation.kt 内部另有几处手工构造的 AutomationResult.Blocked(SafetyStopReason.PAYMENT_BOUNDARY)(例如规格页 跳转订单确认后 returnFromOrderConfirmation() 失败),这些不经过一次新的 classify() 调用产出页面归因,因此保持默认值。这些路径不在“真机任务卡在 pdd_open_app”这一故障范围内,且任务文档的措辞(“当分类结果为 PAYMENT_BOUNDARY 时”)本身即指向 classify() 的输出,未视为需要修改的 范围。
  4. 未改动 PinduoduoOrderDryRunAutomation.kt / AndroidPinduoduoOrderSubmissionDriver.kt / AndroidPinduoduoOrderDryRunDriver.kt。这三者不实现 AutomationGateway/AutomationResult(下单试运行走独立的异常式 stop() 机制),且属于 T-256/T-258/T-259 已冻结的下单流程边界。验收点中 “真实订单确认页对照组”需要的是真机上任意一次触达真实订单确认页时 classify() 产出的归因值,这与 pdd_open_app 阻塞走的是同一条 PinduoduoPageClassifier.classify() 代码路径,采集时不需要改动下单试运行 驱动。

未完成项(需人工真机验证,state 保持 DOING)

  • 真机复现一次 PAYMENT_BOUNDARY 阻塞,从 Admin 读取五个归因值并回填本节。
  • 真机在真实订单确认页采集一组对照归因值并回填本节,供后续判定规则收紧参考。

2026-07-31 真机取证(主会话,UI dump)

在 OnePlus PKG110、拼多多 8.17.0 上对误报现场做了一次 uiautomator dump (NewPageActivity,商品规格弹层已选好颜色与尺码,153 个节点),结论如下。

误报样本的归因实测

命中标记 下标 精确等于 元素 clickable 所在文本
微信支付 5 否 否 支付方式促销横幅
提交订单 1 否 否 规格弹层底部 CTA

hasCheckoutRoot(resourceId == "order_checkout")未命中。即 payCheckoutRoot=0、payMarkerIndex=1、payMarkerHits=2、payClickable=0、 payExact=0。

该页面是商品规格弹层,不是订单确认页。 两条命中文本分别是支付方式促销横幅和 底部 CTA,均为普通页面文案。

payClickable 缺乏区分力(本任务的已知局限)

PinduoduoUiElement 是扁平模型,无父节点引用,因此 markerClickable 只能取命中 文本元素自身的 clickable。实测显示拼多多把文字包在不可点的 TextView 中,真正 可点的是祖先容器:

'提交订单 ¥15.98'  TextView clickable=false
   ↑父2 FrameLayout clickable=true   bounds=[0,2181][1080,2328]
'使用#微信支付…'    TextView clickable=false
   ↑父2 ViewGroup   clickable=true   bounds=[0,2079][1080,2178]

曾考虑改为按 bounds 查找可点祖先,但推演后放弃:促销横幅本身也位于可点容器内 (点击会打开支付方式选择),因此误报样本与真实按钮会得到相同取值,可点击性在本 场景不具备区分力。保持现有实现并记录该局限,避免无收益的改动。

据此,后续收紧判定时最可能的区分器是 payCheckoutRoot,这使「在真实订单确认页 采集对照样本」这一验收项成为决定性依据:若真结算页上该值为 1,收紧规则可以简化为 要求其必须命中。

附带发现(超出本任务范围,另行处理)

同一页面存在「使用#微信支付,更换先用后付可0元下单」。先用后付属信用支付,会 产生真实债务且不经过付款确认,下单路径必须显式排除该入口。需另开任务处理。

同一页面同时出现三条价格文本:¥21.98(可被现有 PRICE_PATTERN 解析)、 仅1次¥15.98、提交订单 ¥15.98。后两条因前缀导致 matchEntire 失败,与 T-257 观测到的 pricePrefix 占比吻合;且同屏存在两个不同金额,其中 ¥15.98 带 「仅1次」限定。这说明价格问题不能靠放宽正则解决,需区分实付价、原价与促销类型, 另行开任务。

2026-07-31 真实结算页对照样本(主会话,UI dump)

在同一设备上手动走到订单确认/去支付页后 dump,结果推翻了两个预设的收紧方向。

对照数据

页面 Activity 节点数 order_checkout paymentMarkers 命中 可见文本
商品规格弹层(误报) NewPageActivity 147–153 未命中 2 条(微信支付、提交订单) 丰富
真实结算页 NewPageActivity 11 未命中 0 条 几乎为零

真实结算页的完整结构是「若干无文本容器 + 一个占满屏幕的 WebView」:

FrameLayout  bounds=[0,0][1080,2376]
LinearLayout bounds=[0,0][1080,2328]
FrameLayout  rid=content
FrameLayout / ViewGroup / FrameLayout  rid=pdd
WebView      bounds=[0,0][1080,2328]      ← 页面内容全在这里,无障碍读不到
View         rid=pdd
View         rid=navigationBarBackground

被否定的两个收紧方向

  1. 要求 payCheckoutRoot 命中——不可行。resourceId == "order_checkout" 在真实 结算页上同样未命中,该常量在当前拼多多版本上似乎已不适用。
  2. 要求 payExact(精确文本匹配)——不可行。真实结算页一个可见文本都没有, 任何基于文本的规则都无法识别它。

判定思路需要改变

当前逻辑是「见到支付关键词即停」,但实测恰好相反:

  • 商品页文本丰富且含支付字样 → 误报
  • 真实结算页几乎无文本、纯 WebView → 文本规则完全失效

因此单纯收紧文本规则会两头落空:误报消除的同时,真实结算页也将无法识别, 那是比误报更危险的结果。在找到可靠的结算页识别特征之前,不得放松现有文本规则。

可能的结构性特征(仅一个样本,尚不足以作为判定依据)

  • 整页只有一个占满屏幕的 WebView,且语义文本数近乎为零
  • 节点总数极少(11 vs 商品页 147–153)
  • Activity 与商品页相同(NewPageActivity),不能用作区分依据

后续所需对照样本

在把上述结构特征用于安全判定之前,至少还需确认不会误报或漏报:

待采页面 用途
拼多多首页 确认正常页不命中新特征
活动 / 优惠券等 WebView 页 确认其他 WebView 页不被误判为结算页
订单列表页 常用页面对照

在样本齐备前,本任务不产出判定规则变更建议;收紧或替换判定属于后续独立任务。

2026-07-31 页面对照样本汇总(主会话,UI dump)

在同一设备、同一版本上采集四类页面,用于判断能否以结构特征替代文本关键词判定。

页面 Activity 节点数 非空文本 WebView order_checkout paymentMarkers
首页 MainFrameActivity 227 48 0 未命中 无
我的优惠券 NewPageActivity 457 205 2(占满屏) 未命中 无
商品规格弹层(误报) NewPageActivity 147–153 多 0 未命中 2 条
订单确认/去支付 NewPageActivity 11 0 1(占满屏) 未命中 0

决定性结论:WebView 本身不阻断无障碍

优惠券页同样是占满屏的 WebView,其内容 205 条文本全部可读;而结算页的 WebView 一条都读不到。这排除了「所有 WebView 内容都不可读」的可能——结算页读不到内容是 该页面特有性质,很可能是拼多多对支付类页面做了无障碍防护。

因此「存在占满屏 WebView 且语义文本数近乎为零」在四个样本中唯有结算页命中, 具备作为主判据的区分度。误报的商品页文本丰富,天然不命中。

仍需处理的风险:加载中的普通页面

任何普通页面在刚打开、WebView 尚未渲染时都可能短暂呈现「WebView + 零文本」。若单次 快照即判定,会把加载瞬间误判为结算页——只是把误报从商品页搬到了加载瞬间。

因此新判据必须要求连续多次快照稳定命中才成立,参照既有 unknownPageLimit (20 次 × 200ms)的模式,不得单次命中即停。

未采集到的样本及原因

「限时秒杀」与「我的订单」两页无法采集:两者均有持续动画(倒计时、轮播), uiautomator dump 报 ERROR: could not get idle state,永远等不到静止状态。

需要说明的是,该限制只影响本次取样手段,不影响 Roubao:uiautomator 必须等界面 idle 才能快照,而 Roubao 的无障碍服务直接读取节点树,不受动画影响。

缺失这两个样本不阻塞主判据设计——两页都含大量文本,不可能命中「WebView + 零文本」。

附带发现:订单列表可能长期被自身安全规则阻挡

订单列表上的待支付订单会显示「立即支付」,在现行文本规则下必然触发 PAYMENT_BOUNDARY。而业务规则 22 要求「网络超时或页面不确定时必须先对账」,对账 正需进入订单列表。该路径可能一直被自身安全判定挡住。本次未能采样验证,需另行 确认,不在本任务范围。