diff --git a/app/gateway/models.py b/app/gateway/models.py index aa40032..16f8b8d 100644 --- a/app/gateway/models.py +++ b/app/gateway/models.py @@ -6,10 +6,10 @@ intent 字段刻意保留成 `dict[str, Any]`——网关不解释下单意图 """ from __future__ import annotations -from typing import Any +from typing import Annotated, Any from urllib.parse import urlsplit -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, WithJsonSchema, field_validator from app.shared.task_state import AccountQueryKind, OrderState, QueryStatus, TaskStatus @@ -17,6 +17,79 @@ from app.shared.task_state import AccountQueryKind, OrderState, QueryStatus, Tas # ---- 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): """上游提交下单意图 @@ -34,7 +107,7 @@ class SubmitOrderRequest(BaseModel): description="站点标识。交易服务只覆盖乐天市场,固定 rakuten", examples=["rakuten"], ) - intent: dict[str, Any] = Field( + intent: OrderIntent = Field( description=( "下单意图原文。网关不解释内容,原样存库并透传给本地 worker,结构由 trading 侧定义:" "推荐使用 items(非空数组,每项含 item_url,及可选 quantity/variant_id/choice);" @@ -50,6 +123,9 @@ class SubmitOrderRequest(BaseModel): "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, }], diff --git a/tests/test_openapi_export.py b/tests/test_openapi_export.py index 95057c7..3fda640 100644 --- a/tests/test_openapi_export.py +++ b/tests/test_openapi_export.py @@ -103,3 +103,16 @@ def test_all_refs_resolve(spec): names = set(spec["components"]["schemas"]) refs = set(re.findall(r"#/components/schemas/([^\"]+)", json.dumps(spec))) assert not refs - names + + +def test_submit_order_intent_schema_documents_multi_item_and_legacy_fields(spec): + """intent 保持透传对象,同时在 OpenAPI 中明确展示新旧两种商品格式。""" + intent = spec["components"]["schemas"]["SubmitOrderRequest"]["properties"]["intent"] + properties = intent["properties"] + assert intent["additionalProperties"] is True + assert properties["items"]["type"] == "array" + assert properties["items"]["minItems"] == 1 + item_object = properties["items"]["items"]["oneOf"][0] + assert item_object["required"] == ["item_url"] + assert "item_url" in properties + assert "quantity" in properties