feat(invite): 好友邀请后端(注册即生效,邀请人+被邀请人各发1万金币)

- user.invite_code 列 + invite_relation 表(invitee_user_id 唯一 = 幂等防重复发奖)
- GET /api/v1/invite/me(我的码+分享链接+战绩)、POST /api/v1/invite/bind
- 复用 wallet.grant_coins 同事务发币;自邀屏蔽 / 无效码 / 新用户闸(注册72h内才发)
- alembic 迁移(11a1d08c6f55 -> invite_code_and_relation)+ 8 个测试(钱路/幂等/并发兜底全覆盖)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 03:21:03 +08:00
parent ece41086cd
commit bc10432f52
11 changed files with 538 additions and 0 deletions
@@ -0,0 +1,54 @@
"""user.invite_code + invite_relation table
Revision ID: invite_code_and_relation
Revises: 11a1d08c6f55
Create Date: 2026-06-08 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'invite_code_and_relation'
down_revision: Union[str, Sequence[str], None] = '11a1d08c6f55'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# user 加邀请码列(唯一索引;nullable 允许多个 NULL=未生成的用户)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('invite_code', sa.String(length=16), nullable=True))
batch_op.create_index('ix_user_invite_code', ['invite_code'], unique=True)
# 邀请关系表
op.create_table(
'invite_relation',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('inviter_user_id', sa.Integer(), nullable=False),
sa.Column('invitee_user_id', sa.Integer(), nullable=False),
sa.Column('channel', sa.String(length=16), nullable=False, server_default='clipboard'),
sa.Column('status', sa.String(length=16), nullable=False, server_default='effective'),
sa.Column('inviter_coin', sa.Integer(), nullable=False, server_default='0'),
sa.Column('invitee_coin', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['inviter_user_id'], ['user.id']),
sa.ForeignKeyConstraint(['invitee_user_id'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_invite_relation_inviter_user_id', 'invite_relation', ['inviter_user_id'])
op.create_index('ix_invite_relation_invitee_user_id', 'invite_relation', ['invitee_user_id'], unique=True)
op.create_index('ix_invite_relation_created_at', 'invite_relation', ['created_at'])
def downgrade() -> None:
op.drop_index('ix_invite_relation_created_at', table_name='invite_relation')
op.drop_index('ix_invite_relation_invitee_user_id', table_name='invite_relation')
op.drop_index('ix_invite_relation_inviter_user_id', table_name='invite_relation')
op.drop_table('invite_relation')
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_index('ix_user_invite_code')
batch_op.drop_column('invite_code')
+64
View File
@@ -0,0 +1,64 @@
"""好友邀请 endpoint。
路由前缀 /api/v1/invite,需 Bearer 鉴权(用户级数据):
GET /me 我的邀请码 + 分享链接 + 已邀人数/已得金币(邀请页展示)
POST /bind 把当前登录用户(被邀请人)绑定到某邀请码,注册即生效,双方各发金币
客户端两种调用 /bind:
- 自动:首启读剪贴板拿到邀请码 → 登录后调本接口(channel=clipboard)
- 手动:用户在邀请页输入好友邀请码(channel=manual)
绑定的真正逻辑(邀请码生成/反查、幂等、自邀屏蔽、发金币)在 repositories/invite.py。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter
from app.api.deps import CurrentUser, DbSession
from app.core.config import settings
from app.repositories import invite as invite_repo
from app.schemas.invite import BindInviteIn, BindInviteOut, InviteInfoOut
logger = logging.getLogger("shagua.invite")
router = APIRouter(prefix="/api/v1/invite", tags=["invite"])
_BIND_MESSAGES = {
"success": "邀请绑定成功,金币已到账",
"already_bound": "你已绑定过邀请人",
"invalid_code": "邀请码无效",
"self_invite": "不能填写自己的邀请码",
"not_eligible": "邀请仅对新注册用户生效",
}
@router.get("/me", response_model=InviteInfoOut, summary="我的邀请码 + 分享链接 + 战绩")
def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
code = invite_repo.ensure_code(db, user)
invited, coins = invite_repo.get_stats(db, user.id)
sep = "&" if "?" in settings.INVITE_LANDING_URL else "?"
share_url = f"{settings.INVITE_LANDING_URL}{sep}ref={code}"
return InviteInfoOut(
invite_code=code,
share_url=share_url,
invited_count=invited,
coins_earned=coins,
)
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
def bind_invite(req: BindInviteIn, user: CurrentUser, db: DbSession) -> BindInviteOut:
result = invite_repo.bind(
db, invitee=user, invite_code=req.invite_code, channel=req.channel,
)
logger.info(
"invite bind invitee=%d code=%s channel=%s -> %s",
user.id, req.invite_code, req.channel, result.status,
)
return BindInviteOut(
status=result.status,
coins_awarded=result.invitee_coin,
message=_BIND_MESSAGES.get(result.status, result.status),
)
+5
View File
@@ -173,6 +173,11 @@ class Settings(BaseSettings):
MEDIA_URL_PREFIX: str = "/media"
AVATAR_MAX_BYTES: int = 5 * 1024 * 1024 # 头像最大 5MB
# ===== 邀请好友 =====
# 分享落地页(二维码 / 分享链接指向这里;扫码 → 落地页 → 引导浏览器下载 APK)。
# MVP 落地页就放 app-server 的 /media 静态目录下(dl.html)。
INVITE_LANDING_URL: str = "https://app-api.shaguabijia.com/media/dl.html"
# ===== CORS =====
CORS_ALLOW_ORIGINS: str = ""
+9
View File
@@ -77,6 +77,15 @@ def record_milestone_reward(milestone: int) -> int:
return RECORD_MILESTONES[milestone - 1]
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
INVITE_INVITER_COINS: int = 10000
INVITE_INVITEE_COINS: int = 10000
# "新用户闸":被邀请人必须在注册后此窗口内绑定才发奖(挡存量老用户互相填码薅羊毛)。
# 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。
INVITE_NEW_USER_WINDOW_HOURS: int = 72
# ===== 看激励视频 / 信息流广告发金币 =====
# 金币数值体系约定:eCPM 单位按"元/千次展示"处理,单次收入 = eCPM / 1000 元。
AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
+2
View File
@@ -22,6 +22,7 @@ from app.api.v1.compare_record import router as compare_record_router
from app.api.v1.coupon import router as coupon_router
from app.api.internal.price import router as internal_price_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.invite import router as invite_router
from app.api.v1.meituan import router as meituan_router
from app.api.v1.order import router as order_router
from app.api.v1.platform import router as platform_router
@@ -79,6 +80,7 @@ def health() -> dict[str, str]:
app.include_router(auth_router)
app.include_router(user_router)
app.include_router(feedback_router)
app.include_router(invite_router)
app.include_router(coupon_router)
app.include_router(compare_router)
app.include_router(compare_record_router)
+1
View File
@@ -8,6 +8,7 @@ from app.models.app_config import AppConfig # noqa: F401
from app.models.comparison import ComparisonRecord # noqa: F401
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
from app.models.ops_stat_config import OpsStatConfig # noqa: F401
+46
View File
@@ -0,0 +1,46 @@
"""好友邀请关系表(注册即生效)。
一行 = 一次成功的邀请绑定。`invitee_user_id` 唯一 = 一个被邀请人只能被归因一次
(天然幂等键,防重复发奖,仿 ad_reward.trans_id 思路)。`channel` 记归因来源
(clipboard 自动 / manual 手动填),便于后续分析"剪贴板自动捕获率"
邀请码本身是 `user.invite_code`(一人一稳定码,懒生成),不在这张表里。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class InviteRelation(Base):
__tablename__ = "invite_relation"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
inviter_user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 被邀请人唯一:一个人只能被邀一次(幂等键,防重复发奖)
invitee_user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), unique=True, index=True, nullable=False
)
# 归因来源:clipboard(剪贴板自动) / manual(手动填码)
channel: Mapped[str] = mapped_column(String(16), nullable=False, default="clipboard")
# 状态:effective(注册即生效)。预留:以后改"完成首单才生效"时用 pending/effective
status: Mapped[str] = mapped_column(String(16), nullable=False, default="effective")
# 本次给邀请人 / 被邀请人各发的金币(记账留痕,便于对账)
inviter_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
invitee_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<InviteRelation inviter={self.inviter_user_id} "
f"invitee={self.invitee_user_id} {self.status}>"
)
+6
View File
@@ -33,6 +33,12 @@ class User(Base):
nickname: Mapped[str | None] = mapped_column(String(64), nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 邀请码:每个用户一个稳定短码(懒生成,见 repositories/invite.ensure_code),
# 分享链接 / 二维码里带它。unique 允许多个 NULL(未生成的用户)。
invite_code: Mapped[str | None] = mapped_column(
String(16), unique=True, index=True, nullable=True
)
# 微信开放平台 openid(微信登录绑定后存,提现转账到零钱用)。同一 appid 下唯一;
# unique 索引保证一个微信只能绑一个账号(nullable,允许多个 NULL=未绑定)。
wechat_openid: Mapped[str | None] = mapped_column(
+166
View File
@@ -0,0 +1,166 @@
"""好友邀请 CRUD(注册即生效,邀请人 + 被邀请人各发金币)。
防重复发奖三道(仿 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
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.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))
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 # 本次给被邀请人发的金币(success 时 >0)
def bind(
db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard"
) -> BindResult:
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效 + 双方发金币。
幂等:invitee 已被绑过 → 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")
inviter_coin = rewards.INVITE_INVITER_COINS
invitee_coin = rewards.INVITE_INVITEE_COINS
rel = InviteRelation(
inviter_user_id=inviter.id,
invitee_user_id=invitee.id,
channel=(channel or "clipboard")[:16],
status="effective",
inviter_coin=inviter_coin,
invitee_coin=invitee_coin,
)
db.add(rel)
# 双方发金币(同事务,与建关系一起 commit)。ref_id 互指对方便于对账。
crud_wallet.grant_coins(
db, inviter.id, inviter_coin,
biz_type="invite_inviter", ref_id=str(invitee.id), remark="邀请好友奖励",
)
crud_wallet.grant_coins(
db, invitee.id, invitee_coin,
biz_type="invite_invitee", ref_id=str(inviter.id), remark="新人受邀奖励",
)
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, invitee_coin)
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。"""
count = db.execute(
select(func.count())
.select_from(InviteRelation)
.where(InviteRelation.inviter_user_id == inviter_id)
).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)
+25
View File
@@ -0,0 +1,25 @@
"""邀请相关 API 收发模型。"""
from __future__ import annotations
from pydantic import BaseModel, Field
class InviteInfoOut(BaseModel):
invite_code: str # 我的邀请码
share_url: str # 落地页链接(含 ?ref=),前端据此生成二维码 + 复制分享
invited_count: int # 已成功邀请人数
coins_earned: int # 累计从邀请获得的金币
class BindInviteIn(BaseModel):
invite_code: str = Field(..., min_length=4, max_length=16, description="邀请人的邀请码")
channel: str = Field(
"clipboard", max_length=16,
description="归因来源:clipboard(首启读剪贴板自动) / manual(用户手动填)",
)
class BindInviteOut(BaseModel):
status: str # success / already_bound / invalid_code / self_invite / not_eligible
coins_awarded: int # 本次给当前用户(被邀请人)发的金币
message: str # 给前端直接展示的文案
+160
View File
@@ -0,0 +1,160 @@
"""好友邀请测试:邀请码、绑定、双方发金币、幂等、自邀/无效码屏蔽。
用 sms mock 登录拿 token(同 test_welfare),再跑邀请闭环。
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from app.core.rewards import (
INVITE_INVITEE_COINS,
INVITE_INVITER_COINS,
INVITE_NEW_USER_WINDOW_HOURS,
)
from app.db.session import SessionLocal
from app.repositories.user import get_user_by_phone
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _coin_balance(client, token: str) -> int:
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.status_code == 200, r.text
return r.json()["coin_balance"]
def _my_code(client, token: str) -> str:
r = client.get("/api/v1/invite/me", headers=_auth(token))
assert r.status_code == 200, r.text
return r.json()["invite_code"]
def test_invite_me_returns_stable_code(client) -> None:
"""/invite/me 返回邀请码 + 分享链接;同一用户多次取码稳定。"""
token = _login(client, "13800002001")
r = client.get("/api/v1/invite/me", headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
assert body["invite_code"] # 非空
assert f"ref={body['invite_code']}" in body["share_url"]
assert body["invited_count"] == 0
assert body["coins_earned"] == 0
# 再取一次,码不变(懒生成 + 持久化)
assert _my_code(client, token) == body["invite_code"]
def test_bind_flow_both_get_coins(client) -> None:
"""B 用 A 的码绑定 → 双方各得 1 万金币;A 战绩 +1。"""
a = _login(client, "13800002002")
b = _login(client, "13800002003")
a_code = _my_code(client, a)
assert _coin_balance(client, a) == 0
assert _coin_balance(client, b) == 0
r = client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(b))
assert r.status_code == 200, r.text
res = r.json()
assert res["status"] == "success"
assert res["coins_awarded"] == INVITE_INVITEE_COINS
# 双方金币到账
assert _coin_balance(client, b) == INVITE_INVITEE_COINS
assert _coin_balance(client, a) == INVITE_INVITER_COINS
# A 的战绩:已邀 1 人,累计获得 = 邀请人那份
r = client.get("/api/v1/invite/me", headers=_auth(a))
assert r.json()["invited_count"] == 1
assert r.json()["coins_earned"] == INVITE_INVITER_COINS
def test_bind_idempotent_no_double_reward(client) -> None:
"""同一被邀请人二次绑定 → already_bound,不重复发奖。"""
a = _login(client, "13800002004")
b = _login(client, "13800002005")
a_code = _my_code(client, a)
r1 = client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(b))
assert r1.json()["status"] == "success"
bal_b = _coin_balance(client, b)
bal_a = _coin_balance(client, a)
# 再绑一次(甚至换个码也不行,已被绑过)
c = _login(client, "13800002006")
c_code = _my_code(client, c)
r2 = client.post("/api/v1/invite/bind", json={"invite_code": c_code}, headers=_auth(b))
assert r2.json()["status"] == "already_bound"
assert r2.json()["coins_awarded"] == 0
# 余额没变(没二次发奖),C 也没拿到邀请奖励
assert _coin_balance(client, b) == bal_b
assert _coin_balance(client, a) == bal_a
assert _coin_balance(client, c) == 0
def test_self_invite_blocked(client) -> None:
"""填自己的邀请码 → self_invite,不发奖。"""
a = _login(client, "13800002007")
a_code = _my_code(client, a)
r = client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(a))
assert r.status_code == 200, r.text
assert r.json()["status"] == "self_invite"
assert _coin_balance(client, a) == 0
def test_invalid_code(client) -> None:
"""无效邀请码 → invalid_code,不发奖。"""
b = _login(client, "13800002008")
r = client.post("/api/v1/invite/bind", json={"invite_code": "ZZZZZZ"}, headers=_auth(b))
assert r.status_code == 200, r.text
assert r.json()["status"] == "invalid_code"
assert _coin_balance(client, b) == 0
def test_manual_channel_recorded(client) -> None:
"""channel=manual 也能绑定(手动填码兜底路径)。"""
a = _login(client, "13800002009")
b = _login(client, "13800002010")
a_code = _my_code(client, a)
r = client.post(
"/api/v1/invite/bind",
json={"invite_code": a_code, "channel": "manual"},
headers=_auth(b),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "success"
def test_invite_requires_auth(client) -> None:
"""不带 token → 401。"""
assert client.get("/api/v1/invite/me").status_code == 401
assert client.post("/api/v1/invite/bind", json={"invite_code": "ABCDEF"}).status_code == 401
def test_old_user_not_eligible(client) -> None:
"""新用户闸:被邀请人注册超过窗口 → not_eligible,双方都不发奖。"""
a = _login(client, "13800002030")
a_code = _my_code(client, a)
b = _login(client, "13800002031")
# 把 b 的注册时间改成超出窗口 → 变"老用户"
with SessionLocal() as db:
u = get_user_by_phone(db, "13800002031")
assert u is not None
u.created_at = datetime.now(timezone.utc) - timedelta(hours=INVITE_NEW_USER_WINDOW_HOURS + 1)
db.commit()
r = client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(b))
assert r.status_code == 200, r.text
assert r.json()["status"] == "not_eligible"
assert r.json()["coins_awarded"] == 0
assert _coin_balance(client, b) == 0
assert _coin_balance(client, a) == 0