"""购物车路由:加购 / 查询状态 / 清空 / 删除指定商品 这些接口的目的是**测试加购与购物车操作是否稳定**,以及在下单流程跑测前 清空购物车避免脏数据。不是给上游业务系统调用的——上游走 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, )