"""网关 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)