"""网关 API 数据模型:请求体与响应体 intent 字段刻意保留成 `dict[str, Any]`——网关不解释下单意图,结构由 trading 侧 定义。网关只负责把它存下来、原样吐给 worker,避免业务规则悄悄渗进任务队列。 账号只读查询的 `params` / `result` 同理。 """ from __future__ import annotations from typing import Annotated, Any from urllib.parse import urlsplit from pydantic import BaseModel, Field, WithJsonSchema, field_validator from app.shared.task_state import AccountQueryKind, OrderState, QueryStatus, TaskStatus # ---- POST /api/orders ---- # The gateway intentionally keeps intent open-ended at runtime. This schema documents # the stable trading fields without preventing future fields from being passed through. OrderIntent = Annotated[ dict[str, Any], WithJsonSchema( { "type": "object", "additionalProperties": True, "description": ( "下单意图。推荐使用 items;旧版 item_url 等同级字段继续兼容。" ), "properties": { "items": { "type": "array", "minItems": 1, "description": "本次购买的商品列表,按顺序加入同一购物车", "items": { "oneOf": [ { "type": "object", "additionalProperties": True, "properties": { "item_url": { "type": "string", "description": "商品页 URL", }, "quantity": { "type": "integer", "minimum": 1, "default": 1, }, "variant_id": {"type": "string"}, "choice": { "oneOf": [ {"type": "string"}, { "type": "array", "items": {"type": "string"}, }, ] }, }, "required": ["item_url"], }, { "type": "string", "description": "商品页 URL(简写)", }, ] }, }, "item_url": { "type": "string", "description": "旧版单商品商品页 URL", }, "quantity": {"type": "integer", "minimum": 1, "default": 1}, "variant_id": {"type": "string"}, "choice": { "oneOf": [ {"type": "string"}, {"type": "array", "items": {"type": "string"}}, ] }, "max_total_yen": { "type": "integer", "description": "本次订单允许的最高应付金额(日元)", }, }, } ), ] 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: OrderIntent = Field( description=( "下单意图原文。网关不解释内容,原样存库并透传给本地 worker,结构由 trading 侧定义:" "推荐使用 items(非空数组,每项含 item_url,及可选 quantity/variant_id/choice);" "为兼容已发布客户端,也支持单商品 item_url(及同级 quantity/variant_id/choice);" "quantity(可选,默认 1);" "variant_id(多规格商品必填,取自 /api/item_detail 的 variants,不传时 worker 自动选第一个非售罄规格);" "choice(可选,商品选项,如 \"颜色:赤\",可传字符串或字符串列表);" "max_total_yen(可选,本次金额上限:确认页实际应付超过即中止并报 needs_human," "缺省用服务端 RAKUTEN_ORDER_MAX_TOTAL_YEN)" ), examples=[{ "items": [{ "item_url": "https://item.rakuten.co.jp/shop/code/", "quantity": 1, "variant_id": "1001", }, { "item_url": "https://item.rakuten.co.jp/shop/another-code/", "quantity": 2, }], "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 = Field(description="站点注文番号,编目与下单任务表对账的天然键") shop_id: str | None = Field(default=None, description="店铺 ID;列表扫描未给出为 null") shop_name: str = Field(default="", description="店铺名(站点原文)") order_date: str | None = Field(default=None, description="下单日期(站点原文);列表扫描未给出为 null") delivery_status: str | None = Field( default=None, description="站点配送阶段的结构化枚举码(如 CHECKING_ORDER),原样带给上游对账;" "未识别时为 null 或 stepper 兜底文本", ) order_state: str | None = Field( default=None, description="worker 规范化上报的订单状态(OrderState 词汇表);映射不到为 null" ) discovered_at: str = Field(description="首次编目时间(ISO8601 UTC)") detail_fetched_at: str | None = Field( default=None, description="最近一次取到订单详情的时间(ISO8601 UTC);从未取过为 null" ) last_seen_at: str = Field(description="最近一次列表扫描见到该订单的时间(ISO8601 UTC)") class CatalogOrderListData(BaseModel): """编目订单列表(GET /api/account/orders)""" items: list[CatalogedOrder] = Field(description="编目订单列表,按最近出现(last_seen_at)倒序") total: int = Field(description="符合筛选条件的总条数") limit: int = Field(description="本次分页大小") offset: int = Field(description="本次分页偏移") class TriggerDiscoveryData(BaseModel): """手动触发一轮下派的响应""" query_id: str = Field(description="本轮 order_list 下派的查询单号") status: QueryStatus = Field(description="该查询单当前状态") created: bool = Field( description="本轮扫描是否新建了一张 order_list 单(False=已有一张在飞/已入队)" ) # ---- GET /health ---- class WorkerHealthEntry(BaseModel): """单个 worker 的健康指标""" worker_id: str = Field(description="worker 标识") last_seen_at: str = Field(description="最近一次心跳时间(ISO8601 UTC)") last_seen_seconds: int = Field(description="距上次心跳的秒数,超过 300 秒(5 分钟)判失联") class QueuedAlertEntry(BaseModel): """长时间无人领的任务告警项""" task_id: str = Field(description="任务 ID") site: str = Field(description="站点标识(rakuten)") created_at: str = Field(description="任务创建时间(ISO8601 UTC)") age_seconds: int = Field(description="从创建到现在的秒数,超过阈值即视为长时间无人领") class GatewayHealthData(BaseModel): """网关健康状态 只做规格 §4.7 的两条兜底告警:worker 失联、任务长时间无人领。付款期限监控 不在网关——本地机 7×24 在线,那套逻辑放本地。 """ status: str = Field(description="整体健康状态:ok=无告警,degraded=有 worker 失联或任务长时间无人领") queued_count: int = Field(description="等待领取的下单任务数量(status=queued)") # 等待本地 worker 领取的账号只读查询单数量。查询没有「无人领即告警」这条 # 规则(它有自己的 TTL 会自动 expired),这里只是给运维一个可见的积压信号。 queued_query_count: int = Field(default=0, description="等待领取的账号只读查询单数量(积压信号,不参与告警)") active_tasks: list[TaskDetail] = Field( default_factory=list, description="当前 leased/running 的下单任务详情列表" ) workers: list[WorkerHealthEntry] = Field( default_factory=list, description="全部已知 worker 的健康指标" ) offline_workers: list[WorkerHealthEntry] = Field( default_factory=list, description="失联 worker(距上次心跳超过 300 秒),非空即 degraded" ) stale_queued_tasks: list[QueuedAlertEntry] = Field( default_factory=list, description="长时间无人领的 queued 任务告警项,非空即 degraded" )