"""比价记录 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 _product_names_from_items(items: list | None) -> str | None: """下单商品 items([{name, qty, specs?}])→ 顿号分隔的商品名串(去重保序), 供 admin「商品」列展示 + 商品名 LIKE 搜索。空 / 无名 → None;超列宽(512)截断留余量。""" if not items: return None names: list[str] = [] for it in items: name = it.get("name") if isinstance(it, dict) else None if not name: continue s = str(name).strip() if s and s not in names: names.append(s) joined = "、".join(names) return joined[:500] or None 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: """按 **trace_id** 幂等写入(唯一键已从 user_id+trace_id 改为 trace_id):已存在则合并 (回填 null user_id + 覆盖字段,但**不降级 success**),否则新建。 灰度期老客户端 POST /compare/record 走这条,与后端 harvest 按 trace_id reconcile; 新客户端不再 POST(改由 compare.py 透传壳 harvest 落库)。 """ derived = _derive(payload) items = [it.model_dump(exclude_none=True) for it in payload.items] fields = dict( device_id=payload.device_id, business_type=payload.business_type, store_name=payload.store_name, product_names=_product_names_from_items(items), 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=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, ) # 按 trace_id 定位(唯一键已改 trace_id):后端 harvest 可能已建行,这里的客户端上报 # (灰度期老客户端 / 新客户端不再走这条)与之 reconcile。 existing = db.execute( select(ComparisonRecord).where( ComparisonRecord.trace_id == payload.trace_id, ) ).scalar_one_or_none() if existing is not None: # 不降级:harvest 或更早上报已落成 success,收尾期 fromFailure 的 cancelled/failed # 不许把它盖回去(老 comparisonReported bug 的服务端兜底)——只补 user_id / trace_url。 if existing.status == "success" and fields.get("status") != "success": if existing.user_id is None and user_id is not None: existing.user_id = user_id if fields.get("trace_url"): existing.trace_url = fields["trace_url"] db.commit() db.refresh(existing) return existing # 只**填**空缺 user_id、不 reassign:trace_id 全局唯一,一条 trace 只属一个用户; # 绝不把已有归属的记录改判给另一个上报者(仅 harvest 建的 null-user 行在此绑上)。 if existing.user_id is None: existing.user_id = user_id 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 # ============================================================ # 后端 harvest:app-server 从 pricebot 透传响应里直接落库(不靠客户端上报)。 # 帧0 建行(running) → 最终 done 更新(success/failed) → finalize 更新(aborted)。 # 全按 trace_id upsert;success 行永不被后到的 failed/aborted 降级。 # ============================================================ def _derive_from_results(results: list[dict]) -> dict: """从 done 帧 comparison_results(pricebot 原始 dict 列表)派生结构化列。 等价 _derive,但吃原始字段(is_source/price/rank/platform_id/store_name...)而非 pydantic 对象。""" priced = [r for r in results if r.get("price") is not None] best = None if priced: best = min( priced, key=lambda r: (r.get("rank") if r.get("rank") is not None else 10**9, r["price"]), ) src_row = next((r for r in results if r.get("is_source")), None) source_price_cents = None if src_row is not None and src_row.get("price") 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 has_valid_target = any( (not r.get("is_source")) and r.get("price") is not None for r in results ) return { "source_platform_id": (src_row or {}).get("platform_id"), "source_platform_name": (src_row or {}).get("platform_name"), "source_package": (src_row or {}).get("package"), "source_price_cents": source_price_cents, "best_platform_id": best.get("platform_id") if best else None, "best_platform_name": best.get("platform_name") if best else None, "best_price_cents": best_price_cents, "saved_amount_cents": saved_amount_cents, "is_source_best": best.get("is_source") if best else None, "store_name": (src_row or {}).get("store_name") or None, "status": "success" if has_valid_target else "failed", } def _device_cols_from_info(device_info: dict | None) -> dict: """step 帧 device_info({locale,brand,model,android_version,rom_version})→ 表列。 step 帧只带这几项;rom_name / android_sdk / app_version / 耗时步数 harvest 拿不到 → 留 None (灰度期老客户端 fromComparison 会补齐;要新客户端也全带需扩 collectDeviceInfo,后续)。""" d = device_info or {} rv = str(d.get("rom_version") or "") return { "device_model": d.get("model") or None, "device_manufacturer": d.get("brand") or None, "android_version": d.get("android_version") or None, "rom_version": int(rv) if rv.isdigit() else None, } def _get_by_trace(db: Session, trace_id: str) -> ComparisonRecord | None: return db.execute( select(ComparisonRecord).where(ComparisonRecord.trace_id == trace_id) ).scalar_one_or_none() def harvest_running( db: Session, *, trace_id: str, user_id: int | None, business_type: str = "food", device_id: str | None = None, device_info: dict | None = None, trace_url: str | None = None, ) -> ComparisonRecord: """帧0(或任一尚未建行的帧)建/补 running 行。幂等:已存在只补空缺(user_id/trace_url/ device_id/机型),绝不动已落定的 status / 结果。""" rec = _get_by_trace(db, trace_id) if rec is None: rec = ComparisonRecord( trace_id=trace_id, user_id=user_id, business_type=business_type or "food", device_id=device_id, status="running", trace_url=trace_url, created_at=datetime.now(CN_TZ).replace(tzinfo=None), **_device_cols_from_info(device_info), ) db.add(rec) db.commit() db.refresh(rec) return rec changed = False if rec.user_id is None and user_id is not None: rec.user_id = user_id changed = True if not rec.trace_url and trace_url: rec.trace_url = trace_url changed = True if rec.device_id is None and device_id: rec.device_id = device_id changed = True for k, v in _device_cols_from_info(device_info).items(): if getattr(rec, k) is None and v is not None: setattr(rec, k, v) changed = True if changed: db.commit() db.refresh(rec) return rec def harvest_done( db: Session, *, trace_id: str, user_id: int | None, done_params: dict, business_type: str = "food", device_id: str | None = None, device_info: dict | None = None, trace_url: str | None = None, ) -> tuple[ComparisonRecord, bool]: """最终 done 帧:running 行 → 终态(success/failed)+结果+trace_url。 返回 (记录, 是否本次**新**落成 success)——供调用方据此幂等发一次邀请奖。 行不存在(理论上帧0已建;防御)则新建。""" results = done_params.get("comparison_results") or [] derived = _derive_from_results(results) # 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items items = next((r.get("items") or [] for r in results if r.get("is_source")), []) fields = dict( business_type=business_type or "food", information=done_params.get("information") or None, # best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补; # 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。 trace_url=trace_url or done_params.get("trace_url"), total_dish_count=done_params.get("total_dish_count"), skipped_dish_count=done_params.get("skipped_dish_count"), skipped_dish_names=list(done_params.get("skipped_dish_names") or []), comparison_results=results, items=items, product_names=_product_names_from_items(items), raw_payload=done_params, **derived, **_device_cols_from_info(device_info), ) rec = _get_by_trace(db, trace_id) was_success = rec is not None and rec.status == "success" if rec is None: rec = ComparisonRecord( trace_id=trace_id, user_id=user_id, created_at=datetime.now(CN_TZ).replace(tzinfo=None), device_id=device_id, **fields, ) db.add(rec) else: if user_id is not None and rec.user_id is None: rec.user_id = user_id if device_id and rec.device_id is None: rec.device_id = device_id for k, v in fields.items(): setattr(rec, k, v) db.commit() db.refresh(rec) newly_success = (rec.status == "success") and not was_success return rec, newly_success def harvest_abort( db: Session, *, trace_id: str, status: str, reason: str | None, trace_url: str | None = None, ) -> ComparisonRecord | None: """finalize(用户终止/超时/异常,无 done 帧):running 行 → aborted/failed + trace_url。 **不降级 success**:行已 success(收尾取消那种 finalize 后到)只 refresh trace_url。 行不存在(极少:帧0没建成)→ 返回 None,不凭空造。""" rec = _get_by_trace(db, trace_id) if rec is None: return None if trace_url and not rec.trace_url: rec.trace_url = trace_url if rec.status != "success": rec.status = status or "cancelled" if reason: rec.information = reason 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