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


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 追加一个判据(现有三个判据一律保留):

同时满足即判定为订单确认页:

  1. 存在掩码手机号形态的文本(如 \d{3}\*{4}\d{4});
  2. 存在以 已选 开头的文本;
  3. 存在数量或运费语义(如纯数字数量、已包邮、运费)。

依据:掩码手机号仅出现在涉及收件信息的页面,商品详情页不会出现;三者同时出现足以与 商品详情页、规格弹层区分。

隐私硬约束:仅做形态匹配用于判定,绝不记录收件人姓名、手机号(含掩码 形式)、详细地址或任何原文,不得进入诊断 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 既有诊断键的顺序、名称与取值逐字节不变, 新键一律追加在末尾。

方案

  1. openPinduoduoSpecifications() 删除「免拼购买」兜底分支,缺少安全入口时返回 false。
  2. 候选自动化统计 specEntryAbsent,并入诊断快照与 message 尾部。
  3. PinduoduoPageClassifier 为 hasOrderConfirmation 追加原生结算页判据;掩码手机号 等只做形态匹配,不留存原文。
  4. PaymentBoundaryAttribution 追加页面类型字段,诊断追加 payPage 键。
  5. 单测覆盖:无安全入口时不点击任何购买控件且计数递增;原生结算页样本被识别为 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 放在同一行后编译通过,不影响任何行为逻辑。

偏离任务文档之处及理由

  1. specEntryAbsent 通过修改 PinduoduoCandidateDriver.openSpecifications() 的返回类型(Boolean → 三态枚举)实现,而非在 BuyerAccessibilityService 内部用一个额外的可变字段传递「上一次是否因缺入口失败」。 原本考虑用 BuyerAccessibilityBridge.openSpecifications(): Boolean 不变、另开一个 只读的 isPinduoduoSpecificationEntryAbsent() 供候选驱动在失败后追加查询 的方案,但那需要第二次节点树查询,且存在「两次查询之间页面已变化」的 理论竞态。改为让唯一的点击实现 openPinduoduoSpecificationsDetailed() 一次性产出三态结果,openPinduoduoSpecifications(): Boolean 只是其布尔 投影,供下单 dry-run 驱动复用同一份点击逻辑而不重复点击——这是改动面 略大但更安全、无竞态的方案。PinduoduoOrderDryRunDriver 是完全独立的 接口(不同文件、不同签名),未受此次接口改动影响。
  2. 验收要点要求「必须写单测证明这条路径不点击任何此类控件」,但项目的 单测环境是纯 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 是扁平模型,无父节点 引用」的既有架构限制是同一类工程判断的延续。
  3. 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())在页面不符合预期时只做 计数与延迟,不发生点击,因此该设计不构成「放松支付边界判据」。
  4. specEntryAbsent 与 payPage 两个新键的相对顺序:任务文档分别在 第一节(specEntryAbsent)与第三节(payPage)描述,未规定二者互相的 先后。按方案小节的编号顺序(specEntryAbsent 对应方案第 2 条,payPage 对应方案第 4 条)追加,specEntryAbsent 在前、payPage 在后。

真机验证(未完成,需人工执行)

以下需人工在真机上执行,任务 status 保持 DOING:

  • 重跑此前触发原生结算页故障的同一采购任务,确认遇到无颜色/尺码规格的 商品(如纸巾)不再点击「免拼购买」、不再进入订单确认页;若仍进入,应能 被识别为 ORDER_CONFIRMATION 并经 returnFromOrderConfirmation() 自动 退出,而非像 T-263 记录的那样卡死。
  • 从 Admin 读取诊断 message 中的 specEntryAbsent,评估移除兜底后的候选 覆盖损失是否偏高;若偏高需另开任务以更安全的方式补回(不得点击购买按 钮)。
  • 从 Admin 读取 payPage,确认支付边界触发时能看到页面分类名称,未触发时 为 NONE。