diff --git a/.gitignore b/.gitignore index eaf4bc3..e1ee6ce 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,13 @@ dist/ *.db-journal *.db-shm *.db-wal -data/ +# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html—— +# 它既是生产落地页又是本地测试资产,纳入 git 便于同事一致测试 +# (见 docs/邀请功能-实现原理与本地测试.md)。其余(avatars/ / *.apk / app.db 等)仍忽略。 +data/* +!data/media/ +data/media/* +!data/media/dl.html secrets/* !secrets/.gitkeep 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/data/media/dl.html b/data/media/dl.html new file mode 100644 index 0000000..efb0d19 --- /dev/null +++ b/data/media/dl.html @@ -0,0 +1,147 @@ + + + + + + +傻瓜比价 · 下载 + + + + +

傻瓜比价

+
买什么都先比一比
自动帮你找全网最低价
+ +
+
🍔 点外卖前一键比价,美团/京东/淘宝到手价一目了然
+
🎟️ 自动领遍各平台红包券,能省的一分不漏
+
💰 省下的钱看得见,还能赚金币提现
+
+ + +
Android 安卓版 · 安装包约 24 MB
+ +
+ 安装时如提示「未知来源」,请允许后继续安装。
+ 本页为内部测试页。 +
+ + +
+ + + + +
+
点击右上角 ···
选择「在浏览器打开
+
微信里无法直接下载安装包
需在系统浏览器中完成下载
+
+
1点右上角的 ··· 菜单
+
2选择「在浏览器打开」
+
3在浏览器里点「下载安装包」
+
+
+
我知道了 ✕
+
+ + + + diff --git a/docs/README.md b/docs/README.md index 0829806..7e9f966 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ | [数据库迁移.md](./数据库迁移.md) | **Alembic 迁移指南**:clone 后如何建表、日常升级、新增迁移、迁移文件命名约定。想"把数据库跑起来 / 改表结构"看这份。 | | [待办与技术债.md](./待办与技术债.md) | **待办与技术债账本**:记"现在先简化、以后要补"的事 + 跨前后端技术债(P1 鉴权/用户绑定、引擎移植待办等),免遗忘。想知道"还欠什么、以后要补什么"看这份。 | | [看广告赚金币上线清单.md](./看广告赚金币上线清单.md) | **看广告发奖上线 checklist**:跨前后端,记上线前必做(GroMore 回调配置、清理调试脚手架、端到端验收)。上线"看广告赚金币"前对照这份。 | +| [邀请功能-实现原理与本地测试.md](./邀请功能-实现原理与本地测试.md) | **好友邀请(invite-mvp)实现原理 + 跨前后端具体实现 + 本地内网测试方法**:剪贴板 deferred-deeplink 归因、双方各发 1 万金币、落地页 dl.html、起本地后端 + 编 debug 包 + 两台真机走全链路 + 上线前还差什么。要本地测 / 接手邀请功能看这份。 | ## api/ 目录是怎么组织的(传送门式) diff --git a/docs/邀请功能-实现原理与本地测试.md b/docs/邀请功能-实现原理与本地测试.md new file mode 100644 index 0000000..1a8b147 --- /dev/null +++ b/docs/邀请功能-实现原理与本地测试.md @@ -0,0 +1,139 @@ +# 好友邀请(invite-mvp)— 实现原理与本地测试 + +> 跨 **shaguabijia-app-server**(后端)+ **shaguabijia-app-android**(前端)的好友邀请功能。 +> 本文给三件事:**做什么 / 怎么实现 / 怎么在本地内网测**(交接给同事用)。 +> 代码引用一律用文件路径 + 符号名(不写行号,重构后行号会失效)。 + +--- + +## 一、功能是什么 + +**注册即生效**:被邀请人(B)用邀请人(A)的邀请码完成绑定 → **A、B 各得 1 万金币(= 1 元,可提现)**。 + +--- + +## 二、实现原理(数据流) + +归因(B 装的这次算不算 A 邀的)有**两条通道**: + +- **剪贴板自动**(deferred deeplink,生产主路径):落地页把邀请码写进剪贴板,APK 首启读回。 +- **手动填码**(兜底):B 在邀请页直接输入 A 的邀请码。 + +剪贴板自动这条的完整跨库链路: + +``` +A 端(已登录) + └─ GET /api/v1/invite/me → 拿到 invite_code + share_url(=落地页?ref=<码>) + └─ 分享 share_url(微信/任意渠道) + ↓ +B 端(系统浏览器打开 share_url = .../media/dl.html?ref=<码>) + └─ 点「下载安装包」 + ├─ dl.html 把 "SGBJ_INVITE:<码>" 写进剪贴板(execCommand) + └─ 跳 APK_URL 下载安装包 + ↓ +B 安装并首启 App + └─ InviteCapture 读剪贴板,识别 "SGBJ_INVITE:" 前缀 → 暂存 pending 邀请码 + └─ B 登录成功 → AuthRepository 触发 InviteRepository.bindPendingFromClipboard() + └─ POST /api/v1/invite/bind { invite_code, channel="clipboard" } + ↓ +后端 repositories/invite.py bind() + └─ 过四道防线 → 建 invite_relation + 给 A、B 各发金币(同事务原子提交) +``` + +手动填码这条:B 在邀请页输码 → `InviteRepository.bindManual()` → `POST /bind { channel="manual" }` → 同一个 `bind()`。 + +--- + +## 三、具体实现 + +### 3.1 后端(shaguabijia-app-server) + +| 关注点 | 位置 | +|---|---| +| 端点 | `app/api/v1/invite.py`:`GET /api/v1/invite/me`(返回 `invite_code` + `share_url` + 战绩)、`POST /api/v1/invite/bind`(绑定,`channel` = `clipboard` / `manual`)。**均需 Bearer 鉴权**。 | +| share_url 构造 | `invite.py` 的 `my_invite`:`settings.INVITE_LANDING_URL + "?ref=" + code`。`INVITE_LANDING_URL` 在 `app/core/config.py`(默认 `https://app-api.shaguabijia.com/media/dl.html`)。 | +| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 累计金币)。 | +| 数据模型 | `app/models/invite.py` 的 `InviteRelation`(`inviter_user_id` / `invitee_user_id` / `channel` / `status` / `inviter_coin` / `invitee_coin` / `created_at`)+ `app/models/user.py` 的 `User.invite_code` 列。 | +| 迁移 | `alembic/versions/invite_code_and_relation.py`:给 `user` 加 `invite_code`(唯一索引)+ 建 `invite_relation` 表。`down_revision = 11a1d08c6f55`。 | +| 收发模型 | `app/schemas/invite.py`:`InviteInfoOut` / `BindInviteIn` / `BindInviteOut`。 | +| 奖励常量 | `app/core/rewards.py`:`INVITE_INVITER_COINS` / `INVITE_INVITEE_COINS`(各 10000 = 1 元)、`INVITE_NEW_USER_WINDOW_HOURS`(72)。 | + +**`bind()` 的四道防线(防重复 / 防刷,看 `repositories/invite.py`):** + +1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复发奖)。 +2. **自邀屏蔽**:`inviter == invitee` → `self_invite`。 +3. **新人闸**:`_is_new_user`(B 的 `created_at` 在 `INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才发奖,挡存量老用户互相填码薅羊毛 → 否则 `not_eligible`。 +4. **手机号唯一**(天然限量):每个 B = 一个真实手机号账号。 + +发金币复用 `repositories/wallet.py` 的 `grant_coins`,与建关系记录在**同一事务**提交,保证"建关系 + 双方加金币"原子。 + +### 3.2 前端(shaguabijia-app-android) + +| 关注点 | 位置 | +|---|---| +| 网络层 | `data/api/InviteApi.kt` + `InviteDto.kt`:`me()` / `bind(BindInviteRequest)`。 | +| 门面 | `data/invite/InviteRepository.kt`:`myInvite()` / `bindManual(code)` / `bindPendingFromClipboard()`。 | +| 剪贴板捕获 | `util/InviteCapture.kt`:首启读剪贴板找 `SGBJ_INVITE:` 前缀(常量 `CLIP_PREFIX`),命中即暂存到 SP(`pending_invite_code`);**未命中不烧机会**,跨多次启动最多试 `MAX_ATTEMPTS`(5)次,之后靠手动填码兜底。 | +| 自动绑定触发 | 登录成功后由 `data/auth/AuthRepository.kt` 调 `InviteRepository.bindPendingFromClipboard()`(best-effort,不抛异常)。 | +| UI | `ui/invite/InviteScreen.kt` + `InviteViewModel.kt`:展示码 / 二维码(`util/QrCodeGen.kt`)/ 战绩 + 手动填码入口。 | + +### 3.3 落地页 dl.html(`data/media/dl.html`,由后端 `/media` 静态托管) + +- 读 URL 的 `?ref=<邀请码>`。 +- 点「下载安装包」时:`legacyCopy()` 用 `document.execCommand('copy')` **同步**把 `"SGBJ_INVITE:<码>"` 写进剪贴板(execCommand **不要求安全上下文**,内网 HTTP 也能写;`navigator.clipboard` 只是 HTTPS 下锦上添花、失败无妨),再跳 `APK_URL` 下载。 +- `APK_URL = location.origin + "/media/shaguabijia.apk"`:**跟随页面 host**——本地测试时页面由笔记本(LAN:8770)托管 → 下载也走 LAN;生产由 `app-api.shaguabijia.com` 托管 → 走生产域名。**无需按机器改 IP**。 +- 微信内显示「去浏览器打开」引导(微信内 execCommand / apk 下载都受限,必须跳系统浏览器)。 + +> dl.html 已纳入 git(见本仓 `.gitignore` 对 `data/media/dl.html` 的例外)。`data/media/shaguabijia.apk`(大二进制)、`avatars/` 等仍忽略。 + +--- + +## 四、本地内网测试方法(重点) + +**拓扑**:同一 WiFi 下,1 台笔记本(起后端 + 托管落地页/APK)+ 2 台安卓真机(A 邀请人 / B 被邀请人)。落地页和 APK 都由笔记本 app-server 的 `/media` 在局域网托管,一处搞定。 + +### 4.1 起后端 + +1. 本机 PostgreSQL 起着;`conda activate price`。 +2. `.env` 改两项(**本地测试覆盖,测完改回**): + - `SMS_MOCK=true` —— 任意手机号 + 任意 6 位码秒注册,方便建 A/B 两个号(生产是 `false` 走真极光)。 + - `INVITE_LANDING_URL=http://<笔记本LAN_IP>:8770/media/dl.html` —— 让 `/invite/me` 返回的 share_url 走内网。 +3. `alembic upgrade head` 建表。 + - ⚠️ **切分支后若报 `Can't locate revision`**(DB 版本指针停在别分支的迁移,例如先测过 coupon-daily-claim-gate,DB 停在 `coupon_state_tables`,而本分支没这个文件):用 `alembic stamp <共同祖先> --purge` 把指针重置到共同祖先(本分支祖先是 `11a1d08c6f55`)→ 再 `alembic upgrade head` 只跑 invite 这一条建表。**别删多余表**。详见 `docs/数据库迁移.md`。 +4. `./run.sh`(监听 `0.0.0.0:8770`)。**笔记本防火墙放行入站 8770**。 + +### 4.2 落地页 + APK(都由 `/media` 在 LAN 托管) + +- **落地页**:`data/media/dl.html` 已在 git、host-agnostic,**无需改**(clone 出来就在)。 +- **APK**:编一个 debug 包放到 `data/media/shaguabijia.apk`: + 1. 前端 `local.properties` 填 `debug.api.host=<笔记本LAN_IP>` / `debug.api.port=8770`(debug 包的 `BASE_URL` 据此**编译期**烧入,见 `app/build.gradle.kts`;debug 包还放行明文 HTTP)。 + 2. `./gradlew assembleDebug`。 + 3. `cp app/build/outputs/apk/debug/app-debug.apk /data/media/shaguabijia.apk`。 + +### 4.3 跑流程 + +1. A 真机装 debug 包 → 登录 → 邀请页拿到 6 位码 / share_url(= `http://:8770/media/dl.html?ref=<码>`)。 +2. A 把 share_url 发出去 → B 真机**用系统浏览器**打开 → 点「下载」(写剪贴板 + 下 APK)→ 安装。 +3. B 首启 → `InviteCapture` 读剪贴板拿码(Android 12+ 会弹"已粘贴"提示,正常)→ B 用**新手机号**登录 → 自动 `bind(clipboard)` → **A、B 各 +1 万金币,`invite_relation` 入库**。 +4. **手动填码路径**:B 在邀请页直接输 A 的码(`channel=manual`),不依赖剪贴板——剪贴板失败时的兜底,值得单独测。 + +### 4.4 测试硬约束 / 坑(都是机制,不是 bug) + +- **B 必须用新手机号**:`invitee_user_id` 唯一,一个 B 只能绑一次;反复测要换号(或手删 `invite_relation` 那行 + 回滚金币)。 +- **72h 新人闸**:B 注册后 72 小时内绑才发奖(刚注册肯定满足)。 +- **A ≠ B**:自邀被屏蔽。 +- **B 从点下载到首启 App 之间别复制别的东西**:剪贴板会被覆盖 → 归因丢(剪贴板 deferred deeplink 的固有脆弱性)。 +- **笔记本 IP 别变**:debug 包把 `BASE_URL` 的 IP 烧死在编译期,DHCP 一换就连不上 → 给笔记本固定个 LAN IP。 + +--- + +## 五、上线前还差什么(本地测通 ≠ 可上线) + +本地这一关证明的是"机制能跑通",离生产可上线还差: + +1. ⚠️ **alembic 多 head(不修 100% 翻车)**:`coupon_state_tables` 和 `invite_code_and_relation` 都挂在 `11a1d08c6f55` 下、是**兄弟迁移**。invite 一合 main,main 上就有两个 head,prod `alembic upgrade head` 撞 `Multiple head revisions` → 服务起不来。**合 main 前 / 部署前必须建一个 merge 迁移**把两条链并起来(参考 repo 里已有的 `*_merge_*_heads.py`)。本地单分支单 head 测不出。 +2. **release 包 ≠ debug 包**:用户拿到的是 release(签名不同、强制 HTTPS、若开 minify 还有 R8 混淆),本地测的是 debug。上线前用 **release 包真机**验全流程。 +3. **风控 / 薅羊毛**:1 万金币可提现,**邀请人无总数上限**,接码平台批量注册新号绑同一个码即可刷。上线前加 **inviter 上限 + 基础风控**。 +4. **仅验了 happy path**:错误分支文案(`invalid_code` / `already_bound` / `self_invite` / `not_eligible`)、手动填码兜底、stats 展示更新、并发绑定都没真测。 +5. **配置回归**:`SMS_MOCK` / `INVITE_LANDING_URL` 是本地覆盖,生产走真极光 + 默认生产落地页。 +6. **(dl.html 纳入 git 后的部署细节)**:生产机上已有未跟踪的 `data/media/dl.html`(之前手动放的)。若走 git 部署,首次 pull 前需先移走生产那份,否则报 `untracked working tree files would be overwritten`。 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