e2ecd3db9e
- 发奖触发点从「比价成功上报」(compare/record)挪到「实际下单上报」 (order/report):被邀请人完成比价并真实下单才给邀请人发 2 元邀请奖励金 (冰 2026-07-02 拍板);仅完成比价不再发奖。幂等闸 compare_reward_granted 不变。 - 新增 POST /api/v1/invite/dev-reset:把固定测试号(TEST_ACCOUNT_PHONE)打回 出厂态(清邀请关系/奖励金/savings/流水 + 刷 created_at 骗过 72h 新用户闸), 便于反复当新用户测发奖。双门控:生产 404 + 只认配置的测试号,无鉴权。 - 测试:8 例改为下单驱动 + 新增 2 例覆盖 dev-reset(可反复邀请 / 开关关闭 400)。
276 lines
11 KiB
Python
276 lines
11 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)
|
|
reward_balance, reward_withdrawn = invite_repo.get_reward_stats(db, user.id)
|
|
days_left, is_fresh_round, countdown_text = invite_repo.compute_invite_countdown(user.created_at)
|
|
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,
|
|
reward_balance_cents=reward_balance,
|
|
reward_withdrawn_cents=reward_withdrawn,
|
|
countdown_days_left=days_left,
|
|
countdown_is_fresh_round=is_fresh_round,
|
|
countdown_text=countdown_text,
|
|
)
|
|
|
|
|
|
@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("/seed-fake", summary="[DEV] 给自己塞虚拟好友(测在途/好友列表 UI,仅非生产)")
|
|
def seed_fake_invitees(
|
|
user: CurrentUser, db: DbSession, pending: int = 3, compared: int = 2
|
|
) -> dict:
|
|
"""造 pending 个未比价 + compared 个已比价的虚拟好友。生产环境(is_prod)直接 404。
|
|
|
|
未比价 → 上"在途列表 / 好友列表去提醒";已比价 → 好友列表"邀请成功"、并发真实邀请奖励金
|
|
(可提现余额/累计提现也有数)。清理见 DELETE /seed-fake。
|
|
"""
|
|
if settings.is_prod:
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=404, detail="Not Found")
|
|
n = invite_repo.seed_fake_invitees(db, user.id, pending=max(0, pending), compared=max(0, compared))
|
|
return {"status": "ok", "created": n, "pending": pending, "compared": compared}
|
|
|
|
|
|
@router.delete("/seed-fake", summary="[DEV] 清掉自己的虚拟好友(仅非生产)")
|
|
def clear_fake_invitees(user: CurrentUser, db: DbSession) -> dict:
|
|
if settings.is_prod:
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=404, detail="Not Found")
|
|
n = invite_repo.clear_fake_invitees(db, user.id)
|
|
return {"status": "ok", "removed": n}
|
|
|
|
|
|
@router.post("/dev-reset", summary="[DEV] 把固定测试号打回出厂态,便于反复当新用户测发奖(仅非生产)")
|
|
def dev_reset(db: DbSession) -> dict:
|
|
"""把 TEST_ACCOUNT_PHONE 这个固定测试号清成"从没被邀请/没下过单"的新用户态。
|
|
|
|
⚠️ 无需鉴权(它只按配置里的测试号操作,不依赖登录态,方便测试脚本第一步直接调)。
|
|
双重门控:生产环境(is_prod)404;测试号总开关(TEST_ACCOUNT_PHONE)没配置 → 400。
|
|
故它永远只能动那一个配置好的测试号,动不了任意真实用户。
|
|
|
|
清什么见 repositories/invite.dev_reset_test_account(全清 + 刷 created_at 骗过 72h 闸)。
|
|
调完该测试号即可再次绑定 + 下单上报,重新触发一次邀请发奖。
|
|
"""
|
|
from fastapi import HTTPException
|
|
|
|
if settings.is_prod:
|
|
raise HTTPException(status_code=404, detail="Not Found")
|
|
phone = settings.test_account_phone
|
|
if not phone:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="测试号未启用:请在 .env 配 TEST_ACCOUNT_PHONE=11111111111 并重启服务",
|
|
)
|
|
cleared = invite_repo.dev_reset_test_account(db, phone)
|
|
logger.info("invite dev-reset phone=%s cleared=%s", phone, cleared)
|
|
return {"status": "ok", "phone": phone, "cleared": cleared}
|
|
|
|
|
|
@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"],
|
|
)
|