feat(trading): support multi-item purchase intents
This commit is contained in:
@@ -94,7 +94,7 @@ import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
from typing import TYPE_CHECKING, Any, TypeVar
|
||||
|
||||
from opentelemetry.trace import SpanKind
|
||||
|
||||
@@ -649,9 +649,9 @@ class SiteInteractor:
|
||||
clear_cart() / remove_item(item_id)
|
||||
"""
|
||||
|
||||
# 每任务保留的临时状态:task_id → {"item_id": str, "shop_bid": str}
|
||||
# 用于 add_to_cart 把抓出来的 item_id / shop_bid 喂给 verify_cart
|
||||
_per_task_state: dict[str, dict[str, str]]
|
||||
# 每任务保留的临时状态:task_id → 首个商品字段及全部 item_ids。
|
||||
# 旧调用方仍读取 item_id,新调用方由 item_ids 校验整组商品。
|
||||
_per_task_state: dict[str, dict[str, Any]]
|
||||
|
||||
# 每任务保留的下单确认页 Page:enter_checkout 落地后不关闭页面,存在这里,
|
||||
# submit_order / pay 复用同一个页面继续操作——下单确认页是服务端会话态,
|
||||
@@ -984,40 +984,36 @@ class SiteInteractor:
|
||||
async def add_to_cart(self, task: LeaseTask) -> PageSnapshot:
|
||||
"""加购(worker 入口):从 task.intent 取字段,调 _add_to_cart_with_fields
|
||||
|
||||
调用方需在 task.intent 提供:
|
||||
- item_url: 商品详情页 URL(必填)
|
||||
- quantity: 数量,默认 1
|
||||
- variant_id: 多规格商品的 variant_id;不传则从 sku.variants[] 自动选第一个非售罄
|
||||
- choice: 必填选项的取值列表;不传则每个必填选项用第一个候选值(站点不严格校验)
|
||||
调用方可在 task.intent 提供 `items` 商品数组;数组元素字段与旧版单商品
|
||||
字段相同(item_url / quantity / variant_id / choice)。为兼容已发布的
|
||||
调用方,也接受顶层 item_url 等旧字段并自动包装成单元素数组。
|
||||
|
||||
Returns:
|
||||
PageSnapshot:加购响应的落地页 HTML + 商品页整页截图,供 runner 落证据。
|
||||
|
||||
Raises:
|
||||
InvalidRequestError: intent.item_url 缺失
|
||||
InvalidRequestError: intent.item_url 缺失或 intent.items 格式非法
|
||||
NotLoggedInError: 登录态失效
|
||||
CartOperationError: 商品页打不开、state 解析失败、商品不可购买、加购返回错误页
|
||||
"""
|
||||
intent = task.intent or {}
|
||||
item_url = intent.get("item_url")
|
||||
if not item_url:
|
||||
raise InvalidRequestError("intent.item_url 必填")
|
||||
quantity = int(intent.get("quantity") or 1)
|
||||
if quantity <= 0:
|
||||
raise InvalidRequestError(f"intent.quantity 必须为正整数,收到 {quantity}")
|
||||
items = _normalize_intent_items(task.intent or {})
|
||||
|
||||
async with self._lock:
|
||||
result = await self._add_to_cart_with_fields(
|
||||
item_url=item_url,
|
||||
quantity=quantity,
|
||||
variant_id=intent.get("variant_id"),
|
||||
choice=intent.get("choice"),
|
||||
)
|
||||
self._per_task_state[task.task_id] = {
|
||||
"item_id": result["item_id"],
|
||||
"shop_bid": result["shop_bid"],
|
||||
"basket_domain": result["basket_domain"],
|
||||
results = []
|
||||
for item in items:
|
||||
results.append(await self._add_to_cart_with_fields(**item))
|
||||
first = results[0]
|
||||
state: dict[str, Any] = {
|
||||
# 保留旧字段,避免已有 verify/监控代码及外部桩失效。
|
||||
"item_id": first["item_id"],
|
||||
"shop_bid": first["shop_bid"],
|
||||
"basket_domain": first["basket_domain"],
|
||||
}
|
||||
if len(results) > 1:
|
||||
state["item_ids"] = [result["item_id"] for result in results]
|
||||
state["items"] = results
|
||||
self._per_task_state[task.task_id] = state
|
||||
result = results[-1]
|
||||
return PageSnapshot(
|
||||
html=result.get("response_html") or "",
|
||||
screenshot=result.get("screenshot") or b"",
|
||||
@@ -1199,8 +1195,15 @@ class SiteInteractor:
|
||||
await self._ensure_context_ready()
|
||||
|
||||
per_task = self._per_task_state.get(task.task_id, {})
|
||||
item_id = (task.intent or {}).get("item_id") or per_task.get("item_id")
|
||||
if not item_id:
|
||||
intent_item_id = (task.intent or {}).get("item_id")
|
||||
item_ids = (
|
||||
[intent_item_id]
|
||||
if intent_item_id
|
||||
else (per_task.get("item_ids") or [])
|
||||
)
|
||||
if not item_ids and per_task.get("item_id"):
|
||||
item_ids = [per_task["item_id"]]
|
||||
if not item_ids:
|
||||
raise CartOperationError(
|
||||
"无法确定 item_id:intent 未提供且 add_to_cart 未记录"
|
||||
)
|
||||
@@ -1211,8 +1214,15 @@ class SiteInteractor:
|
||||
raise CartOperationError("购物车为空,加购可能未生效")
|
||||
logger.info("cart count=%s task_id=%s", count, task.task_id)
|
||||
|
||||
# 2. 渲染 cart 页确认 item_id 在里面
|
||||
return await self._verify_item_in_cart_html(item_id, label=f"task_id={task.task_id}")
|
||||
# 2. 渲染一次 cart 页确认本任务的全部商品都在里面。
|
||||
# 单商品继续走旧 helper,保留原有测试桩与内部调用契约。
|
||||
if len(item_ids) == 1:
|
||||
return await self._verify_item_in_cart_html(
|
||||
str(item_ids[0]), label=f"task_id={task.task_id}"
|
||||
)
|
||||
return await self._verify_items_in_cart_html(
|
||||
[str(item_id) for item_id in item_ids], label=f"task_id={task.task_id}"
|
||||
)
|
||||
|
||||
@traced("site.cart_status", kind=SpanKind.CLIENT)
|
||||
async def cart_status(self) -> dict:
|
||||
@@ -1436,6 +1446,12 @@ class SiteInteractor:
|
||||
|
||||
返回渲染后的 cart 页 HTML + 整页截图(校验通过时),供调用方落证据。
|
||||
"""
|
||||
return await self._verify_items_in_cart_html([item_id], label=label)
|
||||
|
||||
async def _verify_items_in_cart_html(
|
||||
self, item_ids: list[str], *, label: str
|
||||
) -> PageSnapshot:
|
||||
"""渲染一次 cart SPA,确认多个 item_id 都存在,避免多商品任务重复开页。"""
|
||||
page = await self._new_page()
|
||||
try:
|
||||
await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
|
||||
@@ -1448,11 +1464,12 @@ class SiteInteractor:
|
||||
site="rakuten",
|
||||
detail="购物车页出现旧版未登录 marker",
|
||||
)
|
||||
if str(item_id) not in html:
|
||||
missing = [item_id for item_id in item_ids if str(item_id) not in html]
|
||||
if missing:
|
||||
raise CartOperationError(
|
||||
f"购物车页未找到 item_id={item_id}(加购可能被服务端静默丢弃)"
|
||||
f"购物车页未找到 item_id={missing}(加购可能被服务端静默丢弃)"
|
||||
)
|
||||
logger.info("cart 校验通过:%s item_id=%s in cart HTML", label, item_id)
|
||||
logger.info("cart 校验通过:%s item_ids=%s in cart HTML", label, item_ids)
|
||||
# 校验通过的 cart 页整页截图随结果带出;失败不掩盖校验结果
|
||||
screenshot = b""
|
||||
try:
|
||||
@@ -2370,6 +2387,63 @@ class SiteInteractor:
|
||||
# ---- 模块级辅助函数(纯函数,便于单测)----
|
||||
|
||||
|
||||
def _normalize_intent_items(intent: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""把新旧下单意图统一成加购参数列表。
|
||||
|
||||
新格式是 ``{"items": [{"item_url": ..., "quantity": ...}, ...]}``;
|
||||
旧格式的 ``item_url/quantity/variant_id/choice`` 仍直接支持。字符串元素
|
||||
也接受,方便只传多个 URL 的调用方。
|
||||
"""
|
||||
raw_items = intent.get("items")
|
||||
legacy_single = raw_items is None
|
||||
if raw_items is None:
|
||||
if not intent.get("item_url"):
|
||||
# 保持已发布的单商品错误契约不变。
|
||||
raise InvalidRequestError("intent.item_url 必填")
|
||||
raw_items = [intent]
|
||||
if not isinstance(raw_items, list) or not raw_items:
|
||||
raise InvalidRequestError("intent.items 必须是非空数组")
|
||||
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for index, raw in enumerate(raw_items):
|
||||
if isinstance(raw, str):
|
||||
raw = {"item_url": raw}
|
||||
if not isinstance(raw, dict):
|
||||
raise InvalidRequestError(f"intent.items[{index}] 必须是对象")
|
||||
item_url = raw.get("item_url")
|
||||
if not item_url:
|
||||
raise InvalidRequestError(f"intent.items[{index}].item_url 必填")
|
||||
try:
|
||||
quantity = int(raw.get("quantity") or 1)
|
||||
except (TypeError, ValueError) as exc:
|
||||
field_name = (
|
||||
"intent.quantity"
|
||||
if legacy_single
|
||||
else f"intent.items[{index}].quantity"
|
||||
)
|
||||
raise InvalidRequestError(
|
||||
f"{field_name} 必须为正整数"
|
||||
) from exc
|
||||
if quantity <= 0:
|
||||
field_name = (
|
||||
"intent.quantity"
|
||||
if legacy_single
|
||||
else f"intent.items[{index}].quantity"
|
||||
)
|
||||
raise InvalidRequestError(
|
||||
f"{field_name} 必须为正整数,收到 {quantity}"
|
||||
)
|
||||
normalized.append(
|
||||
{
|
||||
"item_url": str(item_url),
|
||||
"quantity": quantity,
|
||||
"variant_id": raw.get("variant_id"),
|
||||
"choice": raw.get("choice"),
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _parse_initial_state(html: str) -> dict | None:
|
||||
"""从商品页 HTML 抽 window.__INITIAL_STATE__ 并解析为 dict"""
|
||||
m = re.search(
|
||||
|
||||
Reference in New Issue
Block a user