Files
cmautobuy/client/src/pdd_purchase_adapter.py
T

241 lines
7.9 KiB
Python
Raw Normal View History

2026-08-09 23:39:37 +08:00
"""PDD 采购演练适配层的稳定边界。
应用服务只依赖本文件中的小接口,不直接认识 uiautomator2。接口故意不提供
“提交订单”或“付款”方法,因此演练代码没有可误调用的真实下单入口。
"""
from __future__ import annotations
2026-08-17 17:04:43 +08:00
import hashlib
2026-08-09 23:39:37 +08:00
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
2026-08-17 17:04:43 +08:00
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,
}
2026-08-09 23:39:37 +08:00
@dataclass(frozen=True)
class PurchasePageState:
"""一次重新读取页面后得到的采购相关事实。"""
page_kind: str
goods_id: str
selected_options: Mapping[str, str] = field(default_factory=dict)
quantity: int = 0
# 当前页面显示的采购金额;最终确认页必须是订单总价,单位人民币分。
2026-08-09 23:39:37 +08:00
price_cent: int = 0
candidate_count: int = 1
in_stock: bool = True
submit_candidate_count: int = 0
2026-08-09 23:39:37 +08:00
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 {})
2026-08-17 17:04:43 +08:00
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
2026-08-09 23:39:37 +08:00
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",
)
2026-08-09 23:39:37 +08:00
@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:
"""用最新页面状态确认唯一按钮并单击一次;调用后禁止重试。"""