同步来源:
docs/tasks/T-262.md· commitafc651f75a3a
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 既有诊断键的顺序、名称与取值逐字节不变, 新键一律追加在末尾。
方案
- 在分类得出
PAYMENT_BOUNDARY处额外产出归因数据,随PinduoduoUiSnapshot暴露; 判定表达式本身不动。 - 归因传递到诊断快照,确保安全停止发生在任意工作流步骤时都能带出。
CandidateSearchDiagnostics追加五个字段与auditSuffix()输出段。- 单测覆盖:仅
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。
五个目标全部通过。
偏离任务文档之处及理由
PaymentBoundaryAttribution定义在workflow包而非pinduoduo包。任务文档 只写“随PinduoduoUiSnapshot暴露”,未指定归属包。既有依赖方向是pinduoduo依赖workflow(如SafetyStopReason),若把该类型放进pinduoduo包会让workflow.AutomationResult.Blocked/WorkflowReport反向依赖pinduoduo,与既有架构方向相反。放进workflow包可保持单向依赖, 且PinduoduoUiSnapshot正常 import 使用,不影响契约。payMarkerHits的口径取“命中任一标记的可见文本条数”(与既有containsAny的判定口径一致),而非“首个命中标记专属的文本条数”。任务 文档原文“命中文本标记的可见文本条数”存在两种读法;选择前者是因为它更能 反映误判噪声的规模(验收点“多条文本命中时的计数”未强制要求按单一标记 过滤),且与containsAny本身的判定语义直接对应,便于人工核对。- 归因只在
PinduoduoPageClassifier.classify()自身判定得出PAYMENT_BOUNDARY时产出;PinduoduoCandidateAutomation.kt内部另有几处手工构造的AutomationResult.Blocked(SafetyStopReason.PAYMENT_BOUNDARY)(例如规格页 跳转订单确认后returnFromOrderConfirmation()失败),这些不经过一次新的classify()调用产出页面归因,因此保持默认值。这些路径不在“真机任务卡在pdd_open_app”这一故障范围内,且任务文档的措辞(“当分类结果为PAYMENT_BOUNDARY时”)本身即指向classify()的输出,未视为需要修改的 范围。 - 未改动
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
被否定的两个收紧方向
- 要求
payCheckoutRoot命中——不可行。resourceId == "order_checkout"在真实 结算页上同样未命中,该常量在当前拼多多版本上似乎已不适用。 - 要求
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 要求「网络超时或页面不确定时必须先对账」,对账
正需进入订单列表。该路径可能一直被自身安全判定挡住。本次未能采样验证,需另行
确认,不在本任务范围。