同步来源:
docs/tasks/T-264.md· commitafc651f75a3a
id: T-264 title: 停止以购买按钮作为规格入口并识别原生订单确认页 phase: 2 deps:
- T-263 status: DOING created: 2026-07-31 context_ref: 3b04fb0 work_branch: null write_paths:
- docs/tasks/T-264.md
- docs/current-state.md
- android-buyer/app/src/main/**
- android-buyer/app/src/test/**
问题 / 背景
T-263 上机后,第 2 轮首次成功进入候选采集(cards=3 distinct=2 attempts=2,
payTextSuppressed=1 证明文本抑制生效)。但随后自动化走进了真实的订单确认页并卡死
在那里,第 3 轮开 App 即被拦。
设备取证显示当时页面内容为:收件人姓名、掩码手机号、详细收货地址、
已选:#心相印 新品【4包 400抽】…、数量 1、已包邮、仅1次¥0.01、¥8.01,以及
使用#微信支付,更换先用后付可0元下单。这是一个原生订单确认页(147 节点、
26 条文本、0 个 WebView),商品是纸巾——图片搜索结果中混入的非服装品类。
因果链
第一处缺口:找不到规格入口时会去点购买按钮
BuyerAccessibilityService.openPinduoduoSpecifications():
val target = directTargets.singleOrNull() // 选择规格/颜色分类/已选:…
?: uniqueClickableTargets(... endsWith("免拼购买")).singleOrNull() // 兜底点购买
?: return@withPinduoduoRoot false
纸巾没有颜色/尺码规格,详情页缺少安全入口文本(或存在多个导致 singleOrNull() 为
null),于是落入兜底分支点击「免拼购买」。正常商品点击后会先弹出规格面板,但该商品
为 仅1次¥0.01 促销且启用先用后付,点击后直接跳转到订单确认页,跳过了规格面板。
第二处缺口:该订单确认页识别不出来
PinduoduoPageClassifier 的 hasOrderConfirmation 三个判据全部落空:
| 判据 | 实际 |
|---|---|
hasCheckoutRoot(order_checkout) |
未命中 |
| 存在文本恰为「确认订单」 | 页面无此文本 |
| 含「收货地址」+ 两个支付方式标记 + 「实付款¥」前缀 | 只有地址内容而无「收货地址」标签;价格为「¥8.01」而非「实付款¥…」 |
因此 page != ORDER_CONFIRMATION。而 awaitSpecificationEntry() 的判断顺序是:
if (page == SPECIFICATION_PANEL) return PANEL
if (page == ORDER_CONFIRMATION) return ORDER_CONFIRMATION // 走不到
safetyResult(snapshot)?.let { return FAILED(it) } // 落到这里
页面认不出来,既有的 returnFromOrderConfirmation() 退出路径根本没有机会执行——
这正是「卡在那里出不来」的直接原因,也解释了 orderConfirmRedirect=0。
最终由「微信支付」促销横幅触发 PAYMENT_BOUNDARY 拦下。当前防线只剩这一条促销
文案,而该文案随时可能变更。
风险评估
自动化在非预期路径下到达了订单确认页,页面上是 仅1次¥0.01 促销价并启用先用后付。
虽然从未点击提交、安全边界未被突破,但距离产生一笔真实订单只差一次点击,且
拦截依赖的是一条营销文案而非稳定特征。
关联需求与交互
- 功能:F-005、F-007
- 用户故事:US-004、US-006
- 交互:不新增采购操作
- 架构/API:沿用既有诊断机制,后端零改动
行为契约
一、不得以购买类控件作为规格入口
移除 openPinduoduoSpecifications() 中以「免拼购买」为兜底点击目标的分支。
找不到安全规格入口(SAFE_ENTRY_TEXTS / SAFE_ENTRY_PREFIXES)时直接返回 false,
由既有 specOpenFailed 路径跳过该候选,不得点击任何购买、下单、拼单、支付语义的
控件。
新增诊断计数 specEntryAbsent,记录因缺少安全入口而放弃的候选数,用于评估本次收紧
造成的覆盖损失。若真机上该值偏高,说明有相当比例的正常商品依赖该兜底,需另开任务以
更安全的方式补回,但不得退回点击购买按钮。
二、识别原生订单确认页
hasOrderConfirmation 追加一个判据(现有三个判据一律保留):
同时满足即判定为订单确认页:
- 存在掩码手机号形态的文本(如
\d{3}\*{4}\d{4}); - 存在以
已选开头的文本; - 存在数量或运费语义(如纯数字数量、
已包邮、运费)。
依据:掩码手机号仅出现在涉及收件信息的页面,商品详情页不会出现;三者同时出现足以与 商品详情页、规格弹层区分。
隐私硬约束:仅做形态匹配用于判定,绝不记录收件人姓名、手机号(含掩码 形式)、详细地址或任何原文,不得进入诊断 message、事件、日志或截图元数据。依据业务 规则 29。
识别成功后,既有 awaitSpecificationEntry() 的 ORDER_CONFIRMATION 分支即可生效,
调用既有 returnFromOrderConfirmation() 安全退出,解决「卡住出不来」。
三、记录支付边界触发时的页面类型
T-262 的归因只记了命中哪个标记,未记当时页面类型,导致本次必须手动 dump 才能定位。
PaymentBoundaryAttribution 追加页面类型,诊断 message 追加:
payPage=<PinduoduoPage 枚举名>
取值为本项目自定义枚举的名称(ASCII,如 UNKNOWN、PRODUCT_DETAIL、
ORDER_CONFIRMATION),不是页面内容,因此可以记录。未触发支付边界时记 NONE。
四、安全边界一律不放松
- 不得削弱
PAYMENT_BOUNDARY的任何判据:T-263 结构判据、hasCheckoutRoot、paymentMarkers文本判据及其抑制规则全部保持现状。 - 不得点击提交订单、立即支付、免密支付、先用后付或任何扣款语义控件。
- 退出订单确认页只允许使用既有
returnFromOrderConfirmation(),且沿用其现有的有界 次数与失败即Blocked(PAYMENT_BOUNDARY)行为。 - 登录、验证码、风控三类安全停止的判定与优先级完全不变。
五、既有诊断不变
T-254/T-255/T-257/T-260/T-261/T-262/T-263 既有诊断键的顺序、名称与取值逐字节不变, 新键一律追加在末尾。
方案
openPinduoduoSpecifications()删除「免拼购买」兜底分支,缺少安全入口时返回 false。- 候选自动化统计
specEntryAbsent,并入诊断快照与 message 尾部。 PinduoduoPageClassifier为hasOrderConfirmation追加原生结算页判据;掩码手机号 等只做形态匹配,不留存原文。PaymentBoundaryAttribution追加页面类型字段,诊断追加payPage键。- 单测覆盖:无安全入口时不点击任何购买控件且计数递增;原生结算页样本被识别为
ORDER_CONFIRMATION;商品详情页与规格弹层不得被新判据误判;掩码号码不出现在 任何诊断输出中;payPage取值正确且未触发时为NONE;既有支付边界判据未削弱。
验收要点
- 详情页无安全规格入口时返回 false,且不点击任何购买/下单/支付语义控件,有 单测证明。
specEntryAbsent计数正确并出现在诊断 message。- 用真机取证的原生结算页特征构造夹具,被识别为
ORDER_CONFIRMATION。 - 商品详情页、规格弹层、搜索结果页不被新判据误判为订单确认页,有回归测试。
- 掩码手机号、收件人、地址不出现在诊断 message、事件或日志中,有单测证明。
payPage出现在诊断 message,未触发支付边界时为NONE。- T-263 结构判据、
hasCheckoutRoot、paymentMarkers及抑制规则未被削弱,有回归 测试。 - 登录、验证码、风控判定与优先级未变。
- 既有诊断键顺序、名称、取值不变。
- Android Debug/Release 单测、
lintDebug、assembleDebug、assembleRelease全部通过。 - 真机验证:同一任务重跑时,遇到无颜色/尺码规格的商品不再进入订单确认页;
若仍进入,应能被识别并自动退出而非卡死;从 Admin 读取
specEntryAbsent与payPage记入执行记录。
边界
- 不放松任何支付边界判据,只增加订单确认页的识别能力。
- 不点击提交订单、立即支付、先用后付或任何扣款语义控件。
- 不改
returnFromOrderConfirmation()的既有实现与有界次数。 - 不改登录、验证码、风控的检测逻辑与优先级。
- 不改 T-261 清理逻辑、T-262 归因既有五键、T-263 结构判据与抑制名单。
- 不改 T-256 提交门槛、T-258 后端校验、T-259 安全停止补报、T-260 节奏配置。
- 不改候选卡片识别、签名算法、跨轮去重、价格解析或三轮预算。
- 不改后端代码、数据库、API 契约或 Admin 模板。
- 不处理「图片搜索结果混入非目标品类」本身——那需要在候选筛选阶段引入品类判断, 另行开任务。
- 不处理第 1 轮
containers=550却 0 卡片的识别问题,另行开任务。
执行记录
开工后记录修改、命令、结果、环境、决策和 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/pinduoduo/PinduoduoCandidateModels.kt: 新增PinduoduoSpecificationOpenOutcome枚举(OPENED/ENTRY_ABSENT/UNAVAILABLE),PinduoduoCandidateDriver.openSpecifications()的返回类型 由Boolean改为该枚举,用于向候选自动化区分「找不到唯一安全入口」与其它 不可用情形。仅此接口改动;PinduoduoOrderDryRunDriver(下单 dry-run 专用 的独立接口,定义于PinduoduoOrderDryRunAutomation.kt)保持openSpecifications(): Boolean不变,未受影响。android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt:openPinduoduoSpecifications()删除「找不到directTargets.singleOrNull()就点击以『免拼购买』结尾的兜底控件」分支(连同私有常量SPECIFICATION_PURCHASE_ENTRY),改为纯投影——新增openPinduoduoSpecificationsDetailed(): PinduoduoSpecificationOpenOutcome作为唯一的点击实现:未验证到商品详情页时返回UNAVAILABLE;directTargets.singleOrNull()为空(找不到唯一安全入口,含零个或多个两种 情形)时返回ENTRY_ABSENT,不再有任何兜底点击;点击成功返回OPENED,点击本身失败返回UNAVAILABLE。openPinduoduoSpecifications(): Boolean改为openPinduoduoSpecificationsDetailed() == PinduoduoSpecificationOpenOutcome.OPENED的布尔投影,供既有下单 dry-run 驱动(经BuyerAccessibilityBridge.openSpecifications())复用同一份点击 逻辑,不重复点击、不改变其行为。android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityBridge.kt: 新增openSpecificationsDetailed(): PinduoduoSpecificationOpenOutcome, 专供候选驱动使用;既有openSpecifications(): Boolean(下单 dry-run 驱动 使用)逐字节未动。android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoCandidateDriver.kt:openSpecifications()改为调用BuyerAccessibilityBridge.openSpecificationsDetailed()并保留既有的动作节奏停顿(actionPacer.pause()),返回值改为枚举。android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateAutomation.kt: 新增会话内累计字段specEntryAbsentCount(reset()一并清零);打开规格 处的if (!driver.openSpecifications())改为对三态枚举的when——OPENED正常继续;ENTRY_ABSENT与UNAVAILABLE都仍使specificationOpenFailedCount(既有specOpenFailed口径)递增并触发既有returnToResultsAfterUnsupportedCandidate()安全返回,ENTRY_ABSENT额外 使specEntryAbsentCount递增;diagnostics()追加specEntryAbsent = specEntryAbsentCount。除新增分支外,候选采集的其余判定 /重试/清理逻辑未动。android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifier.kt:hasOrderConfirmation追加第四个判据hasNativeOrderConfirmation(既有 三个判据逐字节未动,新判据用||追加):同时满足「存在掩码手机号形态 文本(\d{3}\*{4}\d{4},新增私有常量MASKED_PHONE_PATTERN)」「存在以已选开头的文本」「存在数量或运费语义(纯数字文本、已包邮、包含运费)」三者才成立;只产出布尔量,不留存任何原文。computePaymentBoundaryAttribution()追加入参page: PinduoduoPage, 写入PaymentBoundaryAttribution.page = page.name(本项目自定义枚举的 ASCII 名称,不是页面内容);classify()调用处相应传入已计算好的page局部变量,未改变page本身的判定逻辑(仅追加一次赋值读取,不重 排既有计算顺序之外的任何语句)。
android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowModels.kt:PaymentBoundaryAttribution追加字段page: String = "NONE"(末尾追加, 既有八个字段——含 T-263 追加的三个——顺序/含义未动),init增加PAGE_NAME_PATTERN = Regex("^[A-Z_]+$")校验。选择String而非直接引用PinduoduoPage类型,是为了保持既有的单向依赖方向(pinduoduo包依赖workflow包,若反过来会让workflow.AutomationResult.Blocked/WorkflowReport反向依赖pinduoduo,与 T-262 已记录的架构决策相反)。android-buyer/app/src/main/java/com/roubao/autopilot/procurement/CandidateSearchDiagnostics.kt: 末尾追加specEntryAbsent: Int = 0、payPage: String = "NONE"两个字段 (既有字段顺序/含义未动)及对应require校验(含PAGE_NAME_PATTERN);withPaymentBoundaryAttribution()追加payPage = attribution.page(逐次 覆盖,与payCheckoutRoot等字段一致,不同于payStructureStreak的取大 合并);auditSuffix()在既有payTextSuppressed=之后追加specEntryAbsent=、payPage=两个键,此前所有键的名称/顺序/取值逐字节 未动。- 测试:
PinduoduoPageClassifierTest.kt新增 9 个用例:真机取证的原生结算页 样本(掩码手机号199****5018+已选:摘要 + 数量/已包邮+ 微信支付 促销横幅)被识别为ORDER_CONFIRMATION且paymentBoundaryAttribution.page == "ORDER_CONFIRMATION";三个条件单独 缺失任一个都不触发(掩码手机号单独存在、已选摘要+数量但无掩码手机号); 商品详情页、规格弹层、搜索结果页在混入「已选:」摘要与纯数字文本但没有 掩码手机号时不被新判据误判为订单确认页(三个回归用例);归因toString()不含收件人姓名/掩码手机号/地址等测试用合成占位文本。PinduoduoSpecificationParserTest.kt新增 1 个用例:穷举「免拼购买」 「立即购买」「确认订单」「提交订单」「去支付」「立即支付」「下单」 「加入购物车」「去结算」等购买/下单/拼单/支付语义文本,逐一断言isSafeEntryText()均返回false(SAFE_ENTRY_TEXTS/SAFE_ENTRY_PREFIXES是纯 allowlist,不含任何交易语义词,这是移除 兜底分支后唯一的安全入口判据来源)。PinduoduoCandidateAutomationTest.kt:FakeCandidateDriver的openSpecifications()改为返回枚举,新增可选构造参数openSpecificationsEntryAbsent;既有「unsupported specification entry skips candidate safely」用例追加断言specEntryAbsent == 0(证明通用 不可用不会误计入该键);新增 1 个用例覆盖ENTRY_ABSENT路径——断言specificationOpenFailedCount/specEntryAbsent均为 1,且驱动侧唯一 发生的调用只有openSpecifications与returnToResults(PinduoduoCandidateDriver接口本身没有任何购买/下单/拼单/支付语义方法可 供调用,找不到安全入口时不可能触发此类点击),reset()后归零。CandidateSearchDiagnosticsTest.kt:更新 8 处既有精确匹配/endsWith断言以包含specEntryAbsent=0 payPage=NONE(或触发场景下的实际值)尾部 键;新增 4 个用例覆盖specEntryAbsent默认值与非负校验、payPage默认值与逐次覆盖(非取大合并)语义、payPage格式非法值(小写/空串) 拒绝。WorkflowRunnerTest.kt:既有「payment boundary attribution is carried through to the report」用例追加page = "ORDER_CONFIRMATION"构造参数与 对应断言;新增 2 个用例覆盖PaymentBoundaryAttribution.page默认值"NONE"与格式非法值(小写/空串)拒绝。
命令与结果
cmd.exe /c "cd /d D:\chengma\cmroubao\android-buyer && gradlew.bat testDebugUnitTest --no-daemon --console=plain"
BUILD SUCCESSFUL;Debug 单测 329 项,0 失败、0 错误(较 T-263 的 313 项新增 16 项)。
cmd.exe /c "cd /d D:\chengma\cmroubao\android-buyer && gradlew.bat testReleaseUnitTest --no-daemon --console=plain"
BUILD SUCCESSFUL;Release 单测 329 项,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。
五个目标全部通过。
调试过程中曾出现一次编译错误:return@withPinduoduoRoot 与其返回值分写在
两行时,Kotlin 把裸 return@label(换行后另起一句)解析为返回 Unit,导致
openPinduoduoSpecificationsDetailed() 类型不匹配。修正为将返回值与
return@withPinduoduoRoot 放在同一行后编译通过,不影响任何行为逻辑。
偏离任务文档之处及理由
specEntryAbsent通过修改PinduoduoCandidateDriver.openSpecifications()的返回类型(Boolean→ 三态枚举)实现,而非在BuyerAccessibilityService内部用一个额外的可变字段传递「上一次是否因缺入口失败」。 原本考虑用BuyerAccessibilityBridge.openSpecifications(): Boolean不变、另开一个 只读的isPinduoduoSpecificationEntryAbsent()供候选驱动在失败后追加查询 的方案,但那需要第二次节点树查询,且存在「两次查询之间页面已变化」的 理论竞态。改为让唯一的点击实现openPinduoduoSpecificationsDetailed()一次性产出三态结果,openPinduoduoSpecifications(): Boolean只是其布尔 投影,供下单 dry-run 驱动复用同一份点击逻辑而不重复点击——这是改动面 略大但更安全、无竞态的方案。PinduoduoOrderDryRunDriver是完全独立的 接口(不同文件、不同签名),未受此次接口改动影响。- 验收要点要求「必须写单测证明这条路径不点击任何此类控件」,但项目的
单测环境是纯 JVM(无 Robolectric、无 mockito,
build.gradle.kts里testImplementation只有 junit/coroutines-test/mockwebserver),无法在 单测里构造真实的AccessibilityNodeInfo来直接驱动BuyerAccessibilityService.openPinduoduoSpecifications()本身。 因此 证明分两层:(a)PinduoduoSpecificationParser.isSafeEntryText()是纯 Kotlin 函数、可直接单测,新增用例穷举购买/下单/拼单/支付语义文本 证明它们永远不会被判定为安全入口——这是移除兜底分支后唯一决定点击目标 的判据来源;(b)PinduoduoCandidateAutomation经FakeCandidateDriver完全可测,新增用例证明ENTRY_ABSENT时自动化只调用openSpecifications/returnToResults,而PinduoduoCandidateDriver接口本身没有任何购买/下单/拼单/支付语义的方法可供调用。二者结合,从 「判据永不选中购买控件」与「驱动接口没有购买类方法可调」两个角度共同 构成完整的回归证明,弥补了无法直接对AccessibilityNodeInfo做单测的 环境限制。这与 T-262 记录中「PinduoduoUiElement是扁平模型,无父节点 引用」的既有架构限制是同一类工程判断的延续。 hasNativeOrderConfirmation触发时不强制safetyStop = PAYMENT_BOUNDARY——它只影响page的分类,不进入structureHit || hasCheckoutRoot || (textMarkerHit && !textMarkerSuppressed)表达式。 这是刻意设计而非 遗漏:任务文档第二节的目标是「让既有awaitSpecificationEntry()的ORDER_CONFIRMATION分支生效」,该分支在检查safetyStopReason之前 就已按page == ORDER_CONFIRMATION匹配并调用returnFromOrderConfirmation()安全退出——即便safetyStopReason为null也会生效。真机取证的样本本身还含有「微信支付」促销文案,会命中 既有paymentMarkers(page=ORDER_CONFIRMATION不在可抑制页面集合内), 因此实际场景下safetyStopReason依旧会是PAYMENT_BOUNDARY——新判据 解决的是「page分类不准导致awaitSpecificationEntry()走不到优雅退出 分支、只能被全局Blocked卡住」,而不是「补一条新的强制停止判据」。isSafeForCleanup()已经把page == ORDER_CONFIRMATION排除在安全清理 之外(不论safetyStopReason是否为空),因此新判据不会让清理逻辑在 该页面上意外点击任何控件;其余轮询函数(awaitPage()/awaitResultsPage()/validateResultsPage())在页面不符合预期时只做 计数与延迟,不发生点击,因此该设计不构成「放松支付边界判据」。specEntryAbsent与payPage两个新键的相对顺序:任务文档分别在 第一节(specEntryAbsent)与第三节(payPage)描述,未规定二者互相的 先后。按方案小节的编号顺序(specEntryAbsent对应方案第 2 条,payPage对应方案第 4 条)追加,specEntryAbsent在前、payPage在后。
真机验证(未完成,需人工执行)
以下需人工在真机上执行,任务 status 保持 DOING:
- 重跑此前触发原生结算页故障的同一采购任务,确认遇到无颜色/尺码规格的
商品(如纸巾)不再点击「免拼购买」、不再进入订单确认页;若仍进入,应能
被识别为
ORDER_CONFIRMATION并经returnFromOrderConfirmation()自动 退出,而非像 T-263 记录的那样卡死。 - 从 Admin 读取诊断 message 中的
specEntryAbsent,评估移除兜底后的候选 覆盖损失是否偏高;若偏高需另开任务以更安全的方式补回(不得点击购买按 钮)。 - 从 Admin 读取
payPage,确认支付边界触发时能看到页面分类名称,未触发时 为NONE。