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
+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>"""