diff --git a/client/src/pdd_purchase_adapter.py b/client/src/pdd_purchase_adapter.py index 93822c2..238024f 100644 --- a/client/src/pdd_purchase_adapter.py +++ b/client/src/pdd_purchase_adapter.py @@ -78,7 +78,11 @@ class PddPurchaseAdapter(ABC): class PddLivePurchaseAdapter(PddPurchaseAdapter): - """受控真实采购接口;只增加一次性提交,不提供付款或取消。""" + """受控真实采购接口;增加地址标记和一次性提交,不提供付款或取消。""" + + @abstractmethod + def update_shipping_address(self, purchase_number: str) -> None: + """下单前更新地址末尾采购编号;失败时必须保持在可逆阶段。""" @abstractmethod def submit_order_once(self) -> None: diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py index 193cfc1..3e4c1bc 100644 --- a/client/src/pdd_u2_purchase_adapter.py +++ b/client/src/pdd_u2_purchase_adapter.py @@ -55,6 +55,11 @@ from .util.select_color_size import select_color, select_size Bounds = tuple[int, int, int, int] _BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$") _PRICE_PATTERN = re.compile(r"[¥¥]\s*(\d+(?:\.\d{1,2})?)") +_MASKED_PHONE_PATTERN = re.compile(r"(? Optional[Bounds]: return left, top, right, bottom +def _parent_nodes(root: ET.Element) -> dict[ET.Element, ET.Element]: + return { + child: parent + for parent in root.iter() + for child in parent + if child.tag == "node" + } + + +def _nearest_clickable_bounds( + node: ET.Element, parents: Mapping[ET.Element, ET.Element] +) -> Optional[Bounds]: + """从文字节点向上寻找最近的可点击、可见且启用容器。""" + + current: Optional[ET.Element] = node + while current is not None: + bounds = _parse_bounds(current.get("bounds", "")) + if ( + bounds is not None + and current.get("clickable") == "true" + and current.get("enabled", "true") != "false" + and current.get("visible-to-user", "true") != "false" + ): + return bounds + current = parents.get(current) + return None + + +def _clickable_text_targets(root: ET.Element, exact_text: str) -> list[Bounds]: + """按明确文字返回最近的唯一点击容器候选。""" + + parents = _parent_nodes(root) + targets = { + bounds + for node in root.iter("node") + if exact_text + in { + node.get("text", "").strip(), + node.get("content-desc", "").strip(), + } + if (bounds := _nearest_clickable_bounds(node, parents)) is not None + } + return sorted(targets) + + +def _address_entry_targets(root: ET.Element) -> list[Bounds]: + """通过脱敏手机号结构定位规格面板顶部的收货地址卡片。""" + + parents = _parent_nodes(root) + targets = { + bounds + for node in root.iter("node") + if _MASKED_PHONE_PATTERN.search(_label(node)) + if (bounds := _nearest_clickable_bounds(node, parents)) is not None + } + return sorted(targets) + + +def _shipping_address_editor(root: ET.Element) -> Optional[tuple[str, Bounds]]: + """在修改页中寻找与“详细地址”标签同一行的唯一输入框。""" + + address_labels = [ + bounds + for node in root.iter("node") + if "详细地址" in _label(node).replace(" ", "") + if (bounds := _parse_bounds(node.get("bounds", ""))) is not None + ] + editors = [ + (node.get("text", ""), bounds) + for node in root.iter("node") + if node.get("class") == "android.widget.EditText" + if node.get("enabled", "true") != "false" + if node.get("visible-to-user", "true") != "false" + if (bounds := _parse_bounds(node.get("bounds", ""))) is not None + ] + matches = [ + editor + for editor in editors + for label_bounds in address_labels + if editor[1][1] <= label_bounds[3] + and editor[1][3] >= label_bounds[1] + and editor[1][0] >= label_bounds[0] + ] + return matches[0] if len(matches) == 1 and matches[0][0].strip() else None + + +def _tagged_shipping_address(address: str, purchase_number: str) -> str: + """只替换程序生成的末尾标记,保留完整真实地址主体。""" + + checked_address = str(address or "").strip() + checked_number = str(purchase_number or "").strip() + if not checked_address or not checked_number: + raise ValueError("地址和采购编号不能为空") + if any(character in checked_number for character in "\r\n\t"): + raise ValueError("采购编号不能包含控制字符") + address_body = _PROGRAM_ADDRESS_SUFFIX_PATTERN.sub("", checked_address) + if not address_body: + raise ValueError("地址主体不能为空") + return f"{address_body}_{checked_number}" + + def _goods_id_from_url(goods_url: str) -> str: parsed = urlparse(goods_url) host = (parsed.hostname or "").lower() @@ -1090,6 +1196,156 @@ def create_u2_purchase_adapter( class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter): """只允许一次最终提交点击的真机 Adapter,不包含付款路径。""" + def update_shipping_address(self, purchase_number: str) -> None: + """更新正式采购地址末尾标记,并返回原规格确认面板。""" + + self._check_cancelled("purchase_update_shipping_address") + device = self._require_device() + try: + checked_number = str(purchase_number or "").strip() + if not checked_number or any( + character in checked_number for character in "\r\n\t" + ): + raise PddPurchaseError( + "PURCHASE_NUMBER_INVALID", + "采购编号为空或包含无效字符,未修改收货地址", + step="purchase_update_shipping_address", + ) + + root = _parse_xml(self._dump_hierarchy()) + entry_targets = _address_entry_targets(root) + self._click_unique_address_target( + entry_targets, + "PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", + "规格面板没有唯一可靠的收货地址入口", + ) + + panel_root = self._wait_for_address_page("panel") + modify_targets = _clickable_text_targets(panel_root, "修改") + self._click_unique_address_target( + modify_targets, + "PURCHASE_ADDRESS_EDIT_AMBIGUOUS", + "收货地址页没有唯一可靠的修改按钮", + ) + + edit_root = self._wait_for_address_page("edit") + editor_data = _shipping_address_editor(edit_root) + if editor_data is None: + raise PddPurchaseError( + "PURCHASE_ADDRESS_EDITOR_MISSING", + "修改页没有唯一可靠的详细地址输入框", + step="purchase_update_shipping_address", + ) + current_address, _editor_bounds = editor_data + try: + new_address = _tagged_shipping_address( + current_address, checked_number + ) + except ValueError as exc: + raise PddPurchaseError( + "PURCHASE_ADDRESS_VALUE_INVALID", + "当前详细地址或采购编号不符合更新要求", + step="purchase_update_shipping_address", + ) from exc + + editor = device( + className="android.widget.EditText", text=current_address + ) + if int(getattr(editor, "count", 0)) != 1: + raise PddPurchaseError( + "PURCHASE_ADDRESS_EDITOR_AMBIGUOUS", + "无法唯一选中详细地址输入框", + step="purchase_update_shipping_address", + ) + editor.set_text(new_address) + self._sleep(0.2) + + latest_edit_root = _parse_xml(self._dump_hierarchy()) + latest_editor = _shipping_address_editor(latest_edit_root) + if latest_editor is None or latest_editor[0] != new_address: + raise PddPurchaseError( + "PURCHASE_ADDRESS_INPUT_MISMATCH", + "详细地址输入后回读不一致,未保存也未下单", + step="purchase_update_shipping_address", + ) + save_targets = _clickable_text_targets(latest_edit_root, "保存") + self._click_unique_address_target( + save_targets, + "PURCHASE_ADDRESS_SAVE_AMBIGUOUS", + "修改页没有唯一可靠的保存按钮", + ) + + saved_root = self._wait_for_address_page( + "saved", expected_address=new_address + ) + back_targets = _clickable_text_targets(saved_root, "返回") + self._click_unique_address_target( + back_targets, + "PURCHASE_ADDRESS_BACK_AMBIGUOUS", + "收货地址页没有唯一可靠的返回按钮", + ) + self._wait_for_address_page("confirmation") + except PddPurchaseError: + raise + except Exception as exc: + self._raise_device_or_page_error( + exc, "purchase_update_shipping_address" + ) + + def _click_unique_address_target( + self, targets: list[Bounds], code: str, message: str + ) -> None: + """地址流程只允许点击唯一候选,避免误触其他资料。""" + + if len(targets) != 1: + raise PddPurchaseError( + code, + message, + step="purchase_update_shipping_address", + diagnostics={"candidate_count": len(targets)}, + ) + left, top, right, bottom = targets[0] + self._require_device().click( + (left + right) // 2, (top + bottom) // 2 + ) + + def _wait_for_address_page( + self, kind: str, *, expected_address: str = "" + ) -> ET.Element: + """等待地址页面切换;诊断信息不包含任何地址文字。""" + + deadline = self._monotonic() + self._panel_timeout + while self._monotonic() < deadline: + self._check_cancelled("purchase_update_shipping_address") + root = _parse_xml(self._dump_hierarchy()) + labels = _labels(root) + if kind == "panel" and "收货地址" in labels: + return root + if kind == "edit" and any( + "修改收货地址" in label.replace(" ", "") + for label in labels + ): + return root + if ( + kind == "saved" + and "收货地址" in labels + and expected_address + and any(expected_address in label for label in labels) + ): + return root + if ( + kind == "confirmation" + and _page_kind(root, "") == "order_confirmation" + ): + return root + self._sleep(0.2) + raise PddPurchaseError( + "PURCHASE_ADDRESS_PAGE_TIMEOUT", + "收货地址页面切换或保存确认超时,未进入不可逆下单阶段", + step="purchase_update_shipping_address", + diagnostics={"expected_page": kind}, + ) + def submit_order_once(self) -> None: if self._submit_attempted: raise PddPurchaseError( diff --git a/client/src/purchase_task_service.py b/client/src/purchase_task_service.py index e13f249..c912502 100644 --- a/client/src/purchase_task_service.py +++ b/client/src/purchase_task_service.py @@ -166,6 +166,16 @@ class PurchaseTaskService: state = adapter.read_state() self._validate_checkout_values(state, target) + if execution_mode == "live": + assert isinstance(adapter, PddLivePurchaseAdapter) + step = "purchase_update_shipping_address" + self._enter_step(remote_task_id, started.attempt_id, step) + adapter.update_shipping_address(remote_task_id) + + step = "purchase_verify_after_address" + self._enter_step(remote_task_id, started.attempt_id, step) + self._validate_checkout_values(adapter.read_state(), target) + step = "purchase_enter_confirmation" self._enter_step(remote_task_id, started.attempt_id, step) self._validate_checkout_values(adapter.read_state(), target) diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py index bdcb67d..2d23a9e 100644 --- a/client/test/test_pdd_u2_purchase_adapter.py +++ b/client/test/test_pdd_u2_purchase_adapter.py @@ -13,6 +13,7 @@ from src.pdd_u2_purchase_adapter import ( U2PddPurchaseAdapter, _final_submit_targets, _page_kind, + _tagged_shipping_address, ) @@ -239,6 +240,114 @@ class ContextualConfirmPanelDevice(FakeDevice): self.mode = "panel" +def address_confirmation_xml(address: str) -> str: + return f""" + + + + + + + + + + + + + + + + + """ + + +def address_panel_xml(address: str) -> str: + return f""" + + + + + + + + + + + """ + + +def address_edit_xml(address: str) -> str: + return f""" + + + + + + + + + + + + + """ + + +class FakeAddressEditor: + def __init__(self, device, matched: bool) -> None: + self.device = device + self.count = 1 if matched else 0 + + def set_text(self, value: str) -> None: + self.device.address = value + + +class AddressFlowDevice(FakeDevice): + def __init__(self) -> None: + super().__init__() + self.address = "测试省测试市测试区测试路1号-1dd212" + + def dump_hierarchy(self): + if not self.has_opened: + return '' + if self.mode == "home": + return home_xml() + if self.mode == "panel": + return address_confirmation_xml(self.address) + if self.mode in {"address_panel", "address_saved"}: + return address_panel_xml(self.address) + if self.mode == "address_edit": + return address_edit_xml(self.address) + raise AssertionError(f"未知测试页面:{self.mode}") + + def click(self, x, y): + self.clicks.append((x, y)) + if self.mode == "home": + self.mode = "panel" + elif self.mode == "panel" and y < 600: + self.mode = "address_panel" + elif self.mode == "address_panel": + self.mode = "address_edit" + elif self.mode == "address_edit": + self.mode = "address_saved" + elif self.mode == "address_saved": + self.mode = "panel" + + def __call__(self, **selector): + if ( + selector.get("className") == "android.widget.EditText" + and "text" in selector + ): + return FakeAddressEditor(self, selector["text"] == self.address) + return super().__call__(**selector) + + class U2PddPurchaseAdapterTest(unittest.TestCase): def _adapter(self, device, calls): def select_color_fn(_device, _xml, target, **_kwargs): @@ -383,6 +492,47 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): self.assertEqual(device.clicks[-1], (540, 2140)) adapter.close() + def test_program_address_suffix_is_replaced_without_cutting_body(self): + self.assertEqual( + _tagged_shipping_address( + "测试省测试市测试区测试路1号-1dd212", "cg2" + ), + "测试省测试市测试区测试路1号_cg2", + ) + self.assertEqual( + _tagged_shipping_address( + "测试省测试市测试区测试路1号_cg2", "cg3" + ), + "测试省测试市测试区测试路1号_cg3", + ) + self.assertEqual( + _tagged_shipping_address( + "测试省-开发区测试路1号", "cg4" + ), + "测试省-开发区测试路1号_cg4", + ) + + def test_live_adapter_updates_address_and_returns_to_confirmation(self): + device = AddressFlowDevice() + adapter = self._live_adapter(device, []) + adapter.open_goods(GOODS_URL) + adapter.select_options( + {"color": "黑色", "size": "3XL【140-165斤】"} + ) + adapter.set_quantity(1) + + adapter.update_shipping_address("cg2") + state = adapter.read_state() + + self.assertEqual( + device.address, "测试省测试市测试区测试路1号_cg2" + ) + self.assertEqual(device.mode, "panel") + self.assertEqual(state.page_kind, "order_confirmation") + self.assertEqual(state.quantity, 1) + self.assertEqual(state.price_cent, 503) + adapter.close() + def test_reliable_pdd_tree_skips_repeated_app_current(self): device = FakeDevice() adapter = self._adapter(device, []) diff --git a/client/test/test_purchase_task_service.py b/client/test/test_purchase_task_service.py index afdc4cc..a9e5511 100644 --- a/client/test/test_purchase_task_service.py +++ b/client/test/test_purchase_task_service.py @@ -92,11 +92,27 @@ class RecordingDryRunAdapter(PddPurchaseAdapter): class RecordingLiveAdapter(RecordingDryRunAdapter, PddLivePurchaseAdapter): """只记录一次提交调用的 live 测试 Adapter。""" - def __init__(self, *, submit_error: bool = False, **kwargs) -> None: + def __init__( + self, + *, + submit_error: bool = False, + address_error: bool = False, + **kwargs, + ) -> None: super().__init__(**kwargs) self.submit_error = submit_error + self.address_error = address_error self.submit_count = 0 + def update_shipping_address(self, purchase_number: str) -> None: + self.calls.append(("update_shipping_address", purchase_number)) + if self.address_error: + raise PddPurchaseError( + "PURCHASE_ADDRESS_PAGE_TIMEOUT", + "地址页面切换超时", + step="purchase_update_shipping_address", + ) + def submit_order_once(self) -> None: self.submit_count += 1 self.calls.append(("submit_order_once",)) @@ -324,6 +340,7 @@ class PurchaseTaskServiceTest(unittest.TestCase): self.assertEqual(outcome.kind, "reconcile_pending") self.assertEqual(adapter.submit_count, 1) + self.assertIn(("update_shipping_address", "PUR-001"), adapter.calls) detail = self.repository.get_task("PUR-001") run = self.repository.latest_task_run("PUR-001") self.assertEqual(detail.status, TaskStatus.MANUAL_REVIEW) @@ -344,6 +361,21 @@ class PurchaseTaskServiceTest(unittest.TestCase): run = self.repository.latest_task_run("PUR-001") self.assertIsNone(run.irreversible_action_at) + def test_live_address_failure_stops_before_irreversible(self): + self._prepare_task(execution_mode="live") + adapter = RecordingLiveAdapter(address_error=True) + + outcome = self._service(adapter).execute_one_local() + + self.assertEqual(outcome.kind, "task_failed") + self.assertEqual(adapter.submit_count, 0) + run = self.repository.latest_task_run("PUR-001") + self.assertIsNone(run.irreversible_action_at) + detail = self.repository.get_task("PUR-001") + self.assertEqual( + detail.last_error_code, "PURCHASE_ADDRESS_PAGE_TIMEOUT" + ) + def test_live_submit_error_never_retries_and_still_enters_reconcile(self): self._prepare_task(execution_mode="live") adapter = RecordingLiveAdapter(submit_error=True) diff --git a/client/test/test_task_dispatcher.py b/client/test/test_task_dispatcher.py index a0db1d6..60711a1 100644 --- a/client/test/test_task_dispatcher.py +++ b/client/test/test_task_dispatcher.py @@ -76,6 +76,9 @@ class ReadyPurchaseAdapter(PddPurchaseAdapter): class ReadyLivePurchaseAdapter(ReadyPurchaseAdapter, PddLivePurchaseAdapter): + def update_shipping_address(self, purchase_number): + self.calls.append(("purchase", "address", purchase_number)) + def read_state(self): state = super().read_state() return PurchasePageState( diff --git a/docs/client/01-requirements.md b/docs/client/01-requirements.md index 27df3a3..3e56f62 100644 --- a/docs/client/01-requirements.md +++ b/docs/client/01-requirements.md @@ -80,10 +80,16 @@ Client 应执行: 1. 校验商品、规格、数量、库存和价格保护条件。 2. 选择指定颜色、尺码和数量。 -3. 进入订单提交前状态并再次校验价格。 -4. 在允许真实下单时提交订单。 -5. 获取并核对订单编号和下单时间。 -6. 保存本地结果并提交 Admin。 +3. 正式采购使用远程任务编号更新收货地址末尾的采购编号标记,演练流程不修改地址。 +4. 返回订单提交前状态并再次校验商品、规格、数量和价格。 +5. 在允许真实下单时提交订单。 +6. 获取并核对订单编号和下单时间。 +7. 保存本地结果并提交 Admin。 + +地址更新只允许替换程序生成的末尾标记,不能截断真实地址主体。地址入口、修改按钮、 +详细地址输入框、保存按钮或保存结果任一项不唯一、不完整或无法回读时,任务必须在 +不可逆标记写入前失败并停止。姓名、手机号、完整地址和原始控件树只用于本次设备会话, +不得写入 SQLite、日志、Gitea、测试固件或文档。 仅凭“我的订单”列表中的最新一条记录不能认定为当前任务订单。订单页必须读到 非空订单编号、有效下单时间和待付款状态;下单时间须位于本地 diff --git a/docs/client/02-architecture.md b/docs/client/02-architecture.md index bb2b817..2e45fa3 100644 --- a/docs/client/02-architecture.md +++ b/docs/client/02-architecture.md @@ -401,8 +401,11 @@ reconcile_purchase(task, run) -> PurchaseResult | ManualReview 采购演练通过 `PddPurchaseAdapter` 的窄接口逐步读取最新页面状态。该接口只提供 打开商品、读取状态、精确选择动态规格、设置数量、进入提交前确认页和停止, **不提供提交订单或付款方法**。这样即使应用层调用错误,也没有可误触的真实下单入口。 -真实采购另用 `PddLivePurchaseAdapter`,只增加 `submit_order_once`。服务层在最新 -页面复核规格、数量、价格、库存和唯一提交目标后,先提交 +真实采购另用 `PddLivePurchaseAdapter`,只增加下单前地址标记更新和 +`submit_order_once`。地址标记使用任务现有的 `remote_task_id`,只替换程序生成的 +末尾标记;修改、保存和回读任一步不可靠时在可逆阶段停止。地址只在设备会话内处理, +不进入数据库、日志、诊断产物或接口结果。服务层在地址更新后重新读取最新页面, +复核规格、数量、价格、库存和唯一提交目标后,先提交 `irreversible_action_at` 事务,再允许 Adapter 点击一次;之后无论点击结果是否明确, 都只进入订单核对。该接口不提供付款或取消订单方法。 采购规格使用完整 `options` 对象精确比较,不假定只有颜色和尺码两个维度。