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
This commit is contained in:
2026-08-17 08:32:11 +08:00
parent daa5555fd4
commit 086ab82614
6 changed files with 340 additions and 11 deletions
+5 -1
View File
@@ -119,7 +119,11 @@ async def cart_clear(
return ApiResponse[CartClearData]( return ApiResponse[CartClearData](
success=True, success=True,
msg="success", msg="success",
data=CartClearData(**result), # result 还带清理后的页面 html(供 worker 落证据排查用),不进 HTTP 响应
data=CartClearData(
removed_count=result["removed_count"],
cart_count=result["cart_count"],
),
code=0, code=0,
) )
+20 -7
View File
@@ -259,9 +259,9 @@ class WorkerRunner:
"""站点交互的实际调度:清购物车 → 加购 → 校验 → 确认页 → 金额守卫 → 提交 → 付款 """站点交互的实际调度:清购物车 → 加购 → 校验 → 确认页 → 金额守卫 → 提交 → 付款
每一步的顺序:动作 → 落证据 → 写本地 SQLite → 回报 gateway。 每一步的顺序:动作 → 落证据 → 写本地 SQLite → 回报 gateway。
开单前的「清购物车」是本机侧卫生步骤(step 0,见下方代码注释),不走 开单前的「清购物车」是本机侧卫生步骤(step 0,见下方代码注释):落本地
证据与上报;站点交互失败会转 _execute_with_renewal 的 except 分支上报 步骤证据(页面 + meta)但不上报 gateway、不记状态事件;站点交互失败会转
needs_human / failed。 _execute_with_renewal 的 except 分支上报 needs_human / failed。
""" """
await self._db.ensure_started(task.task_id, task.site, task.intent) await self._db.ensure_started(task.task_id, task.site, task.intent)
@@ -276,11 +276,24 @@ class WorkerRunner:
# 购物车,保证「一单 = 只买本单商品」的不变式——无论上一单是怎么失败的 # 购物车,保证「一单 = 只买本单商品」的不变式——无论上一单是怎么失败的
# (包括进程中途崩溃,残留都没人清),这里都从空车起步。 # (包括进程中途崩溃,残留都没人清),这里都从空车起步。
# #
# 这条不在 gateway 上报,也不写步骤证据:它是本机侧的卫生操作,不是订单 # 这条不在 gateway 上报、不记 order_events:它是本机侧的卫生操作,不是订单
# 进度的状态迁移。清理后 count 非 0(含 count API 拿不到结果返回 -1)说明 # 进度的状态迁移。清理后的页面快照与 meta 要落本地证据并登记
# 清理没跑干净,**不能**带着残留往下加购——有把上一单买走的真实风险, # evidence_index——清理没跑干净被下面的闸门拦下转 needs_human 时,这份现场
# 按闸门语义拦截转 needs_human,宁可卡住等人核对,也不赌「残留不会被买走」。 # 就是排查依据,所以证据必须先于闸门判断落盘。清理后 count 非 0(含
# count API 拿不到结果返回 -1)说明清理没跑干净,**不能**带着残留往下
# 加购——有把上一单买走的真实风险,按闸门语义拦截转 needs_human,
# 宁可卡住等人核对,也不赌「残留不会被买走」。
cleared = await self._site.clear_cart() cleared = await self._site.clear_cart()
evidence_ref = self._evidence.write_step(
task.task_id, 0, "cart-clear",
html=cleared.get("html") or None,
meta={
"step": "cart-clear",
"removed_count": cleared.get("removed_count"),
"cart_count": cleared.get("cart_count"),
},
)
await self._db.index_evidence(task.task_id, 0, "cart-clear", evidence_ref)
if cleared.get("cart_count", -1) != 0: if cleared.get("cart_count", -1) != 0:
raise OrderGuardError( raise OrderGuardError(
"开单前清理购物车后仍未清空" "开单前清理购物车后仍未清空"
+17 -3
View File
@@ -1010,8 +1010,11 @@ 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, html};cart_count=-1 表示末尾 count API
(空车属正常结果返回 0,见 _query_cart_count 的 status=101 处理)。 调用失败(空车属正常结果返回 0,见 _query_cart_count 的 status=101 处理)。
html 是清理结束后 cart 页的最终渲染结果,供调用方落证据排查——清理没跑干净
被 runner 闸门拦下时这份现场就是排查依据;HTTP /api/cart/clear 入口的响应
模型不携带它。
""" """
async with self._lock: async with self._lock:
await self._auth_session.require_logged_in("rakuten") await self._auth_session.require_logged_in("rakuten")
@@ -1019,6 +1022,7 @@ class SiteInteractor:
page = await self._context.new_page() page = await self._context.new_page()
removed = 0 removed = 0
html = ""
try: try:
await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000) await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
await self._wait_cart_rendered(page, label="clear_cart") await self._wait_cart_rendered(page, label="clear_cart")
@@ -1047,6 +1051,16 @@ class SiteInteractor:
"clear_cart 触发安全上限 %s,可能有删除失败或 SPA 异常", "clear_cart 触发安全上限 %s,可能有删除失败或 SPA 异常",
_CLEAR_CART_MAX_ITER, _CLEAR_CART_MAX_ITER,
) )
# 清理结束后的最终页面留给调用方落证据(worker runner step 0);
# 抓取失败只记日志,不把排查用的副作用变成新的失败源
try:
html = await page.content()
except Exception:
logger.warning(
"clear_cart:抓取最终页面 HTML 失败(不影响清理结果)",
exc_info=True,
)
finally: finally:
await page.close() await page.close()
@@ -1058,7 +1072,7 @@ class SiteInteractor:
cart_count = -1 cart_count = -1
logger.info("clear_cart 完成:removed=%s cart_count=%s", removed, cart_count) logger.info("clear_cart 完成:removed=%s cart_count=%s", removed, cart_count)
return {"removed_count": removed, "cart_count": cart_count} return {"removed_count": removed, "cart_count": cart_count, "html": html}
async def remove_item(self, item_id: str) -> dict: async def remove_item(self, item_id: str) -> dict:
"""删除购物车里指定 item_id 的商品 """删除购物车里指定 item_id 的商品
+82
View File
@@ -0,0 +1,82 @@
"""真账号验证:clear_cart 两个场景——空车返回成功 / 有商品正确清除
场景 1(空车):clear_cart 应 removed_count=0、cart_count=0(status=101 是合法
空车,不是获取失败),并带回清理后的页面 HTML(runner step 0 落证据用)。
场景 2(有商品):先加购 1 件商品,clear_cart 应 removed_count>=1、cart_count=0,
末尾再用 cart_status 独立复核确实是空车。
前置:登录态有效(.auth/ 下有 storage_state);浏览器必须非无头(站点风控要求,
会弹出真实浏览器窗口)。商品 URL 与其他探针(probe_new_card 等)一致。
用法:
.venv/Scripts/python.exe scripts/verify_cart_clear.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
ITEM_URL = "https://item.rakuten.co.jp/moccasin/ds001iwrgesaaa2/"
async def run() -> int:
settings = get_settings()
auth = AuthSession(settings)
await auth.start()
site = SiteInteractor(auth_session=auth, settings=settings)
await site.start()
try:
# 前置:先清一次,保证从空车起步(顺便清掉上次验证可能残留的商品)
pre = await site.clear_cart()
print(f"前置 clear -> removed={pre['removed_count']} cart_count={pre['cart_count']}")
assert pre["cart_count"] == 0, f"前置清理后应为空车,实际 {pre['cart_count']}"
# 场景 1:空车 —— 应直接返回清理成功,不做任何点击
empty = await site.clear_cart()
print(
f"场景1 空车 -> removed={empty['removed_count']} "
f"cart_count={empty['cart_count']} html={len(empty['html'])}B"
)
assert empty["removed_count"] == 0, f"空车不应有点击,实际 {empty['removed_count']}"
assert empty["cart_count"] == 0, f"空车 cart_count 应为 0,实际 {empty['cart_count']}"
assert empty["html"], "空车也应带回页面 HTML(runner 落证据用)"
# 场景 2:有商品 —— 先加购 1 件,再清,应真删掉
added = await site.add_to_cart_payload(item_url=ITEM_URL, quantity=1)
print(f"加购 -> item_id={added['item_id']} cart_count={added['cart_count']}")
assert added["cart_count"] >= 1, "加购后购物车应有商品"
cleared = await site.clear_cart()
print(
f"场景2 有商品 -> removed={cleared['removed_count']} "
f"cart_count={cleared['cart_count']} html={len(cleared['html'])}B"
)
assert cleared["removed_count"] >= 1, (
f"有商品时至少点 1 次「削除」,实际 {cleared['removed_count']}"
)
assert cleared["cart_count"] == 0, f"清理后应为空车,实际 {cleared['cart_count']}"
assert cleared["html"], "清理后应带回页面 HTML"
# 复核:独立的 status 查询确认确实是空车(不只信 clear 末尾的一次校验)
status = await site.cart_status()
print(f"复核 status -> {status}")
assert status["count"] == 0 and status["raw_status"] == "101", (
f"复核应为空车(count=0, raw_status=101),实际 {status}"
)
print("\n验证通过:空车正确返回成功;有商品时正确清除并复核为空车")
return 0
finally:
await site.close()
await auth.close()
if __name__ == "__main__":
raise SystemExit(asyncio.run(run()))
+159
View File
@@ -6,6 +6,8 @@ _extract_error_message)——这三者覆盖了「从商品页 HTML 抽加购
SiteInteractor 类的 add_to_cart / verify_cart 涉及 Playwright,不在离线测试覆盖 SiteInteractor 类的 add_to_cart / verify_cart 涉及 Playwright,不在离线测试覆盖
范围;只在 test_worker_runner.py 里用桩站点覆盖 runner 与 site 的契约。 范围;只在 test_worker_runner.py 里用桩站点覆盖 runner 与 site 的契约。
clear_cart 与 _dump_debug_snapshot 用不依赖 Playwright 的 fake page 覆盖了方法
自身逻辑(见文件尾部对应小节),真实站点行为仍由 scripts/ 真账号脚本验证。
""" """
from __future__ import annotations from __future__ import annotations
@@ -33,6 +35,7 @@ from app.trading.worker.site_interact import (
OrderStatusSnapshot, OrderStatusSnapshot,
SiteInteractor, SiteInteractor,
_accumulate_order_list_page, _accumulate_order_list_page,
_DELETE_BUTTON_SELECTOR,
_extract_error_message, _extract_error_message,
_extract_purchase_fields, _extract_purchase_fields,
_OrderListAccumulator, _OrderListAccumulator,
@@ -1214,4 +1217,160 @@ async def test_query_cart_count_unparseable_body_raises():
await site._query_cart_count() await site._query_cart_count()
# ---- clear_cart:空车 / 有商品两个场景的核心逻辑(fake page,不依赖 Playwright)----
#
# 真实站点行为(SPA 是否点一个少一个、确认 modal 是否存在)由真账号脚本验证
# (scripts/verify_cart_empty.py、scripts/verify_cart_clear.py);这里覆盖的是
# 方法自身的逻辑:空车立即结束、有商品时循环点「削除」直到按钮消失、最终页面
# HTML 进返回值、HTML 抓取失败不掩盖清理结果。
class _FakeDeleteButton:
"""「削除」按钮替身:还有剩余按钮时 wait_for 成功,click 消耗一个(模拟 SPA
点完一个就把该项从 DOM 摘掉)"""
def __init__(self, page: "_FakeClearCartPage"):
self._page = page
async def wait_for(self, *, state: str, timeout: int) -> None:
if self._page.delete_buttons_left <= 0:
raise RuntimeError("模拟 Playwright 等待超时:没有删除按钮了")
async def click(self) -> None:
self._page.delete_buttons_left -= 1
class _FakeMissingButton:
"""确认 modal 按钮替身:永远不出现(站点是否弹 modal 未实测,按不存在处理)"""
async def wait_for(self, *, state: str, timeout: int) -> None:
raise RuntimeError("模拟 Playwright 等待超时:无确认 modal")
async def click(self) -> None:
raise AssertionError("没有可见的确认按钮,不应触发点击")
class _FakeLocator:
def __init__(self, button: Any):
self.first = button
class _FakeClearCartPage:
"""cart 页替身:实现 clear_cart 用到的全部 page 接口"""
def __init__(self, *, delete_buttons: int, html: str):
self.delete_buttons_left = delete_buttons
self._html = html
self.closed = False
def locator(self, selector: str) -> _FakeLocator:
if selector == _DELETE_BUTTON_SELECTOR:
return _FakeLocator(_FakeDeleteButton(self))
return _FakeLocator(_FakeMissingButton())
async def goto(self, url: str, *, wait_until: str, timeout: int) -> None:
return None
async def wait_for_function(self, *args: Any, **kwargs: Any) -> None:
return None
async def wait_for_load_state(self, *args: Any, **kwargs: Any) -> None:
return None
async def wait_for_timeout(self, *args: Any, **kwargs: Any) -> None:
return None
async def content(self) -> str:
return self._html
async def close(self) -> None:
self.closed = True
class _FakeClearCartContext:
"""BrowserContext 替身:new_page 返回 fake cart 页,request 回固定 count body"""
def __init__(self, *, page: _FakeClearCartPage, count_body: str):
self.page = page
self.request = _FakeCartCountRequest(count_body)
async def new_page(self) -> _FakeClearCartPage:
return self.page
class _FakeLoggedInAuth:
"""AuthSession 替身:require_logged_in 直接放行"""
async def require_logged_in(self, site: str) -> None:
return None
def _build_clear_cart_site(*, delete_buttons: int, count_body: str, html: str) -> SiteInteractor:
"""构造 clear_cart 可离线跑起来的 SiteInteractor(fake page + fake count API)"""
site = SiteInteractor(auth_session=_FakeLoggedInAuth(), settings=None) # type: ignore[arg-type]
site._context = _FakeClearCartContext(
page=_FakeClearCartPage(delete_buttons=delete_buttons, html=html),
count_body=count_body,
) # type: ignore[assignment]
async def _noop_refresh() -> None:
return None
site._refresh_context_if_stale = _noop_refresh # type: ignore[assignment]
return site
_EMPTY_COUNT_BODY = 'callBack({"status":"101","message":"value not found.","count":""})'
async def test_clear_cart_empty_cart_returns_success_without_clicks():
"""场景 1:购物车没有商品 —— 找不到「削除」按钮立即结束,按清理成功返回
空车时 count API 返回 status=101(合法空车),cart_count 必须是 0 而不是
-1(2026-08-16 修复过的误判);removed_count=0,最终页面 HTML 照常带回。
"""
site = _build_clear_cart_site(
delete_buttons=0, count_body=_EMPTY_COUNT_BODY, html="<html>empty cart</html>",
)
result = await site.clear_cart()
assert result == {"removed_count": 0, "cart_count": 0, "html": "<html>empty cart</html>"}
assert site._context.page.closed is True # 页面必须关,不能泄漏
async def test_clear_cart_with_items_clicks_until_no_button_left():
"""场景 2:购物车有商品 —— 循环点第一个「削除」,点一个少一个,直到按钮消失
每次循环都重新查 selector(避免索引漂移),3 件商品应点 3 次;清空后 count
API 回 status=101 → cart_count=0,最终页面 HTML 进返回值。
"""
site = _build_clear_cart_site(
delete_buttons=3, count_body=_EMPTY_COUNT_BODY, html="<html>cleared cart</html>",
)
result = await site.clear_cart()
assert result["removed_count"] == 3
assert result["cart_count"] == 0
assert result["html"] == "<html>cleared cart</html>"
assert site._context.page.closed is True
async def test_clear_cart_html_capture_failure_keeps_clear_result():
"""最终页面 HTML 抓取失败(如页面异常)不掩盖清理结果本身:html 记空串"""
site = _build_clear_cart_site(
delete_buttons=0, count_body=_EMPTY_COUNT_BODY, html="",
)
async def _broken_content() -> str:
raise RuntimeError("page already closed")
site._context.page.content = _broken_content # type: ignore[assignment]
result = await site.clear_cart()
assert result == {"removed_count": 0, "cart_count": 0, "html": ""}
# ---- helper ---- # ---- helper ----
+57
View File
@@ -13,6 +13,7 @@ evidence store / site,覆盖 §6 主循环的分支:
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -559,6 +560,62 @@ async def test_execute_blocks_when_clear_count_unknown(
assert "未清空" in terminal["detail"] assert "未清空" in terminal["detail"]
async def test_clear_cart_step_writes_evidence_but_no_gateway_report(
runner: WorkerRunner, evidence: EvidenceStore
):
"""step 0 落本地证据:清理后的页面 HTML 与 meta.json 在盘上,但不回报 gateway
clear_cart 返回的 html 是清理结束后的 cart 页现场meta removed_count /
cart_count它仍是本机卫生步骤evidence_index 可翻查gateway 收不到
这一步的 evidence_ref
"""
async def _clear(): # noqa: ANN001
return {"removed_count": 1, "cart_count": 0, "html": "<html>cart</html>"}
async def _noop(task): # noqa: ANN001
return None
async def _unimplemented(task): # noqa: ANN001
# 流程走到 enter_checkout 为止:step 0 的证据此时已经落盘
raise NotImplementedError("模拟:enter_checkout 未实现,到此为止")
runner._site.clear_cart = _clear # type: ignore[assignment]
runner._site.add_to_cart = _noop # type: ignore[assignment]
runner._site.verify_cart = _noop # type: ignore[assignment]
runner._site.enter_checkout = _unimplemented # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
step_dir = evidence.step_dir("t1")
html_text = (step_dir / "00-cart-clear.html").read_text(encoding="utf-8")
assert html_text == "<html>cart</html>"
meta = json.loads((step_dir / "00-cart-clear.meta.json").read_text(encoding="utf-8"))
assert meta == {"step": "cart-clear", "removed_count": 1, "cart_count": 0}
# 本机卫生步骤:gateway 的任何 report 都不携带 step-0 的 evidence_ref
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
assert all(r["evidence_ref"] != "t1/00-cart-clear" for r in gateway.reports)
async def test_clear_cart_evidence_written_when_guard_blocks(
runner: WorkerRunner, evidence: EvidenceStore
):
"""闸门拦截(清理后 cart_count 非 0)时 step-0 证据同样已落盘
被拦转 needs_human 时这份现场就是排查依据证据先于闸门判断落盘
"""
async def _clear(): # noqa: ANN001
return {"removed_count": 2, "cart_count": 2, "html": "<html>leftover</html>"}
runner._site.clear_cart = _clear # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
step_dir = evidence.step_dir("t1")
assert (step_dir / "00-cart-clear.html").exists()
meta = json.loads((step_dir / "00-cart-clear.meta.json").read_text(encoding="utf-8"))
assert meta["cart_count"] == 2
# ---- 证据在 report 之前落盘(§9 第 11 条)---- # ---- 证据在 report 之前落盘(§9 第 11 条)----