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
+227 -4
View File
@@ -1,10 +1,11 @@
"""网关 SQLite 访问层:张表 + 原生 SQL,无业务逻辑
"""网关 SQLite 访问层:张表 + 原生 SQL,无业务逻辑
业务规则(状态机、并发度、幂等)放在 `task_queue.py`,本模块只负责持久化与查询,
业务规则(状态机、并发度、幂等)放在 `task_queue.py`(下单任务)与
`query_queue.py`(账号只读查询),本模块只负责持久化与查询,
返回 dataclass 行。所有时间戳以 ISO8601 UTC 字符串存储(以 `Z` 结尾),便于
跨进程对账;时间戳运算在 task_queue 层完成。
跨进程对账;时间戳运算在层完成。
写操作由 task_queue 的 asyncio.Lock 串行化(详见模块),本层不重复加锁,
写操作由上层的 asyncio.Lock 串行化(详见对应模块),本层不重复加锁,
因此**调用方必须确保写操作在外层锁的保护下进行**。
"""
from __future__ import annotations
@@ -49,6 +50,27 @@ CREATE TABLE IF NOT EXISTS workers (
worker_id TEXT PRIMARY KEY,
last_seen_at TEXT NOT NULL
);
-- 账号只读查询单(见 docs/order-gateway.md §11)。与 tasks 分表,不是洁癖:
-- 两者的安全约束相反(只读可重投 vs 写操作绝不重投),共表会让「租约过期怎么办」
-- 这个最关键的分支变成一个 if,迟早被改错。
CREATE TABLE IF NOT EXISTS account_queries (
query_id TEXT PRIMARY KEY, -- 幂等键,语义同 task_id
site TEXT NOT NULL,
kind TEXT NOT NULL, -- order_list / order_detail
params_json TEXT NOT NULL, -- 查询参数原文,网关不解释内容
status TEXT NOT NULL, -- 见 shared.task_state.QueryStatus
lease_owner TEXT,
lease_expires_at TEXT,
attempts INTEGER NOT NULL DEFAULT 0, -- 被领取过几次
result_json TEXT, -- worker 回的结果原文
error_code INTEGER,
error_message TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_queries_pending ON account_queries(status, created_at);
"""
@@ -93,6 +115,34 @@ class WorkerRow:
last_seen_at: str
@dataclass(slots=True)
class QueryRow:
"""account_queries 表一行的强类型视图"""
query_id: str
site: str
kind: str
params_json: str
status: str
lease_owner: str | None
lease_expires_at: str | None
attempts: int
result_json: str | None
error_code: int | None
error_message: str | None
created_at: str
updated_at: str
completed_at: str | None
@property
def params(self) -> dict[str, Any]:
return json.loads(self.params_json)
@property
def result(self) -> dict[str, Any] | None:
return json.loads(self.result_json) if self.result_json else None
def _row_to_task(row: aiosqlite.Row) -> TaskRow:
return TaskRow(
task_id=row["task_id"],
@@ -124,6 +174,25 @@ def _row_to_worker(row: aiosqlite.Row) -> WorkerRow:
return WorkerRow(worker_id=row["worker_id"], last_seen_at=row["last_seen_at"])
def _row_to_query(row: aiosqlite.Row) -> QueryRow:
return QueryRow(
query_id=row["query_id"],
site=row["site"],
kind=row["kind"],
params_json=row["params_json"],
status=row["status"],
lease_owner=row["lease_owner"],
lease_expires_at=row["lease_expires_at"],
attempts=row["attempts"],
result_json=row["result_json"],
error_code=row["error_code"],
error_message=row["error_message"],
created_at=row["created_at"],
updated_at=row["updated_at"],
completed_at=row["completed_at"],
)
class GatewayDB:
"""网关 SQLite 访问对象
@@ -354,3 +423,157 @@ class GatewayDB:
(status,),
) as cur:
return (await cur.fetchone())[0]
# ---- account_queries ----
async def get_query(self, query_id: str) -> QueryRow | None:
async with self.conn.execute(
"SELECT * FROM account_queries WHERE query_id = ?", (query_id,)
) as cur:
row = await cur.fetchone()
return _row_to_query(row) if row else None
async def insert_query(self, row: QueryRow) -> bool:
"""插入新查询单。返回 True=新建,False=query_id 已存在(幂等命中)"""
try:
await self.conn.execute(
"INSERT INTO account_queries (query_id, site, kind, params_json, status, "
"lease_owner, lease_expires_at, attempts, result_json, error_code, "
"error_message, created_at, updated_at, completed_at) "
"VALUES (?, ?, ?, ?, ?, NULL, NULL, 0, NULL, NULL, NULL, ?, ?, NULL)",
(
row.query_id,
row.site,
row.kind,
row.params_json,
row.status,
row.created_at,
row.updated_at,
),
)
await self.conn.commit()
return True
except aiosqlite.IntegrityError:
return False
async def update_query(
self,
query_id: str,
*,
status: str | None = None,
lease_owner: str | None = None,
lease_expires_at: str | None = None,
attempts: int | None = None,
result_json: str | None = None,
error_code: int | None = None,
error_message: str | None = None,
updated_at: str | None = None,
completed_at: str | None = None,
clear_lease: bool = False,
) -> None:
"""更新查询单字段。clear_lease=True 时把 lease_owner/expires_at 置 NULL"""
sets: list[str] = []
params: list[Any] = []
for column, value in (
("status", status),
("lease_owner", lease_owner),
("lease_expires_at", lease_expires_at),
("attempts", attempts),
("result_json", result_json),
("error_code", error_code),
("error_message", error_message),
("updated_at", updated_at),
("completed_at", completed_at),
):
if value is not None:
sets.append(f"{column} = ?")
params.append(value)
if clear_lease:
sets.append("lease_owner = NULL")
sets.append("lease_expires_at = NULL")
if not sets:
return
params.append(query_id)
await self.conn.execute(
f"UPDATE account_queries SET {', '.join(sets)} WHERE query_id = ?",
params,
)
await self.conn.commit()
async def pick_queued_query(self, site: str | None) -> QueryRow | None:
"""取最早的 queued 查询单,可选按站点过滤"""
if site:
sql = (
"SELECT * FROM account_queries WHERE status = ? AND site = ? "
"ORDER BY created_at ASC LIMIT 1"
)
params: tuple[Any, ...] = ("queued", site)
else:
sql = "SELECT * FROM account_queries WHERE status = ? ORDER BY created_at ASC LIMIT 1"
params = ("queued",)
async with self.conn.execute(sql, params) as cur:
row = await cur.fetchone()
return _row_to_query(row) if row else None
async def list_queries(
self,
*,
status: str | None = None,
kind: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[QueryRow], int]:
"""分页列出查询单,按 created_at 降序(查询是即时行为,最近的先看)"""
where = []
params: list[Any] = []
if status:
where.append("status = ?")
params.append(status)
if kind:
where.append("kind = ?")
params.append(kind)
clause = f"WHERE {' AND '.join(where)}" if where else ""
async with self.conn.execute(
f"SELECT COUNT(*) FROM account_queries {clause}", params
) as cur:
total = (await cur.fetchone())[0]
sql = (
f"SELECT * FROM account_queries {clause} "
"ORDER BY created_at DESC LIMIT ? OFFSET ?"
)
async with self.conn.execute(sql, [*params, limit, offset]) as cur:
rows = await cur.fetchall()
return [_row_to_query(r) for r in rows], total
async def list_queries_in_statuses(self, statuses: tuple[str, ...]) -> list[QueryRow]:
"""取处于给定状态集合的全部查询单(sweep 与健康检查用)"""
if not statuses:
return []
placeholders = ", ".join("?" for _ in statuses)
sql = (
f"SELECT * FROM account_queries WHERE status IN ({placeholders}) "
"ORDER BY created_at ASC"
)
async with self.conn.execute(sql, statuses) as cur:
rows = await cur.fetchall()
return [_row_to_query(r) for r in rows]
async def count_queries_by_status(self, status: str) -> int:
async with self.conn.execute(
"SELECT COUNT(*) FROM account_queries WHERE status = ?", (status,)
) as cur:
return (await cur.fetchone())[0]
async def delete_queries_completed_before(self, cutoff: str) -> int:
"""清理 completed_at 早于 cutoff 的终态查询单,返回删除条数
结果里带站点原始 JSON,不清会一直涨。只删已完成的,进行中的一律不动。
"""
cursor = await self.conn.execute(
"DELETE FROM account_queries WHERE completed_at IS NOT NULL AND completed_at < ?",
(cutoff,),
)
await self.conn.commit()
return cursor.rowcount or 0