加 /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:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user