feat(trading): 浏览器掉线兜底——任务边界自愈重建,中途掉线转 needs_human

SiteInteractor 的 Chromium 是进程级单例,此前启动后即假定永远活着:全仓唯一的
is_connected() 探活在 scraping 侧,交易侧既不探活也不重启。容器里 Chromium 崩溃
是有真实前提的(/dev/shm 不足、OOM kill、seccomp 挡 sandbox,docker-compose.yml
里已有相关注释),一旦发生,进程还活着但之后每一单都会失败,且 /health 恒返回
ok,restart: unless-stopped 永远不会被触发。

更隐蔽的一条:clear_cart / enter_checkout / pay 等处的 new_page()、context.request
写在 try 之外,掉线抛的 TargetClosedError 不是 AppError,会穿过 runner 的
except AppError 落到主循环那个只记日志的兜底里——任务一次都不上报,网关侧要干等
整个 lease_ttl(默认 300s)才被 sweep 置 stale。clear_cart 是 execute() 的 step 0,
浏览器死时最先撞上的正是它。

分三层处理:

- 任务边界自愈。_launch() 从 start() 抽出,_context_options() 统一 context 参数
  (重建必须与启动完全一致,指纹漂移就是一次风控事件);_refresh_context_if_stale
  改名 _ensure_context_ready(),先探活重建再做原有的 storage_state mtime 检查
  (顺序不可换,mtime 重建要用 self._browser)。重建前丢弃 _checkout_pages 里的
  残留确认页并记 warning——那些 Page 已随浏览器一起没了。

- 中途掉线不重建,抛 BrowserDeadError(新增,5006)。新增 _new_page() 与
  _request() 两个壳收口裸异常;_request() 只在确认浏览器真死了时才改写异常,
  站点 5xx 这类正常业务失败原样抛出。submit_order / pay 入口用
  _require_live_browser() 直接拒绝:这两步复用 enter_checkout 留存的 Page,
  重建救不回服务端订单草稿,而 pay 跑的时候订单已经真的提交了。顺带修掉一个
  误诊——浏览器死时 submit_order 原先报「未找到确认按钮」,把「浏览器崩了」
  说成「站点改版了」,两者的处置方式完全不同。

- runner 把 BrowserDeadError 转 needs_human 而非 failed,except 分支排在
  except AppError 之前(子类,顺序反了就报 failed)。掉线发生在动作中途,
  站点侧生效与否无从判断,不能给上游「明确失败」的结论。

/health 暴露 browser 状态,掉线时 degraded + HTTP 503 + code 5006,让
Dockerfile.trading 的 HEALTHCHECK 探到并重启容器。重启不会导致重复下单:网关侧
任务绝不自动重投,租约过期只置 stale 等人工 reclaim(docs/order-gateway.md §5),
重启只是恢复领新任务的能力。启动窗口期 started=False 不算掉线。

README 错误码表补 5005(此前遗漏)与 5006。

