加 /api/cart/* 接口;加购链路完全归 trading

trading 新增 4 条购物车接口(POST /api/cart/{add,status,clear,remove}),
全部 Bearer 鉴权、走 SiteInteractor(Playwright + storage_state)。同步把
SiteInteractor 从 gateway URL 解耦——lifespan 总是构造与启停,container
字段 worker_site → site,加 asyncio.Lock 让 HTTP 与 worker 共用同一把锁
(同账号串行硬约束)。clear/remove 用 UI 点击 button[aria-label="削除"],
探针回报这是稳定 selector;真账号实测前先用此路径。

抽 ichiba 加购字段解析到 app/shared/purchase_contract.py(常量 +
inventory_flag_for + basket_domain_of + base_form_fields),原本 scraping
与 trading 重复实现同一段 __INITIAL_STATE__.purchase 解析。进一步发现
README 写的「purchase 块是两服务契约」实际未落地——trading 必须 Playwright
开页(httpx 被 TLS 指纹拦死),本地抽比再调 /api/item_detail 更快更新鲜。
删除 scraping 端 PurchaseInfo/PurchaseOption/PurchaseOptionValue 模型、
各站 _purchase_info 函数、tests/test_purchase.py。ItemDetailData 保留
purchase_condition / is_sold_out / purchase_unit / sku 等商品状态字段。
README「加购与下单」段重写。

328 测试全绿(含架构测试守住三方互不 import)。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 17:18:00 +08:00
co-authored by Claude Opus 4.6
parent 65e3ed31f8
commit 55c01ae4f5
14 changed files with 934 additions and 536 deletions
+148
View File
@@ -0,0 +1,148 @@
"""购物车路由:加购 / 查询状态 / 清空 / 删除指定商品
这些接口的目的是**测试加购与购物车操作是否稳定**,以及在下单流程跑测前
清空购物车避免脏数据。不是给上游业务系统调用的——上游走 order-gateway
拉单,由 worker 异步处理。
底层全走 SiteInteractor(Playwright + 账号 cookie);httpx 在带账号的写操作
上被 Rakuten TLS 指纹拦死,详见 project://jp-rakuten/checkout-flow-probe-findings。
所有路由共用一把 asyncio.Lock(持有在 SiteInteractor),同账号写操作串行
(per project://jp-rakuten/trading-split「全局并发度 1」硬约束)。
"""
from fastapi import APIRouter, Depends
from app.shared.api import ApiResponse, get_container, require_bearer_token
from app.shared.errors import UpstreamRequestError
from app.trading.container import TradingContainer
from app.trading.models import (
CartAddData,
CartAddRequest,
CartClearData,
CartClearRequest,
CartRemoveData,
CartRemoveRequest,
CartStatusData,
CartStatusRequest,
)
router = APIRouter(prefix="/api/cart", tags=["cart"])
def _require_site(container: TradingContainer):
"""取 container.site,未就绪时报错
生产环境 lifespan 一定构造了 site;只有测试或异常启动路径才会 None。
"""
if container.site is None:
raise UpstreamRequestError(
"SiteInteractor 未就绪:服务可能未正确启动(Playwright 未初始化)"
)
return container.site
@router.post(
"/add",
response_model=ApiResponse[CartAddData],
dependencies=[Depends(require_bearer_token)],
)
async def cart_add(
payload: CartAddRequest,
container: TradingContainer = Depends(get_container),
) -> ApiResponse[CartAddData]:
"""加购一件商品
底层:打开 item_url → 抽 __INITIAL_STATE__.purchase → POST basketDomain。
variant_id / choice 不传时按商品页定义自动选第一个候选值。
成功后顺带查一次 cart count API 一并返回。
"""
site = _require_site(container)
result = await site.add_to_cart_payload( # type: ignore[union-attr]
item_url=payload.item_url,
quantity=payload.quantity,
variant_id=payload.variant_id,
choice=payload.choice,
)
return ApiResponse[CartAddData](
success=True,
msg="success",
data=CartAddData(
added=True,
item_id=result["item_id"],
shop_bid=result["shop_bid"],
cart_count=result["cart_count"],
),
code=0,
)
@router.post(
"/status",
response_model=ApiResponse[CartStatusData],
dependencies=[Depends(require_bearer_token)],
)
async def cart_status(
payload: CartStatusRequest,
container: TradingContainer = Depends(get_container),
) -> ApiResponse[CartStatusData]:
"""查询购物车状态(轻量)
只调 cart count JSONP API,不渲染整页。返回登录态、商品件数与站点状态码。
"""
site = _require_site(container)
result = await site.cart_status() # type: ignore[union-attr]
return ApiResponse[CartStatusData](
success=True,
msg="success",
data=CartStatusData(**result),
code=0,
)
@router.post(
"/clear",
response_model=ApiResponse[CartClearData],
dependencies=[Depends(require_bearer_token)],
)
async def cart_clear(
payload: CartClearRequest,
container: TradingContainer = Depends(get_container),
) -> ApiResponse[CartClearData]:
"""清空购物车
策略:渲染 cart SPA → 反复点 button[aria-label="削除"] 直到没有该按钮 →
cart count API 校验。返回实际点击的按钮数与末尾 cart count。
"""
site = _require_site(container)
result = await site.clear_cart() # type: ignore[union-attr]
return ApiResponse[CartClearData](
success=True,
msg="success",
data=CartClearData(**result),
code=0,
)
@router.post(
"/remove",
response_model=ApiResponse[CartRemoveData],
dependencies=[Depends(require_bearer_token)],
)
async def cart_remove(
payload: CartRemoveRequest,
container: TradingContainer = Depends(get_container),
) -> ApiResponse[CartRemoveData]:
"""删除购物车里指定 item_id 的商品
策略:渲染 cart SPA → 在 DOM 里找 button[aria-label="削除"],向上 walk
parentElement 找 innerText 包含 item_id 的祖先,click 那个按钮。
Rakuten cart item 卡片无稳定 data-* 属性,靠文本回溯定位。
"""
site = _require_site(container)
result = await site.remove_item(payload.item_id) # type: ignore[union-attr]
return ApiResponse[CartRemoveData](
success=True,
msg="success",
data=CartRemoveData(**result),
code=0,
)