From 6b6b2431321b10b21b22f62c01696986c94a8e07 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 17 Aug 2026 09:43:07 +0800 Subject: [PATCH] =?UTF-8?q?docs(models):=20=E6=A8=A1=E5=9E=8B=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E8=A1=8C=E5=B0=BE=E6=B3=A8=E9=87=8A=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E4=B8=BA=20Field(description)=EF=BC=8C=E8=A1=A5=E5=85=A8=20Ope?= =?UTF-8?q?nAPI=20=E5=AD=97=E6=AE=B5=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/gateway/models.py | 78 +++-- app/scraping/models/scrape.py | 620 ++++++++++++++++++---------------- app/shared/api.py | 13 +- app/trading/models.py | 73 ++-- 4 files changed, 422 insertions(+), 362 deletions(-) diff --git a/app/gateway/models.py b/app/gateway/models.py index 31e29ac..89fafd8 100644 --- a/app/gateway/models.py +++ b/app/gateway/models.py @@ -380,32 +380,42 @@ class CatalogedOrder(BaseModel): 已经规范化了的稳定契约字段(见 §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 + 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] - total: int - limit: int - offset: int + 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 # 本轮 order_list 下派的单号 - status: QueryStatus - created: bool # 本轮扫描是否新建了一张 order_list 单(False=已有一张在飞/已入队) + query_id: str = Field(description="本轮 order_list 下派的查询单号") + status: QueryStatus = Field(description="该查询单当前状态") + created: bool = Field( + description="本轮扫描是否新建了一张 order_list 单(False=已有一张在飞/已入队)" + ) # ---- GET /health ---- @@ -414,18 +424,18 @@ class TriggerDiscoveryData(BaseModel): class WorkerHealthEntry(BaseModel): """单个 worker 的健康指标""" - worker_id: str - last_seen_at: str - last_seen_seconds: int + 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 - site: str - created_at: str - age_seconds: int + 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): @@ -435,12 +445,20 @@ class GatewayHealthData(BaseModel): 不在网关——本地机 7×24 在线,那套逻辑放本地。 """ - status: str # "ok" 或 "degraded" - queued_count: int + status: str = Field(description="整体健康状态:ok=无告警,degraded=有 worker 失联或任务长时间无人领") + queued_count: int = Field(description="等待领取的下单任务数量(status=queued)") # 等待本地 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) + 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" + ) diff --git a/app/scraping/models/scrape.py b/app/scraping/models/scrape.py index 9bfd553..67c7877 100644 --- a/app/scraping/models/scrape.py +++ b/app/scraping/models/scrape.py @@ -46,26 +46,29 @@ class SearchRequest(BaseModel): 3. 只传 genre_id:抓取该分类下的商品 """ - keyword: str = "" - page: int = Field(default=1, ge=1, le=150) # 站点侧最多约 150 页(subset 6750 / 45) - sort: SortOption = SortOption.STANDARD - genre_id: str | None = None # 乐天分类 ID,如 565950 - min_price: int | None = Field(default=None, ge=0) - max_price: int | None = Field(default=None, ge=0) - shop_id: int | None = None # 限定店铺(对应 `sid` 参数,取搜索结果的 shop.shop_id) - exclude_keyword: str | None = None # 排除词(`nitem`) - title_only: bool = False # 仅在商品标题中匹配(`sf=1`) - or_query: bool = False # 关键词之间用 OR 而非 AND(`st=O`) - min_review_score: int | None = Field(default=None, ge=1, le=5) # 最低评分 - condition: ItemCondition | None = None # 新品 / 中古 / 租赁 - include_sold_out: bool = False # 包含售罄商品 - free_shipping: bool = False # 仅免运费 - has_review: bool = False # 仅有评论 - next_day_delivery: bool = False # 仅次日达 - super_deal: bool = False # 仅 SuperDEAL - tags: list[str] = Field(default_factory=list) # 站点标签 ID(`tg`) - search_url: HttpUrl | None = None - exclude_ads: bool = True # 剔除搜索结果中混入的 CPC 广告位 + keyword: str = Field(default="", description="搜索关键词") + page: int = Field(default=1, ge=1, le=150, description="页码,站点侧最多约 150 页(subset 6750 / 45)") + sort: SortOption = Field(default=SortOption.STANDARD, description="排序方式,对应搜索页 `s=` 参数") + genre_id: str | None = Field(default=None, description="乐天分类 ID,如 565950") + min_price: int | None = Field(default=None, ge=0, description="最低价格(日元)") + max_price: int | None = Field(default=None, ge=0, description="最高价格(日元)") + shop_id: int | None = Field(default=None, description="限定店铺(对应 `sid` 参数,取搜索结果的 shop.shop_id)") + exclude_keyword: str | None = Field(default=None, description="排除词(`nitem`)") + title_only: bool = Field(default=False, description="仅在商品标题中匹配(`sf=1`)") + or_query: bool = Field(default=False, description="关键词之间用 OR 而非 AND(`st=O`)") + min_review_score: int | None = Field(default=None, ge=1, le=5, description="最低评分") + condition: ItemCondition | None = Field(default=None, description="商品成色筛选:新品 / 中古 / 租赁") + include_sold_out: bool = Field(default=False, description="包含售罄商品") + free_shipping: bool = Field(default=False, description="仅免运费") + has_review: bool = Field(default=False, description="仅有评论") + next_day_delivery: bool = Field(default=False, description="仅次日达") + super_deal: bool = Field(default=False, description="仅 SuperDEAL") + tags: list[str] = Field(default_factory=list, description="站点标签 ID(`tg`)") + search_url: HttpUrl | None = Field( + default=None, + description="直接透传一条乐天搜索页 URL,服务端原样抓取;此时除 page 与 exclude_ads 外的筛选字段全部忽略", + ) + exclude_ads: bool = Field(default=True, description="剔除搜索结果中混入的 CPC 广告位") @model_validator(mode="after") def check_search_target(self) -> SearchRequest: @@ -84,8 +87,8 @@ class SearchRequest(BaseModel): class ShopDetailRequest(BaseModel): """乐天商家详情请求参数:传店铺代码(店铺 URL 的路径段),或直接传店铺页 URL""" - shop_code: str | None = None # 店铺代码,如 edion - shop_url: HttpUrl | None = None + shop_code: str | None = Field(default=None, description="店铺代码,如 edion") + shop_url: HttpUrl | None = Field(default=None, description="店铺页 URL;与 shop_code 二选一") @model_validator(mode="after") def check_shop_target(self) -> ShopDetailRequest: @@ -104,18 +107,18 @@ class ShopItemsRequest(BaseModel): 换出 shop_id,多花一次请求,能直接给 shop_id 时优先给。 """ - shop_id: int | None = None # 取自搜索结果或商家详情的 shop.shop_id - shop_code: str | None = None # 店铺代码,如 edion - keyword: str = "" # 在店铺内按关键词过滤 - page: int = Field(default=1, ge=1, le=150) - sort: SortOption = SortOption.STANDARD - genre_id: str | None = None - min_price: int | None = Field(default=None, ge=0) - max_price: int | None = Field(default=None, ge=0) - condition: ItemCondition | None = None - include_sold_out: bool = False - free_shipping: bool = False - exclude_ads: bool = True + shop_id: int | None = Field(default=None, description="店铺 ID,取自搜索结果或商家详情的 shop.shop_id") + shop_code: str | None = Field(default=None, description="店铺代码,如 edion") + keyword: str = Field(default="", description="在店铺内按关键词过滤") + page: int = Field(default=1, ge=1, le=150, description="页码") + sort: SortOption = Field(default=SortOption.STANDARD, description="排序方式") + genre_id: str | None = Field(default=None, description="乐天分类 ID,如 565950") + min_price: int | None = Field(default=None, ge=0, description="最低价格(日元)") + max_price: int | None = Field(default=None, ge=0, description="最高价格(日元)") + condition: ItemCondition | None = Field(default=None, description="商品成色筛选") + include_sold_out: bool = Field(default=False, description="包含售罄商品") + free_shipping: bool = Field(default=False, description="仅免运费") + exclude_ads: bool = Field(default=True, description="剔除搜索结果中混入的 CPC 广告位") @model_validator(mode="after") def check_shop_target(self) -> ShopItemsRequest: @@ -149,30 +152,32 @@ class ShopItemsRequest(BaseModel): class ShopDetailData(BaseModel): """乐天商家详情数据""" - shop_id: int | None = None - shop_code: str = "" - shop_name: str = "" - shop_url: str = "" - introduction: str = "" # 店铺简介 - signboard_url: str = "" # 店铺招牌图 - logo_url: str = "" - review_score: float = 0.0 - review_count: int = 0 + shop_id: int | None = Field(default=None, description="店铺 ID") + shop_code: str = Field(default="", description="店铺代码,如 edion") + shop_name: str = Field(default="", description="店铺名") + shop_url: str = Field(default="", description="店铺页地址") + introduction: str = Field(default="", description="店铺简介") + signboard_url: str = Field(default="", description="店铺招牌图地址") + logo_url: str = Field(default="", description="店铺 Logo 图地址") + review_score: float = Field(default=0.0, description="综合评分") + review_count: int = Field(default=0, description="评价数") # 站点在评价数过少时不展示评分;此时 review_score 不可信 - review_displayed: bool = False - is_39_shop: bool = False # 39ショップ(满 3980 日元免运费) - age_verification_required: bool = False - status: int | None = None # 站点店铺状态码,1 = 营业中 - holidays: list[str] = Field(default_factory=list) # 店铺休息日 + review_displayed: bool = Field(default=False, description="站点是否展示了评分;未展示时 review_score 不可信") + is_39_shop: bool = Field(default=False, description="39ショップ(满 3980 日元免运费)") + age_verification_required: bool = Field(default=False, description="购买该店铺商品需要年龄确认") + status: int | None = Field(default=None, description="站点店铺状态码,1 = 营业中") + holidays: list[str] = Field(default_factory=list, description="店铺休息日") class ItemDetailRequest(BaseModel): """商品详情请求参数:传 shop_code + item_code,或直接传商品页 URL""" - shop_code: str | None = None # 店铺代码,如 edion(商品 URL 的第一段) - item_code: str | None = None # 店铺内商品编号,如 4902370549263(商品 URL 的第二段) - item_url: HttpUrl | None = None - include_sku_variants: bool = True # SKU 组合可能多达数百条,不需要时可关闭 + shop_code: str | None = Field(default=None, description="店铺代码,如 edion(商品 URL 的第一段)") + item_code: str | None = Field(default=None, description="店铺内商品编号,如 4902370549263(商品 URL 的第二段)") + item_url: HttpUrl | None = Field(default=None, description="商品页 URL;不传时需同时提供 shop_code 与 item_code") + include_sku_variants: bool = Field( + default=True, description="是否返回 SKU 组合明细;SKU 组合可能多达数百条,不需要时可关闭" + ) @model_validator(mode="after") def check_item_target(self) -> ItemDetailRequest: @@ -187,164 +192,169 @@ class GenreRequest(BaseModel): 不传 genre_id 时返回 39 个顶层分类;传入时返回该分类的信息、祖先路径与直接子分类。 """ - genre_id: str | None = None + genre_id: str | None = Field( + default=None, + description="分类 ID。不传返回 39 个顶层分类;传入返回该分类的信息、祖先路径与直接子分类", + ) class GenreNode(BaseModel): """分类树上的一个节点""" - genre_id: str = "" - name: str = "" + genre_id: str = Field(default="", description="分类 ID") + name: str = Field(default="", description="分类名") # 该分类下的商品数。顶层列表不返回该值:站点给出的是「当前查询在该分类下的 # 命中数」,与分类自身的商品总量不是一回事,避免误用。 - item_count: int | None = None - shortcut: str = "" # 站点分类短代码,如 game / flower - is_leaf: bool = False # 叶子分类,没有下级 - url: str = "" # 分类页地址 + item_count: int | None = Field(default=None, description="该分类下的商品数;顶层列表不返回该值") + shortcut: str = Field(default="", description="站点分类短代码,如 game / flower") + is_leaf: bool = Field(default=False, description="叶子分类,没有下级") + url: str = Field(default="", description="分类页地址") class GenreData(BaseModel): """分类查询结果""" - genre_id: str = "" # 空串表示顶层 - name: str = "" - full_name: str = "" # 站点给出的完整分类名,仅分类页有 - description: str = "" # 站点分类描述,仅分类页有 - is_leaf: bool = False - url: str = "" - ancestors: list[GenreNode] = Field(default_factory=list) # 从顶层到父级,不含自身 - children: list[GenreNode] = Field(default_factory=list) # 直接子分类 + genre_id: str = Field(default="", description="分类 ID,空串表示顶层") + name: str = Field(default="", description="分类名") + full_name: str = Field(default="", description="站点给出的完整分类名,仅分类页有") + description: str = Field(default="", description="站点分类描述,仅分类页有") + is_leaf: bool = Field(default=False, description="叶子分类,没有下级") + url: str = Field(default="", description="分类页地址") + ancestors: list[GenreNode] = Field(default_factory=list, description="祖先分类路径,从顶层到父级,不含自身") + children: list[GenreNode] = Field(default_factory=list, description="直接子分类") class ShopSummary(BaseModel): """店铺信息""" - shop_id: int | None = None - shop_code: str = "" # 店铺 URL 代码,如 edion;与 item_code 一起可定位商品 - shop_name: str = "" - shop_url: str = "" - review_score: float = 0.0 - review_count: int = 0 + shop_id: int | None = Field(default=None, description="店铺 ID") + shop_code: str = Field(default="", description="店铺 URL 代码,如 edion;与 item_code 一起可定位商品") + shop_name: str = Field(default="", description="店铺名") + shop_url: str = Field(default="", description="店铺页地址") + review_score: float = Field(default=0.0, description="综合评分") + review_count: int = Field(default=0, description="评价数") class ReviewSummary(BaseModel): """评价信息""" - score: float = 0.0 - count: int = 0 - url: str = "" + score: float = Field(default=0.0, description="评分") + count: int = Field(default=0, description="评价数") + url: str = Field(default="", description="评价页地址") class SearchItem(BaseModel): """搜索结果中的单个商品""" - item_id: str = "" # 乐天内部商品 ID(搜索结果的 code 字段) - item_code: str = "" # 商品 URL 第二段,调详情接口用 - item_name: str = "" - item_url: str = "" # 真实商品页地址;广告位已还原为 originalItemUrl - catch_copy: str = "" # 商品副标题 - price: int = 0 - price_range: str = "" # 多 SKU 时的价格区间,如 "1000~2000" - has_price_range: bool = False - image_url: str = "" - image_urls: list[str] = Field(default_factory=list) - shop: ShopSummary = Field(default_factory=ShopSummary) - review: ReviewSummary = Field(default_factory=ReviewSummary) - genre_id: str = "" - genre_path: str = "" # 形如 /0/101205/565950/566404 - genre_names: list[str] = Field(default_factory=list) - shipping_fee: int | None = None # 站点未给出时为 null - delivery_message: str = "" - point_count: int = 0 - is_sold_out: bool = False - is_ad: bool = False # CPC 广告位 - has_multi_sku: bool = False - variant_id: str = "" - item_options: dict[str, Any] = Field(default_factory=dict) # 站点 itemOptions 原样透出 + item_id: str = Field(default="", description="乐天内部商品 ID(搜索结果的 code 字段)") + item_code: str = Field(default="", description="商品 URL 第二段,调详情接口用") + item_name: str = Field(default="", description="商品名") + item_url: str = Field(default="", description="真实商品页地址;广告位已还原为 originalItemUrl") + catch_copy: str = Field(default="", description="商品副标题") + price: int = Field(default=0, description="价格(日元)") + price_range: str = Field(default="", description="多 SKU 时的价格区间,如「1000~2000」") + has_price_range: bool = Field(default=False, description="是否为多 SKU 价格区间") + image_url: str = Field(default="", description="主图地址") + image_urls: list[str] = Field(default_factory=list, description="图片地址列表") + shop: ShopSummary = Field(default_factory=ShopSummary, description="所属店铺信息") + review: ReviewSummary = Field(default_factory=ReviewSummary, description="商品评价信息") + genre_id: str = Field(default="", description="分类 ID") + genre_path: str = Field(default="", description="分类路径,形如 /0/101205/565950/566404") + genre_names: list[str] = Field(default_factory=list, description="分类名列表") + shipping_fee: int | None = Field(default=None, description="运费(日元);站点未给出时为 null") + delivery_message: str = Field(default="", description="配送说明") + point_count: int = Field(default=0, description="乐天积分倍率(站点 point.count 原值)") + is_sold_out: bool = Field(default=False, description="已售罄") + is_ad: bool = Field(default=False, description="CPC 广告位") + has_multi_sku: bool = Field(default=False, description="有多个 SKU") + variant_id: str = Field(default="", description="SKU 组合 ID(站点 variantId 原值)") + item_options: dict[str, Any] = Field(default_factory=dict, description="站点 itemOptions 原样透出") class SearchResultData(BaseModel): """搜索结果数据""" - keyword: str = "" - page: int = 1 - page_size: int = 0 - total_count: int = 0 # 站点声明的命中总数 - reachable_count: int = 0 # 实际可翻页取到的上限(站点 subset,随查询条件变化) - has_more: bool = False + keyword: str = Field(default="", description="搜索关键词") + page: int = Field(default=1, description="当前页码") + page_size: int = Field(default=0, description="每页条数") + total_count: int = Field(default=0, description="站点声明的命中总数") + reachable_count: int = Field(default=0, description="实际可翻页取到的上限(站点 subset,随查询条件变化)") + has_more: bool = Field(default=False, description="是否还有下一页") # 请求页码超出 reachable_count 对应的页数。站点此时不会返回空列表,而是 # 静默回绕到第 1 页;这里识别出来并把 items 置空,避免上游把重复数据当新数据。 - out_of_range: bool = False - ad_count: int = 0 # 本页被识别出的广告位数量(exclude_ads=true 时已从 items 剔除) - request_url: str = "" # 实际抓取的乐天页面地址,便于排查 - items: list[SearchItem] = Field(default_factory=list) + out_of_range: bool = Field( + default=False, description="请求页码超出可翻页范围;此时 items 置空(站点会静默回绕到第 1 页,已识别)" + ) + ad_count: int = Field(default=0, description="本页被识别出的广告位数量(exclude_ads=true 时已从 items 剔除)") + request_url: str = Field(default="", description="实际抓取的乐天页面地址,便于排查") + items: list[SearchItem] = Field(default_factory=list, description="本页商品列表") class SkuAttribute(BaseModel): """SKU 属性项""" - title: str = "" - value: str = "" + title: str = Field(default="", description="属性名") + value: str = Field(default="", description="属性值") class SkuAxisValue(BaseModel): """SKU 选择轴上的一个取值""" - value: str = "" - label: str = "" - is_sold_out: bool = False + value: str = Field(default="", description="取值原值") + label: str = Field(default="", description="取值展示名") + is_sold_out: bool = Field(default=False, description="该取值已售罄") class SkuAxis(BaseModel): """SKU 选择轴,如「颜色」「尺码」""" - key: str = "" - label: str = "" - values: list[SkuAxisValue] = Field(default_factory=list) + key: str = Field(default="", description="轴标识") + label: str = Field(default="", description="轴展示名") + values: list[SkuAxisValue] = Field(default_factory=list, description="可选取值列表") class SkuVariant(BaseModel): """一个具体的 SKU 组合""" - variant_id: str = "" - selector_values: list[str] = Field(default_factory=list) # 与 axis 顺序对应的取值 - price: int = 0 - quantity: int = 0 - is_sold_out: bool = False - delivery_message: str = "" - attributes: list[SkuAttribute] = Field(default_factory=list) + variant_id: str = Field(default="", description="SKU 组合 ID") + selector_values: list[str] = Field(default_factory=list, description="与 axis 顺序对应的取值") + price: int = Field(default=0, description="价格(日元)") + quantity: int = Field(default=0, description="库存数量") + is_sold_out: bool = Field(default=False, description="已售罄") + delivery_message: str = Field(default="", description="配送说明") + attributes: list[SkuAttribute] = Field(default_factory=list, description="SKU 属性列表") class SkuInfo(BaseModel): """商品 SKU 信息""" - inventory_type: str = "" # single / multiple - quantity: int = 0 - show_inventory: bool = False - delivery_message: str = "" - attributes: list[SkuAttribute] = Field(default_factory=list) - axis: list[SkuAxis] = Field(default_factory=list) - variants: list[SkuVariant] = Field(default_factory=list) # include_sku_variants=false 时为空 - variant_count: int = 0 # 不受 include_sku_variants 影响,始终为真实组合数 + inventory_type: str = Field(default="", description="库存类型:single / multiple") + quantity: int = Field(default=0, description="库存数量") + show_inventory: bool = Field(default=False, description="站点是否展示库存") + delivery_message: str = Field(default="", description="配送说明") + attributes: list[SkuAttribute] = Field(default_factory=list, description="SKU 属性列表") + axis: list[SkuAxis] = Field(default_factory=list, description="SKU 选择轴,如「颜色」「尺码」") + variants: list[SkuVariant] = Field(default_factory=list, description="SKU 组合明细;include_sku_variants=false 时为空") + variant_count: int = Field(default=0, description="SKU 组合总数;不受 include_sku_variants 影响,始终为真实组合数") class ShippingInfo(BaseModel): """配送与运费信息""" - shipping_fee: int | None = None - is_shipping_free: bool = False - is_asuraku: bool = False # あす楽(次日达) - is_next_day_delivery: bool = False - free_shipping_threshold: int | None = None - prefecture_id: int | None = None # 站点默认收货地(13 = 东京都) - delivery_message: str = "" + shipping_fee: int | None = Field(default=None, description="运费(日元);站点未给出时为 null") + is_shipping_free: bool = Field(default=False, description="免运费") + is_asuraku: bool = Field(default=False, description="あす楽(次日达)") + is_next_day_delivery: bool = Field(default=False, description="次日达") + free_shipping_threshold: int | None = Field(default=None, description="免运费门槛(日元)") + prefecture_id: int | None = Field(default=None, description="站点默认收货地(13 = 东京都)") + delivery_message: str = Field(default="", description="配送说明") class Breadcrumb(BaseModel): """分类面包屑""" - name: str = "" - url: str = "" + name: str = Field(default="", description="分类名") + url: str = Field(default="", description="分类页地址") class ItemDetailData(BaseModel): @@ -358,28 +368,28 @@ class ItemDetailData(BaseModel): (purchase_condition / is_sold_out)、规格(sku.variants)、起订单位等。 """ - source: str = "ichiba" # ichiba / books / brandavenue / biccamera - source_url: str = "" # 实际解析的页面地址;跳转时与 item_url 不同 - item_id: str = "" - item_code: str = "" - item_name: str = "" - catch_copy: str = "" - description: str = "" # 店铺自填的商品说明,含 HTML - item_url: str = "" - price: int = 0 # 最低售价(含税,多 SKU 时为最低价) - pre_tax_price: int = 0 - tax_flag: bool = False - tax_rate: float = 0.0 - purchase_condition: str = "" # 站点原值,enabled 表示可购买 - is_sold_out: bool = False - purchase_unit: int = 0 # 起订单位 - images: list[str] = Field(default_factory=list) - shop: ShopSummary = Field(default_factory=ShopSummary) - review: ReviewSummary = Field(default_factory=ReviewSummary) - genre_id: str = "" - breadcrumbs: list[Breadcrumb] = Field(default_factory=list) - shipping: ShippingInfo = Field(default_factory=ShippingInfo) - sku: SkuInfo = Field(default_factory=SkuInfo) + source: str = Field(default="ichiba", description="数据来源站点:ichiba / books / brandavenue / biccamera") + source_url: str = Field(default="", description="实际解析的页面地址;跳转时与 item_url 不同") + item_id: str = Field(default="", description="乐天内部商品 ID") + item_code: str = Field(default="", description="店铺内商品编号(商品 URL 第二段)") + item_name: str = Field(default="", description="商品名") + catch_copy: str = Field(default="", description="商品副标题") + description: str = Field(default="", description="店铺自填的商品说明,含 HTML") + item_url: str = Field(default="", description="商品页地址") + price: int = Field(default=0, description="最低售价(日元,含税;多 SKU 时为最低价)") + pre_tax_price: int = Field(default=0, description="税前价格(日元)") + tax_flag: bool = Field(default=False, description="价格是否含税(站点 taxFlag 原值)") + tax_rate: float = Field(default=0.0, description="税率") + purchase_condition: str = Field(default="", description="站点原值,enabled 表示可购买") + is_sold_out: bool = Field(default=False, description="已售罄") + purchase_unit: int = Field(default=0, description="起订单位") + images: list[str] = Field(default_factory=list, description="商品图片地址列表") + shop: ShopSummary = Field(default_factory=ShopSummary, description="所属店铺信息") + review: ReviewSummary = Field(default_factory=ReviewSummary, description="商品评价信息") + genre_id: str = Field(default="", description="分类 ID") + breadcrumbs: list[Breadcrumb] = Field(default_factory=list, description="分类面包屑") + shipping: ShippingInfo = Field(default_factory=ShippingInfo, description="该商品的配送与运费信息") + sku: SkuInfo = Field(default_factory=SkuInfo, description="SKU 信息") class HealthData(BaseModel): @@ -388,11 +398,11 @@ class HealthData(BaseModel): 这里不含登录态——登录态属于交易服务,查它请打交易服务的 /health。 """ - status: str - browser_fallback_enabled: bool - browser_fallback_ready: bool - browser_fallback_error: str | None = None - sessions: dict[str, Any] = Field(default_factory=dict) + status: str = Field(description="服务状态,正常为 ok") + browser_fallback_enabled: bool = Field(description="是否启用浏览器兜底抓取") + browser_fallback_ready: bool = Field(description="浏览器兜底是否就绪") + browser_fallback_error: str | None = Field(default=None, description="浏览器兜底不可用的原因;正常为 null") + sessions: dict[str, Any] = Field(default_factory=dict, description="各站点抓取会话状态(rakuten / rakuma)") # ========================================================================== @@ -449,21 +459,25 @@ class RakumaSearchRequest(BaseModel): keyword、category_id、brand_id、search_url 四者至少提供一个。 """ - keyword: str = "" - page: int = Field(default=1, ge=1, le=100) # 站点侧 page>100 直接 404 - sort: RakumaSortOption = RakumaSortOption.STANDARD - category_id: str | None = None # ラクマ 分类 ID,如 788 - brand_id: str | None = None # ラクマ 品牌 ID,如 5296 - min_price: int | None = Field(default=None, ge=0) - max_price: int | None = Field(default=None, ge=0) - exclude_keyword: str | None = None # 排除词(站点 `excluded_query`,需与 keyword 同时使用) - conditions: list[RakumaCondition] = Field(default_factory=list) # 可多选 - transaction: RakumaTransaction | None = None # 不传表示不限 - free_shipping: bool = False # 仅「送料込み」(卖家承担运费) - anonymous_shipping: bool = False # 仅匿名配送 - except_for_no_brand: bool = False # 排除无品牌商品;与 brand_id 互斥 - authenticity_types: list[RakumaAuthenticity] = Field(default_factory=list) - search_url: HttpUrl | None = None + keyword: str = Field(default="", description="搜索关键词") + page: int = Field(default=1, ge=1, le=100, description="页码,站点侧 page>100 直接 404") + sort: RakumaSortOption = Field(default=RakumaSortOption.STANDARD, description="排序方式") + category_id: str | None = Field(default=None, description="ラクマ 分类 ID,如 788") + brand_id: str | None = Field(default=None, description="ラクマ 品牌 ID,如 5296") + min_price: int | None = Field(default=None, ge=0, description="最低价格(日元)") + max_price: int | None = Field(default=None, ge=0, description="最高价格(日元)") + exclude_keyword: str | None = Field( + default=None, description="排除词(站点 `excluded_query`,需与 keyword 同时使用)" + ) + conditions: list[RakumaCondition] = Field(default_factory=list, description="商品状态筛选,可多选") + transaction: RakumaTransaction | None = Field(default=None, description="售卖状态筛选,不传表示不限") + free_shipping: bool = Field(default=False, description="仅「送料込み」(卖家承担运费)") + anonymous_shipping: bool = Field(default=False, description="仅匿名配送") + except_for_no_brand: bool = Field(default=False, description="排除无品牌商品;与 brand_id 互斥") + authenticity_types: list[RakumaAuthenticity] = Field(default_factory=list, description="正品鉴定服务类型筛选,可多选") + search_url: HttpUrl | None = Field( + default=None, description="直接透传一条 fril.jp 搜索页 URL;此时除 page 外的筛选字段全部忽略" + ) @model_validator(mode="after") def check_search_target(self) -> RakumaSearchRequest: @@ -480,8 +494,8 @@ class RakumaSearchRequest(BaseModel): class RakumaItemDetailRequest(BaseModel): """ラクマ 商品详情请求参数:传 item_id(商品 URL 的最后一段),或直接传商品页 URL""" - item_id: str | None = None # 商品页 hash,如 4aca1d6db3e422f3a251a8a8b61e1eff - item_url: HttpUrl | None = None + item_id: str | None = Field(default=None, description="商品页 hash,如 4aca1d6db3e422f3a251a8a8b61e1eff") + item_url: HttpUrl | None = Field(default=None, description="商品页 URL;与 item_id 二选一") @model_validator(mode="after") def check_item_target(self) -> RakumaItemDetailRequest: @@ -493,10 +507,12 @@ class RakumaItemDetailRequest(BaseModel): class RakumaShopDetailRequest(BaseModel): """ラクマ 卖家详情请求参数:传 shop_id(店铺 URL 的最后一段),或直接传店铺页 URL""" - shop_id: str | None = None # 店铺页 hash,如 422750cb7921557bc8dba2416915d968 - shop_url: HttpUrl | None = None + shop_id: str | None = Field(default=None, description="店铺页 hash,如 422750cb7921557bc8dba2416915d968") + shop_url: HttpUrl | None = Field(default=None, description="店铺页 URL;与 shop_id 二选一") # 评价明细在单独的 /review 页上,需要多打一次请求,默认不取 - include_reviews: bool = False + include_reviews: bool = Field( + default=False, description="是否返回评价明细;评价在单独的 /review 页,会多打一次请求" + ) @model_validator(mode="after") def check_shop_target(self) -> RakumaShopDetailRequest: @@ -512,9 +528,9 @@ class RakumaShopItemsRequest(BaseModel): 排序与筛选参数,因此这里只有页码。 """ - shop_id: str | None = None - shop_url: HttpUrl | None = None - page: int = Field(default=1, ge=1) + shop_id: str | None = Field(default=None, description="店铺页 hash") + shop_url: HttpUrl | None = Field(default=None, description="店铺页 URL;与 shop_id 二选一") + page: int = Field(default=1, ge=1, description="页码") @model_validator(mode="after") def check_shop_target(self) -> RakumaShopItemsRequest: @@ -531,10 +547,14 @@ class RakumaCategoryRequest(BaseModel): 组织方式,不会多打请求。 """ - category_id: str | None = None - # 返回该分类下的完整子树(不止直接子级)。分类树共三层, - # 顶层分类的子树可达上百条。 - include_descendants: bool = False + category_id: str | None = Field( + default=None, + description="分类 ID。不传返回 14 个顶层分类;传入返回该分类的名称、祖先路径与直接子分类", + ) + include_descendants: bool = Field( + default=False, + description="返回该分类下的完整子树(不止直接子级);分类树共三层,顶层分类的子树可达上百条", + ) class RakumaCategoryNode(BaseModel): @@ -544,73 +564,81 @@ class RakumaCategoryNode(BaseModel): 商品数要逐个分类去抓 `/category/{id}` 页面,成本过高,本接口不提供。 """ - category_id: str = "" - name: str = "" - parent_id: str = "" # "0" 表示顶层分类 - is_leaf: bool = False # 叶子分类,没有下级 - url: str = "" # 分类页地址 - # include_descendants=true 时填充下级分类,否则始终为空 - children: list[RakumaCategoryNode] = Field(default_factory=list) + category_id: str = Field(default="", description="分类 ID") + name: str = Field(default="", description="分类名") + parent_id: str = Field(default="", description="父分类 ID,「0」表示顶层分类") + is_leaf: bool = Field(default=False, description="叶子分类,没有下级") + url: str = Field(default="", description="分类页地址") + children: list[RakumaCategoryNode] = Field( + default_factory=list, description="下级分类;include_descendants=true 时填充,否则始终为空" + ) class RakumaCategoryData(BaseModel): """ラクマ 分类查询结果""" - category_id: str = "" # 空串表示顶层 - name: str = "" - full_name: str = "" # 从顶层拼到自身的路径名,如「エンタメ/ホビー / ゲームソフト/ゲーム機本体 / 家庭用ゲームソフト」 - is_leaf: bool = False - url: str = "" - total_count: int = 0 # 站点分类树的节点总数,用于确认取到的是全量树 - ancestors: list[RakumaCategoryNode] = Field(default_factory=list) # 从顶层到父级,不含自身 - children: list[RakumaCategoryNode] = Field(default_factory=list) # 直接子分类;include_descendants=true 时带子树 + category_id: str = Field(default="", description="分类 ID,空串表示顶层") + name: str = Field(default="", description="分类名") + full_name: str = Field( + default="", + description="从顶层拼到自身的路径名,如「エンタメ/ホビー / ゲームソフト/ゲーム機本体 / 家庭用ゲームソフト」", + ) + is_leaf: bool = Field(default=False, description="叶子分类,没有下级") + url: str = Field(default="", description="分类页地址") + total_count: int = Field(default=0, description="站点分类树的节点总数,用于确认取到的是全量树") + ancestors: list[RakumaCategoryNode] = Field( + default_factory=list, description="祖先分类路径,从顶层到父级,不含自身" + ) + children: list[RakumaCategoryNode] = Field( + default_factory=list, description="直接子分类;include_descendants=true 时带子树" + ) class RakumaSeller(BaseModel): """ラクマ 卖家(出品者)摘要""" - shop_id: str = "" # 店铺页 hash,可直接用于 /api/rakuma/shop_detail - user_id: str = "" # 站点内部数值用户 ID - shop_name: str = "" # 店铺名,卖家可自定义 - user_name: str = "" # 用户昵称 - shop_url: str = "" - icon_url: str = "" - seller_type: str = "" # 站点原值,如 一般 / 事業者 - review_score: float = 0.0 - review_count: int = 0 - is_verified: bool = False # 本人確認済 + shop_id: str = Field(default="", description="店铺页 hash,可直接用于 /api/rakuma/shop_detail") + user_id: str = Field(default="", description="站点内部数值用户 ID") + shop_name: str = Field(default="", description="店铺名,卖家可自定义") + user_name: str = Field(default="", description="用户昵称") + shop_url: str = Field(default="", description="店铺页地址") + icon_url: str = Field(default="", description="头像地址") + seller_type: str = Field(default="", description="卖家类型(站点原值),如 一般 / 事業者") + review_score: float = Field(default=0.0, description="综合评分") + review_count: int = Field(default=0, description="评价数") + is_verified: bool = Field(default=False, description="本人確認済") class RakumaSearchItem(BaseModel): """ラクマ 搜索结果中的单个商品""" - item_id: str = "" # 商品页 hash,调详情接口用 - item_number: str = "" # 站点内部数值商品 ID - item_name: str = "" - item_url: str = "" - price: int = 0 - image_url: str = "" - is_sold_out: bool = False - brand_id: str = "" - brand_name: str = "" - category_id: str = "" - category_names: list[str] = Field(default_factory=list) - seller_user_id: str = "" # 卖家数值 ID;店铺 hash 需从详情页取 - seller_type: str = "" + item_id: str = Field(default="", description="商品页 hash,调详情接口用") + item_number: str = Field(default="", description="站点内部数值商品 ID") + item_name: str = Field(default="", description="商品名") + item_url: str = Field(default="", description="商品页地址") + price: int = Field(default=0, description="价格(日元)") + image_url: str = Field(default="", description="主图地址") + is_sold_out: bool = Field(default=False, description="已售出") + brand_id: str = Field(default="", description="品牌 ID") + brand_name: str = Field(default="", description="品牌名") + category_id: str = Field(default="", description="分类 ID") + category_names: list[str] = Field(default_factory=list, description="分类名列表") + seller_user_id: str = Field(default="", description="卖家数值 ID;店铺 hash 需从详情页取") + seller_type: str = Field(default="", description="卖家类型(站点原值),如 一般 / 事業者") class RakumaSearchResultData(BaseModel): """ラクマ 搜索结果数据""" - keyword: str = "" - page: int = 1 - page_size: int = 0 + keyword: str = Field(default="", description="搜索关键词") + page: int = Field(default=1, description="当前页码") + page_size: int = Field(default=0, description="每页条数") # 站点声明的命中总数。页面上展示为「約1,190,000件」的四舍五入值, # 这里取的是埋点属性里的精确值。 - total_count: int = 0 - has_more: bool = False - request_url: str = "" - items: list[RakumaSearchItem] = Field(default_factory=list) + total_count: int = Field(default=0, description="站点声明的命中总数(埋点属性里的精确值)") + has_more: bool = Field(default=False, description="是否还有下一页") + request_url: str = Field(default="", description="实际抓取的页面地址,便于排查") + items: list[RakumaSearchItem] = Field(default_factory=list, description="本页商品列表") class RakumaItemDetailData(BaseModel): @@ -620,78 +648,82 @@ class RakumaItemDetailData(BaseModel): 「規格」在站点上只体现为一个可选的尺码字段。 """ - item_id: str = "" - item_number: str = "" - item_name: str = "" - description: str = "" - item_url: str = "" - price: int = 0 - is_sold_out: bool = False - images: list[str] = Field(default_factory=list) - condition: str = "" # 商品の状態,站点原文如「目立った傷や汚れなし」 - size: str = "" # サイズ,无尺码时为空 - brand_id: str = "" - brand_name: str = "" - category_id: str = "" # 最具体的一级分类 ID - breadcrumbs: list[Breadcrumb] = Field(default_factory=list) - shipping_payer: str = "" # 配送料の負担,如「送料込」 - shipping_method: str = "" # 配送方法 - shipping_date_estimate: str = "" # 発送日の目安 - shipping_from: str = "" # 発送元の地域 - is_anonymous_shipping: bool = False # 匿名配送 - like_count: int = 0 # いいね数 - comment_count: int = 0 - posted_at: str = "" # 站点展示的相对时间,如「約1時間前」 - seller: RakumaSeller = Field(default_factory=RakumaSeller) + item_id: str = Field(default="", description="商品页 hash") + item_number: str = Field(default="", description="站点内部数值商品 ID") + item_name: str = Field(default="", description="商品名") + description: str = Field(default="", description="商品说明(卖家自填)") + item_url: str = Field(default="", description="商品页地址") + price: int = Field(default=0, description="价格(日元)") + is_sold_out: bool = Field(default=False, description="已售出") + images: list[str] = Field(default_factory=list, description="商品图片地址列表") + condition: str = Field(default="", description="商品の状態,站点原文如「目立った傷や汚れなし」") + size: str = Field(default="", description="サイズ,无尺码时为空") + brand_id: str = Field(default="", description="品牌 ID") + brand_name: str = Field(default="", description="品牌名") + category_id: str = Field(default="", description="最具体的一级分类 ID") + breadcrumbs: list[Breadcrumb] = Field(default_factory=list, description="分类面包屑") + shipping_payer: str = Field(default="", description="配送料の負担,如「送料込」") + shipping_method: str = Field(default="", description="配送方法") + shipping_date_estimate: str = Field(default="", description="発送日の目安") + shipping_from: str = Field(default="", description="発送元の地域") + is_anonymous_shipping: bool = Field(default=False, description="匿名配送") + like_count: int = Field(default=0, description="いいね数") + comment_count: int = Field(default=0, description="评论数") + posted_at: str = Field(default="", description="站点展示的相对时间,如「約1時間前」") + seller: RakumaSeller = Field(default_factory=RakumaSeller, description="卖家(出品者)信息") class RakumaReview(BaseModel): """ラクマ 卖家的一条交易评价""" - rating: str = "" # good / normal / bad - title: str = "" # 站点原文,如「よい出品者です」 - comment: str = "" - reviewer_name: str = "" - reviewed_at: str = "" # 站点展示的日期,如 2026/05/04 + rating: str = Field(default="", description="评价档位:good / normal / bad") + title: str = Field(default="", description="站点原文标题,如「よい出品者です」") + comment: str = Field(default="", description="评价内容") + reviewer_name: str = Field(default="", description="评价者昵称") + reviewed_at: str = Field(default="", description="站点展示的日期,如 2026/05/04") class RakumaRatingBreakdown(BaseModel): """评价数量分档""" - good: int = 0 - normal: int = 0 - bad: int = 0 + good: int = Field(default=0, description="好评数") + normal: int = Field(default=0, description="中评数") + bad: int = Field(default=0, description="差评数") class RakumaShopDetailData(BaseModel): """ラクマ 卖家详情数据""" - shop_id: str = "" - user_id: str = "" - shop_name: str = "" - user_name: str = "" - shop_url: str = "" - icon_url: str = "" - cover_url: str = "" - introduction: str = "" # プロフィール文 - review_score: float = 0.0 - review_count: int = 0 - is_verified: bool = False # 本人確認済 - verification_label: str = "" # 站点原文,如「本人確認済」/「本人確認未完了」 - item_count: int = 0 # 该卖家在售 + 已售商品总数 + shop_id: str = Field(default="", description="店铺页 hash") + user_id: str = Field(default="", description="站点内部数值用户 ID") + shop_name: str = Field(default="", description="店铺名,卖家可自定义") + user_name: str = Field(default="", description="用户昵称") + shop_url: str = Field(default="", description="店铺页地址") + icon_url: str = Field(default="", description="头像地址") + cover_url: str = Field(default="", description="封面图地址") + introduction: str = Field(default="", description="プロフィール文(自我介绍)") + review_score: float = Field(default=0.0, description="综合评分") + review_count: int = Field(default=0, description="评价数") + is_verified: bool = Field(default=False, description="本人確認済") + verification_label: str = Field(default="", description="站点原文,如「本人確認済」/「本人確認未完了」") + item_count: int = Field(default=0, description="该卖家在售 + 已售商品总数") # 以下三项需 include_reviews=true 才会填充 - rating_breakdown: RakumaRatingBreakdown = Field(default_factory=RakumaRatingBreakdown) - seller_rating_breakdown: RakumaRatingBreakdown = Field(default_factory=RakumaRatingBreakdown) - reviews: list[RakumaReview] = Field(default_factory=list) + rating_breakdown: RakumaRatingBreakdown = Field( + default_factory=RakumaRatingBreakdown, description="评价数量分档(全部评价)" + ) + seller_rating_breakdown: RakumaRatingBreakdown = Field( + default_factory=RakumaRatingBreakdown, description="评价数量分档(仅作为卖家收到的评价)" + ) + reviews: list[RakumaReview] = Field(default_factory=list, description="评价明细列表") class RakumaShopItemsData(BaseModel): """ラクマ 卖家商品列表数据""" - shop_id: str = "" - shop_name: str = "" - page: int = 1 - total_count: int = 0 # 该卖家的商品总数 - has_more: bool = False - request_url: str = "" - items: list[RakumaSearchItem] = Field(default_factory=list) + shop_id: str = Field(default="", description="店铺页 hash") + shop_name: str = Field(default="", description="店铺名") + page: int = Field(default=1, description="当前页码") + total_count: int = Field(default=0, description="该卖家的商品总数") + has_more: bool = Field(default=False, description="是否还有下一页") + request_url: str = Field(default="", description="实际抓取的页面地址,便于排查") + items: list[RakumaSearchItem] = Field(default_factory=list, description="本页商品列表") diff --git a/app/shared/api.py b/app/shared/api.py index 0f27d62..db6e30f 100644 --- a/app/shared/api.py +++ b/app/shared/api.py @@ -16,7 +16,7 @@ from typing import Any, Generic, TypeVar from fastapi import Depends, FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, Field, ValidationError from starlette.exceptions import HTTPException as StarletteHTTPException from app.shared.errors import AppError, AuthenticationError @@ -29,10 +29,13 @@ T = TypeVar("T") class ApiResponse(BaseModel, Generic[T]): """统一 API 响应格式""" - success: bool - msg: str - data: T | None = None - code: int + success: bool = Field(description="请求是否成功") + msg: str = Field(description="提示信息;失败时为错误原因") + data: T | None = Field(default=None, description="响应数据;无数据或失败时为 null") + code: int = Field( + description="错误码:0 表示成功,其余见统一错误码表(如 1001 鉴权失败、" + "1002 请求参数校验失败、5xxx 交易服务、6xxx 网关)" + ) # ---- 依赖注入 ---- diff --git a/app/trading/models.py b/app/trading/models.py index d3106b0..96d60e3 100644 --- a/app/trading/models.py +++ b/app/trading/models.py @@ -26,43 +26,43 @@ class AuthSite(StrEnum): class AuthStatusRequest(BaseModel): """登录态查询请求""" - site: AuthSite | None = None # 不传则返回所有已配置站点 - refresh: bool = True # 是否真实打一次请求探测;false 时只读缓存 + site: AuthSite | None = Field(default=None, description="站点标识;不传则返回所有已配置站点") + refresh: bool = Field(default=True, description="是否真实打一次请求探测;false 时只读缓存") class AuthSiteStatus(BaseModel): """单站登录态""" - site: str - state_file_exists: bool # 是否已跑过 scripts/login.py - logged_in: bool | None # None 表示尚未探测 - checked_age_seconds: float | None = None - detail: str = "" + site: str = Field(description="站点标识(rakuten)") + state_file_exists: bool = Field(description="是否已跑过 scripts/login.py(本地登录态文件存在)") + logged_in: bool | None = Field(description="是否已登录;null 表示尚未探测") + checked_age_seconds: float | None = Field(default=None, description="上次探测距现在的秒数;未探测为 null") + detail: str = Field(default="", description="补充说明(如未登录原因)") class AuthStatusData(BaseModel): """登录态查询响应""" - sites: list[AuthSiteStatus] = Field(default_factory=list) + sites: list[AuthSiteStatus] = Field(default_factory=list, description="各站点登录态列表") class AuthReloadRequest(BaseModel): """重新加载登录态请求(人工登录完成后调用,免重启服务)""" - site: AuthSite | None = None # 不传则重载所有已配置站点 + site: AuthSite | None = Field(default=None, description="站点标识;不传则重载所有已配置站点") class AuthReloadData(BaseModel): """重新加载登录态响应""" - reloaded: dict[str, int] = Field(default_factory=dict) # site -> cookie 条数 - sites: list[AuthSiteStatus] = Field(default_factory=list) + reloaded: dict[str, int] = Field(default_factory=dict, description="重载结果:站点 -> cookie 条数") + sites: list[AuthSiteStatus] = Field(default_factory=list, description="重载后的各站点登录态") class AuthLoginRequest(BaseModel): """触发自动登录请求""" - site: AuthSite | None = None # 不传则对所有已配置站点各跑一次 + site: AuthSite | None = Field(default=None, description="站点标识;不传则对所有已配置站点各跑一次") class AuthLoginData(BaseModel): @@ -73,9 +73,11 @@ class AuthLoginData(BaseModel): 不会白起一次浏览器。 """ - logged_in: bool - relogin_attempted: dict[str, bool] = Field(default_factory=dict) - sites: list[AuthSiteStatus] = Field(default_factory=list) + logged_in: bool = Field(description="本次动作的最终结论:所有涉及站点都登录着才为 true") + relogin_attempted: dict[str, bool] = Field( + default_factory=dict, description="各站点是否真的跑了登录流程;已登录的站点直接跳过" + ) + sites: list[AuthSiteStatus] = Field(default_factory=list, description="各站点登录态") class TradingHealthData(BaseModel): @@ -85,8 +87,8 @@ class TradingHealthData(BaseModel): POST /api/auth/status。 """ - status: str - auth: dict[str, Any] = Field(default_factory=dict) + status: str = Field(description="服务状态,健康为 ok") + auth: dict[str, Any] = Field(default_factory=dict, description="缓存的各站点登录态(不触发网络探测)") # ---- 购物车接口(/api/cart/*)---- @@ -100,10 +102,15 @@ class CartAddRequest(BaseModel): choice 接受字符串("颜色:赤")或字符串列表(["颜色:赤", "サイズ:M"])。 """ - item_url: str - quantity: int = 1 - variant_id: str | None = None - choice: str | list[str] | None = None + item_url: str = Field(description="商品页 URL") + quantity: int = Field(default=1, description="加购数量") + variant_id: str | None = Field( + default=None, description="多规格商品的规格 ID;不传时自动选第一个非售罄规格" + ) + choice: str | list[str] | None = Field( + default=None, + description="商品选项,如 \"颜色:赤\";可传字符串或字符串列表,不传时必填选项自动选第一个候选值", + ) class CartStatusRequest(BaseModel): @@ -117,35 +124,35 @@ class CartClearRequest(BaseModel): class CartRemoveRequest(BaseModel): """删除指定商品请求""" - item_id: str + item_id: str = Field(description="要删除的商品 ID") class CartAddData(BaseModel): """加购响应数据""" - added: bool = True - item_id: str - shop_bid: str - cart_count: int # -1 表示加购成功但末尾 count 查询失败 + added: bool = Field(default=True, description="是否加购成功") + item_id: str = Field(description="加购的商品 ID") + shop_bid: str = Field(description="店铺 bid") + cart_count: int = Field(description="加购后的购物车商品件数;-1 表示加购成功但末尾 count 查询失败") class CartStatusData(BaseModel): """购物车状态响应数据""" - logged_in: bool - count: int - raw_status: str # 站点状态码字符串,"100" 表示正常 + logged_in: bool = Field(description="当前是否已登录") + count: int = Field(description="购物车商品件数(空车为 0)") + raw_status: str = Field(description="站点 cart count API 的状态码字符串,\"100\" 表示正常、\"101\" 表示空车") class CartClearData(BaseModel): """清空购物车响应数据""" - removed_count: int # 实际点击「削除」按钮的次数 - cart_count: int # -1 表示末尾 count 查询失败 + removed_count: int = Field(description="实际点击「削除」按钮的次数") + cart_count: int = Field(description="清空后的购物车商品件数;-1 表示末尾 count 查询失败") class CartRemoveData(BaseModel): """删除指定商品响应数据""" - removed: bool # 末尾 cart HTML 已不含 item_id 时为 true - item_id: str + removed: bool = Field(description="末尾 cart HTML 已不含该 item_id 时为 true") + item_id: str = Field(description="被删除的商品 ID")