"""网关 API 数据模型:请求体与响应体 intent 字段刻意保留成 `dict[str, Any]`——网关不解释下单意图,结构由 trading 侧 定义。网关只负责把它存下来、原样吐给 worker,避免业务规则悄悄渗进任务队列。 账号只读查询的 `params` / `result` 同理。 """ from __future__ import annotations from typing import Any from urllib.parse import urlsplit from pydantic import BaseModel, Field, field_validator 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 = Field( default=None, description="幂等键。不传则服务端生成;同一 task_id 重复提交不新建任务," "返回既有任务(created=false)——上游重发不会变成两单", examples=["po-20260727-0001"], ) site: str = Field( description="站点标识。交易服务只覆盖乐天市场,固定 rakuten", examples=["rakuten"], ) intent: dict[str, Any] = Field( description=( "下单意图原文。网关不解释内容,原样存库并透传给本地 worker,结构由 trading 侧定义:" "item_url(必填,商品页 URL);quantity(可选,默认 1);" "variant_id(多规格商品必填,取自 /api/item_detail 的 variants,不传时 worker 自动选第一个非售罄规格);" "choice(可选,商品选项,如 \"颜色:赤\",可传字符串或字符串列表);" "max_total_yen(可选,本次金额上限:确认页实际应付超过即中止并报 needs_human," "缺省用服务端 RAKUTEN_ORDER_MAX_TOTAL_YEN)" ), examples=[{ "item_url": "https://item.rakuten.co.jp/shop/code/", "quantity": 1, "variant_id": "1001", "max_total_yen": 30000, }], ) callback_url: str | None = Field( default=None, description="终结类事件的异步通知地址(http/https)。任务到达终态(succeeded / " "failed / needs_human)或被置 stale 时,网关向该地址 POST 一条 JSON 通知," "上游可免去轮询。注意:幂等重发(created=false)不会更新既有任务的回调地址", examples=["https://upstream.example.com/hooks/rakuten-order"], ) @field_validator("callback_url") @classmethod def _validate_callback_url(cls, value: str | None) -> str | None: """只接受 http/https 且带 host 的绝对 URL,其余当场 422""" if value is None: return None parts = urlsplit(value) if parts.scheme not in ("http", "https") or not parts.netloc: raise ValueError("callback_url 必须是 http/https 绝对 URL") return value class SubmitOrderData(BaseModel): """提交响应""" task_id: str = Field(description="任务 ID(幂等键),后续查询与 worker 回报都用它") status: TaskStatus = Field(description="任务状态,新建恒为 queued") created: bool = Field(description="True=本次新建,False=命中既有任务(幂等重发)") # ---- GET /api/orders/lease ---- class LeaseData(BaseModel): """lease 响应 无任务可领时整个 data 为 null(HTTP 仍 200)。lease_count > 1 表示这是恢复 领取(stale → reclaim),worker 必须先核对站点订单列表,见 runner 主循环。 """ task_id: str = Field(description="任务 ID(幂等键)") site: str = Field(description="站点标识(rakuten)") intent: dict[str, Any] = Field(description="下单意图原文,与提交时一致") lease_expires_at: str = Field( description="租约过期时间(ISO8601 UTC)。TTL 由 RAKUTEN_LEASE_TTL_SECONDS 控制" "(默认 300 秒);执行中每 60 秒调 renew 续租" ) lease_count: int = Field( description="该任务被领取过的次数。>1 表示恢复领取(stale → reclaim):worker 执行前" "必须先核对站点订单列表确认这单下没下,核对不出结论报 needs_human,绝不直接重新下单" ) known_state: OrderState | None = Field( description="之前上报过的最新订单状态;首次领取为 null。恢复领取时是核对的重要线索" ) # ---- POST /api/orders/{id}/renew ---- class RenewRequest(BaseModel): """续租请求""" worker_id: str = Field(description="worker 标识,必须是当前租约持有者,否则 6002") class RenewData(BaseModel): """续租响应""" task_id: str = Field(description="任务 ID") lease_expires_at: str = Field(description="续租后的新过期时间(ISO8601 UTC)") lease_count: int = Field(description="领取次数,续租不改变") # ---- POST /api/orders/{id}/report ---- class ReportRequest(BaseModel): """本地回报订单状态 同一 (task_id, state) 重复上报是幂等的——网络抖动导致 worker 重发时覆盖同一行, 不产生第二条记录。terminal=true 时释放租约并把任务推到终态。 """ worker_id: str = Field(description="worker 标识,必须是当前租约持有者,否则 6002") state: OrderState = Field( description="本次上报的订单状态:created / in_cart / ordered / awaiting_payment / " "paid / shipped / delivered / cancelled" ) payable_yen: int | None = Field(default=None, description="实际应付金额(日元),进入确认页后上报") pay_deadline: str | None = Field(default=None, description="付款期限(ISO8601),コンビニ払い 常见三天") site_order_id: str | None = Field(default=None, description="站点侧订单号(注文番号),下单成功后上报") evidence_ref: str | None = Field( default=None, description="本地证据相对路径,如 po-20260727-0001/03-order-confirm(不含扩展名)。" "证据原文(HTML/截图)留在本地,不回传", ) detail: str = Field(default="", description="补充说明,如付款方式与单号、失败原因") terminal: bool = Field( default=False, description="True 表示任务到此结束:释放租约并推到终态。缺省按 state 推断终态:" "paid→succeeded,cancelled→failed,其余(如 awaiting_payment 搁置)→needs_human", ) terminal_status: TaskStatus | None = Field( default=None, description="terminal=true 时显式指定终态(succeeded / failed / needs_human),缺省按 state 推断", ) class ReportData(BaseModel): """回报响应""" task_id: str = Field(description="任务 ID") status: TaskStatus = Field(description="回报后的任务状态") recorded: bool = Field( description="True=新写入一条状态记录;False=同 (task_id, state) 已存在,本次为幂等覆盖" ) # ---- POST /api/orders/{id}/reclaim ---- class ReclaimRequest(BaseModel): """把 stale 任务重新租给 worker""" worker_id: str = Field( description="worker 标识。reclaim 是显式恢复动作——正常 lease 永远拿不到 stale 任务" ) class ReclaimData(BaseModel): """reclaim 响应,结构与 LeaseData 一致,但 lease_count 必然 > 1""" task_id: str = Field(description="任务 ID(幂等键)") site: str = Field(description="站点标识(rakuten)") intent: dict[str, Any] = Field(description="下单意图原文,与提交时一致") lease_expires_at: str = Field(description="新租约的过期时间(ISO8601 UTC)") lease_count: int = Field( description="必然 > 1:worker 执行前必须先核对站点订单列表,确认这单到底下没下" ) known_state: OrderState | None = Field( description="租约丢失前上报过的最新订单状态,恢复核对时的重要线索" ) # ---- GET /api/orders/{id} 与 GET /api/orders ---- class ReportEntry(BaseModel): """task_reports 单行:一次状态上报(append-only,最新一条即当前状态)""" state: OrderState = Field(description="上报的订单状态") payable_yen: int | None = Field(default=None, description="实际应付金额(日元)") pay_deadline: str | None = Field(default=None, description="付款期限(ISO8601)") site_order_id: str | None = Field(default=None, description="站点侧订单号(注文番号)") evidence_ref: str | None = Field(default=None, description="本地证据相对路径(不含扩展名)") detail: str = Field(default="", description="补充说明") reported_at: str = Field(description="上报时间(ISO8601 UTC)") class TaskDetail(BaseModel): """单个任务详情""" task_id: str = Field(description="任务 ID(幂等键)") site: str = Field(description="站点标识(rakuten)") intent: dict[str, Any] = Field(description="下单意图原文") callback_url: str | None = Field( default=None, description="提交时登记的终结类事件通知地址;未登记为 null" ) status: TaskStatus = Field( description="任务状态:queued / leased / running / succeeded / failed / needs_human / stale" ) lease_owner: str | None = Field(default=None, description="当前租约持有者(worker_id);无租约为 null") lease_expires_at: str | None = Field(default=None, description="租约过期时间(ISO8601 UTC);无租约为 null") lease_count: int = Field(default=0, description="被领取过的次数,>1 即发生过恢复") created_at: str = Field(description="创建时间(ISO8601 UTC)") updated_at: str = Field(description="最近更新时间(ISO8601 UTC)") latest_state: OrderState | None = Field(default=None, description="最新一次上报的订单状态;从未上报为 null") reports: list[ReportEntry] = Field(default_factory=list, description="完整状态历史(append-only)") class TaskListData(BaseModel): """任务列表""" items: list[TaskDetail] = Field(description="任务详情列表,按创建时间倒序") total: int = Field(description="符合筛选条件的总条数") limit: int = Field(description="本次分页大小") offset: int = Field(description="本次分页偏移") # ---- 账号只读查询通道(见 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 = Field( default=None, description="幂等键。不传则服务端生成;同一 query_id 重复提交返回既有单(created=false)", examples=["aq-20260816-0001"], ) site: str = Field(default="rakuten", description="站点标识,固定 rakuten") kind: AccountQueryKind = Field( description="查询类型:order_list=按时间窗口翻页扫描订单列表;" "order_detail=读单笔注文番号的详情(配送阶段等)" ) params: dict[str, Any] = Field( default_factory=dict, description=( "查询参数,网关原样透传给 worker。order_list:since(可选,ISO8601 窗口下界," "缺省不设下界)、max_pages(可选,1..20,缺省用服务端 " "RAKUTEN_ACCOUNT_QUERY_DEFAULT_MAX_PAGES=3);" "order_detail:order_number(必填,站点注文番号)" ), examples=[{"order_number": "306087-20260813-0863947697"}], ) class SubmitQueryData(BaseModel): """提交响应。纯异步:这里只拿到单号,结果去 GET /api/account/queries/{id} 取""" query_id: str = Field(description="查询单 ID(幂等键),取结果时用它") status: QueryStatus = Field(description="查询单状态,新建恒为 queued") created: bool = Field(description="True=本次新建,False=命中既有单(幂等重发)") class QueryLeaseData(BaseModel): """GET /api/account/queries/lease 的响应 无可领查询单时整个 data 为 null(HTTP 仍 200)。没有 known_state 之类的字段—— 查询是无状态的一次性动作,attempt > 1 只是说明上一轮超时被重投了,worker 不需要为此改变行为(只读,重跑安全)。 """ query_id: str = Field(description="查询单 ID") site: str = Field(description="站点标识(rakuten)") kind: AccountQueryKind = Field(description="查询类型(order_list / order_detail)") params: dict[str, Any] = Field(description="查询参数原文,与提交时一致") lease_expires_at: str = Field( description="租约过期时间(ISO8601 UTC)。TTL 由 RAKUTEN_QUERY_LEASE_TTL_SECONDS 控制" "(默认 180 秒),过期自动重投" ) attempt: int = Field(description="第几次被领取。>1 表示上一轮超时被重投(只读,重跑安全)") class QueryResultRequest(BaseModel): """worker 回报查询结果 success=true 时 result 必填;false 时 error_message 必填、error_code 可选 (沿用 shared.errors 的错误码,便于上游按同一张表分支)。 """ worker_id: str = Field(description="worker 标识,必须是当前租约持有者,否则 6006") success: bool = Field(description="站点读取是否成功") result: dict[str, Any] | None = Field( default=None, description="success=true 时必填:规范化字段 + 站点原始 JSON,结构按 kind 见 " "GET /api/account/queries/{id} 的说明。体积上限 RAKUTEN_QUERY_RESULT_MAX_BYTES(默认 1MB)", ) error_code: int | None = Field( default=None, description="success=false 时的错误码,沿用统一错误码表(如 5001 掉登录)" ) error_message: str = Field(default="", description="success=false 时必填,失败原因") class QueryResultData(BaseModel): """回报响应""" query_id: str = Field(description="查询单 ID") status: QueryStatus = Field(description="回报后的查询单状态(succeeded / failed)") class QueryError(BaseModel): """查询失败的原因""" code: int | None = Field(default=None, description="错误码(沿用统一错误码表),可能为 null") message: str = Field(default="", description="失败原因") class QueryDetail(BaseModel): """单张查询单的完整视图 result 是 worker 回的原文(站点原始 JSON + 规范化字段),网关不解释内容。 """ query_id: str = Field(description="查询单 ID(幂等键)") site: str = Field(description="站点标识(rakuten)") kind: AccountQueryKind = Field(description="查询类型(order_list / order_detail)") params: dict[str, Any] = Field(description="查询参数原文") status: QueryStatus = Field( description="查询单状态:queued / leased / succeeded / failed / expired" ) lease_owner: str | None = Field(default=None, description="当前租约持有者(worker_id);无租约为 null") lease_expires_at: str | None = Field(default=None, description="租约过期时间(ISO8601 UTC);无租约为 null") attempts: int = Field(default=0, description="已被领取的次数,用尽(默认 3 次)置 failed") created_at: str = Field(description="创建时间(ISO8601 UTC)") updated_at: str = Field(description="最近更新时间(ISO8601 UTC)") completed_at: str | None = Field(default=None, description="到达终态的时间(ISO8601 UTC);未终结为 null") result: dict[str, Any] | None = Field( default=None, description="status=succeeded 时的结果:规范化字段 + 站点原始 JSON,结构按 kind " "见接口说明;未成功为 null", ) error: QueryError | None = Field(default=None, description="status=failed/expired 时的失败原因;否则为 null") class QueryListData(BaseModel): """查询单列表(运维排查用)""" items: list[QueryDetail] = Field(description="查询单详情列表,按创建时间倒序") total: int = Field(description="符合筛选条件的总条数") limit: int = Field(description="本次分页大小") offset: int = Field(description="本次分页偏移") # ---- 定时下派通道:账号订单编目(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)