Files
2026-10-15 20:36:59 +08:00

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)