同步来源:
docs/tasks/T-263.md· commitafc651f75a3a
id: T-263 title: 以页面结构判定支付边界并按页面类型收敛文本规则 phase: 2 deps:
- T-262 status: DOING created: 2026-07-31 context_ref: 0c2e423 work_branch: null write_paths:
- docs/tasks/T-263.md
- docs/current-state.md
- android-buyer/app/src/main/**
- android-buyer/app/src/test/**
问题 / 背景
T-262 采集的四个真机样本证明:现行「见到支付关键词即停」的判定,在误报和漏报两端 同时失效。
| 页面 | Activity | 节点数 | 非空文本 | WebView | order_checkout |
paymentMarkers |
|---|---|---|---|---|---|---|
| 首页 | MainFrameActivity |
227 | 48 | 0 | 未命中 | 无 |
| 我的优惠券 | NewPageActivity |
457 | 205 | 2(占满屏) | 未命中 | 无 |
| 商品规格弹层 | NewPageActivity |
147–153 | 多 | 0 | 未命中 | 2 条 → 误报 |
| 订单确认/去支付 | NewPageActivity |
11 | 0 | 1(占满屏) | 未命中 | 0 → 漏报 |
两端同时失效
误报:商品规格弹层上的「使用#微信支付,更换先用后付可0元下单」(促销横幅)和 「提交订单 ¥14.9」(底部 CTA)被子串匹配命中。这是正常购物页面,自动化必须能在 上面读取规格,却被拦在第一步,导致连续多次真机任务三轮全败。
漏报:真实结算页一条可见文本都没有,order_checkout 也未命中。现行规则
完全无法识别它——之所以从未出事,是因为自动化根本走不到那一步。
根本原因:混淆了「在支付页上」与「看得见通往支付的按钮」
商品规格弹层上本来就有结算入口按钮,这是正常的。自动化需要停止的是已经身处支付 页面,而不是看见了支付相关字样。文本关键词无法区分这两者。
决定性证据:WebView 本身不阻断无障碍
优惠券页同为占满屏 WebView,其 205 条文本全部可读;结算页的 WebView 一条读不 到。因此「结算页读不到内容」是该页面特有性质(很可能是支付页的无障碍防护), 而非 WebView 的普遍限制。
据此,「存在占满屏 WebView 且语义文本数近乎为零」在四个样本中唯有结算页命中。
关联需求与交互
- 功能:F-005、F-007
- 用户故事:US-004、US-006
- 交互:不新增采购操作
- 架构/API:沿用 T-262 归因诊断,后端零改动
行为契约
一、总原则:只增强,不削弱
新判据是追加,不是替换。三条判据任一成立即判 PAYMENT_BOUNDARY:
结构判据(新) 或 hasCheckoutRoot(保留) 或 文本判据(收敛后保留)
paymentMarkers 列表内容与顺序不得删改。hasCheckoutRoot 规则不得删改。
二、结构判据(新增主判据)
同时满足以下全部条件时判定为支付边界:
- 前台包名为拼多多;
- 存在占满屏的
WebView(宽高各不小于屏幕的 90%); - 可见语义文本数不超过阈值(常量定义,初值
2,覆盖极少量装饰性文本); - 连续
PAYMENT_STRUCTURE_CONFIRMATIONS次快照均满足上述条件,初值3。
第 4 条是硬要求。任何普通页面在 WebView 尚未渲染时都会短暂呈现「WebView + 零文本」,
单次命中即判定会把页面加载瞬间误判为结算页。参照既有 unknownPageLimit
(20 次 × 200 毫秒)的模式,不得单次命中即停。
连续计数在页面结构不再满足条件时立即清零,不得跨页面累积。
三、文本判据按页面类型收敛
仅当页面未被正面识别为已知安全页面类型时,文本关键词命中才强制停止。
- 页面已被分类器正面识别为商品详情、规格弹层、搜索结果、图片搜索结果或首页时, 文本命中不再强制停止,但必须完整记录(见第四节)。
- 页面为
UNKNOWN或任何未正面识别的类型时,文本命中维持现有强制停止行为。
依据:在已确认的正常购物页面上出现支付字样属预期现象(促销横幅、结算入口按钮); 在无法识别的页面上出现支付字样则是危险信号,必须保守处理。
登录、验证码、风控三类安全停止的判定与优先级完全不变,不受本节影响。
四、可审计:记录被放行的文本命中
新增诊断计数,追加在 T-262 五个键之后:
payStructureHit=<0|1> payStructureStreak=<n> payTextSuppressed=<n>
| 键 | 含义 |
|---|---|
payStructureHit |
本轮是否由结构判据触发过支付边界 |
payStructureStreak |
结构条件连续命中的最大次数 |
payTextSuppressed |
因页面被正面识别而未强制停止的文本命中次数 |
payTextSuppressed 是本次变更的安全审计口径:它记录了在旧规则下会停止、新规则下
放行的次数,用于事后评估变更影响。若真机上该值异常偏高,说明收敛过度,需回头收紧。
沿用既有脱敏口径:只记计数,不记页面文本或商品信息。
五、回归测试必须基于真实样本
必须用 T-262 记录的四个真机样本构造测试夹具,逐一断言判定结果:
| 样本 | 期望 |
|---|---|
| 首页(227 节点 / 48 文本 / 0 WebView / 无命中) | 不判支付边界 |
| 优惠券页(457 / 205 / 2 WebView / 无命中) | 不判支付边界 |
| 商品规格弹层(147 / 多文本 / 0 WebView / 2 条命中) | 不判支付边界(本任务修复目标) |
| 结算页(11 / 0 文本 / 1 占满屏 WebView / 无命中) | 判支付边界(连续 3 次后) |
另需覆盖:结算页结构仅命中 1 次和 2 次时不得判定;hasCheckoutRoot 单独命中时
仍判定;UNKNOWN 页面文本命中时仍判定;登录/验证码/风控优先级不变。
六、既有行为不变
- 不改登录、验证码、风控的检测与优先级。
- 不改
paymentMarkers内容与顺序、containsAny实现、hasCheckoutRoot规则。 - 不改 T-262 五个归因键的顺序、名称与取值,也不改 T-254/T-255/T-257/T-260/T-261 既有诊断键。
- 不改候选识别、签名算法、跨轮去重、SKU 硬约束、价格解析、三轮预算或成功判定式。
- 不改下单 dry-run、订单提交、授权价格策略或支付边界之后的处置逻辑。
方案
PinduoduoPageClassifier增加结构判据计算(占满屏 WebView + 文本数阈值), 阈值与连续次数以具名常量定义。- 连续确认状态由调用方持有并传入,分类器保持无副作用的纯函数特性;结构不满足时清零。
- 文本判据增加「页面已正面识别」的抑制条件,抑制时记录计数而非停止。
- 三个新诊断键并入
CandidateSearchDiagnostics,追加在 T-262 键之后。 - 用四个真机样本构造测试夹具并逐一断言,另覆盖连续次数边界与各优先级场景。
验收要点
- 四个真机样本的判定结果与上表逐一相符。
- 结算页结构连续命中不足 3 次时不判定,达到 3 次后判定。
- 结构条件中断后连续计数清零,不跨页面累积。
hasCheckoutRoot单独命中仍判定支付边界。UNKNOWN页面上文本命中仍强制停止。- 登录、验证码、风控的判定与优先级未变,有回归测试。
paymentMarkers内容与顺序未删改。- 三个新诊断键出现且不含页面文本或商品信息。
- T-254/T-255/T-257/T-260/T-261/T-262 既有诊断键顺序、名称、取值不变。
- Android Debug/Release 单测、
lintDebug、assembleDebug、assembleRelease全部通过。 - 真机验证:同一商品重跑采购任务不再被拦在
pdd_open_app,能进入候选采集; 并从 Admin 读取payTextSuppressed确认放行次数在合理范围。
边界
- 不删除任何现有判据:
paymentMarkers、hasCheckoutRoot全部保留。 - 不改登录、验证码、风控的检测逻辑与优先级。
- 不改支付边界判定成立之后的处置逻辑(立即停止、不清理、不点击)。
- 不改 T-261 清理逻辑与
isSafeForCleanup。 - 不改 T-256 提交门槛、T-258 后端校验、T-259 安全停止补报、T-260 节奏配置。
- 不改候选识别、价格解析或下单相关任何逻辑。
- 不改后端代码、数据库、API 契约或 Admin 模板。
- 不处理订单列表页可能被自身规则阻挡的问题(见 T-262 附带发现),另行开任务。
已知局限
真实结算页样本只有一个。 结构判据基于该单一样本设计,可能存在盲区:
- 若某些拼多多版本或路径使用原生结算页(有可读文本),结构判据不会命中。此时
仍依赖
hasCheckoutRoot与UNKNOWN页面上的文本判据兜底。 order_checkout这个 resourceId 在本次采集的结算页上并未命中,其在当前版本 上的有效性存疑,但按「只增强不削弱」原则仍予保留。
因此本任务不宣称覆盖全部支付页面形态,只解决已证实的误报并补上已证实的漏报。 后续若发现新的结算页形态,应继续追加判据而非放松现有判据。
执行记录
开工后记录修改、命令、结果、环境、决策和 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追加三个字段——structureHit(本次PAYMENT_BOUNDARY是否由结构判据触发)、structureConfirmations(产出本次 归因时的结构连续确认次数)、textSuppressedCount(本次判定时因页面被正面 识别而放行的命中文本条数);均为末尾追加、默认值false/0/0,init增 加对应非负校验。既有五个字段与其含义/顺序未动。android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifier.kt:- 新增具名常量
PAYMENT_STRUCTURE_TEXT_THRESHOLD = 2、PAYMENT_STRUCTURE_CONFIRMATIONS = 3、私有FULL_SCREEN_WEBVIEW_RATIO = 0.9。 classify()追加末尾可选参数priorStructureConfirmations: Int = 0(调用方 持有并传入的连续确认状态,分类器本身仍是无副作用纯函数);新增私有函数hasFullScreenWebViewWithFewTexts():屏幕范围取当前可见元素包围盒(根容器 通常等于屏幕大小,避免新增屏幕尺寸入参改动既有调用方签名),WebView 宽高 各不小于该包围盒的 90% 且可见非空文本数不超过阈值时判定单次快照满足结构 条件;structureConfirmations = if (met) prior+1 else 0,structureHit = structureConfirmations >= PAYMENT_STRUCTURE_CONFIRMATIONS。- 将原来紧跟在
visibleTexts之后计算的page判定块整体上移到safetyStop判定之前(page的计算只依赖hasCheckoutRoot/normalized/visibleElements等既有局部变量,与safetyStop无关,纯粹重排不改变page本身的判定逻辑),使文本判据可以引用已确定的page值。 safetyStop表达式改为structureHit || hasCheckoutRoot || (textMarkerHit && !textMarkerSuppressed), 其中textMarkerHit = visibleTexts.containsAny(paymentMarkers)(containsAny实现、paymentMarkers内容与顺序、hasCheckoutRoot规则逐字节未动);textMarkerSuppressed = textMarkerHit && page in {PRODUCT_DETAIL, SPECIFICATION_PANEL, SEARCH_RESULTS, IMAGE_SEARCH_RESULTS, HOME}。登录/ 验证码/风控三类判定仍在同一个when的前三个分支,优先级和触发逻辑未动; 结构/文本判据只影响LoginBlocker.NONE/UNKNOWN分支内部。PinduoduoUiSnapshot追加两个字段:structureConfirmations: Int = 0(本 次调用后的连续确认计数,供调用方在下一次调用时回传)、textMarkerSuppressed: Boolean = false(本次快照是否发生文本命中放行, 与PAYMENT_BOUNDARY是否触发无关,始终计算,供调用方做会话级审计)。computePaymentBoundaryAttribution()追加三个入参并透传进PaymentBoundaryAttribution的三个新字段,其余标记扫描逻辑未动。
- 新增具名常量
android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt: 新增私有实例字段pinduoduoStructureConfirmations;classifyPinduoduoRoot()(全仓库唯一的真机快照来源,其余snapshotPinduoduoUi()、clickPinduoduoSearchEntry()等数十处调用均经此函数)将该字段作为priorStructureConfirmations传入classify(),再用返回快照的structureConfirmations回写该字段,从而让结构判据的连续确认覆盖所有轮询 循环与一次性动作调用;snapshotPinduoduoUi()在前台包名不是拼多多的分支 显式清零该字段,确保离开拼多多前台或结构条件中断时立即清零、不跨页面累积。android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateAutomation.kt: 新增两个会话内累计字段maxStructureConfirmations、textSuppressedCount(reset()一并清零);新增私有observeSnapshot()作为本类唯一的快照读取 入口(原有约 11 处driver.snapshot()全部替换为该函数),每次快照后更新 两个累计字段的最大值/计数,不改变driver.snapshot()的返回值或任何既有 判定/重试/清理行为;diagnostics()追加payStructureStreak = maxStructureConfirmations、payTextSuppressed = textSuppressedCount。android-buyer/app/src/main/java/com/roubao/autopilot/procurement/CandidateSearchDiagnostics.kt: 末尾追加payStructureHit/payStructureStreak/payTextSuppressed三个字段 (默认值0)及对应require校验;withPaymentBoundaryAttribution()追加 设置payStructureHit(直接取自本次归因),payStructureStreak改为maxOf(既有值, 归因中的 structureConfirmations)(取大合并,不覆盖候选自动化 自身轮询中已观察到的更高计数);payTextSuppressed不在此方法中设置, 完全由调用方(PinduoduoCandidateAutomation.diagnostics())持续累积后写入, 避免被本方法在未触发PAYMENT_BOUNDARY的常见场景下清零或覆盖(原因见下方 「偏离与决策」第 2 条)。既有五个键与auditSuffix()中它们之前的所有键均 逐字节未动,三个新键追加在payExact=之后。- 测试:
PinduoduoPageClassifierTest.kt新增 9 个用例:基于 T-262 四个真机样本的 首页/优惠券页/规格弹层/结算页判定(规格弹层用例直接复现 T-262 记录的误报 文案,断言不再判定且paymentBoundaryAttribution保持默认值)、结算页 结构连续命中 1/2 次不判定、第 3 次判定、结构中断后清零再重新计数、hasCheckoutRoot单独命中且结构计数为 0 时仍判定、UNKNOWN页面文本命中 仍强制停止、登录/验证码/风控在已有结构连续计数时优先级不变。CandidateSearchDiagnosticsTest.kt更新 6 处既有精确匹配断言以包含三个新 增尾部默认键,新增 4 个用例覆盖非法计数拒绝、结构命中归因追加、payStructureStreak取大合并两种方向、payTextSuppressed不被withPaymentBoundaryAttribution清零。PinduoduoCandidateAutomationTest.kt的FakeCandidateDriver新增structureConfirmations/textMarkerSuppressed两个可选构造参数并透传进 快照;新增一个用例验证候选自动化经observeSnapshot()累积的payStructureStreak/payTextSuppressed出现在diagnostics()中,且reset()后归零。
命令与结果
cmd.exe /c "cd /d D:\chengma\cmroubao\android-buyer && gradlew.bat testDebugUnitTest --no-daemon --console=plain"
BUILD SUCCESSFUL;Debug 单测 313 项,0 失败、0 错误(较 T-262 的 299 项新增 14 项)。
cmd.exe /c "cd /d D:\chengma\cmroubao\android-buyer && gradlew.bat testReleaseUnitTest --no-daemon --console=plain"
BUILD SUCCESSFUL;Release 单测 313 项,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。
五个目标全部通过。
四个真机样本的断言结果(单测,非真机)
| 样本 | 断言 | 结果 |
|---|---|---|
| 首页(无 WebView、46+2 条文本、无标记命中) | page=HOME,safetyStopReason=null,structureConfirmations=0 |
通过 |
| 优惠券页(2 个占满屏 WebView、205 条文本、无标记命中) | safetyStopReason=null,structureConfirmations=0(文本数量守住占满屏 WebView 不误判) |
通过 |
| 商品规格弹层(0 WebView、命中“提交订单”“微信支付”2 条) | page=SPECIFICATION_PANEL,safetyStopReason=null(本任务修复目标),paymentBoundaryAttribution 为默认值,textMarkerSuppressed=true |
通过 |
| 结算页(1 个占满屏 WebView、0 条文本) | 连续 1/2 次快照 safetyStopReason=null;第 3 次 safetyStopReason=PAYMENT_BOUNDARY,paymentBoundaryAttribution.structureHit=true、structureConfirmations=3、checkoutRootHit=false、markerIndex=-1 |
通过 |
另覆盖:结构条件中断后连续计数清零、hasCheckoutRoot 单独命中仍判定、
UNKNOWN 页面文本命中仍强制停止、登录/验证码/风控在已有结构连续计数时优先级
不变——均通过。
偏离任务文档之处及理由
- 结构判据的“屏幕范围”不是新增入参,而是取当前可见元素包围盒。 任务文档
要求“宽高各不小于屏幕的 90%”,但
classify()现有签名不含屏幕尺寸,PinduoduoUiElement也不含屏幕尺寸字段。改为在可见元素集合内取boundsLeft/Top/Right/Bottom的包围盒作为屏幕范围的近似——真机根容器 (FrameLayout)的 bounds 本就等于屏幕大小(T-262 结算页 dump 亦如此:FrameLayout bounds=[0,0][1080,2376]),且无障碍节点树采集时根节点必然 被收录(BuyerAccessibilityService.collectNodes()用 BFS 且根节点最先入 队)。这样既满足“宽高各不小于屏幕的 90%”的判定意图,又不改变classify()/PinduoduoUiElement对既有调用方的签名,风险更低。 payTextSuppressed的口径改为调用方持续累积,而非像 T-262 五个键那样只 在PAYMENT_BOUNDARY触发时产出。 这是本任务中分量最重的一个偏离, 理由:文本判据被放行(textMarkerSuppressed=true)几乎总是意味着PAYMENT_BOUNDARY没有触发(正是本任务要修复的常见场景——自动化在 规格弹层等页面继续正常浏览)。若沿用 T-262 的“只在触发时产出”模式,payTextSuppressed在绝大多数真实场景下都会读到默认值0,验收要点 “若真机上该值异常偏高,说明收敛过度”将无法被观察到,审计口径名存实亡。 经推演证实:hasCheckoutRoot恒定强制page=ORDER_CONFIRMATION(不在可 抑制页面集合内),结构判据要求非空文本数<=2而可抑制的五类页面 (HOME/SEARCH_RESULTS/IMAGE_SEARCH_RESULTS/PRODUCT_DETAIL/ SPECIFICATION_PANEL)现有识别规则均需要>=2个独立于标记文本之外的 语义文本,二者在现有启发式下几乎互斥——即“归因触发时刻”与“文本判据被 放行”事实上不会同时发生。因此改为:PinduoduoUiSnapshot新增textMarkerSuppressed(与structureConfirmations一样,每次快照都计算, 不依赖PAYMENT_BOUNDARY是否触发);PinduoduoCandidateAutomation通过 新增的唯一快照入口observeSnapshot()在自身轮询过程中持续累积textSuppressedCount(会话内计数,reset()清零),随diagnostics()一并暴露;CandidateSearchDiagnostics.withPaymentBoundaryAttribution()不再覆盖或清零该键。 已知局限:该累积目前只覆盖pdd_browse_candidates步骤(候选浏览, 规格弹层重复出现的主要场所),不覆盖PinduoduoSearchAutomation/PinduoduoImageSearchAutomation所在的pdd_open_app/pdd_enter_query/pdd_submit_query等更早步骤(这两个类当前没有诊断累积基础设施,为其新增 等价机制需要更大改动面)。原故障恰好是“在pdd_open_app单次命中即误判”, 修复后该步骤会正常通过并进入候选浏览,绝大多数后续文本放行会发生在pdd_browse_candidates内并被正确计数;但如果放行只发生在更早步骤且之后 再未出现,payTextSuppressed会读到偏低的值。这是本次为控制改动面而接受 的范围边界,如需覆盖全流程应另开任务为PinduoduoSearchAutomation/PinduoduoImageSearchAutomation补充等价的 累积与暴露机制。payStructureStreak由withPaymentBoundaryAttribution()与PinduoduoCandidateAutomation.diagnostics()共同产出,取二者较大值,而非 单一来源。 归因中的structureConfirmations只反映触发PAYMENT_BOUNDARY那一刻的读数(可能来自pdd_open_app阶段,PinduoduoCandidateAutomation完全不知情);候选自动化自身累积的maxStructureConfirmations只覆盖它 自己轮询到的快照。二者是互补而非互斥的信息源,取大合并可以同时覆盖“结构 判据在更早步骤触发”与“候选浏览阶段观察到接近阈值但未触发”两类场景,且不 丢失任何一方已经观察到的信息。- 文本判据可抑制的页面集合按任务文档字面五类严格取值
(
PRODUCT_DETAIL、SPECIFICATION_PANEL、SEARCH_RESULTS、IMAGE_SEARCH_RESULTS、HOME),未包含SEARCH_RESULTS_OTHER_QUERY、IMAGE_SEARCH、IMAGE_SEARCH_RETRY_DIALOG、ORDER_LIST等其余非UNKNOWN类型。任务文档原文列举的是“商品详情、规格弹层、搜索结果、图片 搜索结果或首页”,理解为这五个具名类型;其余非正面列举的类型按“任何未正面 识别的页面”对待,维持强制停止,偏保守方向,符合“遇到不确定页面停止”的 业务硬边界。 PinduoduoOrderDryRunAutomation/AndroidPinduoduoOrderDryRunDriver(T-217 订单 dry-run)一个字节未改,但由于二者最终都经同一个BuyerAccessibilityService.classifyPinduoduoRoot()获取快照,新增的结构 判据在共享的classify()层面对它们同样生效:若 dry-run 流程在expectingOrderConfirmation变为true之前意外连续 3 次观察到“占满屏 WebView + 语义文本 ≤ 2”的结构(现有PinduoduoCollapsedCheckoutPolicy本就是为识别这一结构而设计,只是原先只在该布尔量被显式置位后才转换为PAYMENT_BOUNDARY),classify()会独立触发PAYMENT_BOUNDARY,比 dry-run 驱动原有的门控逻辑更早/更广地停止。这是共享判据修复误报/漏报后 的必然连带效应,方向上只会让安全停止更早/更确定,不会绕过或削弱既有 dry-run 停止点,且不修改 dry-run 文件本身的任何代码,因此判断为符合 “只增强不削弱”原则、未违反“不改下单 dry-run”的边界,但作为可能影响未来 dry-run 行为的连带效应在此明确记录,供后续如遇异常提前停止时排查参考。
未完成项(需人工真机验证,state 保持 DOING)
- 真机复现同一商品重跑采购任务,确认不再被拦在
pdd_open_app且能进入候选 采集。 - 从 Admin 读取
payTextSuppressed,结合上方「偏离与决策」第 2 条的范围边界 确认放行次数在合理范围;若异常偏高需回头收紧文本判据的可抑制页面集合或 结构判据阈值。 - 若条件允许,在真实结算页上验证连续 3 次快照后确实触发
PAYMENT_BOUNDARY且payStructureHit=1。