新增 §12 定时下派通道:网关自己定期派 order_list / order_detail 查询,把账号
真实订单沉淀进新表 account_orders(回写网关),上游可直接查 GET /api/account/orders
拿到账号里实际有哪些订单,不必自己记 order_number。
- collector.py: OrderDiscoveryCollector 常驻后台任务(与 sweep 并列)。每隔
account_discovery_interval_seconds 派 order_list,收割结果后把「编目里没有的
订单」upsert 进编目,再逐笔派 order_detail 沉淀 delivery_status/order_state。
状态无痕:不新增编排跟踪表,仅内存 _pending + _detail_dispatched_this_day;
detail query_id 按天分段(discover-detail-<order>-<日期>),同天幂等防重复派。
边界:collector 只消费 worker 结果的规范化字段,绝不解析 raw/raw_pages。
- db.py: account_orders 表 + AccountOrderRow + upsert/single/list/stale 访问;
upsert 以 order_number 为主键,重复采集只刷新,列表重扫不抹详情节点。
- 路由: GET /api/account/orders(列编目)、GET /api/account/orders/{n}(6005)、
POST /api/account/discovery/trigger(手动立即派一轮 list)。
- config: RAKUTEN_ACCOUNT_DISCOVERY_ENABLED / _INTERVAL_SECONDS / _MAX_PAGES /
RAKUTEN_ACCOUNT_DETAIL_REFRESH_SECONDS。
- 既有网关测试夹具统一关 discovery(会启动即派扫描污染查询队列语义),
新表测试在 tests/test_gateway_discovery.py(13 用例,真实 QueryQueue+DB 装配)。
472 tests passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
339 lines
8.9 KiB
Python
339 lines
8.9 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
|
|
|
|
|
|
# ---- 定时下派通道:账号订单编目(docs/order-gateway.md §12)----
|
|
|
|
|
|
class CatalogedOrder(BaseModel):
|
|
"""account_orders 编目的单笔订单
|
|
|
|
这是网关 collector 收到 worker 回结果后**自己缩写出来的目录行**(只读规范化
|
|
字段:订单号/店铺/日期/配送状态),不是查询单 result 的原样透传——`params`
|
|
与 `result` 仍保持「网关不解释内容」,唯独 collector 消费的是 worker 那边
|
|
已经规范化了的稳定契约字段(见 §12 说明)。
|
|
"""
|
|
|
|
order_number: str
|
|
shop_id: str | None = None
|
|
shop_name: str = ""
|
|
order_date: str | None = None
|
|
delivery_status: str | None = None
|
|
order_state: str | None = None
|
|
discovered_at: str
|
|
detail_fetched_at: str | None = None
|
|
last_seen_at: str
|
|
|
|
|
|
class CatalogOrderListData(BaseModel):
|
|
"""编目订单列表(GET /api/account/orders)"""
|
|
|
|
items: list[CatalogedOrder]
|
|
total: int
|
|
limit: int
|
|
offset: int
|
|
|
|
|
|
class TriggerDiscoveryData(BaseModel):
|
|
"""手动触发一轮下派的响应"""
|
|
|
|
query_id: str # 本轮 order_list 下派的单号
|
|
status: QueryStatus
|
|
created: bool # 本轮扫描是否新建了一张 order_list 单(False=已有一张在飞/已入队)
|
|
|
|
|
|
# ---- 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)
|