f5d1a1a20d
比价沉淀的价格事实(平台/门店/菜篮/到手价/时间/来源)无条件落库,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
64 lines
2.3 KiB
Python
64 lines
2.3 KiB
Python
"""价格观测内部上报端点(pricebot → app-server)。
|
|
|
|
pricebot 在比价 done 帧把整批价格事实 POST 到这里落库。**不是给客户端的接口**:
|
|
不走用户 JWT,靠 server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。
|
|
密钥未配置(默认空)时直接 503,避免裸奔的写端点。
|
|
|
|
与不鉴权的比价透传(compare.py / coupon.py)无关:那两个是客户端→server 的透传壳,
|
|
这个是 server→server 的内部写库。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import logging
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Header, HTTPException, status
|
|
|
|
from app.api.deps import DbSession
|
|
from app.core.config import settings
|
|
from app.repositories import price_observation as repo
|
|
from app.schemas.price_observation import (
|
|
PriceObservationBatchIn,
|
|
PriceObservationBatchOut,
|
|
)
|
|
|
|
logger = logging.getLogger("shagua.internal.price")
|
|
|
|
router = APIRouter(prefix="/internal", tags=["internal"])
|
|
|
|
|
|
def _check_secret(x_internal_secret: str | None) -> None:
|
|
"""共享密钥校验。未配置 → 503(挡住裸奔写端点);不匹配 → 401(常量时间比较)。"""
|
|
configured = settings.INTERNAL_API_SECRET
|
|
if not configured:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="internal api not configured",
|
|
)
|
|
if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="invalid internal secret",
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/price-observation",
|
|
response_model=PriceObservationBatchOut,
|
|
summary="价格观测内部上报(pricebot→app-server,落 price_observation)",
|
|
)
|
|
def report_price_observation(
|
|
payload: PriceObservationBatchIn,
|
|
db: DbSession,
|
|
x_internal_secret: Annotated[str | None, Header()] = None,
|
|
) -> PriceObservationBatchOut:
|
|
_check_secret(x_internal_secret)
|
|
inserted, skipped = repo.insert_batch(db, payload)
|
|
logger.info(
|
|
"price_observation trace=%s inserted=%d skipped=%d device=%s user=%s",
|
|
payload.trace_id, inserted, skipped,
|
|
payload.source_device_id, payload.source_user_id,
|
|
)
|
|
return PriceObservationBatchOut(inserted=inserted, skipped=skipped)
|