Files
rakuten-api/app/gateway/models.py
T

545 lines
25 KiB
Python

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