Files
marco f5d1a1a20d feat(price-observation): 价格观测资产表 + pricebot 内部上报端点 (#21)
比价沉淀的价格事实(平台/门店/菜篮/到手价/时间/来源)无条件落库,server 侧存储、
与登录无关,独立于用户视角的 comparison_record。是未来"别人查过同店→秒回价格"
价格大数据的源头(先存数据,用法后续)。

- models/price_observation.py: 新增 price_observation 表。结构化列(平台/门店/价格/
  红包/地理/来源)+ dishes/attrs 两个 JSONB 兜底列;(trace_id,platform,scope) 幂等
  唯一约束;observed_at/store_name/geohash/source_device_id/source_user_id 等索引
- alembic/versions/price_observation_table.py: 建表迁移,down_revision=wx_transfer_auth
  (已核为当前唯一 head,无多 head 风险)
- schemas/ + repositories/price_observation.py: 批量收发模型 + 跨方言(PG/SQLite)幂等
  批量插入(先查 trace 已有 (platform,scope) 只插新的,并发撞唯一约束则回滚跳过)
- api/internal/price.py: 新增 POST /internal/price-observation,X-Internal-Secret 头校验
  (常量时间比较,未配密钥→503),server→server 专用,不走用户 JWT
- core/config.py + main.py: 新增 INTERNAL_API_SECRET 配置 + 注册内部路由
- .env.example: INTERNAL_API_SECRET(须与 pricebot 侧同值,留空=内部写端点关闭)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: pure <pure@192.168.0.104>
Reviewed-on: #21
2026-06-07 03:31:35 +08:00

50 lines
1.8 KiB
Python

"""价格观测内部上报的收发模型。
pricebot 在比价 done 帧 server→server POST 一批观测(一次比价 = 源 + N 目标平台各一条)。
批级字段(门店 / 菜篮 / 地理 / 来源)所有观测共享,放外层;逐平台字段放 observations[]。
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class PriceObservationItem(BaseModel):
"""单个平台的价格观测(一次比价里的一行)。"""
platform: str
is_source: bool = False
scope: str = "order" # order / dish
price_cents: int | None = None # 到手价(分);None=该平台失败/打烊
coupon_saved_cents: int | None = None
coupon_name: str | None = None
store_closed: str | None = None # 打烊原因(非空时 price_cents=None)
rank: int | None = None # 全局名次(1=最便宜)
platform_store_id: str | None = None
attrs: dict | None = None # 该平台的灵活明细兜底
class PriceObservationBatchIn(BaseModel):
"""一次比价的整批价格观测上报体。"""
trace_id: str
business_type: str = "food"
# 门店 / 菜篮 / 地理 —— 一次比价同一份,批级共享
store_name: str | None = None
city: str | None = None
geohash: str | None = None
lng: float | None = None
lat: float | None = None
dishes: list | None = None # [{name, qty, subtotal?}]
# 来源
source_device_id: str | None = None
source_user_id: int | None = None
# 逐平台观测(源 + 各目标)
observations: list[PriceObservationItem] = Field(default_factory=list)
class PriceObservationBatchOut(BaseModel):
"""上报结果:本批新写入条数 / 因幂等跳过条数。"""
inserted: int
skipped: int