6.5 KiB
6.5 KiB
invite_relation — 好友邀请绑定关系(注册即生效)
模型
app/models/invite.py(InviteRelation) · 仓库app/repositories/invite.py· 接口app/api/v1/invite.py:POST /api/v1/invite/bind(写)、GET /api/v1/invite/me(战绩)、GET /api/v1/invite/invitees(列表) · ← 索引 · 总览
一行 = 一次成功的邀请绑定。被邀请人(B)用邀请人(A)的邀请码完成绑定后写入,绑定关系注册即生效,但绑定本身不发奖(#113 起):发奖后置到 B 完成比价并实际下单(POST /order/report → try_reward_on_compare),给 A 发邀请奖励金(入独立账本 invite_cash_transaction,非金币),本表 compare_reward_granted 做幂等闸。数据来自 bind(),触发它的归因来源有三种(channel)。这是邀请功能的结果表 / 账本;邀请码本身存在 user.invite_code,不在这张表。
用在哪 / 增删改查
- C(插入):
POST /api/v1/invite/bind(bind_invite)→invite_repo.bind()。过四道防线后建一行status='effective'。#113 起绑定只建关系、不发奖(inviter_coin/invitee_coin新行恒 0)。- 幂等键 =
invitee_user_id唯一:一个 B 只能被绑一次,重复请求返回already_bound(仿ad_reward_record.trans_id思路)。并发下两请求同时插同一 invitee → 后者撞唯一约束IntegrityError,bind()rollback 后改判already_bound兜底。 - 四道防线(
bind()内):① invitee 已绑 →already_bound;② 无效码 / 邀请人非active→invalid_code;③ 自邀(inviter==invitee)→self_invite;④ 新人闸_is_new_user(B 的created_at在rewards.INVITE_NEW_USER_WINDOW_HOURS=72h 内)→ 否则not_eligible。
- 幂等键 =
- U(发奖,#113):B 比价后下单
POST /order/report→try_reward_on_compare(invitee_user_id):compare_reward_granted=False才发 → 置True+ 记compare_reward_cents/compare_rewarded_at,同事务wallet.grant_invite_cash给 A 入邀请奖励金 → "置闸 + 入账"原子,一个 B 只发一次。 - D:无。
- R:
GET /api/v1/invite/me(my_invite)→get_stats(inviter_id):count(*)得已邀人数、sum(inviter_coin)得累计金币(历史口径);另调get_reward_stats出邀请奖励金余额/累计已提(读coin_account.invite_cash_balance_cents+withdraw_order(source=invite_cash))。GET /api/v1/invite/invitees(my_invitees)→get_invitees(inviter_id, limit, offset):joinuser出被邀请人列表(倒序分页),名字降级兜底nickname → wechat_nickname → 脱敏手机号,coins取inviter_coin。
字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
id |
Integer | PK, autoincrement | |
inviter_user_id |
Integer | FK→user.id, index, NOT NULL | 邀请人(A) |
invitee_user_id |
Integer | FK→user.id, UNIQUE, index, NOT NULL | 被邀请人(B)。唯一 = 幂等键,一个 B 只能被归因一次 |
channel |
String(16) | NOT NULL, default clipboard |
归因来源:clipboard(剪贴板自动)/ manual(手动填码)/ fingerprint(指纹反查兜底)。入库前 [:16] 截断 |
status |
String(16) | NOT NULL, default effective |
当前只有 effective(绑定关系注册即生效)。「发奖后置」没有走 status,而是用下面的 compare_reward_granted 闸表达 |
inviter_coin |
Integer | NOT NULL, default 0 | 历史留痕(#113 前"绑定即发金币"时代给 A 发的金币,当时=10000);#113 起新绑定恒 0 |
invitee_coin |
Integer | NOT NULL, default 0 | 同上,给 B 发的金币历史留痕;新绑定恒 0 |
compare_reward_granted |
Boolean | NOT NULL, default false | 发奖幂等闸(#113):B 首次「比价并下单」后置 true,一个 B 只给 A 发一次邀请奖励金 |
compare_reward_cents |
Integer | NOT NULL, default 0 | 实发的邀请奖励金(分,留痕;改常量不影响历史行) |
compare_rewarded_at |
DateTime(tz) | nullable | 发奖时间 |
created_at |
DateTime(tz) | server_default now(), index | 绑定时间(= 列表倒序键) |
关系 / Join Key
inviter_user_id→user.id(硬 FK,多对一):一个 A 可邀多个 B。invitee_user_id→user.id(硬 FK,一对一,唯一约束):一个 B 至多一行。- 流水关联:#113 前的绑定发金币落
coin_transaction(biz_type='invite_inviter'/'invite_invitee',ref_id互指对方)——现为历史类型,新绑定不再产生;#113 起发奖落invite_cash_transaction(biz_type='invite_reward',ref_id=被邀请人 id)。 get_invitees用InviteRelation JOIN user ON user.id = invitee_user_id取被邀请人资料;total单独count算has_more。
索引与约束
- PK
id。 ix_invite_relation_inviter_user_id(inviter_user_id):get_stats/get_invitees按邀请人查。ix_invite_relation_invitee_user_id(invitee_user_id,UNIQUE):幂等键 + 加速_relation_of_invitee反查。ix_invite_relation_created_at(created_at):列表倒序。
注意
- 防重复发奖三道(
repositories/invite.pydocstring):①invitee_user_id唯一(应用层_relation_of_invitee先查 + DB 唯一约束并发兜底);② 自邀屏蔽;③ 手机号天然唯一(每个 B = 一个真实手机号账号)= 限制刷量规模。 status当前恒为effective(绑定关系维度);「发奖后置」由compare_reward_granted闸表达,没有引入pending状态。- 金币留痕字段已冻结:
inviter_coin/invitee_coin只反映 #113 前旧口径的历史发放额,便于对账;新奖励额看compare_reward_cents。 channel三种取值:clipboard(deferred-deeplink 主路径,落地页写剪贴板、首启读回)/manual(用户在邀请页手输)/fingerprint(剪贴板被覆盖时走指纹兜底,见 invite_fingerprint);三者都汇入同一个bind(),只channel不同。- 风控演进:#24 时代的缺口是"注册即发 1 万可提现金币、接码批量刷"——#113 把发奖后置到真实比价+下单(且发的是邀请奖励金独立账本),批量注册空号不再直接得利,刷奖成本显著抬高;inviter 总数上限等进一步风控仍待补。
- alembic 多 head:本表迁移
invite_code_and_relation(down_revision=11a1d08c6f55)与coupon_state_tables是同一父的兄弟迁移,合 main 前需建 merge 迁移,否则 prodalembic upgrade head撞Multiple head revisions(#24 设计文档已警示)。