Compare commits

...

2 Commits

Author SHA1 Message Date
marco 1b51cd2d1b docs(invite): 邀请实现原理+本地测试文档, 落地页 dl.html 纳入 git
- docs/邀请功能-实现原理与本地测试.md: 跨前后端实现 + 剪贴板 deferred-deeplink 归因原理
  + 本地内网测试全流程(起后端 / 编 debug 包 / 两台真机) + 上线前待办(alembic 多 head / release 包 / 风控)
- data/media/dl.html: 落地页纳入 git(.gitignore 加例外, apk 等大文件仍忽略);
  APK_URL 改 location.origin 随页面 host 自动适配, 免按机器改 IP
- docs/README.md: 文档索引补一行

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 04:51:16 +08:00
marco bc10432f52 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>
2026-06-08 03:21:03 +08:00
15 changed files with 832 additions and 1 deletions
+7 -1
View File
@@ -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
@@ -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 # 给前端直接展示的文案
+147
View File
@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="format-detection" content="telephone=no">
<title>傻瓜比价 · 下载</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
html,body { height:100%; }
body {
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
background:linear-gradient(165deg,#FF7A3D 0%,#FF3B30 52%,#E0245E 100%);
color:#fff; min-height:100%; display:flex; flex-direction:column;
align-items:center; justify-content:center; padding:40px 26px; text-align:center;
overflow-x:hidden;
}
.logo {
width:104px; height:104px; border-radius:26px; background:#fff;
display:flex; align-items:center; justify-content:center; font-size:52px;
box-shadow:0 14px 34px rgba(0,0,0,.22); margin-bottom:24px;
}
h1 { font-size:30px; font-weight:800; letter-spacing:1px; }
.slogan { margin-top:12px; font-size:16px; line-height:1.7; opacity:.95; max-width:300px; }
.feats { margin-top:26px; display:flex; flex-direction:column; gap:12px; width:100%; max-width:320px; }
.feat { background:rgba(255,255,255,.16); border-radius:14px; padding:13px 16px; font-size:15px; display:flex; align-items:center; gap:10px; }
.feat b { font-weight:700; }
.btn {
margin-top:34px; width:100%; max-width:320px; border:none; cursor:pointer;
background:#fff; color:#FF3B30; font-size:19px; font-weight:800;
padding:17px 0; border-radius:999px; box-shadow:0 10px 26px rgba(0,0,0,.22);
display:flex; align-items:center; justify-content:center; gap:9px;
}
.btn:active { transform:translateY(1px); opacity:.92; }
.hint { margin-top:16px; font-size:13px; opacity:.85; }
.foot { margin-top:30px; font-size:12px; opacity:.6; line-height:1.6; max-width:320px; }
/* 微信内"去浏览器打开"引导蒙层 */
#wxmask {
display:none; position:fixed; inset:0; z-index:9999;
background:rgba(0,0,0,.86); padding:18px;
}
#wxmask.show { display:block; }
.arrow { position:absolute; top:8px; right:14px; width:120px; }
.wxtip { position:absolute; top:150px; right:18px; left:18px; text-align:right; }
.wxtip .big { font-size:21px; font-weight:800; line-height:1.5; }
.wxtip .big em { color:#FFD24D; font-style:normal; }
.wxtip .sub { margin-top:14px; font-size:15px; line-height:1.8; opacity:.9; }
.wxsteps { margin-top:26px; text-align:left; background:rgba(255,255,255,.1); border-radius:14px; padding:18px 18px; font-size:15px; line-height:2; }
.wxsteps .n { display:inline-block; width:22px; height:22px; line-height:22px; text-align:center; border-radius:50%; background:#FFD24D; color:#333; font-weight:800; font-size:13px; margin-right:8px; }
.closebar { position:absolute; bottom:30px; left:0; right:0; text-align:center; font-size:14px; opacity:.7; }
</style>
</head>
<body>
<div class="logo">🛒</div>
<h1>傻瓜比价</h1>
<div class="slogan">买什么都先比一比<br>自动帮你找全网最低价</div>
<div class="feats">
<div class="feat">🍔 <span>点外卖前一键比价,<b>美团/京东/淘宝</b>到手价一目了然</span></div>
<div class="feat">🎟️ <span>自动领遍各平台<b>红包券</b>,能省的一分不漏</span></div>
<div class="feat">💰 <span>省下的钱看得见,还能<b>赚金币提现</b></span></div>
</div>
<button class="btn" id="dlbtn">⬇️ 下载安装包</button>
<div class="hint" id="hint">Android 安卓版 · 安装包约 24 MB</div>
<div class="foot">
安装时如提示「未知来源」,请允许后继续安装。<br>
本页为内部测试页。
</div>
<!-- 微信内引导:跳出微信去浏览器 -->
<div id="wxmask">
<svg class="arrow" viewBox="0 0 120 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M30 120 C 30 70, 55 40, 95 28" stroke="#FFD24D" stroke-width="6" stroke-linecap="round" fill="none" stroke-dasharray="2 13"/>
<path d="M95 28 L 78 30 M95 28 L 92 46" stroke="#FFD24D" stroke-width="6" stroke-linecap="round"/>
</svg>
<div class="wxtip">
<div class="big">点击右上角 <em>···</em><br>选择「<em>在浏览器打开</em></div>
<div class="sub">微信里无法直接下载安装包<br>需在系统浏览器中完成下载</div>
<div class="wxsteps">
<div><span class="n">1</span>点右上角的 ··· 菜单</div>
<div><span class="n">2</span>选择「在浏览器打开」</div>
<div><span class="n">3</span>在浏览器里点「下载安装包」</div>
</div>
</div>
<div class="closebar" id="wxclose">我知道了 ✕</div>
</div>
<script>
// APK 下载地址跟随当前页面 origin:本地测试时页面由笔记本 app-server(LAN:8770)托管 →
// 下载也走 LAN;生产由 app-api.shaguabijia.com 托管 → 下载走生产域名。无需按机器改 IP。
var APK_URL = location.origin + "/media/shaguabijia.apk";
var ua = navigator.userAgent || "";
var isWeChat = /MicroMessenger/i.test(ua);
var isIOS = /iPhone|iPad|iPod/i.test(ua);
var isAndroid = /Android/i.test(ua);
var ref = new URLSearchParams(location.search).get("ref"); // 邀请码(来自二维码 URL ?ref=)
// 把邀请码写进剪贴板,APK 首启时读出来完成归因(deferred deeplink 的关键一步)。
// 浏览器要求:必须在用户点击手势里调用 + HTTPS 下才允许写。
function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底
try {
var ta = document.createElement("textarea");
ta.value = payload; ta.style.position = "fixed"; ta.style.top = "-1000px"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.focus(); ta.select();
document.execCommand("copy"); document.body.removeChild(ta);
} catch (e) {}
}
function copyInviteCode() { // 返回 Promise(完成后才下载,避免异步写入被打断)
if (!ref) return Promise.resolve();
var payload = "SGBJ_INVITE:" + ref;
legacyCopy(payload); // 同步兜底:在用户手势内立刻 execCommand 写一次(最可靠)
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(payload).catch(function () {}); // 现代 API 锦上添花,失败无妨
}
return Promise.resolve();
}
// 确保剪贴板写完(或最多等 400ms 兜底,防写入卡住)再触发下载。
function startDownload() {
var fired = false;
function go() { if (!fired) { fired = true; window.location.href = APK_URL; } }
copyInviteCode().then(go);
setTimeout(go, 400);
}
var hint = document.getElementById("hint");
if (isIOS) hint.textContent = "检测到 iPhone · iOS 版请前往 App Store";
var mask = document.getElementById("wxmask");
function showMask(){ mask.classList.add("show"); }
function hideMask(){ mask.classList.remove("show"); }
document.getElementById("wxclose").addEventListener("click", hideMask);
// 微信里一进页面就提示去浏览器打开(下载在微信内必被拦)
if (isWeChat) showMask();
document.getElementById("dlbtn").addEventListener("click", function(){
if (isWeChat) { showMask(); return; } // 微信内:引导去浏览器
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
// 普通浏览器:先把邀请码写进剪贴板(写完或 400ms 兜底),再下载 APK(首启读回完成归因)
startDownload();
});
</script>
</body>
</html>
+1
View File
@@ -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/ 目录是怎么组织的(传送门式)
@@ -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 <app-server>/data/media/shaguabijia.apk`
### 4.3 跑流程
1. A 真机装 debug 包 → 登录 → 邀请页拿到 6 位码 / share_url(= `http://<LAN>: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`
+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