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
+357
View File
@@ -0,0 +1,357 @@
"""账号只读查询通道的业务逻辑:状态机、租约、重投、过期与保留期清理
与 `task_queue.py` 是**刻意分开的两套语义**,不是重复代码(见 docs/order-gateway.md §11):
| | 下单任务队列 | 本模块(只读查询) |
| --- | --- | --- |
| 操作性质 | 不可逆写 | 只读 |
| 全局并发度 1 | 是(同账号写操作必须串行) | 否(账号级串行由本地 SiteInteractor 的锁兜底) |
| 租约过期 | 置 stale,**绝不自动重投**,等人工 reclaim | 回 queued **自动重投**,重跑无副作用 |
| 续租 | 有(下单要几分钟) | 无(超时即重投,比续租简单且安全) |
| 心跳 | lease 兼作心跳 | **不刷心跳**(见 `lease` 注释) |
这两张表共用一个 `GatewayDB` 连接,但各自持有自己的锁:查询的长轮询等待不该
把下单任务的 lease/report 一起挂住。SQLite 单连接本身是串行的,两把锁只是各自
保护「检查 + 写入」的原子序列。
"""
from __future__ import annotations
import asyncio
import json
import logging
import secrets
from datetime import datetime, timedelta, timezone
from app.gateway.db import GatewayDB, QueryRow
from app.gateway.models import (
QueryDetail,
QueryError,
QueryLeaseData,
QueryListData,
QueryResultData,
SubmitQueryData,
)
from app.shared.errors import QueryLeaseInvalidError, QueryNotFoundError
from app.shared.task_state import QUERY_TERMINAL_STATUSES, QueryStatus
logger = logging.getLogger(__name__)
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def to_iso(dt: datetime) -> str:
"""统一时间戳格式:ISO8601 UTC,秒精度,Z 结尾(与 task_queue 一致)"""
return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def parse_iso(s: str) -> datetime:
if s.endswith("Z"):
s = s[:-1] + "+00:00"
return datetime.fromisoformat(s)
def _generate_query_id() -> str:
"""服务端生成的 query_id:与 task_id 用不同前缀,日志里一眼能分清两条通道"""
return f"q-{to_iso(utcnow())[:10].replace('-', '')}-{secrets.token_hex(4)}"
def _row_to_detail(row: QueryRow) -> QueryDetail:
error = None
if row.error_code is not None or row.error_message:
error = QueryError(code=row.error_code, message=row.error_message or "")
return QueryDetail(
query_id=row.query_id,
site=row.site,
kind=row.kind, # type: ignore[arg-type]
params=row.params,
status=QueryStatus(row.status),
lease_owner=row.lease_owner,
lease_expires_at=row.lease_expires_at,
attempts=row.attempts,
created_at=row.created_at,
updated_at=row.updated_at,
completed_at=row.completed_at,
result=row.result,
error=error,
)
class QueryQueue:
"""账号只读查询单队列
生命周期与 TaskQueue 一致:由 GatewayContainer 持有,共用同一个 GatewayDB。
"""
def __init__(
self,
db: GatewayDB,
*,
lease_ttl_seconds: int,
query_ttl_seconds: int,
max_attempts: int,
retention_seconds: int,
):
self._db = db
self._lease_ttl = lease_ttl_seconds
self._query_ttl = query_ttl_seconds
self._max_attempts = max_attempts
self._retention = retention_seconds
self._lock = asyncio.Lock()
self._cond = asyncio.Condition(self._lock)
# ---- 提交 ----
async def submit(
self, *, query_id: str | None, site: str, kind: str, params: dict
) -> SubmitQueryData:
"""上游提交查询单。query_id 缺省时服务端生成;重复提交幂等"""
qid = query_id or _generate_query_id()
now = to_iso(utcnow())
row = QueryRow(
query_id=qid,
site=site,
kind=kind,
params_json=json.dumps(params, ensure_ascii=False),
status=QueryStatus.QUEUED.value,
lease_owner=None,
lease_expires_at=None,
attempts=0,
result_json=None,
error_code=None,
error_message=None,
created_at=now,
updated_at=now,
completed_at=None,
)
async with self._lock:
created = await self._db.insert_query(row)
if created:
self._cond.notify_all()
else:
existing = await self._db.get_query(qid)
if existing is None:
raise RuntimeError(f"查询单 {qid} 既未新建也无法读取,状态异常")
row = existing
return SubmitQueryData(
query_id=row.query_id, status=QueryStatus(row.status), created=created
)
# ---- 领取 ----
async def lease(
self, *, worker_id: str, wait: int, site: str | None, max_wait: int
) -> QueryLeaseData | None:
"""本地长轮询领取一张查询单
与下单 lease 的三点不同:
1. **没有全局并发度 1 的闸门**——只读操作允许多张单同时在飞;真正的账号级
串行由本地 SiteInteractor 那把锁保证,网关不必也不该重复实现。
2. **不刷 workers.last_seen_at**。心跳的语义是「下单 worker 还活着」,
如果查询循环活着就刷心跳,下单主循环挂了也看不出来,告警会失真。
3. 领走前先 sweep:把超时的重投回 queued、过期的置 expired。
"""
effective_wait = max(0, min(wait, max_wait))
deadline = utcnow() + timedelta(seconds=effective_wait)
async with self._lock:
await self._sweep_locked()
while True:
row = await self._db.pick_queued_query(site)
if row is not None:
return await self._lease_to_locked(row, worker_id)
remaining = (deadline - utcnow()).total_seconds()
if remaining <= 0:
return None
try:
await asyncio.wait_for(self._cond.wait(), timeout=remaining)
except asyncio.TimeoutError:
return None
async def _lease_to_locked(self, row: QueryRow, worker_id: str) -> QueryLeaseData:
"""把一张 queued 查询单租给 worker。调用方必须持有 self._lock"""
now = utcnow()
expires = to_iso(now + timedelta(seconds=self._lease_ttl))
attempt = row.attempts + 1
await self._db.update_query(
row.query_id,
status=QueryStatus.LEASED.value,
lease_owner=worker_id,
lease_expires_at=expires,
attempts=attempt,
updated_at=to_iso(now),
)
return QueryLeaseData(
query_id=row.query_id,
site=row.site,
kind=row.kind, # type: ignore[arg-type]
params=row.params,
lease_expires_at=expires,
attempt=attempt,
)
# ---- 回结果 ----
async def submit_result(
self,
query_id: str,
*,
worker_id: str,
success: bool,
result: dict | None,
error_code: int | None,
error_message: str,
) -> QueryResultData:
"""worker 回报结果
校验 `worker_id == lease_owner` 且查询单未终结,否则 6006——最典型的场景是
执行超时、单子已被重投给下一轮,迟到的结果必须丢弃,不能覆盖新结果。
"""
async with self._lock:
row = await self._db.get_query(query_id)
if row is None:
raise QueryNotFoundError(query_id)
status = QueryStatus(row.status)
if status in QUERY_TERMINAL_STATUSES:
raise QueryLeaseInvalidError(
f"查询单 {query_id} 已是终态 {status.value},不再接受结果"
)
if row.lease_owner != worker_id:
raise QueryLeaseInvalidError(
f"worker {worker_id} 不是查询单 {query_id} 的租约持有者"
f"(当前持有者 {row.lease_owner}"
)
now = to_iso(utcnow())
if success:
await self._db.update_query(
query_id,
status=QueryStatus.SUCCEEDED.value,
result_json=json.dumps(result or {}, ensure_ascii=False),
updated_at=now,
completed_at=now,
clear_lease=True,
)
final = QueryStatus.SUCCEEDED
else:
await self._db.update_query(
query_id,
status=QueryStatus.FAILED.value,
error_code=error_code,
error_message=error_message or "worker 未给出失败原因",
updated_at=now,
completed_at=now,
clear_lease=True,
)
final = QueryStatus.FAILED
logger.warning(
"查询单失败:query_id=%s kind=%s code=%s msg=%s",
query_id, row.kind, error_code, error_message,
)
return QueryResultData(query_id=query_id, status=final)
# ---- 清扫:重投 / 过期 / 保留期 ----
async def sweep(self) -> int:
"""扫一轮:租约超时重投、整体超时置 expired、过保留期的终态单清理
返回本轮发生状态变更的查询单条数(删除的不计入)。
"""
async with self._lock:
changed = await self._sweep_locked()
purged = await self._db.delete_queries_completed_before(
to_iso(utcnow() - timedelta(seconds=self._retention))
)
if purged:
logger.info("清理了 %s 张过保留期的查询单", purged)
return changed
async def _sweep_locked(self) -> int:
"""锁内清扫。调用方必须持有 self._lock"""
now = utcnow()
changed = 0
rows = await self._db.list_queries_in_statuses(
(QueryStatus.QUEUED.value, QueryStatus.LEASED.value)
)
for row in rows:
# 整体 TTL 优先:单子已经没有意义了,再重投也只是白跑一趟
if parse_iso(row.created_at) + timedelta(seconds=self._query_ttl) < now:
await self._db.update_query(
row.query_id,
status=QueryStatus.EXPIRED.value,
error_code=None,
error_message=(
f"查询单超过 {self._query_ttl} 秒仍未完成"
f"(attempts={row.attempts},多半是本地 worker 不在线)"
),
updated_at=to_iso(now),
completed_at=to_iso(now),
clear_lease=True,
)
changed += 1
logger.warning("查询单过期:query_id=%s kind=%s", row.query_id, row.kind)
continue
if row.status != QueryStatus.LEASED.value:
continue
if not row.lease_expires_at or parse_iso(row.lease_expires_at) >= now:
continue
if row.attempts >= self._max_attempts:
await self._db.update_query(
row.query_id,
status=QueryStatus.FAILED.value,
error_message=(
f"已被领取 {row.attempts} 次仍未回结果,不再重投"
"(可能是站点页面卡住或本地 worker 反复崩溃)"
),
updated_at=to_iso(now),
completed_at=to_iso(now),
clear_lease=True,
)
logger.warning(
"查询单重投次数用尽,置 failed:query_id=%s attempts=%s",
row.query_id, row.attempts,
)
else:
# 只读操作可以安全重投——这正是查询与下单任务最大的语义差别
await self._db.update_query(
row.query_id,
status=QueryStatus.QUEUED.value,
updated_at=to_iso(now),
clear_lease=True,
)
logger.info(
"查询单租约过期,重投回队列:query_id=%s attempts=%s",
row.query_id, row.attempts,
)
changed += 1
if changed:
self._cond.notify_all()
return changed
# ---- 查询 ----
async def get_detail(self, query_id: str) -> QueryDetail:
row = await self._db.get_query(query_id)
if row is None:
raise QueryNotFoundError(query_id)
return _row_to_detail(row)
async def list_queries(
self, *, status: str | None, kind: str | None, limit: int, offset: int
) -> QueryListData:
rows, total = await self._db.list_queries(
status=status, kind=kind, limit=limit, offset=offset
)
return QueryListData(
items=[_row_to_detail(r) for r in rows], total=total, limit=limit, offset=offset
)
async def queued_count(self) -> int:
"""等待领取的查询单数量(/health 用)"""
return await self._db.count_queries_by_status(QueryStatus.QUEUED.value)