fix(trading): cart count status=101 识别为空车,区分空车与获取失败

This commit is contained in:
2026-08-17 01:42:08 +08:00
parent 48c6f2a28f
commit daa5555fd4
6 changed files with 279 additions and 4 deletions
+1
View File
@@ -607,6 +607,7 @@ docker compose --profile gateway up -d
- `POST /api/cart/add` — 加购,入参 `{item_url, quantity?, variant_id?, choice?}`
- `POST /api/cart/status` — 调 cart count API,返回购物车商品件数
(空车是正常结果:`count=0, raw_status="101"`;获取失败才报错 5002)
- `POST /api/cart/clear` — 清空购物车(UI 点击 `button[aria-label="削除"]`
- `POST /api/cart/remove` — 删除指定 `item_id`
+1
View File
@@ -88,6 +88,7 @@ async def cart_status(
"""查询购物车状态(轻量)
只调 cart count JSONP API,不渲染整页。返回登录态、商品件数与站点状态码。
空车是正常结果:count=0 且 raw_status="101";获取失败走错误码 5002。
"""
site = _require_site(container)
result = await site.cart_status() # type: ignore[union-attr]
+24 -4
View File
@@ -6,6 +6,10 @@ data/evidence/checkout-research-20260811/NOTES.md):
- clear_cart、remove_item 用 UI 点击 `button[aria-label^="削除"]` 路径实现,
2026-08-11 用真账号跑通下单测试时确认实际 aria-label 是「削除する」而非旧探针
记录的「削除」,selector 已改前缀匹配;modal 是否存在仍未实测确认
- cart count JSONP API 契约 2026-08-16 真账号实测(scripts/probe_cart_count.py +
.probe/cart_count/):status=101 + count="" 是**空车**(合法响应,记 count=0),
status=300(referer/sid 被拒)才是获取失败——此前一律按失败处理,
空车时 clear_cart 末尾校验拿 -1、开单前清理被误判失败
- enter_checkout 已实现到「进入 session upgrade 密码页并尝试自动复核」:购物车页
点击真正的「購入手続き」(注意页面上还有个促销用的「カード入会&購入手続き」
按钮,selector 必须精确匹配,不能 .first 模糊选)后,即使 SSO 会话仍有效,
@@ -133,6 +137,11 @@ _BASKET_PATH = "/rms/mall/bss/cartadd/set"
_CART_PAGE = auth_site.RAKUTEN_CART_URL
_CART_COUNT_API = "https://cart-api.step.rakuten.co.jp/rms/mall/cart/count/all/jsonp/"
# cart count API 的「空车」状态码(2026-08-16 真账号实测,
# scripts/probe_cart_count.py):status=101 + message "value not found." +
# count="" 表示购物车为空,是合法响应而非获取失败;status=300 之类才是被拒
_CART_EMPTY_STATUS = "101"
# 购物车页未登录标记(旧 marker;新 SPA 上不可靠,这里只作辅助判据)
_LEGACY_LOGGED_OUT_MARKER = auth_site.RAKUTEN_LOGGED_OUT_MARKER
@@ -942,6 +951,7 @@ class SiteInteractor:
策略(探针实测最稳的两步):
1. 打 cart count JSONP API:status=100 且 count>=1 才算「购物车非空」
(status=101 是空车,count 记 0,见 _query_cart_count)
2. 打开 cart 页等 SPA 渲染,在 HTML 里找 item_id
Returns:
@@ -974,7 +984,8 @@ class SiteInteractor:
返回 {logged_in, count, raw_status}。
count 是站点返回的购物车里商品总件数(含数量,非 SKU 数)。
raw_status 是站点的状态码字符串"100" 表示正常。
raw_status 是站点的状态码字符串"100" 非空、"101" 空车(count=0);
其余 status 属「获取失败」,抛 CartOperationError 而不是返回。
"""
async with self._lock:
await self._auth_session.require_logged_in("rakuten")
@@ -999,7 +1010,8 @@ class SiteInteractor:
- 若 SPA 把按钮渲染在 iframe 里,selector 失败需实测后调整
- Rakuten cart item 卡片无 data-testid,本方法不依赖 DOM 结构定位
返回 {removed_count, cart_count};cart_count=-1 表示末尾 count API 调用失败
返回 {removed_count, cart_count};cart_count=-1 表示末尾 count API 调用失败
(空车属正常结果返回 0,见 _query_cart_count 的 status=101 处理)。
"""
async with self._lock:
await self._auth_session.require_logged_in("rakuten")
@@ -1125,8 +1137,14 @@ class SiteInteractor:
async def _query_cart_count(self) -> tuple[str, int]:
"""调 cart count JSONP API,返回 (raw_status, count)
解析失败、status 非 100 都抛 CartOperationError——这是站点在告诉我们
「请求被拒了」,常见原因是 Referer 错或 cookie 失效。
站点契约(2026-08-16 真账号实测,scripts/probe_cart_count.py +
.probe/cart_count/)——必须区分「车是空的」与「获取失败」:
- status "100":购物车非空,count 是数字字符串
- status "101"(message "value not found."):**购物车为空**,count 是
空串——这是合法结果,返回 ("101", 0),不能当失败
- 其余 status(实测如 "300":referer 缺失 / sid 不可用)或响应无法解析,
才是「获取失败」,抛 CartOperationError——常见原因是 Referer 错或
cookie 失效
"""
resp = await self._context.request.get(
_CART_COUNT_API + "?sid=1010",
@@ -1137,6 +1155,8 @@ class SiteInteractor:
if not status_match:
raise CartOperationError(f"cart count 响应无法解析:{body[:200]!r}")
raw_status = status_match.group(1)
if raw_status == _CART_EMPTY_STATUS:
return raw_status, 0
if raw_status != "100":
raise CartOperationError(
f"cart count API 异常:status={raw_status} body={body[:200]!r}"
+126
View File
@@ -0,0 +1,126 @@
"""cart count API 探针:实测「空购物车」与「获取失败」两种响应形态
背景_query_cart_count 目前只认 status=="100"其余一律 CartOperationError
需要真账号确认购物车为空时 cart-api.step.rakuten.co.jp 到底返回什么
如果空车响应与请求被拒长的不一样却被同一错误吞掉就无法区分
车是空的获取失败
只读探测不做任何加购/删除操作
用法
.venv/Scripts/python.exe scripts/probe_cart_count.py
.venv/Scripts/python.exe scripts/probe_cart_count.py --headful
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.shared.config import get_settings # noqa: E402
from app.trading.core import auth_site # noqa: E402
PROBE_DIR = Path(__file__).resolve().parent.parent / ".probe" / "cart_count"
PROBE_DIR.mkdir(parents=True, exist_ok=True)
CART_COUNT_API = "https://cart-api.step.rakuten.co.jp/rms/mall/cart/count/all/jsonp/"
CART_PAGE = auth_site.RAKUTEN_CART_URL
def save(name: str, content: str) -> Path:
path = PROBE_DIR / name
path.write_text(content, encoding="utf-8")
print(f" saved -> {path} ({len(content)} bytes)")
return path
async def call_count_api(context, *, referer: str | None, tag: str) -> None:
headers = {"Referer": referer} if referer else {}
resp = await context.request.get(CART_COUNT_API + "?sid=1010", headers=headers)
body = await resp.text()
print(f" [{tag}] http_status={resp.status} body={body!r}")
save(f"{tag}.txt", f"http_status={resp.status}\n{body}")
async def run(*, headful: bool) -> int:
from playwright.async_api import async_playwright
settings = get_settings()
state_path = settings.auth_state_path / "rakuten_state.json"
if not state_path.exists():
print(f"找不到登录态文件:{state_path}")
return 1
async with async_playwright() as pw:
browser = await pw.chromium.launch(
headless=not headful,
channel=settings.browser_channel or None,
proxy=settings.playwright_proxy,
args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
)
context = await browser.new_context(
storage_state=state_path,
user_agent=auth_site.RAKUTEN_USER_AGENT,
locale="ja-JP",
timezone_id="Asia/Tokyo",
viewport={"width": 390, "height": 844},
is_mobile=True,
has_touch=True,
)
print("=== 1. 带正确 Referer 调 cart count API(生产路径)===")
await call_count_api(context, referer=CART_PAGE, tag="10-with-referer")
print("=== 2. 不带 Referer 调 cart count API(观测「被拒」形态)===")
await call_count_api(context, referer=None, tag="11-no-referer")
print("=== 3. 错误 sid 调 cart count API(另一种异常入参)===")
resp = await context.request.get(
CART_COUNT_API + "?sid=999999", headers={"Referer": CART_PAGE}
)
body = await resp.text()
print(f" [bad-sid] http_status={resp.status} body={body!r}")
save("12-bad-sid.txt", f"http_status={resp.status}\n{body}")
print("=== 4. 打开购物车页,看当前车状态(只读)===")
page = await context.new_page()
await page.goto(CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
await page.wait_for_timeout(5000)
state = await page.evaluate(
"""() => {
const s = window.__INITIAL_STATE__;
if (!s || !s.cart) return {has_state: !!s, keys: s ? Object.keys(s) : []};
return {
has_state: true,
cart_keys: Object.keys(s.cart),
shopUrlList_len: Array.isArray(s.cart.shopUrlList) ? s.cart.shopUrlList.length : null,
};
}"""
)
print(f" __INITIAL_STATE__.cart: {state}")
html = await page.content()
save("20-cart-page.html", html)
delete_btns = await page.locator('button[aria-label^="削除"]').count()
print(f" 削除按钮数={delete_btns} final_url={page.url}")
print("=== 5. 车页打开后再调一次 count API(贴近 clear_cart 末尾场景)===")
await call_count_api(context, referer=CART_PAGE, tag="21-after-cart-page")
await browser.close()
print(f"\n探针输出目录:{PROBE_DIR}")
return 0
async def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--headful", action="store_true")
args = parser.parse_args()
return await run(headful=args.headful)
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+51
View File
@@ -0,0 +1,51 @@
"""真账号验证:空车场景下 cart_status / clear_cart 不再被误判为获取失败
修复前行为2026-08-16 探针实测空车时 cart count API 返回 status=101
_query_cart_count 一律当失败抛 CartOperationError cart_status 报错 5002
clear_cart 末尾校验拿 cart_count=-1runner 据此误判开单前清理未清空
修复后status=101 识别为合法空车count=0
用法
.venv/Scripts/python.exe scripts/verify_cart_empty.py
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.shared.config import get_settings # noqa: E402
from app.trading.services.auth_session import AuthSession # noqa: E402
from app.trading.worker.site_interact import SiteInteractor # noqa: E402
async def run() -> int:
settings = get_settings()
auth = AuthSession(settings)
await auth.start()
site = SiteInteractor(auth_session=auth, settings=settings)
await site.start()
try:
status = await site.cart_status()
print(f"cart_status -> {status}")
assert status["logged_in"] is True
assert status["count"] == 0, f"预期空车 count=0,实际 {status['count']}"
assert status["raw_status"] == "101", f"预期 raw_status=101,实际 {status['raw_status']}"
cleared = await site.clear_cart()
print(f"clear_cart -> {cleared}")
assert cleared["cart_count"] == 0, (
f"空车 clear 后 cart_count 应为 0(修复前是 -1),实际 {cleared['cart_count']}"
)
print("\n验证通过:空车按正常结果返回(count=0 / raw_status=101),未误判为获取失败")
return 0
finally:
await site.close()
await auth.close()
if __name__ == "__main__":
raise SystemExit(asyncio.run(run()))
+76
View File
@@ -1138,4 +1138,80 @@ async def test_dump_debug_snapshot_swallows_its_own_errors(tmp_path):
assert list((tmp_path / "t1").glob("debug-*")) == []
# ---- _query_cart_count:空车(status=101)与获取失败(其他 status)必须区分 ----
#
# 响应 body 均为 2026-08-16 真账号实测原样(scripts/probe_cart_count.py +
# .probe/cart_count/):空车是合法响应,当失败抛错会让 clear_cart 末尾校验拿 -1、
# 开单前清理被 runner 误判为「未清空」。
class _FakeCartCountRequest:
"""APIRequestContext 替身:回固定 body,记录入参"""
def __init__(self, body: str):
self._body = body
self.calls: list[tuple[str, dict[str, str]]] = []
async def get(self, url: str, headers: dict[str, str] | None = None) -> Any:
self.calls.append((url, headers or {}))
class _Resp:
def __init__(self, text: str):
self._text = text
async def text(self) -> str:
return self._text
return _Resp(self._body)
class _FakeCartCountContext:
def __init__(self, body: str):
self.request = _FakeCartCountRequest(body)
def _build_cart_count_site(body: str) -> SiteInteractor:
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
site._context = _FakeCartCountContext(body) # type: ignore[assignment]
return site
async def test_query_cart_count_non_empty_returns_count():
site = _build_cart_count_site(
'callBack({"status":"100","message":"success.","count":"3"})'
)
raw_status, count = await site._query_cart_count()
assert (raw_status, count) == ("100", 3)
async def test_query_cart_count_empty_cart_is_not_a_failure():
"""status=101 + count="" 是空车(合法响应),返回 ("101", 0) 而不是抛错"""
site = _build_cart_count_site(
'callBack({"status":"101","message":"value not found.","count":""})'
)
raw_status, count = await site._query_cart_count()
assert (raw_status, count) == ("101", 0)
async def test_query_cart_count_rejected_request_raises():
"""status=300(referer/sid 被站点拒绝)才是「获取失败」"""
site = _build_cart_count_site(
'callBack({"status":"300","message":"referer is empty value.","count":""})'
)
with pytest.raises(CartOperationError):
await site._query_cart_count()
async def test_query_cart_count_unparseable_body_raises():
site = _build_cart_count_site("<html>not jsonp</html>")
with pytest.raises(CartOperationError):
await site._query_cart_count()
# ---- helper ----