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
+23
View File
@@ -151,6 +151,29 @@ class Settings(BaseSettings):
# 正常 worker 每 30 秒来一次 lease,超过该阈值未来 lease 即视为异常。
worker_offline_alert_seconds: int = 300
# ---- 账号只读查询通道(网关 + worker 两侧共用,见 docs/order-gateway.md §11)----
# 查询单租约 TTL(秒)。worker 领走后必须在此时间内回结果,否则网关把它
# **重投回 queued**——只读操作重复执行没有副作用,与下单任务的 stale 语义相反。
query_lease_ttl_seconds: int = 180
# 查询单整体存活上限(秒)。从创建算起,超过仍未拿到结果即置 expired
# (多半是本地 worker 不在线)。上游据此判断「这张单不用再等了」。
query_ttl_seconds: int = 900
# 同一张查询单最多被领取几次。租约过期重投累计到这个次数仍无结果即置 failed,
# 避免某条查询让 worker 反复卡死在同一个页面上。
query_max_attempts: int = 3
# 终态查询单的保留时长(秒)。结果里带站点原始 JSON,体积不小,超期由 sweep
# 清掉;默认 7 天,够上游对账与排查。
query_retention_seconds: int = 7 * 24 * 3600
# worker 侧单次站点读取的耗时上限(秒)。查询与下单共用同一把账号锁,正在
# 跑的下单会让查询排队等待,这个上限保证查询不会一直挂着——超时按失败回报,
# 上游重发即可(只读,重发无副作用)。
account_query_timeout_seconds: int = 120
# 订单列表查询缺省翻页上限。上游可在 params.max_pages 覆盖(1..20)。
account_query_default_max_pages: int = 3
# 回报给网关的结果 JSON 体积上限(字节)。超过时丢掉站点原始 JSON、只回
# 规范化字段并标记 raw_omitted——避免把几 MB 的页面状态塞进网关 SQLite。
query_result_max_bytes: int = 1024 * 1024
# ---- 本地下单 worker(仅交易服务内的 worker 子模块使用)----
# 网关 URL。**留空则不启动 worker**,交易服务只跑登录态接口。
# 部署形态:本地机(NAT 后无公网入口)通过出站长轮询领任务,详见
+36
View File
@@ -241,3 +241,39 @@ class InvalidTaskStateError(AppError):
retryable=False,
status_code=409,
)
# ---- 账号只读查询通道(仅网关进程使用,见 docs/order-gateway.md §11)----
# 与下单任务不同:查询是只读的,**可以安全重投**,因此这两类错误标 retryable=True,
# 上游重发一张查询单不会有任何副作用。
class QueryNotFoundError(AppError):
"""查询单不存在(或已过保留期被清理)"""
def __init__(self, query_id: str):
super().__init__(
message=f"查询单不存在:{query_id}",
code="QUERY_NOT_FOUND",
err_code=6005,
retryable=False,
status_code=404,
)
self.query_id = query_id
class QueryLeaseInvalidError(AppError):
"""查询单租约无效:不是持有者、已被重投给别人或已终结
最常见的触发场景是 worker 执行超时、查询单被 sweep 重投后,原 worker 才姗姗
来迟地回结果——这时结果必须被拒绝,否则会覆盖掉新一轮的执行结果。
"""
def __init__(self, message: str = "查询单租约无效"):
super().__init__(
message=message,
code="QUERY_LEASE_INVALID",
err_code=6006,
retryable=True,
status_code=409,
)
+37
View File
@@ -49,6 +49,43 @@ LEASABLE_STATUSES: frozenset[TaskStatus] = frozenset({TaskStatus.QUEUED})
RECLAIMABLE_STATUSES: frozenset[TaskStatus] = frozenset({TaskStatus.STALE})
class QueryStatus(StrEnum):
"""账号只读查询单状态(网关权威,见 docs/order-gateway.md §11)
与 TaskStatus 刻意分成两套词汇表,因为安全约束正好相反:
- 下单是不可逆写操作 → 租约过期只能置 stale 等人工 reclaim,绝不自动重投
- 查询是只读操作 → 租约过期直接回 queued 自动重投,重复执行没有副作用
没有 running:查询是「领走 → 一次性回结果」,中间没有需要单独表达的进行态,
也不需要续租(超时就重投)。
"""
QUEUED = "queued" # 已入队,等待 worker 领取
LEASED = "leased" # 已被 worker 领走,等待回结果
SUCCEEDED = "succeeded" # 终态:拿到结果
FAILED = "failed" # 终态:worker 明确失败,或重投次数用尽
EXPIRED = "expired" # 终态:超过查询单 TTL 仍未完成(多半是 worker 不在线)
# 查询单终态集合:到达后 result 一律报 6006,sweep 也不再动它
QUERY_TERMINAL_STATUSES: frozenset[QueryStatus] = frozenset(
{QueryStatus.SUCCEEDED, QueryStatus.FAILED, QueryStatus.EXPIRED}
)
class AccountQueryKind(StrEnum):
"""账号只读查询的种类
放在 shared 是因为它是网关与 worker 之间的 HTTP 契约的一部分:网关不解释
`params` 的内容(与 intent 同样原样透传),但**校验 kind 合法**——不然上游
拼错一个字符,要等 worker 领走、执行、回报失败才知道,比当场 422 差得多。
"""
ORDER_LIST = "order_list" # 订单列表(可带时间窗与翻页上限)
ORDER_DETAIL = "order_detail" # 单笔订单详情(按注文番号)
class OrderState(StrEnum):
"""订单状态(本地权威,网关只存镜像)