diff --git a/app/api/v1/invite.py b/app/api/v1/invite.py index dab5183..5bbc99e 100644 --- a/app/api/v1/invite.py +++ b/app/api/v1/invite.py @@ -38,7 +38,7 @@ logger = logging.getLogger("shagua.invite") router = APIRouter(prefix="/api/v1/invite", tags=["invite"]) _BIND_MESSAGES = { - "success": "邀请绑定成功,金币已到账", + "success": "邀请绑定成功", "already_bound": "你已绑定过邀请人", "invalid_code": "邀请码无效", "self_invite": "不能填写自己的邀请码", @@ -161,7 +161,7 @@ def landing_track( return LandingTrackOut(status="ok") -@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)") +@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,绑定不发奖)") def bind_invite( req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request ) -> BindInviteOut: diff --git a/app/core/rewards.py b/app/core/rewards.py index 8ca5e5e..d931db4 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -113,10 +113,9 @@ PRICE_REPORT_REWARD_COINS: int = 1000 FEEDBACK_REWARD_MAX_COINS: int = 10000 -# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)===== -# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。 -INVITE_INVITER_COINS: int = 10000 -INVITE_INVITEE_COINS: int = 10000 +# ===== 邀请好友(绑定即生效,绑定只建归因关系、双方都不发钱)===== +# v3(冰 2026-06-26):废除 v1 的"绑定双方各发金币"——被邀请人无奖励、邀请人改比价后发现金 +# (见下方 INVITE_COMPARE_REWARD_CENTS)。原 INVITE_INVITER_COINS / INVITE_INVITEE_COINS 已删。 # "新用户闸":被邀请人必须在注册后此窗口内绑定才发奖(挡存量老用户互相填码薅羊毛)。 # 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。 INVITE_NEW_USER_WINDOW_HOURS: int = 72 diff --git a/app/repositories/invite.py b/app/repositories/invite.py index 9c26fe0..7fd7dbc 100644 --- a/app/repositories/invite.py +++ b/app/repositories/invite.py @@ -1,13 +1,14 @@ -"""好友邀请 CRUD(注册即生效,邀请人 + 被邀请人各发金币)。 +"""好友邀请 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. 现成的手机号唯一(每个被邀请人 = 一个真实手机号账号)= 天然限制刷量规模。 - -发金币复用 wallet.grant_coins(grant 只 flush 不 commit),与建关系记录在**同一事务** -commit,保证"建关系 + 双方加金币"原子。奖励额 = rewards.INVITE_INVITER_COINS / -INVITE_INVITEE_COINS。 """ from __future__ import annotations @@ -114,7 +115,7 @@ def _is_new_user(user: User) -> bool: class BindResult: status: str # success / already_bound / invalid_code / self_invite / not_eligible relation: InviteRelation | None = None - invitee_coin: int = 0 # 本次给被邀请人发的金币(success 时 >0) + invitee_coin: int = 0 # v3 起恒 0(绑定不再发金币);保留字段兼容响应 def bind( @@ -122,8 +123,9 @@ def bind( ) -> BindResult: """把 invitee 绑定到 invite_code 对应的邀请人,注册即生效。 - v2 发奖(冰 2026-06-23 拍板):被邀请人绑定即得新人金币(拉新即时激励);邀请人不在此发, - 改"好友比价才发 2 元邀请奖励金"(见 try_reward_on_compare,防刷)。幂等:已绑过 → already_bound。 + v3 发奖(冰 2026-06-26 拍板):绑定双方都不发钱——被邀请人无奖励,邀请人改"好友成功比价一次 + 才发 2 元邀请奖励金"(见 try_reward_on_compare,防刷)。绑定只建归因关系 + 跑防刷闸。 + 幂等:已绑过 → already_bound。 """ # 幂等:已绑过直接返回(不重复发奖) existing = _relation_of_invitee(db, invitee.id) @@ -139,24 +141,18 @@ def bind( if not _is_new_user(invitee): return BindResult("not_eligible") - # v2(冰 2026-06-23 拍板):邀请人不再在绑定时发金币,改"好友比价才发 2 元邀请奖励金" - # (见 try_reward_on_compare,防刷);被邀请人保留绑定即得新人金币(拉新即时激励)。 - invitee_coin = rewards.INVITE_INVITEE_COINS - + # 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, # 邀请人改比价发现金,绑定不发金币 - invitee_coin=invitee_coin, + inviter_coin=0, # v1 金币线已停用,列保留恒 0(待清) + invitee_coin=0, # v3:被邀请人绑定不再发金币 ) db.add(rel) - # 只给被邀请人发新人金币(同事务,与建关系一起 commit);邀请人那份移到比价发奖。 - crud_wallet.grant_coins( - db, invitee.id, invitee_coin, - biz_type="invite_invitee", ref_id=str(inviter.id), remark="新人受邀奖励", - ) + # 绑定不发任何金币(邀请人改比价发现金、被邀请人无奖励),仅建归因关系。 try: db.commit() except IntegrityError: @@ -167,12 +163,12 @@ def bind( return BindResult("already_bound", existing) raise except Exception: - # 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证"建关系 + 双方发币" - # 原子(要么全成要么全无),不依赖 get_db 关闭时的隐式回滚,语义更硬。 + # 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证建关系原子, + # 不依赖 get_db 关闭时的隐式回滚,语义更硬。 db.rollback() raise db.refresh(rel) - return BindResult("success", rel, invitee_coin) + return BindResult("success", rel) @dataclass @@ -218,7 +214,11 @@ def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardRes def get_stats(db: Session, inviter_id: int) -> tuple[int, int]: - """返回 (已成功邀请人数, 累计从邀请获得的金币)。""" + """返回 (已成功邀请人数, 累计从邀请获得的金币)。 + + 金币口径(inviter_coin 之和)自 v3 起恒 0(邀请人收益改走邀请奖励金,见 get_reward_stats / + try_reward_on_compare);保留返回位兼容旧响应字段 coins_earned。 + """ count = db.execute( select(func.count()) .select_from(InviteRelation) @@ -294,7 +294,7 @@ def get_invitees( 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, + "coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金) "invited_at": rel.created_at, }) has_more = offset + len(rows) < int(total) diff --git a/tests/test_invite.py b/tests/test_invite.py index db67220..994ce8b 100644 --- a/tests/test_invite.py +++ b/tests/test_invite.py @@ -1,4 +1,4 @@ -"""好友邀请测试:邀请码、绑定、双方发金币、幂等、自邀/无效码屏蔽、指纹兜底归因。 +"""好友邀请测试:邀请码、绑定(双方不发钱)、幂等、自邀/无效码屏蔽、指纹兜底归因。 用 sms mock 登录拿 token(同 test_welfare),再跑邀请闭环。 """ @@ -10,7 +10,6 @@ from sqlalchemy import select from app.core.rewards import ( INVITE_FP_WINDOW_DAYS, - INVITE_INVITEE_COINS, INVITE_NEW_USER_WINDOW_HOURS, ) from app.db.session import SessionLocal @@ -55,8 +54,8 @@ def test_invite_me_returns_stable_code(client) -> None: assert _my_code(client, token) == body["invite_code"] -def test_bind_flow_invitee_gets_coins(client) -> None: - """v2:B 用 A 的码绑定 → 只有被邀请人 B 得新人金币;邀请人 A 绑定时不发(改比价发现金)。""" +def test_bind_flow_no_coins_either_side(client) -> None: + """v3:B 用 A 的码绑定 → 双方都不发钱(被邀请人无奖励、邀请人改比价后发现金)。""" a = _login(client, "13800002002") b = _login(client, "13800002003") a_code = _my_code(client, a) @@ -68,13 +67,13 @@ def test_bind_flow_invitee_gets_coins(client) -> None: assert r.status_code == 200, r.text res = r.json() assert res["status"] == "success" - assert res["coins_awarded"] == INVITE_INVITEE_COINS + assert res["coins_awarded"] == 0 - # 被邀请人金币到账;邀请人绑定时不发金币(v2 改"好友比价发 2 元邀请奖励金") - assert _coin_balance(client, b) == INVITE_INVITEE_COINS + # 绑定后双方金币都不增(邀请人收益改走"好友比价发 2 元邀请奖励金") + assert _coin_balance(client, b) == 0 assert _coin_balance(client, a) == 0 - # A 的战绩:已邀 1 人;金币口径收益为 0(邀请人收益改走邀请奖励金,见 try_reward_on_compare) + # A 的战绩:已邀 1 人;金币口径收益恒 0(邀请人收益走邀请奖励金,见 try_reward_on_compare) r = client.get("/api/v1/invite/me", headers=_auth(a)) assert r.json()["invited_count"] == 1 assert r.json()["coins_earned"] == 0 @@ -216,9 +215,9 @@ def test_bind_by_fingerprint_success(client) -> None: ) assert r2.status_code == 200, r2.text assert r2.json()["status"] == "success" - # v2:只被邀请人发新人金币;邀请人绑定不发(改比价发现金) + # v3:绑定双方都不发钱(被邀请人无奖励、邀请人改比价发现金) assert _coin_balance(client, a) == 0 - assert _coin_balance(client, b) == INVITE_INVITEE_COINS + assert _coin_balance(client, b) == 0 def test_bind_by_fingerprint_not_found(client) -> None: diff --git a/tests/test_invite_compare_reward.py b/tests/test_invite_compare_reward.py index 2fb43b9..3761f3a 100644 --- a/tests/test_invite_compare_reward.py +++ b/tests/test_invite_compare_reward.py @@ -1,5 +1,5 @@ -"""邀请 v2 比价发奖测试:好友完成成功比价 → 邀请人得邀请奖励金(独立账户),幂等只发一次, -账户隔离不串金币/现金。被邀请人保留"绑定即得新人金币"由 test_invite.py 覆盖。 +"""邀请 v3 比价发奖测试:好友完成成功比价 → 邀请人得邀请奖励金(独立账户),幂等只发一次, +账户隔离不串金币/现金。被邀请人绑定不得任何奖励由 test_invite.py 覆盖。 """ from __future__ import annotations diff --git a/tests/test_welfare.py b/tests/test_welfare.py index d011907..07af7bc 100644 --- a/tests/test_welfare.py +++ b/tests/test_welfare.py @@ -46,7 +46,12 @@ def test_account_auto_created_empty(client) -> None: r = client.get("/api/v1/wallet/account", headers=_auth(token)) assert r.status_code == 200, r.text body = r.json() - assert body == {"coin_balance": 0, "cash_balance_cents": 0, "total_coin_earned": 0} + assert body == { + "coin_balance": 0, + "cash_balance_cents": 0, + "invite_cash_balance_cents": 0, # v2 账户隔离新增(邀请奖励金,与现金隔离) + "total_coin_earned": 0, + } def test_signin_flow(client) -> None: