"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。 平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。 淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。 群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。 """ from __future__ import annotations import time from datetime import datetime, timedelta, timezone from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, Request 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, CpsGroupCreate, CpsGroupOut, CpsGroupStat, CpsGroupUpdate, CpsOrderOut, CpsReconcileResult, CpsReferralLinkItem, CpsReferralLinksOut, CpsReferralLinksRequest, CpsStatsOut, ) 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.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}") 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, ) 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) # ───────────── 生成链接(批量,按平台分支) ───────────── 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) # ───────────── 订单对账 ───────────── @router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账") def reconcile_orders( request: Request, admin: Annotated[AdminUser, Depends(require_role("finance"))], db: AdminDb, days: Annotated[int, Query(ge=1, le=90)] = 7, sid: Annotated[str | None, Query(max_length=64)] = None, ) -> CpsReconcileResult: now = int(time.time()) try: result = cps_repo.reconcile_orders( db, start_time=now - days * 86400, end_time=now, 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={"days": days, "sid": sid, **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), )