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
):
+22 -4
View File
@@ -1,10 +1,13 @@
"""真账号验证:clear_cart 两个场景——空车返回成功 / 有商品正确清除
场景 1空车clear_cart removed_count=0cart_count=0status=101 是合法
空车不是获取失败并带回清理后的页面 HTMLrunner step 0 证据用
空车不是获取失败并带回清理后的页面 HTML 与整页截图runner step 0
证据用
场景 2有商品先加购 1 件商品clear_cart removed_count>=1cart_count=0
末尾再用 cart_status 独立复核确实是空车
两个场景的截图会写到 .probe/cart_clear/ 供人工查看
前置登录态有效.auth/ 下有 storage_state浏览器必须非无头站点风控要求
会弹出真实浏览器窗口商品 URL 与其他探针probe_new_card 一致
@@ -25,6 +28,13 @@ from app.trading.worker.site_interact import SiteInteractor # noqa: E402
ITEM_URL = "https://item.rakuten.co.jp/moccasin/ds001iwrgesaaa2/"
PROBE_DIR = Path(__file__).resolve().parent.parent / ".probe" / "cart_clear"
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
def _check_png(data: bytes, label: str) -> None:
assert data[:8] == _PNG_MAGIC, f"{label} 截图不是 PNG({len(data)}B)"
async def run() -> int:
settings = get_settings()
@@ -32,6 +42,7 @@ async def run() -> int:
await auth.start()
site = SiteInteractor(auth_session=auth, settings=settings)
await site.start()
PROBE_DIR.mkdir(parents=True, exist_ok=True)
try:
# 前置:先清一次,保证从空车起步(顺便清掉上次验证可能残留的商品)
pre = await site.clear_cart()
@@ -42,27 +53,34 @@ async def run() -> int:
empty = await site.clear_cart()
print(
f"场景1 空车 -> removed={empty['removed_count']} "
f"cart_count={empty['cart_count']} html={len(empty['html'])}B"
f"cart_count={empty['cart_count']} html={len(empty['html'])}B "
f"png={len(empty['screenshot'])}B"
)
assert empty["removed_count"] == 0, f"空车不应有点击,实际 {empty['removed_count']}"
assert empty["cart_count"] == 0, f"空车 cart_count 应为 0,实际 {empty['cart_count']}"
assert empty["html"], "空车也应带回页面 HTML(runner 落证据用)"
_check_png(empty["screenshot"], "场景1 空车")
(PROBE_DIR / "scenario1-empty.png").write_bytes(empty["screenshot"])
# 场景 2:有商品 —— 先加购 1 件,再清,应真删掉
added = await site.add_to_cart_payload(item_url=ITEM_URL, quantity=1)
print(f"加购 -> item_id={added['item_id']} cart_count={added['cart_count']}")
assert added["cart_count"] >= 1, "加购后购物车应有商品"
_check_png(added["screenshot"], "加购后商品页")
cleared = await site.clear_cart()
print(
f"场景2 有商品 -> removed={cleared['removed_count']} "
f"cart_count={cleared['cart_count']} html={len(cleared['html'])}B"
f"cart_count={cleared['cart_count']} html={len(cleared['html'])}B "
f"png={len(cleared['screenshot'])}B"
)
assert cleared["removed_count"] >= 1, (
f"有商品时至少点 1 次「削除」,实际 {cleared['removed_count']}"
)
assert cleared["cart_count"] == 0, f"清理后应为空车,实际 {cleared['cart_count']}"
assert cleared["html"], "清理后应带回页面 HTML"
_check_png(cleared["screenshot"], "场景2 清理后")
(PROBE_DIR / "scenario2-cleared.png").write_bytes(cleared["screenshot"])
# 复核:独立的 status 查询确认确实是空车(不只信 clear 末尾的一次校验)
status = await site.cart_status()
@@ -71,7 +89,7 @@ async def run() -> int:
f"复核应为空车(count=0, raw_status=101),实际 {status}"
)
print("\n验证通过:空车正确返回成功;有商品时正确清除并复核为空车")
print(f"\n验证通过:空车正确返回成功;有商品时正确清除并复核为空车。截图见 {PROBE_DIR}")
return 0
finally:
await site.close()
+21 -5
View File
@@ -535,6 +535,9 @@ class _FakeOrderPage:
async def content(self) -> str:
return self._html
async def screenshot(self, *, full_page: bool = True) -> bytes:
return b"fake-png"
async def close(self) -> None:
self.closed = True
@@ -1283,6 +1286,9 @@ class _FakeClearCartPage:
async def content(self) -> str:
return self._html
async def screenshot(self, *, full_page: bool = True) -> bytes:
return b"fake-png"
async def close(self) -> None:
self.closed = True
@@ -1327,7 +1333,7 @@ async def test_clear_cart_empty_cart_returns_success_without_clicks():
"""场景 1:购物车没有商品 —— 找不到「削除」按钮立即结束,按清理成功返回
空车时 count API 返回 status=101合法空车cart_count 必须是 0 而不是
-12026-08-16 修复过的误判removed_count=0最终页面 HTML 照常带回
-12026-08-16 修复过的误判removed_count=0最终页面 HTML 与截图照常带回
"""
site = _build_clear_cart_site(
delete_buttons=0, count_body=_EMPTY_COUNT_BODY, html="<html>empty cart</html>",
@@ -1335,7 +1341,12 @@ async def test_clear_cart_empty_cart_returns_success_without_clicks():
result = await site.clear_cart()
assert result == {"removed_count": 0, "cart_count": 0, "html": "<html>empty cart</html>"}
assert result == {
"removed_count": 0,
"cart_count": 0,
"html": "<html>empty cart</html>",
"screenshot": b"fake-png",
}
assert site._context.page.closed is True # 页面必须关,不能泄漏
@@ -1343,7 +1354,7 @@ async def test_clear_cart_with_items_clicks_until_no_button_left():
"""场景 2:购物车有商品 —— 循环点第一个「削除」,点一个少一个,直到按钮消失
每次循环都重新查 selector避免索引漂移3 件商品应点 3 清空后 count
API status=101 cart_count=0最终页面 HTML 进返回值
API status=101 cart_count=0最终页面 HTML 与截图进返回值
"""
site = _build_clear_cart_site(
delete_buttons=3, count_body=_EMPTY_COUNT_BODY, html="<html>cleared cart</html>",
@@ -1354,11 +1365,12 @@ async def test_clear_cart_with_items_clicks_until_no_button_left():
assert result["removed_count"] == 3
assert result["cart_count"] == 0
assert result["html"] == "<html>cleared cart</html>"
assert result["screenshot"] == b"fake-png"
assert site._context.page.closed is True
async def test_clear_cart_html_capture_failure_keeps_clear_result():
"""最终页面 HTML 抓取失败(如页面异常)不掩盖清理结果本身:html 记空"""
"""最终页面 HTML / 截图抓取失败(如页面异常)不掩盖清理结果本身:两者记空"""
site = _build_clear_cart_site(
delete_buttons=0, count_body=_EMPTY_COUNT_BODY, html="",
)
@@ -1366,11 +1378,15 @@ async def test_clear_cart_html_capture_failure_keeps_clear_result():
async def _broken_content() -> str:
raise RuntimeError("page already closed")
async def _broken_screenshot(*, full_page: bool = True) -> bytes:
raise RuntimeError("page already closed")
site._context.page.content = _broken_content # type: ignore[assignment]
site._context.page.screenshot = _broken_screenshot # type: ignore[assignment]
result = await site.clear_cart()
assert result == {"removed_count": 0, "cart_count": 0, "html": ""}
assert result == {"removed_count": 0, "cart_count": 0, "html": "", "screenshot": b""}
# ---- helper ----
+105 -12
View File
@@ -27,7 +27,13 @@ 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.runner import WorkerRunner
from app.trading.worker.site_interact import CheckoutSummary, OrderStatusSnapshot, SiteInteractor
from app.trading.worker.site_interact import (
CheckoutSummary,
OrderStatusSnapshot,
PageSnapshot,
SiteInteractor,
SubmitOutcome,
)
# ---- 桩:网关客户端 ----
@@ -296,7 +302,7 @@ async def test_unimplemented_site_interaction_becomes_needs_human(
return None
async def _checkout_html(task): # noqa: ANN001
return "<html>checkout</html>"
return PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
async def _unimplemented(html): # noqa: ANN001
raise NotImplementedError("模拟:假装 parse_checkout 还没实现")
@@ -375,7 +381,7 @@ async def test_amount_guard_blocks_when_payable_exceeds_limit(
call_log.append(task.task_id + ":step")
async def _checkout_html(task): # noqa: ANN001
return "<html>checkout</html>"
return PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
async def _parse(html: str):
return CheckoutSummary(payable_yen=50000)
@@ -385,7 +391,10 @@ async def test_amount_guard_blocks_when_payable_exceeds_limit(
async def _submit(task): # noqa: ANN001
nonlocal submit_called
submit_called = True
return "ord-1"
return SubmitOutcome(
site_order_id="ord-1",
evidence=PageSnapshot(html="<html>complete</html>", screenshot=b"fake-submit-png"),
)
runner._site.add_to_cart = _ok # type: ignore[assignment]
runner._site.verify_cart = _ok # type: ignore[assignment]
@@ -415,7 +424,7 @@ async def test_amount_guard_honors_intent_max_total_yen(
return None
async def _checkout_html(task): # noqa: ANN001
return "<html>checkout</html>"
return PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
async def _parse(html: str):
return CheckoutSummary(payable_yen=8000)
@@ -469,13 +478,16 @@ async def test_execute_clears_cart_before_adding(
return None
async def _checkout_html(task): # noqa: ANN001
return "<html>checkout</html>"
return PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
async def _parse(html: str):
return CheckoutSummary(payable_yen=297)
async def _submit(task): # noqa: ANN001
return "ord-1"
return SubmitOutcome(
site_order_id="ord-1",
evidence=PageSnapshot(html="<html>complete</html>", screenshot=b"fake-submit-png"),
)
async def _pay(task, site_order_id): # noqa: ANN001
return None
@@ -570,7 +582,12 @@ async def test_clear_cart_step_writes_evidence_but_no_gateway_report(
这一步的 evidence_ref
"""
async def _clear(): # noqa: ANN001
return {"removed_count": 1, "cart_count": 0, "html": "<html>cart</html>"}
return {
"removed_count": 1,
"cart_count": 0,
"html": "<html>cart</html>",
"screenshot": b"fake-clear-png",
}
async def _noop(task): # noqa: ANN001
return None
@@ -589,6 +606,7 @@ async def test_clear_cart_step_writes_evidence_but_no_gateway_report(
step_dir = evidence.step_dir("t1")
html_text = (step_dir / "00-cart-clear.html").read_text(encoding="utf-8")
assert html_text == "<html>cart</html>"
assert (step_dir / "00-cart-clear.png").read_bytes() == b"fake-clear-png"
meta = json.loads((step_dir / "00-cart-clear.meta.json").read_text(encoding="utf-8"))
assert meta == {"step": "cart-clear", "removed_count": 1, "cart_count": 0}
# 本机卫生步骤:gateway 的任何 report 都不携带 step-0 的 evidence_ref
@@ -604,7 +622,12 @@ async def test_clear_cart_evidence_written_when_guard_blocks(
被拦转 needs_human 时这份现场就是排查依据证据先于闸门判断落盘
"""
async def _clear(): # noqa: ANN001
return {"removed_count": 2, "cart_count": 2, "html": "<html>leftover</html>"}
return {
"removed_count": 2,
"cart_count": 2,
"html": "<html>leftover</html>",
"screenshot": b"fake-leftover-png",
}
runner._site.clear_cart = _clear # type: ignore[assignment]
@@ -612,10 +635,77 @@ async def test_clear_cart_evidence_written_when_guard_blocks(
step_dir = evidence.step_dir("t1")
assert (step_dir / "00-cart-clear.html").exists()
assert (step_dir / "00-cart-clear.png").read_bytes() == b"fake-leftover-png"
meta = json.loads((step_dir / "00-cart-clear.meta.json").read_text(encoding="utf-8"))
assert meta["cart_count"] == 2
async def test_every_order_step_writes_html_png_and_meta(
runner: WorkerRunner, evidence: EvidenceStore
):
"""下单流程每一步(step 0 清车 + step 1-5)都落 html + png + meta 三件套
排查问题时每一步都要有现场可看页面 HTML整页截图步骤 meta 缺一不可
"""
async def _clear(): # noqa: ANN001
return {
"removed_count": 0,
"cart_count": 0,
"html": "<html>cart</html>",
"screenshot": b"png-0",
}
async def _add(task): # noqa: ANN001
return PageSnapshot(html="<html>added</html>", screenshot=b"png-1")
async def _verify(task): # noqa: ANN001
return PageSnapshot(html="<html>verified</html>", screenshot=b"png-2")
async def _checkout(task): # noqa: ANN001
return PageSnapshot(html="<html>checkout</html>", screenshot=b"png-3")
async def _parse(html: str):
return CheckoutSummary(payable_yen=297)
async def _submit(task): # noqa: ANN001
return SubmitOutcome(
site_order_id="ord-1",
evidence=PageSnapshot(html="<html>done</html>", screenshot=b"png-4"),
)
async def _pay(task, site_order_id): # noqa: ANN001
return PageSnapshot(html="<html>paid</html>", screenshot=b"png-5")
async def _unchanged(order_id: str): # noqa: ANN001
# 监控不污染断言:found=True 但无状态变化,轮询到上限自行退出
return OrderStatusSnapshot(found=True, order_state=None)
runner._site.clear_cart = _clear # type: ignore[assignment]
runner._site.add_to_cart = _add # type: ignore[assignment]
runner._site.verify_cart = _verify # type: ignore[assignment]
runner._site.enter_checkout = _checkout # type: ignore[assignment]
runner._site.parse_checkout = _parse # type: ignore[assignment]
runner._site.submit_order = _submit # type: ignore[assignment]
runner._site.pay = _pay # type: ignore[assignment]
runner._site.check_order_status = _unchanged # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
step_dir = evidence.step_dir("t1")
expected = {
"00-cart-clear": b"png-0",
"01-cart-add": b"png-1",
"02-cart-check": b"png-2",
"03-order-confirm": b"png-3",
"04-order-submit": b"png-4",
"05-payment": b"png-5",
}
for stem, png in expected.items():
assert (step_dir / f"{stem}.png").read_bytes() == png, f"{stem} 缺整页截图"
assert (step_dir / f"{stem}.html").exists(), f"{stem} 缺页面 HTML"
assert (step_dir / f"{stem}.meta.json").exists(), f"{stem} 缺 meta"
# ---- 证据在 report 之前落盘(§9 第 11 条)----
@@ -647,7 +737,7 @@ async def test_evidence_files_exist_before_each_report(
return None
async def _checkout_html(task): # noqa: ANN001
return "<html>checkout</html>"
return PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
async def _unimplemented(html): # noqa: ANN001
raise NotImplementedError("模拟:假装 parse_checkout 还没实现")
@@ -682,13 +772,16 @@ async def test_successful_order_spawns_monitor_task_and_cancel_monitors_cleans_u
return None
async def _checkout_html(task): # noqa: ANN001
return "<html>checkout</html>"
return PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
async def _parse(html: str):
return CheckoutSummary(payable_yen=297)
async def _submit(task): # noqa: ANN001
return "306087-20260813-0863947697"
return SubmitOutcome(
site_order_id="306087-20260813-0863947697",
evidence=PageSnapshot(html="<html>complete</html>", screenshot=b"fake-submit-png"),
)
async def _pay(task, site_order_id): # noqa: ANN001
return None