新增 15 个用例覆盖探活三态、is_connected() 自身抛错、边界重建/不重建/丢弃残留页/
重建失败、两个包装壳的分支、submit/pay 拒绝、runner 转 needs_human、/health 503。
真实 Chromium 崩溃无法在离线测试里制造,用例模拟的是 is_connected() 返回 False
这个唯一可观测信号,覆盖的是代码对该信号的反应而非崩溃本身;容器 HEALTHCHECK
真的触发重启这条链路尚未实跑验证。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 16:01:47 +08:00
co-authored by Claude Opus 5
parent 6b6b243132
commit 3284e086ad
10 changed files with 633 additions and 56 deletions
+24
View File
@@ -172,6 +172,30 @@ class OrderGuardError(AppError):
super().__init__(message=message, code="ORDER_GUARD", err_code=5004, retryable=False)
class BrowserDeadError(AppError):
"""Playwright 浏览器已掉线(Chromium 崩溃 / 被 OOM kill / 驱动连接断开)
单独成一类而不是复用 OrderOperationError,有两个理由:
1. **必须是 AppError**。掉线时 Playwright 抛的是 `TargetClosedError` 这类
非 AppError 异常,会直接穿过 `runner._execute_with_renewal` 的
`except AppError`,落到主循环那个只记日志的兜底里——任务一次都不上报,
网关侧要干等整个 lease_ttl(默认 300s)才被 sweep 置 stale。包成 AppError
是为了让 worker 能**立刻**回报。
2. **结论必须是 needs_human 而不是 failed**。浏览器是在动作中途没的,站点侧
到底生效没有无从判断(尤其 submit_order / pay 之后),按「明确失败」上报
会误导上游。`runner` 为此单独接这一类,见 _execute_with_renewal。
掉线本身能自愈的部分在 `SiteInteractor._ensure_browser_alive()`:**任务边界**
上探到浏览器没了会就地重建,那条路径不抛本异常。抛到这里的都是重建也救不回来
的场景(重建失败、掉线发生在一次调用中途、submit/pay 复用的确认页已随浏览器
一起消失)。
"""
def __init__(self, message: str = "浏览器已掉线"):
super().__init__(message=message, code="BROWSER_DEAD", err_code=5006, retryable=False)
class CheckoutBlockedError(AppError):
"""结算流程被站点风控拦截(session upgrade 二次验证 / 3DS / 短信验证等)
+30 -5
View File
@@ -1,5 +1,5 @@
"""交易服务健康检查路由"""
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Response
from app.shared.api import ApiResponse, get_container
from app.trading.container import TradingContainer
@@ -10,6 +10,7 @@ router = APIRouter(tags=["health"])
@router.get("/health", response_model=ApiResponse[TradingHealthData])
async def health(
response: Response,
container: TradingContainer = Depends(get_container),
) -> ApiResponse[TradingHealthData]:
"""交易服务健康状态
@@ -18,10 +19,34 @@ async def health(
上游高频轮询时反复打站点;要实时结果请用 POST /api/auth/status。
注意 `logged_in=null` 表示服务启动后还没探测过,不等于未登录。
browser 反映 Playwright 浏览器的连接状态。**浏览器已掉线时本接口返回 503**:
这个进程的所有站点操作都要靠那一个浏览器,它没了以后进程虽然还活着、端口还
通,却已经什么都干不了(每单都会失败)。返回 503 是为了让容器 HEALTHCHECK
(Dockerfile.trading)能探到并触发重启——这是掉线自愈的最后一道兜底,前面还有
SiteInteractor 在任务边界的就地重建。
重启是安全的:网关侧的下单任务绝不自动重投(租约过期只置 stale 等人工
reclaim,见 docs/order-gateway.md §5),所以重启只是让 worker 恢复领**新**
任务的能力,不会让任何一笔已经在跑的订单被重复下单。
尚未 start() 的启动窗口期不算掉线(HEALTHCHECK 有 start-period 兜着),
只有「起过浏览器且现在连不上」才转 degraded。
"""
# site 恒非空(lifespan 必建),留 None 分支只是不为一个健康检查赌这一点
browser = container.site.browser_status() if container.site is not None else {}
degraded = bool(browser.get("started")) and not browser.get("alive")
if degraded:
response.status_code = 503
return ApiResponse[TradingHealthData](
success=True,
msg="success",
data=TradingHealthData(status="ok", auth=container.auth_session.status_all()),
code=0,
success=not degraded,
msg="浏览器已掉线" if degraded else "success",
data=TradingHealthData(
status="degraded" if degraded else "ok",
auth=container.auth_session.status_all(),
browser=browser,
),
# 与 BrowserDeadError 用同一个错误码,上游按同一张错误码表分支
code=5006 if degraded else 0,
)
+11 -1
View File
@@ -85,10 +85,20 @@ class TradingHealthData(BaseModel):
只读缓存的登录态,不触发网络探测——健康检查会被高频轮询,实时结果请用
POST /api/auth/status。
browser 是唯一一个「实时」字段,但它只读 Playwright 的本地连接状态
(`browser.is_connected()`),不打站点也不进事件循环,高频探活没有代价。
"""
status: str = Field(description="服务状态,健康为 ok")
status: str = Field(description="服务状态:ok 正常,degraded 表示浏览器已掉线(HTTP 503)")
auth: dict[str, Any] = Field(default_factory=dict, description="缓存的各站点登录态(不触发网络探测)")
browser: dict[str, Any] = Field(
default_factory=dict,
description=(
"Playwright 浏览器状态:started 是否已跑过 start(),alive 连接是否正常,"
"pending_checkout_tasks 仍留有下单确认页的 task_id,detail 文字说明"
),
)
# ---- 购物车接口(/api/cart/*)----
+23 -1
View File
@@ -28,7 +28,12 @@ import contextlib
import logging
from typing import TYPE_CHECKING
from app.shared.errors import AppError, CheckoutBlockedError, OrderGuardError
from app.shared.errors import (
AppError,
BrowserDeadError,
CheckoutBlockedError,
OrderGuardError,
)
from app.shared.task_state import OrderState, TaskStatus
from app.trading.worker import verify
from app.trading.worker.client import GatewayClient
@@ -240,6 +245,23 @@ class WorkerRunner:
terminal=True,
terminal_status=TaskStatus.NEEDS_HUMAN,
)
except BrowserDeadError as exc:
# 浏览器在执行途中没了:站点侧到底生效没有无从判断(尤其掉线发生在
# submit_order / pay 前后),按 needs_human 交人工核对订单列表。
# 必须排在 except AppError 前面——BrowserDeadError 是 AppError 的
# 子类,顺序反了就会被当成普通失败报 failed。
logger.error(
"浏览器掉线导致任务中断,转 needs_human:task_id=%s msg=%s",
task.task_id,
exc.message,
)
await self._report_safe(
task,
state=_coerce_state(task.known_state),
detail=exc.message,
terminal=True,
terminal_status=TaskStatus.NEEDS_HUMAN,
)
except AppError as exc:
logger.warning(
"执行失败:task_id=%s code=%s msg=%s",
+237 -45
View File
@@ -97,6 +97,7 @@ from datetime import datetime
from typing import TYPE_CHECKING, TypeVar
from app.shared.errors import (
BrowserDeadError,
CartOperationError,
CheckoutBlockedError,
InvalidRequestError,
@@ -117,6 +118,7 @@ from app.trading.worker.models import LeaseTask
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from pathlib import Path
from playwright.async_api import Page
@@ -620,6 +622,20 @@ class SiteInteractor:
浏览器 context 复用同一份登录态,所有任务串行(self._lock + worker 主循环本就串行),
不需要为每个任务开新 context——开销大且 cookie 状态会乱。
**掉线兜底**:Chromium 可能崩溃(容器里 /dev/shm 不足、被 OOM kill、
seccomp 挡住 sandbox 等,见 docker-compose.yml 注释),而这个浏览器是进程级
单例,一旦没了后续每一单都会失败。处理分两层:
- 任务边界(各公开方法入口的 `_ensure_context_ready()`)探到掉线就**就地重建**
一套完全一样的 playwright/browser/context,调用方无感知。
- 一次调用**中途**掉线不重建:站点侧生效与否无从判断,统一抛 `BrowserDeadError`
(AppError 的一种),由 runner 转 needs_human 交人工核对。submit_order / pay
因为复用 enter_checkout 留存的 Page,入口处直接用 `_require_live_browser()`
拒绝,不走重建。
`browser_status()` 把这份状态暴露给交易服务 /health,容器 HEALTHCHECK 据此
在进程还活着但浏览器已死时触发重启。
调用方:
- worker runner:传入 LeaseTask,调 add_to_cart(task) / verify_cart(task)
- HTTP 路由 /api/cart/*:调 add_to_cart_payload(...) / cart_status() /
@@ -654,28 +670,56 @@ class SiteInteractor:
# ---- 生命周期 ----
def _state_path(self) -> "Path":
"""rakuten 的 storage_state 文件路径"""
return self._settings.auth_state_path / auth_site.profile("rakuten").state_filename
@staticmethod
def _context_options(storage_state: str | None) -> dict:
"""new_context 的参数。启动与掉线重建必须用同一套,指纹漂移会触发风控"""
return {
"storage_state": storage_state,
"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,
}
async def start(self) -> None:
"""启动 Playwright 与带 cookie 的浏览器 context
登录态文件不存在时同样启动(context 没 cookie),后续 add_to_cart 会
在 require_logged_in 里报错。这样保持启动路径一致。
"""
await self._launch()
logger.info("SiteInteractor 已就绪:storage_state=%s", self._state_path())
async def _launch(self) -> None:
"""真正拉起 playwright + browser + context。start() 与掉线重建共用
抽出来的唯一目的就是让 `_ensure_browser_alive()` 能原样重放一遍启动流程——
重建出来的浏览器必须和启动时**完全一致**(同样的 UA / viewport / proxy /
launch args),否则 Akamai 那边指纹一变就是一次风控事件。
"""
from playwright.async_api import async_playwright
state_path = self._settings.auth_state_path / auth_site.profile("rakuten").state_filename
state_path = self._state_path()
storage_state = str(state_path) if state_path.exists() else None
if storage_state is None:
logger.warning(
"登录态文件不存在:site_interactor 以无 cookie 状态启动,"
"加购请求会被站点拒认"
)
self._state_mtime = None
else:
self._state_mtime = state_path.stat().st_mtime
self._playwright = await async_playwright().start()
self._browser = await self._playwright.chromium.launch(
# 2026-08-14 实测确认:headless=True 会让站点的购物车/结算 SPA 表现
# 异常(入手続き点了不跳转、shopUrlList 渲染不出来),换 headless=False
# 异常(入手続き点了不跳转、shopUrlList 渲染不出来),换 headless=False
# 后行为与真实下单一致(能正常触发 session upgrade)——所有会改动站点
# 状态的操作(加购/结算/支付)都必须走非无头浏览器
headless=False,
@@ -683,26 +727,150 @@ class SiteInteractor:
proxy=playwright_launch_proxy(self._settings),
args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
)
self._context = await self._browser.new_context(
storage_state=storage_state,
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,
)
logger.info("SiteInteractor 已就绪:storage_state=%s", storage_state or "(none)")
self._context = await self._browser.new_context(**self._context_options(storage_state))
async def _refresh_context_if_stale(self) -> None:
"""检查 storage_state 文件 mtime,变化则重建 context
# ---- 掉线探活与重建 ----
AuthSession.try_relogin 成功后会重写 storage_state 文件。本 context 启动时
用快照式 storage_state 创建,cookie 不会自动同步——必须关掉旧 context、
用新文件重建。在 add_to_cart / verify_cart 开头各调一次,开销可接受
(只在 mtime 变了才重建)。
@property
def browser_alive(self) -> bool:
"""浏览器进程当前是否还连着
供 /health 与 `_ensure_browser_alive()` 判断。`is_connected()` 是同步调用,
不打站点、不进事件循环,高频探活没有代价。
"""
state_path = self._settings.auth_state_path / auth_site.profile("rakuten").state_filename
browser = self._browser
if browser is None:
return False
try:
return bool(browser.is_connected())
except Exception:
return False
def browser_status(self) -> dict:
"""浏览器状态快照,供交易服务 /health 暴露
`started` 与 `alive` 要分开看:`started=False` 是「还没跑 start()」(正常的
启动窗口期),`started=True, alive=False` 才是「浏览器死了」——只有后者该
让健康检查转 degraded。
"""
started = self._browser is not None
alive = self.browser_alive
if not started:
detail = "尚未启动"
elif alive:
detail = "连接正常"
else:
detail = "浏览器已掉线(Chromium 崩溃 / 被 kill),需要重建或重启进程"
return {
"started": started,
"alive": alive,
"pending_checkout_tasks": sorted(self._checkout_pages),
"detail": detail,
}
async def _ensure_browser_alive(self) -> None:
"""任务边界上的探活:浏览器没了就地重建一套
**只在任务边界重建**(各公开方法入口,动作还没发出去的时候)。中途掉线不
走这里——那时站点侧生不生效已经无从判断,只能抛 BrowserDeadError 交人工,
见 `_new_page()` 与 `_require_live_browser()`。
重建前会把 `_checkout_pages` 里残留的确认页丢掉:那些 Page 已经随浏览器
一起没了,留着只会让后续 submit_order / pay 拿到一个必然报错的死对象。
丢弃前记一条 warning——留了确认页说明上一单卡在「已进确认页、可能已提交」
的中间态,这条日志是人工核对的线索。
Raises:
BrowserDeadError: 重建失败(Chromium 起不来),下一次调用会再试一次
"""
if self.browser_alive:
return
orphaned = sorted(self._checkout_pages)
if orphaned:
logger.warning(
"浏览器掉线时仍留有未收尾的下单确认页,随浏览器一起丢弃,"
"这些任务的站点侧状态需人工核对:task_ids=%s",
orphaned,
)
self._checkout_pages.clear()
logger.warning("检测到浏览器已掉线,就地重建 Playwright / 浏览器 / context")
await self._teardown()
try:
await self._launch()
except Exception as exc:
# 重建失败时把半成品收干净,让下一次调用从 _browser=None 重新试
await self._teardown()
raise BrowserDeadError(
f"浏览器掉线后重建失败:{type(exc).__name__}: {exc}"
) from exc
logger.info("浏览器已重建完成")
def _require_live_browser(self, where: str) -> None:
"""确认浏览器还活着,否则抛 BrowserDeadError(**不重建**)
给 submit_order / pay 这种「复用 enter_checkout 留存的 Page」的步骤用:
重建一套新浏览器救不回那份服务端订单草稿,而且这两步正好卡在提交前后,
站点侧生效与否不能猜——直接抛出去让 runner 转 needs_human。
"""
if self.browser_alive:
return
raise BrowserDeadError(
f"{where}:浏览器已掉线,enter_checkout 留存的下单确认页已随之失效,"
"本单站点侧是否已提交无法判断,需人工核对订单列表(不要按失败重试)"
)
async def _new_page(self) -> "Page":
"""开一个新页面,掉线时抛 BrowserDeadError 而不是裸的 TargetClosedError
各方法里 `new_page()` 都写在 try 之外(页面还没拿到,没有要 close 的东西),
裸异常会穿过 runner 的 `except AppError` 一路飘到主循环,任务一次都不上报。
统一走这个壳把它包成 AppError。
"""
try:
return await self._context.new_page()
except Exception as exc:
raise BrowserDeadError(
f"打开新页面失败,浏览器可能已掉线:{type(exc).__name__}: {exc}"
) from exc
async def _request(self, method: str, url: str, **kwargs):
"""走 context.request 发请求(共享 cookie),掉线时转成 BrowserDeadError
与 `_new_page()` 同样的理由:裸的 TargetClosedError 会绕过 runner 的
`except AppError`。区别是这里的请求本来就可能因为站点/网络原因失败,那些
是正常的业务失败——所以**只在确认浏览器真的没了时**才改写异常,其余原样
抛出,交给各调用点既有的判据处理。
"""
try:
return await getattr(self._context.request, method)(url, **kwargs)
except Exception as exc:
if self.browser_alive:
raise
raise BrowserDeadError(
f"{method.upper()} {url} 失败且浏览器已掉线:{type(exc).__name__}: {exc}"
) from exc
async def _ensure_context_ready(self) -> None:
"""每个站点动作前的统一前置:浏览器还活着 + context 拿的是最新 cookie
两件事按顺序做,顺序不能换——mtime 重建要用 `self._browser`,浏览器已经
死了的话得先重建出来:
1. `_ensure_browser_alive()`:掉线就地重建(任务边界)
2. storage_state mtime 变了就重建 context:AuthSession.try_relogin 成功后会
重写该文件,而本 context 启动时拿的是快照式 storage_state,cookie 不会
自动同步,必须关掉旧 context 用新文件重建
只在 mtime 真的变了才重建,每个动作前调一次的开销可接受。
Raises:
BrowserDeadError: 浏览器掉线且重建失败
"""
await self._ensure_browser_alive()
state_path = self._state_path()
if not state_path.exists():
return
mtime = state_path.stat().st_mtime
@@ -718,15 +886,14 @@ class SiteInteractor:
await self._context.close()
except Exception:
logger.debug("关闭旧 context 失败", exc_info=True)
self._context = await self._browser.new_context(
storage_state=str(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,
)
try:
self._context = await self._browser.new_context(
**self._context_options(str(state_path))
)
except Exception as exc:
raise BrowserDeadError(
f"重建 context 失败,浏览器可能已掉线:{type(exc).__name__}: {exc}"
) from exc
self._state_mtime = mtime
async def _read_with_relogin_retry(
@@ -751,7 +918,7 @@ class SiteInteractor:
await self._auth_session.require_logged_in("rakuten")
# 重登会重写 storage_state,本 context 的 cookie 是启动时的快照,
# 必须在每次 attempt 前都刷一遍,否则重登后仍拿旧 cookie 去读。
await self._refresh_context_if_stale()
await self._ensure_context_ready()
try:
return await read()
except _LoggedOutMidRead as exc:
@@ -778,6 +945,16 @@ class SiteInteractor:
"""关闭 context、browser、playwright,吞掉单个 close 异常"""
for task_id in list(self._checkout_pages):
await self._discard_checkout_page(task_id)
await self._teardown()
async def _teardown(self) -> None:
"""释放 context / browser / playwright 三件套并置空
与 close() 分开是因为掉线重建(`_ensure_browser_alive`)也要走一遍:那条
路径不能碰 `_checkout_pages`(那些 Page 已经死了,`page.close()` 只会再抛
一次异常),它自己负责丢弃。每个 close 独立 try——已经死掉的浏览器上
close 本来就会抛,不能因此漏掉后面两个资源的释放。
"""
for resource, name in (
(self._context, "context"),
(self._browser, "browser"),
@@ -880,7 +1057,7 @@ class SiteInteractor:
response_html / screenshot 供 worker 入口包成 PageSnapshot 落证据。
"""
await self._auth_session.require_logged_in("rakuten")
await self._refresh_context_if_stale()
await self._ensure_context_ready()
intent_for_extract: dict = {}
if variant_id is not None:
@@ -888,7 +1065,7 @@ class SiteInteractor:
if choice is not None:
intent_for_extract["choice"] = choice
page = await self._context.new_page()
page = await self._new_page()
try:
try:
await page.goto(item_url, wait_until="domcontentloaded", timeout=30_000)
@@ -932,7 +1109,8 @@ class SiteInteractor:
"加购请求:basket=%s payload=%s", fields["basket_domain"], payload,
)
resp = await self._context.request.post(
resp = await self._request(
"post",
fields["basket_domain"],
form=payload,
max_redirects=5,
@@ -1000,7 +1178,7 @@ class SiteInteractor:
"""
async with self._lock:
await self._auth_session.require_logged_in("rakuten")
await self._refresh_context_if_stale()
await self._ensure_context_ready()
per_task = self._per_task_state.get(task.task_id, {})
item_id = (task.intent or {}).get("item_id") or per_task.get("item_id")
@@ -1028,7 +1206,7 @@ class SiteInteractor:
"""
async with self._lock:
await self._auth_session.require_logged_in("rakuten")
await self._refresh_context_if_stale()
await self._ensure_context_ready()
raw_status, count = await self._query_cart_count()
return {
"logged_in": True,
@@ -1057,9 +1235,9 @@ class SiteInteractor:
"""
async with self._lock:
await self._auth_session.require_logged_in("rakuten")
await self._refresh_context_if_stale()
await self._ensure_context_ready()
page = await self._context.new_page()
page = await self._new_page()
removed = 0
html = ""
screenshot = b""
@@ -1148,9 +1326,9 @@ class SiteInteractor:
raise InvalidRequestError("item_id 必填")
async with self._lock:
await self._auth_session.require_logged_in("rakuten")
await self._refresh_context_if_stale()
await self._ensure_context_ready()
page = await self._context.new_page()
page = await self._new_page()
try:
await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
await self._wait_cart_rendered(page, label=f"remove_item {item_id}")
@@ -1212,7 +1390,8 @@ class SiteInteractor:
才是「获取失败」,抛 CartOperationError——常见原因是 Referer 错或
cookie 失效
"""
resp = await self._context.request.get(
resp = await self._request(
"get",
_CART_COUNT_API + "?sid=1010",
headers={"Referer": _CART_PAGE},
)
@@ -1236,7 +1415,7 @@ class SiteInteractor:
返回渲染后的 cart 页 HTML + 整页截图(校验通过时),供调用方落证据。
"""
page = await self._context.new_page()
page = await self._new_page()
try:
await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
await self._wait_cart_rendered(page, label=label)
@@ -1367,9 +1546,9 @@ class SiteInteractor:
"""
async with self._lock:
await self._auth_session.require_logged_in("rakuten")
await self._refresh_context_if_stale()
await self._ensure_context_ready()
page = await self._context.new_page()
page = await self._new_page()
success = False
try:
await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
@@ -1856,6 +2035,10 @@ class SiteInteractor:
供人工核对,详见 _discard_checkout_page 的调用取舍)
"""
async with self._lock:
# 掉线检查必须在取 page 之前:浏览器没了的话 _checkout_pages 里那个
# Page 是个死对象(不是 None),下面的 selector 轮询会全部超时,最后
# 报「未找到确认按钮」——把「浏览器崩了」误诊成「站点改版了」
self._require_live_browser("submit_order")
page = self._checkout_pages.get(task.task_id)
if page is None:
raise OrderOperationError(
@@ -1952,6 +2135,10 @@ class SiteInteractor:
整页截图,供 runner step 5 落证据。
"""
async with self._lock:
# 这一步最危险:submit_order 已经真的提交过订单了。浏览器在这时候没了,
# 「付款到底完成没有」完全无从判断,必须当场抛出去转人工,不能让裸的
# TargetClosedError 飘到主循环变成「一次都不上报」。
self._require_live_browser("pay")
page = self._checkout_pages.pop(task.task_id, None)
if page is None:
raise OrderOperationError(
@@ -1987,7 +2174,12 @@ class SiteInteractor:
logger.warning("pay 页面截图失败(不影响付款判定)", exc_info=True)
return PageSnapshot(html=html, screenshot=screenshot)
finally:
await page.close()
# 关页面失败不能掩盖 try 里的原始异常——浏览器要是在本方法执行途中
# 没的,close() 自己也会抛,那条异常没有任何诊断价值
try:
await page.close()
except Exception:
logger.debug("pay 关闭确认页失败", exc_info=True)
async def check_order_status(self, site_order_id: str) -> OrderStatusSnapshot:
"""付款后监控的单次探测:查一次订单详情页的配送阶段,不循环
@@ -2034,7 +2226,7 @@ class SiteInteractor:
url = _ORDER_DETAIL_URL_TEMPLATE.format(order_number=site_order_id, shop_id=shop_id)
async def read() -> OrderDetailSnapshot:
page = await self._context.new_page()
page = await self._new_page()
try:
try:
await page.goto(url, wait_until="domcontentloaded", timeout=30_000)
@@ -2112,7 +2304,7 @@ class SiteInteractor:
acc = _OrderListAccumulator()
stop = False
page = await self._context.new_page()
page = await self._new_page()
try:
for page_num in range(1, page_limit + 1):
url = _ORDER_LIST_URL if page_num == 1 else f"{_ORDER_LIST_URL}?page={page_num}"