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

87 lines
3.0 KiB
Python

"""价格观测落库:一次比价的整批观测幂等写入。
幂等键 = (trace_id, platform, scope)。pricebot 重试 / replay 会重复上报同一 trace,
这里先查该 trace 已存在的 (platform, scope),只插新的——跨方言(PG / SQLite dev)都安全,
不依赖 ON CONFLICT。极端并发下若仍撞唯一约束,IntegrityError 回滚后忽略(本批本就幂等)。
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.price_observation import PriceObservation
from app.schemas.price_observation import PriceObservationBatchIn
logger = logging.getLogger("shagua.price_obs")
def insert_batch(db: Session, payload: PriceObservationBatchIn) -> tuple[int, int]:
"""写入一次比价的整批观测,返回 (inserted, skipped)。
skipped = 因 (trace_id, platform, scope) 已存在而幂等跳过的条数。
"""
if not payload.observations:
return 0, 0
# 该 trace 已有的 (platform, scope),用于幂等过滤
existing: set[tuple[str, str]] = set(
db.execute(
select(PriceObservation.platform, PriceObservation.scope).where(
PriceObservation.trace_id == payload.trace_id
)
).all()
)
inserted = 0
skipped = 0
for obs in payload.observations:
key = (obs.platform, obs.scope)
if key in existing:
skipped += 1
continue
existing.add(key) # 同批内去重(防 pricebot 同帧给重复平台)
db.add(
PriceObservation(
trace_id=payload.trace_id,
business_type=payload.business_type,
platform=obs.platform,
platform_store_id=obs.platform_store_id,
store_name=payload.store_name,
city=payload.city,
geohash=payload.geohash,
lng=payload.lng,
lat=payload.lat,
is_source=obs.is_source,
scope=obs.scope,
price_cents=obs.price_cents,
coupon_saved_cents=obs.coupon_saved_cents,
coupon_name=obs.coupon_name,
store_closed=obs.store_closed,
rank=obs.rank,
dishes=payload.dishes,
attrs=obs.attrs,
source_device_id=payload.source_device_id,
source_user_id=payload.source_user_id,
)
)
inserted += 1
if inserted == 0:
return 0, skipped
try:
db.commit()
except IntegrityError:
# 并发下另一个请求刚插了同 (trace,platform,scope) → 唯一约束撞了。
# 本批本就幂等,回滚后当作全跳过(不重试,失败就失败,见 P0 约定)。
db.rollback()
logger.warning(
"price_observation 并发幂等冲突 trace=%s,本批回滚跳过", payload.trace_id
)
return 0, skipped + inserted
return inserted, skipped