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>
This commit is contained in:
2026-06-17 19:21:20 +08:00
parent 003fd9d986
commit 3a40f617bd
9 changed files with 422 additions and 172 deletions
+61
View File
@@ -0,0 +1,61 @@
"""cps v2: 活动 payload(淘口令/京东链接) + 群多平台/sid 可空 + 点击 event_type
Revision ID: cps_v2_platforms
Revises: cps_link_tables
Create Date: 2026-06-17 11:00:00.000000
接入淘宝/京东:
- cps_activity.payload 淘宝整段淘口令文本 / 京东推广链接(美团仍用 act_id)
- cps_group.platforms 该群发哪些平台(多选);sid 改可空(纯淘宝/京东无 sid)
- cps_link.sid 可空 + target_url 加长(淘口令较长)
- cps_click.event_type visit / copy(淘宝落地页"复制口令")
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "cps_v2_platforms"
down_revision: Union[str, Sequence[str], None] = "cps_link_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 活动:淘宝淘口令 / 京东链接
op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True))
# 群:多平台(现有群都是美团,server_default 回填)+ sid 可空
op.add_column(
"cps_group",
sa.Column(
"platforms",
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
nullable=False,
server_default=sa.text("'[\"meituan\"]'::jsonb"),
),
)
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=True)
# link:sid 可空 + target 加长
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=True)
op.alter_column("cps_link", "target_url", existing_type=sa.String(1024), type_=sa.String(2048))
# click:sid 可空 + 事件类型
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=True)
op.add_column(
"cps_click",
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit"),
)
def downgrade() -> None:
op.drop_column("cps_click", "event_type")
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=False)
op.alter_column("cps_link", "target_url", existing_type=sa.String(2048), type_=sa.String(1024))
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=False)
op.drop_column("cps_group", "platforms")
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=False)
op.drop_column("cps_activity", "payload")
+62 -41
View File
@@ -71,19 +71,23 @@ def list_groups(
def create_group(
db: Session, *, name: str, sid: str | None = None,
db: Session, *, name: str, platforms: list[str], sid: str | None = None,
member_count: int | None = None, remark: str | None = None, commit: bool = True,
) -> CpsGroup:
"""建群。sid 留空自动 g<id>(先 flush 拿 id 再回填)。"""
"""建群。含 meituan 才有 sid(留空自动 g<id>);纯淘宝/京东 sid=None(那俩不支持 sid)。"""
has_meituan = "meituan" in (platforms or [])
# 含美团:用填的 sid,或临时占位待回填 g<id>;不含美团:sid 恒 None
seed_sid = (sid or f"tmp{uuid4().hex[:20]}") if has_meituan else None
group = CpsGroup(
name=name,
sid=sid or f"tmp{uuid4().hex[:20]}", # 临时唯一占位,留空时下面回填 g<id>
platforms=list(platforms or []),
sid=seed_sid,
member_count=member_count,
remark=remark,
)
db.add(group)
db.flush()
if not sid:
if has_meituan and not sid:
group.sid = f"g{group.id}"
db.flush()
if commit:
@@ -94,11 +98,14 @@ def create_group(
def update_group(
db: Session, group: CpsGroup, *, name: str | None = None,
platforms: list[str] | None = None,
member_count: int | None = None, status: str | None = None,
remark: str | None = None, commit: bool = True,
) -> CpsGroup:
if name is not None:
group.name = name
if platforms is not None:
group.platforms = list(platforms)
if member_count is not None:
group.member_count = member_count
if status is not None:
@@ -132,11 +139,12 @@ def list_activities(
def create_activity(
db: Session, *, name: str, platform: str = "meituan", act_id: str | None = None,
product_view_sign: str | None = None, remark: str | None = None, commit: bool = True,
product_view_sign: str | None = None, payload: str | None = None,
remark: str | None = None, commit: bool = True,
) -> CpsActivity:
activity = CpsActivity(
name=name, platform=platform, act_id=act_id,
product_view_sign=product_view_sign, remark=remark,
product_view_sign=product_view_sign, payload=payload, remark=remark,
)
db.add(activity)
if commit:
@@ -233,55 +241,68 @@ def group_stats(
"""按 sid 聚合订单 + join 群信息。已建但本期无单的活跃群也列出(全 0)。
未归群的 sid(历史/其它来源)单独成行 group_id=None。按预估佣金降序。
"""
stmt = select(CpsOrder)
ostmt = select(CpsOrder)
if date_from is not None:
stmt = stmt.where(CpsOrder.pay_time >= _as_utc(date_from))
ostmt = ostmt.where(CpsOrder.pay_time >= _as_utc(date_from))
if date_to is not None:
stmt = stmt.where(CpsOrder.pay_time <= _as_utc(date_to))
orders = list(db.execute(stmt).scalars().all())
ostmt = ostmt.where(CpsOrder.pay_time <= _as_utc(date_to))
orders_by_sid: dict[str | None, list[CpsOrder]] = {}
for o in db.execute(ostmt).scalars().all():
orders_by_sid.setdefault(o.sid, []).append(o)
groups = {g.sid: g for g in db.execute(select(CpsGroup)).scalars().all()}
clicks = cps_link_repo.click_stats_by_group(db, date_from=date_from, date_to=date_to)
groups = list(db.execute(select(CpsGroup)).scalars().all())
buckets: dict[str | None, list[CpsOrder]] = {}
for o in orders:
buckets.setdefault(o.sid, []).append(o)
rows: list[dict] = []
for sid, items in buckets.items():
g = groups.get(sid) if sid else None
def _order_agg(items: list[CpsOrder]) -> dict:
valid = [o for o in items if o.mt_status not in _INVALID_STATUS]
settled = [o for o in items if o.mt_status == _SETTLED_STATUS]
canceled = [o for o in items if o.mt_status in _INVALID_STATUS]
rows.append({
"group_id": g.id if g else None,
"sid": sid,
"name": g.name if g else (sid or "(无 sid 归属)"),
"member_count": g.member_count if g else None,
return {
"order_count": len(valid),
"settled_count": len(settled),
"canceled_count": len(canceled),
"gmv_cents": sum(o.pay_price_cents or 0 for o in valid),
"est_commission_cents": sum(o.commission_cents or 0 for o in valid),
"settled_commission_cents": sum(o.commission_cents or 0 for o in settled),
})
}
# 已建的活跃群但本期无单 → 补 0 行,让运营看到全部群
seen = set(buckets.keys())
for sid, g in groups.items():
if sid not in seen and g.status == "active":
rows.append({
"group_id": g.id, "sid": sid, "name": g.name,
"member_count": g.member_count, "order_count": 0,
"settled_count": 0, "canceled_count": 0, "gmv_cents": 0,
"est_commission_cents": 0, "settled_commission_cents": 0,
})
# 淘宝/京东无法对账 → 对账字段全 None(前端显示 "-")
_no_recon = {
"order_count": None, "settled_count": None, "canceled_count": None,
"gmv_cents": None, "est_commission_cents": None, "settled_commission_cents": None,
}
# 合并点击数据(按 group_id):点击只对生成过我们短链的群有,未归群行(group_id=None)恒 0
clicks = cps_link_repo.click_stats_by_group(db, date_from=date_from, date_to=date_to)
for r in rows:
c = clicks.get(r["group_id"]) if r["group_id"] is not None else None
r["click_pv"] = c["pv"] if c else 0
r["click_uv"] = c["uv"] if c else 0
rows: list[dict] = []
seen_sids: set[str] = set()
for g in groups:
if g.status != "active":
continue
c = clicks.get(g.id) or {}
row = {
"group_id": g.id, "sid": g.sid, "name": g.name,
"platforms": list(g.platforms or []), "member_count": g.member_count,
"click_pv": c.get("pv", 0), "click_uv": c.get("uv", 0),
"copy_pv": c.get("copy_pv", 0), "copy_uv": c.get("copy_uv", 0),
}
# 对账只对美团群(有 sid);淘宝/京东 → None
if "meituan" in (g.platforms or []) and g.sid:
row.update(_order_agg(orders_by_sid.get(g.sid, [])))
seen_sids.add(g.sid)
else:
row.update(_no_recon)
rows.append(row)
rows.sort(key=lambda x: x["est_commission_cents"], reverse=True)
# 未归群的历史美团 sid(如 wonderableai):单列,有对账无点击
for sid, items in orders_by_sid.items():
if sid is None or sid in seen_sids:
continue
row = {
"group_id": None, "sid": sid, "name": sid, "platforms": ["meituan"],
"member_count": None, "click_pv": 0, "click_uv": 0, "copy_pv": 0, "copy_uv": 0,
}
row.update(_order_agg(items))
rows.append(row)
# 佣金降序(None 当 0),次按点击
rows.sort(key=lambda x: ((x["est_commission_cents"] or 0), x["click_pv"]), reverse=True)
return rows
+96 -69
View File
@@ -1,6 +1,7 @@
"""admin CPS 分发与对账:群(sid)管理 + 活动池 + 生成带 sid 券链接 + 美团订单对账 + 统计。
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。
当前仅接美团(get_referral_link / query_order)。淘宝/京东待各自联盟凭证
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)
淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"
群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。
"""
from __future__ import annotations
@@ -15,8 +16,6 @@ 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,
@@ -26,13 +25,18 @@ from app.admin.schemas.cps import (
CpsGroupUpdate,
CpsOrderOut,
CpsReconcileResult,
CpsReferralLinkOut,
CpsReferralLinkRequest,
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",
@@ -40,9 +44,11 @@ router = APIRouter(
dependencies=[Depends(get_current_admin)],
)
_VALID_PLATFORMS = {"meituan", "taobao", "jd"}
# ───────────── 群(sid) ─────────────
@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群(sid)列表")
# ───────────── 群 ─────────────
@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群列表")
def list_groups(
db: AdminDb,
keyword: Annotated[str | None, Query(max_length=100)] = None,
@@ -60,22 +66,27 @@ def list_groups(
)
@router.post("/groups", response_model=CpsGroupOut, summary="新建群(分配 sid)")
@router.post("/groups", response_model=CpsGroupOut, summary="新建群")
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:
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, sid=body.sid, member_count=body.member_count,
remark=body.remark, commit=False,
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={"sid": group.sid, "name": group.name}, ip=get_client_ip(request), commit=False,
detail={"name": group.name, "platforms": group.platforms, "sid": group.sid},
ip=get_client_ip(request), commit=False,
)
db.commit()
db.refresh(group)
@@ -93,9 +104,13 @@ def update_group(
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, member_count=body.member_count,
status=body.status, remark=body.remark, commit=False,
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,
@@ -132,15 +147,21 @@ def create_activity(
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 必填其一")
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, remark=body.remark, commit=False,
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, "act_id": activity.act_id, "platform": activity.platform},
detail={"name": activity.name, "platform": activity.platform},
ip=get_client_ip(request), commit=False,
)
db.commit()
@@ -148,65 +169,71 @@ def create_activity(
return CpsActivityOut.model_validate(activity)
# ───────────── 转链 ─────────────
@router.post("/referral-link", response_model=CpsReferralLinkOut, summary="生成群发短链(带 sid + 点击统计)")
def generate_referral_link(
body: CpsReferralLinkRequest,
# ───────────── 生成链接(批量,按平台分支) ─────────────
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,
) -> CpsReferralLinkOut:
) -> CpsReferralLinksOut:
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
group_platforms = set(group.platforms or [])
base = settings.CPS_REDIRECT_BASE.rstrip("/")
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 是否在推广有效期)")
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,
))
# 存我们的短链:发群用 /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},
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()
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,
)
return CpsReferralLinksOut(group_name=group.name, results=items)
# ───────────── 订单对账 ─────────────
@@ -262,7 +289,7 @@ def get_stats(
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),
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),
)
+49 -32
View File
@@ -1,4 +1,8 @@
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。"""
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)。
对账类字段对淘宝/京东为 None → 前端显示 "-"(无法对账)。
"""
from __future__ import annotations
from datetime import datetime
@@ -6,13 +10,14 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
# ───────────── 群(sid) ─────────────
# ───────────── 群 ─────────────
class CpsGroupOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
sid: str
sid: str | None = None # 仅美团群有
name: str
platforms: list[str] = Field(default_factory=list)
member_count: int | None = None
status: str
remark: str | None = None
@@ -21,7 +26,9 @@ class CpsGroupOut(BaseModel):
class CpsGroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=128)
# 留空 → 后端自动生成 g<id>;填写则必须字母+数字(美团 sid 限制)。
# 该群发哪些平台,至少选一个
platforms: list[str] = Field(min_length=1)
# 仅当含 meituan 时需要(留空自动生成);纯淘宝/京东不填,后端置 None。
sid: str | None = Field(default=None, max_length=64, pattern=r"^[A-Za-z0-9]+$")
member_count: int | None = Field(default=None, ge=0)
remark: str | None = Field(default=None, max_length=256)
@@ -29,12 +36,13 @@ class CpsGroupCreate(BaseModel):
class CpsGroupUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=128)
platforms: list[str] | None = None
member_count: int | None = Field(default=None, ge=0)
status: str | None = Field(default=None, pattern=r"^(active|archived)$")
remark: str | None = Field(default=None, max_length=256)
# ───────────── 活动(actId) ─────────────
# ───────────── 活动 ─────────────
class CpsActivityOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -43,6 +51,7 @@ class CpsActivityOut(BaseModel):
name: str
act_id: str | None = None
product_view_sign: str | None = None
payload: str | None = None # 淘宝淘口令 / 京东链接
status: str
remark: str | None = None
created_at: datetime
@@ -50,36 +59,40 @@ class CpsActivityOut(BaseModel):
class CpsActivityCreate(BaseModel):
name: str = Field(min_length=1, max_length=128)
platform: str = Field(default="meituan", pattern=r"^(meituan|taobao|jd)$")
platform: str = Field(pattern=r"^(meituan|taobao|jd)$")
# 美团:act_id 或 product_view_sign 二选一
act_id: str | None = Field(default=None, max_length=64)
product_view_sign: str | None = Field(default=None, max_length=128)
# 淘宝:整段淘口令文本 / 京东:推广链接
payload: str | None = Field(default=None, max_length=4096)
remark: str | None = Field(default=None, max_length=256)
# ───────────── 转链 ─────────────
class CpsReferralLinkRequest(BaseModel):
# ───────────── 生成链接(批量) ─────────────
class CpsReferralLinksRequest(BaseModel):
group_id: int
activity_ids: list[int] = Field(min_length=1)
class CpsReferralLinkItem(BaseModel):
activity_id: int
# 链接类型:1长链 2短链 3deeplink。默认短链(微信可打开)+deeplink。
link_types: list[int] = Field(default_factory=lambda: [2, 3])
class CpsReferralLinkOut(BaseModel):
redirect_url: str # ★ 我们的群发短链(/c/{code}),发群就用这个,点击经我们统计
code: str
sid: str
group_name: str
activity_name: str
target_url: str # 实际 302 跳转的美团短链
link_map: dict[str, str] # 美团原始 1长链/2短链/3deeplink(参考/备用)
platform: str
redirect_url: str # 我们的落地页 /c/{code}(发群用)
code: str
class CpsReferralLinksOut(BaseModel):
group_name: str
results: list[CpsReferralLinkItem]
# ───────────── 对账拉单 ─────────────
class CpsReconcileResult(BaseModel):
fetched: int # 本次美团返回的订单条数
inserted: int # 新入库
updated: int # 已存在→更新状态/佣金
pages: int # 翻了几页
fetched: int
inserted: int
updated: int
pages: int
# ───────────── 订单明细 ─────────────
@@ -101,18 +114,22 @@ class CpsOrderOut(BaseModel):
# ───────────── 统计 ─────────────
class CpsGroupStat(BaseModel):
group_id: int | None = None # None = 未归群的 sid(历史/其它来源)
group_id: int | None = None
sid: str | None = None
name: str
platforms: list[str] = Field(default_factory=list)
member_count: int | None = None
click_pv: int = 0 # 点击总次数
click_uv: int = 0 # 独立点击(按 ip+ua 近似去重)
order_count: int # 有效订单数(不含取消/风控)
settled_count: int # 已结算单数
canceled_count: int # 取消+风控单数
gmv_cents: int # 成交额(有效单 payPrice 合计)
est_commission_cents: int # 预估佣金(有效单 profit 合计)
settled_commission_cents: int # 已结算佣金
click_pv: int = 0
click_uv: int = 0
copy_pv: int = 0 # 淘宝"复制口令"次数
copy_uv: int = 0
# 对账类:淘宝/京东无法对账 → None(前端显示 "-");美团群为数值
order_count: int | None = None
settled_count: int | None = None
canceled_count: int | None = None
gmv_cents: int | None = None
est_commission_cents: int | None = None
settled_commission_cents: int | None = None
class CpsStatsOut(BaseModel):
+110 -11
View File
@@ -1,19 +1,24 @@
"""CPS 群发短链跳转:用户点 /c/{code} → 记一条点击 → 302 跳美团
"""CPS 群发短链落地:用户点 /c/{code} → 记点击 → 按平台 302 跳 或 返回淘宝落地页
公网无鉴权(群里任何人点都要能跳)。记点击失败绝不影响跳转(用户体验优先)
公网无鉴权(群里任何人点都要能跳/能领)。记点击失败绝不影响用户
- 美团/京东:记 visit + 302 跳 target(美团短链 / 京东链接)
- 淘宝:记 visit + 返回 H5 落地页(整段淘口令复制按钮);点"复制口令"→ POST /c/{code}/copy 记 copy
"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.cps_link import CpsLink
from app.repositories import cps_link as cps_link_repo
router = APIRouter(tags=["cps-redirect"])
# code 不存在/失效时的兜底落地(避免用户看到报错)
# code 不存在/失效时的兜底落地
_FALLBACK_URL = "https://www.meituan.com/"
@@ -24,16 +29,110 @@ def _client_ip(request: Request) -> str | None:
return request.client.host if request.client else None
@router.get("/c/{code}", summary="群发短链跳转(记点击 + 302)")
def cps_redirect(code: str, request: Request, db: Session = Depends(get_db)):
link = cps_link_repo.get_by_code(db, code)
if link is None:
return RedirectResponse(_FALLBACK_URL, status_code=302)
def _record(db: Session, link: CpsLink, request: Request, event_type: str) -> None:
try:
cps_link_repo.record_click(
db, link=link, ip=_client_ip(request),
ua=request.headers.get("user-agent"),
ua=request.headers.get("user-agent"), event_type=event_type,
)
except Exception:
pass # 记点击失败不阻断跳转
pass # 记点击失败不阻断
@router.get("/c/{code}", summary="群发短链落地(记点击 + 跳转/淘宝落地页)")
def cps_landing(code: str, request: Request, db: Session = Depends(get_db)):
link = cps_link_repo.get_by_code(db, code)
if link is None:
return RedirectResponse(_FALLBACK_URL, status_code=302)
_record(db, link, request, "visit")
if link.platform == "taobao":
return HTMLResponse(_taobao_landing_html(link.target_url))
# 美团短链 / 京东链接:直接 302 跳
return RedirectResponse(link.target_url, status_code=302)
@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件)")
def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict:
link = cps_link_repo.get_by_code(db, code)
if link is not None:
_record(db, link, request, "copy")
return {"ok": True}
def _taobao_landing_html(token: str) -> str:
"""淘宝落地页 H5(红色喜庆,两步引导,底部复制淘口令按钮)。token 安全嵌入 JS。"""
return _TAOBAO_HTML.replace("__TOKEN_JS__", json.dumps(token))
_TAOBAO_HTML = """<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>淘宝闪购 · 天天领红包</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
body{font-family:-apple-system,"PingFang SC",sans-serif;background:#fff0ef;color:#333;padding-bottom:120px}
.banner{background:linear-gradient(180deg,#ff4d4f,#ff7a45);color:#fff;padding:26px 20px 62px;text-align:center;border-radius:0 0 26px 26px}
.tag{display:inline-block;background:#fff;color:#ff4d4f;font-weight:700;font-size:15px;padding:4px 16px;border-radius:14px;margin-bottom:14px}
.banner h1{font-size:30px;font-weight:800;line-height:1.25;letter-spacing:1px}
.banner .pill{display:inline-block;margin-top:14px;background:#ffd54a;color:#a52800;font-weight:700;font-size:14px;padding:6px 18px;border-radius:16px}
.card{background:#fff;margin:-42px 16px 0;border-radius:18px;padding:22px 18px;box-shadow:0 8px 24px rgba(255,77,79,.12)}
.card .title{text-align:center;color:#ff4d4f;font-weight:800;font-size:18px;background:linear-gradient(90deg,#fff,#ffe1e1,#fff);padding:9px;border-radius:18px;margin-bottom:16px}
.step{display:flex;align-items:center;margin:14px 4px;font-size:15px}
.step .num{flex:none;width:22px;height:22px;border-radius:50%;background:#222;color:#fff;font-size:13px;font-weight:700;display:flex;align-items:center;justify-content:center;margin-right:10px}
.step b{color:#ff4d4f}
.redbao{background:linear-gradient(135deg,#ff5b5b,#ff3b3b);border-radius:14px;margin-top:16px;padding:16px}
.redbao .rb-head{text-align:center;color:#fff;font-size:18px;font-weight:800;margin-bottom:2px}
.redbao .rb-sub{text-align:center;color:#fff;font-size:12px;opacity:.85;margin-bottom:10px}
.coupon{background:#fff;color:#ff3b3b;border-radius:10px;padding:11px 14px;display:flex;align-items:center;margin-top:10px}
.coupon .amt{font-size:24px;font-weight:800;flex:none;width:92px}
.coupon .amt small{font-size:11px;display:block;color:#999;font-weight:400}
.coupon .desc{flex:1}
.coupon .desc .c-t{font-weight:700;font-size:15px;color:#333}
.coupon .desc .c-s{font-size:11px;color:#999}
.coupon .go{flex:none;background:#ff3b3b;color:#fff;font-size:13px;padding:6px 14px;border-radius:14px}
.btn-wrap{position:fixed;left:0;right:0;bottom:0;padding:16px;background:linear-gradient(180deg,rgba(255,240,239,0),#fff0ef 45%)}
.btn{display:block;width:100%;background:linear-gradient(90deg,#ff5b5b,#ff3b3b);color:#fff;font-size:19px;font-weight:800;text-align:center;padding:16px;border:none;border-radius:30px;box-shadow:0 6px 16px rgba(255,59,59,.4)}
.btn:active{transform:scale(.98)}
.toast{position:fixed;left:50%;top:42%;transform:translate(-50%,-50%);background:rgba(0,0,0,.82);color:#fff;padding:12px 22px;border-radius:10px;font-size:15px;opacity:0;transition:opacity .25s;pointer-events:none;z-index:99;white-space:nowrap}
.toast.show{opacity:1}
</style>
</head>
<body>
<div class="banner">
<div class="tag">淘宝闪购</div>
<h1>天天来闪购<br>下单抽免单</h1>
<div class="pill">最高 15-15 红包 · 天天可领</div>
</div>
<div class="card">
<div class="title">最高 15-15 红包 天天可领!</div>
<div class="step"><span class="num">1</span><div>第一步:点击下方按钮 <b>复制淘口令</b></div></div>
<div class="step"><span class="num">2</span><div>第二步:打开 <b>淘宝 App</b>,自动弹出领取红包</div></div>
<div class="redbao">
<div class="rb-head">外卖红包 · 今日已到账</div>
<div class="rb-sub">明天继续来领哦</div>
<div class="coupon"><div class="amt">¥15<small>满15可用</small></div><div class="desc"><div class="c-t">外卖红包</div><div class="c-s">仅限于美食外卖</div></div><div class="go">去使用</div></div>
<div class="coupon"><div class="amt">¥7<small>满9可用</small></div><div class="desc"><div class="c-t">外卖红包</div><div class="c-s">仅限于美食外卖</div></div><div class="go">去使用</div></div>
</div>
</div>
<div class="btn-wrap"><button class="btn" onclick="copyToken()">复制口令去淘宝领红包</button></div>
<div class="toast" id="toast"></div>
<script>
var TOKEN = __TOKEN_JS__;
function showToast(m){var t=document.getElementById('toast');t.textContent=m;t.classList.add('show');setTimeout(function(){t.classList.remove('show')},2200)}
function reportCopy(){try{fetch(location.pathname+'/copy',{method:'POST',keepalive:true})}catch(e){}}
function done(){showToast('复制成功!打开淘宝即可领取');reportCopy()}
function fallback(){
var ta=document.createElement('textarea');ta.value=TOKEN;ta.style.position='fixed';ta.style.opacity='0';
document.body.appendChild(ta);ta.focus();ta.select();
try{document.execCommand('copy');done()}catch(e){showToast('复制失败,请长按手动复制')}
document.body.removeChild(ta);
}
function copyToken(){
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(TOKEN).then(done).catch(fallback)}
else{fallback()}
}
</script>
</body>
</html>"""
+3 -1
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy import DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -24,6 +24,8 @@ class CpsActivity(Base):
act_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 美团商品券 productViewSign(与 act_id 二选一转链;多数活动用 act_id)。
product_view_sign: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 淘宝:整段淘口令文本(落地页原样复制);京东:推广链接(落地页 302 跳)。美团不用这个(用 act_id)。
payload: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | archived
remark: Mapped[str | None] = mapped_column(String(256), nullable=True)
created_at: Mapped[datetime] = mapped_column(
+8 -3
View File
@@ -7,7 +7,8 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy import JSON, DateTime, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -17,9 +18,13 @@ class CpsGroup(Base):
__tablename__ = "cps_group"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 每群唯一的渠道标识,建群时分配(留空则自动生成 g<id>)。字母+数字,≤64
sid: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
# 渠道标识。仅美团需要(sid 追踪);纯淘宝/京东群无 sid(那俩不支持)。含美团时建群分配
sid: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
# 该群发哪些平台的券(多选): ["meituan","taobao","jd"]。含 meituan 才需要 sid。
platforms: Mapped[list] = mapped_column(
JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=list
)
# 群人数,运营填,作转化率分母(可空)。
member_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | archived
+9 -4
View File
@@ -23,10 +23,11 @@ class CpsLink(Base):
code: Mapped[str] = mapped_column(String(16), unique=True, index=True, nullable=False)
group_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
activity_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
sid: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
# 仅美团 link 有 sid(渠道追踪);淘宝/京东无 sid。
sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
platform: Mapped[str] = mapped_column(String(20), nullable=False, default="meituan")
# 302 跳转目标(美团短链,微信内可打开并唤起 App)。
target_url: Mapped[str] = mapped_column(String(1024), nullable=False)
# 跳转/落地目标: 美团短链 / 京东链接(302 跳) / 淘宝整段淘口令文本(H5 落地页复制)。
target_url: Mapped[str] = mapped_column(String(2048), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
@@ -41,7 +42,11 @@ class CpsClick(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
link_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
group_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) # 冗余,按群聚合快
sid: Mapped[str] = mapped_column(String(64), index=True, nullable=False) # 冗余
sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 冗余(淘宝/京东无)
# 事件类型: visit(进落地页/被跳转) | copy(淘宝落地页点了"复制口令")
event_type: Mapped[str] = mapped_column(
String(16), nullable=False, default="visit", server_default="visit"
)
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
ua: Mapped[str | None] = mapped_column(String(512), nullable=True)
clicked_at: Mapped[datetime] = mapped_column(
+24 -11
View File
@@ -26,7 +26,7 @@ def get_by_code(db: Session, code: str) -> CpsLink | None:
def create_link(
db: Session, *, group_id: int, activity_id: int, sid: str, target_url: str,
db: Session, *, group_id: int, activity_id: int, sid: str | None, target_url: str,
platform: str = "meituan", commit: bool = True,
) -> CpsLink:
"""生成唯一短码并存库。冲突重试 5 次,仍冲突用 12 位兜底(概率近 0)。"""
@@ -50,9 +50,13 @@ def create_link(
return link
def record_click(db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None) -> None:
def record_click(
db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None,
event_type: str = "visit",
) -> None:
"""记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。"""
db.add(CpsClick(
link_id=link.id, group_id=link.group_id, sid=link.sid,
link_id=link.id, group_id=link.group_id, sid=link.sid, event_type=event_type,
ip=ip, ua=(ua[:500] if ua else None),
))
db.commit()
@@ -61,7 +65,8 @@ def record_click(db: Session, *, link: CpsLink, ip: str | None = None, ua: str |
def click_stats_by_group(
db: Session, *, date_from: datetime | None = None, date_to: datetime | None = None,
) -> dict[int, dict]:
"""按 group_id 聚合点击 → {group_id: {"pv": N, "uv": M}}。uv 按 (ip, ua) 近似去重。"""
"""按 group_id 聚合点击 → {group_id: {pv, uv, copy_pv, copy_uv}}。
visit 计入 pv/uv,copy(淘宝复制口令) 计入 copy_pv/copy_uv;uv (ip, ua) 近似去重"""
stmt = select(CpsClick)
if date_from is not None:
stmt = stmt.where(CpsClick.clicked_at >= date_from)
@@ -70,13 +75,21 @@ def click_stats_by_group(
clicks = list(db.execute(stmt).scalars().all())
agg: dict[int, dict] = {}
seen: dict[int, set] = {}
seen_visit: dict[int, set] = {}
seen_copy: dict[int, set] = {}
for c in clicks:
a = agg.setdefault(c.group_id, {"pv": 0, "uv": 0})
a["pv"] += 1
s = seen.setdefault(c.group_id, set())
a = agg.setdefault(c.group_id, {"pv": 0, "uv": 0, "copy_pv": 0, "copy_uv": 0})
key = (c.ip, c.ua)
if key not in s:
s.add(key)
a["uv"] += 1
if c.event_type == "copy":
a["copy_pv"] += 1
s = seen_copy.setdefault(c.group_id, set())
if key not in s:
s.add(key)
a["copy_uv"] += 1
else:
a["pv"] += 1
s = seen_visit.setdefault(c.group_id, set())
if key not in s:
s.add(key)
a["uv"] += 1
return agg