结算失败关键节点落调试快照(HTML+截图),方便排查 needs_human 场景
enter_checkout/submit_order/pay 抛错前尽力落一份页面快照到
evidence_dir/{task_id}/debug-*,与已有编号步骤证据同目录;快照方法自身
失败只记日志,不会掩盖原始异常。之前失败时只有日志文字,要复现现场只能
靠临时探针脚本重新触发一遍真实流程。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1056,6 +1056,33 @@ class SiteInteractor:
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
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 验证)----
|
# ---- 已实现:enter_checkout(到下单确认页,中间步骤未经真实 HTML 验证)----
|
||||||
|
|
||||||
async def enter_checkout(self, task: LeaseTask) -> str:
|
async def enter_checkout(self, task: LeaseTask) -> str:
|
||||||
@@ -1166,6 +1193,11 @@ class SiteInteractor:
|
|||||||
self._checkout_pages[task.task_id] = page
|
self._checkout_pages[task.task_id] = page
|
||||||
success = True
|
success = True
|
||||||
return html
|
return html
|
||||||
|
except Exception:
|
||||||
|
await self._dump_debug_snapshot(
|
||||||
|
page, task_id=task.task_id, label="enter_checkout-error"
|
||||||
|
)
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
if not success:
|
if not success:
|
||||||
await page.close()
|
await page.close()
|
||||||
@@ -1588,6 +1620,9 @@ class SiteInteractor:
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
if btn is None:
|
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)
|
await self._discard_checkout_page(task.task_id)
|
||||||
raise OrderOperationError(
|
raise OrderOperationError(
|
||||||
"下单确认页未找到任何已知文案的确认按钮"
|
"下单确认页未找到任何已知文案的确认按钮"
|
||||||
@@ -1598,6 +1633,9 @@ class SiteInteractor:
|
|||||||
try:
|
try:
|
||||||
await btn.click(timeout=10_000)
|
await btn.click(timeout=10_000)
|
||||||
except Exception as exc:
|
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)
|
await self._discard_checkout_page(task.task_id)
|
||||||
raise OrderOperationError(
|
raise OrderOperationError(
|
||||||
f"点击确认下单按钮失败:{type(exc).__name__}: {exc}"
|
f"点击确认下单按钮失败:{type(exc).__name__}: {exc}"
|
||||||
@@ -1608,6 +1646,9 @@ class SiteInteractor:
|
|||||||
if page.url != url_before:
|
if page.url != url_before:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
|
await self._dump_debug_snapshot(
|
||||||
|
page, task_id=task.task_id, label="submit_order-no-response"
|
||||||
|
)
|
||||||
raise OrderOperationError(
|
raise OrderOperationError(
|
||||||
f"点击确认下单按钮后 url={url_before} 长时间无响应,"
|
f"点击确认下单按钮后 url={url_before} 长时间无响应,"
|
||||||
"无法确认是否提交成功,需要人工核对(不要按失败重试)"
|
"无法确认是否提交成功,需要人工核对(不要按失败重试)"
|
||||||
@@ -1616,6 +1657,9 @@ class SiteInteractor:
|
|||||||
html = await page.content()
|
html = await page.content()
|
||||||
match = _ORDER_ID_PATTERN.search(html)
|
match = _ORDER_ID_PATTERN.search(html)
|
||||||
if not match:
|
if not match:
|
||||||
|
await self._dump_debug_snapshot(
|
||||||
|
page, task_id=task.task_id, label="submit_order-no-order-id"
|
||||||
|
)
|
||||||
raise OrderOperationError(
|
raise OrderOperationError(
|
||||||
f"提交下单后未能从落地页解析出订单号,url={page.url}"
|
f"提交下单后未能从落地页解析出订单号,url={page.url}"
|
||||||
"(可能已经下单成功,需要人工核对站点订单列表,不要按失败重试)"
|
"(可能已经下单成功,需要人工核对站点订单列表,不要按失败重试)"
|
||||||
@@ -1648,6 +1692,9 @@ class SiteInteractor:
|
|||||||
try:
|
try:
|
||||||
for sel in _PAYMENT_BLOCK_INDICATORS:
|
for sel in _PAYMENT_BLOCK_INDICATORS:
|
||||||
if await page.locator(sel).count() > 0:
|
if await page.locator(sel).count() > 0:
|
||||||
|
await self._dump_debug_snapshot(
|
||||||
|
page, task_id=task.task_id, label="pay-blocked"
|
||||||
|
)
|
||||||
raise CheckoutBlockedError(
|
raise CheckoutBlockedError(
|
||||||
f"提交订单后检测到需要人工处理的验证环节({sel}),"
|
f"提交订单后检测到需要人工处理的验证环节({sel}),"
|
||||||
f"site_order_id={site_order_id},按规格 §10.1 转 needs_human"
|
f"site_order_id={site_order_id},按规格 §10.1 转 needs_human"
|
||||||
|
|||||||
@@ -629,4 +629,60 @@ async def test_add_to_cart_without_start_raises():
|
|||||||
await site.add_to_cart(_make_task())
|
await site.add_to_cart(_make_task())
|
||||||
|
|
||||||
|
|
||||||
|
# ---- _dump_debug_snapshot:失败时落调试快照,本身出错不能污染原始异常 ----
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePage:
|
||||||
|
"""不依赖 Playwright 的最小 page 替身,只实现 _dump_debug_snapshot 用到的接口"""
|
||||||
|
|
||||||
|
def __init__(self, *, html: str = "<html></html>", 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="<html>boom</html>")
|
||||||
|
|
||||||
|
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") == "<html>boom</html>"
|
||||||
|
|
||||||
|
|
||||||
|
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 ----
|
# ---- helper ----
|
||||||
|
|||||||
Reference in New Issue
Block a user