Files
shaguabijia-app-server/app/admin/routers/cps.py
T
marco 3a40f617bd feat(cps): 后端接入淘宝/京东多平台(payload + 群多平台 + 复制统计 + 迁移)
- 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>
2026-06-17 19:21:20 +08:00

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),
)