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/alembic/versions/cps_activity_image.py b/alembic/versions/cps_activity_image.py new file mode 100644 index 0000000..e677950 --- /dev/null +++ b/alembic/versions/cps_activity_image.py @@ -0,0 +1,38 @@ +"""cps activity 落地页图: cps_activity.image_url + 回填存量淘宝活动 + +新建淘宝活动时运营提供落地页主视觉图(上传或选已有),落地页按活动展示对应图。 +存量淘宝活动回填为此前写死的那张图,无缝衔接。 + +Revision ID: cps_activity_image +Revises: cps_v2_platforms +Create Date: 2026-06-17 12:00:00.000000 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +from app.core.config import settings + + +revision: str = "cps_activity_image" +down_revision: Union[str, Sequence[str], None] = "cps_v2_platforms" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("cps_activity", sa.Column("image_url", sa.String(512), nullable=True)) + # 回填存量淘宝活动 = 此前写死的那张图(绝对 URL,基于落地页域名 CPS_REDIRECT_BASE; + # dev 未配则相对路径)。新活动由运营自传/选,不回填。 + base = settings.CPS_REDIRECT_BASE.rstrip("/") + default_img = f"{base}/media/taobao_landing.jpg" if base else "/media/taobao_landing.jpg" + op.execute( + sa.text( + "UPDATE cps_activity SET image_url = :img WHERE platform = 'taobao'" + ).bindparams(img=default_img) + ) + + +def downgrade() -> None: + op.drop_column("cps_activity", "image_url") 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/cps.py b/app/admin/repositories/cps.py index 6733de9..a71e16b 100644 --- a/app/admin/repositories/cps.py +++ b/app/admin/repositories/cps.py @@ -5,7 +5,7 @@ """ from __future__ import annotations -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from decimal import Decimal, InvalidOperation from uuid import uuid4 @@ -17,12 +17,16 @@ from app.integrations import meituan from app.repositories import cps_link as cps_link_repo from app.models.cps_activity import CpsActivity from app.models.cps_group import CpsGroup +from app.models.cps_link import CpsClick from app.models.cps_order import CpsOrder # 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账 _INVALID_STATUS = {"4", "5"} _SETTLED_STATUS = "6" +# CPS 点击时序按北京时区分桶(运营看的是北京时间) +_BJ_TZ = timezone(timedelta(hours=8)) + # ───────────── 单位换算 ───────────── def _yuan_to_cents(v: object) -> int | None: @@ -120,6 +124,15 @@ def update_group( return group +def delete_group(db: Session, group: CpsGroup, *, commit: bool = True) -> None: + """硬删群。不级联删已生成的 cps_link(已发链接继续可用);该群历史点击/订单按 sid 仍在库。""" + db.delete(group) + if commit: + db.commit() + else: + db.flush() + + # ───────────── 活动 ───────────── def get_activity(db: Session, activity_id: int) -> CpsActivity | None: return db.get(CpsActivity, activity_id) @@ -137,14 +150,29 @@ def list_activities( return offset_paginate(db, stmt, (desc(CpsActivity.id),), limit=limit, cursor=cursor) +def list_activity_images(db: Session) -> list[str]: + """已用过的活动落地页图(distinct 非空 image_url),供新建活动复用选择。""" + rows = ( + db.execute( + select(CpsActivity.image_url) + .where(CpsActivity.image_url.is_not(None)) + .distinct() + ) + .scalars() + .all() + ) + return [r for r in rows if r] + + def create_activity( db: Session, *, name: str, platform: str = "meituan", act_id: str | None = None, product_view_sign: str | None = None, payload: str | None = None, - remark: str | None = None, commit: bool = True, + image_url: str | None = None, remark: str | None = None, commit: bool = True, ) -> CpsActivity: activity = CpsActivity( name=name, platform=platform, act_id=act_id, - product_view_sign=product_view_sign, payload=payload, remark=remark, + product_view_sign=product_view_sign, payload=payload, + image_url=image_url, remark=remark, ) db.add(activity) if commit: @@ -155,6 +183,46 @@ def create_activity( return activity +def update_activity( + db: Session, activity: CpsActivity, *, name: str | None = None, + platform: str | None = None, act_id: str | None = None, + product_view_sign: str | None = None, payload: str | None = None, + image_url: str | None = None, remark: str | None = None, + status: str | None = None, commit: bool = True, +) -> CpsActivity: + if name is not None: + activity.name = name + if platform is not None: + activity.platform = platform + if act_id is not None: + activity.act_id = act_id + if product_view_sign is not None: + activity.product_view_sign = product_view_sign + if payload is not None: + activity.payload = payload + if image_url is not None: + activity.image_url = image_url + if remark is not None: + activity.remark = remark + if status is not None: + activity.status = status + if commit: + db.commit() + db.refresh(activity) + else: + db.flush() + return activity + + +def delete_activity(db: Session, activity: CpsActivity, *, commit: bool = True) -> None: + """硬删活动。不级联删 cps_link(已发链接继续可用;淘宝落地页缺活动时取图走兜底默认图)。""" + db.delete(activity) + if commit: + db.commit() + else: + db.flush() + + # ───────────── 订单(对账) ───────────── def _map_order_fields(r: dict) -> dict: """美团 query_order 单条 dataList → CpsOrder 字段(金额转分、时间转 datetime)。""" @@ -306,3 +374,54 @@ def group_stats( # 佣金降序(None 当 0),次按点击 rows.sort(key=lambda x: ((x["est_commission_cents"] or 0), x["click_pv"]), reverse=True) return rows + + +def group_click_timeseries( + db: Session, *, group_id: int, granularity: str, + start: datetime, end: datetime, +) -> list[dict]: + """该群点击时序(北京时区分桶,granularity=day|hour)。 + + 每桶:click_pv/uv(visit)+copy_pv/uv;UV=distinct(ip,ua)。**生成连续桶(无数据补 0)**, + 让折线图 x 轴按所选范围铺满、不断裂(CPS 数据稀疏,补零是关键)。 + """ + rows = db.execute( + select(CpsClick.clicked_at, CpsClick.event_type, CpsClick.ip, CpsClick.ua) + .where(CpsClick.group_id == group_id) + .where(CpsClick.clicked_at >= _as_utc(start)) + .where(CpsClick.clicked_at <= _as_utc(end)) + ).all() + + agg: dict = {} # bucket_key -> {vp, vu(set), cp, cu(set)} + for clicked_at, event_type, ip, ua in rows: + bj = clicked_at.astimezone(_BJ_TZ) + key = bj.strftime("%Y-%m-%d") if granularity == "day" else bj.hour + b = agg.setdefault(key, {"vp": 0, "vu": set(), "cp": 0, "cu": set()}) + uid = f"{ip or ''}|{ua or ''}" + if event_type == "copy": + b["cp"] += 1 + b["cu"].add(uid) + else: + b["vp"] += 1 + b["vu"].add(uid) + + def _point(label: str, b: dict | None) -> dict: + return { + "label": label, + "click_pv": b["vp"] if b else 0, + "click_uv": len(b["vu"]) if b else 0, + "copy_pv": b["cp"] if b else 0, + "copy_uv": len(b["cu"]) if b else 0, + } + + points: list[dict] = [] + if granularity == "day": + cur = start.astimezone(_BJ_TZ).date() + last = end.astimezone(_BJ_TZ).date() + while cur <= last: + points.append(_point(cur.strftime("%m-%d"), agg.get(cur.strftime("%Y-%m-%d")))) + cur += timedelta(days=1) + else: + for h in range(24): + points.append(_point(f"{h:02d}:00", agg.get(h))) + return points 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/routers/cps.py b/app/admin/routers/cps.py index 5eae8fe..c6cd9b3 100644 --- a/app/admin/routers/cps.py +++ b/app/admin/routers/cps.py @@ -10,7 +10,7 @@ import time from datetime import datetime, timedelta, timezone from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile from app.admin.audit import write_audit from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role @@ -19,6 +19,7 @@ from app.admin.schemas.common import CursorPage from app.admin.schemas.cps import ( CpsActivityCreate, CpsActivityOut, + CpsActivityUpdate, CpsGroupCreate, CpsGroupOut, CpsGroupStat, @@ -30,6 +31,7 @@ from app.admin.schemas.cps import ( CpsReferralLinksRequest, CpsStatsOut, ) +from app.core import media from app.core.config import settings from app.integrations import meituan from app.integrations.meituan import MeituanCpsError @@ -121,6 +123,26 @@ def update_group( return CpsGroupOut.model_validate(group) +@router.delete("/groups/{group_id}", summary="删除群") +def delete_group( + group_id: int, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> dict: + group = cps_repo.get_group(db, group_id) + if group is None: + raise HTTPException(status_code=404, detail="群不存在") + write_audit( + db, admin, action="cps.group.delete", target_type="cps_group", target_id=group_id, + detail={"name": group.name, "sid": group.sid, "platforms": list(group.platforms or [])}, + ip=get_client_ip(request), commit=False, + ) + cps_repo.delete_group(db, group, commit=False) + db.commit() + return {"ok": True} + + # ───────────── 活动 ───────────── @router.get("/activities", response_model=CursorPage[CpsActivityOut], summary="活动列表") def list_activities( @@ -154,10 +176,12 @@ def create_activity( if not body.payload: label = "淘口令" if body.platform == "taobao" else "推广链接" raise HTTPException(status_code=400, detail=f"{body.platform} 活动需填{label}") + if body.platform == "taobao" and not body.image_url: + raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)") activity = cps_repo.create_activity( db, name=body.name, platform=body.platform, act_id=body.act_id, product_view_sign=body.product_view_sign, payload=body.payload, - remark=body.remark, commit=False, + image_url=media.to_abs_media_url(body.image_url), remark=body.remark, commit=False, ) write_audit( db, admin, action="cps.activity.create", target_type="cps_activity", target_id=activity.id, @@ -169,6 +193,85 @@ def create_activity( return CpsActivityOut.model_validate(activity) +@router.patch("/activities/{activity_id}", response_model=CpsActivityOut, summary="编辑活动") +def update_activity( + activity_id: int, + body: CpsActivityUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> CpsActivityOut: + activity = cps_repo.get_activity(db, activity_id) + if activity is None: + raise HTTPException(status_code=404, detail="活动不存在") + # 用「合并后最终值」校验平台必填项(同新建口径),允许只改部分字段 + platform = body.platform or activity.platform + act_id = body.act_id if body.act_id is not None else activity.act_id + pvs = body.product_view_sign if body.product_view_sign is not None else activity.product_view_sign + payload = body.payload if body.payload is not None else activity.payload + image_url = body.image_url if body.image_url is not None else activity.image_url + if platform == "meituan": + if not act_id and not pvs: + raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign") + else: # taobao / jd + if not payload: + label = "淘口令" if platform == "taobao" else "推广链接" + raise HTTPException(status_code=400, detail=f"{platform} 活动需填{label}") + if platform == "taobao" and not image_url: + raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)") + cps_repo.update_activity( + db, activity, name=body.name, platform=body.platform, act_id=body.act_id, + product_view_sign=body.product_view_sign, payload=body.payload, + image_url=media.to_abs_media_url(body.image_url) if body.image_url else None, + remark=body.remark, status=body.status, commit=False, + ) + write_audit( + db, admin, action="cps.activity.update", target_type="cps_activity", target_id=activity_id, + detail=body.model_dump(exclude_none=True), ip=get_client_ip(request), commit=False, + ) + db.commit() + db.refresh(activity) + return CpsActivityOut.model_validate(activity) + + +@router.post("/upload-image", summary="上传活动落地页图(返回绝对 URL)") +async def upload_activity_image( + admin: Annotated[AdminUser, Depends(require_role("operator"))], + file: UploadFile = File(..., description="落地页图(jpeg/png/webp,≤5MB)"), +) -> dict: + data = await file.read() + try: + url = media.save_cps_image(admin.id, data) + except media.MediaError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"url": media.to_abs_media_url(url)} + + +@router.get("/activity-images", summary="已有活动落地页图(供新建复用选择)") +def activity_images(db: AdminDb) -> dict: + return {"images": cps_repo.list_activity_images(db)} + + +@router.delete("/activities/{activity_id}", summary="删除活动") +def delete_activity( + activity_id: int, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> dict: + activity = cps_repo.get_activity(db, activity_id) + if activity is None: + raise HTTPException(status_code=404, detail="活动不存在") + write_audit( + db, admin, action="cps.activity.delete", target_type="cps_activity", target_id=activity_id, + detail={"name": activity.name, "platform": activity.platform}, + ip=get_client_ip(request), commit=False, + ) + cps_repo.delete_activity(db, activity, commit=False) + db.commit() + return {"ok": True} + + # ───────────── 生成链接(批量,按平台分支) ───────────── def _gen_one_link(db, group: CpsGroup, activity: CpsActivity): """给一个活动生成一条落地页 link。美团:转链拿短链(需群 sid);淘宝/京东:用 payload。""" @@ -293,3 +396,40 @@ def get_stats( total_est_commission_cents=sum(s.est_commission_cents or 0 for s in stats), total_settled_commission_cents=sum(s.settled_commission_cents or 0 for s in stats), ) + + +@router.get("/groups/{group_id}/timeseries", summary="群点击时序(折线图:天/小时级 PV/UV/复制)") +def group_timeseries( + group_id: int, + db: AdminDb, + granularity: Annotated[str, Query(pattern="^(day|hour)$")] = "day", + days: Annotated[int, Query(ge=1, le=30)] = 7, + date: Annotated[str | None, Query()] = None, # hour 粒度看哪天(YYYY-MM-DD 北京),默认今天 +) -> dict: + group = cps_repo.get_group(db, group_id) + if group is None: + raise HTTPException(status_code=404, detail="群不存在") + bj = timezone(timedelta(hours=8)) + now_bj = datetime.now(bj) + if granularity == "hour": + if date: + try: + day0 = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=bj) + except ValueError as e: + raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD") from e + else: + day0 = now_bj + start = day0.replace(hour=0, minute=0, second=0, microsecond=0) + end = start + timedelta(days=1) - timedelta(microseconds=1) + else: + start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0) + end = now_bj + points = cps_repo.group_click_timeseries( + db, group_id=group_id, granularity=granularity, start=start, end=end + ) + return { + "group_id": group.id, + "group_name": group.name, + "granularity": granularity, + "points": points, + } 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/admin/schemas/cps.py b/app/admin/schemas/cps.py index 5ca7dbb..e7c3f7e 100644 --- a/app/admin/schemas/cps.py +++ b/app/admin/schemas/cps.py @@ -52,6 +52,7 @@ class CpsActivityOut(BaseModel): act_id: str | None = None product_view_sign: str | None = None payload: str | None = None # 淘宝淘口令 / 京东链接 + image_url: str | None = None # 淘宝落地页图(绝对 URL) status: str remark: str | None = None created_at: datetime @@ -65,9 +66,24 @@ class CpsActivityCreate(BaseModel): product_view_sign: str | None = Field(default=None, max_length=128) # 淘宝:整段淘口令文本 / 京东:推广链接 payload: str | None = Field(default=None, max_length=4096) + # 淘宝落地页图(绝对/相对 URL;淘宝必填,由 router 校验) + image_url: str | None = Field(default=None, max_length=512) remark: str | None = Field(default=None, max_length=256) +class CpsActivityUpdate(BaseModel): + """编辑活动(PATCH):全字段可选,只更新传入的非 None 项;最终平台对应字段由 router 校验。""" + + name: str | None = Field(default=None, min_length=1, max_length=128) + platform: str | None = Field(default=None, pattern=r"^(meituan|taobao|jd)$") + act_id: str | None = Field(default=None, max_length=64) + product_view_sign: str | None = Field(default=None, max_length=128) + payload: str | None = Field(default=None, max_length=4096) + image_url: str | None = Field(default=None, max_length=512) + remark: str | None = Field(default=None, max_length=256) + status: str | None = Field(default=None, pattern=r"^(active|archived)$") + + # ───────────── 生成链接(批量) ───────────── class CpsReferralLinksRequest(BaseModel): group_id: int 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/api/v1/cps_redirect.py b/app/api/v1/cps_redirect.py index 0c604dd..9be15e6 100644 --- a/app/api/v1/cps_redirect.py +++ b/app/api/v1/cps_redirect.py @@ -6,13 +6,16 @@ """ from __future__ import annotations +import html import json from fastapi import APIRouter, Depends, Request from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy.orm import Session +from app.core import media from app.db.session import get_db +from app.models.cps_activity import CpsActivity from app.models.cps_link import CpsLink from app.repositories import cps_link as cps_link_repo @@ -21,6 +24,9 @@ router = APIRouter(tags=["cps-redirect"]) # code 不存在/失效时的兜底落地 _FALLBACK_URL = "https://www.meituan.com/" +# 淘宝活动未设图时的兜底主视觉(存量已回填,基本只在老 link/异常时触发) +_DEFAULT_TAOBAO_IMAGE = "/media/taobao_landing.jpg" + def _client_ip(request: Request) -> str | None: xff = request.headers.get("x-forwarded-for") @@ -46,7 +52,9 @@ def cps_landing(code: str, request: Request, db: Session = Depends(get_db)): return RedirectResponse(_FALLBACK_URL, status_code=302) _record(db, link, request, "visit") if link.platform == "taobao": - return HTMLResponse(_taobao_landing_html(link.target_url)) + activity = db.get(CpsActivity, link.activity_id) + image_url = (activity.image_url if activity else None) or _DEFAULT_TAOBAO_IMAGE + return HTMLResponse(_taobao_landing_html(link.target_url, image_url)) # 美团短链 / 京东链接:直接 302 跳 return RedirectResponse(link.target_url, status_code=302) @@ -59,9 +67,18 @@ def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict return {"ok": True} -def _taobao_landing_html(token: str) -> str: - """淘宝落地页 H5(主视觉为设计图 /media/taobao_landing.jpg,复制淘口令按钮在 75% 屏高)。token 安全嵌入 JS。""" - return _TAOBAO_HTML.replace("__TOKEN_JS__", json.dumps(token)) +def _taobao_landing_html(token: str, image_url: str) -> str: + """淘宝落地页 H5(主视觉图按活动传入,复制淘口令按钮在 75% 屏高)。 + + image_url 转绝对(落地页 coupon 域,相对也可达,绝对更稳)后经 html.escape 嵌入 + (防属性注入);token 经 json.dumps 安全嵌入 JS。 + """ + safe_img = html.escape(media.to_abs_media_url(image_url) or image_url, quote=True) + return ( + _TAOBAO_HTML + .replace("__IMAGE_URL__", safe_img) + .replace("__TOKEN_JS__", json.dumps(token)) + ) _TAOBAO_HTML = """ @@ -82,7 +99,7 @@ body{font-family:-apple-system,"PingFang SC",sans-serif;background:#fff0ef;color -淘宝闪购 天天领红包 +淘宝闪购 天天领红包