diff --git a/alembic/versions/invite_code_and_relation.py b/alembic/versions/invite_code_and_relation.py new file mode 100644 index 0000000..b89a29a --- /dev/null +++ b/alembic/versions/invite_code_and_relation.py @@ -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') diff --git a/app/api/v1/invite.py b/app/api/v1/invite.py new file mode 100644 index 0000000..2bbb857 --- /dev/null +++ b/app/api/v1/invite.py @@ -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), + ) diff --git a/app/core/config.py b/app/core/config.py index c6eff5d..7daebd0 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 = "" diff --git a/app/core/rewards.py b/app/core/rewards.py index 2d06c31..88f70c8 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -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], ...] = ( diff --git a/app/main.py b/app/main.py index 751a5d8..b905b93 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/models/__init__.py b/app/models/__init__.py index 2f95779..9bab636 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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 diff --git a/app/models/invite.py b/app/models/invite.py new file mode 100644 index 0000000..8ed6951 --- /dev/null +++ b/app/models/invite.py @@ -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"" + ) diff --git a/app/models/user.py b/app/models/user.py index bf4be86..2f1d672 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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( diff --git a/app/repositories/invite.py b/app/repositories/invite.py new file mode 100644 index 0000000..8d496dd --- /dev/null +++ b/app/repositories/invite.py @@ -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) diff --git a/app/schemas/invite.py b/app/schemas/invite.py new file mode 100644 index 0000000..ecff181 --- /dev/null +++ b/app/schemas/invite.py @@ -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 # 给前端直接展示的文案 diff --git a/tests/test_invite.py b/tests/test_invite.py new file mode 100644 index 0000000..3064ef8 --- /dev/null +++ b/tests/test_invite.py @@ -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