diff --git a/client/src/pdd_u2_purchase_reconcile_adapter.py b/client/src/pdd_u2_purchase_reconcile_adapter.py
index 9c4ff0d..3eedaab 100644
--- a/client/src/pdd_u2_purchase_reconcile_adapter.py
+++ b/client/src/pdd_u2_purchase_reconcile_adapter.py
@@ -53,12 +53,20 @@ _NON_UNPAID_MARKERS = ("已付款", "交易成功", "交易完成", "已取消",
_SAFE_NAVIGATION_LABELS = ("个人中心", "我的订单", "待付款")
_ORDER_DETAIL_PAY_LABELS = ("去支付",)
_ORDER_DETAIL_CANCEL_LABELS = ("取消订单",)
-_ANDROID_CHOOSER_PACKAGE = "android"
-_ANDROID_CHOOSER_TITLES = ("选择要使用的应用",)
+_ANDROID_CHOOSER_PACKAGES = ("android", "com.android.intentresolver")
+_ANDROID_CHOOSER_TITLES = (
+ "选择要使用的应用",
+ "使用以下应用打开",
+ "完成操作时使用",
+)
_ANDROID_CHOOSER_ACTIVITIES = (
"com.android.internal.app.ChooserActivity",
"com.android.internal.app.ResolverActivity",
)
+_ANDROID_CHOOSER_ACTIVITY_SUFFIXES = (
+ ".ChooserActivity",
+ ".ResolverActivity",
+)
class PddPurchaseReconcileError(RuntimeError):
@@ -144,24 +152,50 @@ def _is_single_order_detail(root: ET.Element) -> bool:
return pay_count == 1 and cancel_count <= 1 and len(order_numbers) <= 1
-def _is_android_wechat_chooser(root: ET.Element) -> bool:
- """严格识别覆盖在 PDD 上方的 Android 微信应用选择器。"""
+def _node_accessibility_values(node: ET.Element) -> tuple[str, ...]:
+ """分别返回 text 和 content-desc,避免拼接后破坏精确识别。"""
- if _foreground_package_from_tree(root) != _ANDROID_CHOOSER_PACKAGE:
- return False
- labels = tuple(_label(node) for node in root.iter("node"))
- has_title = any(label in _ANDROID_CHOOSER_TITLES for label in labels)
- has_wechat_candidate = any(
- label == "微信" or label.startswith("微信分身") for label in labels
+ return tuple(
+ dict.fromkeys(
+ value.strip()
+ for value in (
+ node.get("text", ""),
+ node.get("content-desc", ""),
+ )
+ if value.strip()
+ )
)
+
+
+def _has_android_wechat_chooser_semantics(root: ET.Element) -> bool:
+ """只判断选择器标题和微信候选,不在这里猜测前台应用。"""
+
+ has_title = False
+ has_wechat_candidate = False
+ for node in root.iter("node"):
+ values = _node_accessibility_values(node)
+ if any(value in _ANDROID_CHOOSER_TITLES for value in values):
+ has_title = True
+ if any(value.startswith("微信") for value in values):
+ has_wechat_candidate = True
return has_title and has_wechat_candidate
-def _is_known_android_chooser_activity(activity: str) -> bool:
- """前台 Activity 只接受 Android 标准选择器,避免误退未知系统页。"""
+def _is_known_android_chooser_foreground(
+ package: str, activity: str
+) -> bool:
+ """前台包和 Activity 都属于 Android Resolver 白名单才可返回。"""
+ normalized_package = str(package or "").strip()
normalized = str(activity or "").strip()
- return not normalized or normalized in _ANDROID_CHOOSER_ACTIVITIES
+ activity_known = (
+ normalized in _ANDROID_CHOOSER_ACTIVITIES
+ or normalized.endswith(_ANDROID_CHOOSER_ACTIVITY_SUFFIXES)
+ )
+ return (
+ normalized_package in _ANDROID_CHOOSER_PACKAGES
+ and activity_known
+ )
def _parse_bounds(value: str) -> Optional[Bounds]:
@@ -500,30 +534,31 @@ class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter):
if self._monotonic() > deadline:
last_reason = "读取手机控件树超时"
break
+ if _has_android_wechat_chooser_semantics(root):
+ if pressed_chooser_back:
+ raise PddPurchaseReconcileError(
+ "RECONCILE_ANDROID_CHOOSER_STUCK",
+ "Android 微信应用选择器在一次安全返回后仍未关闭,请人工返回 PDD 核单",
+ )
+ current = device.app_current()
+ chooser_package = str(current.get("package") or "")
+ chooser_activity = str(current.get("activity") or "")
+ if not _is_known_android_chooser_foreground(
+ chooser_package, chooser_activity
+ ):
+ raise PddPurchaseReconcileError(
+ "RECONCILE_ANDROID_CHOOSER_UNRECOGNIZED",
+ "检测到疑似微信应用选择页,但前台系统组件不在安全白名单,请人工处理",
+ )
+ if self._monotonic() > deadline:
+ last_reason = "确认 Android 微信应用选择器超时"
+ break
+ device.press("back")
+ pressed_chooser_back = True
+ last_reason = "已关闭 Android 微信应用选择器,等待 PDD 待付款详情加载"
+ self._settle()
+ continue
if not _package_hint_from_tree(root):
- if _is_android_wechat_chooser(root):
- if pressed_chooser_back:
- raise PddPurchaseReconcileError(
- "RECONCILE_ANDROID_CHOOSER_STUCK",
- "Android 微信应用选择器在一次安全返回后仍未关闭,请人工返回 PDD 核单",
- )
- current = device.app_current()
- chooser_activity = str(current.get("activity") or "")
- if not _is_known_android_chooser_activity(
- chooser_activity
- ):
- raise PddPurchaseReconcileError(
- "RECONCILE_ANDROID_CHOOSER_UNRECOGNIZED",
- "检测到疑似微信应用选择页,但前台 Activity 不在安全白名单,请人工处理",
- )
- if self._monotonic() > deadline:
- last_reason = "确认 Android 微信应用选择器超时"
- break
- device.press("back")
- pressed_chooser_back = True
- last_reason = "已关闭 Android 微信应用选择器,等待 PDD 待付款详情加载"
- self._settle()
- continue
if not checked_foreground:
current_package = _foreground_package_from_tree(root)
if not current_package:
diff --git a/client/test/test_pdd_u2_purchase_reconcile_adapter.py b/client/test/test_pdd_u2_purchase_reconcile_adapter.py
index 1923a57..bf84e64 100644
--- a/client/test/test_pdd_u2_purchase_reconcile_adapter.py
+++ b/client/test/test_pdd_u2_purchase_reconcile_adapter.py
@@ -349,6 +349,69 @@ class PddU2PurchaseReconcileAdapterTest(unittest.TestCase):
self.assertEqual(device.app_start_calls, 0)
self.assertEqual(device.app_current_calls, 1)
+ def test_mixed_package_chooser_with_duplicate_accessibility_values_closes(self):
+ chooser_over_pdd = (
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ )
+ device = TransitionDevice(
+ [chooser_over_pdd, xml_detail("待付款")],
+ current_package="android",
+ current_activity="com.android.internal.app.ChooserActivity",
+ )
+ clock = FakeClock()
+ adapter = U2PddPurchaseReconcileAdapter(
+ "serial",
+ device_service=FakeDeviceService(device),
+ settle_seconds=1,
+ transition_timeout_seconds=30,
+ monotonic=clock.monotonic,
+ sleep=clock.sleep,
+ )
+
+ adapter._open_unpaid_orders(device)
+
+ self.assertEqual(device.back_calls, 1)
+ self.assertEqual(device.app_start_calls, 0)
+ self.assertEqual(device.app_current_calls, 1)
+
+ def test_android_resolver_package_and_title_variant_are_supported(self):
+ resolver = (
+ ''
+ ''
+ ''
+ ''
+ ''
+ )
+ device = TransitionDevice(
+ [resolver, xml_detail("待付款")],
+ current_package="com.android.intentresolver",
+ current_activity="com.android.intentresolver.ResolverActivity",
+ )
+ clock = FakeClock()
+ adapter = U2PddPurchaseReconcileAdapter(
+ "serial",
+ device_service=FakeDeviceService(device),
+ settle_seconds=1,
+ transition_timeout_seconds=30,
+ monotonic=clock.monotonic,
+ sleep=clock.sleep,
+ )
+
+ adapter._open_unpaid_orders(device)
+
+ self.assertEqual(device.back_calls, 1)
+ self.assertEqual(device.app_start_calls, 0)
+
def test_android_wechat_chooser_stops_when_one_back_does_not_close_it(self):
device = TransitionDevice(
[xml_android_wechat_chooser(), xml_android_wechat_chooser()],
diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md
index 6a11d8f..5b87971 100644
--- a/docs/client/06-quality-security.md
+++ b/docs/client/06-quality-security.md
@@ -147,6 +147,11 @@ Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode`
截止时间,也要先分析刚返回的最新控件树,再决定成功或超时。规格面板超时必须记录
最后页面类型、控件树读取次数和脱敏 XML 引用,不保存商品、账号或收货相关原文。
+只读核单遇到多个微信的 Android 应用选择器时,分别读取节点的 `text` 和
+`content-desc`,并同时校验白名单标题、微信候选、前台 Resolver 包和 Activity。
+选择器可能与底层 PDD 节点同时出现在控件树中,因此必须先判断选择器;证据完整时
+最多按一次返回键,绝不选择微信或点击支付。证据不完整或一次返回后仍存在时转人工。
+
> **运营提醒(不是代码规则):** Admin 超时重派可能导致同一任务被两台 Client 各下一单,
> 产生重复的未付款订单。人工审核时取消多余订单即可。但未付款订单长期堆积可能触发平台风控,
> 需要操作人员留意,不要放着不管。