feat(trading): 下单流程每步证据带整页截图(html + png + meta 三件套)

- site_interact 新增 PageSnapshot{html, screenshot} 证据载体:
  add_to_cart 截商品页、verify_cart 截 cart 页、enter_checkout 截落地
  确认页、pay 截付款检查后的完成页;截图全部 best-effort,失败记
  warning 留空,不掩盖动作结果
- submit_order 改返回 SubmitOutcome{site_order_id, evidence}:完成页
  HTML+截图首次随 step 4 落盘(此前只有 meta)
- fetch_order_detail 详情页截图挂在 OrderStatusSnapshot.screenshot,
  监控步骤 write_step 带 png;只读查询通道按字段挑出,不受影响
- runner._run_step 认 PageSnapshot(旧 str 契约保留兼容),step 0/3/4
  显式传 png
- 测试:桩同步新契约;新增每步三件套齐全的全流程断言;clear_cart
  场景单测补截图断言
- 真账号复验 verify_cart_clear.py:两场景截图均合法 PNG,落盘
  .probe/cart_clear/ 并目检为空车页渲染
This commit is contained in:
2026-08-17 08:56:11 +08:00
parent 086ab82614
commit f31e127ba7
5 changed files with 304 additions and 58 deletions
+27 -13
View File
@@ -35,7 +35,7 @@ from app.trading.worker.client import GatewayClient
from app.trading.worker.evidence import EvidenceStore
from app.trading.worker.local_db import LocalDB
from app.trading.worker.models import LeaseTask
from app.trading.worker.site_interact import SiteInteractor
from app.trading.worker.site_interact import PageSnapshot, SiteInteractor
if TYPE_CHECKING:
from app.shared.config import Settings
@@ -260,8 +260,8 @@ class WorkerRunner:
每一步的顺序:动作 → 落证据 → 写本地 SQLite → 回报 gateway。
开单前的「清购物车」是本机侧卫生步骤(step 0,见下方代码注释):落本地
步骤证据(页面 + meta)但不上报 gateway、不记状态事件;站点交互失败会转
_execute_with_renewal 的 except 分支上报 needs_human / failed。
步骤证据(页面 + 截图 + meta)但不上报 gateway、不记状态事件;站点交互
失败会转 _execute_with_renewal 的 except 分支上报 needs_human / failed。
"""
await self._db.ensure_started(task.task_id, task.site, task.intent)
@@ -277,7 +277,7 @@ class WorkerRunner:
# (包括进程中途崩溃,残留都没人清),这里都从空车起步。
#
# 这条不在 gateway 上报、不记 order_events:它是本机侧的卫生操作,不是订单
# 进度的状态迁移。但清理后的页面快照与 meta 要落本地证据并登记
# 进度的状态迁移。但清理后的页面快照、整页截图与 meta 要落本地证据并登记
# evidence_index——清理没跑干净被下面的闸门拦下转 needs_human 时,这份现场
# 就是排查依据,所以证据必须先于闸门判断落盘。清理后 count 非 0(含
# count API 拿不到结果返回 -1)说明清理没跑干净,**不能**带着残留往下
@@ -287,6 +287,7 @@ class WorkerRunner:
evidence_ref = self._evidence.write_step(
task.task_id, 0, "cart-clear",
html=cleared.get("html") or None,
png=cleared.get("screenshot") or None,
meta={
"step": "cart-clear",
"removed_count": cleared.get("removed_count"),
@@ -319,15 +320,16 @@ class WorkerRunner:
)
# 步骤 3:进入下单确认页 + 金额守卫
checkout_html = await self._site.enter_checkout(task)
summary = await self._site.parse_checkout(checkout_html)
checkout = await self._site.enter_checkout(task)
summary = await self._site.parse_checkout(checkout.html)
self._enforce_amount_guard(task, summary.payable_yen)
await self._run_step(
task, step_no=3, step_name="order-confirm",
action=self._noop(),
state=OrderState.CREATED,
detail=f"下单确认页已解析:应付 {summary.payable_yen}",
html=checkout_html,
html=checkout.html or None,
png=checkout.screenshot or None,
evidence_meta={
"payable_yen": summary.payable_yen,
"site_order_id": summary.site_order_id,
@@ -336,13 +338,16 @@ class WorkerRunner:
)
# 步骤 4:提交下单
site_order_id = await self._site.submit_order(task)
submit = await self._site.submit_order(task)
site_order_id = submit.site_order_id
await self._run_step(
task, step_no=4, step_name="order-submit",
action=self._noop(),
state=OrderState.ORDERED,
site_order_id=site_order_id,
detail=f"已提交下单,站点订单号 {site_order_id}",
html=submit.evidence.html or None,
png=submit.evidence.screenshot or None,
)
# 步骤 5:付款
@@ -424,6 +429,7 @@ class WorkerRunner:
evidence_ref = self._evidence.write_step(
task.task_id, step_no, step_name,
html=snapshot.html,
png=snapshot.screenshot or None,
meta={
"step": step_name,
"state": snapshot.order_state.value,
@@ -485,15 +491,23 @@ class WorkerRunner:
pay_deadline: str | None = None,
evidence_meta: dict | None = None,
html: str | None = None,
png: bytes | None = None,
) -> None:
"""单步执行:动作 → 落证据 → 写本地 → 回报 gateway
`action` 返回值若是字符串,视为该步骤要落盘的页面 HTML(add_to_cart /
verify_cart 均已改为返回渲染后的页面);显式传入的 `html` 优先级更高,
供 step3 等已经单独拿到页面 HTML 的调用点直接使用。
`action` 返回 PageSnapshot(add_to_cart / verify_cart / pay 等站点方法的
证据载体)时,其 html / screenshot 即本步骤要落盘的页面与整页截图;
显式传入的 `html` / `png` 优先级更高,供 step3/step4 等已经单独拿到
页面的调用点使用。
"""
result = await action()
if html is None and isinstance(result, str):
if isinstance(result, PageSnapshot):
if html is None:
html = result.html or None
if png is None:
png = result.screenshot or None
elif html is None and isinstance(result, str):
# 旧契约兼容:站点方法返回字符串时视为页面 HTML
html = result
meta = {
"step": step_name,
@@ -501,7 +515,7 @@ class WorkerRunner:
**(evidence_meta or {}),
}
evidence_ref = self._evidence.write_step(
task.task_id, step_no, step_name, html=html, meta=meta
task.task_id, step_no, step_name, html=html, png=png, meta=meta
)
await self._db.index_evidence(task.task_id, step_no, step_name, evidence_ref)
await self._db.record_event(
+129 -24
View File
@@ -529,6 +529,30 @@ def _accumulate_order_list_page(
return new_acc, False
@dataclass(slots=True)
class PageSnapshot:
"""一步站点交互带回的页面证据:渲染后 HTML + 整页截图
供 runner 给每个步骤落证据(html → {step}.html,screenshot → {step}.png)。
两者都是 best-effort:页面抓取/截图失败时对应字段为空,不掩盖动作本身的结果。
"""
html: str = ""
screenshot: bytes = b""
@dataclass(slots=True)
class SubmitOutcome:
"""submit_order 的返回值:站点订单号 + 下单完成页证据
完成页(「ご注文がショップに送信されました」)是「订单真的提交了」最直接的
现场,HTML 与截图随返回值带出,供 runner step 4 落证据。
"""
site_order_id: str
evidence: PageSnapshot
@dataclass(slots=True)
class CheckoutSummary:
"""下单确认页解析结果(待实测确认字段位置)
@@ -565,6 +589,8 @@ class OrderStatusSnapshot:
order_state: OrderState | None = None
delivery_status: str | None = None
html: str = ""
# 详情页整页截图(fetch_order_detail 填,best-effort),供监控步骤落证据
screenshot: bytes = b""
@dataclass(slots=True)
@@ -770,7 +796,7 @@ class SiteInteractor:
# ---- 已实现:add_to_cart / verify_cart / cart_status ----
async def add_to_cart(self, task: LeaseTask) -> str | None:
async def add_to_cart(self, task: LeaseTask) -> PageSnapshot:
"""加购(worker 入口):从 task.intent 取字段,调 _add_to_cart_with_fields
调用方需在 task.intent 提供:
@@ -780,7 +806,7 @@ class SiteInteractor:
- choice: 必填选项的取值列表;不传则每个必填选项用第一个候选值(站点不严格校验)
Returns:
加购成功后落地页(cart 页)的 HTML,供 runner 落证据;查不到时为 None
PageSnapshot:加购响应的落地页 HTML + 商品页整页截图,供 runner 落证据。
Raises:
InvalidRequestError: intent.item_url 缺失
@@ -807,7 +833,10 @@ class SiteInteractor:
"shop_bid": result["shop_bid"],
"basket_domain": result["basket_domain"],
}
return result.get("response_html")
return PageSnapshot(
html=result.get("response_html") or "",
screenshot=result.get("screenshot") or b"",
)
async def add_to_cart_payload(
self,
@@ -846,8 +875,9 @@ class SiteInteractor:
) -> dict:
"""加购核心逻辑(不持锁,由调用方包裹 self._lock)
返回 dict:{item_id, shop_bid, basket_domain, cart_count}
返回 dict:{item_id, shop_bid, basket_domain, cart_count, response_html, screenshot}
cart_count 在加购成功后顺带查一次 cart count API,方便 HTTP 调用方一次性返回。
response_html / screenshot 供 worker 入口包成 PageSnapshot 落证据。
"""
await self._auth_session.require_logged_in("rakuten")
await self._refresh_context_if_stale()
@@ -928,6 +958,14 @@ class SiteInteractor:
"加购成功:item_id=%s final=%s",
fields["form_fields"].get("item_id"), final_url,
)
# 商品页整页截图随结果带出(runner step 1 落证据用);
# 截图失败只记日志,不掩盖加购结果
screenshot = b""
try:
screenshot = await page.screenshot(full_page=True)
except Exception:
logger.warning("加购后截取商品页截图失败(不影响加购结果)", exc_info=True)
finally:
await page.close()
@@ -944,9 +982,10 @@ class SiteInteractor:
"basket_domain": fields["basket_domain"],
"cart_count": cart_count,
"response_html": body,
"screenshot": screenshot,
}
async def verify_cart(self, task: LeaseTask) -> str | None:
async def verify_cart(self, task: LeaseTask) -> PageSnapshot:
"""校验购物车里有没有刚加的商品
策略(探针实测最稳的两步):
@@ -955,7 +994,7 @@ class SiteInteractor:
2. 打开 cart 页等 SPA 渲染,在 HTML 里找 item_id
Returns:
渲染后的 cart 页 HTML,供 runner 落证据。
PageSnapshot:渲染后的 cart 页 HTML + 整页截图,供 runner 落证据。
登录态失效抛 NotLoggedInError;找不到 item 抛 CartOperationError。
"""
@@ -1010,11 +1049,11 @@ class SiteInteractor:
- 若 SPA 把按钮渲染在 iframe 里,selector 失败需实测后调整
- Rakuten cart item 卡片无 data-testid,本方法不依赖 DOM 结构定位
返回 {removed_count, cart_count, html};cart_count=-1 表示末尾 count API
调用失败(空车属正常结果返回 0,见 _query_cart_count 的 status=101 处理)。
html 是清理结束后 cart 页的最终渲染结果,供调用方落证据排查——清理没跑干净
被 runner 闸门拦下时这份现场就是排查依据;HTTP /api/cart/clear 入口的响应
模型不携带它。
返回 {removed_count, cart_count, html, screenshot};cart_count=-1 表示末尾
count API 调用失败(空车属正常结果返回 0,见 _query_cart_count 的
status=101 处理)。html 与 screenshot 是清理结束后 cart 页的最终渲染结果
与整页截图,供调用方落证据排查——清理没跑干净被 runner 闸门拦下时这份现场
就是排查依据;HTTP /api/cart/clear 入口的响应模型不携带它
"""
async with self._lock:
await self._auth_session.require_logged_in("rakuten")
@@ -1023,6 +1062,7 @@ class SiteInteractor:
page = await self._context.new_page()
removed = 0
html = ""
screenshot = b""
try:
await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
await self._wait_cart_rendered(page, label="clear_cart")
@@ -1052,8 +1092,8 @@ class SiteInteractor:
_CLEAR_CART_MAX_ITER,
)
# 清理结束后的最终页面留给调用方落证据(worker runner step 0);
# 抓取失败只记日志,不把排查用的副作用变成新的失败源
# 清理结束后的最终页面与整页截图留给调用方落证据(worker runner
# step 0);抓取失败只记日志,不把排查用的副作用变成新的失败源
try:
html = await page.content()
except Exception:
@@ -1061,6 +1101,13 @@ class SiteInteractor:
"clear_cart:抓取最终页面 HTML 失败(不影响清理结果)",
exc_info=True,
)
try:
screenshot = await page.screenshot(full_page=True)
except Exception:
logger.warning(
"clear_cart:截取最终页面截图失败(不影响清理结果)",
exc_info=True,
)
finally:
await page.close()
@@ -1072,7 +1119,12 @@ 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, "html": html}
return {
"removed_count": removed,
"cart_count": cart_count,
"html": html,
"screenshot": screenshot,
}
async def remove_item(self, item_id: str) -> dict:
"""删除购物车里指定 item_id 的商品
@@ -1179,10 +1231,10 @@ class SiteInteractor:
count = int(count_match.group(1)) if count_match and count_match.group(1) else 0
return raw_status, count
async def _verify_item_in_cart_html(self, item_id: str, *, label: str) -> str:
async def _verify_item_in_cart_html(self, item_id: str, *, label: str) -> PageSnapshot:
"""渲染 cart SPA,在 HTML 里找 item_id,确认商品确实进了购物车
返回渲染后的 cart 页 HTML(校验通过时),供调用方落证据。
返回渲染后的 cart 页 HTML + 整页截图(校验通过时),供调用方落证据。
"""
page = await self._context.new_page()
try:
@@ -1201,7 +1253,13 @@ class SiteInteractor:
f"购物车页未找到 item_id={item_id}(加购可能被服务端静默丢弃)"
)
logger.info("cart 校验通过:%s item_id=%s in cart HTML", label, item_id)
return html
# 校验通过的 cart 页整页截图随结果带出;失败不掩盖校验结果
screenshot = b""
try:
screenshot = await page.screenshot(full_page=True)
except Exception:
logger.warning("cart 校验后截图失败(不影响校验结果)", exc_info=True)
return PageSnapshot(html=html, screenshot=screenshot)
finally:
await page.close()
@@ -1269,7 +1327,7 @@ class SiteInteractor:
# ---- 已实现:enter_checkout(到下单确认页,中间步骤未经真实 HTML 验证)----
async def enter_checkout(self, task: LeaseTask) -> str:
async def enter_checkout(self, task: LeaseTask) -> PageSnapshot:
"""进入下单确认页:购物车 → 点「購入手続き」→ 依次处理中间步骤 → 落地确认页
流程:
@@ -1297,7 +1355,8 @@ class SiteInteractor:
页面重开拿不回同一份订单草稿。
Returns:
下单确认页(或目前所能到达的最后一页)的 HTML,供 runner 落证据。
PageSnapshot:下单确认页(或目前所能到达的最后一页)的 HTML + 整页截图,
供 runner 落证据。
Raises:
NotLoggedInError: 登录态失效
@@ -1374,9 +1433,18 @@ class SiteInteractor:
"enter_checkout 落地:task_id=%s url=%s", task.task_id, page.url
)
html = await page.content()
# 落地页整页截图随结果带出(step 3 落证据用);页面要保持打开供
# submit_order 复用,截图失败只记日志不掩盖落地结果
screenshot = b""
try:
screenshot = await page.screenshot(full_page=True)
except Exception:
logger.warning(
"enter_checkout 落地页截图失败(不影响落地结果)", exc_info=True,
)
self._checkout_pages[task.task_id] = page
success = True
return html
return PageSnapshot(html=html, screenshot=screenshot)
except Exception:
await self._dump_debug_snapshot(
page, task_id=task.task_id, label="enter_checkout-error"
@@ -1770,7 +1838,7 @@ class SiteInteractor:
"""
return _parse_checkout_summary(html)
async def submit_order(self, task: LeaseTask) -> str:
async def submit_order(self, task: LeaseTask) -> SubmitOutcome:
"""点击下单确认页的最终确认按钮,提交订单
调用前必须已经过 enter_checkout(本方法复用它留存的 Page)与金额守卫;
@@ -1778,7 +1846,8 @@ class SiteInteractor:
**本会话内从未被真实执行过**。
Returns:
从提交后页面解析出的站点订单号;解析不出时抛错,不返回猜测值。
SubmitOutcome:从提交后页面解析出的站点订单号 + 完成页证据(HTML +
整页截图);订单号解析不出时抛错,不返回猜测值。
Raises:
OrderOperationError: 找不到 enter_checkout 留存的确认页会话 / 找不到
@@ -1849,12 +1918,24 @@ class SiteInteractor:
"(可能已经下单成功,需要人工核对站点订单列表,不要按失败重试)"
)
site_order_id = match.group(1)
# 完成页整页截图随返回值带出(step 4 落证据用);页面留着给 pay 复用,
# 截图失败只记日志不掩盖提交结果
screenshot = b""
try:
screenshot = await page.screenshot(full_page=True)
except Exception:
logger.warning(
"submit_order 完成页截图失败(不影响提交结果)", exc_info=True,
)
logger.info(
"submit_order 完成:task_id=%s site_order_id=%s", task.task_id, site_order_id
)
return site_order_id
return SubmitOutcome(
site_order_id=site_order_id,
evidence=PageSnapshot(html=html, screenshot=screenshot),
)
async def pay(self, task: LeaseTask, site_order_id: str) -> None:
async def pay(self, task: LeaseTask, site_order_id: str) -> PageSnapshot:
"""检查提交下单后是否已完成付款 / 是否触发了需要人工介入的验证环节
按 docs/order-gateway.md §10.1 既定方案实现:检测到常见 3DS/短信 OTP 迹象
@@ -1865,6 +1946,10 @@ class SiteInteractor:
本方法结束时无论成败都会关闭 self._checkout_pages 里留存的 Page
(提交/付款环节已经走到这一步,没有再复用同一页面的后续步骤)。
Returns:
PageSnapshot:付款检查通过后的页面(通常仍是下单完成页)HTML +
整页截图,供 runner step 5 落证据。
"""
async with self._lock:
page = self._checkout_pages.pop(task.task_id, None)
@@ -1888,6 +1973,19 @@ class SiteInteractor:
"假设):task_id=%s site_order_id=%s",
task.task_id, site_order_id,
)
# 付款检查通过后的页面留证据(runner step 5):此时通常仍是下单
# 完成页。抓取失败只记日志,不掩盖付款判定
html = ""
screenshot = b""
try:
html = await page.content()
except Exception:
logger.warning("pay 页面 HTML 抓取失败(不影响付款判定)", exc_info=True)
try:
screenshot = await page.screenshot(full_page=True)
except Exception:
logger.warning("pay 页面截图失败(不影响付款判定)", exc_info=True)
return PageSnapshot(html=html, screenshot=screenshot)
finally:
await page.close()
@@ -1948,6 +2046,12 @@ class SiteInteractor:
) from exc
html = await page.content()
final_url = page.url
# 详情页整页截图随 snapshot 带出(监控步骤落证据用,best-effort)
screenshot = b""
try:
screenshot = await page.screenshot(full_page=True)
except Exception:
logger.warning("订单详情页截图失败(不影响读取结果)", exc_info=True)
finally:
await page.close()
@@ -1962,6 +2066,7 @@ class SiteInteractor:
snapshot = _parse_order_detail_status(state, site_order_id) or _parse_order_status(
html, site_order_id
)
snapshot.screenshot = screenshot
if not snapshot.found and auth_site.looks_logged_out(
"rakuten", final_url=final_url, body=html
):