feat(invite): 被邀请人列表接口 + 指纹归因(任务3) (#31)

- GET /invitees: 分页 + 名字降级兜底(昵称→微信昵称→脱敏手机号)
- 指纹归因: 落地页采集 + (IP,设备型号)反查撞库 + 时间窗口闸
- test_invite: +5 个列表测试(脱敏/倒序/昵称优先/分页/空), 修指纹测试跨用例串味

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Reviewed-on: #31
Co-authored-by: xiebing <xiebing@wonderable.ai>
Co-committed-by: xiebing <xiebing@wonderable.ai>
This commit was merged in pull request #31.
This commit is contained in:
xiebing
2026-06-09 21:48:48 +08:00
committed by marco
parent a828b51d9f
commit b2f5a53dd8
11 changed files with 777 additions and 23 deletions
+1
View File
@@ -13,6 +13,7 @@ from app.models.coupon_state import ( # noqa: F401
)
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
from app.models.ops_stat_config import OpsStatConfig # noqa: F401
+46
View File
@@ -0,0 +1,46 @@
"""邀请指纹归因表(剪贴板归因兜底)。
数据流(对应 [[invite-three-tasks]] 任务 3):
1. B 浏览器打开落地页 dl.html?ref=邀请码 → JS POST /api/v1/invite/landing-track
2. 后端从 HTTP 头拿 IP/UA,解析 UA 得 device_model,跟 JS 上报的 screen 一起入库
3. B 装包首启 → 登录后,若剪贴板没拿到邀请码(被覆盖),客户端再算一次 screen+Build.MODEL
上报 → 后端用 (ip, screen, device_model) 反查本表 7 天内最近匹配 → 拿出 inviter_user_id
→ 走原 bind 路径(channel=fingerprint)
不要索引(IP, created_at) 单独,组合索引 (ip, screen, device_model, created_at desc) 反查更省。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class InviteFingerprint(Base):
__tablename__ = "invite_fingerprint"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
inviter_user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 落地页访问者 IP(服务端从 HTTP 头拿,X-Forwarded-For 由部署侧 nginx 透传)
ip: Mapped[str] = mapped_column(String(64), nullable=False)
# 客户端 Build.MODEL / 浏览器 UA 解析出来的型号(如 "PJF110"、"23046PNC9C")
device_model: Mapped[str] = mapped_column(String(64), nullable=False, default="")
# 屏幕分辨率 "1080x2400"(浏览器 screen.width × height,客户端 DisplayMetrics)
screen: Mapped[str] = mapped_column(String(32), nullable=False, default="")
# 完整 UA 字符串(留 debug / 排查"匹配不上"用,不直接参与匹配)
user_agent: Mapped[str] = mapped_column(Text, nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<InviteFingerprint inviter={self.inviter_user_id} "
f"ip={self.ip} model={self.device_model} screen={self.screen}>"
)