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 各下一单, > 产生重复的未付款订单。人工审核时取消多余订单即可。但未付款订单长期堆积可能触发平台风控, > 需要操作人员留意,不要放着不管。