150 lines
4.9 KiB
Python
150 lines
4.9 KiB
Python
"""购物车路由:加购 / 查询状态 / 清空 / 删除指定商品
|
|
|
|
这些接口的目的是**测试加购与购物车操作是否稳定**,以及在下单流程跑测前
|
|
清空购物车避免脏数据。不是给上游业务系统调用的——上游走 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,不渲染整页。返回登录态、商品件数与站点状态码。
|
|
空车是正常结果:count=0 且 raw_status="101";获取失败走错误码 5002。
|
|
"""
|
|
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,
|
|
)
|