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:
@@ -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')
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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], ...] = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>"
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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 # 给前端直接展示的文案
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user