"""价格观测表(price_observation)—— 比价沉淀的价格资产层。 每完成一次比价,pricebot 在 done 帧把"在某平台某店,这一篮菜/这单到手价多少钱" 按平台逐条算出来,server→server 内部上报落这里。**与登录无关、不依赖客户端上报**—— 比价透传链路当前不鉴权,只要比价跑到 done 就记,匿名用户也记(来源记 device_id, user_id 客户端带上时一并记)。 与 comparison_record 的区别: - comparison_record:用户视角的「我的比价记录」,客户端登录后主动上报,按 user_id 存。 - price_observation:**平台/门店视角的价格事实**,server 侧无条件沉淀,按 (平台,门店,菜,时间) 组织,是未来"别人查过同店→直接秒回价格"大数据的源头。两表独立,互不影响。 「先存下来、用法后说」:结构化列给将来按 平台/门店/菜/时间/地理 查询聚合用;dishes / attrs (JSONB)兜底存灵活明细,免得每多记一个字段就迁移 schema。raw trace(逐 step 全过程) 另存 pricebot 本地 jsonl.gz,本表只存提炼后的价格事实(本表可从 raw 重算)。 """ from __future__ import annotations from datetime import datetime from sqlalchemy import ( JSON, Boolean, DateTime, Float, Integer, String, UniqueConstraint, func, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base # PG 上用 JSONB(可建 GIN 索引),SQLite(本地/测试)退化为通用 JSON(同 comparison_record)。 _JSON = JSON().with_variant(JSONB(), "postgresql") class PriceObservation(Base): __tablename__ = "price_observation" __table_args__ = ( # 一次比价(trace)里,同一平台、同一口径(scope)只记一条:pricebot 重试 / 客户端 # replay 重复上报时幂等去重(insert-or-ignore)。一次外卖比价 = 源 + N 个目标平台 # 各一条 scope='order'。 UniqueConstraint( "trace_id", "platform", "scope", name="uq_price_obs_trace_platform_scope", ), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) # 观测时刻(≈ 比价 done 时;服务端落库时取 now)。按时间查"最近有效价"用,建索引。 observed_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True, nullable=False ) # pricebot 侧 trace_id:回指 A 层原始 trace(能溯源 / 重算)+ 幂等去重键 trace_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False) # 业务类型:food(外卖,当前唯一接通)/ ecom(电商,二期)。预留扩展。 business_type: Mapped[str] = mapped_column( String(16), nullable=False, default="food", index=True ) # ===== 门店维度(先 denormalize 成文本,不建维度表;实体归一二期再做)===== platform: Mapped[str] = mapped_column(String(32), index=True, nullable=False) # 平台内门店 ID(抓得到就存,将来归一最稳的 key;现在多为空) platform_store_id: Mapped[str | None] = mapped_column(String(64), nullable=True) store_name: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True) city: Mapped[str | None] = mapped_column(String(64), nullable=True) # 地理(现在拿不到 → 留空;二期把 device 经纬度透传进比价请求后回填) geohash: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True) lng: Mapped[float | None] = mapped_column(Float, nullable=True) lat: Mapped[float | None] = mapped_column(Float, nullable=True) # ===== 价格事实 ===== # 是否源平台(发起比价那家)。源平台价也是真实观测,照记。 is_source: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) # 口径:order(整单到手价,外卖当前唯一)/ dish(单菜单价,引擎暂不逐菜读,二期) scope: Mapped[str] = mapped_column(String(16), nullable=False, default="order") # 该口径的到手价(分);None = 该平台采集失败 / 门店打烊,无有效价 price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) coupon_saved_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) coupon_name: Mapped[str | None] = mapped_column(String(64), nullable=True) # 该平台目标店打烊原因(非空时 price_cents 为 None) store_closed: Mapped[str | None] = mapped_column(String(32), nullable=True) # 本次比价里的全局名次(1=最便宜;按到手价升序,源/目标统一排) rank: Mapped[int | None] = mapped_column(Integer, nullable=True) # ===== 明细(JSON)===== # 菜篮 [{name, qty, subtotal?}](外卖)。dish 级单价二期从这里 + 引擎增强推。 dishes: Mapped[list | None] = mapped_column(_JSON, nullable=True) # 灵活字段兜底(配送费 / 起送 / 活动名 / 跳过菜数 等),免得加字段就迁移。 attrs: Mapped[dict | None] = mapped_column(_JSON, nullable=True) # ===== 来源(溯源 / 去重 / 置信)===== source_device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # user_id 当前比价链路不鉴权拿不到,客户端带上时才有;先可空。 source_user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True) confidence: Mapped[float] = mapped_column(Float, nullable=False, default=1.0) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) def __repr__(self) -> str: # pragma: no cover return ( f"" )