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(