From a6f690a823f3016e7a9f07b6c6d276d98febe657 Mon Sep 17 00:00:00 2001 From: ila Date: Fri, 7 Aug 2026 16:37:09 +0800 Subject: [PATCH] docs: import wiki at afc651f75a3a --- T-269.-.md | 307 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 T-269.-.md diff --git a/T-269.-.md b/T-269.-.md new file mode 100644 index 0000000..e008139 --- /dev/null +++ b/T-269.-.md @@ -0,0 +1,307 @@ + +> 同步来源:[`docs/tasks/T-269.md`](/chengma/mroubao/src/commit/afc651f75a3abc2676bb13aa8a80f6aa6a25a72e/docs/tasks/T-269.md) · commit `afc651f75a3a` + +--- +id: T-269 +title: 修正商品详情页识别并在开 App 时归位到已知页面 +phase: 2 +deps: + - T-268 +status: DOING +created: 2026-08-01 +context_ref: 126507c +work_branch: null +write_paths: + - docs/tasks/T-269.md + - docs/current-state.md + - android-buyer/app/src/main/** + - android-buyer/app/src/test/** +--- + +## 问题 / 背景 + +真机任务 `54ee4d8a-41a8-4a63-bba2-a7b77162f27d` 三轮**全部在 `pdd_open_app` 被 +`PAYMENT_BOUNDARY` 拦下**,一步都没跑。T-268 的门控诊断同时证明下单侧一切正常: + +``` +ORDER_DRY_RUN_GATE gateOpen=0 execution=1 command=0 cmdAck=0 storageOk=1 + canStart=1 pddInstalled=1 a11yEnabled=1 a11yConnected=1 + loginBlocker=UNKNOWN → NONE +``` + +`command=0` 属预期(该任务尚未走到人工确认,没有授权)。**`canStart=1` 直接证伪了 +「`loginBlocker` 粘滞缓存阻塞下单」这一先前推测**。 + +### 失败归因 + +``` +step=pdd_open_app reason=PAYMENT_BOUNDARY +payPage=UNKNOWN ← 症结 +payMarkerIndex=1 ← 提交订单 +payMarkerHits=2 +payCheckoutRoot=0 payStructureHit=0 payTextSuppressed=0 +``` + +T-263 的抑制规则是「页面被正面识别为商品详情/规格弹层/搜索结果/图搜结果/首页时, +文本命中放行」。`UNKNOWN` 不在名单内,因此文本判据强制生效——**按设计执行,问题在于 +页面本该被识别却没被识别**。 + +`payMarkerHits=2` 且 `payMarkerIndex=1`(提交订单)与此前 dump 过的**商品详情页**特征 +完全吻合:该页正好有「提交订单 ¥14.9」和「使用#微信支付…」两条。 + +### 直接原因:`hasDetailBack` 要求返回键自身可点 + +```kotlin +val hasDetailBack = visibleElements.any { element -> + element.clickable && + normalize(element.contentDescription.orEmpty()) == "返回" +} +val detailMarkerCount = setOf("客服", "收藏", "店铺") + .count { marker -> normalized.any { it.contains(marker) } } + +hasDetailBack && detailMarkerCount >= 2 -> PinduoduoPage.PRODUCT_DETAIL +``` + +真机 dump 的商品详情页: + +``` +'返回' clickable=false ← 不可点 +'店铺' clickable=true +'收藏' clickable=true +'客服' clickable=true +``` + +`detailMarkerCount = 3 >= 2` 成立,但 `hasDetailBack` 为 false,判定链落到 +`else -> UNKNOWN`。**一个 `clickable` 属性导致整条链路瘫痪。** + +这与 T-262 发现的 `payClickable` 恒为 0 是**同一个模式**:拼多多把文字/图标放在不可点的 +子节点里,真正可点的是祖先容器。代码库中已有 `clickableNodeOrAncestor()` +(`BuyerAccessibilityService`,受 `MAX_CLICK_ANCESTORS` 约束)处理此模式。 + +### 结构性问题:开 App 依赖上一轮遗留的页面 + +同一份代码,2026-08-01 00:37 那轮**完整跑通并 `SUCCEEDED`**(`resolved=2 target=2`), +当时拼多多停在中性页面;本轮因停在商品详情页而全军覆没。**成败取决于上一轮把拼多多 +留在哪一页**,这是不可接受的脆弱性。 + +这已是第三次「残留页面拖垮下一轮」:T-261 是结算页滞留、T-263 是支付边界误报、 +本次是详情页未识别。 + +## 关联需求与交互 + +- 功能:F-005、F-007 +- 用户故事:US-004、US-006 +- 交互:不新增采购操作 +- 架构/API:沿用既有诊断机制,**后端零改动** + +## 行为契约 + +### 一、`hasDetailBack` 改为祖先可点 + +判定改为:**存在返回语义元素,且其自身或有界层数内的祖先可点击**。沿用既有 +`clickableNodeOrAncestor()` 的模式与 `MAX_CLICK_ANCESTORS` 上限,不新增遍历深度。 + +`detailMarkerCount >= 2` 的要求**保持不变**,返回语义的文本匹配规则也不变。 + +必须有回归测试证明:真机 dump 的商品详情页特征(返回不可点 + 客服/收藏/店铺 三者 +齐全)被识别为 `PRODUCT_DETAIL`;而搜索结果页、首页、规格弹层、订单确认页**不因本次 +放宽而被误判为商品详情页**。 + +### 二、开 App 时归位到已知页面 + +`pdd_open_app` 步骤中,当拼多多已在前台但页面为 `UNKNOWN` 时,允许**有界归位**: + +1. 最多 `MAX_HOME_RECOVERY_STEPS` 次(常量定义,初值 `3`)返回操作; +2. 每次返回后重新分类,一旦页面成为**任一已正面识别的类型**即停止归位并继续流程; +3. 用尽次数仍为 `UNKNOWN` 时,维持现有行为(文本命中则 `PAYMENT_BOUNDARY` 停止)。 + +**只允许返回方向的导航,不得点击任何前进方向控件。** + +### 三、归位的安全前置条件 + +**这是硬边界。** 出现以下任一情况时**立即停止,不得归位**: + +- `hasCheckoutRoot` 命中; +- T-263 结构判据命中(`payStructureHit`); +- 页面被识别为 `ORDER_CONFIRMATION`; +- 登录、验证码、风控任一安全停止成立。 + +即:**只有「纯文本命中且无任何结算迹象的 UNKNOWN 页面」才允许归位**。归位过程中每一步 +前必须重新取快照检查上述条件,一旦出现立即中止归位并返回原安全停止结果。 + +归位不得点击提交订单、立即支付、免密支付、先用后付或任何扣款语义控件;必须有单测 +证明该路径不调用任何此类动作。 + +### 四、诊断可见 + +追加计数,置于既有键之后: + +``` +homeRecoverySteps= homeRecovered=<0|1> +``` + +| 键 | 含义 | +| --- | --- | +| `homeRecoverySteps` | 本轮执行的归位返回次数 | +| `homeRecovered` | 归位后是否成功到达已识别页面 | + +沿用既有脱敏口径:只记计数,不记页面文本或商品信息。 + +### 五、既有能力一律不放松 + +- 不改 `paymentMarkers` 内容与顺序、`containsAny` 实现、`hasCheckoutRoot` 规则。 +- 不改 T-263 结构判据、抑制名单与连续确认次数。 +- 不改 T-264 原生订单确认页判据、T-265 规格入口优先级与落到结算页的跳过逻辑。 +- 不改登录、验证码、风控的检测逻辑与优先级。 +- 不改 T-268 门控诊断与既有诊断键的顺序、名称、取值;新键追加在末尾。 +- 不改候选卡片识别、签名算法、跨轮去重、价格解析或三轮预算。 + +## 方案 + +1. `PinduoduoPageClassifier` 的 `hasDetailBack` 改为「自身或有界祖先可点」。由于 + `PinduoduoUiElement` 是扁平模型无父引用,需在节点采集侧补充「该元素自身或祖先 + 可点」的布尔属性,而非在分类器内遍历树。 +2. `AndroidPinduoduoImageSearchDriver.openApp()` 在页面为 `UNKNOWN` 且通过第三节安全 + 前置条件时执行有界归位,沿用既有返回能力,不新增点击目标。 +3. 两个新计数并入诊断快照与 message 尾部。 +4. 单测覆盖:真机详情页特征被识别为 `PRODUCT_DETAIL`;其余页面类型不被误判; + 归位在 UNKNOWN 且无结算迹象时执行并在到达已识别页面后停止;出现结算迹象或 + 风控时立即中止归位;归位路径不调用任何购买/支付语义动作;用尽次数仍 UNKNOWN 时 + 维持原停止行为;诊断键格式。 + +## 验收要点 + +- [x] 真机 dump 的商品详情页特征(返回不可点 + 三个 marker)被识别为 `PRODUCT_DETAIL`。 +- [x] 搜索结果页、首页、规格弹层、订单确认页不被误判为商品详情页,有回归测试。 +- [x] `UNKNOWN` 且无结算迹象时执行有界归位,到达已识别页面后继续流程。 +- [x] 出现 `hasCheckoutRoot` / 结构判据 / `ORDER_CONFIRMATION` / 风控时**不归位**, + 立即维持原安全停止,有单测证明。 +- [x] 归位路径不调用任何提交订单、支付或扣款语义动作,有单测证明。 +- [x] 用尽 `MAX_HOME_RECOVERY_STEPS` 仍为 `UNKNOWN` 时行为与本任务前一致。 +- [x] `homeRecoverySteps` / `homeRecovered` 出现在诊断 message 且不含页面文本。 +- [x] T-263/T-264/T-265/T-268 的判据与诊断键未被改动,有回归测试。 +- [x] Android Debug/Release 单测、`lintDebug`、`assembleDebug`、`assembleRelease` 全部通过。 +- [ ] 真机验证:**故意把拼多多停在商品详情页**后跑任务,不再被拦在 `pdd_open_app`; + 从 Admin 读取 `payPage`、`homeRecoverySteps`、`homeRecovered` 记入执行记录。 + +## 边界 + +- **不放松任何支付边界判据**,只修正页面识别并增加有界归位。 +- 归位只允许返回方向导航,绝不点击前进控件、提交订单或支付。 +- 不改 `returnFromOrderConfirmation()` 与 T-265 落到结算页的跳过逻辑。 +- 不改登录、验证码、风控的检测与优先级。 +- 不改 T-261 清理逻辑、T-262 归因既有键、T-263 结构判据与抑制名单、T-268 门控诊断。 +- 不改后端代码、数据库、API 契约或 Admin 模板。 +- 不处理价格解析(前缀与节点拆分)问题,另行开任务。 +- 不处理图搜结果混入非目标品类的问题,另行开任务。 + +## 执行记录 + +开工后记录修改、命令、结果、环境、决策和 blocker。 + +### 2026-08-01 实现 + +**改动文件** + +- `android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifier.kt` + — `PinduoduoUiElement` 追加 `selfOrAncestorClickable: Boolean = clickable` + 字段(扁平模型无父引用,需在采集侧带出该布尔量);`hasDetailBack` 从 + `element.clickable` 改为 `element.selfOrAncestorClickable`。 + `detailMarkerCount>=2` 与返回语义的文本匹配规则未改动。 +- `android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt` + — `uiElement()` 用 `clickableNodeOrAncestor(node) != null` 计算 + `selfOrAncestorClickable`(复用既有函数与 `MAX_CLICK_ANCESTORS`,不新增 + 遍历深度、不新增点击);新增 `performPinduoduoHomeRecoveryBack()`,只调用 + `performGlobalAction(GLOBAL_ACTION_BACK)`,不做任何页面判断(安全前置 + 条件由调用方在调用前决定)。 +- `android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoHomeRecoveryPolicy.kt` + (新增)— 纯 Kotlin 归位策略:`MAX_HOME_RECOVERY_STEPS = 3`;仅当页面为 + `UNKNOWN` 且当前安全停止恰好是「纯文本命中」触发的 `PAYMENT_BOUNDARY` + (非 `checkoutRootHit`、非 `structureHit`)时才允许下一步返回;每步后重新 + 取快照,若出现 `ORDER_CONFIRMATION`/`checkoutRootHit`/`structureHit`/ + 登录验证码风控任一,立即停止且不算「归位成功」;到达其它已识别页面类型 + 才算成功。策略只接受 `goBack`/`snapshot` 两个回调,结构上不具备触发 + 提交订单/支付/免密支付/先用后付的能力。 +- `android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityBridge.kt` + — 新增 `homeRecoveryBack()`,桥接 `performPinduoduoHomeRecoveryBack()`。 +- `android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoImageSearchDriver.kt` + — `openApp()` 在页面为 `UNKNOWN` 时,既有 `dismissTransientOverlay()` 之后 + 追加调用 `PinduoduoHomeRecoveryPolicy.attempt()`,把结果记入新增的 + `homeRecoverySteps`/`homeRecovered` 只读属性;不新增点击目标。 +- `android-buyer/app/src/main/java/com/roubao/autopilot/procurement/CandidateSearchDiagnostics.kt` + — 尾部追加 `homeRecoverySteps`/`homeRecovered` 两个字段、对应 `require` + 边界校验、`withHomeRecovery()` 合并方法(逐次覆盖,不累积),并在 + `auditSuffix()` 末尾追加两个键。既有键顺序、名称、取值未改动。 +- `android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt` + — 把原先内联构造的 `AndroidPinduoduoImageSearchDriver` 提到 `val + imageSearchDriver` 便于 `runner.run()` 结束后读取归位诊断;成功与失败两条 + 上报路径都追加 `.withHomeRecovery(...)`。未改动候选搜索、门控、清理等 + 既有诊断字段的计算方式。 +- 测试:`PinduoduoPageClassifierTest.kt`(`element()` helper 新增 + `selfOrAncestorClickable` 参数,默认等于 `clickable`;新增真机详情页 + 识别修复回归测试、「自身与祖先均不可点仍为 UNKNOWN」回归测试、以及 + 搜索结果页/首页/规格弹层/订单确认页四类不被误判为商品详情页的回归 + 测试)、`PinduoduoHomeRecoveryPolicyTest.kt`(新增,覆盖:纯文本命中时 + 执行归位并在到达已识别页面后停止;`checkoutRootHit`/结构判据命中时不 + 归位;归位途中出现 `ORDER_CONFIRMATION`/登录/验证码/风控立即停止且不算 + 成功;无任何强制停止时不归位;用尽 3 步仍 `UNKNOWN` 时行为不变; + `goBack` 失败时不计入步数;结构性证明归位只调用 `goBack`)、 + `CandidateSearchDiagnosticsTest.kt`(更新既有精确字符串断言以容纳两个 + 新增尾部键的默认值,新增默认值/`withHomeRecovery`/覆盖式合并/越界校验 + 测试)。 + +**关键决策** + +- `homeRecoverySteps`/`homeRecovered` 的归位循环按任务方案放在 + `AndroidPinduoduoImageSearchDriver.openApp()` 里(而非 + `PinduoduoImageSearchAutomation`),因为方案第 1 条明确把归位职责放在 + 该驱动;但驱动依赖真实无障碍服务、没有 Robolectric,无法直接单测。为此 + 把「何时允许归位、何时立即停止」的判定拆成纯 Kotlin 的 + `PinduoduoHomeRecoveryPolicy`(只接受注入的 `goBack`/`snapshot` 回调), + 由驱动在生产代码里用真实的 `BuyerAccessibilityBridge` 调用桥接; + 这样既遵循方案的落点,又能满足单测覆盖要求。 +- 归位判据里把「页面变为 `ORDER_CONFIRMATION`」明确算作「硬停止、不算 + 归位成功」,即使 `ORDER_CONFIRMATION` 本身是一个「已正面识别」的页面 + 类型——这是遵照任务契约第三节把 `ORDER_CONFIRMATION` 单独列入硬边界的 + 字面要求,与「二、开 App 时归位到已知页面」第 2 条的「到达已识别页面 + 即停止并继续流程」有轻微措辞张力,但契约第三节对 `ORDER_CONFIRMATION` + 的处理更具体、更严格,故以第三节为准。既有 `awaitPage()` 仍会把 + `ORDER_CONFIRMATION` 当作 `pdd_open_app` 的可接受落点(未改动该逻辑), + 只是不算作本归位机制自身的「成功」。 +- 未改动 `AndroidPinduoduoUiDriver`/`PinduoduoSearchAutomation`(文本关键词 + 搜索路径),因为任务方案第 1 条只点名 `AndroidPinduoduoImageSearchDriver`, + 且该路径不在真机故障任务涉及的自动化管线上;避免顺手扩大范围。 +- `PinduoduoUiElement.selfOrAncestorClickable` 默认值取 `clickable` + (而非 `false`),使所有未显式设置该字段的既有测试和调用方行为完全 + 不变,只有新增测试显式传入 `true`/`false` 来模拟「祖先可点但自身不可点」 + 的真机特征。 + +**执行命令与结果(均在 Windows 侧执行)** + +``` +cmd.exe /c "cd /d D:\chengma\cmroubao\android-buyer && gradlew.bat testDebugUnitTest --no-daemon --console=plain" + → BUILD SUCCESSFUL;Debug 单测合计 412 项,全部通过(含新增 + PinduoduoHomeRecoveryPolicyTest 9 项、PinduoduoPageClassifierTest 57 项、 + CandidateSearchDiagnosticsTest 24 项)。 + +cmd.exe /c "cd /d D:\chengma\cmroubao\android-buyer && gradlew.bat testReleaseUnitTest --no-daemon --console=plain" + → BUILD SUCCESSFUL;Release 单测合计 412 项,全部通过。 + +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。 +``` + +五个目标全部通过。`init.ps1` 未运行(按指示不运行)。 + +**待完成(人工/真机)** + +- 未做:真机验证——故意把拼多多停在商品详情页后跑任务,确认不再被拦在 + `pdd_open_app`,并从 Admin 读取 `payPage`、`homeRecoverySteps`、 + `homeRecovered` 记入本节。这一步只能在真机上完成,本次未执行,任务 + 状态保持 `DOING`,不改为 `DONE`。