fix: 等待微信返回后再只读核单 (#124)

This commit is contained in:
chengma
2026-08-10 19:58:57 +08:00
parent 0f5d38b08f
commit 4999aa70e4
4 changed files with 274 additions and 27 deletions
+83 -21
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import re import re
import time import time
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from collections import Counter
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
@@ -96,6 +97,26 @@ def _package_hint_from_tree(root: ET.Element) -> str:
return PDD_PACKAGE_NAME if pdd_node_count >= 3 else "" return PDD_PACKAGE_NAME if pdd_node_count >= 3 else ""
def _foreground_package_from_tree(root: ET.Element) -> str:
"""控件树只有一个明确业务包时返回它;系统界面节点不参与判断。"""
ignored_packages = {"", "com.android.systemui"}
counts = Counter(
node.get("package", "")
for node in root.iter("node")
if node.get("package", "") not in ignored_packages
)
if not counts:
return ""
highest = max(counts.values())
packages = [
package
for package, count in counts.items()
if count == highest and count >= 3
]
return packages[0] if len(packages) == 1 else ""
def _label_count(root: ET.Element, targets: tuple[str, ...]) -> int: def _label_count(root: ET.Element, targets: tuple[str, ...]) -> int:
return sum( return sum(
1 1
@@ -329,7 +350,10 @@ class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter):
device_service: Optional[PddDeviceService] = None, device_service: Optional[PddDeviceService] = None,
cancelled: Callable[[], bool] = lambda: False, cancelled: Callable[[], bool] = lambda: False,
settle_seconds: float = 0.4, settle_seconds: float = 0.4,
transition_timeout_seconds: float = 30.0,
max_pages: int = 5, max_pages: int = 5,
monotonic: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep,
) -> None: ) -> None:
self._device_address = str(device_address or "").strip() self._device_address = str(device_address or "").strip()
self._device_service = ( self._device_service = (
@@ -339,7 +363,12 @@ class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter):
) )
self._cancelled = cancelled self._cancelled = cancelled
self._settle_seconds = max(0.0, float(settle_seconds)) self._settle_seconds = max(0.0, float(settle_seconds))
self._transition_timeout_seconds = max(
0.0, float(transition_timeout_seconds)
)
self._max_pages = max(1, int(max_pages)) self._max_pages = max(1, int(max_pages))
self._monotonic = monotonic
self._sleep = sleep
self._session = None self._session = None
self._device = None self._device = None
@@ -431,23 +460,39 @@ class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter):
return self._device return self._device
def _open_unpaid_orders(self, device: Any) -> None: def _open_unpaid_orders(self, device: Any) -> None:
root = _parse_xml(device.dump_hierarchy(compressed=False)) deadline = self._monotonic() + self._transition_timeout_seconds
if not _package_hint_from_tree(root):
current = device.app_current()
if str(current.get("package") or "") != PDD_PACKAGE_NAME:
device.app_start(PDD_PACKAGE_NAME)
self._settle()
opened_order_area = False opened_order_area = False
for _ in range(6): checked_foreground = False
started_pdd = False
pressed_back = False
clicked_navigation: set[str] = set()
last_reason = "等待 PDD 待付款页稳定"
while self._monotonic() <= deadline:
self._check_cancelled() self._check_cancelled()
root = _parse_xml(device.dump_hierarchy(compressed=False)) root = _parse_xml(device.dump_hierarchy(compressed=False))
if self._monotonic() > deadline:
last_reason = "读取手机控件树超时"
break
if not _package_hint_from_tree(root): if not _package_hint_from_tree(root):
if not checked_foreground:
current_package = _foreground_package_from_tree(root)
if not current_package:
current = device.app_current() current = device.app_current()
if str(current.get("package") or "") != PDD_PACKAGE_NAME: current_package = str(current.get("package") or "")
raise PddPurchaseReconcileError( if self._monotonic() > deadline:
"RECONCILE_WRONG_APP", "核单时 PDD 不在前台" last_reason = "查询手机前台应用超时"
) break
checked_foreground = True
if current_package != PDD_PACKAGE_NAME:
device.app_start(PDD_PACKAGE_NAME)
started_pdd = True
last_reason = "已返回 PDD,等待待付款页加载"
elif started_pdd:
last_reason = "PDD 正在从微信中间页恢复"
else:
last_reason = "PDD 控件树正在加载"
self._settle()
continue
self._raise_for_special_page(root) self._raise_for_special_page(root)
combined = _subtree_text(root) combined = _subtree_text(root)
if _is_single_order_detail(root) or ( if _is_single_order_detail(root) or (
@@ -460,27 +505,44 @@ class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter):
): ):
return return
if any(marker in combined for marker in _PAYMENT_ACTION_MARKERS): if any(marker in combined for marker in _PAYMENT_ACTION_MARKERS):
if not pressed_back:
device.press("back") device.press("back")
pressed_back = True
last_reason = "已只读返回,等待待付款页稳定"
self._settle() self._settle()
continue continue
if "我的订单" in combined: if "我的订单" in combined:
if self._click_unique_label(device, root, "待付款"): if (
"待付款" not in clicked_navigation
and self._click_unique_label(device, root, "待付款")
):
clicked_navigation.add("待付款")
opened_order_area = True opened_order_area = True
last_reason = "已打开待付款,等待订单列表加载"
self._settle() self._settle()
continue continue
if self._click_unique_label(device, root, "我的订单"): if (
"我的订单" not in clicked_navigation
and self._click_unique_label(device, root, "我的订单")
):
clicked_navigation.add("我的订单")
opened_order_area = True opened_order_area = True
last_reason = "已打开我的订单,等待待付款入口加载"
self._settle() self._settle()
continue continue
if self._click_unique_label(device, root, "个人中心"): if (
"个人中心" not in clicked_navigation
and self._click_unique_label(device, root, "个人中心")
):
clicked_navigation.add("个人中心")
last_reason = "已打开个人中心,等待我的订单入口加载"
self._settle() self._settle()
continue continue
last_reason = "PDD 页面仍在过渡,尚未出现唯一只读订单入口"
self._settle()
raise PddPurchaseReconcileError( raise PddPurchaseReconcileError(
"RECONCILE_ORDER_ENTRY_NOT_FOUND", "RECONCILE_ORDER_PAGE_TIMEOUT",
"没有找到唯一的“个人中心/我的订单/待付款”只读入口", f"等待 PDD 待付款页稳定超时:{last_reason}",
)
raise PddPurchaseReconcileError(
"RECONCILE_ORDER_PAGE_TIMEOUT", "打开待付款订单列表超时"
) )
@staticmethod @staticmethod
@@ -534,7 +596,7 @@ class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter):
def _settle(self) -> None: def _settle(self) -> None:
if self._settle_seconds: if self._settle_seconds:
time.sleep(self._settle_seconds) self._sleep(self._settle_seconds)
def create_u2_purchase_reconcile_adapter( def create_u2_purchase_reconcile_adapter(
+14 -4
View File
@@ -133,7 +133,10 @@ class PurchaseReconcileService:
return self._save_manual_review( return self._save_manual_review(
task, run, "ambiguous", diagnostics task, run, "ambiguous", diagnostics
) )
match_status = "not_found" if not candidates else "unknown" read_failed = bool(diagnostics.get("error_code"))
match_status = (
"unknown" if read_failed or candidates else "not_found"
)
return self._save_manual_review(task, run, match_status, diagnostics) return self._save_manual_review(task, run, match_status, diagnostics)
def _read_candidates( def _read_candidates(
@@ -153,9 +156,13 @@ class PurchaseReconcileService:
): ):
raise TypeError("采购核对候选数据无效") raise TypeError("采购核对候选数据无效")
except Exception as exc: except Exception as exc:
error_code = str(
getattr(exc, "code", "RECONCILE_READ_FAILED")
or "RECONCILE_READ_FAILED"
)
scan = PurchaseReconcileScan( scan = PurchaseReconcileScan(
diagnostics={ diagnostics={
"error_code": "RECONCILE_READ_FAILED", "error_code": error_code,
"error_message": str(exc)[:300], "error_message": str(exc)[:300],
} }
) )
@@ -181,13 +188,16 @@ class PurchaseReconcileService:
dict(diagnostics), dict(diagnostics),
) )
messages = { messages = {
"not_found": "未找到符合时间范围的订单", "not_found": "未找到符合条件的订单",
"ambiguous": "找到多个完全匹配的未付款订单", "ambiguous": "找到多个完全匹配的未付款订单",
"unknown": "订单字段不完整、不一致或读取失败", "unknown": "订单字段不完整、不一致或读取失败",
} }
message = messages[match_status]
if match_status == "unknown" and diagnostics.get("error_message"):
message = f"订单读取失败:{str(diagnostics['error_message'])[:200]}"
return PurchaseReconcileOutcome( return PurchaseReconcileOutcome(
"manual_review", "manual_review",
f"任务 {task.remote_task_id} {messages[match_status]};需人工处理,绝不重新下单", f"任务 {task.remote_task_id} {message};需人工处理,绝不重新下单",
task.remote_task_id, task.remote_task_id,
) )
@@ -8,6 +8,7 @@ from src.pdd_purchase_reconcile_adapter import (
PurchaseReconcileQuery, PurchaseReconcileQuery,
) )
from src.pdd_u2_purchase_reconcile_adapter import ( from src.pdd_u2_purchase_reconcile_adapter import (
PddPurchaseReconcileError,
U2PddPurchaseReconcileAdapter, U2PddPurchaseReconcileAdapter,
_is_single_order_detail, _is_single_order_detail,
_merge_order_evidence, _merge_order_evidence,
@@ -96,6 +97,51 @@ class DetailDevice:
self.page_index = min(self.page_index + 1, len(self.pages) - 1) self.page_index = min(self.page_index + 1, len(self.pages) - 1)
class FakeClock:
def __init__(self):
self.now = 0.0
def monotonic(self):
return self.now
def sleep(self, seconds):
self.now += seconds
def xml_package_page(package: str, text: str = "") -> str:
return (
'<hierarchy>'
f'<node package="{package}" text="{text}" />'
f'<node package="{package}" text="" />'
f'<node package="{package}" text="" />'
"</hierarchy>"
)
class TransitionDevice(DetailDevice):
def __init__(self, pages, current_package="com.tencent.mm"):
super().__init__(pages)
self.current_package = current_package
self.dump_calls = 0
self.back_calls = 0
def dump_hierarchy(self, compressed=False):
index = min(self.dump_calls, len(self.pages) - 1)
self.dump_calls += 1
return self.pages[index]
def app_current(self):
self.app_current_calls += 1
return {"package": self.current_package}
def press(self, key):
if key == "back":
self.back_calls += 1
def swipe(self, *args):
self.swipes += 1
class PddU2PurchaseReconcileAdapterTest(unittest.TestCase): class PddU2PurchaseReconcileAdapterTest(unittest.TestCase):
def test_parser_extracts_only_required_unpaid_order_fields(self): def test_parser_extracts_only_required_unpaid_order_fields(self):
candidates = parse_order_candidates( candidates = parse_order_candidates(
@@ -202,6 +248,132 @@ class PddU2PurchaseReconcileAdapterTest(unittest.TestCase):
self.assertFalse(_is_single_order_detail(root)) self.assertFalse(_is_single_order_detail(root))
def test_waits_for_pdd_detail_after_external_transition(self):
complete_detail = xml_detail(
"待付款 订单编号:ORDER-20260810 "
"商品编号:737116531267 黑色 L 共2件 合计 ¥84.00 "
"下单时间:2026-08-10 16:03:00"
)
device = TransitionDevice(
[
xml_package_page("com.tencent.mm", "微信登录"),
xml_package_page("com.xunmeng.pinduoduo", "页面加载中"),
complete_detail,
]
)
clock = FakeClock()
adapter = U2PddPurchaseReconcileAdapter(
"serial",
device_service=FakeDeviceService(device),
settle_seconds=1,
transition_timeout_seconds=30,
max_pages=2,
monotonic=clock.monotonic,
sleep=clock.sleep,
)
scan = adapter.read_order_candidates(query())
adapter.close()
self.assertEqual(len(scan.candidates), 1)
self.assertEqual(device.app_start_calls, 1)
self.assertEqual(device.app_current_calls, 0)
self.assertLess(clock.now, 30)
def test_payment_back_is_pressed_at_most_once_while_page_changes(self):
payment_page = xml_package_page(
"com.xunmeng.pinduoduo", "立即支付"
)
device = TransitionDevice(
[payment_page, payment_page, xml_detail("待付款")],
current_package="com.xunmeng.pinduoduo",
)
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)
def test_transition_timeout_reports_last_waiting_reason(self):
device = TransitionDevice(
[xml_package_page("com.xunmeng.pinduoduo", "页面加载中")],
current_package="com.xunmeng.pinduoduo",
)
clock = FakeClock()
adapter = U2PddPurchaseReconcileAdapter(
"serial",
device_service=FakeDeviceService(device),
settle_seconds=1,
transition_timeout_seconds=2,
monotonic=clock.monotonic,
sleep=clock.sleep,
)
with self.assertRaises(PddPurchaseReconcileError) as captured:
adapter._open_unpaid_orders(device)
self.assertEqual(captured.exception.code, "RECONCILE_ORDER_PAGE_TIMEOUT")
self.assertIn("页面仍在过渡", str(captured.exception))
def test_transition_wait_can_be_cancelled(self):
device = TransitionDevice(
[xml_package_page("com.xunmeng.pinduoduo", "页面加载中")],
current_package="com.xunmeng.pinduoduo",
)
clock = FakeClock()
checks = iter((False, False, True))
adapter = U2PddPurchaseReconcileAdapter(
"serial",
device_service=FakeDeviceService(device),
cancelled=lambda: next(checks, True),
settle_seconds=1,
transition_timeout_seconds=30,
monotonic=clock.monotonic,
sleep=clock.sleep,
)
with self.assertRaises(PddPurchaseReconcileError) as captured:
adapter.read_order_candidates(query())
self.assertEqual(captured.exception.code, "RECONCILE_CANCELLED")
def test_security_pages_still_stop_transition_wait(self):
cases = (
("请完成验证", "RECONCILE_CAPTCHA"),
("手机号登录", "RECONCILE_LOGIN_REQUIRED"),
("操作频繁", "RECONCILE_RISK_CONTROL"),
)
for marker, error_code in cases:
with self.subTest(marker=marker):
device = TransitionDevice(
[xml_package_page("com.xunmeng.pinduoduo", marker)],
current_package="com.xunmeng.pinduoduo",
)
clock = FakeClock()
adapter = U2PddPurchaseReconcileAdapter(
"serial",
device_service=FakeDeviceService(device),
settle_seconds=1,
transition_timeout_seconds=30,
monotonic=clock.monotonic,
sleep=clock.sleep,
)
with self.assertRaises(PddPurchaseReconcileError) as captured:
adapter._open_unpaid_orders(device)
self.assertEqual(captured.exception.code, error_code)
self.assertEqual(device.app_start_calls, 0)
self.assertEqual(device.back_calls, 0)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+3
View File
@@ -327,11 +327,14 @@ class PurchaseRecoveryTest(unittest.TestCase):
).execute_one() ).execute_one()
self.assertEqual(outcome.kind, "manual_review") self.assertEqual(outcome.kind, "manual_review")
self.assertIn("核对设备已断开", outcome.message)
self.assertNotIn("时间范围", outcome.message)
self.assertEqual(purchase_calls, []) self.assertEqual(purchase_calls, [])
detail = self.repository.get_task(task_id) detail = self.repository.get_task(task_id)
run = self.repository.latest_task_run(task_id) run = self.repository.latest_task_run(task_id)
assert detail is not None and run is not None assert detail is not None and run is not None
self.assertEqual(detail.current_step, "reconcile_manual_review") self.assertEqual(detail.current_step, "reconcile_manual_review")
self.assertEqual(run.error_code, "ORDER_MATCH_UNCERTAIN")
self.assertIn( self.assertIn(
"核对设备已断开", "核对设备已断开",
run.diagnostics_json["reconciliation"]["error_message"], run.diagnostics_json["reconciliation"]["error_message"],