170 lines
7.0 KiB
Python
170 lines
7.0 KiB
Python
"""推广归因服务:点击埋点 / 模糊匹配 / H5 中间页
|
|
|
|
三段式自建方案的辅助路径:
|
|
1. 分享口令(剪贴板)→ 注册时显式 invite_code,准确率 100%(主路径,App 端解析)
|
|
2. H5 中间页点击埋点(code+IP+UA 存 Redis 24h)→ App 首启上报 device_id 模糊匹配(本模块)
|
|
3. 注册页邀请码手填(兜底,沿用 /auth/register 的 invite_code 字段)
|
|
|
|
安全设计:模糊匹配结果由服务端按 device_id 缓存(invite:attr:{device_id}),
|
|
注册时服务端自取并一次性消费,不信任客户端回传,防止伪造归因。
|
|
"""
|
|
import html
|
|
import json
|
|
import time
|
|
|
|
import redis.asyncio as aioredis
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.user import User
|
|
from app.services import config_service
|
|
|
|
CLICK_TTL = 24 * 3600 # 点击记录有效期(超过 24h 未安装不再归因)
|
|
ATTR_TTL = 7 * 24 * 3600 # 归因结果缓存(命中后允许延迟安装注册)
|
|
CLICK_DUP_TTL = 60 # 同 IP+code 60s 内重复埋点去重
|
|
MATCH_CANDIDATES = 5 # 同 IP 最近点击候选数
|
|
|
|
|
|
def _ip_key(ip: str) -> str:
|
|
return f"invite:ip:{ip}"
|
|
|
|
|
|
def _clk_key(code: str, ip: str) -> str:
|
|
return f"invite:clk:{code}:{ip}"
|
|
|
|
|
|
def attr_key(device_id: str) -> str:
|
|
return f"invite:attr:{device_id}"
|
|
|
|
|
|
# ---------- 点击埋点 ----------
|
|
|
|
async def record_click(redis: aioredis.Redis, code: str, ip: str, ua: str) -> dict:
|
|
"""H5 中间页点击埋点:IP 维度 zset(score=时间戳)+ 点击详情"""
|
|
if not ip:
|
|
return {"recorded": False}
|
|
# 60s 内同 IP+code 重复点击去重(页面刷新/重复打开)
|
|
dup = await redis.set(f"invite:dup:{ip}:{code}", 1, nx=True, ex=CLICK_DUP_TTL)
|
|
if not dup:
|
|
return {"recorded": True, "dedup": True}
|
|
now = time.time()
|
|
await redis.zadd(_ip_key(ip), {code: now})
|
|
await redis.expire(_ip_key(ip), CLICK_TTL)
|
|
await redis.set(_clk_key(code, ip), json.dumps({"ua": ua, "ts": now}), ex=CLICK_TTL)
|
|
return {"recorded": True}
|
|
|
|
|
|
# ---------- 归因匹配 ----------
|
|
|
|
async def match_and_cache(redis: aioredis.Redis, device_id: str, ip: str, ua: str) -> dict:
|
|
"""App 首启归因:按 IP 取最近一次点击的邀请码,缓存到 device_id 维度(幂等)"""
|
|
cached = await redis.get(attr_key(device_id))
|
|
if cached:
|
|
return {"matched": True, "invite_code": cached}
|
|
|
|
code = None
|
|
if ip:
|
|
# 最近点击优先(同 IP 可能点击过多个推广链接,时间近者为准)
|
|
for c in await redis.zrevrange(_ip_key(ip), 0, MATCH_CANDIDATES - 1):
|
|
if await redis.get(_clk_key(c, ip)):
|
|
code = c
|
|
break
|
|
if not code:
|
|
return {"matched": False}
|
|
await redis.set(attr_key(device_id), code, ex=ATTR_TTL)
|
|
return {"matched": True, "invite_code": code}
|
|
|
|
|
|
async def pop_attribution(redis: aioredis.Redis, device_id: str) -> str | None:
|
|
"""注册时取归因结果并一次性消费"""
|
|
key = attr_key(device_id)
|
|
code = await redis.get(key)
|
|
if code:
|
|
await redis.delete(key)
|
|
return code
|
|
|
|
|
|
# ---------- H5 中间页 ----------
|
|
|
|
_LANDING_HTML = """<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
|
<title>邀请你加入短视频</title>
|
|
<style>
|
|
* {{ margin:0; padding:0; box-sizing:border-box; }}
|
|
body {{ background:#0f0f14; color:#fff; font-family:-apple-system,"PingFang SC","Helvetica Neue",sans-serif;
|
|
min-height:100vh; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:32px 24px; }}
|
|
.logo {{ width:88px; height:88px; border-radius:20px; display:flex; align-items:center; justify-content:center;
|
|
background:linear-gradient(135deg,#fe2c55,#7c4dff); font-size:44px; margin-bottom:20px; }}
|
|
h1 {{ font-size:22px; margin-bottom:8px; }}
|
|
.sub {{ color:#9a9aa5; font-size:14px; margin-bottom:32px; text-align:center; line-height:1.6; }}
|
|
.inviter {{ color:#ffd700; }}
|
|
.btn {{ width:100%; max-width:320px; padding:15px 0; border:none; border-radius:12px; font-size:16px;
|
|
font-weight:600; cursor:pointer; margin-bottom:14px; }}
|
|
.btn-primary {{ background:linear-gradient(135deg,#fe2c55,#7c4dff); color:#fff; }}
|
|
.btn-ghost {{ background:#1e1e28; color:#e8e8ec; border:1px solid #33333f; }}
|
|
.code {{ color:#9a9aa5; font-size:13px; margin-top:10px; }}
|
|
.code b {{ color:#ffd700; font-size:16px; letter-spacing:2px; }}
|
|
#toast {{ position:fixed; left:50%; bottom:60px; transform:translateX(-50%); background:#333; color:#fff;
|
|
padding:10px 20px; border-radius:8px; font-size:14px; opacity:0; transition:opacity .3s; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="logo">▶</div>
|
|
<h1>{title}</h1>
|
|
<p class="sub">新人注册立送体验会员<br>复制口令后打开 App 即可自动绑定邀请</p>
|
|
<button class="btn btn-primary" onclick="copyCmd()">复制邀请口令</button>
|
|
{download_btn}
|
|
<p class="code">我的邀请码 <b>{code}</b></p>
|
|
<div id="toast"></div>
|
|
<script>
|
|
var CODE = "{code}";
|
|
var CMD = "#短视频邀请码" + CODE + "#";
|
|
// 打开页面即埋点(记录 code+IP+UA,安装首启后据此归因)
|
|
fetch("/api/v1/invite/click", {{
|
|
method: "POST",
|
|
headers: {{ "Content-Type": "application/json" }},
|
|
body: JSON.stringify({{ code: CODE }})
|
|
}}).catch(function() {{}});
|
|
function toast(t) {{
|
|
var el = document.getElementById("toast");
|
|
el.textContent = t; el.style.opacity = 1;
|
|
setTimeout(function() {{ el.style.opacity = 0; }}, 1800);
|
|
}}
|
|
function copyCmd() {{
|
|
var done = function() {{ toast("口令已复制,打开 App 自动识别"); }};
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {{
|
|
navigator.clipboard.writeText(CMD).then(done, function() {{ fallback(); }});
|
|
}} else {{ fallback(); }}
|
|
function fallback() {{
|
|
var ta = document.createElement("textarea");
|
|
ta.value = CMD; document.body.appendChild(ta);
|
|
ta.select(); try {{ document.execCommand("copy"); done(); }}
|
|
catch (e) {{ toast("复制失败,请手动输入邀请码"); }}
|
|
document.body.removeChild(ta);
|
|
}}
|
|
}}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
async def render_landing_page(db: AsyncSession, redis: aioredis.Redis, code: str) -> str:
|
|
"""推广落地页:展示邀请人 + 复制口令 + (可选)下载入口;页面加载即自动埋点"""
|
|
inviter = (await db.execute(select(User).where(User.invite_code == code))).scalar_one_or_none()
|
|
if inviter is not None:
|
|
title = f'<span class="inviter">{html.escape(inviter.nickname)}</span> 邀请你加入短视频'
|
|
else:
|
|
title = "邀请你加入短视频"
|
|
|
|
download_url = await config_service.get_config(db, redis, "app.download_url", "")
|
|
download_btn = (
|
|
f'<button class="btn btn-ghost" onclick="location.href=\'{html.escape(download_url, quote=True)}\'">下载 APP</button>'
|
|
if download_url else ""
|
|
)
|
|
safe_code = html.escape(code)
|
|
return _LANDING_HTML.format(title=title, code=safe_code, download_btn=download_btn)
|