fix: 增强双微信选择器识别 (#263)

This commit is contained in:
chengma
2026-08-18 10:11:08 +08:00
parent 9f6705a24c
commit 2a0c17ae30
3 changed files with 139 additions and 36 deletions
+71 -36
View File
@@ -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:
@@ -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 = (
'<hierarchy>'
'<node package="com.xunmeng.pinduoduo" text="页面加载中" />'
'<node package="com.xunmeng.pinduoduo" text="" />'
'<node package="com.xunmeng.pinduoduo" text="" />'
'<node package="android" text="选择要使用的应用" '
'content-desc="选择要使用的应用" />'
'<node package="com.tencent.mm" text="微信" '
'content-desc="微信" />'
'<node package="com.tencent.mm" text="微信 2" />'
'</hierarchy>'
)
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 = (
'<hierarchy>'
'<node package="com.android.intentresolver" '
'text="使用以下应用打开" />'
'<node package="com.tencent.mm" text="微信" />'
'<node package="com.tencent.mm" text="微信分身" />'
'</hierarchy>'
)
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()],
+5
View File
@@ -147,6 +147,11 @@ Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode`
截止时间,也要先分析刚返回的最新控件树,再决定成功或超时。规格面板超时必须记录
最后页面类型、控件树读取次数和脱敏 XML 引用,不保存商品、账号或收货相关原文。
只读核单遇到多个微信的 Android 应用选择器时,分别读取节点的 `text` 和
`content-desc`,并同时校验白名单标题、微信候选、前台 Resolver 包和 Activity。
选择器可能与底层 PDD 节点同时出现在控件树中,因此必须先判断选择器;证据完整时
最多按一次返回键,绝不选择微信或点击支付。证据不完整或一次返回后仍存在时转人工。
> **运营提醒(不是代码规则):** Admin 超时重派可能导致同一任务被两台 Client 各下一单,
> 产生重复的未付款订单。人工审核时取消多余订单即可。但未付款订单长期堆积可能触发平台风控,
> 需要操作人员留意,不要放着不管。