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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""真账号验证:clear_cart 两个场景——空车返回成功 / 有商品正确清除
|
||||
|
||||
场景 1(空车):clear_cart 应 removed_count=0、cart_count=0(status=101 是合法
|
||||
空车,不是获取失败),并带回清理后的页面 HTML(runner step 0 落证据用)。
|
||||
空车,不是获取失败),并带回清理后的页面 HTML 与整页截图(runner step 0 落
|
||||
证据用)。
|
||||
场景 2(有商品):先加购 1 件商品,clear_cart 应 removed_count>=1、cart_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()
|
||||
|
||||
@@ -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 而不是
|
||||
-1(2026-08-16 修复过的误判);removed_count=0,最终页面 HTML 照常带回。
|
||||
-1(2026-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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user