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)。
553 lines
23 KiB
Python
553 lines
23 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
|
||
|
||
|
||
# ===== 开发用:塞虚拟好友(仅非生产,方便真机测在途/好友列表 UI)=====
|
||
|
||
def seed_fake_invitees(db: Session, inviter_id: int, *, pending: int = 3, compared: int = 2) -> int:
|
||
"""给 inviter 造 pending 个"未比价"+ compared 个"已比价"的虚拟被邀请好友(仅测试用)。
|
||
|
||
造真实 User(随机手机号/昵称/头像)+ InviteRelation;已比价的顺带发邀请奖励金(走真实入账,
|
||
这样"可提现余额/累计"也有数)。仅非生产环境开放(endpoint 层用 is_prod 挡)。返回新建好友数。
|
||
"""
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from app.repositories import user as user_repo
|
||
|
||
# 造点像样的中文昵称 + 打包进 APK 的真人头像(asset://,客户端转 file:///android_asset/ 本地加载,
|
||
# 离线必显、不依赖外网,避免 dicebear 这类国外图在国内加载失败成空白)。头像图见客户端
|
||
# app/src/main/assets/avatars/fake/p0..p7.jpg,按序号轮询分配。
|
||
names = ["小满同学", "一罐可乐Colour", "草原上感受宁静者", "晴天小豆", "momo", "隔壁老王",
|
||
"爱吃辣的猫", "月半子", "阿柴", "橘子汽水"]
|
||
FAKE_AVATAR_COUNT = 8 # 与客户端 assets/avatars/fake/ 图数一致
|
||
created = 0
|
||
total = pending + compared
|
||
now = datetime.now(timezone.utc)
|
||
for i in range(total):
|
||
is_compared = i >= pending
|
||
# 唯一手机号:99 开头 + 9 位随机(避开真实号段,不与真人撞)
|
||
phone = "99" + "".join(secrets.choice("0123456789") for _ in range(9))
|
||
nickname = names[i % len(names)] + secrets.choice(["", "", "_", str(secrets.randbelow(99))])
|
||
u = User(
|
||
phone=phone,
|
||
username=user_repo._gen_unique_username(db),
|
||
nickname=nickname,
|
||
register_channel="fake_seed",
|
||
avatar_url=f"asset://avatars/fake/p{i % FAKE_AVATAR_COUNT}.jpg", # 打包头像,轮询分配
|
||
status="active",
|
||
)
|
||
db.add(u)
|
||
db.flush() # 拿 u.id
|
||
rel = InviteRelation(
|
||
inviter_user_id=inviter_id,
|
||
invitee_user_id=u.id,
|
||
channel="fake_seed",
|
||
status="effective",
|
||
inviter_coin=0,
|
||
invitee_coin=0,
|
||
# 邀请时间错开,便于看"时间近→远"排序
|
||
created_at=(now - timedelta(hours=i * 6)).replace(tzinfo=None),
|
||
)
|
||
if is_compared:
|
||
# 已比价 → 走真实"好友比价发奖"链路:标记 + 真发 ¥2 进邀请奖励金账户(invite_cash),
|
||
# 让"可提现余额/累计/人数/流水/提现记录"全链路真实变化,可验证功能正确。
|
||
# (钱只进 invite_cash,不碰金币现金账户;清理 DELETE /seed-fake 会原路退回。)
|
||
rel.compare_reward_granted = True
|
||
rel.compare_reward_cents = rewards.INVITE_COMPARE_REWARD_CENTS
|
||
rel.compare_rewarded_at = now
|
||
db.add(rel)
|
||
db.flush()
|
||
if is_compared:
|
||
crud_wallet.grant_invite_cash(
|
||
db, inviter_id, rewards.INVITE_COMPARE_REWARD_CENTS,
|
||
biz_type="invite_reward", ref_id=str(u.id), remark="虚拟好友比价奖励(测试)",
|
||
)
|
||
created += 1
|
||
db.commit()
|
||
return created
|
||
|
||
|
||
def clear_fake_invitees(db: Session, inviter_id: int) -> int:
|
||
"""删掉该 inviter 名下所有 channel=fake_seed 的虚拟好友关系 + 对应虚拟 User(测试收尾)。
|
||
|
||
⚠️已发过邀请奖励金的假关系(compare_reward_granted),删除时必须把当初 grant 的钱**原路退回**
|
||
(负数 grant_invite_cash),否则假好友清掉了、发进钱包的奖励金却残留 → invite_cash 余额脏账。
|
||
"""
|
||
rels = db.execute(
|
||
select(InviteRelation).where(
|
||
InviteRelation.inviter_user_id == inviter_id,
|
||
InviteRelation.channel == "fake_seed",
|
||
)
|
||
).scalars().all()
|
||
n = 0
|
||
for rel in rels:
|
||
# 先退钱:该关系当初发过奖励金 → 反向扣回同额,钱包不留残
|
||
if rel.compare_reward_granted and rel.compare_reward_cents:
|
||
crud_wallet.grant_invite_cash(
|
||
db, inviter_id, -rel.compare_reward_cents,
|
||
biz_type="invite_reward", ref_id=str(rel.invitee_user_id),
|
||
remark="虚拟好友清理·奖励金退回(测试)",
|
||
)
|
||
u = db.get(User, rel.invitee_user_id)
|
||
db.delete(rel)
|
||
if u is not None and u.register_channel == "fake_seed":
|
||
db.delete(u)
|
||
n += 1
|
||
db.commit()
|
||
return n
|
||
|
||
|
||
def dev_reset_test_account(db: Session, phone: str) -> dict:
|
||
"""[DEV] 把固定测试号打回"从没被邀请/没下过单"的出厂态,以便反复当新用户测发奖。
|
||
|
||
清理范围(全清,冰 2026-07-02 拍板):该 user 的
|
||
- invite_relation(作为被邀请人的那条 = 解 already_bound;作为邀请人的那些)
|
||
- invite_cash_transaction / coin_account 三个余额归零(invite_cash / coin / cash)
|
||
- savings_record(= 解 order/report 的 duplicated 幂等)
|
||
- invite_fingerprint(作为邀请人落的指纹)
|
||
- coin_transaction / cash_transaction / withdraw_order(彻底出厂)
|
||
并把 user.created_at 刷成 now(UTC)—— 骗过 bind 的"新用户 72h 窗口"闸(_is_new_user),
|
||
否则该号第一次跑之后 created_at 固定、过 72h 即使清了关系也会 not_eligible。
|
||
|
||
仅供 dev-reset 端点调用(端点已 is_prod→404 + 只认配置的测试号)。返回各表清理条数。
|
||
user 不存在(还没登录过)→ 返回全 0,不报错(下一步登录会新建,天然新号)。
|
||
"""
|
||
from datetime import datetime, timezone
|
||
|
||
from app.models.invite_fingerprint import InviteFingerprint
|
||
from app.models.savings import SavingsRecord
|
||
from app.models.wallet import (
|
||
CashTransaction,
|
||
CoinAccount,
|
||
CoinTransaction,
|
||
InviteCashTransaction,
|
||
WithdrawOrder,
|
||
)
|
||
|
||
user = db.execute(select(User).where(User.phone == phone)).scalar_one_or_none()
|
||
if user is None:
|
||
return {"user_found": False}
|
||
uid = user.id
|
||
|
||
def _del(model, whereclause) -> int:
|
||
rows = db.execute(select(model).where(whereclause)).scalars().all()
|
||
for r in rows:
|
||
db.delete(r)
|
||
return len(rows)
|
||
|
||
cleared = {
|
||
"user_found": True,
|
||
"user_id": uid,
|
||
"invite_relation_as_invitee": _del(
|
||
InviteRelation, InviteRelation.invitee_user_id == uid
|
||
),
|
||
"invite_relation_as_inviter": _del(
|
||
InviteRelation, InviteRelation.inviter_user_id == uid
|
||
),
|
||
"invite_cash_transaction": _del(
|
||
InviteCashTransaction, InviteCashTransaction.user_id == uid
|
||
),
|
||
"coin_transaction": _del(CoinTransaction, CoinTransaction.user_id == uid),
|
||
"cash_transaction": _del(CashTransaction, CashTransaction.user_id == uid),
|
||
"withdraw_order": _del(WithdrawOrder, WithdrawOrder.user_id == uid),
|
||
"invite_fingerprint": _del(
|
||
InviteFingerprint, InviteFingerprint.inviter_user_id == uid
|
||
),
|
||
"savings_record": _del(SavingsRecord, SavingsRecord.user_id == uid),
|
||
}
|
||
|
||
# 余额账户归零(保留行,只清三个余额;没有账户行则跳过)
|
||
acc = db.get(CoinAccount, uid)
|
||
if acc is not None:
|
||
acc.invite_cash_balance_cents = 0
|
||
acc.coin_balance = 0
|
||
acc.cash_balance_cents = 0
|
||
cleared["coin_account_zeroed"] = True
|
||
else:
|
||
cleared["coin_account_zeroed"] = False
|
||
|
||
# 刷 created_at 骗过 72h 新用户闸(存 naive UTC,与登录新建 user 的写法一致)
|
||
user.created_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||
cleared["created_at_refreshed"] = True
|
||
|
||
db.commit()
|
||
return cleared
|
||
|
||
|
||
# ===== 指纹归因(剪贴板兜底,见 [[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)
|