实现租约恢复核对:verify_on_site 接真实订单列表反查

新增 SiteInteractor.list_recent_orders(分页拉 order.my.rakuten.co.jp 订单列表,
按商品 URL 反查)+ GatewayClient.get_task,替换掉恒返回 UNKNOWN 的桩。
NOT_ORDERED 分支目前只有逻辑验证、没有真实多单数据支撑,刻意仍路由到
needs_human,不自动重新下单。全程只读查询,未触发任何真实付款操作。
This commit is contained in:
2026-08-13 23:58:54 +08:00
parent 51e2538438
commit 93c1a83406
8 changed files with 868 additions and 22 deletions
+10
View File
@@ -127,6 +127,16 @@ class GatewayClient:
body = await self._request("POST", f"/api/orders/{task_id}/report", json=payload)
return body["data"]
async def get_task(self, task_id: str) -> dict[str, Any]:
"""任务详情,返回网关响应里的 data 字段(含 created_at)
供 verify.verify_on_site 恢复核对用:LeaseTask(lease/reclaim 的响应)
不带 created_at,规格 §5「按创建时间 ~ stale 之间」的窗口核对必须单独
查一次这个接口才能拿到。
"""
body = await self._request("GET", f"/api/orders/{task_id}")
return body["data"]
async def reclaim(self, task_id: str, worker_id: str) -> LeaseTask:
"""恢复领取。返回的 LeaseTask 必然 lease_count > 1"""
body = await self._request(
+8 -4
View File
@@ -162,7 +162,7 @@ class WorkerRunner:
task.lease_count,
task.task_id,
)
verdict = await verify.verify_on_site(task)
verdict = await verify.verify_on_site(task, gateway=self._gateway, site=self._site)
if verdict.verdict == verify.VerifyVerdict.ALREADY_ORDERED:
await self._report_safe(
task,
@@ -176,12 +176,16 @@ class WorkerRunner:
await self._db.mark_finished(task.task_id, OrderState.ORDERED.value)
return
# NOT_ORDERED 也走 needs_human:当前 verify 桩不会返回这个值,但留接口给
# 未来真正能可靠核对时——那时再决定 NOT_ORDERED 是否直接重新执行
# NOT_ORDERED 也走 needs_human(2026-08-13 verify_on_site 已实现,会真的
# 返回这个值,但刻意仍不自动重新执行):核对逻辑目前只有「账号 1 笔订单」
# 的真实数据支撑 ALREADY_ORDERED 分支,NOT_ORDERED 分支只验证过逻辑本身,
# 没有被真实多单场景跑过——在这条判断被更多真实数据验证之前,即使确认
# 未下单也交人工决定是否重新提交,不自动触发新的下单动作。这是刻意的
# 保守选择,不是遗漏;要不要放开需要显式决定,不在这里静默改。
await self._report_safe(
task,
state=_coerce_state(task.known_state),
detail=f"恢复核对无法定论{verdict.detail}",
detail=f"恢复核对结论({verdict.verdict.value}{verdict.detail}",
terminal=True,
terminal_status=TaskStatus.NEEDS_HUMAN,
)
+212 -1
View File
@@ -56,6 +56,14 @@ data/evidence/checkout-research-20260811/NOTES.md):
(刚接单,未发货)这一阶段,「出荷」「配達完了」两个状态转换点没有被真实数据
验证过,只是按进度条文案直译映射(_ORDER_STAGE_TO_STATE);取消/退款没有
找到可靠信号,检测不到,遇到需要人工核对订单列表页。
- list_recent_orders(规格 §5 恢复核对用,verify.verify_on_site 调用)**2026-08-13
用真账号实测过** order.my.rakuten.co.jp/purchase-history/order-list:这是与
check_order_status 不同的另一个页面(真正的「我的订单列表」,不是
detail_page_view 那个 act 分支),pageType="ph-list" 时 __INITIAL_STATE__.
orderListData 直接是结构化 JSON(ordersFound/orderList[].items[].itemUrl 等),
不需要正则抠 DOM。当时账号只有 1 笔订单、1 页,验证了单页解析与
`?page=2` 超出范围返回空列表;多页翻页时「新订单排在前面」的排序假设、以及
分页游标本身,都没有被真实多页数据验证过。
**httpx 不能用于带账号的写操作**:Rakuten 对账号操作有 TLS/HTTP2 指纹校验,
同一份 cookie Playwright 能用、httpx 不能。所以本模块全程使用 Playwright
@@ -71,7 +79,8 @@ import asyncio
import json
import logging
import re
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING
from app.shared.errors import (
@@ -295,6 +304,122 @@ _ORDER_STAGE_TO_STATE: dict[str, OrderState] = {
"配達完了": OrderState.DELIVERED,
}
# ---- 订单列表反查:verify_on_site 恢复核对用(规格 §5,2026-08-13 实测确认)----
# order.my.rakuten.co.jp/purchase-history/order-list 是真实的「我的订单列表」页
# (不是 _ORDER_DETAIL_URL_TEMPLATE 那个 detail_page_view 分支,两者 act 不同)。
# 探测路径:my.rakuten.co.jp 首页有 scid=myr_popular_purchasehist 链接指向
# order.my.rakuten.co.jp/,服务端把它 302 到 /purchase-history/order-list;直接
# 访问该固定路径同样命中,不需要带 scid。pageType="ph-list" 时 __INITIAL_STATE__.
# orderListData 是结构化 JSON(ordersFound/pageSize/orderList[],每条订单带
# items[].itemUrl),不需要正则抠 DOM——这是与订单详情页最大的不同。
# ?page=N 控制分页(2026-08-13 用 page=2 验证过「超出范围返回空 orderList」,
# 但账号当时只有 1 笔订单、1 页,真实多页场景的排序/翻页边界未被验证过)。
_ORDER_LIST_URL = "https://order.my.rakuten.co.jp/purchase-history/order-list"
# 安全上限:避免账号订单极多时无限翻页;命中上限仍未翻完窗口时
# window_fully_covered=False,调用方(verify.verify_on_site)转 unknown,不猜。
_ORDER_LIST_MAX_PAGES = 20
@dataclass(slots=True)
class OrderListItem:
"""订单列表页单笔订单里的一个商品条目"""
item_url: str | None
item_name: str = ""
item_id: int | str | None = None
@dataclass(slots=True)
class OrderListEntry:
"""订单列表页单笔订单"""
order_number: str
order_date: str # 站点原始 ISO8601 字符串,如 "2026-08-13T09:47:28.000Z"
shop_id: int | str | None = None
shop_name: str = ""
items: list[OrderListItem] = field(default_factory=list)
@dataclass(slots=True)
class OrderListPage:
"""order-list 单页解析结果(_parse_order_list 的返回值)"""
entries: list[OrderListEntry]
orders_found: int | None = None
page_size: int | None = None
@dataclass(slots=True)
class OrderListWindow:
"""list_recent_orders 的返回值:某个时间窗口内翻页汇总的结果
window_fully_covered=True 才代表「窗口内的订单已经看全」——可能是因为翻到了
比 since 更早的订单、可能是列表本身翻完了、也可能是 ordersFound 已经对上。
False 表示翻页在覆盖完窗口前就停了(命中 _ORDER_LIST_MAX_PAGES,或页面结构
解析不出 ordersFound 之类的异常),此时 entries 里「没有匹配」不能当作
「确实没下单」——调用方必须转 unknown,不能默认 NOT_ORDERED。
"""
entries: list[OrderListEntry]
window_fully_covered: bool
def parse_order_datetime(raw: str | None) -> datetime | None:
"""把站点返回的 ISO8601 字符串解析成 datetime,解析不出就返回 None(不猜)"""
if not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
@dataclass(slots=True)
class _OrderListAccumulator:
"""list_recent_orders 翻页时的累积状态(内部用,不对外暴露)"""
entries: list[OrderListEntry] = field(default_factory=list)
total_found: int | None = None
is_first_page: bool = True
gave_up: bool = False # True=第一页就拿不到结构化数据,翻页无意义,直接放弃
def _accumulate_order_list_page(
acc: _OrderListAccumulator, page: OrderListPage, *, since: datetime
) -> tuple[_OrderListAccumulator, bool]:
"""把一页解析结果并入累积状态,返回 (新累积状态, 是否应该停止翻页)
纯函数,不碰 Playwright——list_recent_orders 每翻一页调一次,离线单测可以
直接喂一串 OrderListPage 模拟多页翻页,不需要真实浏览器(与本文件其余
_parse_* 纯函数同一套测试思路)。
「停止翻页」不等于「窗口已覆盖完」:acc.gave_up=True 表示第一页就拿不到
结构化列表数据(页面结构变了/act 分支不对/未登录跳转),此时也会停止翻页,
但调用方必须按「没覆盖」处理,不能当成真的翻完了。
"""
if acc.is_first_page and page.orders_found is None:
return (
_OrderListAccumulator(entries=acc.entries, total_found=acc.total_found, is_first_page=False, gave_up=True),
True,
)
total_found = acc.total_found if acc.total_found is not None else page.orders_found
if not page.entries:
return (
_OrderListAccumulator(entries=acc.entries, total_found=total_found, is_first_page=False),
True,
)
new_entries = acc.entries + page.entries
new_acc = _OrderListAccumulator(entries=new_entries, total_found=total_found, is_first_page=False)
oldest_dt = parse_order_datetime(page.entries[-1].order_date)
if oldest_dt is not None and oldest_dt < since:
return new_acc, True
if total_found is not None and len(new_entries) >= total_found:
return new_acc, True
return new_acc, False
@dataclass(slots=True)
class CheckoutSummary:
@@ -1474,6 +1599,51 @@ class SiteInteractor:
return _parse_order_status(html, site_order_id)
async def list_recent_orders(self, *, since: datetime) -> OrderListWindow:
"""恢复核对用:拉取「任务创建时间之后」的订单列表(规格 §5 依赖它)
由 verify.verify_on_site 调用,不由 worker 主循环直接调。翻页直到看到
order_date 早于 since 的订单(说明窗口内的都已经看过一遍)、或
ordersFound 已经全部翻完、或到达 _ORDER_LIST_MAX_PAGES 上限。命中上限仍
没能确认覆盖完整窗口时,`OrderListWindow.window_fully_covered=False`——
调用方据此转 unknown,绝不能把「没翻完」当成「翻完了但没有」。
2026-08-13 只用「账号只有 1 笔订单、1 页」的真实数据验证过单页解析与
page=2 返回空列表这两点;多页翻页的排序假设(新订单在前)未经真实数据
验证,多页场景上线前应重新探测确认。
Raises:
NotLoggedInError: 登录态失效
OrderOperationError: 订单列表页打开/渲染失败
"""
async with self._lock:
await self._auth_session.require_logged_in("rakuten")
await self._refresh_context_if_stale()
acc = _OrderListAccumulator()
stop = False
page = await self._context.new_page()
try:
for page_num in range(1, _ORDER_LIST_MAX_PAGES + 1):
url = _ORDER_LIST_URL if page_num == 1 else f"{_ORDER_LIST_URL}?page={page_num}"
try:
await page.goto(url, wait_until="domcontentloaded", timeout=30_000)
await page.wait_for_timeout(2_000)
except Exception as exc:
raise OrderOperationError(
f"订单列表页打开失败:page={page_num} {type(exc).__name__}: {exc}"
) from exc
html = await page.content()
page_result = _parse_order_list(html)
acc, stop = _accumulate_order_list_page(acc, page_result, since=since)
if stop:
break
finally:
await page.close()
return OrderListWindow(entries=acc.entries, window_fully_covered=stop and not acc.gave_up)
# ---- 模块级辅助函数(纯函数,便于单测)----
@@ -1642,3 +1812,44 @@ def _parse_order_status(html: str, site_order_id: str) -> OrderStatusSnapshot:
html=html,
)
return OrderStatusSnapshot(found=True, html=html)
def _parse_order_list(html: str) -> OrderListPage:
"""订单列表页解析核心逻辑(纯函数,供 list_recent_orders 调用,便于离线单测)
2026-08-13 用真账号真实探测过 order.my.rakuten.co.jp/purchase-history/order-list:
pageType="ph-list" 时 __INITIAL_STATE__.orderListData 是结构化 JSON,不需要
正则抠 DOM——直接复用 _parse_initial_state。pageType 不是 "ph-list"(比如
命中了旧的 detail_page_view 错误分支、或未登录被跳转)时 orderListData 拿
不到,返回空列表 + orders_found=None,调用方据此判断「没拿到列表数据」而不
是「列表是空的」。
"""
state = _parse_initial_state(html)
if not state or state.get("pageType") != "ph-list":
return OrderListPage(entries=[])
data = state.get("orderListData") or {}
entries: list[OrderListEntry] = []
for order in data.get("orderList") or []:
items = [
OrderListItem(
item_url=it.get("itemUrl"),
item_name=it.get("itemName", ""),
item_id=it.get("itemId"),
)
for it in order.get("items") or []
]
entries.append(
OrderListEntry(
order_number=order.get("orderNumber", ""),
order_date=order.get("orderDate", ""),
shop_id=order.get("shopId"),
shop_name=order.get("shopName", ""),
items=items,
)
)
return OrderListPage(
entries=entries,
orders_found=data.get("ordersFound"),
page_size=data.get("pageSize"),
)
+91 -11
View File
@@ -4,16 +4,27 @@
此时 worker 不能盲目重新提交(可能上次已经下单成功,只是回报断网),必须先查
站点订单列表比对。
订单列表反查的实现需要实测(规格 §10 第 3 条:订单列表页能否按商品 + 时间窗口
可靠地反查出「这单下没下」)。当前为桩:恒返回 `unknown`,让 runner 走
needs_human 分支。**绝不**默认按「没下单」处理——那是猜,可能变成重复下单。
2026-08-13 实现:核对逻辑用真实账号 + 真实订单验证过订单列表的数据结构(见
app/trading/worker/site_interact.py::list_recent_orders 与 _parse_order_list 的
模块文档),但当时账号只有 1 笔订单,ALREADY_ORDERED 分支有真实数据支撑,
NOT_ORDERED / 多笔命中 / 多页翻页这几个分支目前只有逻辑,没有被真实多单数据
跑过——**绝不**在没有把握时猜:任何一步信息不足都转 UNKNOWN,宁可卡住等人看
一眼,也不赌一次重复下单。
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from typing import TYPE_CHECKING
from urllib.parse import urlsplit
from app.shared.errors import AppError
from app.trading.worker.models import LeaseTask
from app.trading.worker.site_interact import parse_order_datetime
if TYPE_CHECKING:
from app.trading.worker.client import GatewayClient
from app.trading.worker.site_interact import SiteInteractor
class VerifyVerdict(StrEnum):
@@ -31,16 +42,85 @@ class VerifyResult:
detail: str = ""
async def verify_on_site(task: LeaseTask) -> VerifyResult:
def _normalize_item_url(url: str | None) -> str | None:
"""去掉查询串与末尾斜杠,只留 scheme+host+path 用于比对
订单列表返回的 itemUrl 实测带 `?variantId=...`(见 _parse_order_list 的真实
样例),intent.item_url 通常不带——按查询串比对会产生假阴性,所以只比
路径本身。
"""
if not url:
return None
parts = urlsplit(url)
return f"{parts.scheme}://{parts.netloc}{parts.path.rstrip('/')}"
async def verify_on_site(
task: LeaseTask, *, gateway: "GatewayClient", site: "SiteInteractor"
) -> VerifyResult:
"""核对一笔任务是否已在站点上下过单
桩实现:永远返回 UNKNOWN。**绝不返回 NOT_ORDERED**——除非真实订单列表反查
能可靠证明这一点,否则视为「无法判断」交人工,宁可卡住等人看一眼。
实现方需要补的实测:用 intent 里的商品 + 时间窗口(created_at 到 stale 之间)
比对订单列表,能拿到 site_order_id 最好
核对链路:intent.item_url → 查任务创建时间(GET /api/orders/{task_id}
LeaseTask 本身不带 created_at)→ 拉「创建时间之后」的订单列表 → 按商品 URL
比对。任何一环拿不到足够信息都返回 UNKNOWN,不猜——尤其是 NOT_ORDERED,
只有在确认翻完了窗口内的全部订单后才允许返回,否则「没找到」可能只是没翻
到那一页
"""
intent = task.intent or {}
item_url = intent.get("item_url")
if not item_url:
return VerifyResult(
VerifyVerdict.UNKNOWN, detail="intent 缺 item_url,无法比对商品"
)
target = _normalize_item_url(item_url)
try:
task_detail = await gateway.get_task(task.task_id)
except AppError as exc:
return VerifyResult(
VerifyVerdict.UNKNOWN, detail=f"查询任务创建时间失败:{exc.message}"
)
created_at_raw = task_detail.get("created_at")
created_at = parse_order_datetime(created_at_raw) if created_at_raw else None
if created_at is None:
return VerifyResult(
VerifyVerdict.UNKNOWN, detail=f"任务创建时间不可解析:{created_at_raw!r}"
)
try:
window = await site.list_recent_orders(since=created_at)
except Exception as exc: # noqa: BLE001 — 站点交互失败一律转 unknown,不重试
return VerifyResult(
VerifyVerdict.UNKNOWN,
detail=f"订单列表查询失败:{type(exc).__name__}: {exc}",
)
matches = [
entry
for entry in window.entries
if any(_normalize_item_url(it.item_url) == target for it in entry.items)
]
if len(matches) == 1:
return VerifyResult(
VerifyVerdict.ALREADY_ORDERED,
site_order_id=matches[0].order_number,
detail=f"订单列表命中 1 笔匹配商品的订单({matches[0].order_number}",
)
if len(matches) > 1:
return VerifyResult(
VerifyVerdict.UNKNOWN,
detail=(
f"订单列表命中 {len(matches)} 笔匹配商品的订单,"
f"无法唯一确定:{[m.order_number for m in matches]}"
),
)
if window.window_fully_covered:
return VerifyResult(
VerifyVerdict.NOT_ORDERED,
detail="已核对任务创建时间之后的全部订单,未找到匹配商品",
)
return VerifyResult(
verdict=VerifyVerdict.UNKNOWN,
detail="订单列表反查未实现:见 docs/order-gateway.md §10 第 3 条",
VerifyVerdict.UNKNOWN,
detail="订单列表未能确认翻完任务创建时间之后的全部订单",
)
+130
View File
@@ -0,0 +1,130 @@
"""订单列表页探针:为实现 verify_on_site(规格 §5 恢复核对)取真实 DOM 结构
只读导航,不做任何加购/下单操作。目标:确认 order.my.rakuten.co.jp 的订单列表页
(不带 order_number/shop_id 参数)能否按「商品名 + 时间窗口」反查出某个任务是否已
下单——这是 app/trading/worker/verify.py::verify_on_site 目前的桩要补的实测。
用法:
.venv/Scripts/python.exe scripts/probe_order_list.py
.venv/Scripts/python.exe scripts/probe_order_list.py --headful
"""
from __future__ import annotations
import argparse
import asyncio
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.shared.config import get_settings # noqa: E402
from app.trading.core import auth_site # noqa: E402
PROBE_DIR = Path(__file__).resolve().parent.parent / ".probe" / "order_list"
PROBE_DIR.mkdir(parents=True, exist_ok=True)
ORDER_LIST_URL = "https://order.my.rakuten.co.jp/purchase-history/"
KNOWN_ORDER_ID = "306087-20260813-0863947697"
def save(name: str, content: str) -> Path:
path = PROBE_DIR / name
path.write_text(content, encoding="utf-8")
print(f" saved -> {path} ({len(content)} bytes)")
return path
async def run(*, headful: bool) -> int:
from playwright.async_api import async_playwright
settings = get_settings()
state_path = settings.auth_state_path / "rakuten_state.json"
if not state_path.exists():
print(f"找不到登录态文件:{state_path}")
return 1
async with async_playwright() as pw:
browser = await pw.chromium.launch(
headless=not headful,
channel=settings.browser_channel or None,
args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
)
context = await browser.new_context(
storage_state=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,
)
page = await context.new_page()
api_calls: list[tuple[int, str]] = []
def on_response(r):
url = str(r.url)
if any(kw in url for kw in ["purchase-history", "order", "history"]):
api_calls.append((r.status, url))
page.on("response", on_response)
print("=== 访问订单列表页(不带参数)===")
await page.goto(ORDER_LIST_URL, wait_until="domcontentloaded", timeout=30_000)
await page.wait_for_timeout(3000)
html = await page.content()
save("00-list-default.html", html)
print(f" final_url={page.url} body_len={len(html)}")
print(f" 含已知订单号 {KNOWN_ORDER_ID}: {KNOWN_ORDER_ID in html}")
# 尝试常见的日期范围/分页查询参数,看服务端是否支持按时间窗口过滤
candidate_qs = [
"?period=3months",
"?range=3",
f"?order_number={KNOWN_ORDER_ID.split('-')[0]}",
]
for qs in candidate_qs:
url = ORDER_LIST_URL + qs
print(f"\n=== 尝试 {url} ===")
try:
resp = await page.goto(url, wait_until="domcontentloaded", timeout=20_000)
await page.wait_for_timeout(2000)
html2 = await page.content()
status = resp.status if resp else None
print(f" status={status} final_url={page.url} body_len={len(html2)}")
safe_name = re.sub(r"[^\w]+", "_", qs) or "root"
save(f"01{safe_name}.html", html2)
except Exception as exc:
print(f" 失败:{type(exc).__name__}: {exc}")
print("\n=== 捕获的 XHR ===")
for status, url in api_calls[-30:]:
print(f" {status} {url[:160]}")
# 在默认列表页上找商品名/日期相关的文本线索
print("\n=== 默认列表页文本线索(商品名/日期候选片段)===")
for pat in [
r'"itemName"\s*:\s*"([^"]{2,60})"',
r'"orderDate"\s*:\s*"([^"]{2,40})"',
r'"orderNumber"\s*:\s*"([^"]{2,40})"',
r"\d{4}[年/]\d{1,2}[月/]\d{1,2}日?",
]:
matches = re.findall(pat, html)
print(f" pattern={pat!r} -> {matches[:5]}")
await browser.close()
print(f"\n探针输出目录:{PROBE_DIR}")
return 0
async def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--headful", action="store_true")
args = parser.parse_args()
return await run(headful=args.headful)
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+188
View File
@@ -15,18 +15,26 @@ from typing import Any
import pytest
from datetime import datetime, timezone
from app.shared.errors import CartOperationError, InvalidRequestError, OrderOperationError
from app.trading.worker.models import LeaseTask
from app.shared.task_state import OrderState
from app.trading.worker.site_interact import (
CheckoutSummary,
OrderListEntry,
OrderListPage,
OrderStatusSnapshot,
SiteInteractor,
_accumulate_order_list_page,
_extract_error_message,
_extract_purchase_fields,
_OrderListAccumulator,
_parse_checkout_summary,
_parse_initial_state,
_parse_order_list,
_parse_order_status,
parse_order_datetime,
)
FIXTURES = Path(__file__).parent / "fixtures"
@@ -329,6 +337,186 @@ async def test_check_order_status_without_start_raises():
await site.check_order_status(_REAL_ORDER_ID)
# ---- _parse_order_list:verify_on_site 恢复核对用,2026-08-13 用真实账号跑
# order.my.rakuten.co.jp/purchase-history/order-list 验证过这套 __INITIAL_STATE__
# 结构(pageType="ph-list" 时 orderListData 直接是结构化 JSON,不需要正则抠 DOM)。
# 下面 fixture 里的字段名/嵌套结构与真实响应一致,只是精简掉了大量与匹配逻辑
# 无关的字段(推荐组件、通知、会员信息等)。
def _real_order_list_state(
*,
orders_found: int = 1,
page_size: int = 25,
orders: list[dict] | None = None,
page_type: str = "ph-list",
) -> dict:
if orders is None:
orders = [
{
"orderNumber": _REAL_ORDER_ID,
"orderDate": "2026-08-13T09:47:28.000Z",
"shopId": 306087,
"shopName": "BACKYARD FAMILY インテリアタウン",
"items": [
{
"itemId": 10422789,
"itemName": "ウォールフック 粘着フック",
"itemUrl": "https://item.rakuten.co.jp/moccasin/ds001iwrgesaaa2/?variantId=ds001iwrgesaaa2-1a-2a",
"itemPrice": 297,
}
],
}
]
return {
"pageType": page_type,
"orderListData": {
"ordersFound": orders_found,
"pageSize": page_size,
"orderList": orders,
},
}
def test_parse_order_list_extracts_entries_from_real_shape():
html = _wrap_state(_real_order_list_state())
page = _parse_order_list(html)
assert page.orders_found == 1
assert page.page_size == 25
assert len(page.entries) == 1
entry = page.entries[0]
assert entry.order_number == _REAL_ORDER_ID
assert entry.order_date == "2026-08-13T09:47:28.000Z"
assert entry.shop_id == 306087
assert len(entry.items) == 1
assert entry.items[0].item_url == (
"https://item.rakuten.co.jp/moccasin/ds001iwrgesaaa2/?variantId=ds001iwrgesaaa2-1a-2a"
)
def test_parse_order_list_multiple_items_in_one_order():
orders = [
{
"orderNumber": "111-20260101-0000000001",
"orderDate": "2026-01-01T00:00:00.000Z",
"items": [
{"itemUrl": "https://item.rakuten.co.jp/shop/a/", "itemName": "A"},
{"itemUrl": "https://item.rakuten.co.jp/shop/b/", "itemName": "B"},
],
}
]
html = _wrap_state(_real_order_list_state(orders=orders))
page = _parse_order_list(html)
assert len(page.entries[0].items) == 2
def test_parse_order_list_wrong_page_type_returns_empty_and_no_orders_found():
"""pageType 不是 ph-list(比如命中了 detail_page_view 的错误分支):拿不到结构化数据"""
html = _wrap_state(_real_order_list_state(page_type="ph-error"))
page = _parse_order_list(html)
assert page.entries == []
assert page.orders_found is None
def test_parse_order_list_no_initial_state_returns_empty():
page = _parse_order_list("<html>没有 state 的页面</html>")
assert page.entries == []
assert page.orders_found is None
def test_parse_order_list_zero_orders_still_has_orders_found():
"""账号没有任何订单:orderList 空,但 orders_found=0(不是 None)——区分「真的没有」与「解析不出」"""
html = _wrap_state(_real_order_list_state(orders_found=0, orders=[]))
page = _parse_order_list(html)
assert page.entries == []
assert page.orders_found == 0
# ---- parse_order_datetime ----
def test_parse_order_datetime_parses_real_format():
dt = parse_order_datetime("2026-08-13T09:47:28.000Z")
assert dt == datetime(2026, 8, 13, 9, 47, 28, tzinfo=timezone.utc)
def test_parse_order_datetime_none_and_invalid():
assert parse_order_datetime(None) is None
assert parse_order_datetime("") is None
assert parse_order_datetime("不是日期") is None
# ---- _accumulate_order_list_page:list_recent_orders 翻页决策的纯函数核心,
# 2026-08-13 只有「账号 1 笔订单、1 页」的真实数据支撑第一个分支,其余分支
# (多页、命中窗口边界、拿不到结构化数据)目前只验证逻辑正确,未经真实多页数据跑过 ----
def _page(entries: list[OrderListEntry], *, orders_found: int | None) -> OrderListPage:
return OrderListPage(entries=entries, orders_found=orders_found, page_size=25)
def _entry(order_number: str, order_date: str) -> OrderListEntry:
return OrderListEntry(order_number=order_number, order_date=order_date, items=[])
_SINCE = datetime(2026, 8, 1, tzinfo=timezone.utc)
def test_accumulate_single_page_all_within_window_and_matches_orders_found():
acc = _OrderListAccumulator()
page = _page([_entry("o1", "2026-08-10T00:00:00Z")], orders_found=1)
new_acc, stop = _accumulate_order_list_page(acc, page, since=_SINCE)
assert stop is True
assert new_acc.gave_up is False
assert [e.order_number for e in new_acc.entries] == ["o1"]
def test_accumulate_stops_when_oldest_entry_older_than_since():
"""翻到比 since 更早的订单:窗口内数据已经看全,停止翻页且视为覆盖完整"""
acc = _OrderListAccumulator(entries=[_entry("o1", "2026-08-10T00:00:00Z")], total_found=5, is_first_page=False)
page = _page([_entry("o2", "2026-07-01T00:00:00Z")], orders_found=5)
new_acc, stop = _accumulate_order_list_page(acc, page, since=_SINCE)
assert stop is True
assert new_acc.gave_up is False
assert [e.order_number for e in new_acc.entries] == ["o1", "o2"]
def test_accumulate_continues_when_more_pages_remain():
"""orders_found 还没凑够、最老的一条也没早于 since:应该继续翻下一页"""
acc = _OrderListAccumulator()
page = _page([_entry("o1", "2026-08-10T00:00:00Z")], orders_found=3)
new_acc, stop = _accumulate_order_list_page(acc, page, since=_SINCE)
assert stop is False
assert new_acc.total_found == 3
def test_accumulate_empty_page_means_fully_covered():
"""翻到空页(没有更多订单了):不是错误,视为窗口已覆盖完"""
acc = _OrderListAccumulator(total_found=1, is_first_page=False)
page = _page([], orders_found=1)
new_acc, stop = _accumulate_order_list_page(acc, page, since=_SINCE)
assert stop is True
assert new_acc.gave_up is False
def test_accumulate_first_page_missing_orders_found_gives_up():
"""第一页就拿不到结构化数据(orders_found=None):停止翻页,但 gave_up=True,不算覆盖完"""
acc = _OrderListAccumulator()
page = _page([], orders_found=None)
new_acc, stop = _accumulate_order_list_page(acc, page, since=_SINCE)
assert stop is True
assert new_acc.gave_up is True
# ---- list_recent_orders 在没启动 Playwright 时应失败 ----
async def test_list_recent_orders_without_start_raises():
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
with pytest.raises((AttributeError, TypeError)):
await site.list_recent_orders(since=_SINCE)
# ---- submit_order / pay:没有 enter_checkout 留存的确认页会话时应报错,不静默成功 ----
+188
View File
@@ -0,0 +1,188 @@
"""verify.verify_on_site 单元测试
用桩 gateway(只需要 get_task)和桩 site(只需要 list_recent_orders)跑通全部
分支——不涉及 Playwright/httpx,纯逻辑测试。核对链路本身(intent.item_url →
查任务创建时间 → 拉订单列表 → 按商品 URL 比对)2026-08-13 用真实账号验证过
数据结构(见 test_site_interact.py 里 _parse_order_list 那组测试),但当时账号
只有 1 笔订单,这里 NOT_ORDERED / 多笔命中 / 查询失败这几个分支只覆盖逻辑本身,
没有被真实多单数据跑过。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
import pytest
from app.shared.errors import AppError
from app.trading.worker import verify
from app.trading.worker.models import LeaseTask
from app.trading.worker.site_interact import OrderListEntry, OrderListItem, OrderListWindow
def _make_task(*, item_url: str | None = "https://item.rakuten.co.jp/shop/x/") -> LeaseTask:
intent: dict[str, Any] = {}
if item_url is not None:
intent["item_url"] = item_url
return LeaseTask(task_id="t1", site="rakuten", intent=intent, lease_count=2)
@dataclass
class FakeGateway:
created_at: str | None = "2026-08-01T00:00:00Z"
fail_with: Exception | None = None
calls: list[str] = field(default_factory=list)
async def get_task(self, task_id: str) -> dict[str, Any]:
self.calls.append(task_id)
if self.fail_with is not None:
raise self.fail_with
return {"task_id": task_id, "created_at": self.created_at}
@dataclass
class FakeSite:
window: OrderListWindow | None = None
fail_with: Exception | None = None
received_since: datetime | None = None
async def list_recent_orders(self, *, since: datetime) -> OrderListWindow:
self.received_since = since
if self.fail_with is not None:
raise self.fail_with
assert self.window is not None
return self.window
def _entry(order_number: str, item_url: str) -> OrderListEntry:
return OrderListEntry(
order_number=order_number,
order_date="2026-08-10T00:00:00Z",
items=[OrderListItem(item_url=item_url)],
)
# ---- intent 缺 item_url:不查任何东西,直接 unknown ----
async def test_missing_item_url_returns_unknown_without_calling_gateway():
gateway = FakeGateway()
site = FakeSite()
result = await verify.verify_on_site(_make_task(item_url=None), gateway=gateway, site=site)
assert result.verdict == verify.VerifyVerdict.UNKNOWN
assert gateway.calls == []
# ---- 查任务创建时间失败/不可解析 ----
async def test_get_task_failure_returns_unknown():
gateway = FakeGateway(fail_with=AppError(message="网关挂了", code="X", err_code=9999))
site = FakeSite()
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
assert result.verdict == verify.VerifyVerdict.UNKNOWN
assert "网关挂了" in result.detail
async def test_unparseable_created_at_returns_unknown():
gateway = FakeGateway(created_at="不是日期")
site = FakeSite()
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
assert result.verdict == verify.VerifyVerdict.UNKNOWN
# ---- 站点订单列表查询失败 ----
async def test_site_list_failure_returns_unknown():
gateway = FakeGateway()
site = FakeSite(fail_with=RuntimeError("Playwright 挂了"))
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
assert result.verdict == verify.VerifyVerdict.UNKNOWN
assert "Playwright 挂了" in result.detail
# ---- 命中 1 笔:已下单(2026-08-13 真实账号验证过的分支)----
async def test_exact_one_match_returns_already_ordered():
gateway = FakeGateway()
site = FakeSite(
window=OrderListWindow(
entries=[_entry("306087-20260813-0863947697", "https://item.rakuten.co.jp/shop/x/")],
window_fully_covered=True,
)
)
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
assert result.verdict == verify.VerifyVerdict.ALREADY_ORDERED
assert result.site_order_id == "306087-20260813-0863947697"
async def test_match_ignores_query_string_difference():
"""订单列表的 itemUrl 带 ?variantId=...,intent.item_url 通常不带——只比路径"""
gateway = FakeGateway()
site = FakeSite(
window=OrderListWindow(
entries=[_entry("o1", "https://item.rakuten.co.jp/shop/x/?variantId=abc-1")],
window_fully_covered=True,
)
)
result = await verify.verify_on_site(
_make_task(item_url="https://item.rakuten.co.jp/shop/x/"), gateway=gateway, site=site
)
assert result.verdict == verify.VerifyVerdict.ALREADY_ORDERED
# ---- 命中 0 笔且窗口确认覆盖完:未下单 ----
async def test_no_match_and_fully_covered_returns_not_ordered():
gateway = FakeGateway()
site = FakeSite(
window=OrderListWindow(
entries=[_entry("o1", "https://item.rakuten.co.jp/other-shop/y/")],
window_fully_covered=True,
)
)
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
assert result.verdict == verify.VerifyVerdict.NOT_ORDERED
# ---- 命中 0 笔但窗口没确认覆盖完:不能猜没下单 ----
async def test_no_match_and_not_fully_covered_returns_unknown():
gateway = FakeGateway()
site = FakeSite(window=OrderListWindow(entries=[], window_fully_covered=False))
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
assert result.verdict == verify.VerifyVerdict.UNKNOWN
# ---- 命中多笔:无法唯一确定 ----
async def test_multiple_matches_returns_unknown():
gateway = FakeGateway()
site = FakeSite(
window=OrderListWindow(
entries=[
_entry("o1", "https://item.rakuten.co.jp/shop/x/"),
_entry("o2", "https://item.rakuten.co.jp/shop/x/"),
],
window_fully_covered=True,
)
)
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
assert result.verdict == verify.VerifyVerdict.UNKNOWN
assert "o1" in result.detail and "o2" in result.detail
# ---- since 正确传递给 site.list_recent_orders(用任务创建时间,不是别的)----
async def test_since_passed_to_site_matches_task_created_at():
gateway = FakeGateway(created_at="2026-08-05T12:00:00Z")
site = FakeSite(window=OrderListWindow(entries=[], window_fully_covered=True))
await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
assert site.received_since == datetime(2026, 8, 5, 12, 0, 0, tzinfo=timezone.utc)
+41 -6
View File
@@ -40,11 +40,18 @@ class FakeGateway:
renews: int = 0
fail_report_with: Exception | None = None
# verify_on_site 恢复核对会调 get_task 拿 created_at;测试默认给一个可解析
# 的时间戳,具体核对分支由 monkeypatch 替换 verify.verify_on_site 控制
task_created_at: str = "2026-01-01T00:00:00Z"
async def lease(
self, worker_id: str, *, wait: int = 30, site: str | None = None
) -> LeaseTask | None:
return None # 主循环测试不通过 lease 喂数据,直接调 handle
async def get_task(self, task_id: str) -> dict[str, Any]:
return {"task_id": task_id, "created_at": self.task_created_at}
async def renew(self, task_id: str, worker_id: str) -> dict[str, Any]:
self.renews += 1
return {"task_id": task_id, "lease_expires_at": "2099-01-01T00:00:00Z"}
@@ -193,15 +200,17 @@ async def test_already_finished_task_is_reported_not_re_executed(
async def test_recovery_task_with_unknown_verdict_reports_needs_human(
runner: WorkerRunner, local_db: LocalDB, monkeypatch
):
"""lease_count > 1 + verify 返回 unknown → 转 needs_human,不执行"""
"""lease_count > 1 + verify 返回 unknown(intent 缺 item_url)→ 转 needs_human,不执行"""
# verify 桩默认返回 unknown,不用 monkeypatch
# verify.verify_on_site 已是真实实现(2026-08-13),但 _make_task 默认 intent 为空,
# 缺 item_url 时函数第一步就短路返回 unknown,不用 monkeypatch 也能测这条分支
await runner.handle(_make_task(task_id="t1", lease_count=2))
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
terminal = gateway.last_terminal_report()
assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN
assert "无法定论" in terminal["detail"]
assert "unknown" in terminal["detail"]
assert "item_url" in terminal["detail"]
async def test_recovery_task_with_already_ordered_verdict_reports_succeeded(
@@ -209,7 +218,7 @@ async def test_recovery_task_with_already_ordered_verdict_reports_succeeded(
):
"""lease_count > 1 + verify 返回 already_ordered → 补报 succeeded,不重新下单"""
async def _already_ordered(task): # noqa: ANN001
async def _already_ordered(task, **kwargs): # noqa: ANN001
return verify.VerifyResult(
verdict=verify.VerifyVerdict.ALREADY_ORDERED,
site_order_id="ord-1",
@@ -226,8 +235,34 @@ async def test_recovery_task_with_already_ordered_verdict_reports_succeeded(
assert terminal["site_order_id"] == "ord-1"
assert terminal["terminal_status"] == TaskStatus.SUCCEEDED
# 本地 DB 也应当被标记为已完成
assert await local_db.has_finished("t1") is True
async def test_recovery_task_with_not_ordered_verdict_still_reports_needs_human(
runner: WorkerRunner, local_db: LocalDB, monkeypatch
):
"""lease_count > 1 + verify 确认 not_ordered → 仍转 needs_human,不自动重新下单
刻意的保守选择(见 runner._handle_recovery 里的注释):NOT_ORDERED 分支目前
只有逻辑正确性,没有被真实多单数据验证过,放开自动重新执行前必须显式决定。
"""
async def _not_ordered(task, **kwargs): # noqa: ANN001
return verify.VerifyResult(
verdict=verify.VerifyVerdict.NOT_ORDERED,
detail="已核对任务创建时间之后的全部订单,未找到匹配商品",
)
monkeypatch.setattr(verify, "verify_on_site", _not_ordered)
await runner.handle(_make_task(task_id="t1", lease_count=2))
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
terminal = gateway.last_terminal_report()
assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN
assert "not_ordered" in terminal["detail"]
# needs_human 分支不标记本地完成(与 UNKNOWN 分支一致):只有 ALREADY_ORDERED
# 才 mark_finished,避免把「转人工」误当成本地已完成的终态
assert await local_db.has_finished("t1") is False
# ---- 站点交互未实现 → needs_human(§10 接口缝)----