"""比价记录 endpoint(「我的比价记录」+ admin 数据源)。 路由前缀 `/api/v1/compare`: POST /record (灰度期兼容)老客户端上报,按 **trace_id** 幂等 + 不降级 success GET /records 比价记录列表(游标分页) GET /records/{id} 单条详情(含 raw_payload 全量) **均需鉴权**(CurrentUser)。⚠️ 写路径现以 `compare.py` 透传壳的**后端 harvest** 为主 (帧0 建 running 行 → done/finalize 落终态,新客户端不再 POST);本 POST /record 仅灰度期 给老客户端用,与 harvest 按 trace_id reconcile。新版覆盖够高后可下线本 POST(阶段3)。 """ from __future__ import annotations import logging from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status from app.api.deps import CurrentUser, DbSession from app.repositories import comparison as crud_compare from app.repositories import risk as risk_repo from app.schemas.compare_record import ( CompareStartReserveIn, CompareStartReserveOut, CompareStatsOut, ComparisonRecordCreatedOut, ComparisonRecordDetailOut, ComparisonRecordIn, ComparisonRecordOut, ComparisonRecordPage, ) from app.services.comparison_llm_backfill import backfill_comparison_llm_cost logger = logging.getLogger("shagua.compare_record") router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"]) @router.post( "/start", response_model=CompareStartReserveOut, summary="预占一次当日比价发起次数(上限由风控监控配置)", ) def reserve_compare_start( payload: CompareStartReserveIn, user: CurrentUser, db: DbSession, ) -> CompareStartReserveOut: daily_limit = risk_repo.get_rule_threshold(db, risk_repo.RULE_COMPARE_DAILY) if risk_repo.is_restricted( db, subject_type="user", subject_id=str(user.id), scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT, ): raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用") try: rec, used = crud_compare.reserve_daily_start( db, user_id=user.id, trace_id=payload.trace_id, daily_limit=daily_limit, business_type=payload.business_type, device_id=payload.device_id, ) except crud_compare.DailyCompareStartLimitExceeded: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=f"今日已比价超过{daily_limit}次,请明天再试", ) from None except crud_compare.ComparisonTraceOwnershipError: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="比价任务标识冲突,请重新发起", ) from None # 告警和业务限流共用同一动态阈值,避免后台已修改但用户侧仍固定为 100 次。 risk_repo.sync_compare_incident(db, user_id=user.id, at=rec.created_at) return CompareStartReserveOut( limit=daily_limit, used=used, remaining=max(daily_limit - used, 0), ) @router.post( "/record", response_model=ComparisonRecordCreatedOut, summary="上报一次比价结果(幂等)", ) def report_record( payload: ComparisonRecordIn, user: CurrentUser, db: DbSession, background_tasks: BackgroundTasks, ) -> ComparisonRecordCreatedOut: if risk_repo.is_restricted( db, subject_type="user", subject_id=str(user.id), scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT, ): raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用") rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload) # LLM 调用明细异步回填:同机拉 pricebot llm_calls 最长 5s 且是 best-effort,放后台 # 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用 # 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。 background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id) # 注:邀请发奖已从"比价成功"挪到"实际下单"(见 api/v1/order.py report_order)—— # 冰拍板:被邀请人完成比价并实际下单才算邀请成功,仅完成比价不再发奖。 logger.info( "compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)", user.id, rec.trace_id, rec.business_type, rec.status, rec.saved_amount_cents, ) return ComparisonRecordCreatedOut(id=rec.id) def _backfill_llm_calls(record_id: int, trace_id: str) -> None: """后台回填本次比价的 LLM 调用明细 + 派生 llm_call_count/retry_count。 独立 DB session(请求 session 此时已关);拉取/写库失败只 log,绝不影响已落库的上报。""" backfill_comparison_llm_cost(record_id, trace_id) @router.get( "/stats", response_model=CompareStatsOut, summary="比价口径战绩(完成比价数 + 累计发现可省)", ) def stats(user: CurrentUser, db: DbSession) -> CompareStatsOut: count, saved = crud_compare.get_stats(db, user.id) return CompareStatsOut(compare_count=count, discovered_saved_cents=saved) @router.get( "/records", response_model=ComparisonRecordPage, summary="比价记录列表(游标分页)", ) def list_records( user: CurrentUser, db: DbSession, limit: int = Query(20, ge=1, le=100), cursor: int | None = Query(None, description="上一页末条 id"), ordered: bool | None = Query( None, description="true=只看「已下单」(店名命中本人真实下单)的记录;不传=全部", ), keyword: str | None = Query( None, max_length=64, description="按店名 / 菜名模糊搜索,忽略大小写;空白串等同不传", ), include_trace: bool = Query( False, description="客户端开了本机 agent 调试模式时带 true,放行本人记录的 trace_url", ), ) -> ComparisonRecordPage: items, next_cursor = crud_compare.list_records( db, user.id, limit=limit, cursor=cursor, ordered=ordered, keyword=keyword ) outs = [ComparisonRecordOut.model_validate(it) for it in items] # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)。 # include_trace=true 例外:客户端开了本机 agent 调试模式时带上,放行**本人记录**的 trace_url # ——list_records 只查 user.id 自己的记录,给本人看自己的调试链接无越权,与实时结果页 # CompResultScreen「debug 权限 OR 本机 agent 调试」同口径(领导 2026-06-12 拍板)。 if not (user.debug_trace_enabled or include_trace): for o in outs: o.trace_url = None return ComparisonRecordPage(items=outs, next_cursor=next_cursor) @router.get( "/records/{record_id}", response_model=ComparisonRecordDetailOut, summary="比价记录详情(含 raw_payload)", ) def get_record( record_id: int, user: CurrentUser, db: DbSession ) -> ComparisonRecordDetailOut: rec = crud_compare.get_record(db, user.id, record_id) if rec is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="record not found") out = ComparisonRecordDetailOut.model_validate(rec) # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url。 # ⚠️ raw_payload 是上报体全量(model_dump),里面也藏着一份 trace_url,必须一并抹掉—— # 否则无权限用户从详情接口的 raw_payload 绕过权限闸拿到 trace_url。 if not user.debug_trace_enabled: out.trace_url = None if isinstance(out.raw_payload, dict): out.raw_payload.pop("trace_url", None) return out