Files
rakuten-api/app/trading/api/routes/cart.py
T
q792602257 086ab82614 feat(trading): 清空购物车落步骤证据,补空车/有商品两场景测试
- clear_cart 返回清理后 cart 页最终 HTML(best-effort,抓取失败不掩盖清理结果)
- runner step 0 落 00-cart-clear.{html,meta.json} 并登记 evidence_index,
  证据先于闸门判断落盘(清理未净被拦时这份现场就是排查依据);
  仍是本机卫生步骤:不上报 gateway、不记 order_events
- /api/cart/clear 响应不携带 html(路由显式挑字段,与 cart_add 同风格)
- 单测:fake page 覆盖空车/有商品/HTML 抓取失败;runner 层覆盖证据落盘
  与闸门拦截场景
- 真账号复验 scripts/verify_cart_clear.py:空车 removed=0/count=0;
  加购 1 件后 clear removed=1/count=0,status 复核 101
2026-08-17 08:32:11 +08:00

154 lines
5.1 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",
# result 还带清理后的页面 html(供 worker 落证据排查用),不进 HTTP 响应
data=CartClearData(
removed_count=result["removed_count"],
cart_count=result["cart_count"],
),
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,
)