fix(trading): cart count status=101 识别为空车,区分空车与获取失败
This commit is contained in:
@@ -607,6 +607,7 @@ docker compose --profile gateway up -d
|
|||||||
|
|
||||||
- `POST /api/cart/add` — 加购,入参 `{item_url, quantity?, variant_id?, choice?}`
|
- `POST /api/cart/add` — 加购,入参 `{item_url, quantity?, variant_id?, choice?}`
|
||||||
- `POST /api/cart/status` — 调 cart count API,返回购物车商品件数
|
- `POST /api/cart/status` — 调 cart count API,返回购物车商品件数
|
||||||
|
(空车是正常结果:`count=0, raw_status="101"`;获取失败才报错 5002)
|
||||||
- `POST /api/cart/clear` — 清空购物车(UI 点击 `button[aria-label="削除"]`)
|
- `POST /api/cart/clear` — 清空购物车(UI 点击 `button[aria-label="削除"]`)
|
||||||
- `POST /api/cart/remove` — 删除指定 `item_id`
|
- `POST /api/cart/remove` — 删除指定 `item_id`
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ async def cart_status(
|
|||||||
"""查询购物车状态(轻量)
|
"""查询购物车状态(轻量)
|
||||||
|
|
||||||
只调 cart count JSONP API,不渲染整页。返回登录态、商品件数与站点状态码。
|
只调 cart count JSONP API,不渲染整页。返回登录态、商品件数与站点状态码。
|
||||||
|
空车是正常结果:count=0 且 raw_status="101";获取失败走错误码 5002。
|
||||||
"""
|
"""
|
||||||
site = _require_site(container)
|
site = _require_site(container)
|
||||||
result = await site.cart_status() # type: ignore[union-attr]
|
result = await site.cart_status() # type: ignore[union-attr]
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ data/evidence/checkout-research-20260811/NOTES.md):
|
|||||||
- clear_cart、remove_item 用 UI 点击 `button[aria-label^="削除"]` 路径实现,
|
- clear_cart、remove_item 用 UI 点击 `button[aria-label^="削除"]` 路径实现,
|
||||||
2026-08-11 用真账号跑通下单测试时确认实际 aria-label 是「削除する」而非旧探针
|
2026-08-11 用真账号跑通下单测试时确认实际 aria-label 是「削除する」而非旧探针
|
||||||
记录的「削除」,selector 已改前缀匹配;modal 是否存在仍未实测确认
|
记录的「削除」,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 密码页并尝试自动复核」:购物车页
|
- enter_checkout 已实现到「进入 session upgrade 密码页并尝试自动复核」:购物车页
|
||||||
点击真正的「購入手続き」(注意页面上还有个促销用的「カード入会&購入手続き」
|
点击真正的「購入手続き」(注意页面上还有个促销用的「カード入会&購入手続き」
|
||||||
按钮,selector 必须精确匹配,不能 .first 模糊选)后,即使 SSO 会话仍有效,
|
按钮,selector 必须精确匹配,不能 .first 模糊选)后,即使 SSO 会话仍有效,
|
||||||
@@ -133,6 +137,11 @@ _BASKET_PATH = "/rms/mall/bss/cartadd/set"
|
|||||||
_CART_PAGE = auth_site.RAKUTEN_CART_URL
|
_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 = "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 上不可靠,这里只作辅助判据)
|
# 购物车页未登录标记(旧 marker;新 SPA 上不可靠,这里只作辅助判据)
|
||||||
_LEGACY_LOGGED_OUT_MARKER = auth_site.RAKUTEN_LOGGED_OUT_MARKER
|
_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 才算「购物车非空」
|
1. 打 cart count JSONP API:status=100 且 count>=1 才算「购物车非空」
|
||||||
|
(status=101 是空车,count 记 0,见 _query_cart_count)
|
||||||
2. 打开 cart 页等 SPA 渲染,在 HTML 里找 item_id
|
2. 打开 cart 页等 SPA 渲染,在 HTML 里找 item_id
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -974,7 +984,8 @@ class SiteInteractor:
|
|||||||
|
|
||||||
返回 {logged_in, count, raw_status}。
|
返回 {logged_in, count, raw_status}。
|
||||||
count 是站点返回的购物车里商品总件数(含数量,非 SKU 数)。
|
count 是站点返回的购物车里商品总件数(含数量,非 SKU 数)。
|
||||||
raw_status 是站点的状态码字符串,"100" 表示正常。
|
raw_status 是站点的状态码字符串:"100" 非空、"101" 空车(count=0);
|
||||||
|
其余 status 属「获取失败」,抛 CartOperationError 而不是返回。
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
await self._auth_session.require_logged_in("rakuten")
|
await self._auth_session.require_logged_in("rakuten")
|
||||||
@@ -999,7 +1010,8 @@ class SiteInteractor:
|
|||||||
- 若 SPA 把按钮渲染在 iframe 里,selector 失败需实测后调整
|
- 若 SPA 把按钮渲染在 iframe 里,selector 失败需实测后调整
|
||||||
- Rakuten cart item 卡片无 data-testid,本方法不依赖 DOM 结构定位
|
- 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:
|
async with self._lock:
|
||||||
await self._auth_session.require_logged_in("rakuten")
|
await self._auth_session.require_logged_in("rakuten")
|
||||||
@@ -1125,8 +1137,14 @@ class SiteInteractor:
|
|||||||
async def _query_cart_count(self) -> tuple[str, int]:
|
async def _query_cart_count(self) -> tuple[str, int]:
|
||||||
"""调 cart count JSONP API,返回 (raw_status, count)
|
"""调 cart count JSONP API,返回 (raw_status, count)
|
||||||
|
|
||||||
解析失败、status 非 100 都抛 CartOperationError——这是站点在告诉我们
|
站点契约(2026-08-16 真账号实测,scripts/probe_cart_count.py +
|
||||||
「请求被拒了」,常见原因是 Referer 错或 cookie 失效。
|
.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(
|
resp = await self._context.request.get(
|
||||||
_CART_COUNT_API + "?sid=1010",
|
_CART_COUNT_API + "?sid=1010",
|
||||||
@@ -1137,6 +1155,8 @@ class SiteInteractor:
|
|||||||
if not status_match:
|
if not status_match:
|
||||||
raise CartOperationError(f"cart count 响应无法解析:{body[:200]!r}")
|
raise CartOperationError(f"cart count 响应无法解析:{body[:200]!r}")
|
||||||
raw_status = status_match.group(1)
|
raw_status = status_match.group(1)
|
||||||
|
if raw_status == _CART_EMPTY_STATUS:
|
||||||
|
return raw_status, 0
|
||||||
if raw_status != "100":
|
if raw_status != "100":
|
||||||
raise CartOperationError(
|
raise CartOperationError(
|
||||||
f"cart count API 异常:status={raw_status} body={body[:200]!r}"
|
f"cart count API 异常:status={raw_status} body={body[:200]!r}"
|
||||||
|
|||||||
@@ -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()))
|
||||||
@@ -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=-1(runner 据此误判「开单前清理未清空」)。
|
||||||
|
修复后: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()))
|
||||||
@@ -1138,4 +1138,80 @@ async def test_dump_debug_snapshot_swallows_its_own_errors(tmp_path):
|
|||||||
assert list((tmp_path / "t1").glob("debug-*")) == []
|
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 ----
|
# ---- helper ----
|
||||||
|
|||||||
Reference in New Issue
Block a user