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:
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user