e1bd0e3ef7
改了什么:新增新版大盘日期窗口聚合、广告收益拆分、比价耗时字段、美团 CPS 拉单入库与大盘展示,并同步反馈审核和邀请奖励下线口径。 验证:python -m pytest tests/test_admin_read.py tests/test_admin_write.py tests/test_compare_record.py tests/test_invite.py tests/test_feedback.py -q。
218 lines
7.8 KiB
Python
218 lines
7.8 KiB
Python
"""好友邀请 endpoint。
|
|
|
|
路由前缀 /api/v1/invite,需 Bearer 鉴权(用户级数据);唯一例外是 /landing-track
|
|
(落地页 dl.html 浏览器访问、无 token,详见任务 3 [[invite-three-tasks]]):
|
|
GET /me 我的邀请码 + 分享链接 + 已邀人数(邀请页展示)
|
|
POST /bind 把当前登录用户(被邀请人)绑定到某邀请码;支持三种归因:
|
|
① clipboard:首启读剪贴板拿邀请码 → 上报码
|
|
② manual:用户在邀请页输入邀请码 → 上报码
|
|
③ fingerprint:剪贴板没拿到时上报指纹,后端反查
|
|
POST /landing-track 落地页 dl.html 访问时上报指纹(剪贴板兜底的服务端一端,无需鉴权)
|
|
|
|
绑定的真正逻辑(邀请码生成/反查、幂等、自邀屏蔽、指纹反查)在
|
|
repositories/invite.py。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
|
|
from fastapi import APIRouter, Request
|
|
|
|
from app.api.deps import CurrentUser, DbSession
|
|
from app.core import rewards
|
|
from app.core.config import settings
|
|
from app.repositories import invite as invite_repo
|
|
from app.schemas.invite import (
|
|
BindInviteIn,
|
|
BindInviteOut,
|
|
InviteeItem,
|
|
InviteeListOut,
|
|
InviteInfoOut,
|
|
LandingTrackIn,
|
|
LandingTrackOut,
|
|
)
|
|
|
|
logger = logging.getLogger("shagua.invite")
|
|
|
|
router = APIRouter(prefix="/api/v1/invite", tags=["invite"])
|
|
|
|
_BIND_MESSAGES = {
|
|
"success": "邀请绑定成功",
|
|
"already_bound": "你已绑定过邀请人",
|
|
"invalid_code": "邀请码无效",
|
|
"self_invite": "不能填写自己的邀请码",
|
|
"not_eligible": "邀请仅对新注册用户生效",
|
|
"fp_not_found": "未匹配到邀请关系",
|
|
}
|
|
|
|
|
|
def _client_ip(request: Request) -> str:
|
|
"""从 HTTP 头拿真实 IP。nginx 反代时走 X-Forwarded-For;裸跑 uvicorn 走 request.client。"""
|
|
xff = request.headers.get("x-forwarded-for", "")
|
|
if xff:
|
|
# X-Forwarded-For 可能是 "client, proxy1, proxy2" 链;取第一个 = 真实客户端
|
|
return xff.split(",")[0].strip()
|
|
return request.client.host if request.client else ""
|
|
|
|
|
|
# Android UA 形如 '... ; <Model> Build/<id>) AppleWebKit/...';抓 ';' 后到 ' Build/' 前
|
|
# 的 token = Build.MODEL(跨端跟客户端 android.os.Build.MODEL 对齐)。user-agents 库
|
|
# 把 Android 设备归为 'Smartphone' 通用名,抓不到真实型号,只能正则。
|
|
_ANDROID_BUILD_MODEL_RE = re.compile(r";\s*([^;]+?)\s+Build/", re.IGNORECASE)
|
|
|
|
|
|
def _parse_device_model(ua: str) -> str:
|
|
"""解析浏览器 UA 拿手机型号(如 '24115RA8EC'、'PJF110')。
|
|
|
|
Android:正则抓 UA 里 ';...Build/' 前的 token(== Build.MODEL),跨端可严格匹配。
|
|
iOS / 解析不到:退到 user-agents 库的通用解析,失败/UA 空 → 返空字符串。
|
|
"""
|
|
if not ua:
|
|
return ""
|
|
m = _ANDROID_BUILD_MODEL_RE.search(ua)
|
|
if m:
|
|
return m.group(1).strip()
|
|
try:
|
|
from user_agents import parse
|
|
return (parse(ua).device.model or "").strip()
|
|
except Exception: # noqa: BLE001
|
|
return ""
|
|
|
|
|
|
@router.get("/me", response_model=InviteInfoOut, summary="我的邀请码 + 分享链接 + 战绩")
|
|
def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
|
|
code = invite_repo.ensure_code(db, user)
|
|
invited, coins = invite_repo.get_stats(db, user.id)
|
|
sep = "&" if "?" in settings.INVITE_LANDING_URL else "?"
|
|
share_url = f"{settings.INVITE_LANDING_URL}{sep}ref={code}"
|
|
return InviteInfoOut(
|
|
invite_code=code,
|
|
share_url=share_url,
|
|
invited_count=invited,
|
|
coins_earned=coins,
|
|
)
|
|
|
|
|
|
@router.get("/invitees", response_model=InviteeListOut, summary="我邀请的人列表(分页)")
|
|
def my_invitees(
|
|
user: CurrentUser, db: DbSession, limit: int = 20, offset: int = 0
|
|
) -> InviteeListOut:
|
|
"""邀请页小窗(取前几条) + 完整列表页(分页加载)共用。
|
|
|
|
名字/头像的降级兜底在 repositories/invite.get_invitees 算好,这里只组装响应。
|
|
limit 夹到 [1,50] 防滥用;offset 不小于 0。
|
|
"""
|
|
limit = max(1, min(limit, 50))
|
|
offset = max(0, offset)
|
|
items, total, has_more = invite_repo.get_invitees(
|
|
db, user.id, limit=limit, offset=offset,
|
|
)
|
|
return InviteeListOut(
|
|
items=[InviteeItem(**it) for it in items],
|
|
total=total,
|
|
has_more=has_more,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/landing-track",
|
|
response_model=LandingTrackOut,
|
|
summary="落地页指纹采集(无需鉴权)",
|
|
)
|
|
def landing_track(
|
|
req: LandingTrackIn, request: Request, db: DbSession
|
|
) -> LandingTrackOut:
|
|
"""B 浏览器打开 dl.html?ref=xxx 时上报指纹。
|
|
|
|
服务端从 HTTP 头拿 IP/UA、解析 UA 拿 device_model,跟 req.screen 一起入库。
|
|
无需鉴权(浏览器没 token)。invalid_code / no_ip 走 silent 返回(不抛 5xx 影响下载流程)。
|
|
"""
|
|
inviter = invite_repo.resolve_inviter(db, req.ref)
|
|
if inviter is None or inviter.status != "active":
|
|
return LandingTrackOut(status="invalid_code")
|
|
|
|
ip = _client_ip(request)
|
|
if not ip:
|
|
return LandingTrackOut(status="no_ip")
|
|
|
|
ua_str = request.headers.get("user-agent", "")
|
|
device_model = _parse_device_model(ua_str)
|
|
|
|
invite_repo.record_fingerprint(
|
|
db,
|
|
inviter_user_id=inviter.id,
|
|
ip=ip,
|
|
screen=req.screen,
|
|
device_model=device_model,
|
|
user_agent=ua_str,
|
|
)
|
|
logger.info(
|
|
"invite landing-track inviter=%d ip=%s screen=%s model=%s",
|
|
inviter.id, ip, req.screen, device_model,
|
|
)
|
|
return LandingTrackOut(status="ok")
|
|
|
|
|
|
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,不发邀请金币)")
|
|
def bind_invite(
|
|
req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request
|
|
) -> BindInviteOut:
|
|
code = (req.invite_code or "").strip()
|
|
|
|
# 路径 1:有邀请码 → 走原路径(clipboard / manual)
|
|
if code:
|
|
result = invite_repo.bind(
|
|
db, invitee=user, invite_code=code, channel=req.channel,
|
|
)
|
|
logger.info(
|
|
"invite bind invitee=%d code=%s channel=%s -> %s",
|
|
user.id, code, req.channel, result.status,
|
|
)
|
|
return BindInviteOut(
|
|
status=result.status,
|
|
coins_awarded=result.invitee_coin,
|
|
message=_BIND_MESSAGES.get(result.status, result.status),
|
|
)
|
|
|
|
# 路径 2:无邀请码 + 有指纹 → 指纹兜底反查
|
|
if req.fingerprint is not None:
|
|
ip = _client_ip(request)
|
|
inviter = invite_repo.resolve_inviter_by_fingerprint(
|
|
db,
|
|
ip=ip,
|
|
screen=req.fingerprint.screen,
|
|
device_model=req.fingerprint.device_model,
|
|
window_days=rewards.INVITE_FP_WINDOW_DAYS,
|
|
)
|
|
if inviter is None:
|
|
logger.info(
|
|
"invite bind invitee=%d fingerprint not_found ip=%s screen=%s model=%s",
|
|
user.id, ip, req.fingerprint.screen, req.fingerprint.device_model,
|
|
)
|
|
return BindInviteOut(
|
|
status="fp_not_found",
|
|
coins_awarded=0,
|
|
message=_BIND_MESSAGES["fp_not_found"],
|
|
)
|
|
# 用反查到的 inviter.invite_code 走原 bind 流程(走原幂等/自邀/新人闸/发币逻辑)
|
|
result = invite_repo.bind(
|
|
db, invitee=user, invite_code=inviter.invite_code, channel="fingerprint",
|
|
)
|
|
logger.info(
|
|
"invite bind invitee=%d fingerprint -> inviter=%d code=%s -> %s",
|
|
user.id, inviter.id, inviter.invite_code, result.status,
|
|
)
|
|
return BindInviteOut(
|
|
status=result.status,
|
|
coins_awarded=result.invitee_coin,
|
|
message=_BIND_MESSAGES.get(result.status, result.status),
|
|
)
|
|
|
|
# 路径 3:啥都没传 → 无效请求
|
|
return BindInviteOut(
|
|
status="invalid_code",
|
|
coins_awarded=0,
|
|
message=_BIND_MESSAGES["invalid_code"],
|
|
)
|