Files
rakuten-api/app/gateway/models.py
T
q792602257andClaude Opus 5 c03158488b 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>
2026-08-16 22:46:01 +08:00

299 lines
7.6 KiB
Python

"""网关 API 数据模型:请求体与响应体
intent 字段刻意保留成 `dict[str, Any]`——网关不解释下单意图,结构由 trading 侧
定义。网关只负责把它存下来、原样吐给 worker,避免业务规则悄悄渗进任务队列。
账号只读查询的 `params` / `result` 同理。
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
from app.shared.task_state import AccountQueryKind, OrderState, QueryStatus, TaskStatus
# ---- POST /api/orders ----
class SubmitOrderRequest(BaseModel):
"""上游提交下单意图
task_id 可选:上游自带的幂等键。不传则服务端生成。同一个 task_id 重复提交
不新建任务,返回既有任务且 created=false。
"""
task_id: str | None = None
site: str
intent: dict[str, Any]
class SubmitOrderData(BaseModel):
"""提交响应"""
task_id: str
status: TaskStatus
created: bool # True=本次新建,False=命中既有任务(幂等)
# ---- GET /api/orders/lease ----
class LeaseData(BaseModel):
"""lease 响应
无任务可领时整个 data 为 null(HTTP 仍 200)。lease_count > 1 表示这是恢复
领取(stale → reclaim),worker 必须先核对站点订单列表,见 runner 主循环。
"""
task_id: str
site: str
intent: dict[str, Any]
lease_expires_at: str # ISO8601 UTC
lease_count: int
known_state: OrderState | None # 之前上报过的最新订单状态;首次领取为 null
# ---- POST /api/orders/{id}/renew ----
class RenewRequest(BaseModel):
"""续租请求"""
worker_id: str
class RenewData(BaseModel):
"""续租响应"""
task_id: str
lease_expires_at: str
lease_count: int
# ---- POST /api/orders/{id}/report ----
class ReportRequest(BaseModel):
"""本地回报订单状态
同一 (task_id, state) 重复上报是幂等的——网络抖动导致 worker 重发时覆盖同一行,
不产生第二条记录。terminal=true 时释放租约并把任务推到终态。
"""
worker_id: str
state: OrderState
payable_yen: int | None = None
pay_deadline: str | None = None # ISO8601
site_order_id: str | None = None
evidence_ref: str | None = None # 本地相对路径,不含扩展名
detail: str = ""
terminal: bool = False
terminal_status: TaskStatus | None = None # terminal=true 时指定终态,缺省按 state 推断
class ReportData(BaseModel):
"""回报响应"""
task_id: str
status: TaskStatus
recorded: bool # False=同 (task_id, state) 已存在,本次为幂等覆盖;True=新写入
# ---- POST /api/orders/{id}/reclaim ----
class ReclaimRequest(BaseModel):
"""把 stale 任务重新租给 worker"""
worker_id: str
class ReclaimData(BaseModel):
"""reclaim 响应,结构与 LeaseData 一致,但 lease_count 必然 > 1"""
task_id: str
site: str
intent: dict[str, Any]
lease_expires_at: str
lease_count: int
known_state: OrderState | None
# ---- GET /api/orders/{id} 与 GET /api/orders ----
class ReportEntry(BaseModel):
"""task_reports 单行"""
state: OrderState
payable_yen: int | None = None
pay_deadline: str | None = None
site_order_id: str | None = None
evidence_ref: str | None = None
detail: str = ""
reported_at: str
class TaskDetail(BaseModel):
"""单个任务详情"""
task_id: str
site: str
intent: dict[str, Any]
status: TaskStatus
lease_owner: str | None = None
lease_expires_at: str | None = None
lease_count: int = 0
created_at: str
updated_at: str
latest_state: OrderState | None = None
reports: list[ReportEntry] = Field(default_factory=list)
class TaskListData(BaseModel):
"""任务列表"""
items: list[TaskDetail]
total: int
limit: int
offset: int
# ---- 账号只读查询通道(见 docs/order-gateway.md §11)----
class SubmitQueryRequest(BaseModel):
"""上游提交一张账号只读查询单
query_id 语义同 task_id:上游自带的幂等键,重复提交返回既有单。
params 由网关原样透传给 worker,结构由 trading 侧定义(见 §11.2):
- order_list:`{"since": ISO8601?, "max_pages": int?}`
- order_detail:`{"order_number": "306087-20260813-0863947697"}`
"""
query_id: str | None = None
site: str = "rakuten"
kind: AccountQueryKind
params: dict[str, Any] = Field(default_factory=dict)
class SubmitQueryData(BaseModel):
"""提交响应。纯异步:这里只拿到单号,结果去 GET /api/account/queries/{id} 取"""
query_id: str
status: QueryStatus
created: bool # True=本次新建,False=命中既有单(幂等)
class QueryLeaseData(BaseModel):
"""GET /api/account/queries/lease 的响应
无可领查询单时整个 data 为 null(HTTP 仍 200)。没有 known_state 之类的字段——
查询是无状态的一次性动作,attempt > 1 只是说明上一轮超时被重投了,worker
不需要为此改变行为(只读,重跑安全)。
"""
query_id: str
site: str
kind: AccountQueryKind
params: dict[str, Any]
lease_expires_at: str # ISO8601 UTC
attempt: int
class QueryResultRequest(BaseModel):
"""worker 回报查询结果
success=true 时 result 必填;false 时 error_message 必填、error_code 可选
(沿用 shared.errors 的错误码,便于上游按同一张表分支)。
"""
worker_id: str
success: bool
result: dict[str, Any] | None = None
error_code: int | None = None
error_message: str = ""
class QueryResultData(BaseModel):
"""回报响应"""
query_id: str
status: QueryStatus
class QueryError(BaseModel):
"""查询失败的原因"""
code: int | None = None
message: str = ""
class QueryDetail(BaseModel):
"""单张查询单的完整视图
result 是 worker 回的原文(站点原始 JSON + 规范化字段),网关不解释内容。
"""
query_id: str
site: str
kind: AccountQueryKind
params: dict[str, Any]
status: QueryStatus
lease_owner: str | None = None
lease_expires_at: str | None = None
attempts: int = 0
created_at: str
updated_at: str
completed_at: str | None = None
result: dict[str, Any] | None = None
error: QueryError | None = None
class QueryListData(BaseModel):
"""查询单列表(运维排查用)"""
items: list[QueryDetail]
total: int
limit: int
offset: int
# ---- GET /health ----
class WorkerHealthEntry(BaseModel):
"""单个 worker 的健康指标"""
worker_id: str
last_seen_at: str
last_seen_seconds: int
class QueuedAlertEntry(BaseModel):
"""长时间无人领的任务告警项"""
task_id: str
site: str
created_at: str
age_seconds: int
class GatewayHealthData(BaseModel):
"""网关健康状态
只做规格 §4.7 的两条兜底告警:worker 失联、任务长时间无人领。付款期限监控
不在网关——本地机 7×24 在线,那套逻辑放本地。
"""
status: str # "ok" 或 "degraded"
queued_count: int
# 等待本地 worker 领取的账号只读查询单数量。查询没有「无人领即告警」这条
# 规则(它有自己的 TTL 会自动 expired),这里只是给运维一个可见的积压信号。
queued_query_count: int = 0
active_tasks: list[TaskDetail] = Field(default_factory=list)
workers: list[WorkerHealthEntry] = Field(default_factory=list)
offline_workers: list[WorkerHealthEntry] = Field(default_factory=list)
stale_queued_tasks: list[QueuedAlertEntry] = Field(default_factory=list)