feat: 实现受控真实下单安全边界 (#99)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""uiautomator2 采购演练 Adapter。
|
||||
"""uiautomator2 采购 Adapter。
|
||||
|
||||
本模块只到 PDD 最终提交订单按钮前。代码中没有点击提交订单
|
||||
或付款的方法。
|
||||
演练 factory 返回没有提交方法的窄接口;live factory 单独返回只允许一次提交的
|
||||
接口。两条路径都不提供付款、取消订单或绕过安全校验的方法。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,6 +19,7 @@ from .pdd_device_service import (
|
||||
PddDeviceService,
|
||||
)
|
||||
from .pdd_purchase_adapter import (
|
||||
PddLivePurchaseAdapter,
|
||||
PddPurchaseAdapter,
|
||||
PddPurchaseError,
|
||||
PurchasePageState,
|
||||
@@ -36,6 +37,7 @@ _CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击
|
||||
_RISK_MARKERS = ("操作频繁", "异常请求", "风险提示", "账号异常")
|
||||
_PAYMENT_MARKERS = ("输入支付密码", "立即支付", "支付成功", "支付失败")
|
||||
_FINAL_SUBMIT_MARKERS = ("提交订单", "现在买,仅", "确认购买")
|
||||
_OUT_OF_STOCK_MARKERS = ("已售罄", "暂时缺货", "库存不足", "该商品已售罄")
|
||||
_SUPPORTED_OPTION_KEYS = frozenset({"color", "size"})
|
||||
|
||||
|
||||
@@ -200,6 +202,33 @@ def _price_cent(root: ET.Element) -> int:
|
||||
return max(parsed_nodes)[2]
|
||||
|
||||
|
||||
def _final_submit_targets(root: ET.Element) -> list[Bounds]:
|
||||
"""返回底部可见、启用且文字明确的唯一提交按钮坐标。"""
|
||||
|
||||
screen_bottom = 0
|
||||
for node in root.iter("node"):
|
||||
bounds = _parse_bounds(node.get("bounds", ""))
|
||||
if bounds is not None:
|
||||
screen_bottom = max(screen_bottom, bounds[3])
|
||||
if screen_bottom <= 0:
|
||||
return []
|
||||
|
||||
targets = set()
|
||||
for node in root.iter("node"):
|
||||
label = _label(node)
|
||||
bounds = _parse_bounds(node.get("bounds", ""))
|
||||
if not label or bounds is None:
|
||||
continue
|
||||
if not any(marker in label for marker in _FINAL_SUBMIT_MARKERS):
|
||||
continue
|
||||
if node.get("visible-to-user") != "true" or node.get("enabled") != "true":
|
||||
continue
|
||||
if (bounds[1] + bounds[3]) // 2 < screen_bottom * 0.6:
|
||||
continue
|
||||
targets.add(bounds)
|
||||
return sorted(targets)
|
||||
|
||||
|
||||
class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
||||
"""PDD 真机采购演练会话,不提供真实下单能力。"""
|
||||
|
||||
@@ -229,6 +258,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
||||
self._device = None
|
||||
self._goods_id = ""
|
||||
self._requested_options: dict[str, str] = {}
|
||||
self._submit_attempted = False
|
||||
|
||||
def open_goods(self, goods_url: str) -> None:
|
||||
self._goods_id = _goods_id_from_url(goods_url)
|
||||
@@ -268,6 +298,8 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
||||
xml_data = self._dump_hierarchy()
|
||||
root = _parse_xml(xml_data)
|
||||
kind = _page_kind(root, str(current.get("package") or ""))
|
||||
labels = _labels(root)
|
||||
submit_targets = _final_submit_targets(root)
|
||||
selected = {
|
||||
key: value
|
||||
for key, value in self._requested_options.items()
|
||||
@@ -280,6 +312,11 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
||||
quantity=_quantity(root),
|
||||
price_cent=_price_cent(root),
|
||||
candidate_count=1 if kind != "unknown" else 0,
|
||||
in_stock=not any(
|
||||
marker in " ".join(labels)
|
||||
for marker in _OUT_OF_STOCK_MARKERS
|
||||
),
|
||||
submit_candidate_count=len(submit_targets),
|
||||
)
|
||||
except PddPurchaseError:
|
||||
raise
|
||||
@@ -512,3 +549,57 @@ def create_u2_purchase_adapter(
|
||||
device_service=_PURCHASE_DEVICE_SERVICE,
|
||||
cancelled=cancelled,
|
||||
)
|
||||
|
||||
|
||||
class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter):
|
||||
"""只允许一次最终提交点击的真机 Adapter,不包含付款路径。"""
|
||||
|
||||
def submit_order_once(self) -> None:
|
||||
if self._submit_attempted:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_SUBMIT_ALREADY_ATTEMPTED",
|
||||
"本次采购已经尝试提交,禁止再次点击",
|
||||
step="purchase_submit_once",
|
||||
)
|
||||
self._check_cancelled("purchase_submit_once")
|
||||
device = self._require_device()
|
||||
try:
|
||||
current = device.app_current()
|
||||
root = _parse_xml(self._dump_hierarchy())
|
||||
kind = _page_kind(root, str(current.get("package") or ""))
|
||||
if kind in {"captcha", "login_required", "risk_control", "payment"}:
|
||||
self._raise_special_page(kind)
|
||||
if kind != "order_confirmation":
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_CONFIRMATION_LOST",
|
||||
"最终提交前页面已经变化,禁止提交订单",
|
||||
step="purchase_submit_once",
|
||||
)
|
||||
targets = _final_submit_targets(root)
|
||||
if len(targets) != 1:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
|
||||
"最终提交按钮不是唯一可靠目标,禁止提交订单",
|
||||
step="purchase_submit_once",
|
||||
diagnostics={"candidate_count": len(targets)},
|
||||
)
|
||||
left, top, right, bottom = targets[0]
|
||||
self._submit_attempted = True
|
||||
device.click((left + right) // 2, (top + bottom) // 2)
|
||||
except PddPurchaseError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._submit_attempted = True
|
||||
self._raise_device_or_page_error(exc, "purchase_submit_once")
|
||||
|
||||
|
||||
def create_u2_live_purchase_adapter(
|
||||
device_address: str, cancelled: Callable[[], bool]
|
||||
) -> PddLivePurchaseAdapter:
|
||||
"""为已通过本地绑定授权的任务创建一次 live 会话。"""
|
||||
|
||||
return U2PddLivePurchaseAdapter(
|
||||
device_address,
|
||||
device_service=_PURCHASE_DEVICE_SERVICE,
|
||||
cancelled=cancelled,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user