"""比价记录 CRUD:上报 upsert(按 user_id+trace_id 幂等) + 派生字段 + 明细分页。 派生逻辑:best_* / saved_amount_cents / is_source_best 全部从 comparison_results 算出 (协议保证已按 price 升序、rank=1 最便宜),客户端不用自己算、也不可信它算。 """ from __future__ import annotations from datetime import datetime from sqlalchemy import func, select from sqlalchemy.orm import Session from app.core.rewards import CN_TZ from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.comparison import ComparisonRecord from app.models.savings import SavingsRecord from app.schemas.compare_record import ComparisonRecordIn def _yuan_to_cents(yuan: float | None) -> int | None: """元(float)→ 分(int)。None 透传。""" if yuan is None: return None return round(yuan * 100) def _derive(payload: ComparisonRecordIn) -> dict: """从上报 payload 派生结构化列(best/saved/is_source_best/status)。""" results = payload.comparison_results # 最优 = rank 最小的一条;协议已升序,但不信顺序,显式按 rank/price 兜底取最小价。 best = None priced = [r for r in results if r.price is not None] if priced: best = min( priced, key=lambda r: (r.rank if r.rank is not None else 10**9, r.price), ) source_price_cents = _yuan_to_cents(payload.source_price) if source_price_cents is None: # 源价没单独给,从 comparison_results 里的 is_source 行兜底 src_row = next((r for r in results if r.is_source and r.price is not None), None) if src_row is not None: source_price_cents = _yuan_to_cents(src_row.price) best_price_cents = _yuan_to_cents(best.price) if best else None saved_amount_cents = None if source_price_cents is not None and best_price_cents is not None: saved_amount_cents = source_price_cents - best_price_cents is_source_best = best.is_source if best is not None else None # status:客户端显式给了就用;否则有"非源且有价"的结果=success,否则 failed status = payload.status if status is None: has_valid_target = any( (not r.is_source) and r.price is not None for r in results ) status = "success" if has_valid_target else "failed" return { "source_price_cents": source_price_cents, "best_platform_id": best.platform_id if best else None, "best_platform_name": best.platform_name if best else None, "best_price_cents": best_price_cents, "saved_amount_cents": saved_amount_cents, "is_source_best": is_source_best, "status": status, } def upsert_record( db: Session, *, user_id: int, payload: ComparisonRecordIn ) -> ComparisonRecord: """按 (user_id, trace_id) 幂等写入:已存在则覆盖(更完整的重试上报胜出),否则新建。""" derived = _derive(payload) fields = dict( device_id=payload.device_id, business_type=payload.business_type, store_name=payload.store_name, source_platform_id=payload.source_platform_id, source_platform_name=payload.source_platform_name, source_package=payload.source_package, information=payload.information, best_deeplink=payload.best_deeplink, trace_url=payload.trace_url, total_dish_count=payload.total_dish_count, skipped_dish_count=payload.skipped_dish_count, items=[it.model_dump(exclude_none=True) for it in payload.items], comparison_results=[r.model_dump() for r in payload.comparison_results], skipped_dish_names=list(payload.skipped_dish_names), # 客户端环境 / 性能(debug,客户端上报;旧客户端为 None) device_model=payload.device_model, device_manufacturer=payload.device_manufacturer, rom_vendor=payload.rom_vendor, rom_name=payload.rom_name, rom_version=payload.rom_version, android_version=payload.android_version, android_sdk=payload.android_sdk, app_version=payload.app_version, app_version_code=payload.app_version_code, source_app_version=payload.source_app_version, longitude=payload.longitude, latitude=payload.latitude, total_ms=payload.total_ms, step_count=payload.step_count, raw_payload=payload.model_dump(), **derived, ) existing = db.execute( select(ComparisonRecord).where( ComparisonRecord.user_id == user_id, ComparisonRecord.trace_id == payload.trace_id, ) ).scalar_one_or_none() if existing is not None: for k, v in fields.items(): setattr(existing, k, v) db.commit() db.refresh(existing) return existing # created_at 显式存 naive 北京 wall-clock(同 savings_record):客户端「比价记录」页 # formatRecordTime 原样切片 created_at 字符串、不转时区,而默认 server_default=func.now() # 在 SQLite 下返回 UTC → 直接慢 8h。覆盖分支不动 created_at(保留首次创建时间)。 rec = ComparisonRecord( user_id=user_id, trace_id=payload.trace_id, created_at=datetime.now(CN_TZ).replace(tzinfo=None), **fields, ) db.add(rec) db.commit() db.refresh(rec) return rec def _ordered_shop_names(db: Session, user_id: int) -> set[str]: """该用户「真实下单」(source='compare')覆盖到的店名集合,用来给比价记录打「已下单」。 只认 compare(归因命中后真实上报),demo 演示数据不算。下单上报不带 trace_id, 只能按店名对齐——两边店名同源(都来自比价意图识别阶段的门店名 query),精确相等即视为同店。 语义=店级:同一家店比价过多次,这些记录会一并标「已下单」。 """ rows = db.execute( select(SavingsRecord.shop_name).where( SavingsRecord.user_id == user_id, SavingsRecord.source == "compare", SavingsRecord.shop_name.is_not(None), ) ).scalars().all() return {s for s in rows if s} def _ad_coins_by_trace(db: Session, user_id: int, trace_ids: list[str]) -> dict[str, int]: """各 trace_id「看广告赚的金币」:按 trace_id 聚合该用户已发放(granted)的信息流广告金币。 广告金币来自比价等候期信息流(biz_type=feed_ad_reward);客户端结算时把本场 trace_id 带上, 落到 ad_feed_reward_record.trace_id。此处按 trace_id 求和 = 这次比价看广告实发的金币。 trace_id 仅比价场景客户端带(领券/福利/旧客户端为 NULL),故按 trace_id 过滤天然只算比价广告。 空集合直接返回(避免 IN () 非法)。 """ if not trace_ids: return {} rows = db.execute( select( AdFeedRewardRecord.trace_id, func.coalesce(func.sum(AdFeedRewardRecord.coin), 0), ).where( AdFeedRewardRecord.user_id == user_id, AdFeedRewardRecord.status == "granted", AdFeedRewardRecord.trace_id.in_(trace_ids), ).group_by(AdFeedRewardRecord.trace_id) ).all() return {tid: int(coin) for tid, coin in rows if tid} def list_records( db: Session, user_id: int, *, limit: int = 20, cursor: int | None = None, ) -> tuple[list[ComparisonRecord], int | None]: """比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」店级标记 + 「看广告赚的金币」(瞬态,不写库)。""" stmt = select(ComparisonRecord).where(ComparisonRecord.user_id == user_id) if cursor is not None: stmt = stmt.where(ComparisonRecord.id < cursor) stmt = stmt.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc()).limit(limit) items = list(db.execute(stmt).scalars().all()) next_cursor = items[-1].id if len(items) == limit else None # 「已下单」标记:本页记录的 store_name 若落在该用户真实下单的店名集合里即 True。 # ordered / ad_coins_earned 均非 ORM 列,仅挂实例上供 ComparisonRecordOut(from_attributes) 读出,不持久化。 ordered_shops = _ordered_shop_names(db, user_id) # 「本次比价看广告赚的金币」:按本页 trace_id 一次性聚合(同 ordered 范式)。 ad_coins = _ad_coins_by_trace(db, user_id, [it.trace_id for it in items]) for it in items: it.ordered = bool(it.store_name and it.store_name in ordered_shops) it.ad_coins_earned = ad_coins.get(it.trace_id, 0) return items, next_cursor def count_success(db: Session, user_id: int) -> int: """该用户成功比价(status='success')的条数。比价战绩里程碑的解锁进度源。""" return db.execute( select(func.count(ComparisonRecord.id)).where( ComparisonRecord.user_id == user_id, ComparisonRecord.status == "success", ) ).scalar_one() def get_stats(db: Session, user_id: int) -> tuple[int, int]: """「我的」页省钱战绩卡的**比价口径**聚合:(成功比价数, 累计发现可省额/分)。 - 完成比价 = status='success' 的比价记录数(同 count_success)。 - 累计发现可省 = 各成功比价 saved_amount_cents(源价−最低价)之和;null(没省/未识别)不计。 与「下单成交记账」savings_record 解耦:比过价就计,不要求下单(见 savings.py)。 """ row = db.execute( select( func.count(ComparisonRecord.id), func.coalesce(func.sum(ComparisonRecord.saved_amount_cents), 0), ).where( ComparisonRecord.user_id == user_id, ComparisonRecord.status == "success", ) ).one() return int(row[0]), int(row[1]) def get_record(db: Session, user_id: int, record_id: int) -> ComparisonRecord | None: """取单条(限本人,避免越权读他人记录)。附「看广告赚的金币」瞬态属性(同 list_records)。""" rec = db.execute( select(ComparisonRecord).where( ComparisonRecord.id == record_id, ComparisonRecord.user_id == user_id, ) ).scalar_one_or_none() if rec is not None: rec.ad_coins_earned = _ad_coins_by_trace(db, user_id, [rec.trace_id]).get(rec.trace_id, 0) return rec