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
+