"""推广归因服务:点击埋点 / 模糊匹配 / 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 = """ 邀请你加入短视频

{title}

新人注册立送体验会员
复制口令后打开 App 即可自动绑定邀请

{download_btn}

我的邀请码 {code}

""" 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'{html.escape(inviter.nickname)} 邀请你加入短视频' else: title = "邀请你加入短视频" download_url = await config_service.get_config(db, redis, "app.download_url", "") download_btn = ( f'' if download_url else "" ) safe_code = html.escape(code) return _LANDING_HTML.format(title=title, code=safe_code, download_btn=download_btn)