From 295fc7ad69bc40b09214baf1c70408c16ca49277 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 14 Aug 2026 02:07:12 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=93=E7=AE=97=E5=A4=B1=E8=B4=A5=E5=85=B3?= =?UTF-8?q?=E9=94=AE=E8=8A=82=E7=82=B9=E8=90=BD=E8=B0=83=E8=AF=95=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=EF=BC=88HTML+=E6=88=AA=E5=9B=BE=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E6=96=B9=E4=BE=BF=E6=8E=92=E6=9F=A5=20needs=5Fhuman=20?= =?UTF-8?q?=E5=9C=BA=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enter_checkout/submit_order/pay 抛错前尽力落一份页面快照到 evidence_dir/{task_id}/debug-*,与已有编号步骤证据同目录;快照方法自身 失败只记日志,不会掩盖原始异常。之前失败时只有日志文字,要复现现场只能 靠临时探针脚本重新触发一遍真实流程。 Co-Authored-By: Claude Sonnet 5 --- app/trading/worker/site_interact.py | 47 ++++++++++++++++++++++++ tests/test_site_interact.py | 56 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/app/trading/worker/site_interact.py b/app/trading/worker/site_interact.py index c4aa9f0..4ccc68c 100644 --- a/app/trading/worker/site_interact.py +++ b/app/trading/worker/site_interact.py @@ -1056,6 +1056,33 @@ class SiteInteractor: except Exception: continue + async def _dump_debug_snapshot(self, page, *, task_id: str, label: str) -> None: + """结算流程失败时尽力落一份页面快照(HTML + 截图),排查用 + + 写到 `{evidence_dir}/{task_id}/` 下(跟 EvidenceStore 落地的编号步骤文件 + 同目录,文件名前缀 `debug-` 区分),不经过 EvidenceStore/本地 DB 索引—— + 这条路径在 SiteInteractor 失败抛错、page 即将被关闭之前调用,此时 + WorkerRunner 那边还没收到异常,没法替我们落证据;本方法自己出错(页面已 + 关闭、content()/screenshot() 失败等)只记日志,绝不能把排查用的副作用 + 变成掩盖原始异常的新异常。 + """ + try: + debug_dir = self._settings.evidence_path / task_id + debug_dir.mkdir(parents=True, exist_ok=True) + stem = f"debug-{datetime.now():%Y%m%d-%H%M%S}-{label}" + html = await page.content() + (debug_dir / f"{stem}.html").write_text(html, encoding="utf-8") + await page.screenshot(path=str(debug_dir / f"{stem}.png"), full_page=True) + logger.info( + "失败快照已落盘:task_id=%s label=%s url=%s file=%s", + task_id, label, page.url, debug_dir / stem, + ) + except Exception: + logger.warning( + "失败快照落盘失败(忽略,不影响原始异常):task_id=%s label=%s", + task_id, label, exc_info=True, + ) + # ---- 已实现:enter_checkout(到下单确认页,中间步骤未经真实 HTML 验证)---- async def enter_checkout(self, task: LeaseTask) -> str: @@ -1166,6 +1193,11 @@ class SiteInteractor: self._checkout_pages[task.task_id] = page success = True return html + except Exception: + await self._dump_debug_snapshot( + page, task_id=task.task_id, label="enter_checkout-error" + ) + raise finally: if not success: await page.close() @@ -1588,6 +1620,9 @@ class SiteInteractor: except Exception: continue if btn is None: + await self._dump_debug_snapshot( + page, task_id=task.task_id, label="submit_order-no-confirm-button" + ) await self._discard_checkout_page(task.task_id) raise OrderOperationError( "下单确认页未找到任何已知文案的确认按钮" @@ -1598,6 +1633,9 @@ class SiteInteractor: try: await btn.click(timeout=10_000) except Exception as exc: + await self._dump_debug_snapshot( + page, task_id=task.task_id, label="submit_order-click-failed" + ) await self._discard_checkout_page(task.task_id) raise OrderOperationError( f"点击确认下单按钮失败:{type(exc).__name__}: {exc}" @@ -1608,6 +1646,9 @@ class SiteInteractor: if page.url != url_before: break else: + await self._dump_debug_snapshot( + page, task_id=task.task_id, label="submit_order-no-response" + ) raise OrderOperationError( f"点击确认下单按钮后 url={url_before} 长时间无响应," "无法确认是否提交成功,需要人工核对(不要按失败重试)" @@ -1616,6 +1657,9 @@ class SiteInteractor: html = await page.content() match = _ORDER_ID_PATTERN.search(html) if not match: + await self._dump_debug_snapshot( + page, task_id=task.task_id, label="submit_order-no-order-id" + ) raise OrderOperationError( f"提交下单后未能从落地页解析出订单号,url={page.url}" "(可能已经下单成功,需要人工核对站点订单列表,不要按失败重试)" @@ -1648,6 +1692,9 @@ class SiteInteractor: try: for sel in _PAYMENT_BLOCK_INDICATORS: if await page.locator(sel).count() > 0: + await self._dump_debug_snapshot( + page, task_id=task.task_id, label="pay-blocked" + ) raise CheckoutBlockedError( f"提交订单后检测到需要人工处理的验证环节({sel})," f"site_order_id={site_order_id},按规格 §10.1 转 needs_human" diff --git a/tests/test_site_interact.py b/tests/test_site_interact.py index 40b629b..75f6832 100644 --- a/tests/test_site_interact.py +++ b/tests/test_site_interact.py @@ -629,4 +629,60 @@ async def test_add_to_cart_without_start_raises(): await site.add_to_cart(_make_task()) +# ---- _dump_debug_snapshot:失败时落调试快照,本身出错不能污染原始异常 ---- + + +class _FakePage: + """不依赖 Playwright 的最小 page 替身,只实现 _dump_debug_snapshot 用到的接口""" + + def __init__(self, *, html: str = "", url: str = "https://example.test/x"): + self.url = url + self._html = html + self.screenshot_calls: list[str] = [] + + async def content(self) -> str: + return self._html + + async def screenshot(self, *, path: str, full_page: bool = True) -> None: + self.screenshot_calls.append(path) + Path(path).write_bytes(b"fake-png") + + +class _BrokenPage(_FakePage): + """content() 抛错,模拟页面已关闭等场景""" + + async def content(self) -> str: + raise RuntimeError("page already closed") + + +async def test_dump_debug_snapshot_writes_html_and_png(tmp_path): + from app.shared.config import Settings + + settings = Settings(evidence_dir=str(tmp_path)) + site = SiteInteractor(auth_session=None, settings=settings) # type: ignore[arg-type] + page = _FakePage(html="boom") + + await site._dump_debug_snapshot(page, task_id="t1", label="enter_checkout-error") + + written = list((tmp_path / "t1").glob("debug-*")) + htmls = [p for p in written if p.suffix == ".html"] + pngs = [p for p in written if p.suffix == ".png"] + assert len(htmls) == 1 + assert len(pngs) == 1 + assert htmls[0].read_text(encoding="utf-8") == "boom" + + +async def test_dump_debug_snapshot_swallows_its_own_errors(tmp_path): + """page 已经关闭、content() 抛错时,快照方法本身不能再抛,否则会掩盖原始异常""" + from app.shared.config import Settings + + settings = Settings(evidence_dir=str(tmp_path)) + site = SiteInteractor(auth_session=None, settings=settings) # type: ignore[arg-type] + page = _BrokenPage() + + await site._dump_debug_snapshot(page, task_id="t1", label="pay-blocked") + + assert list((tmp_path / "t1").glob("debug-*")) == [] + + # ---- helper ----