"""好友邀请 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)