实现租约恢复核对: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="订单列表未能确认翻完任务创建时间之后的全部订单",
)