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:
2026-08-16 22:46:01 +08:00
co-authored by Claude Opus 5
parent b01c9659d1
commit c03158488b
22 changed files with 2575 additions and 43 deletions
+102 -27
View File
@@ -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,
)