3a40f617bd
- model: cps_group.platforms 多选 / cps_activity.payload(淘口令/京东链接) / cps_link.sid 可空 + 复制统计 - admin API: 群与活动平台多选、批量生成落地页短链 referral-links、对账字段可空 - 落地页 cps_redirect: 淘宝展示淘口令(记 copy 事件) / 京东 302 / 美团原逻辑 - 迁移 cps_v2_platforms: 加 platforms/payload/event_type 列, sid 放宽可空(含 downgrade) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
296 lines
12 KiB
Python
296 lines
12 KiB
Python
"""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),
|
|
)
|