上游要的不只是网关记的任务状态镜像,还有「已登录账号在站点上的真实订单」,
但账号只在 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>
146 lines
5.3 KiB
Python
146 lines
5.3 KiB
Python
"""账号只读查询通道路由(docs/order-gateway.md §11)
|
|
|
|
上游要的是「已登录账号在站点上的真实订单」,而不是网关自己记录的任务状态镜像;
|
|
但本地机在 NAT 后没有公网入口,网关推不进去。所以这条通道与下单任务同构:
|
|
上游把查询意图放进队列,本地 worker 出站长轮询领走、去站点上真读一次、回结果。
|
|
|
|
- POST /api/account/queries 上游提交查询单(幂等)
|
|
- GET /api/account/queries/lease 本地长轮询领取
|
|
- POST /api/account/queries/{id}/result 本地回结果
|
|
- GET /api/account/queries/{id} 取查询单状态与结果
|
|
- GET /api/account/queries 查询单列表(运维排查)
|
|
|
|
**纯异步**:提交只拿 query_id,结果去 GET 取。网关不提供「挂起等结果」的接口——
|
|
下单任务可能占着账号锁跑好几分钟,同步等待会把上游的连接一起卡住。
|
|
|
|
注意路由声明顺序:`/lease` 必须在 `/{query_id}` 之前,否则 lease 会被当成 query_id。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
|
|
from app.gateway.container import GatewayContainer
|
|
from app.gateway.models import (
|
|
QueryDetail,
|
|
QueryLeaseData,
|
|
QueryListData,
|
|
QueryResultData,
|
|
QueryResultRequest,
|
|
SubmitQueryData,
|
|
SubmitQueryRequest,
|
|
)
|
|
from app.shared.api import ApiResponse, get_container, require_bearer_token
|
|
|
|
router = APIRouter(prefix="/api/account/queries", tags=["account-queries"])
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=ApiResponse[SubmitQueryData],
|
|
dependencies=[Depends(require_bearer_token)],
|
|
)
|
|
async def submit_query(
|
|
payload: SubmitQueryRequest,
|
|
container: GatewayContainer = Depends(get_container),
|
|
) -> ApiResponse[SubmitQueryData]:
|
|
"""提交一张账号只读查询单
|
|
|
|
重复提交同一 query_id 不新建,返回既有单且 created=false。查询是只读的,
|
|
重复提交本身无害,幂等键的意义在于上游重发时不会拿到两个单号。
|
|
"""
|
|
data = await container.query_queue.submit(
|
|
query_id=payload.query_id,
|
|
site=payload.site,
|
|
kind=payload.kind.value,
|
|
params=payload.params,
|
|
)
|
|
return ApiResponse[SubmitQueryData](success=True, msg="success", data=data, code=0)
|
|
|
|
|
|
@router.get(
|
|
"/lease",
|
|
response_model=ApiResponse[QueryLeaseData | None],
|
|
dependencies=[Depends(require_bearer_token)],
|
|
)
|
|
async def lease_query(
|
|
worker_id: str = Query(...),
|
|
wait: int = Query(default=30, ge=0, le=300),
|
|
site: str | None = Query(default=None),
|
|
container: GatewayContainer = Depends(get_container),
|
|
) -> ApiResponse[QueryLeaseData | None]:
|
|
"""本地长轮询领取查询单
|
|
|
|
无单可领时挂起到 wait 秒后返回 data: null,HTTP 仍 200。与下单 lease 不同,
|
|
这里没有「已有任务在执行就一律返回空」的闸门。
|
|
"""
|
|
data = await container.query_queue.lease(
|
|
worker_id=worker_id,
|
|
wait=wait,
|
|
site=site,
|
|
max_wait=container.settings.lease_max_wait_seconds,
|
|
)
|
|
return ApiResponse[QueryLeaseData | None](success=True, msg="success", data=data, code=0)
|
|
|
|
|
|
@router.post(
|
|
"/{query_id}/result",
|
|
response_model=ApiResponse[QueryResultData],
|
|
dependencies=[Depends(require_bearer_token)],
|
|
)
|
|
async def submit_query_result(
|
|
query_id: str,
|
|
payload: QueryResultRequest,
|
|
container: GatewayContainer = Depends(get_container),
|
|
) -> ApiResponse[QueryResultData]:
|
|
"""本地回报查询结果
|
|
|
|
租约不是自己的(多半是超时后被重投给了下一轮)时报 6006 并丢弃本次结果,
|
|
避免迟到的旧结果覆盖新结果。
|
|
"""
|
|
data = await container.query_queue.submit_result(
|
|
query_id,
|
|
worker_id=payload.worker_id,
|
|
success=payload.success,
|
|
result=payload.result,
|
|
error_code=payload.error_code,
|
|
error_message=payload.error_message,
|
|
)
|
|
return ApiResponse[QueryResultData](success=True, msg="success", data=data, code=0)
|
|
|
|
|
|
@router.get(
|
|
"/{query_id}",
|
|
response_model=ApiResponse[QueryDetail],
|
|
dependencies=[Depends(require_bearer_token)],
|
|
)
|
|
async def get_query(
|
|
query_id: str,
|
|
container: GatewayContainer = Depends(get_container),
|
|
) -> ApiResponse[QueryDetail]:
|
|
"""取查询单状态与结果
|
|
|
|
status=succeeded 时 result 里是 worker 从站点读到的原文 + 规范化字段;
|
|
failed/expired 时看 error。仍在 queued/leased 就是还没读到,稍后再来。
|
|
"""
|
|
data = await container.query_queue.get_detail(query_id)
|
|
return ApiResponse[QueryDetail](success=True, msg="success", data=data, code=0)
|
|
|
|
|
|
@router.get(
|
|
"",
|
|
response_model=ApiResponse[QueryListData],
|
|
dependencies=[Depends(require_bearer_token)],
|
|
)
|
|
async def list_queries(
|
|
status: str | None = Query(default=None),
|
|
kind: str | None = Query(default=None),
|
|
limit: int = Query(default=50, ge=1, le=500),
|
|
offset: int = Query(default=0, ge=0),
|
|
container: GatewayContainer = Depends(get_container),
|
|
) -> ApiResponse[QueryListData]:
|
|
"""查询单列表,按创建时间倒序(运维排查用)"""
|
|
data = await container.query_queue.list_queries(
|
|
status=status, kind=kind, limit=limit, offset=offset
|
|
)
|
|
return ApiResponse[QueryListData](success=True, msg="success", data=data, code=0)
|