"""PDD 采购演练适配层的稳定边界。 应用服务只依赖本文件中的小接口,不直接认识 uiautomator2。接口故意不提供 “提交订单”或“付款”方法,因此演练代码没有可误调用的真实下单入口。 """ from __future__ import annotations import hashlib from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Mapping, Sequence def _hash_frame(value: str) -> str: """按接口契约生成带 UTF-8 字节长度的哈希片段。""" return f"{len(value.encode('utf-8'))}:{value}" @dataclass(frozen=True) class PurchaseSizeObservation: """完整遍历时看到的一条第二规格,包含不可购买项。""" raw_text: str available: bool @dataclass(frozen=True) class PurchaseSpecCandidate: """可以交给 Admin 解析的一条可购买尺码候选。""" candidate_id: str raw_text: str options: Mapping[str, str] def to_dict(self) -> dict[str, object]: return { "candidate_id": self.candidate_id, "raw_text": self.raw_text, "options": dict(self.options), } @dataclass(frozen=True) class PurchaseSpecCandidateSnapshot: """当前商品、已选颜色下,一次完整且稳定的尺码候选观察。""" goods_id: str selected_color: str target_size: str dimension_name: str observations: tuple[PurchaseSizeObservation, ...] candidates: tuple[PurchaseSpecCandidate, ...] candidate_snapshot_hash: str observed_at: str @classmethod def build( cls, *, goods_id: str, selected_color: str, target_size: str, dimension_name: str, observations: Sequence[PurchaseSizeObservation], observed_at: str, ) -> "PurchaseSpecCandidateSnapshot": """按页面顺序生成候选短编号和 spec-resolution-v1 哈希。""" checked_observations = tuple(observations) candidates = tuple( PurchaseSpecCandidate( candidate_id=f"c{index}", raw_text=item.raw_text, options={"color": selected_color, "size": item.raw_text}, ) for index, item in enumerate( (item for item in checked_observations if item.available), start=1, ) ) material = "".join( ( _hash_frame("spec-resolution-v1"), _hash_frame(goods_id), _hash_frame(selected_color), _hash_frame(str(len(candidates))), *( "".join( ( _hash_frame(item.candidate_id), _hash_frame(item.raw_text), _hash_frame(str(item.options["color"])), _hash_frame(str(item.options["size"])), ) ) for item in candidates ), ) ) return cls( goods_id=goods_id, selected_color=selected_color, target_size=target_size, dimension_name=dimension_name, observations=checked_observations, candidates=candidates, candidate_snapshot_hash=hashlib.sha256( material.encode("utf-8") ).hexdigest(), observed_at=observed_at, ) def to_dict(self) -> dict[str, object]: """返回确定顺序的内存结构;调用方不得把它直接写入日志。""" return { "goods_id": self.goods_id, "selected_color": self.selected_color, "target_size": self.target_size, "dimension_name": self.dimension_name, "observations": [ {"raw_text": item.raw_text, "available": item.available} for item in self.observations ], "candidates": [item.to_dict() for item in self.candidates], "candidate_snapshot_hash": self.candidate_snapshot_hash, "observed_at": self.observed_at, } @dataclass(frozen=True) class PurchasePageState: """一次重新读取页面后得到的采购相关事实。""" page_kind: str goods_id: str selected_options: Mapping[str, str] = field(default_factory=dict) quantity: int = 0 # 当前页面显示的采购金额;最终确认页必须是订单总价,单位人民币分。 price_cent: int = 0 candidate_count: int = 1 in_stock: bool = True submit_candidate_count: int = 0 class PddPurchaseError(RuntimeError): """适配层失败,包含稳定代码、步骤和是否可安全重试。""" def __init__( self, code: str, message: str, *, step: str, retryable: bool = False, diagnostics: Mapping[str, object] | None = None, ) -> None: super().__init__(message) self.code = str(code or "PURCHASE_ADAPTER_ERROR") self.message = str(message or "PDD 采购演练失败") self.step = str(step or "purchase_prepare") self.retryable = bool(retryable) self.diagnostics = dict(diagnostics or {}) class PddPurchaseSpecResolutionRequired(PddPurchaseError): """本地无法精确匹配,但已得到完整可购买候选快照。""" def __init__(self, snapshot: PurchaseSpecCandidateSnapshot) -> None: super().__init__( "PURCHASE_SPEC_RESOLUTION_REQUIRED", f"目标尺码无法精确匹配,需要解析候选:{snapshot.target_size}", step="purchase_select_options", diagnostics={ "selection_failure": "target_not_visible", "candidate_count": len(snapshot.candidates), "candidate_snapshot_hash": snapshot.candidate_snapshot_hash, "dimension_name": snapshot.dimension_name, }, ) self.snapshot = snapshot class PddPurchaseAdapter(ABC): """一台设备的一次采购演练会话;只能由创建它的工作线程使用。""" @abstractmethod def open_goods(self, goods_url: str) -> None: """通过商品链接打开 PDD 商品页。""" @abstractmethod def read_state(self) -> PurchasePageState: """重新读取当前页面,不返回缓存状态。""" @abstractmethod def select_options(self, options: Mapping[str, str]) -> None: """按完整动态规格对象精确选择,不做相似匹配。""" def apply_resolved_size( self, expected_snapshot: PurchaseSpecCandidateSnapshot, candidate: PurchaseSpecCandidate, ) -> None: """重新核对真机候选后应用 Admin 返回的原始尺码。""" raise PddPurchaseError( "PURCHASE_SPEC_RESOLUTION_UNSUPPORTED", "当前采购 Adapter 不支持运行时规格解析", step="purchase_resolve_options", ) @abstractmethod def set_quantity(self, quantity: int) -> None: """设置采购数量。""" @abstractmethod def enter_confirmation(self) -> None: """进入最终提交前确认页,但不得提交订单。""" @abstractmethod def stop_before_submit(self) -> None: """在提交订单按钮之前停止并保持页面可供人工核对。""" @abstractmethod def close(self) -> None: """释放设备会话;不得在此方法中产生页面点击。""" class PddLivePurchaseAdapter(PddPurchaseAdapter): """受控真实采购接口;增加地址标记和一次性提交,不提供付款或取消。""" @abstractmethod def update_shipping_address(self, purchase_number: str) -> None: """下单前更新地址末尾采购编号;失败时必须保持在可逆阶段。""" @abstractmethod def submit_order_once(self) -> None: """用最新页面状态确认唯一按钮并单击一次;调用后禁止重试。"""