From a2fd666203c95706164090b0c73f364bec6a585b Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 17 Jun 2026 22:33:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=AF=94=E4=BB=B7=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=20debug=20=E9=A1=B5=E5=90=8E=E7=AB=AF=E2=80=94?= =?UTF-8?q?=E2=80=94=E8=A1=A5=E5=85=A8=E9=93=BE=E8=B7=AF=20+=20DB=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit model/schema 的 debug 字段已在 4ee6de2 起头,本次补完整条链路: - repo upsert 存客户端环境/性能字段;端点同机拉 pricebot llm_calls 落库 + 算 llm次数/重试 - schema 加 platform_results 透传(admin「卡在哪一步」从 raw_payload 读) - admin 比价记录查询接口(按 user_id/phone 列表分页 + 详情含 llm_calls) - 迁移 comparison_debug_fields: 给 comparison_record 加环境/性能/llm_calls 列(全 nullable) Co-Authored-By: Claude Opus 4.8 --- alembic/versions/comparison_debug_fields.py | 54 ++++++++++++++++ app/admin/main.py | 2 + app/admin/repositories/queries.py | 57 +++++++++++++++++ app/admin/routers/comparison.py | 58 +++++++++++++++++ app/admin/schemas/comparison.py | 70 +++++++++++++++++++++ app/api/v1/compare_record.py | 12 +++- app/repositories/comparison.py | 15 +++++ app/schemas/compare_record.py | 4 ++ app/services/pricebot_llm_calls.py | 38 +++++++++++ 9 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/comparison_debug_fields.py create mode 100644 app/admin/routers/comparison.py create mode 100644 app/admin/schemas/comparison.py create mode 100644 app/services/pricebot_llm_calls.py diff --git a/alembic/versions/comparison_debug_fields.py b/alembic/versions/comparison_debug_fields.py new file mode 100644 index 0000000..2d472d9 --- /dev/null +++ b/alembic/versions/comparison_debug_fields.py @@ -0,0 +1,54 @@ +"""comparison_record: 客户端环境 / 性能统计 / LLM 明细 debug 字段 + +Revision ID: comparison_debug_fields +Revises: cps_activity_image +Create Date: 2026-06-17 22:00:00.000000 + +给 admin 比价记录 debug 页新增采集列,全部 nullable(旧记录 / 未发版客户端为 NULL,向后兼容): +- 客户端环境: device_*/rom_*/android_*/app_version*/source_app_version/经纬度 +- 过程统计: total_ms/step_count/llm_call_count/retry_count +- LLM 明细: llm_calls(JSONB,server 按 trace_id 同机拉 pricebot 落库) +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "comparison_debug_fields" +down_revision: Union[str, Sequence[str], None] = "cps_activity_image" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_JSONB = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") + + +def upgrade() -> None: + op.add_column("comparison_record", sa.Column("device_model", sa.String(64), nullable=True)) + op.add_column("comparison_record", sa.Column("device_manufacturer", sa.String(64), nullable=True)) + op.add_column("comparison_record", sa.Column("rom_vendor", sa.String(32), nullable=True)) + op.add_column("comparison_record", sa.Column("rom_name", sa.String(32), nullable=True)) + op.add_column("comparison_record", sa.Column("rom_version", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("android_version", sa.String(16), nullable=True)) + op.add_column("comparison_record", sa.Column("android_sdk", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("app_version", sa.String(32), nullable=True)) + op.add_column("comparison_record", sa.Column("app_version_code", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("source_app_version", sa.String(32), nullable=True)) + op.add_column("comparison_record", sa.Column("longitude", sa.Float(), nullable=True)) + op.add_column("comparison_record", sa.Column("latitude", sa.Float(), nullable=True)) + op.add_column("comparison_record", sa.Column("total_ms", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("step_count", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("llm_call_count", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("retry_count", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("llm_calls", _JSONB, nullable=True)) + + +def downgrade() -> None: + for col in ( + "llm_calls", "retry_count", "llm_call_count", "step_count", "total_ms", + "latitude", "longitude", "source_app_version", "app_version_code", + "app_version", "android_sdk", "android_version", "rom_version", + "rom_name", "rom_vendor", "device_manufacturer", "device_model", + ): + op.drop_column("comparison_record", col) diff --git a/app/admin/main.py b/app/admin/main.py index a67b403..b718441 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -18,6 +18,7 @@ from app.admin.routers.ad_revenue import router as ad_revenue_router from app.admin.routers.admins import router as admins_router from app.admin.routers.audit import router as audit_router from app.admin.routers.auth import router as auth_router +from app.admin.routers.comparison import router as comparison_router from app.admin.routers.config import router as config_router from app.admin.routers.cps import router as cps_router from app.admin.routers.dashboard import router as dashboard_router @@ -91,6 +92,7 @@ admin_app.include_router(feedback_router) admin_app.include_router(admins_router) admin_app.include_router(audit_router) admin_app.include_router(config_router) +admin_app.include_router(comparison_router) admin_app.include_router(cps_router) admin_app.include_router(ad_audit_router) admin_app.include_router(ad_revenue_router) diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 6e39192..7294ab7 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -109,6 +109,63 @@ def list_users( return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor) +def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None: + """给每条比价记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。""" + uids = {r.user_id for r in records} + if not uids: + return + rows = db.execute( + select(User.id, User.phone, User.nickname).where(User.id.in_(uids)) + ).all() + umap = {uid: (phone, nick) for uid, phone, nick in rows} + for r in records: + phone, nick = umap.get(r.user_id, (None, None)) + r.phone = phone + r.nickname = nick + + +def list_comparison_records( + db: Session, + *, + user_id: int | None = None, + phone: str | None = None, + status: str | None = None, + business_type: str | None = None, + limit: int = 20, + cursor: int | None = None, +) -> tuple[list[ComparisonRecord], int | None, int]: + """admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛, + offset 分页(创建时间倒序、id 兜底)。join User 取 phone/nickname 瞬态挂记录上。""" + stmt = select(ComparisonRecord) + if user_id is not None: + stmt = stmt.where(ComparisonRecord.user_id == user_id) + if phone: + stmt = stmt.where( + ComparisonRecord.user_id.in_( + select(User.id).where(User.phone.like(f"{phone}%")) + ) + ) + if status: + stmt = stmt.where(ComparisonRecord.status == status) + if business_type: + stmt = stmt.where(ComparisonRecord.business_type == business_type) + items, next_cursor, total = offset_paginate( + db, stmt, + (desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)), + limit=limit, cursor=cursor, + ) + _attach_user_info(db, items) + return items, next_cursor, total + + +def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None: + """admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。""" + rec = db.get(ComparisonRecord, record_id) + if rec is not None: + _attach_user_info(db, [rec]) + return rec + + def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]: """按设备(device_id, ANDROID_ID)聚合 onboarding_completion:每台设备走过引导的账号数 + 最近完成时间,按最近完成倒序。设备维度新手引导管理用。没走过引导的设备不在表里(本就会 diff --git a/app/admin/routers/comparison.py b/app/admin/routers/comparison.py new file mode 100644 index 0000000..c9b0ccb --- /dev/null +++ b/app/admin/routers/comparison.py @@ -0,0 +1,58 @@ +"""admin 比价记录 debug:按 user_id / phone 查列表 + 单条详情(含 LLM 每次调用明细 / 原始 payload)。 + +只读。读权限默认即可(get_current_admin),不额外 require_role(查记录是基础 debug 能力)。 +trace_url 无条件下发——admin 是内部 debug 工具,不走 C 端 user.debug_trace_enabled 权限闸。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.admin.deps import AdminDb, get_current_admin +from app.admin.repositories import queries +from app.admin.schemas.common import CursorPage +from app.admin.schemas.comparison import AdminComparisonDetail, AdminComparisonListItem + +router = APIRouter( + prefix="/admin/api/comparison-records", + tags=["admin-comparison"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get( + "", + response_model=CursorPage[AdminComparisonListItem], + summary="比价记录列表(按 user_id/phone 筛 + 分页)", +) +def list_comparison_records( + db: AdminDb, + user_id: Annotated[int | None, Query()] = None, + phone: Annotated[str | None, Query(description="手机号前缀")] = None, + status: Annotated[str | None, Query(pattern="^(success|failed)$")] = None, + business_type: Annotated[str | None, Query()] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: Annotated[int | None, Query()] = None, +) -> CursorPage[AdminComparisonListItem]: + items, next_cursor, total = queries.list_comparison_records( + db, user_id=user_id, phone=phone, status=status, + business_type=business_type, limit=limit, cursor=cursor, + ) + return CursorPage( + items=[AdminComparisonListItem.model_validate(r) for r in items], + next_cursor=next_cursor, + total=total, + ) + + +@router.get( + "/{record_id}", + response_model=AdminComparisonDetail, + summary="比价记录详情(含 LLM 每次调用明细 + 原始 payload)", +) +def get_comparison_record(record_id: int, db: AdminDb) -> AdminComparisonDetail: + rec = queries.get_comparison_record(db, record_id) + if rec is None: + raise HTTPException(status_code=404, detail="记录不存在") + return AdminComparisonDetail.model_validate(rec) diff --git a/app/admin/schemas/comparison.py b/app/admin/schemas/comparison.py new file mode 100644 index 0000000..2fef36d --- /dev/null +++ b/app/admin/schemas/comparison.py @@ -0,0 +1,70 @@ +"""admin 比价记录 debug 页 schema(独立于 C 端 app/schemas/compare_record:admin 看任意用户、 +无 debug_trace 权限闸、展示全量 debug 字段)。phone/nickname 由 queries 瞬态挂在 ORM 实例上。""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class AdminComparisonListItem(BaseModel): + """列表项:概要 + debug 维度概览(不含大字段 raw_payload/llm_calls/明细数组)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + phone: str | None = None # join User 瞬态(非 DB 列) + nickname: str | None = None # join User 瞬态 + business_type: str + trace_id: str + # admin 是 debug 工具,无条件下发 trace_url(不看 user.debug_trace_enabled) + trace_url: str | None = None + status: str + information: str | None = None + store_name: str | None = None + source_platform_name: str | None = None + best_platform_name: str | None = None + source_price_cents: int | None = None + best_price_cents: int | None = None + saved_amount_cents: int | None = None + # debug 概览 + total_ms: int | None = None + step_count: int | None = None + llm_call_count: int | None = None + retry_count: int | None = None + device_model: str | None = None + rom_vendor: str | None = None + rom_name: str | None = None + android_version: str | None = None + app_version: str | None = None + created_at: datetime + + +class AdminComparisonDetail(AdminComparisonListItem): + """详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。""" + + source_platform_id: str | None = None + source_package: str | None = None + best_platform_id: str | None = None + best_deeplink: str | None = None + is_source_best: bool | None = None + total_dish_count: int | None = None + skipped_dish_count: int | None = None + device_id: str | None = None + items: list = [] + comparison_results: list = [] # 逐平台对比(价格/rank/coupon/打烊...) + skipped_dish_names: list = [] + # 全量环境 + device_manufacturer: str | None = None + rom_version: int | None = None + android_sdk: int | None = None + app_version_code: int | None = None + source_app_version: str | None = None + longitude: float | None = None + latitude: float | None = None + # 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}] + llm_calls: list | None = None + # 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读 + # (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。 + raw_payload: dict | None = None diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index d53dd15..14286d7 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -19,6 +19,7 @@ from fastapi import APIRouter, HTTPException, Query, status from app.api.deps import CurrentUser, DbSession from app.repositories import comparison as crud_compare +from app.services.pricebot_llm_calls import fetch_llm_calls from app.schemas.compare_record import ( CompareStatsOut, ComparisonRecordCreatedOut, @@ -42,13 +43,22 @@ def report_record( payload: ComparisonRecordIn, user: CurrentUser, db: DbSession ) -> ComparisonRecordCreatedOut: rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload) + # 同机拉 pricebot 本次比价的 LLM 调用明细落库(best-effort,失败不阻断上报)。 + # llm_call_count/retry_count 直接由明细派生:次数=条数,重试=带 error 的条数。 + calls = fetch_llm_calls(rec.trace_id) + if calls: + rec.llm_calls = calls + rec.llm_call_count = len(calls) + rec.retry_count = sum(1 for c in calls if c.get("error")) + db.commit() logger.info( - "compare record user=%s trace=%s biz=%s status=%s saved=%s", + "compare record user=%s trace=%s biz=%s status=%s saved=%s llm_calls=%d", user.id, rec.trace_id, rec.business_type, rec.status, rec.saved_amount_cents, + len(calls), ) return ComparisonRecordCreatedOut(id=rec.id) diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index 640ffa0..09fa555 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -90,6 +90,21 @@ def upsert_record( 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, ) diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 1ddede8..5f7306c 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -69,6 +69,10 @@ class ComparisonRecordIn(BaseModel): # 明细 items: list[ComparisonItemIn] = Field(default_factory=list) comparison_results: list[ComparisonResultIn] = Field(default_factory=list) + # 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/ + # unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列), + # admin「卡在哪一步」从这里读。list[dict] 宽松存(结构由 pricebot 定,只作 debug 展示)。 + platform_results: list = Field(default_factory=list) skipped_dish_count: int | None = None skipped_dish_names: list[str] = Field(default_factory=list) total_dish_count: int | None = None diff --git a/app/services/pricebot_llm_calls.py b/app/services/pricebot_llm_calls.py new file mode 100644 index 0000000..1c44bac --- /dev/null +++ b/app/services/pricebot_llm_calls.py @@ -0,0 +1,38 @@ +"""按 trace_id 同机拉 pricebot 的本次比价 LLM 调用明细(供 admin 比价记录 debug)。 + +pricebot 在 chat() 收口处把每次 LLM input/output 落盘,暴露内部接口 +GET /api/internal/llm_calls/{trace_id}(X-Internal-Secret 校验,与本服务 INTERNAL_API_SECRET 同值)。 +app-server 收到比价记录上报后同机拉一次,落 comparison_record.llm_calls。 + +best-effort:拉不到(pricebot 未部署该版本 / 旧 trace 无明细 / 网络)一律返 [],绝不阻断 +比价记录上报。多实例下用 pick_pricebot(trace_id) 选对实例(同 trace 落同进程,明细在 +该进程磁盘),与比价透传同一致性 hash 口径。 +""" +from __future__ import annotations + +import logging + +import httpx + +from app.core.config import settings +from app.core.pricebot_router import pick_pricebot + +logger = logging.getLogger("shagua.pricebot_llm") + + +def fetch_llm_calls(trace_id: str) -> list[dict]: + """返回该次比价的 LLM 调用明细列表(每条 {scene,model,input_messages,output,usage,latency_ms,error}); + 未配密钥 / 无 trace_id / 拉取失败 → []。""" + secret = settings.INTERNAL_API_SECRET + if not secret or not trace_id: + return [] + base = pick_pricebot(trace_id).rstrip("/") + url = f"{base}/api/internal/llm_calls/{trace_id}" + try: + resp = httpx.get(url, headers={"X-Internal-Secret": secret}, timeout=5.0) + if resp.status_code == 200: + return resp.json().get("calls", []) or [] + logger.warning("fetch_llm_calls trace=%s status=%s", trace_id, resp.status_code) + except Exception as e: # noqa: BLE001 — best-effort,任何异常都不该影响上报 + logger.warning("fetch_llm_calls trace=%s failed: %s", trace_id, e) + return []