"""admin CPS 分发与对账:群(sid)管理 + 活动池 + 生成带 sid 券链接 + 美团订单对账 + 统计。 当前仅接美团(get_referral_link / query_order)。淘宝/京东待各自联盟凭证。 群/活动管理 = 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.core.config import settings from app.repositories import cps_link as cps_link_repo from app.admin.schemas.cps import ( CpsActivityCreate, CpsActivityOut, CpsGroupCreate, CpsGroupOut, CpsGroupStat, CpsGroupUpdate, CpsOrderOut, CpsReconcileResult, CpsReferralLinkOut, CpsReferralLinkRequest, CpsStatsOut, ) from app.integrations import meituan from app.integrations.meituan import MeituanCpsError from app.models.admin import AdminUser router = APIRouter( prefix="/admin/api/cps", tags=["admin-cps"], dependencies=[Depends(get_current_admin)], ) # ───────────── 群(sid) ───────────── @router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群(sid)列表") 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="新建群(分配 sid)") def create_group( body: CpsGroupCreate, request: Request, admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> CpsGroupOut: if 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, 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={"sid": group.sid, "name": group.name}, 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="群不存在") cps_repo.update_group( db, group, name=body.name, 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 not body.act_id and not body.product_view_sign: raise HTTPException(status_code=400, detail="act_id 或 product_view_sign 必填其一") activity = cps_repo.create_activity( db, name=body.name, platform=body.platform, act_id=body.act_id, product_view_sign=body.product_view_sign, 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, "act_id": activity.act_id, "platform": activity.platform}, ip=get_client_ip(request), commit=False, ) db.commit() db.refresh(activity) return CpsActivityOut.model_validate(activity) # ───────────── 转链 ───────────── @router.post("/referral-link", response_model=CpsReferralLinkOut, summary="生成群发短链(带 sid + 点击统计)") def generate_referral_link( body: CpsReferralLinkRequest, request: Request, admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> CpsReferralLinkOut: group = cps_repo.get_group(db, body.group_id) if group is None: raise HTTPException(status_code=404, detail="群不存在") activity = cps_repo.get_activity(db, body.activity_id) if activity is None: raise HTTPException(status_code=404, detail="活动不存在") if activity.platform != "meituan": raise HTTPException(status_code=400, detail=f"当前仅支持美团转链(活动平台={activity.platform})") try: # 全要:短链作跳转目标(微信可打开),长链/deeplink 作参考/备用 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"]} # 跳转目标优先短链(2,微信可打开)→ 长链(1)→ deeplink(3) target_url = link_map.get("2") or link_map.get("1") or link_map.get("3") if not target_url: raise HTTPException(status_code=502, detail="美团未返回有效链接(检查 actId 是否在推广有效期)") # 存我们的短链:发群用 /c/{code},用户点 → 我们记点击 → 302 跳 target_url link = cps_link_repo.create_link( db, group_id=group.id, activity_id=activity.id, sid=group.sid, target_url=target_url, platform=activity.platform, commit=False, ) write_audit( db, admin, action="cps.referral_link.generate", target_type="cps_link", target_id=link.id, detail={"sid": group.sid, "activity_id": activity.id, "code": link.code}, ip=get_client_ip(request), commit=False, ) db.commit() db.refresh(link) base = settings.CPS_REDIRECT_BASE.rstrip("/") redirect_url = f"{base}/c/{link.code}" if base else f"/c/{link.code}" return CpsReferralLinkOut( redirect_url=redirect_url, code=link.code, sid=group.sid, group_name=group.name, activity_name=activity.name, target_url=target_url, link_map=link_map, ) # ───────────── 订单对账 ───────────── @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 for s in stats), total_est_commission_cents=sum(s.est_commission_cents for s in stats), total_settled_commission_cents=sum(s.settled_commission_cents for s in stats), )