diff --git a/app/trading/api/routes/cart.py b/app/trading/api/routes/cart.py index ecb7ad0..23fa90a 100644 --- a/app/trading/api/routes/cart.py +++ b/app/trading/api/routes/cart.py @@ -119,7 +119,11 @@ async def cart_clear( return ApiResponse[CartClearData]( success=True, msg="success", - data=CartClearData(**result), + # result 还带清理后的页面 html(供 worker 落证据排查用),不进 HTTP 响应 + data=CartClearData( + removed_count=result["removed_count"], + cart_count=result["cart_count"], + ), code=0, ) diff --git a/app/trading/worker/runner.py b/app/trading/worker/runner.py index 96d4e30..721fb3b 100644 --- a/app/trading/worker/runner.py +++ b/app/trading/worker/runner.py @@ -259,9 +259,9 @@ class WorkerRunner: """站点交互的实际调度:清购物车 → 加购 → 校验 → 确认页 → 金额守卫 → 提交 → 付款 每一步的顺序:动作 → 落证据 → 写本地 SQLite → 回报 gateway。 - 开单前的「清购物车」是本机侧卫生步骤(step 0,见下方代码注释),不走 - 证据与上报;站点交互失败会转 _execute_with_renewal 的 except 分支上报 - needs_human / failed。 + 开单前的「清购物车」是本机侧卫生步骤(step 0,见下方代码注释):落本地 + 步骤证据(页面 + meta)但不上报 gateway、不记状态事件;站点交互失败会转 + _execute_with_renewal 的 except 分支上报 needs_human / failed。 """ await self._db.ensure_started(task.task_id, task.site, task.intent) @@ -276,11 +276,24 @@ class WorkerRunner: # 购物车,保证「一单 = 只买本单商品」的不变式——无论上一单是怎么失败的 # (包括进程中途崩溃,残留都没人清),这里都从空车起步。 # - # 这条不在 gateway 上报,也不写步骤证据:它是本机侧的卫生操作,不是订单 - # 进度的状态迁移。清理后 count 非 0(含 count API 拿不到结果返回 -1)说明 - # 清理没跑干净,**不能**带着残留往下加购——有把上一单买走的真实风险, - # 按闸门语义拦截转 needs_human,宁可卡住等人核对,也不赌「残留不会被买走」。 + # 这条不在 gateway 上报、不记 order_events:它是本机侧的卫生操作,不是订单 + # 进度的状态迁移。但清理后的页面快照与 meta 要落本地证据并登记 + # evidence_index——清理没跑干净被下面的闸门拦下转 needs_human 时,这份现场 + # 就是排查依据,所以证据必须先于闸门判断落盘。清理后 count 非 0(含 + # count API 拿不到结果返回 -1)说明清理没跑干净,**不能**带着残留往下 + # 加购——有把上一单买走的真实风险,按闸门语义拦截转 needs_human, + # 宁可卡住等人核对,也不赌「残留不会被买走」。 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: raise OrderGuardError( "开单前清理购物车后仍未清空" diff --git a/app/trading/worker/site_interact.py b/app/trading/worker/site_interact.py index d627ae2..b342ab1 100644 --- a/app/trading/worker/site_interact.py +++ b/app/trading/worker/site_interact.py @@ -1010,8 +1010,11 @@ class SiteInteractor: - 若 SPA 把按钮渲染在 iframe 里,selector 失败需实测后调整 - Rakuten cart item 卡片无 data-testid,本方法不依赖 DOM 结构定位 - 返回 {removed_count, cart_count};cart_count=-1 表示末尾 count API 调用失败 - (空车属正常结果返回 0,见 _query_cart_count 的 status=101 处理)。 + 返回 {removed_count, cart_count, html};cart_count=-1 表示末尾 count API + 调用失败(空车属正常结果返回 0,见 _query_cart_count 的 status=101 处理)。 + html 是清理结束后 cart 页的最终渲染结果,供调用方落证据排查——清理没跑干净 + 被 runner 闸门拦下时这份现场就是排查依据;HTTP /api/cart/clear 入口的响应 + 模型不携带它。 """ async with self._lock: await self._auth_session.require_logged_in("rakuten") @@ -1019,6 +1022,7 @@ class SiteInteractor: page = await self._context.new_page() removed = 0 + html = "" try: await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000) await self._wait_cart_rendered(page, label="clear_cart") @@ -1047,6 +1051,16 @@ class SiteInteractor: "clear_cart 触发安全上限 %s,可能有删除失败或 SPA 异常", _CLEAR_CART_MAX_ITER, ) + + # 清理结束后的最终页面留给调用方落证据(worker runner step 0); + # 抓取失败只记日志,不把排查用的副作用变成新的失败源 + try: + html = await page.content() + except Exception: + logger.warning( + "clear_cart:抓取最终页面 HTML 失败(不影响清理结果)", + exc_info=True, + ) finally: await page.close() @@ -1058,7 +1072,7 @@ class SiteInteractor: cart_count = -1 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: """删除购物车里指定 item_id 的商品 diff --git a/scripts/verify_cart_clear.py b/scripts/verify_cart_clear.py new file mode 100644 index 0000000..6f531f2 --- /dev/null +++ b/scripts/verify_cart_clear.py @@ -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())) diff --git a/tests/test_site_interact.py b/tests/test_site_interact.py index d843020..902ae3f 100644 --- a/tests/test_site_interact.py +++ b/tests/test_site_interact.py @@ -6,6 +6,8 @@ _extract_error_message)——这三者覆盖了「从商品页 HTML 抽加购 SiteInteractor 类的 add_to_cart / verify_cart 涉及 Playwright,不在离线测试覆盖 范围;只在 test_worker_runner.py 里用桩站点覆盖 runner 与 site 的契约。 +clear_cart 与 _dump_debug_snapshot 用不依赖 Playwright 的 fake page 覆盖了方法 +自身逻辑(见文件尾部对应小节),真实站点行为仍由 scripts/ 真账号脚本验证。 """ from __future__ import annotations @@ -33,6 +35,7 @@ from app.trading.worker.site_interact import ( OrderStatusSnapshot, SiteInteractor, _accumulate_order_list_page, + _DELETE_BUTTON_SELECTOR, _extract_error_message, _extract_purchase_fields, _OrderListAccumulator, @@ -1214,4 +1217,160 @@ async def test_query_cart_count_unparseable_body_raises(): 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="empty cart", + ) + + result = await site.clear_cart() + + assert result == {"removed_count": 0, "cart_count": 0, "html": "empty cart"} + 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="cleared cart", + ) + + result = await site.clear_cart() + + assert result["removed_count"] == 3 + assert result["cart_count"] == 0 + assert result["html"] == "cleared cart" + 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 ---- diff --git a/tests/test_worker_runner.py b/tests/test_worker_runner.py index 802516c..e2fac6c 100644 --- a/tests/test_worker_runner.py +++ b/tests/test_worker_runner.py @@ -13,6 +13,7 @@ evidence store / site,覆盖 §6 主循环的分支: from __future__ import annotations import asyncio +import json from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -559,6 +560,62 @@ async def test_execute_blocks_when_clear_count_unknown( 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": "cart"} + + 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 == "cart" + 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": "leftover"} + + 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 条)----