b0e2d7c8d7
评审意见:POST/DELETE /invite/seed-fake、POST /invite/dev-reset 三个接口标注 [DEV]、 且 dev-reset 无鉴权,是本地测试用的造数/重置口子,不应合进 main。 删掉两处: - app/api/v1/invite.py: 三个端点 handler - app/repositories/invite.py: seed_fake_invitees / clear_fake_invitees / dev_reset_test_account 保留 is_compared 好友已比价状态、发奖改「比价并下单」、每日兑换定时器锁北京时区等产品改动。 纯删除,不影响其余接口(/me /invitees /bind /landing-track 均在)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
381 lines
16 KiB
Python
381 lines
16 KiB
Python
"""好友邀请 CRUD(注册即生效,绑定只建归因关系、双方都不发钱)。
|
||
|
||
发奖规则(v3,冰 2026-06-26 拍板):
|
||
- 绑定时双方都不发钱(被邀请人无奖励、邀请人不发金币)。
|
||
- 邀请人的钱由 try_reward_on_compare 在好友"成功比价一次"后发 2 元邀请奖励金(防刷)。
|
||
- v1 的"绑定双方各发金币"已废;invite_relation 的 inviter_coin/invitee_coin 列保留恒 0(待清)。
|
||
|
||
防重复发奖三道(仿 ad_reward / 提现的资金安全思路):
|
||
1. invitee_user_id 唯一 → 一个被邀请人只能被绑定一次(幂等键)。
|
||
2. 自邀屏蔽 → inviter == invitee 直接拒。
|
||
3. 现成的手机号唯一(每个被邀请人 = 一个真实手机号账号)= 天然限制刷量规模。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import secrets
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.exc import IntegrityError
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core import rewards
|
||
from app.models.invite import InviteRelation
|
||
from app.models.invite_fingerprint import InviteFingerprint
|
||
from app.models.user import User
|
||
from app.repositories import wallet as crud_wallet
|
||
|
||
# 邀请码字符集:去掉易混字符(0/O/1/I/L/B/8/S/5/Z/2),用户口述/手输不易错
|
||
_CODE_ALPHABET = "ACDEFGHJKMNPQRTUVWXY34679"
|
||
_CODE_LEN = 6
|
||
|
||
|
||
def _gen_code() -> str:
|
||
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LEN))
|
||
|
||
|
||
# ===== v2 邀请倒计时(7 天 1 轮,锚点=注册日,自然日差,东八区)=====
|
||
# 中国不用夏令时,固定 +8 偏移即可(不依赖 tzdata,Windows 本地联调也稳)。
|
||
_CST = timezone(timedelta(hours=8))
|
||
|
||
|
||
def compute_invite_countdown(register_at: datetime) -> tuple[int, bool, str]:
|
||
"""按 7 天 1 轮算邀请页倒计时。
|
||
|
||
锚点 = 用户注册日(user.created_at),按东八区自然日差算
|
||
(跨自然日才减、同日多次登录不减,天然满足)。返回:
|
||
(本轮剩余天数 1..7, 是否刚进入新一轮[非首轮第1天], 展示文案)。
|
||
"""
|
||
reg = register_at if register_at.tzinfo else register_at.replace(tzinfo=timezone.utc)
|
||
days_since = max(0, (datetime.now(_CST).date() - reg.astimezone(_CST).date()).days)
|
||
day_in_cycle = days_since % 7 # 0..6(本轮第几天,0-based)
|
||
days_left = 7 - day_in_cycle # 7..1(第1天=7、第7天=1)
|
||
is_fresh_round = days_since >= 7 and day_in_cycle == 0 # 非首轮的第1天
|
||
if is_fresh_round:
|
||
text = "恭喜您进入新一轮邀请!\n距离本轮结束还有7天"
|
||
else:
|
||
text = f"距本轮结束还有{days_left}天"
|
||
return days_left, is_fresh_round, text
|
||
|
||
|
||
def ensure_code(db: Session, user: User) -> str:
|
||
"""保证 user 有邀请码(懒生成),返回它。唯一约束碰撞则换码重试。
|
||
|
||
⚠️ 本函数会 db.commit() 整个 session(与 grant_coins"只 flush 不 commit"约定相反)。
|
||
当前只在 GET /invite/me 里调(该请求此前无其它写,提交范围干净)。若将来在带其它未提交
|
||
写的请求里复用本函数,会被它提前 commit——届时应改成 flush + 由调用方 commit。
|
||
"""
|
||
if user.invite_code:
|
||
return user.invite_code
|
||
for _ in range(8):
|
||
user.invite_code = _gen_code()
|
||
try:
|
||
db.commit()
|
||
db.refresh(user)
|
||
return user.invite_code
|
||
except IntegrityError:
|
||
db.rollback()
|
||
user = db.get(User, user.id) # 重取(rollback 后实例已过期),继续换码
|
||
raise RuntimeError("生成邀请码连续碰撞,请重试")
|
||
|
||
|
||
def resolve_inviter(db: Session, invite_code: str) -> User | None:
|
||
"""邀请码 → 邀请人(大小写不敏感)。"""
|
||
code = (invite_code or "").strip().upper()
|
||
if not code:
|
||
return None
|
||
return db.execute(
|
||
select(User).where(User.invite_code == code)
|
||
).scalar_one_or_none()
|
||
|
||
|
||
def _relation_of_invitee(db: Session, invitee_id: int) -> InviteRelation | None:
|
||
return db.execute(
|
||
select(InviteRelation).where(InviteRelation.invitee_user_id == invitee_id)
|
||
).scalar_one_or_none()
|
||
|
||
|
||
def _is_new_user(user: User) -> bool:
|
||
"""被邀请人是否为"新注册"(created_at 在 INVITE_NEW_USER_WINDOW_HOURS 窗口内)。
|
||
|
||
"新用户闸":只奖刚注册的人,挡存量老用户互相填码薅羊毛。兼容 PG(tz-aware)与
|
||
SQLite(naive,按 UTC 解释)。
|
||
"""
|
||
created = user.created_at
|
||
if created is None:
|
||
return False
|
||
if created.tzinfo is None:
|
||
created = created.replace(tzinfo=timezone.utc)
|
||
age = datetime.now(timezone.utc) - created
|
||
return age <= timedelta(hours=rewards.INVITE_NEW_USER_WINDOW_HOURS)
|
||
|
||
|
||
@dataclass
|
||
class BindResult:
|
||
status: str # success / already_bound / invalid_code / self_invite / not_eligible
|
||
relation: InviteRelation | None = None
|
||
invitee_coin: int = 0 # v3 起恒 0(绑定不再发金币);保留字段兼容响应
|
||
|
||
|
||
def bind(
|
||
db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard"
|
||
) -> BindResult:
|
||
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效。
|
||
|
||
v3 发奖(冰 2026-06-26 拍板):绑定双方都不发钱——被邀请人无奖励,邀请人改"好友成功比价一次
|
||
才发 2 元邀请奖励金"(见 try_reward_on_compare,防刷)。绑定只建归因关系 + 跑防刷闸。
|
||
幂等:已绑过 → already_bound。
|
||
"""
|
||
# 幂等:已绑过直接返回(不重复发奖)
|
||
existing = _relation_of_invitee(db, invitee.id)
|
||
if existing is not None:
|
||
return BindResult("already_bound", existing)
|
||
|
||
inviter = resolve_inviter(db, invite_code)
|
||
if inviter is None or inviter.status != "active":
|
||
return BindResult("invalid_code")
|
||
if inviter.id == invitee.id:
|
||
return BindResult("self_invite")
|
||
# 新用户闸:被邀请人必须是"新注册"(窗口内)才发奖,挡存量老用户互相填码薅羊毛
|
||
if not _is_new_user(invitee):
|
||
return BindResult("not_eligible")
|
||
|
||
# v3(冰 2026-06-26 拍板):绑定双方都不发钱。被邀请人不再发新人金币(去掉拉新即时激励);
|
||
# 邀请人的钱由 try_reward_on_compare 在好友成功比价后发 2 元邀请奖励金(防刷)。
|
||
rel = InviteRelation(
|
||
inviter_user_id=inviter.id,
|
||
invitee_user_id=invitee.id,
|
||
channel=(channel or "clipboard")[:16],
|
||
status="effective",
|
||
inviter_coin=0, # v1 金币线已停用,列保留恒 0(待清)
|
||
invitee_coin=0, # v3:被邀请人绑定不再发金币
|
||
)
|
||
db.add(rel)
|
||
# 绑定不发任何金币(邀请人改比价发现金、被邀请人无奖励),仅建归因关系。
|
||
try:
|
||
db.commit()
|
||
except IntegrityError:
|
||
# 并发:同一 invitee 另一个请求先建了关系 → 回滚返回已存在(幂等兜底)
|
||
db.rollback()
|
||
existing = _relation_of_invitee(db, invitee.id)
|
||
if existing is not None:
|
||
return BindResult("already_bound", existing)
|
||
raise
|
||
except Exception:
|
||
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证建关系原子,
|
||
# 不依赖 get_db 关闭时的隐式回滚,语义更硬。
|
||
db.rollback()
|
||
raise
|
||
db.refresh(rel)
|
||
return BindResult("success", rel)
|
||
|
||
|
||
@dataclass
|
||
class CompareRewardResult:
|
||
status: str # granted / no_relation / already_granted / inviter_inactive
|
||
inviter_user_id: int | None = None
|
||
reward_cents: int = 0
|
||
|
||
|
||
def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardResult:
|
||
"""被邀请人完成一次成功比价时调用:若其有邀请关系且尚未发过比价奖,给【邀请人】发邀请奖励金。
|
||
|
||
v2 发奖规则核心(替代 v1 的"注册即发金币"):好友"下载+登录+比价一次"→ 邀请人得 2 元现金。
|
||
幂等:compare_reward_granted 标记保证好友比价多次只发一次。无邀请关系 / 已发过 / 邀请人失效
|
||
→ 空操作。发奖(grant_invite_cash 入账独立账户)+ 置标记同事务 commit,保证原子。
|
||
"""
|
||
rel = _relation_of_invitee(db, invitee_user_id)
|
||
if rel is None:
|
||
return CompareRewardResult("no_relation")
|
||
if rel.compare_reward_granted:
|
||
return CompareRewardResult("already_granted", rel.inviter_user_id)
|
||
|
||
inviter = db.get(User, rel.inviter_user_id)
|
||
if inviter is None or inviter.status != "active":
|
||
# 邀请人注销 / 封禁:本次不发、不置标记,待其恢复后下次比价再试(保守,不吞奖励)
|
||
return CompareRewardResult("inviter_inactive", rel.inviter_user_id)
|
||
|
||
reward = rewards.INVITE_COMPARE_REWARD_CENTS
|
||
rel.compare_reward_granted = True
|
||
rel.compare_reward_cents = reward
|
||
rel.compare_rewarded_at = datetime.now(timezone.utc)
|
||
# 发邀请奖励金到邀请人的独立账户(与金币隔离),ref_id 指向被邀请人便于对账
|
||
crud_wallet.grant_invite_cash(
|
||
db, inviter.id, reward,
|
||
biz_type="invite_reward", ref_id=str(invitee_user_id), remark="好友比价奖励",
|
||
)
|
||
try:
|
||
db.commit()
|
||
except Exception:
|
||
db.rollback()
|
||
raise
|
||
return CompareRewardResult("granted", inviter.id, reward)
|
||
|
||
|
||
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
|
||
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。
|
||
|
||
"已邀请好友数"口径 = 完成一次比价(已触发邀请奖励金)的被邀请人数,即 compare_reward_granted=True。
|
||
不再数"仅绑定未比价"的关系——否则会出现"已邀请 1、可提现余额 0"(好友下载登录但没比价),
|
||
与产品口径"已邀请好友数 × 2元 = 累计提现 + 可提现余额"对不上。过滤后该恒等式天然成立
|
||
(每个计入的好友都恰好发过 1 笔 2 元,钱要么在余额要么已提现)。
|
||
|
||
金币口径(inviter_coin 之和)自 v3 起恒 0(邀请人收益改走邀请奖励金,见 get_reward_stats /
|
||
try_reward_on_compare);保留返回位兼容旧响应字段 coins_earned。
|
||
"""
|
||
count = db.execute(
|
||
select(func.count())
|
||
.select_from(InviteRelation)
|
||
.where(
|
||
InviteRelation.inviter_user_id == inviter_id,
|
||
InviteRelation.compare_reward_granted.is_(True),
|
||
)
|
||
).scalar_one()
|
||
coins = db.execute(
|
||
select(func.coalesce(func.sum(InviteRelation.inviter_coin), 0))
|
||
.where(InviteRelation.inviter_user_id == inviter_id)
|
||
).scalar_one()
|
||
return int(count), int(coins)
|
||
|
||
|
||
def get_reward_stats(db: Session, inviter_id: int) -> tuple[int, int]:
|
||
"""v2 邀请奖励金战绩:(可提现余额/分, 累计提现成功/分)。
|
||
|
||
余额 = coin_account.invite_cash_balance_cents;累计提现 = 该用户 source=invite_cash 且
|
||
status=success 的提现单金额之和(成功打款才算)。供 /invite/me 提现板块展示。
|
||
"""
|
||
from app.models.wallet import CoinAccount, WithdrawOrder
|
||
balance = db.execute(
|
||
select(CoinAccount.invite_cash_balance_cents).where(CoinAccount.user_id == inviter_id)
|
||
).scalar_one_or_none()
|
||
withdrawn = db.execute(
|
||
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
|
||
WithdrawOrder.user_id == inviter_id,
|
||
WithdrawOrder.source == "invite_cash",
|
||
WithdrawOrder.status == "success",
|
||
)
|
||
).scalar_one()
|
||
return int(balance or 0), int(withdrawn)
|
||
|
||
|
||
def _mask_phone(phone: str) -> str:
|
||
"""手机号脱敏:138****8888。前端拿不到完整号,展示被邀请人时在此兜底名字。
|
||
|
||
11 位标准手机号 → 前 3 + **** + 后 4;非标准(异常/截断)→ 只露后 4 位;太短 → "新用户"。
|
||
"""
|
||
p = (phone or "").strip()
|
||
if len(p) == 11:
|
||
return f"{p[:3]}****{p[-4:]}"
|
||
if len(p) >= 4:
|
||
return f"****{p[-4:]}"
|
||
return "新用户"
|
||
|
||
|
||
def get_invitees(
|
||
db: Session, inviter_id: int, *, limit: int = 20, offset: int = 0
|
||
) -> tuple[list[dict], int, bool]:
|
||
"""查某邀请人的被邀请人列表(按邀请时间倒序, 分页),返回 (items, total, has_more)。
|
||
|
||
每条 item = {display_name, avatar_url, coins, invited_at}。降级兜底:
|
||
名字 = 昵称 → 微信昵称 → 脱敏手机号(很多被邀请人是刚注册新用户、没设资料);
|
||
头像 = 用户头像 → 微信头像 → None(前端画默认色块)。
|
||
邀请关系表 join 用户表;total 单独 count(分页时算 has_more)。
|
||
"""
|
||
total = db.execute(
|
||
select(func.count())
|
||
.select_from(InviteRelation)
|
||
.where(InviteRelation.inviter_user_id == inviter_id)
|
||
).scalar_one()
|
||
|
||
rows = db.execute(
|
||
select(InviteRelation, User)
|
||
.join(User, User.id == InviteRelation.invitee_user_id)
|
||
.where(InviteRelation.inviter_user_id == inviter_id)
|
||
.order_by(InviteRelation.created_at.desc())
|
||
.limit(limit)
|
||
.offset(offset)
|
||
).all()
|
||
|
||
items: list[dict] = []
|
||
for rel, u in rows:
|
||
items.append({
|
||
"display_name": u.nickname or u.wechat_nickname or _mask_phone(u.phone),
|
||
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
|
||
"coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金)
|
||
"invited_at": rel.created_at,
|
||
# 是否已完成过一次比价(= 已发过邀请奖励金)。客户端据此:好友列表分"去提醒/邀请成功"、
|
||
# 在途列表只取未比价(is_compared=False)、算在途好友数与在途收益(未比价数×2元)。
|
||
"is_compared": bool(rel.compare_reward_granted),
|
||
})
|
||
has_more = offset + len(rows) < int(total)
|
||
return items, int(total), has_more
|
||
|
||
|
||
# ===== 指纹归因(剪贴板兜底,见 [[invite-three-tasks]] 任务 3)=====
|
||
|
||
def record_fingerprint(
|
||
db: Session,
|
||
*,
|
||
inviter_user_id: int,
|
||
ip: str,
|
||
screen: str,
|
||
device_model: str,
|
||
user_agent: str,
|
||
) -> InviteFingerprint:
|
||
"""落地页 dl.html 访问时调用,写一行指纹记录。
|
||
|
||
后续被邀请人登录、剪贴板没拿到邀请码时,/bind 会按 (ip, screen, device_model) 反查本表
|
||
7 天内最近一条匹配 → 拿出 inviter_user_id 撞库。
|
||
|
||
本函数会 commit;调用方(/landing-track endpoint)在此之前无其它写操作。
|
||
"""
|
||
fp = InviteFingerprint(
|
||
inviter_user_id=inviter_user_id,
|
||
ip=ip[:64],
|
||
screen=screen[:32],
|
||
device_model=device_model[:64],
|
||
user_agent=user_agent[:2000] if user_agent else "", # 截一下防异常长 UA
|
||
)
|
||
db.add(fp)
|
||
db.commit()
|
||
db.refresh(fp)
|
||
return fp
|
||
|
||
|
||
def resolve_inviter_by_fingerprint(
|
||
db: Session,
|
||
*,
|
||
ip: str,
|
||
screen: str,
|
||
device_model: str,
|
||
window_days: int,
|
||
) -> User | None:
|
||
"""根据指纹反查邀请人(time window 内最近一条匹配)。
|
||
|
||
匹配规则:(ip, device_model) 相等 + created_at > now - window_days。
|
||
|
||
screen 字段**只入库不反查**:浏览器算物理像素走 `CSS × devicePixelRatio` 路径、
|
||
Android 走 `DisplayMetrics.widthPixels` 真实硬件值,两端浮点 round 必然 ±1 像素漂移
|
||
(DPR 不严格是整数)→ 严格匹配注定撞不上。同 device_model 必同 screen(同型号同硬件)
|
||
→ screen 是冗余字段,删它不损精度。撞错只有"同 IP 下同型号手机同时被邀请"才会
|
||
发生,中国家庭/公司 WiFi 场景概率极低。
|
||
"""
|
||
if not ip:
|
||
return None
|
||
from datetime import datetime, timedelta, timezone
|
||
cutoff = datetime.now(timezone.utc) - timedelta(days=window_days)
|
||
fp = db.execute(
|
||
select(InviteFingerprint)
|
||
.where(
|
||
InviteFingerprint.ip == ip,
|
||
InviteFingerprint.device_model == device_model,
|
||
InviteFingerprint.created_at > cutoff,
|
||
)
|
||
.order_by(InviteFingerprint.created_at.desc())
|
||
.limit(1)
|
||
).scalar_one_or_none()
|
||
if fp is None:
|
||
return None
|
||
return db.get(User, fp.inviter_user_id)
|