diff --git a/alembic/versions/cps_v2_platforms.py b/alembic/versions/cps_v2_platforms.py new file mode 100644 index 0000000..e80c1a6 --- /dev/null +++ b/alembic/versions/cps_v2_platforms.py @@ -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") diff --git a/app/admin/repositories/cps.py b/app/admin/repositories/cps.py index 17516e2..6733de9 100644 --- a/app/admin/repositories/cps.py +++ b/app/admin/repositories/cps.py @@ -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(先 flush 拿 id 再回填)。""" + """建群。含 meituan 才有 sid(留空自动 g);纯淘宝/京东 sid=None(那俩不支持 sid)。""" + has_meituan = "meituan" in (platforms or []) + # 含美团:用填的 sid,或临时占位待回填 g;不含美团: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 + 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 diff --git a/app/admin/routers/cps.py b/app/admin/routers/cps.py index 592f363..5eae8fe 100644 --- a/app/admin/routers/cps.py +++ b/app/admin/routers/cps.py @@ -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), ) diff --git a/app/admin/schemas/cps.py b/app/admin/schemas/cps.py index 9028fe1..5ca7dbb 100644 --- a/app/admin/schemas/cps.py +++ b/app/admin/schemas/cps.py @@ -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;填写则必须字母+数字(美团 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): diff --git a/app/api/v1/cps_redirect.py b/app/api/v1/cps_redirect.py index 3b4120c..f69fab3 100644 --- a/app/api/v1/cps_redirect.py +++ b/app/api/v1/cps_redirect.py @@ -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 = """ + + + + +淘宝闪购 · 天天领红包 + + + + +
+
最高 15-15 红包 天天可领!
+
1
第一步:点击下方按钮 复制淘口令
+
2
第二步:打开 淘宝 App,自动弹出领取红包
+
+
外卖红包 · 今日已到账
+
明天继续来领哦
+
¥15满15可用
外卖红包
仅限于美食外卖
去使用
+
¥7满9可用
外卖红包
仅限于美食外卖
去使用
+
+
+
+
+ + +""" diff --git a/app/models/cps_activity.py b/app/models/cps_activity.py index d52823a..383f854 100644 --- a/app/models/cps_activity.py +++ b/app/models/cps_activity.py @@ -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( diff --git a/app/models/cps_group.py b/app/models/cps_group.py index fcd40fd..8a9e516 100644 --- a/app/models/cps_group.py +++ b/app/models/cps_group.py @@ -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)。字母+数字,≤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 diff --git a/app/models/cps_link.py b/app/models/cps_link.py index ef53859..2702c8a 100644 --- a/app/models/cps_link.py +++ b/app/models/cps_link.py @@ -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( diff --git a/app/repositories/cps_link.py b/app/repositories/cps_link.py index 9991ff8..2e34970 100644 --- a/app/repositories/cps_link.py +++ b/app/repositories/cps_link.py @@ -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