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
17 changed files with 24 additions and 1271 deletions
@@ -1,26 +0,0 @@
"""merge coupon_state and invite heads
Revision ID: 0cf18d590b1d
Revises: coupon_state_tables, invite_code_and_relation
Create Date: 2026-06-08 05:49:29.604571
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0cf18d590b1d'
down_revision: Union[str, Sequence[str], None] = ('coupon_state_tables', 'invite_code_and_relation')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
-77
View File
@@ -1,77 +0,0 @@
"""coupon state tables (领券今日状态:弹窗频控 engagement + 领券记录 claim)
Revision ID: coupon_state_tables
Revises: 11a1d08c6f55
Create Date: 2026-06-08 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'coupon_state_tables'
down_revision: Union[str, Sequence[str], None] = '11a1d08c6f55'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# PG 上为 JSONB,其它(SQLite dev)为 JSON——与模型层 with_variant 对齐
_JSON = sa.JSON().with_variant(postgresql.JSONB(), 'postgresql')
def upgrade() -> None:
# 领券记录(资产层,当前不参与去重判断)
op.create_table(
'coupon_claim_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('device_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('coupon_id', sa.String(length=64), nullable=False),
sa.Column('claim_date', sa.Date(), nullable=False),
sa.Column('status', sa.String(length=24), nullable=False),
sa.Column('vendor', sa.String(length=48), nullable=True),
sa.Column('coupon_name', sa.String(length=128), nullable=True),
sa.Column('claimed_count', sa.Integer(), nullable=True),
sa.Column('trace_id', sa.String(length=64), nullable=True),
sa.Column('reason', sa.String(length=255), nullable=True),
sa.Column('extra', _JSON, nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('device_id', 'coupon_id', 'claim_date', name='uq_coupon_claim_device_coupon_date'),
)
with op.batch_alter_table('coupon_claim_record', schema=None) as batch_op:
batch_op.create_index('ix_coupon_claim_device_date', ['device_id', 'claim_date'], unique=False)
batch_op.create_index(batch_op.f('ix_coupon_claim_record_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_coupon_claim_record_trace_id'), ['trace_id'], unique=False)
# 弹窗频控(今天 engage 过就不再弹)
op.create_table(
'coupon_prompt_engagement',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('device_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('engage_date', sa.Date(), nullable=False),
sa.Column('engage_type', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('device_id', 'engage_date', name='uq_coupon_engage_device_date'),
)
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_coupon_prompt_engagement_user_id'), ['user_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_coupon_prompt_engagement_user_id'))
op.drop_table('coupon_prompt_engagement')
with op.batch_alter_table('coupon_claim_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_coupon_claim_record_trace_id'))
batch_op.drop_index(batch_op.f('ix_coupon_claim_record_user_id'))
batch_op.drop_index('ix_coupon_claim_device_date')
op.drop_table('coupon_claim_record')
@@ -1,50 +0,0 @@
"""invite_fingerprint table (任务 3 指纹归因兜底)
Revision ID: invite_fingerprint_table
Revises: 0cf18d590b1d
Create Date: 2026-06-08 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'invite_fingerprint_table'
down_revision: Union[str, Sequence[str], None] = '0cf18d590b1d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 邀请指纹归因表:落地页访问时记一行,登录后剪贴板没拿到邀请码时反查
op.create_table(
'invite_fingerprint',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('inviter_user_id', sa.Integer(), nullable=False),
sa.Column('ip', sa.String(length=64), nullable=False),
sa.Column('device_model', sa.String(length=64), nullable=False, server_default=''),
sa.Column('screen', sa.String(length=32), nullable=False, server_default=''),
sa.Column('user_agent', sa.Text(), nullable=False, server_default=''),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['inviter_user_id'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
)
# 反查主索引:(ip, screen, device_model, created_at)
# /bind 兜底分支 WHERE ip=? AND screen=? AND device_model=? AND created_at > now - 7d
# ORDER BY created_at DESC LIMIT 1 — SQLite/PG 优化器都能反向扫该索引
op.create_index(
'ix_invite_fp_match',
'invite_fingerprint',
['ip', 'screen', 'device_model', 'created_at'],
)
# 兜底/统计索引:按 inviter 看"这个邀请人的落地页被谁访问过"
op.create_index('ix_invite_fp_inviter', 'invite_fingerprint', ['inviter_user_id'])
def downgrade() -> None:
op.drop_index('ix_invite_fp_inviter', table_name='invite_fingerprint')
op.drop_index('ix_invite_fp_match', table_name='invite_fingerprint')
op.drop_table('invite_fingerprint')
+1 -107
View File
@@ -16,66 +16,15 @@ from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.concurrency import run_in_threadpool
from app.api.deps import DbSession
from app.core.config import settings
from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal
from app.repositories import coupon_state as coupon_repo
from app.schemas.coupon_state import CouponPromptDismissIn, CouponPromptShouldShowOut
logger = logging.getLogger("shagua.coupon")
router = APIRouter(prefix="/api/v1/coupon", tags=["coupon"])
def _to_int(v: object) -> int | None:
"""user_id 协议是字符串(登录态才带),转 int 存库;缺失/非法 → None。"""
if v is None:
return None
try:
return int(v) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def _extract_coupon_results(resp_json: dict) -> list[dict]:
"""从 pricebot 响应抽本帧券结果,并**按 coupon_id 去重**。
⚠️ done/单券帧同时带 last_coupon_result(最后一张)+ action.params.coupon_results
(全量含最后一张)→ 最后一张出现两次。必须去重:同批里同 coupon_id 两次,
record_claims 在 autoflush=False 下两次 select 都查不到刚 add 的行 → 重复 add →
commit 撞唯一约束 IntegrityError 回滚整批(done/单券记录全丢)。
coupon_results(全量权威)覆盖 last_coupon_result。"""
by_id: dict[str, dict] = {}
last = resp_json.get("last_coupon_result")
if isinstance(last, dict) and last.get("coupon_id"):
by_id[last["coupon_id"]] = last
params = (resp_json.get("action") or {}).get("params") or {}
cr = params.get("coupon_results")
if isinstance(cr, list):
for x in cr:
if isinstance(x, dict) and x.get("coupon_id"):
by_id[x["coupon_id"]] = x
return list(by_id.values())
def _mark_engagement_blocking(
device_id: str, user_id: int | None, engage_type: str
) -> None:
"""独立 session 写 engagement(async 端点经 run_in_threadpool 调,不阻塞事件循环)。"""
with SessionLocal() as db:
coupon_repo.mark_engagement(db, device_id, user_id, engage_type)
def _record_claims_blocking(
device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
) -> None:
with SessionLocal() as db:
coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
@router.post("/step", summary="领券任务步进 (透传到 pricebot)")
async def coupon_step(
request: Request,
@@ -96,21 +45,6 @@ async def coupon_step(
if not isinstance(meta, dict):
meta = {}
device_id = meta.get("device_id")
user_id = _to_int(meta.get("user_id")) # 登录态才带;判断不靠它,资产留痕用
trace_id = meta.get("trace_id")
# 领券任务首帧(step=0)= 用户已发起领券 → 记一条今日 engagement(claim_started),
# 今天这台设备不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能
# 连累领券主流程,整段吞掉。
if device_id and meta.get("step") == 0:
try:
await run_in_threadpool(
_mark_engagement_blocking, device_id, user_id, "claim_started"
)
except Exception as e: # noqa: BLE001
logger.warning("coupon engagement write failed: %s", e)
# 按 trace_id 一致性 hash 选 pricebot 实例(同一领券任务的所有帧落同一进程)
base = pick_pricebot(meta.get("trace_id"))
url = f"{base.rstrip('/')}/api/coupon/step"
@@ -146,44 +80,4 @@ async def coupon_step(
detail=f"pricebot upstream returned {resp.status_code}",
)
resp_json = resp.json()
# 领券结果沉淀(资产):中间帧 last_coupon_result + done 帧 coupon_results 幂等写库。
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
if device_id:
results = _extract_coupon_results(resp_json)
if results:
try:
await run_in_threadpool(
_record_claims_blocking, device_id, user_id, trace_id, results
)
except Exception as e: # noqa: BLE001
logger.warning("coupon claim write failed: %s", e)
return resp_json
@router.post("/prompt/dismiss", summary="用户拒绝/关闭领券引导窗(记今日已 engage)")
def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
"""客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天不再弹。
server 在透传链路里看不到"用户拒绝"(拒绝不发起领券),故必须客户端通知。
MVP 不鉴权,按 device_id 记。
"""
coupon_repo.mark_engagement(db, payload.device_id, payload.user_id, "dismissed")
return {"ok": True}
@router.get(
"/prompt/should-show",
response_model=CouponPromptShouldShowOut,
summary="切到外卖 App 时是否还应弹领券引导窗",
)
def coupon_prompt_should_show(
device_id: str, db: DbSession
) -> CouponPromptShouldShowOut:
"""今天这台设备已 engage(领或拒)过 → should_show=false。客户端据此决定弹不弹
(前台 SP 缓存做快速路径,这里是权威)。"""
return CouponPromptShouldShowOut(
should_show=not coupon_repo.has_engaged_today(db, device_id)
)
return resp.json()
+19 -172
View File
@@ -1,37 +1,25 @@
"""好友邀请 endpoint。
路由前缀 /api/v1/invite,需 Bearer 鉴权(用户级数据);唯一例外是 /landing-track
(落地页 dl.html 浏览器访问、无 token,详见任务 3 [[invite-three-tasks]]):
GET /me 我的邀请码 + 分享链接 + 已邀人数/已得金币(邀请页展示)
POST /bind 把当前登录用户(被邀请人)绑定到某邀请码;支持三种归因:
① clipboard:首启读剪贴板拿邀请码 → 上报码
② manual:用户在邀请页输入邀请码 → 上报码
③ fingerprint:剪贴板没拿到时上报指纹,后端反查
POST /landing-track 落地页 dl.html 访问时上报指纹(剪贴板兜底的服务端一端,无需鉴权)
路由前缀 /api/v1/invite,需 Bearer 鉴权(用户级数据):
GET /me 我的邀请码 + 分享链接 + 已邀人数/已得金币(邀请页展示)
POST /bind 把当前登录用户(被邀请人)绑定到某邀请码,注册即生效,双方各发金币
绑定的真正逻辑(邀请码生成/反查、幂等、自邀屏蔽、发金币、指纹反查)在
repositories/invite.py。
客户端两种调用 /bind:
- 自动:首启读剪贴板拿到邀请码 → 登录后调本接口(channel=clipboard)
- 手动:用户在邀请页输入好友邀请码(channel=manual)
绑定的真正逻辑(邀请码生成/反查、幂等、自邀屏蔽、发金币)在 repositories/invite.py。
"""
from __future__ import annotations
import logging
import re
from fastapi import APIRouter, Request
from fastapi import APIRouter
from app.api.deps import CurrentUser, DbSession
from app.core import rewards
from app.core.config import settings
from app.repositories import invite as invite_repo
from app.schemas.invite import (
BindInviteIn,
BindInviteOut,
InviteeItem,
InviteeListOut,
InviteInfoOut,
LandingTrackIn,
LandingTrackOut,
)
from app.schemas.invite import BindInviteIn, BindInviteOut, InviteInfoOut
logger = logging.getLogger("shagua.invite")
@@ -43,43 +31,9 @@ _BIND_MESSAGES = {
"invalid_code": "邀请码无效",
"self_invite": "不能填写自己的邀请码",
"not_eligible": "邀请仅对新注册用户生效",
"fp_not_found": "未匹配到邀请关系",
}
def _client_ip(request: Request) -> str:
"""从 HTTP 头拿真实 IP。nginx 反代时走 X-Forwarded-For;裸跑 uvicorn 走 request.client。"""
xff = request.headers.get("x-forwarded-for", "")
if xff:
# X-Forwarded-For 可能是 "client, proxy1, proxy2" 链;取第一个 = 真实客户端
return xff.split(",")[0].strip()
return request.client.host if request.client else ""
# Android UA 形如 '... ; <Model> Build/<id>) AppleWebKit/...';抓 ';' 后到 ' Build/' 前
# 的 token = Build.MODEL(跨端跟客户端 android.os.Build.MODEL 对齐)。user-agents 库
# 把 Android 设备归为 'Smartphone' 通用名,抓不到真实型号,只能正则。
_ANDROID_BUILD_MODEL_RE = re.compile(r";\s*([^;]+?)\s+Build/", re.IGNORECASE)
def _parse_device_model(ua: str) -> str:
"""解析浏览器 UA 拿手机型号(如 '24115RA8EC''PJF110')。
Android:正则抓 UA 里 ';...Build/' 前的 token(== Build.MODEL),跨端可严格匹配。
iOS / 解析不到:退到 user-agents 库的通用解析,失败/UA 空 → 返空字符串。
"""
if not ua:
return ""
m = _ANDROID_BUILD_MODEL_RE.search(ua)
if m:
return m.group(1).strip()
try:
from user_agents import parse
return (parse(ua).device.model or "").strip()
except Exception: # noqa: BLE001
return ""
@router.get("/me", response_model=InviteInfoOut, summary="我的邀请码 + 分享链接 + 战绩")
def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
code = invite_repo.ensure_code(db, user)
@@ -94,124 +48,17 @@ def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
)
@router.get("/invitees", response_model=InviteeListOut, summary="我邀请的人列表(分页)")
def my_invitees(
user: CurrentUser, db: DbSession, limit: int = 20, offset: int = 0
) -> InviteeListOut:
"""邀请页小窗(取前几条) + 完整列表页(分页加载)共用。
名字/头像的降级兜底在 repositories/invite.get_invitees 算好,这里只组装响应。
limit 夹到 [1,50] 防滥用;offset 不小于 0。
"""
limit = max(1, min(limit, 50))
offset = max(0, offset)
items, total, has_more = invite_repo.get_invitees(
db, user.id, limit=limit, offset=offset,
)
return InviteeListOut(
items=[InviteeItem(**it) for it in items],
total=total,
has_more=has_more,
)
@router.post(
"/landing-track",
response_model=LandingTrackOut,
summary="落地页指纹采集(无需鉴权)",
)
def landing_track(
req: LandingTrackIn, request: Request, db: DbSession
) -> LandingTrackOut:
"""B 浏览器打开 dl.html?ref=xxx 时上报指纹。
服务端从 HTTP 头拿 IP/UA、解析 UA 拿 device_model,跟 req.screen 一起入库。
无需鉴权(浏览器没 token)。invalid_code / no_ip 走 silent 返回(不抛 5xx 影响下载流程)。
"""
inviter = invite_repo.resolve_inviter(db, req.ref)
if inviter is None or inviter.status != "active":
return LandingTrackOut(status="invalid_code")
ip = _client_ip(request)
if not ip:
return LandingTrackOut(status="no_ip")
ua_str = request.headers.get("user-agent", "")
device_model = _parse_device_model(ua_str)
invite_repo.record_fingerprint(
db,
inviter_user_id=inviter.id,
ip=ip,
screen=req.screen,
device_model=device_model,
user_agent=ua_str,
@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 landing-track inviter=%d ip=%s screen=%s model=%s",
inviter.id, ip, req.screen, device_model,
"invite bind invitee=%d code=%s channel=%s -> %s",
user.id, req.invite_code, req.channel, result.status,
)
return LandingTrackOut(status="ok")
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
def bind_invite(
req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request
) -> BindInviteOut:
code = (req.invite_code or "").strip()
# 路径 1:有邀请码 → 走原路径(clipboard / manual)
if code:
result = invite_repo.bind(
db, invitee=user, invite_code=code, channel=req.channel,
)
logger.info(
"invite bind invitee=%d code=%s channel=%s -> %s",
user.id, code, req.channel, result.status,
)
return BindInviteOut(
status=result.status,
coins_awarded=result.invitee_coin,
message=_BIND_MESSAGES.get(result.status, result.status),
)
# 路径 2:无邀请码 + 有指纹 → 指纹兜底反查
if req.fingerprint is not None:
ip = _client_ip(request)
inviter = invite_repo.resolve_inviter_by_fingerprint(
db,
ip=ip,
screen=req.fingerprint.screen,
device_model=req.fingerprint.device_model,
window_days=rewards.INVITE_FP_WINDOW_DAYS,
)
if inviter is None:
logger.info(
"invite bind invitee=%d fingerprint not_found ip=%s screen=%s model=%s",
user.id, ip, req.fingerprint.screen, req.fingerprint.device_model,
)
return BindInviteOut(
status="fp_not_found",
coins_awarded=0,
message=_BIND_MESSAGES["fp_not_found"],
)
# 用反查到的 inviter.invite_code 走原 bind 流程(走原幂等/自邀/新人闸/发币逻辑)
result = invite_repo.bind(
db, invitee=user, invite_code=inviter.invite_code, channel="fingerprint",
)
logger.info(
"invite bind invitee=%d fingerprint -> inviter=%d code=%s -> %s",
user.id, inviter.id, inviter.invite_code, result.status,
)
return BindInviteOut(
status=result.status,
coins_awarded=result.invitee_coin,
message=_BIND_MESSAGES.get(result.status, result.status),
)
# 路径 3:啥都没传 → 无效请求
return BindInviteOut(
status="invalid_code",
coins_awarded=0,
message=_BIND_MESSAGES["invalid_code"],
status=result.status,
coins_awarded=result.invitee_coin,
message=_BIND_MESSAGES.get(result.status, result.status),
)
-5
View File
@@ -85,11 +85,6 @@ INVITE_INVITEE_COINS: int = 10000
# 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。
INVITE_NEW_USER_WINDOW_HOURS: int = 72
# 指纹归因兜底(剪贴板被覆盖时):落地页 POST /landing-track 存的指纹记录,在此窗口内可被
# /bind 反查撞库。窗口取舍:太短(24h)覆盖不到"晚上看链接、第二天装"的常见场景;太宽
# (30d)IP/设备会漂、匹配错率上升。7 天兼顾"看广告→使用"周期与匹配精度。
INVITE_FP_WINDOW_DAYS: int = 7
# ===== 看激励视频 / 信息流广告发金币 =====
# 金币数值体系约定:eCPM 单位按"元/千次展示"处理,单次收入 = eCPM / 1000 元。
-5
View File
@@ -7,13 +7,8 @@ from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
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.coupon_state import ( # noqa: F401
CouponClaimRecord,
CouponPromptEngagement,
)
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # 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
-130
View File
@@ -1,130 +0,0 @@
"""领券今日状态(弹窗频控 + 领券记录)两张表。
- `coupon_prompt_engagement`:按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"
——点「一键领取」(claim_started) 或 点拒绝/关闭 (dismissed) 都算。切到外卖 App 时
据此决定弹不弹:今天 engage 过就不再弹。判断维度是 **device_id**——券发到的是设备上
登录的那个外卖账号,device 比 user 更贴近"哪个登录环境",且 device_id 全链路现成、
不依赖领券鉴权。
- `coupon_claim_record`:按 (device, 券, 自然日) 记每张券的领取结果,纯沉淀(资产/画像/
排查)。当前**不参与**"要不要领"的判断(MVP 先不去重:今天 engage 过就不弹,A 路径
主动领则全跑)。留作以后做按券去重 / CPS 归因 / 用户画像的数据源。
口径:
- 日期 = Asia/Shanghai 的自然日(claim_date / engage_date)。
- user_id 领券登录态有就记(资产),可空、不进唯一键、不阻塞判断。
- device_id 客户端生成存 SP,卸载重装会变 → 重装当新设备重新弹一次(产品预期)。
"""
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import (
JSON,
Date,
DateTime,
Index,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
# PG 上用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 price_observation / comparison)。
_JSON = JSON().with_variant(JSONB(), "postgresql")
class CouponClaimRecord(Base):
"""单张券一天一条领取记录(资产层,当前不做去重判断)。"""
__tablename__ = "coupon_claim_record"
__table_args__ = (
# 同设备、同券、同一天只一条:领券每帧 last_coupon_result + done 帧 coupon_results
# 会重复上报同一张券,靠它幂等 upsert。
UniqueConstraint(
"device_id", "coupon_id", "claim_date",
name="uq_coupon_claim_device_coupon_date",
),
# 去重/统计查询按 (device, 日) 取一天所有券。
Index("ix_coupon_claim_device_date", "device_id", "claim_date"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 判断/聚合维度。client getOrCreateDeviceId 生成,重装会变。
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
# 登录态有就记(资产/画像),可空、不进唯一键。
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
coupon_id: Mapped[str] = mapped_column(String(64), nullable=False)
# Asia/Shanghai 自然日。每日可领的券(签到/天天红包)靠这天然每天一条。
claim_date: Mapped[date] = mapped_column(Date, nullable=False)
# success / already_claimed / failed / skipped(原样取 pricebot coupon 结果)
status: Mapped[str] = mapped_column(String(24), nullable=False)
vendor: Mapped[str | None] = mapped_column(String(48), nullable=True)
coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 这张领到几张(pricebot display_count;给不出时为 None)
claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 哪次任务领的,回指 pricebot work_logs / 排查
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# failed / skipped 原因
reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
# 杂项兜底:券的结构化信息(面额/入口/关键节点摘要等),免得加字段就迁移。
# ⚠️ 别塞原始无障碍树(几十 KB → 行膨胀);原始大树看 trace_id 指过去的 work_logs。
extra: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<CouponClaimRecord device={self.device_id} coupon={self.coupon_id} "
f"date={self.claim_date} status={self.status}>"
)
class CouponPromptEngagement(Base):
"""按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。"""
__tablename__ = "coupon_prompt_engagement"
__table_args__ = (
# 一台设备一天一条:今天 engage 过(领或拒)就不再弹。
UniqueConstraint(
"device_id", "engage_date",
name="uq_coupon_engage_device_date",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# Asia/Shanghai 自然日。
engage_date: Mapped[date] = mapped_column(Date, nullable=False)
# claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)。仅记录区分,
# 判断只看"今天有没有这条",type 不影响弹不弹。
engage_type: Mapped[str] = mapped_column(String(16), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<CouponPromptEngagement device={self.device_id} "
f"date={self.engage_date} type={self.engage_type}>"
)
-46
View File
@@ -1,46 +0,0 @@
"""邀请指纹归因表(剪贴板归因兜底)。
数据流(对应 [[invite-three-tasks]] 任务 3):
1. B 浏览器打开落地页 dl.html?ref=邀请码 → JS POST /api/v1/invite/landing-track
2. 后端从 HTTP 头拿 IP/UA,解析 UA 得 device_model,跟 JS 上报的 screen 一起入库
3. B 装包首启 → 登录后,若剪贴板没拿到邀请码(被覆盖),客户端再算一次 screen+Build.MODEL
上报 → 后端用 (ip, screen, device_model) 反查本表 7 天内最近匹配 → 拿出 inviter_user_id
→ 走原 bind 路径(channel=fingerprint)
不要索引(IP, created_at) 单独,组合索引 (ip, screen, device_model, created_at desc) 反查更省。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class InviteFingerprint(Base):
__tablename__ = "invite_fingerprint"
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
)
# 落地页访问者 IP(服务端从 HTTP 头拿,X-Forwarded-For 由部署侧 nginx 透传)
ip: Mapped[str] = mapped_column(String(64), nullable=False)
# 客户端 Build.MODEL / 浏览器 UA 解析出来的型号(如 "PJF110"、"23046PNC9C")
device_model: Mapped[str] = mapped_column(String(64), nullable=False, default="")
# 屏幕分辨率 "1080x2400"(浏览器 screen.width × height,客户端 DisplayMetrics)
screen: Mapped[str] = mapped_column(String(32), nullable=False, default="")
# 完整 UA 字符串(留 debug / 排查"匹配不上"用,不直接参与匹配)
user_agent: Mapped[str] = mapped_column(Text, nullable=False, default="")
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"<InviteFingerprint inviter={self.inviter_user_id} "
f"ip={self.ip} model={self.device_model} screen={self.screen}>"
)
-129
View File
@@ -1,129 +0,0 @@
"""领券今日状态读写:弹窗频控(engagement)+ 领券记录(claim)。
写操作按唯一键幂等 upsert,自带 commit + 并发 IntegrityError 兜底(对齐 price_observation)。
日期口径 = Asia/Shanghai 的自然日。
"""
from __future__ import annotations
import logging
from datetime import date, datetime
from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.coupon_state import CouponClaimRecord, CouponPromptEngagement
logger = logging.getLogger("shagua.coupon_state")
_CN_TZ = ZoneInfo("Asia/Shanghai")
def today_cn() -> date:
"""Asia/Shanghai 的自然日(领券判断的"今天")。"""
return datetime.now(_CN_TZ).date()
# ===== 弹窗频控(coupon_prompt_engagement)=====
def has_engaged_today(db: Session, device_id: str) -> bool:
"""这台设备今天是否已对领券引导窗表达过意向(领或拒)。有 = 不再弹。"""
row = db.execute(
select(CouponPromptEngagement.id).where(
CouponPromptEngagement.device_id == device_id,
CouponPromptEngagement.engage_date == today_cn(),
)
).first()
return row is not None
def mark_engagement(
db: Session, device_id: str, user_id: int | None, engage_type: str
) -> None:
"""记今日意向(claim_started / dismissed)。(device, 今天) 唯一,幂等 upsert。"""
today = today_cn()
row = db.execute(
select(CouponPromptEngagement).where(
CouponPromptEngagement.device_id == device_id,
CouponPromptEngagement.engage_date == today,
)
).scalar_one_or_none()
if row is not None:
row.engage_type = engage_type
if user_id is not None:
row.user_id = user_id
else:
db.add(CouponPromptEngagement(
device_id=device_id, user_id=user_id,
engage_date=today, engage_type=engage_type,
))
try:
db.commit()
except IntegrityError:
# 并发下另一请求刚插了同 (device, 日) → 唯一约束撞,回滚忽略(本就幂等)。
db.rollback()
# ===== 领券记录(coupon_claim_record)=====
def record_claims(
db: Session,
device_id: str,
user_id: int | None,
trace_id: str | None,
results: list[dict],
) -> int:
"""一批券领取结果幂等写入,返回写入(新增 + 更新)条数。
results 单项取自 pricebot 的 last_coupon_result / done.coupon_results,识别字段:
coupon_id(必需)/ status(必需)/ name / vendor / reason /(display_count)。
(device, coupon_id, 今天) 唯一:重复上报同张券走更新(status 以最后一次为准)。
"""
today = today_cn()
written = 0
seen: set[str] = set() # 同批去重防御:autoflush=False 下同 coupon_id 重复会两次 add → 撞唯一约束回滚整批
for r in results:
coupon_id = r.get("coupon_id")
status = r.get("status")
if not coupon_id or not status or coupon_id in seen:
continue # 脏数据 / 同批重复跳过
seen.add(coupon_id)
count = r.get("display_count")
if count is None:
count = r.get("claimed_count")
row = db.execute(
select(CouponClaimRecord).where(
CouponClaimRecord.device_id == device_id,
CouponClaimRecord.coupon_id == coupon_id,
CouponClaimRecord.claim_date == today,
)
).scalar_one_or_none()
if row is not None:
row.status = status
row.reason = r.get("reason")
if user_id is not None:
row.user_id = user_id
if count is not None:
row.claimed_count = count
row.extra = r
else:
db.add(CouponClaimRecord(
device_id=device_id, user_id=user_id,
coupon_id=coupon_id, claim_date=today,
status=status, vendor=r.get("vendor"), coupon_name=r.get("name"),
claimed_count=count, trace_id=trace_id, reason=r.get("reason"),
extra=r,
))
written += 1
if written == 0:
return 0
try:
db.commit()
except IntegrityError:
db.rollback()
logger.warning(
"coupon_claim 并发幂等冲突 device=%s trace=%s,回滚", device_id, trace_id
)
return 0
return written
-119
View File
@@ -21,7 +21,6 @@ from sqlalchemy.orm import Session
from app.core import rewards
from app.models.invite import InviteRelation
from app.models.invite_fingerprint import InviteFingerprint
from app.models.user import User
from app.repositories import wallet as crud_wallet
@@ -165,121 +164,3 @@ def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
.where(InviteRelation.inviter_user_id == inviter_id)
).scalar_one()
return int(count), int(coins)
def _mask_phone(phone: str) -> str:
"""手机号脱敏:138****8888。前端拿不到完整号,展示被邀请人时在此兜底名字。
11 位标准手机号 → 前 3 + **** + 后 4;非标准(异常/截断)→ 只露后 4 位;太短 → "新用户"
"""
p = (phone or "").strip()
if len(p) == 11:
return f"{p[:3]}****{p[-4:]}"
if len(p) >= 4:
return f"****{p[-4:]}"
return "新用户"
def get_invitees(
db: Session, inviter_id: int, *, limit: int = 20, offset: int = 0
) -> tuple[list[dict], int, bool]:
"""查某邀请人的被邀请人列表(按邀请时间倒序, 分页),返回 (items, total, has_more)。
每条 item = {display_name, avatar_url, coins, invited_at}。降级兜底:
名字 = 昵称 → 微信昵称 → 脱敏手机号(很多被邀请人是刚注册新用户、没设资料);
头像 = 用户头像 → 微信头像 → None(前端画默认色块)。
邀请关系表 join 用户表;total 单独 count(分页时算 has_more)。
"""
total = db.execute(
select(func.count())
.select_from(InviteRelation)
.where(InviteRelation.inviter_user_id == inviter_id)
).scalar_one()
rows = db.execute(
select(InviteRelation, User)
.join(User, User.id == InviteRelation.invitee_user_id)
.where(InviteRelation.inviter_user_id == inviter_id)
.order_by(InviteRelation.created_at.desc())
.limit(limit)
.offset(offset)
).all()
items: list[dict] = []
for rel, u in rows:
items.append({
"display_name": u.nickname or u.wechat_nickname or _mask_phone(u.phone),
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
"coins": rel.inviter_coin,
"invited_at": rel.created_at,
})
has_more = offset + len(rows) < int(total)
return items, int(total), has_more
# ===== 指纹归因(剪贴板兜底,见 [[invite-three-tasks]] 任务 3)=====
def record_fingerprint(
db: Session,
*,
inviter_user_id: int,
ip: str,
screen: str,
device_model: str,
user_agent: str,
) -> InviteFingerprint:
"""落地页 dl.html 访问时调用,写一行指纹记录。
后续被邀请人登录、剪贴板没拿到邀请码时,/bind 会按 (ip, screen, device_model) 反查本表
7 天内最近一条匹配 → 拿出 inviter_user_id 撞库。
本函数会 commit;调用方(/landing-track endpoint)在此之前无其它写操作。
"""
fp = InviteFingerprint(
inviter_user_id=inviter_user_id,
ip=ip[:64],
screen=screen[:32],
device_model=device_model[:64],
user_agent=user_agent[:2000] if user_agent else "", # 截一下防异常长 UA
)
db.add(fp)
db.commit()
db.refresh(fp)
return fp
def resolve_inviter_by_fingerprint(
db: Session,
*,
ip: str,
screen: str,
device_model: str,
window_days: int,
) -> User | None:
"""根据指纹反查邀请人(time window 内最近一条匹配)。
匹配规则:(ip, device_model) 相等 + created_at > now - window_days。
screen 字段**只入库不反查**:浏览器算物理像素走 `CSS × devicePixelRatio` 路径、
Android 走 `DisplayMetrics.widthPixels` 真实硬件值,两端浮点 round 必然 ±1 像素漂移
(DPR 不严格是整数)→ 严格匹配注定撞不上。同 device_model 必同 screen(同型号同硬件)
→ screen 是冗余字段,删它不损精度。撞错只有"同 IP 下同型号手机同时被邀请"才会
发生,中国家庭/公司 WiFi 场景概率极低。
"""
if not ip:
return None
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(days=window_days)
fp = db.execute(
select(InviteFingerprint)
.where(
InviteFingerprint.ip == ip,
InviteFingerprint.device_model == device_model,
InviteFingerprint.created_at > cutoff,
)
.order_by(InviteFingerprint.created_at.desc())
.limit(1)
).scalar_one_or_none()
if fp is None:
return None
return db.get(User, fp.inviter_user_id)
-21
View File
@@ -1,21 +0,0 @@
"""领券今日状态端点的收发模型。"""
from __future__ import annotations
from pydantic import BaseModel
class CouponPromptDismissIn(BaseModel):
"""客户端拒绝/关闭领券引导窗的通知体。
server 据此记一条今日 engagement(dismissed)→ 今天这台设备不再弹引导窗。
MVP 不鉴权,按 device_id 判断;user_id 登录态带上就一并记(资产),可空。
"""
device_id: str
user_id: int | None = None
class CouponPromptShouldShowOut(BaseModel):
"""切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。"""
should_show: bool
+3 -51
View File
@@ -1,8 +1,6 @@
"""邀请相关 API 收发模型。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
@@ -13,61 +11,15 @@ class InviteInfoOut(BaseModel):
coins_earned: int # 累计从邀请获得的金币
class LandingTrackIn(BaseModel):
"""落地页 dl.html 访问时上报的指纹(用于剪贴板归因失败时兜底)。"""
ref: str = Field(..., min_length=4, max_length=16, description="邀请码(落地页 ?ref=)")
screen: str = Field("", max_length=32, description="屏幕分辨率,如 1080x2400")
# IP / UA 服务端从 HTTP 头自动拿,无需 JS 上报
class LandingTrackOut(BaseModel):
status: str # ok / invalid_code / no_ip
class FingerprintIn(BaseModel):
"""客户端登录后剪贴板归因失败时上报的设备指纹。
跨端匹配字段:浏览器落地页存的同名字段 == 客户端 Build.MODEL / DisplayMetrics 算的值。
IP 服务端从 HTTP 头拿,不在这里。
"""
screen: str = Field("", max_length=32)
device_model: str = Field("", max_length=64)
class BindInviteIn(BaseModel):
# invite_code: 可选 — 走指纹兜底时为空(channel=fingerprint)
invite_code: str | None = Field(
None, max_length=16, description="邀请码;走指纹兜底时为空"
)
invite_code: str = Field(..., min_length=4, max_length=16, description="邀请人的邀请码")
channel: str = Field(
"clipboard", max_length=16,
description="归因来源:clipboard(首启读剪贴板自动) / manual(手动填) / fingerprint(指纹兜底)",
description="归因来源:clipboard(首启读剪贴板自动) / manual(用户手动填)",
)
# 当 invite_code 为空、channel=fingerprint 时,后端用这组指纹反查
fingerprint: FingerprintIn | None = None
class BindInviteOut(BaseModel):
status: str # success / already_bound / invalid_code / self_invite / not_eligible / fp_not_found
status: str # success / already_bound / invalid_code / self_invite / not_eligible
coins_awarded: int # 本次给当前用户(被邀请人)发的金币
message: str # 给前端直接展示的文案
class InviteeItem(BaseModel):
"""被邀请人列表的一条(邀请页小窗 / 完整列表页共用)。
display_name / avatar_url 的"降级兜底"在后端算好(见 repositories/invite.get_invitees):
名字 = 昵称 → 微信昵称 → 脱敏手机号(前端拿不到完整号,必须后端脱敏);
头像 = 用户头像 → 微信头像 → null(前端拿到 null 画默认色块)。
"""
display_name: str # 已兜底好的显示名(真名/脱敏号)
avatar_url: str | None = None # 头像 URL;null = 前端画默认色块
coins: int # 这次邀请给我(邀请人)发的金币
invited_at: datetime # 邀请绑定时间(前端转"今天/3天前")
class InviteeListOut(BaseModel):
"""GET /invite/invitees 响应:被邀请人列表 + 分页。"""
items: list[InviteeItem]
total: int # 我邀请的总人数(用于"共 N 人")
has_more: bool # 还有下一页吗(完整列表页滚到底加载更多)
-21
View File
@@ -98,27 +98,6 @@
var isAndroid = /Android/i.test(ua);
var ref = new URLSearchParams(location.search).get("ref"); // 邀请码(来自二维码 URL ?ref=)
// 【任务 3】指纹归因兜底:页面加载即上报访问者指纹,后端存 invite_fingerprint 表。
// 当 APK 首启读剪贴板失败(被覆盖)时,客户端会用 (IP+屏幕+UA 解析的手机型号) 反查
// 本表 7 天内最近一条匹配 → 撞库出原邀请人 → 走原 bind 流程。
// 任何失败都 silent(不影响下载主流程);后端 invalid_code/no_ip 也只返 200。
if (ref) {
// screen 报【物理像素】= CSS 像素 × devicePixelRatio,跟 Android dm.widthPixels(物理像素)对齐。
// 不同设备 DPR 不同(常见 2/2.5/3/3.5),CSS 像素直接报会跟客户端不对齐 → 撞不上库。
var _dpr = window.devicePixelRatio || 1;
var _sw = Math.round(screen.width * _dpr);
var _sh = Math.round(screen.height * _dpr);
fetch("/api/v1/invite/landing-track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ref: ref,
screen: _sw + "x" + _sh,
// IP / UA 服务端从 HTTP 头自动拿,无需 JS 上报
}),
}).catch(function () {}); // silent,绝不阻断下载
}
// 把邀请码写进剪贴板,APK 首启时读出来完成归因(deferred deeplink 的关键一步)。
// 浏览器要求:必须在用户点击手势里调用 + HTTPS 下才允许写。
function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底
-3
View File
@@ -34,9 +34,6 @@ dependencies = [
# admin 后台账号密码 hash(用户侧是手机号+验证码登录,不需要密码;admin 才用)
"bcrypt>=4.0.0",
# 邀请指纹归因:解析浏览器 UA 拿手机型号(Build.MODEL),跨端匹配用
"user-agents>=2.2.0",
]
[project.optional-dependencies]
-91
View File
@@ -1,91 +0,0 @@
# -*- coding: utf-8 -*-
"""造测试数据验证邀请列表"真实头像能不能连上"
A 邀请 B1(上传真实头像)/B2(无头像→色块)/B3(设昵称→色块), 然后:
① 打印 A 的邀请码 + /invitees 返回(看 B1 有 avatar_url、B2 空、B3 昵称);
② 直接 HTTP 访问 B1 的头像地址,确认文件能取到(后端静态托管通)。
前端 AsyncImage 实际显示,装机后真机用 A 登录看。
跑: <app-server py> scripts/seed_invitee_avatar_test.py"""
import io
import struct
import sys
import zlib
import httpx
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
BASE = "http://127.0.0.1:8770"
def solid_png(size: int = 96, rgb: tuple = (80, 140, 240)) -> bytes:
"""生成一张纯色 PNG(蓝色 96x96),够真机看清、魔数是合法 PNG。"""
row = b"\x00" + bytes(rgb) * size
raw = row * size
comp = zlib.compress(raw)
def chunk(typ: bytes, data: bytes) -> bytes:
c = typ + data
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
sig = b"\x89PNG\r\n\x1a\n"
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # 8bit, RGB
return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", comp) + chunk(b"IEND", b"")
def login(c: httpx.Client, phone: str) -> str:
c.post(f"{BASE}/api/v1/auth/sms/send", json={"phone": phone})
r = c.post(f"{BASE}/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
r.raise_for_status()
return r.json()["access_token"]
def auth(tok: str) -> dict:
return {"Authorization": f"Bearer {tok}"}
with httpx.Client(timeout=15) as c:
# A = 邀请人(真机用它登录看)
A_PHONE = "13900008001"
a_tok = login(c, A_PHONE)
a_code = c.get(f"{BASE}/api/v1/invite/me", headers=auth(a_tok)).json()["invite_code"]
print(f"邀请人 A: 手机号={A_PHONE} 验证码=123456 邀请码={a_code}")
# B1: 绑 A 码 + 上传真实头像
b1 = login(c, "13900008002")
c.post(f"{BASE}/api/v1/invite/bind", json={"invite_code": a_code}, headers=auth(b1))
r = c.post(
f"{BASE}/api/v1/user/avatar",
headers=auth(b1),
files={"file": ("avatar.png", io.BytesIO(solid_png()), "image/png")},
)
print(f"B1 绑定 + 上传头像 -> HTTP {r.status_code}, avatar_url={r.json().get('avatar_url')!r}")
# B2: 绑 A 码, 不传头像(测色块兜底)
b2 = login(c, "13900008003")
c.post(f"{BASE}/api/v1/invite/bind", json={"invite_code": a_code}, headers=auth(b2))
print("B2 绑定, 无头像(测色块)")
# B3: 绑 A 码 + 设昵称(测昵称优先 + 色块)
b3 = login(c, "13900008004")
c.post(f"{BASE}/api/v1/invite/bind", json={"invite_code": a_code}, headers=auth(b3))
c.patch(f"{BASE}/api/v1/user/profile", json={"nickname": "测试昵称"}, headers=auth(b3))
print("B3 绑定 + 昵称='测试昵称'")
# A 的邀请列表
inv = c.get(f"{BASE}/api/v1/invite/invitees", headers=auth(a_tok)).json()
print(f"\n=== A 的 /invitees (共 {inv['total']} 人) ===")
for it in inv["items"]:
print(f" name={it['display_name']!r} avatar={it['avatar_url']!r} coins={it['coins']} time={it['invited_at']}")
# 关键: 直接 HTTP 取 B1 的头像文件, 确认后端静态托管能 serve(= 前端拿这地址也能加载)
b1_avatar = next((it["avatar_url"] for it in inv["items"] if it["avatar_url"]), None)
print("\n=== 头像文件 HTTP 可访问性(后端静态托管) ===")
if b1_avatar:
rr = c.get(f"{BASE}{b1_avatar}")
ok = rr.status_code == 200 and (rr.headers.get("content-type", "").startswith("image"))
print(f" GET {BASE}{b1_avatar}")
print(f" -> HTTP {rr.status_code}, content-type={rr.headers.get('content-type')}, {len(rr.content)} bytes {'✅ 能取到' if ok else '✗ 取不到'}")
else:
print(" 没有带头像的被邀请人?!")
+1 -218
View File
@@ -1,4 +1,4 @@
"""好友邀请测试:邀请码、绑定、双方发金币、幂等、自邀/无效码屏蔽、指纹兜底归因
"""好友邀请测试:邀请码、绑定、双方发金币、幂等、自邀/无效码屏蔽。
用 sms mock 登录拿 token(同 test_welfare),再跑邀请闭环。
"""
@@ -6,16 +6,12 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from app.core.rewards import (
INVITE_FP_WINDOW_DAYS,
INVITE_INVITEE_COINS,
INVITE_INVITER_COINS,
INVITE_NEW_USER_WINDOW_HOURS,
)
from app.db.session import SessionLocal
from app.models.invite_fingerprint import InviteFingerprint
from app.repositories.user import get_user_by_phone
@@ -162,216 +158,3 @@ def test_old_user_not_eligible(client) -> None:
assert r.json()["coins_awarded"] == 0
assert _coin_balance(client, b) == 0
assert _coin_balance(client, a) == 0
# =====================================================================
# 任务 3:指纹归因(剪贴板兜底,见 brain [[invite-three-tasks]])
# =====================================================================
def test_landing_track_records_fingerprint(client) -> None:
"""落地页 POST /landing-track 成功存指纹(无需鉴权)。"""
a = _login(client, "13800002040")
a_code = _my_code(client, a)
# 浏览器无 token 调
r = client.post(
"/api/v1/invite/landing-track",
json={"ref": a_code, "screen": "1080x2400"},
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "ok"
def test_landing_track_invalid_code(client) -> None:
"""无效邀请码 → invalid_code(silent 返,不影响落地页下载流程)。"""
r = client.post(
"/api/v1/invite/landing-track",
json={"ref": "ZZZZZZ", "screen": "1080x2400"},
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "invalid_code"
def test_bind_by_fingerprint_success(client) -> None:
"""B 落地页存指纹 → B 登录后用指纹兜底绑定成功(模拟剪贴板被覆盖丢失)。"""
a = _login(client, "13800002041")
a_code = _my_code(client, a)
# 1. B 浏览器(无 token)访问落地页 → 存指纹
r1 = client.post(
"/api/v1/invite/landing-track",
json={"ref": a_code, "screen": "1234x5678"}, # 用独特 screen 避免跟其它测试撞
)
assert r1.json()["status"] == "ok"
# 2. B 注册登录(剪贴板被覆盖,没拿到邀请码)
b = _login(client, "13800002042")
# 3. B 不带 invite_code,只带 fingerprint 走兜底
r2 = client.post(
"/api/v1/invite/bind",
json={
"channel": "fingerprint",
"fingerprint": {"screen": "1234x5678", "device_model": ""},
},
headers=_auth(b),
)
assert r2.status_code == 200, r2.text
assert r2.json()["status"] == "success"
# 双方各发金币
assert _coin_balance(client, a) == INVITE_INVITER_COINS
assert _coin_balance(client, b) == INVITE_INVITEE_COINS
def test_bind_by_fingerprint_not_found(client) -> None:
"""没有匹配的指纹记录 → fp_not_found,不发奖。"""
b = _login(client, "13800002043")
r = client.post(
"/api/v1/invite/bind",
json={
"channel": "fingerprint",
"fingerprint": {"screen": "0000x9999", "device_model": "no_such_model"},
},
headers=_auth(b),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "fp_not_found"
assert r.json()["coins_awarded"] == 0
assert _coin_balance(client, b) == 0
def test_bind_by_fingerprint_outside_window(client) -> None:
"""指纹记录超出 INVITE_FP_WINDOW_DAYS 窗口 → fp_not_found。
用独特设备型号隔离:测试环境所有请求 IP 都是 testclient、落地页默认无 UA(解析出的
型号=""),而指纹反查按 (IP, 型号) 匹配 → 会串到别的测试残留的、没过期的 model=""
指纹上(误判 success)。这里给落地页传一个独特 UA(解析出独特型号 OUTWIN9X),反查只可能
命中本测试自己的指纹,"过期就反查不到"才验得准。
"""
a = _login(client, "13800002044")
a_code = _my_code(client, a)
# 落地页存指纹(独特 UA → 独特型号 OUTWIN9X),再手动把 created_at 改到窗口外
unique_screen = "2222x3333"
unique_ua = "Mozilla/5.0 (Linux; Android 13; OUTWIN9X Build/TQ) AppleWebKit/537.36"
r1 = client.post(
"/api/v1/invite/landing-track",
json={"ref": a_code, "screen": unique_screen},
headers={"user-agent": unique_ua},
)
assert r1.json()["status"] == "ok"
with SessionLocal() as db:
fp = db.execute(
select(InviteFingerprint).where(InviteFingerprint.screen == unique_screen)
).scalar_one()
fp.created_at = datetime.now(timezone.utc) - timedelta(days=INVITE_FP_WINDOW_DAYS + 1)
db.commit()
b = _login(client, "13800002045")
r = client.post(
"/api/v1/invite/bind",
json={
"channel": "fingerprint",
"fingerprint": {"screen": unique_screen, "device_model": "OUTWIN9X"},
},
headers=_auth(b),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "fp_not_found"
assert _coin_balance(client, a) == 0
assert _coin_balance(client, b) == 0
def test_bind_no_code_no_fingerprint(client) -> None:
"""既没邀请码也没指纹 → invalid_code(无效请求)。"""
b = _login(client, "13800002046")
r = client.post(
"/api/v1/invite/bind",
json={"channel": "fingerprint"}, # 没 invite_code、没 fingerprint
headers=_auth(b),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "invalid_code"
assert _coin_balance(client, b) == 0
# =====================================================================
# 被邀请人列表 GET /invitees(邀请页小窗 + 完整列表页共用)
# =====================================================================
def test_invitees_basic(client) -> None:
"""A 邀 B、C → 列表返 2 条、total=2、没设资料的名字=脱敏手机号、头像 null、金币对。"""
a = _login(client, "13800002050")
a_code = _my_code(client, a)
for phone in ("13800002051", "13800002052"):
u = _login(client, phone)
client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(u))
r = client.get("/api/v1/invite/invitees", headers=_auth(a))
assert r.status_code == 200, r.text
body = r.json()
assert body["total"] == 2
assert body["has_more"] is False
assert len(body["items"]) == 2
# 没设昵称头像 → 名字脱敏手机号、头像 null(不依赖顺序用 set)
names = {it["display_name"] for it in body["items"]}
assert names == {"138****2051", "138****2052"}
assert all(it["avatar_url"] is None for it in body["items"])
assert all(it["coins"] == INVITE_INVITER_COINS for it in body["items"])
def test_invitees_order_desc(client) -> None:
"""按邀请时间倒序:最近邀请的在最前(手动拉开时间,避开 SQLite 秒级精度)。"""
from app.models.invite import InviteRelation
a = _login(client, "13800002060")
a_code = _my_code(client, a)
b = _login(client, "13800002061")
c = _login(client, "13800002062")
client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(b))
client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(c))
with SessionLocal() as db:
ub = get_user_by_phone(db, "13800002061")
rel_b = db.execute(
select(InviteRelation).where(InviteRelation.invitee_user_id == ub.id)
).scalar_one()
rel_b.created_at = datetime.now(timezone.utc) - timedelta(hours=2)
db.commit()
items = client.get("/api/v1/invite/invitees", headers=_auth(a)).json()["items"]
assert items[0]["display_name"] == "138****2062" # C 刚邀,在前
assert items[1]["display_name"] == "138****2061" # B 2 小时前,在后
def test_invitees_nickname_priority(client) -> None:
"""设了昵称的被邀请人 → 名字用昵称(优先于脱敏手机号)。"""
a = _login(client, "13800002070")
a_code = _my_code(client, a)
b = _login(client, "13800002071")
client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(b))
with SessionLocal() as db:
u = get_user_by_phone(db, "13800002071")
u.nickname = "小明"
db.commit()
items = client.get("/api/v1/invite/invitees", headers=_auth(a)).json()["items"]
assert items[0]["display_name"] == "小明"
def test_invitees_pagination(client) -> None:
"""分页:limit=1 → 1 条 + has_more=True;offset=1 → 第 2 条 + has_more=False。"""
a = _login(client, "13800002080")
a_code = _my_code(client, a)
for phone in ("13800002081", "13800002082"):
u = _login(client, phone)
client.post("/api/v1/invite/bind", json={"invite_code": a_code}, headers=_auth(u))
p1 = client.get("/api/v1/invite/invitees?limit=1&offset=0", headers=_auth(a)).json()
assert len(p1["items"]) == 1 and p1["total"] == 2 and p1["has_more"] is True
p2 = client.get("/api/v1/invite/invitees?limit=1&offset=1", headers=_auth(a)).json()
assert len(p2["items"]) == 1 and p2["has_more"] is False
def test_invitees_empty_and_auth(client) -> None:
"""没邀请人 → 空列表;不带 token → 401。"""
a = _login(client, "13800002090")
body = client.get("/api/v1/invite/invitees", headers=_auth(a)).json()
assert body["total"] == 0 and body["items"] == [] and body["has_more"] is False
assert client.get("/api/v1/invite/invitees").status_code == 401