feat(gateway): 账号只读查询通道——从已登录账号取真实订单
上游要的不只是网关记的任务状态镜像,还有「已登录账号在站点上的真实订单」,
但账号只在 NAT 后本地机上,只能经网关队列走。新增独立查询通道(§11):
- gateway 单开 account_queries 表 + QueryStatus 状态机,接口
POST /api/account/queries(幂等)/ lease / {id}/result / {id}
- 不复用下单任务队列:查询是只读,租约过期可安全重投(与下单「绝不自动
重投」相反),且不该被全局并发度 1 堵死、task_reports 是订单镜像不能污染
- 本地交易服务起第二条常驻循环 query_runner,领到即调 SiteInteractor 真读:
order_list 复用已实测的 list_recent_orders(规范化字段 + 站点
orderListData 原文),order_detail 复用 fetch_order_detail(配送阶段 +
页面 __INITIAL_STATE__ 原样透传,结构未经真实样本,不抽字段)
- 账号级串行仍由 SiteInteractor 的锁保证;每次执行套超时按失败回报
- 错误码 6005/6006(查询通道,可重试只读区别于 6001-6004);/health 暴露
queued_query_count;结果体积上限先丢原始 JSON
openapi.json 重导,docs/order-gateway.md §11、README、.env.example 补全
配置与实测边界。全量测试 404→454 通过。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,7 @@ from app.shared.config import Settings
|
||||
from app.shared.errors import AppError
|
||||
from app.shared.proxy import httpx_client_options
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
from app.trading.worker.models import LeaseTask
|
||||
from app.trading.worker.models import LeaseTask, QueryTask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -158,3 +158,55 @@ class GatewayClient:
|
||||
lease_count=data.get("lease_count", 1),
|
||||
known_state=data.get("known_state"),
|
||||
)
|
||||
|
||||
# ---- 账号只读查询通道(docs/order-gateway.md §11)----
|
||||
|
||||
async def lease_query(self, worker_id: str, *, wait: int = 30) -> QueryTask | None:
|
||||
"""长轮询领取一张只读查询单。无单可领时返回 None
|
||||
|
||||
与 lease() 走的是两条互不干扰的通道:下单任务在跑的时候,查询照样能领到
|
||||
(账号级串行由 SiteInteractor 那把锁保证,不靠网关排队)。
|
||||
"""
|
||||
body = await self._request(
|
||||
"GET",
|
||||
"/api/account/queries/lease",
|
||||
params={"worker_id": worker_id, "wait": wait},
|
||||
)
|
||||
data = body.get("data")
|
||||
if not data:
|
||||
return None
|
||||
return QueryTask(
|
||||
query_id=data["query_id"],
|
||||
site=data["site"],
|
||||
kind=data["kind"],
|
||||
params=data.get("params") or {},
|
||||
lease_expires_at=data.get("lease_expires_at", ""),
|
||||
attempt=data.get("attempt", 1),
|
||||
)
|
||||
|
||||
async def report_query_result(
|
||||
self,
|
||||
query_id: str,
|
||||
worker_id: str,
|
||||
*,
|
||||
success: bool,
|
||||
result: dict[str, Any] | None = None,
|
||||
error_code: int | None = None,
|
||||
error_message: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""回报查询结果,返回网关响应里的 data 字段
|
||||
|
||||
网关可能回 6006(租约已被重投给下一轮)——那说明本次结果已经作废,
|
||||
调用方按「丢弃」处理即可,不要重发。
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"worker_id": worker_id,
|
||||
"success": success,
|
||||
"result": result,
|
||||
"error_code": error_code,
|
||||
"error_message": error_message,
|
||||
}
|
||||
body = await self._request(
|
||||
"POST", f"/api/account/queries/{query_id}/result", json=payload
|
||||
)
|
||||
return body["data"]
|
||||
|
||||
@@ -26,3 +26,20 @@ class LeaseTask:
|
||||
lease_expires_at: str = ""
|
||||
lease_count: int = 1
|
||||
known_state: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class QueryTask:
|
||||
"""worker 从网关领到的账号只读查询单
|
||||
|
||||
对应网关 GET /api/account/queries/lease 的 QueryLeaseData。没有 known_state
|
||||
之类的字段——查询是一次性只读动作,`attempt > 1` 只说明上一轮超时被重投了,
|
||||
worker 不需要因此改变行为(重跑安全,这正是它与下单任务的根本差别)。
|
||||
"""
|
||||
|
||||
query_id: str
|
||||
site: str
|
||||
kind: str
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
lease_expires_at: str = ""
|
||||
attempt: int = 1
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"""账号只读查询 worker:第二条常驻循环,与下单主循环并行
|
||||
|
||||
为什么单独一条循环而不是塞进 `runner.WorkerRunner`(docs/order-gateway.md §11):
|
||||
下单主循环执行一笔任务要几分钟(加购 → 确认页 → 提交 → 付款),期间它整个人
|
||||
都占在那笔任务上;查询要是排在同一条循环里,上游问一句「账号里那笔订单现在
|
||||
什么状态」就得等下单跑完。两条循环各自长轮询各自的通道,互不阻塞。
|
||||
|
||||
那「同一账号并发操作」的硬约束靠什么保证?靠 `SiteInteractor` 里那把
|
||||
`asyncio.Lock`——所有站点交互(下单写操作与这里的只读操作)都要先拿它,天然串行。
|
||||
网关侧因此**不需要**为查询再实现一遍「全局并发度 1」。
|
||||
|
||||
代价是:正在下单时,查询会卡在那把锁上等。所以每次执行都套
|
||||
`account_query_timeout_seconds` 超时,超时按失败回报,上游重发即可
|
||||
(只读,重发没有副作用)——总比让查询单一直挂到租约过期强。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.shared.errors import AppError, InvalidRequestError
|
||||
from app.shared.task_state import AccountQueryKind
|
||||
from app.trading.worker.client import GatewayClient
|
||||
from app.trading.worker.models import QueryTask
|
||||
from app.trading.worker.site_interact import SiteInteractor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.shared.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 没给 since 时的窗口下界。用 epoch 而不是 None,是为了让翻页终止条件
|
||||
# (`order_date < since`)与「不设下界」共用同一条代码路径,不多一个分支。
|
||||
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
# 超时时回报的错误码:沿用 2002(资源繁忙 / 等槽位超时),这里等的是账号锁。
|
||||
_TIMEOUT_ERR_CODE = 2002
|
||||
# 未预期异常回报的错误码,与 HTTP 层兜底处理器保持一致
|
||||
_UNEXPECTED_ERR_CODE = 1500
|
||||
|
||||
|
||||
def _parse_since(raw: Any) -> datetime:
|
||||
"""解析 params.since。缺省用 epoch(不设下界);给了但解析不出直接报错,不猜"""
|
||||
if raw in (None, ""):
|
||||
return _EPOCH
|
||||
if not isinstance(raw, str):
|
||||
raise InvalidRequestError(f"params.since 必须是 ISO8601 字符串,收到 {type(raw).__name__}")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise InvalidRequestError(f"params.since 不是合法的 ISO8601 时间:{raw}") from exc
|
||||
# 站点返回的下单时间带时区,比较两侧必须都 aware,否则直接 TypeError
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _parse_max_pages(raw: Any, default: int) -> int:
|
||||
if raw in (None, ""):
|
||||
return default
|
||||
if not isinstance(raw, int) or isinstance(raw, bool):
|
||||
raise InvalidRequestError(f"params.max_pages 必须是整数,收到 {raw!r}")
|
||||
if raw < 1:
|
||||
raise InvalidRequestError(f"params.max_pages 必须 ≥ 1,收到 {raw}")
|
||||
return raw
|
||||
|
||||
|
||||
class QueryRunner:
|
||||
"""账号只读查询的常驻循环
|
||||
|
||||
只依赖网关客户端与 SiteInteractor:查询不落证据、不写本地 SQLite——它没有
|
||||
「执行事实」需要留痕,读到什么就回什么。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: "Settings",
|
||||
gateway_client: GatewayClient,
|
||||
site: SiteInteractor,
|
||||
):
|
||||
self._settings = settings
|
||||
self._gateway = gateway_client
|
||||
self._site = site
|
||||
self._running = False
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
@property
|
||||
def worker_id(self) -> str:
|
||||
return self._settings.worker_id_effective
|
||||
|
||||
# ---- 主循环 ----
|
||||
|
||||
async def run(self) -> None:
|
||||
"""常驻循环:长轮询领查询单 → 执行 → 回结果"""
|
||||
self._running = True
|
||||
logger.info("账号查询 worker 启动:worker_id=%s", self.worker_id)
|
||||
while self._running:
|
||||
try:
|
||||
query = await self._gateway.lease_query(self.worker_id, wait=30)
|
||||
except AppError as exc:
|
||||
logger.warning("查询 lease 失败:%s (err=%s)", exc.message, exc.err_code)
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("查询 lease 异常")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
if query is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
await self.handle(query)
|
||||
except Exception: # noqa: BLE001
|
||||
# 任何未预期的异常都不应让这条循环退出
|
||||
logger.exception("处理查询单异常:query_id=%s", query.query_id)
|
||||
|
||||
# ---- 单张查询单 ----
|
||||
|
||||
async def handle(self, query: QueryTask) -> None:
|
||||
"""执行一张查询单并回报结果。任何失败都转成一次「失败回报」,不抛出去"""
|
||||
logger.info(
|
||||
"领到查询单:query_id=%s kind=%s attempt=%s",
|
||||
query.query_id, query.kind, query.attempt,
|
||||
)
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
self.execute(query),
|
||||
timeout=self._settings.account_query_timeout_seconds,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"查询超时(%s 秒,多半是下单任务正占着账号锁):query_id=%s",
|
||||
self._settings.account_query_timeout_seconds, query.query_id,
|
||||
)
|
||||
await self._report_safe(
|
||||
query,
|
||||
success=False,
|
||||
error_code=_TIMEOUT_ERR_CODE,
|
||||
error_message=(
|
||||
f"查询超过 {self._settings.account_query_timeout_seconds} 秒未完成"
|
||||
"(账号锁被下单任务占用或站点页面无响应),可稍后重发"
|
||||
),
|
||||
)
|
||||
return
|
||||
except AppError as exc:
|
||||
logger.warning(
|
||||
"查询失败:query_id=%s code=%s msg=%s",
|
||||
query.query_id, exc.err_code, exc.message,
|
||||
)
|
||||
await self._report_safe(
|
||||
query, success=False, error_code=exc.err_code, error_message=exc.message
|
||||
)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("查询未预期异常:query_id=%s", query.query_id)
|
||||
await self._report_safe(
|
||||
query,
|
||||
success=False,
|
||||
error_code=_UNEXPECTED_ERR_CODE,
|
||||
error_message=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
return
|
||||
|
||||
payload, oversized = self._enforce_result_size(result)
|
||||
if oversized is not None:
|
||||
await self._report_safe(
|
||||
query, success=False, error_code=1003, error_message=oversized
|
||||
)
|
||||
return
|
||||
await self._report_safe(query, success=True, result=payload)
|
||||
|
||||
async def execute(self, query: QueryTask) -> dict[str, Any]:
|
||||
"""按 kind 分发到 SiteInteractor 的只读方法,返回要回给上游的结果字典"""
|
||||
if query.site != "rakuten":
|
||||
raise InvalidRequestError(
|
||||
f"site={query.site} 不在交易服务范围内(账号查询仅支持 rakuten)"
|
||||
)
|
||||
|
||||
if query.kind == AccountQueryKind.ORDER_LIST.value:
|
||||
return await self._execute_order_list(query)
|
||||
if query.kind == AccountQueryKind.ORDER_DETAIL.value:
|
||||
return await self._execute_order_detail(query)
|
||||
raise InvalidRequestError(f"未知的查询种类:{query.kind}")
|
||||
|
||||
async def _execute_order_list(self, query: QueryTask) -> dict[str, Any]:
|
||||
"""订单列表:规范化条目 + 站点原始 orderListData(逐页)"""
|
||||
since = _parse_since(query.params.get("since"))
|
||||
max_pages = _parse_max_pages(
|
||||
query.params.get("max_pages"), self._settings.account_query_default_max_pages
|
||||
)
|
||||
|
||||
window = await self._site.list_recent_orders(since=since, max_pages=max_pages)
|
||||
return {
|
||||
"kind": AccountQueryKind.ORDER_LIST.value,
|
||||
"since": since.isoformat().replace("+00:00", "Z"),
|
||||
"max_pages": max_pages,
|
||||
# False=翻页没能覆盖完窗口(命中页数上限 / 页面结构不对 / 掉登录),
|
||||
# 此时「列表里没有某笔订单」不等于「账号里没有这笔订单」。
|
||||
"window_fully_covered": window.window_fully_covered,
|
||||
"orders": [
|
||||
{
|
||||
"order_number": entry.order_number,
|
||||
"order_date": entry.order_date,
|
||||
"shop_id": entry.shop_id,
|
||||
"shop_name": entry.shop_name,
|
||||
"items": [
|
||||
{
|
||||
"item_id": item.item_id,
|
||||
"item_name": item.item_name,
|
||||
"item_url": item.item_url,
|
||||
}
|
||||
for item in entry.items
|
||||
],
|
||||
}
|
||||
for entry in window.entries
|
||||
],
|
||||
# 站点 __INITIAL_STATE__.orderListData 原文,按翻页顺序。规范化字段
|
||||
# 只挑了下单/核对用得上的那几个,别的字段(金额、配送、状态文案)在这里。
|
||||
"raw_pages": window.raw_pages,
|
||||
}
|
||||
|
||||
async def _execute_order_detail(self, query: QueryTask) -> dict[str, Any]:
|
||||
"""单笔订单详情:已实测的配送阶段 + 未实测结构的页面原始状态"""
|
||||
order_number = query.params.get("order_number")
|
||||
if not order_number or not isinstance(order_number, str):
|
||||
raise InvalidRequestError("params.order_number 必填(站点注文番号)")
|
||||
|
||||
detail = await self._site.fetch_order_detail(order_number)
|
||||
status = detail.status
|
||||
return {
|
||||
"kind": AccountQueryKind.ORDER_DETAIL.value,
|
||||
"order_number": order_number,
|
||||
# found=False 不是错误:站点自己说下单后订单要 10 分钟左右才反映到
|
||||
# 订单页,刚下的单查不到属正常。
|
||||
"found": status.found,
|
||||
"stage_label": status.stage_label,
|
||||
"order_state": status.order_state.value if status.order_state else None,
|
||||
# 详情页的 __INITIAL_STATE__ 结构从未拿到真实样本,本服务不做字段抽取,
|
||||
# 原样透传由上游自担结构变动风险(见 site_interact.OrderDetailSnapshot)。
|
||||
"raw": detail.raw,
|
||||
"raw_available": detail.raw is not None,
|
||||
}
|
||||
|
||||
# ---- 结果体积与回报 ----
|
||||
|
||||
def _enforce_result_size(self, result: dict[str, Any]) -> tuple[dict[str, Any], str | None]:
|
||||
"""体积闸门:超限先丢站点原始 JSON,仍超限则判失败
|
||||
|
||||
返回 (要回报的结果, 超限说明)。超限说明非 None 时表示这次要按失败回报——
|
||||
与其把几 MB 页面状态塞进网关 SQLite,不如让上游把窗口调小重来。
|
||||
"""
|
||||
limit = self._settings.query_result_max_bytes
|
||||
if self._size_of(result) <= limit:
|
||||
return result, None
|
||||
|
||||
trimmed = dict(result)
|
||||
trimmed["raw_pages"] = []
|
||||
trimmed["raw"] = None
|
||||
trimmed["raw_omitted"] = f"站点原始 JSON 超过 {limit} 字节上限,已丢弃,仅保留规范化字段"
|
||||
if self._size_of(trimmed) <= limit:
|
||||
logger.warning("查询结果超限,已丢弃站点原始 JSON:limit=%s", limit)
|
||||
return trimmed, None
|
||||
|
||||
return result, (
|
||||
f"查询结果超过 {limit} 字节上限,丢弃站点原始 JSON 后仍超限;"
|
||||
"请缩小查询窗口(减小 params.max_pages 或收紧 params.since)后重试"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _size_of(payload: dict[str, Any]) -> int:
|
||||
return len(json.dumps(payload, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
async def _report_safe(
|
||||
self,
|
||||
query: QueryTask,
|
||||
*,
|
||||
success: bool,
|
||||
result: dict[str, Any] | None = None,
|
||||
error_code: int | None = None,
|
||||
error_message: str = "",
|
||||
) -> None:
|
||||
"""回报结果,失败只记日志不抛——这条循环不能因为回报失败退出
|
||||
|
||||
网关回 6006 属于正常情况(本次租约已被重投给下一轮,结果作废),
|
||||
同样只记日志,不重发。
|
||||
"""
|
||||
try:
|
||||
await self._gateway.report_query_result(
|
||||
query.query_id,
|
||||
self.worker_id,
|
||||
success=success,
|
||||
result=result,
|
||||
error_code=error_code,
|
||||
error_message=error_message,
|
||||
)
|
||||
except AppError as exc:
|
||||
logger.warning(
|
||||
"回报查询结果被网关拒绝:query_id=%s code=%s msg=%s",
|
||||
query.query_id, exc.err_code, exc.message,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("回报查询结果失败:query_id=%s", query.query_id)
|
||||
@@ -64,6 +64,11 @@ data/evidence/checkout-research-20260811/NOTES.md):
|
||||
不需要正则抠 DOM。当时账号只有 1 笔订单、1 页,验证了单页解析与
|
||||
`?page=2` 超出范围返回空列表;多页翻页时「新订单排在前面」的排序假设、以及
|
||||
分页游标本身,都没有被真实多页数据验证过。
|
||||
- fetch_order_detail / list_recent_orders 同时供「账号只读查询通道」使用
|
||||
(docs/order-gateway.md §11,上游经网关问「账号里真实的订单长什么样」)。
|
||||
查询返回的规范化字段全部来自上面这两条已实测路径;额外带出的站点原始 JSON 里,
|
||||
**只有订单列表页的 orderListData 是实测过的结构**,详情页的 __INITIAL_STATE__
|
||||
从未拿到过真实样本,只做「解析得动就原样透传」,本模块不猜它的字段。
|
||||
|
||||
**httpx 不能用于带账号的写操作**:Rakuten 对账号操作有 TLS/HTTP2 指纹校验,
|
||||
同一份 cookie Playwright 能用、httpx 不能。所以本模块全程使用 Playwright
|
||||
@@ -376,11 +381,17 @@ class OrderListEntry:
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrderListPage:
|
||||
"""order-list 单页解析结果(_parse_order_list 的返回值)"""
|
||||
"""order-list 单页解析结果(_parse_order_list 的返回值)
|
||||
|
||||
`raw` 是站点 `__INITIAL_STATE__.orderListData` 的原文,供只读查询接口原样
|
||||
透传给上游(见 docs/order-gateway.md §11)——站点比我们的 dataclass 多给的
|
||||
字段(金额、配送、状态文案等)不该在这一层被悄悄丢掉。解析不到时为 None。
|
||||
"""
|
||||
|
||||
entries: list[OrderListEntry]
|
||||
orders_found: int | None = None
|
||||
page_size: int | None = None
|
||||
raw: dict | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -389,13 +400,16 @@ class OrderListWindow:
|
||||
|
||||
window_fully_covered=True 才代表「窗口内的订单已经看全」——可能是因为翻到了
|
||||
比 since 更早的订单、可能是列表本身翻完了、也可能是 ordersFound 已经对上。
|
||||
False 表示翻页在覆盖完窗口前就停了(命中 _ORDER_LIST_MAX_PAGES,或页面结构
|
||||
False 表示翻页在覆盖完窗口前就停了(命中 max_pages 上限,或页面结构
|
||||
解析不出 ordersFound 之类的异常),此时 entries 里「没有匹配」不能当作
|
||||
「确实没下单」——调用方必须转 unknown,不能默认 NOT_ORDERED。
|
||||
|
||||
raw_pages 按翻页顺序保存每页的站点原始 orderListData,只读查询接口用。
|
||||
"""
|
||||
|
||||
entries: list[OrderListEntry]
|
||||
window_fully_covered: bool
|
||||
raw_pages: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_order_datetime(raw: str | None) -> datetime | None:
|
||||
@@ -416,6 +430,7 @@ class _OrderListAccumulator:
|
||||
total_found: int | None = None
|
||||
is_first_page: bool = True
|
||||
gave_up: bool = False # True=第一页就拿不到结构化数据,翻页无意义,直接放弃
|
||||
raw_pages: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def _accumulate_order_list_page(
|
||||
@@ -431,21 +446,39 @@ def _accumulate_order_list_page(
|
||||
结构化列表数据(页面结构变了/act 分支不对/未登录跳转),此时也会停止翻页,
|
||||
但调用方必须按「没覆盖」处理,不能当成真的翻完了。
|
||||
"""
|
||||
raw_pages = acc.raw_pages + [page.raw] if page.raw is not None else acc.raw_pages
|
||||
|
||||
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),
|
||||
_OrderListAccumulator(
|
||||
entries=acc.entries,
|
||||
total_found=acc.total_found,
|
||||
is_first_page=False,
|
||||
gave_up=True,
|
||||
raw_pages=raw_pages,
|
||||
),
|
||||
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),
|
||||
_OrderListAccumulator(
|
||||
entries=acc.entries,
|
||||
total_found=total_found,
|
||||
is_first_page=False,
|
||||
raw_pages=raw_pages,
|
||||
),
|
||||
True,
|
||||
)
|
||||
|
||||
new_entries = acc.entries + page.entries
|
||||
new_acc = _OrderListAccumulator(entries=new_entries, total_found=total_found, is_first_page=False)
|
||||
new_acc = _OrderListAccumulator(
|
||||
entries=new_entries,
|
||||
total_found=total_found,
|
||||
is_first_page=False,
|
||||
raw_pages=raw_pages,
|
||||
)
|
||||
|
||||
oldest_dt = parse_order_datetime(page.entries[-1].order_date)
|
||||
if oldest_dt is not None and oldest_dt < since:
|
||||
@@ -486,6 +519,23 @@ class OrderStatusSnapshot:
|
||||
html: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrderDetailSnapshot:
|
||||
"""订单详情页的一次完整读取结果(fetch_order_detail 的返回值)
|
||||
|
||||
`status` 是已实测的那部分(配送阶段进度条,见 _parse_order_status);
|
||||
`raw` 是整页 `window.__INITIAL_STATE__` 的原文——**详情页的这份结构从未被
|
||||
真实数据验证过**(2026-08-13 那次实测只验了进度条组件),因此这里刻意
|
||||
只做「能解析成 JSON 就原样带出去」,不写任何字段抽取逻辑。要金额、收货
|
||||
地址、付款方式这些字段的调用方,自己从 raw 里取并自担结构变动风险;等拿到
|
||||
真实详情页样本后再在本模块补规范化解析,不要在没有样本的情况下先猜着写。
|
||||
解析不出(页面没有内联状态、或不是 JSON)时为 None。
|
||||
"""
|
||||
|
||||
status: OrderStatusSnapshot
|
||||
raw: dict | None = None
|
||||
|
||||
|
||||
class SiteInteractor:
|
||||
"""Rakuten 站点交互器:持有 Playwright 浏览器 context,复用账号 cookie
|
||||
|
||||
@@ -1772,23 +1822,36 @@ class SiteInteractor:
|
||||
async def check_order_status(self, site_order_id: str) -> OrderStatusSnapshot:
|
||||
"""付款后监控的单次探测:查一次订单详情页的配送阶段,不循环
|
||||
|
||||
`fetch_order_detail` 的薄封装——监控只关心配送阶段,不需要页面原始状态。
|
||||
循环轮询(间隔、次数上限、状态变化时 report)由
|
||||
`runner.WorkerRunner._monitor_order` 负责——本方法只做一次
|
||||
「导航 + 解析」,站点交互与轮询节奏解耦,也方便离线单测轮询逻辑
|
||||
(用桩替换本方法)与解析逻辑(`_parse_order_status`,纯函数)。
|
||||
|
||||
与 enter_checkout 不同,本方法开自己的临时 Page 并在返回前关闭——
|
||||
submit_order/pay 已经处理完并关闭了 `_checkout_pages` 里留存的会话,
|
||||
监控阶段没有需要跨调用复用的页面状态。
|
||||
|
||||
执行中掉登录会自动重登一次并重跑(详见 `_read_with_relogin_retry`):
|
||||
本方法是纯读,重跑没有副作用;不加这层的话订单页被踢到 SSO 会静默走进
|
||||
解析逻辑、被当成「订单号还没出现」,轮询白转几个小时也看不出原因。
|
||||
`runner.WorkerRunner._monitor_order` 负责。
|
||||
|
||||
Returns:
|
||||
OrderStatusSnapshot;订单号暂时查不到、或进度条解析不出新阶段都
|
||||
**不算错误**(详见该 dataclass 文档),由调用方决定是否继续轮询。
|
||||
|
||||
Raises:
|
||||
NotLoggedInError: 登录态失效且自动重登没能恢复
|
||||
OrderOperationError: 订单详情页打开/渲染失败
|
||||
"""
|
||||
return (await self.fetch_order_detail(site_order_id)).status
|
||||
|
||||
async def fetch_order_detail(self, site_order_id: str) -> OrderDetailSnapshot:
|
||||
"""读一次订单详情页:配送阶段 + 页面原始 __INITIAL_STATE__
|
||||
|
||||
两个调用方:付款后监控(`check_order_status`,只要配送阶段)与账号只读
|
||||
查询通道的 order_detail(规格 §11,还要 raw 原文)。站点交互与轮询节奏
|
||||
解耦,也方便离线单测轮询逻辑(用桩替换本方法)与解析逻辑
|
||||
(`_parse_order_status`,纯函数)。
|
||||
|
||||
与 enter_checkout 不同,本方法开自己的临时 Page 并在返回前关闭——
|
||||
submit_order/pay 已经处理完并关闭了 `_checkout_pages` 里留存的会话,
|
||||
监控阶段没有需要跨调用复用的页面状态。
|
||||
|
||||
执行中掉登录会自动重登一次并重跑(详见 `_read_with_relogin_retry`):
|
||||
本方法是纯读,重跑没有副作用;不加这层的话订单页被踢到 SSO 会静默走进
|
||||
解析逻辑、被当成「订单号还没出现」,轮询白转几个小时也看不出原因。
|
||||
|
||||
Raises:
|
||||
NotLoggedInError: 登录态失效且自动重登没能恢复
|
||||
OrderOperationError: 订单详情页打开/渲染失败
|
||||
@@ -1796,7 +1859,7 @@ class SiteInteractor:
|
||||
shop_id = site_order_id.split("-", 1)[0]
|
||||
url = _ORDER_DETAIL_URL_TEMPLATE.format(order_number=site_order_id, shop_id=shop_id)
|
||||
|
||||
async def read() -> OrderStatusSnapshot:
|
||||
async def read() -> OrderDetailSnapshot:
|
||||
page = await self._context.new_page()
|
||||
try:
|
||||
try:
|
||||
@@ -1820,20 +1883,27 @@ class SiteInteractor:
|
||||
"rakuten", final_url=final_url, body=html
|
||||
):
|
||||
raise _LoggedOutMidRead(f"订单详情页落地 {final_url}")
|
||||
return snapshot
|
||||
return OrderDetailSnapshot(status=snapshot, raw=_parse_initial_state(html))
|
||||
|
||||
return await self._read_with_relogin_retry(
|
||||
f"check_order_status site_order_id={site_order_id}", read
|
||||
)
|
||||
|
||||
async def list_recent_orders(self, *, since: datetime) -> OrderListWindow:
|
||||
"""恢复核对用:拉取「任务创建时间之后」的订单列表(规格 §5 依赖它)
|
||||
async def list_recent_orders(
|
||||
self, *, since: datetime, max_pages: int | None = None
|
||||
) -> OrderListWindow:
|
||||
"""拉取「since 之后」的订单列表
|
||||
|
||||
由 verify.verify_on_site 调用,不由 worker 主循环直接调。翻页直到看到
|
||||
order_date 早于 since 的订单(说明窗口内的都已经看过一遍)、或
|
||||
ordersFound 已经全部翻完、或到达 _ORDER_LIST_MAX_PAGES 上限。命中上限仍
|
||||
没能确认覆盖完整窗口时,`OrderListWindow.window_fully_covered=False`——
|
||||
调用方据此转 unknown,绝不能把「没翻完」当成「翻完了但没有」。
|
||||
两个调用方:
|
||||
- `verify.verify_on_site` 恢复核对(规格 §5 依赖它)
|
||||
- 账号只读查询通道的 order_list(规格 §11),此时 `max_pages` 由上游给,
|
||||
`raw_pages` 会被原样透传出去
|
||||
|
||||
翻页直到看到 order_date 早于 since 的订单(说明窗口内的都已经看过一遍)、或
|
||||
ordersFound 已经全部翻完、或到达 max_pages 上限(缺省
|
||||
`_ORDER_LIST_MAX_PAGES`)。命中上限仍没能确认覆盖完整窗口时,
|
||||
`OrderListWindow.window_fully_covered=False`——调用方据此转 unknown,绝不能
|
||||
把「没翻完」当成「翻完了但没有」。
|
||||
|
||||
2026-08-13 只用「账号只有 1 笔订单、1 页」的真实数据验证过单页解析与
|
||||
page=2 返回空列表这两点;多页翻页的排序假设(新订单在前)未经真实数据
|
||||
@@ -1848,13 +1918,15 @@ class SiteInteractor:
|
||||
NotLoggedInError: 登录态失效且自动重登没能恢复
|
||||
OrderOperationError: 订单列表页打开/渲染失败
|
||||
"""
|
||||
page_limit = max(1, min(max_pages or _ORDER_LIST_MAX_PAGES, _ORDER_LIST_MAX_PAGES))
|
||||
|
||||
async def read() -> OrderListWindow:
|
||||
acc = _OrderListAccumulator()
|
||||
stop = False
|
||||
|
||||
page = await self._context.new_page()
|
||||
try:
|
||||
for page_num in range(1, _ORDER_LIST_MAX_PAGES + 1):
|
||||
for page_num in range(1, page_limit + 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)
|
||||
@@ -1880,7 +1952,9 @@ class SiteInteractor:
|
||||
await page.close()
|
||||
|
||||
return OrderListWindow(
|
||||
entries=acc.entries, window_fully_covered=stop and not acc.gave_up
|
||||
entries=acc.entries,
|
||||
window_fully_covered=stop and not acc.gave_up,
|
||||
raw_pages=acc.raw_pages,
|
||||
)
|
||||
|
||||
return await self._read_with_relogin_retry("list_recent_orders", read)
|
||||
@@ -2093,4 +2167,5 @@ def _parse_order_list(html: str) -> OrderListPage:
|
||||
entries=entries,
|
||||
orders_found=data.get("ordersFound"),
|
||||
page_size=data.get("pageSize"),
|
||||
raw=data or None,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user