"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。 平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。 淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。 群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。 """ from __future__ import annotations import time from datetime import date as _date, datetime, time as _dt_time, timedelta, timezone from typing import Annotated 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 from app.admin.repositories import cps as cps_repo from app.admin.schemas.common import CursorPage from app.admin.schemas.cps import ( CpsActivityCreate, CpsActivityOut, CpsActivityUpdate, CpsGroupCreate, CpsGroupOut, CpsGroupStat, CpsGroupUpdate, CpsOrderOut, CpsReconcileResult, CpsReferralLinkItem, CpsReferralLinksOut, CpsReferralLinksRequest, CpsStatsOut, ) from app.core import media from app.core.config import settings from app.integrations import meituan from app.integrations.meituan import MeituanCpsError from app.models.admin import AdminUser from app.models.cps_activity import CpsActivity from app.models.cps_group import CpsGroup from app.repositories import cps_link as cps_link_repo router = APIRouter( prefix="/admin/api/cps", tags=["admin-cps"], dependencies=[Depends(get_current_admin)], ) _VALID_PLATFORMS = {"meituan", "taobao", "jd"} # ───────────── 群 ───────────── @router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群列表") def list_groups( db: AdminDb, keyword: Annotated[str | None, Query(max_length=100)] = None, status: Annotated[str | None, Query(pattern="^(active|archived)$")] = None, limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[CpsGroupOut]: items, next_cursor, total = cps_repo.list_groups( db, keyword=keyword, status=status, limit=limit, cursor=cursor ) return CursorPage( items=[CpsGroupOut.model_validate(g) for g in items], next_cursor=next_cursor, total=total, ) @router.post("/groups", response_model=CpsGroupOut, summary="新建群") def create_group( body: CpsGroupCreate, request: Request, admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> CpsGroupOut: bad = [p for p in body.platforms if p not in _VALID_PLATFORMS] if bad: raise HTTPException(status_code=400, detail=f"非法平台: {bad}") has_meituan = "meituan" in body.platforms if has_meituan and body.sid and cps_repo.get_group_by_sid(db, body.sid) is not None: raise HTTPException(status_code=409, detail="sid 已存在") group = cps_repo.create_group( db, name=body.name, platforms=body.platforms, sid=body.sid, member_count=body.member_count, remark=body.remark, commit=False, ) write_audit( db, admin, action="cps.group.create", target_type="cps_group", target_id=group.id, detail={"name": group.name, "platforms": group.platforms, "sid": group.sid}, ip=get_client_ip(request), commit=False, ) db.commit() db.refresh(group) return CpsGroupOut.model_validate(group) @router.patch("/groups/{group_id}", response_model=CpsGroupOut, summary="编辑群") def update_group( group_id: int, body: CpsGroupUpdate, request: Request, admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> CpsGroupOut: group = cps_repo.get_group(db, group_id) if group is None: raise HTTPException(status_code=404, detail="群不存在") if body.platforms is not None: bad = [p for p in body.platforms if p not in _VALID_PLATFORMS] if bad: raise HTTPException(status_code=400, detail=f"非法平台: {bad}") cps_repo.update_group( db, group, name=body.name, platforms=body.platforms, member_count=body.member_count, status=body.status, remark=body.remark, commit=False, ) write_audit( db, admin, action="cps.group.update", target_type="cps_group", target_id=group_id, detail=body.model_dump(exclude_none=True), ip=get_client_ip(request), commit=False, ) db.commit() db.refresh(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( db: AdminDb, platform: Annotated[str | None, Query(max_length=20)] = None, status: Annotated[str | None, Query(pattern="^(active|archived)$")] = None, limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[CpsActivityOut]: items, next_cursor, total = cps_repo.list_activities( db, platform=platform, status=status, limit=limit, cursor=cursor ) return CursorPage( items=[CpsActivityOut.model_validate(a) for a in items], next_cursor=next_cursor, total=total, ) @router.post("/activities", response_model=CpsActivityOut, summary="新建活动") def create_activity( body: CpsActivityCreate, request: Request, admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> CpsActivityOut: if body.platform == "meituan": if not body.act_id and not body.product_view_sign: raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign") else: # taobao / jd 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, 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, detail={"name": activity.name, "platform": activity.platform}, ip=get_client_ip(request), commit=False, ) db.commit() db.refresh(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。""" if activity.platform == "meituan": if not group.sid: raise HTTPException(status_code=400, detail=f"群「{group.name}」无 sid,无法生成美团链接") try: resp = meituan.get_referral_link( act_id=activity.act_id, product_view_sign=activity.product_view_sign, sid=group.sid, link_type_list=[1, 2, 3], ) except MeituanCpsError as e: raise HTTPException(status_code=502, detail=f"美团转链失败: {e}") from e link_map = {str(k): v for k, v in (resp.get("referralLinkMap") or {}).items()} if not link_map and resp.get("data"): link_map = {"1": resp["data"]} target = link_map.get("2") or link_map.get("1") or link_map.get("3") if not target: raise HTTPException(status_code=502, detail=f"美团未返回有效链接(活动「{activity.name}」)") else: # taobao(淘口令) / jd(链接) if not activity.payload: raise HTTPException(status_code=400, detail=f"活动「{activity.name}」缺少内容") target = activity.payload return cps_link_repo.create_link( db, group_id=group.id, activity_id=activity.id, sid=group.sid, target_url=target, platform=activity.platform, commit=False, ) @router.post("/referral-links", response_model=CpsReferralLinksOut, summary="批量生成落地页短链") def generate_referral_links( body: CpsReferralLinksRequest, request: Request, admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> CpsReferralLinksOut: group = cps_repo.get_group(db, body.group_id) if group is None: raise HTTPException(status_code=404, detail="群不存在") group_platforms = set(group.platforms or []) base = settings.CPS_REDIRECT_BASE.rstrip("/") items: list[CpsReferralLinkItem] = [] for aid in body.activity_ids: activity = cps_repo.get_activity(db, aid) if activity is None: raise HTTPException(status_code=404, detail=f"活动 {aid} 不存在") if activity.platform not in group_platforms: raise HTTPException( status_code=400, detail=f"活动「{activity.name}」平台({activity.platform})不在群「{group.name}」范围内", ) link = _gen_one_link(db, group, activity) items.append(CpsReferralLinkItem( activity_id=activity.id, activity_name=activity.name, platform=activity.platform, redirect_url=f"{base}/c/{link.code}" if base else f"/c/{link.code}", code=link.code, )) write_audit( db, admin, action="cps.referral_link.generate", target_type="cps_group", target_id=group.id, detail={"group": group.name, "activity_ids": body.activity_ids, "count": len(items)}, ip=get_client_ip(request), commit=False, ) db.commit() return CpsReferralLinksOut(group_name=group.name, results=items) # ───────────── 订单对账 ───────────── _BEIJING = timezone(timedelta(hours=8)) def _parse_day(value: str | None, *, field: str) -> _date | None: if value is None: return None try: return _date.fromisoformat(value) except ValueError as e: raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e def _reconcile_range_to_ts( date_from: _date | None, date_to: _date | None, days: int ) -> tuple[int, int]: if date_from is None and date_to is None: now = int(time.time()) return now - days * 86400, now start_day = date_from or date_to end_day = date_to or date_from if start_day is None or end_day is None: raise HTTPException(status_code=422, detail="日期参数不完整") if start_day > end_day: start_day, end_day = end_day, start_day if (end_day - start_day).days + 1 > 90: raise HTTPException(status_code=422, detail="美团订单查询最长 90 天") start_dt = datetime.combine(start_day, _dt_time.min, tzinfo=_BEIJING) end_dt = datetime.combine(end_day + timedelta(days=1), _dt_time.min, tzinfo=_BEIJING) return int(start_dt.timestamp()), int(end_dt.timestamp()) @router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账") def reconcile_orders( request: Request, admin: Annotated[AdminUser, Depends(require_role("finance"))], db: AdminDb, date_from: Annotated[str | None, Query(description="起始日 YYYY-MM-DD")] = None, date_to: Annotated[str | None, Query(description="结束日 YYYY-MM-DD")] = None, days: Annotated[int, Query(ge=1, le=90, description="未传日期时默认拉近 N 天")] = 7, sid: Annotated[str | None, Query(max_length=64)] = None, query_time_type: Annotated[int, Query(ge=1, le=2)] = 1, ) -> CpsReconcileResult: start_ts, end_ts = _reconcile_range_to_ts( _parse_day(date_from, field="date_from"), _parse_day(date_to, field="date_to"), days, ) try: result = cps_repo.reconcile_orders( db, start_time=start_ts, end_time=end_ts, query_time_type=query_time_type, sid=sid, ) except MeituanCpsError as e: raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e write_audit( db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None, detail={ "date_from": date_from, "date_to": date_to, "days": days, "sid": sid, "query_time_type": query_time_type, **result, }, ip=get_client_ip(request), commit=True, ) return CpsReconcileResult(**result) @router.get("/orders", response_model=CursorPage[CpsOrderOut], summary="订单明细") def list_orders( db: AdminDb, sid: Annotated[str | None, Query(max_length=64)] = None, mt_status: Annotated[str | None, Query(max_length=8)] = None, limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[CpsOrderOut]: items, next_cursor, total = cps_repo.list_orders( db, sid=sid, mt_status=mt_status, limit=limit, cursor=cursor ) return CursorPage( items=[CpsOrderOut.model_validate(o) for o in items], next_cursor=next_cursor, total=total, ) # ───────────── 统计 ───────────── @router.get("/stats", response_model=CpsStatsOut, summary="按群对账统计") def get_stats( db: AdminDb, days: Annotated[int, Query(ge=1, le=90)] = 30, ) -> CpsStatsOut: date_to = datetime.now(timezone.utc) date_from = date_to - timedelta(days=days) rows = cps_repo.group_stats(db, date_from=date_from, date_to=date_to) stats = [CpsGroupStat(**r) for r in rows] return CpsStatsOut( groups=stats, total_order_count=sum(s.order_count or 0 for s in 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, } @router.get("/groups/{group_id}/daily", summary="群每天明细大表格(点击+订单按天合并)") def group_daily( group_id: int, db: AdminDb, days: Annotated[int, Query(ge=1, le=90)] = 7, ) -> 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) start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0) end = now_bj # 点击侧:连续 N 天补零,顺序 = start..end(与下面 while 同序,按 index 对齐避免跨年 MM-DD 碰撞) click_points = cps_repo.group_click_timeseries( db, group_id=group_id, granularity="day", start=start, end=end ) # 订单侧:仅美团群(有 sid)有对账;淘宝/京东订单列返回 None(前端显示 "-") is_meituan = "meituan" in (group.platforms or []) and bool(group.sid) order_daily = ( cps_repo.group_order_daily(db, sid=group.sid, start=start, end=end) if is_meituan else {} ) _ORDER_KEYS = ( "order_count", "canceled_count", "gmv_cents", "est_commission_cents", "refund_profit_cents", "settled_commission_cents", ) rows = [] cur = start.astimezone(bj).date() last = end.astimezone(bj).date() idx = 0 while cur <= last: cp = click_points[idx] if idx < len(click_points) else None row = { "date": cur.strftime("%Y-%m-%d"), "click_pv": cp["click_pv"] if cp else 0, "click_uv": cp["click_uv"] if cp else 0, "copy_pv": cp["copy_pv"] if cp else 0, } if is_meituan: od = order_daily.get(cur.strftime("%Y-%m-%d")) row.update({k: (od[k] if od else 0) for k in _ORDER_KEYS}) else: row.update({k: None for k in _ORDER_KEYS}) rows.append(row) idx += 1 cur += timedelta(days=1) return { "group_id": group.id, "group_name": group.name, "is_meituan": is_meituan, "days": days, "rows": rows, } @router.get("/groups/{group_id}/wx-users", summary="群内微信用户(领券画像:头像/昵称/领券次数)") def group_wx_users(group_id: int, db: AdminDb) -> dict: group = cps_repo.get_group(db, group_id) if group is None: raise HTTPException(status_code=404, detail="群不存在") return {"users": cps_repo.group_wx_users(db, group_id=group_id)} @router.get("/groups/{group_id}/day-users", summary="某天该群按用户的领券/点击 + 每人点过的券") def group_day_users( group_id: int, db: AdminDb, date: Annotated[str, Query(description="北京日期 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)) 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 start = day0.replace(hour=0, minute=0, second=0, microsecond=0) end = start + timedelta(days=1) users = cps_repo.group_day_users(db, group_id=group_id, start=start, end=end) return { "group_id": group.id, "group_name": group.name, "date": date, "users": users, }