24 KiB
Registration Invite Code Architecture
本文定义 App 注册邀请码能力。用户注册时可以选填邀请码;如果该邀请码属于某个有效用户,则该用户成为新用户的邀请人。邀请码归因是用户关系事实,不能只把字符串放在 users.invite_code 里。
Goals
- 每个用户有一个可展示、可复制的永久邀请码。
- 新用户注册或完成资料时可以选填邀请码。
- 非空邀请码必须能解析到同一
app_code、同一region_id下的有效邀请人。 - 邀请关系一旦绑定不可被客户端修改。
- 邀请关系必须落 MySQL;Redis 只能做短 TTL 查询缓存。
- 邀请统计必须拆成
invite_count和valid_invite_count:前者表示成功填码绑定关系的人数,后者表示被邀请人累计充值达到后台配置门槛的人数。 - 后续奖励、活动、风控和运营统计都从邀请关系事实消费,不反推历史字符串。
Non-Goals
- 首版不在注册链路内同步发奖励。
- 首版不支持用户自己修改邀请码。
- 首版不支持跨 App 邀请。
- 首版不把邀请码等同于 Agency、BD、币商或主播组织关系。
- 首版不做邀请排行榜;只保存事实和提供轻量查询。
- 首版不在注册链路内同步判断有效邀请;有效邀请由钱包充值事实异步推进。
Ownership
| Capability | Owner | Rule |
|---|---|---|
| 邀请码生成 | user-service |
新用户创建时生成自己的邀请码 |
| 邀请码解析 | user-service |
注册事务内按 app_code + code 查询有效 code |
| 邀请关系绑定 | user-service |
一个被邀请用户最多一条关系 |
| 外部 HTTP | gateway-service |
转发 invite_code,不自行解析归属 |
| 邀请人数统计 | user-service invite module |
invite_count 来自 user_invite_relations 绑定事实 |
| 有效邀请判定 | user-service invite module + wallet-service recharge event |
钱包只产出充值事实,邀请模块按后台策略累计并标记有效 |
| 我的页展示 | gateway-service + user-service |
overview 返回当前用户自己的邀请码、邀请人数和有效邀请人数 |
| 奖励/通知 | 后续活动或钱包模块 | 通过 outbox/event 异步消费,不阻塞注册 |
Product Rules
Code Ownership
- 邀请码属于用户,不属于设备、session、三方账号或 Agency。
- 邀请码按
app_code + region_id隔离;lalu的邀请码不能邀请popoo用户,不同区域用户也不能互相绑定邀请关系。 - 用户被禁用或封禁后,默认不允许继续作为新邀请人的有效 code owner;历史邀请关系不删除。
- 用户自己的邀请码创建后长期稳定。后续如果允许换码,必须保留旧码和历史关系快照。
Binding Rules
| Case | Result |
|---|---|
| 注册不填邀请码 | 创建用户,不创建邀请关系 |
| 注册填写有效邀请码 | 创建用户,并绑定 inviter_user_id |
| 注册填写不存在、停用、跨 App、跨区域或 owner 非 active 的邀请码 | 返回 INVALID_INVITE_CODE,不静默忽略 |
| 用户填写自己的邀请码 | 返回 INVALID_INVITE_CODE |
| 已有邀请关系的用户再次提交邀请码 | 不修改原关系 |
| 已完成注册用户提交邀请码 | 不修改原关系;如果通过 onboarding 入口提交,按已完成资料逻辑不处理邀请码 |
| 已存在三方身份再次登录并带新邀请码 | 不创建、不修改邀请关系 |
非空邀请码失败时选择“失败注册”而不是“忽略邀请码”,原因是用户和渠道都需要明确知道归因没有成功,避免后续奖励和申诉没有事实依据。
Count Rules
邀请统计必须拆成两个指标:
| Metric | Meaning | Source | Update Timing |
|---|---|---|---|
invite_count |
邀请人数,表示有多少用户成功填写邀请码并绑定到当前邀请人 | user_invite_relations |
绑定关系成功后增加 |
valid_invite_count |
有效邀请人数,表示有多少被邀请用户累计成功充值金币达到策略门槛 | user_invite_recharge_progress.status=valid |
钱包充值事件累计达到门槛后增加 |
规则:
invite_count只看填码绑定事实,不要求被邀请人充值、消费、上麦或进入房间。valid_invite_count默认门槛是被邀请人累计成功充值80000COIN,门槛必须由后台策略配置,不能写死在代码里。- 有效邀请只统计钱包标记为“充值”的
COIN账务事实;送礼收入、奖励、后台普通调账、资源赠送和当前余额变化都不能直接计入。 - 币商给玩家转金币如果已经由
wallet-service.wallet_recharge_records记录为用户充值,可以计入;是否计入 Apple/Google、线下人工充值、币商转账等类型由策略的eligible_recharge_types控制。 - 同一个被邀请用户只能让邀请人成为一次有效邀请;充值超过门槛后不重复增加。
- 有效状态一旦达成,首版不因后续消费减少而回退;如果后续接入退款、拒付或作弊处理,需要后台风控撤销或新增负向事件重算。
- 邀请人后续被封禁不删除历史关系和历史有效事实,但可以让其邀请码失效,避免继续产生新关系。
App Flow
首版兼容两种客户端采集时机:
- 客户端在三方登录前已经拿到邀请码:提交到
POST /api/v1/auth/third-party/login。 - 客户端在资料补全页让用户输入邀请码:提交到
POST /api/v1/users/me/onboarding/complete。
推荐 App 把邀请码放在资料补全页,因为当前架构是“三方登录即创建未完成用户”,注册页用户资料都在 CompleteOnboarding 原子提交。
sequenceDiagram
participant C as Client
participant G as gateway-service
participant U as user-service
participant DB as hyapp_user
C->>G: POST /auth/third-party/login(provider, credential, optional invite_code)
G->>U: LoginThirdParty
U->>U: verify provider credential
alt new third-party identity
U->>DB: create users + own user_invite_codes
opt invite_code present
U->>DB: resolve invite code and insert user_invite_relations
end
U->>DB: create third_party_identity + auth_session
else existing identity
U->>DB: create auth_session only
end
U-->>G: token(profile_completed=false/true)
C->>G: POST /users/me/onboarding/complete(username, avatar, country, optional invite_code)
G->>U: CompleteOnboarding
U->>DB: lock users row
opt no existing invite relation and invite_code present
U->>DB: resolve invite code and insert user_invite_relations
end
U->>DB: update profile_completed=true
U-->>G: new access_token(profile_completed=true)
HTTP Contract
Login Or Register
POST /api/v1/auth/third-party/login
invite_code 仍然是可选字段。只有本次请求首次创建用户时,该字段才可能绑定邀请关系;已有用户登录时忽略该字段,不允许通过重新登录改归因。
Complete Onboarding
POST /api/v1/users/me/onboarding/complete
Authorization: Bearer <access_token>
Request:
{
"username": "Alice",
"avatar": "https://cdn.example/avatar.png",
"gender": "female",
"country": "SG",
"invite_code": "A1B2C3"
}
Response:
{
"user_id": "918274650129384448",
"profile_completed": true,
"invite": {
"bound": true,
"invite_code": "A1B2C3",
"inviter_user_id": "10001"
},
"token": {
"access_token": "jwt_xxx",
"profile_completed": true
}
}
invite 可以首版只返回 bound,但服务端必须在库里保存 inviter_user_id。客户端不能根据返回内容自行计算邀请关系。
My Invite Code
首屏可以直接跟随 GET /api/v1/users/me/overview 返回:
{
"invite": {
"my_invite_code": "A1B2C3",
"invite_enabled": true,
"invite_count": 12,
"valid_invite_count": 3,
"valid_invite_threshold_coin": 80000
}
}
invite_count 和 valid_invite_count 必须来自预计算 read model。首版没有 read model 时返回 0 或不返回,不能为了首页展示实时 COUNT(*) 扫邀请关系或充值流水。invited_count 这个旧命名语义不清,后续对外接口应收敛为 invite_count。
Data Model
user_invite_codes
CREATE TABLE user_invite_codes (
app_code VARCHAR(32) NOT NULL,
invite_code_id BIGINT NOT NULL,
code VARCHAR(64) NOT NULL,
owner_user_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
is_primary TINYINT(1) NOT NULL DEFAULT 1,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, invite_code_id),
UNIQUE KEY uk_user_invite_codes_code (app_code, code),
KEY idx_user_invite_codes_owner (app_code, owner_user_id, status)
);
字段规则:
code入库前 trim、转大写,只允许A-Z0-9,长度建议 6 到 12。status首版只需要active、disabled。- 一个 owner 首版只创建一个 primary code。MySQL 无法跨状态表达复杂唯一时,先通过事务锁用户行和查询保证;后续可加 generated column 做 active primary 唯一。
- code 不使用
users.current_display_user_id,避免靓号、短号过期或短号运营变更影响邀请关系。
user_invite_relations
CREATE TABLE user_invite_relations (
app_code VARCHAR(32) NOT NULL,
invited_user_id BIGINT NOT NULL,
inviter_user_id BIGINT NOT NULL,
invite_code_id BIGINT NOT NULL,
invite_code VARCHAR(64) NOT NULL,
source VARCHAR(32) NOT NULL,
bound_at_ms BIGINT NOT NULL,
request_id VARCHAR(96) NOT NULL,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, invited_user_id),
KEY idx_user_invite_relations_inviter (app_code, inviter_user_id, bound_at_ms),
KEY idx_user_invite_relations_code (app_code, invite_code, bound_at_ms)
);
字段规则:
invited_user_id主键保证一个用户只能被归因一次。invite_code是绑定时的快照;即使后续 code 被禁用或换码,历史关系仍可解释。source首版可取login或onboarding。- 历史关系不因为邀请人被封禁、改资料或换码而删除。
user_invite_validity_policies
有效邀请门槛必须由后台配置。默认策略是累计成功充值 80000 COIN 后成为有效邀请。
CREATE TABLE user_invite_validity_policies (
app_code VARCHAR(32) NOT NULL,
policy_id BIGINT NOT NULL,
policy_version VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
region_id BIGINT NOT NULL DEFAULT 0,
required_recharge_coin_amount BIGINT NOT NULL,
eligible_recharge_types JSON NOT NULL,
effective_from_ms BIGINT NOT NULL,
effective_to_ms BIGINT NULL,
created_by_user_id BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, policy_id),
UNIQUE KEY uk_user_invite_validity_policy_version (app_code, policy_version),
KEY idx_user_invite_validity_policy_active (app_code, region_id, status, effective_from_ms)
);
字段规则:
required_recharge_coin_amount默认配置为80000,单位是钱包COIN最小整数单位。region_id=0表示全局默认策略;有区域策略时按被邀请用户注册/当前业务区域选择,具体选择规则必须写入策略说明。eligible_recharge_types控制哪些充值类型可计入有效邀请,例如provider_iap、coin_seller_transfer、manual_recharge。奖励、礼物收入、活动赠送和普通调账默认不计入。- 策略修改只能影响新达成有效邀请的判断;已达成的
valid事实不自动重算。
user_invite_recharge_progress
该表保存每个被邀请用户距离有效邀请的累计进度。它不是钱包余额来源,只消费钱包充值事实。
CREATE TABLE user_invite_recharge_progress (
app_code VARCHAR(32) NOT NULL,
invited_user_id BIGINT NOT NULL,
inviter_user_id BIGINT NOT NULL,
policy_id BIGINT NOT NULL,
policy_version VARCHAR(64) NOT NULL,
required_recharge_coin_amount BIGINT NOT NULL,
accumulated_recharge_coin_amount BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
valid_at_ms BIGINT NULL,
valid_transaction_id VARCHAR(96) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, invited_user_id),
KEY idx_user_invite_progress_inviter_status (app_code, inviter_user_id, status, valid_at_ms),
KEY idx_user_invite_progress_updated (app_code, status, updated_at_ms)
);
字段规则:
status首版使用pending和valid。accumulated_recharge_coin_amount只累计符合策略的成功充值金币数量。valid_transaction_id保存让累计金额首次达到门槛的钱包交易 ID,便于审计。- 同一个
invited_user_id只能有一条进度;达到valid后不再重复增加valid_invite_count。
user_invite_event_consumption
钱包充值事件可能重投,邀请模块必须记录消费幂等。
CREATE TABLE user_invite_event_consumption (
app_code VARCHAR(32) NOT NULL,
source VARCHAR(64) NOT NULL,
event_id VARCHAR(128) NOT NULL,
transaction_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
error_message VARCHAR(512) NOT NULL DEFAULT '',
consumed_at_ms BIGINT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, source, event_id),
KEY idx_user_invite_event_tx (app_code, transaction_id),
KEY idx_user_invite_event_status (app_code, status, updated_at_ms)
);
同一 transaction_id 如果通过不同事件重复到达,也不能重复累计;实现时应在同一事务中检查 event_id 和 transaction_id 的已消费状态。
user_invite_counters
我的页和后台列表不能实时扫关系表或钱包流水,应读取计数 read model。
CREATE TABLE user_invite_counters (
app_code VARCHAR(32) NOT NULL,
inviter_user_id BIGINT NOT NULL,
invite_count BIGINT NOT NULL DEFAULT 0,
valid_invite_count BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, inviter_user_id)
);
字段规则:
invite_count由UserInvited事实推进。valid_invite_count由UserInviteBecameValid事实推进。- 计数写入必须幂等;重复消费同一事件不能重复增加。
- 如果计数异常,允许后台重算:
invite_count从user_invite_relations汇总,valid_invite_count从user_invite_recharge_progress.status='valid'汇总。
users.invite_code
现有 users.invite_code 只保留“注册时提交的邀请码字符串快照”。它不能作为权威邀请关系来源,因为它没有 inviter_user_id,也无法处理 code 换绑、停用、跨 App 和历史审计。
新代码应优先读取 user_invite_relations;users.invite_code 可以继续保留用于兼容和排障。
Transaction Rules
新用户创建事务内建议顺序:
- 校验 provider credential。
- 锁定或分配
user_id和默认短号。 - 创建
users。 - 创建用户自己的
user_invite_codes。 - 如果请求带
invite_code,解析并锁定 code owner 的users行。 - 校验 owner active、非自己、同
app_code、同region_id。 - 插入
user_invite_relations。 - 创建
third_party_identities和auth_sessions。 - 写
UserRegistered/UserInvitedoutbox 事件。
CompleteOnboarding 绑定邀请码时:
- 锁当前
users行。 - 如果
profile_completed=true,不允许再绑定邀请码。 - 如果已有
user_invite_relations,不修改原关系。 - 如果请求带非空
invite_code,解析 code,锁 inviter 用户行,插入关系。 - 更新资料、国家、区域和
profile_completed。 - 返回新的 access token。
关系插入和资料完成必须在同一事务内提交,避免用户看到注册完成但邀请关系丢失。
邀请关系绑定成功后,同事务或同一 outbox 消费链路需要初始化 user_invite_recharge_progress(status=pending) 并幂等增加 user_invite_counters.invite_count。如果计数更新失败,关系事实不能回滚;必须通过 UserInvited outbox 重试补偿计数。
Effective Invite Flow
有效邀请不在注册链路内计算。它由钱包充值事实异步推进:
sequenceDiagram
participant W as wallet-service
participant WO as wallet_outbox
participant C as invite consumer
participant U as user-service invite module
participant DB as hyapp_user
W->>W: commit successful COIN recharge
W->>WO: WalletRechargeRecorded(user_id, coin_amount, recharge_type)
C->>WO: consume recharge event
C->>U: ApplyInviteRecharge(user_id, transaction_id, coin_amount)
U->>DB: find user_invite_relations by invited_user_id
alt relation exists and recharge_type eligible
U->>DB: lock user_invite_recharge_progress
U->>DB: accumulate recharge coin amount
opt first time accumulated >= required threshold
U->>DB: mark status=valid and valid_at_ms
U->>DB: increment user_invite_counters.valid_invite_count
U->>DB: write UserInviteBecameValid outbox
end
else no relation or ineligible recharge
U-->>C: idempotent skipped
end
处理规则:
- 钱包只负责产出充值事实,不理解邀请关系,也不直接增加邀请统计。
- 邀请模块按
app_code + transaction_id对充值事件做幂等消费,同一充值事件不能重复累计。 - 被邀请用户未绑定邀请关系时,充值事件跳过,不创建关系。
- 充值事件早于邀请关系绑定时不应补算,除非后台明确执行重算任务;正常链路要求先填邀请码、后充值才算有效邀请。
- 达到门槛的判断使用累计充值金币数量,不使用当前
COIN余额;用户充值后消费完仍然可以成为有效邀请。 - 如果后续接入退款/拒付事件,需要新增负向充值处理策略;首版只处理正向成功充值。
Code Generation
邀请码生成建议:
- 使用服务端随机生成,不让客户端提交自己的 code。
- 字符集使用
ABCDEFGHJKLMNPQRSTUVWXYZ23456789,去掉容易混淆的0/O/1/I。 - 首版 8 位即可;如果发生唯一冲突,最多重试 5 次,仍失败返回内部错误并告警。
- 不把手机号、邮箱、设备号、短号直接作为邀请码。
Caching
注册量没有明显压力前,邀请码解析直接查 MySQL。
如果后续加入 Redis:
| Key | Value | TTL | Rule |
|---|---|---|---|
invite_code:{app_code}:{code} |
owner_user_id,status,version |
5-30 min | 只做加速 |
Redis miss 或异常时必须回 MySQL 或 fail-closed,不能把不存在当有效。禁用邀请码、封禁用户或换码时必须主动删除缓存;不能只依赖 TTL。
Outbox And Rewards
注册链路只写事实,不同步发奖励。后续奖励通过事件消费:
UserRegisteredUserInvitedUserInviteBecameValid
UserInvited payload 至少包含:
{
"app_code": "lalu",
"invited_user_id": "20001",
"inviter_user_id": "10001",
"invite_code": "A1B2C3",
"source": "onboarding",
"bound_at_ms": 1777996800000
}
UserInviteBecameValid payload 至少包含:
{
"app_code": "lalu",
"invited_user_id": "20001",
"inviter_user_id": "10001",
"policy_id": "9001",
"policy_version": "invite-valid-v1",
"required_recharge_coin_amount": 80000,
"accumulated_recharge_coin_amount": 80000,
"valid_transaction_id": "wtx_01",
"valid_at_ms": 1777996800000
}
奖励消费者必须幂等,幂等键使用 app_code + invited_user_id + reward_type,不能因为 outbox 重投重复发放。需要按有效邀请发奖励时,只消费 UserInviteBecameValid,不要消费原始 UserInvited。
Risk Controls
- gateway 对带邀请码的注册请求继续使用 public auth 频控。
- user-service 记录
device_id、IP、UA、country_by_ip,用于后续识别同设备互邀、批量注册和异常渠道。 - 自己邀请自己必须拒绝。
- 同设备大量互邀、同 IP 大量注册、邀请人短时间异常增长不阻断注册主链路,但要进入风控统计或后台审核。
- 有效邀请达成前必须保留充值事件来源、充值类型和触发交易 ID,便于识别币商刷量、同设备互充、异常退款和人工充值滥用。
- 后台可以冻结某个邀请人的新邀请资格或奖励资格,但不能删除历史
user_invite_relations。 - 邀请码解析失败不要返回邀请人是否存在的细节;统一
INVALID_INVITE_CODE。
Admin Requirements
后台需要提供最小管理能力:
- 查询用户自己的邀请码、邀请人数、有效邀请人数、被邀请用户列表和有效达成明细。
- 启用/禁用某个用户的邀请码;禁用只影响新绑定,不删除历史关系。
- 配置有效邀请策略:门槛金币数、适用区域、可计入充值类型、生效时间和停用时间。
- 查看某个被邀请用户的充值累计进度和触发有效邀请的钱包交易 ID。
- 标记异常邀请或撤销奖励资格;撤销奖励资格不能物理删除邀请关系和有效达成记录。
- 触发计数重算任务,用于修复
user_invite_counters与事实表不一致。
所有后台写操作必须经过 hyapp-admin-server 鉴权和审计,不能让 App 用户直接调用。
Implementation Sequence
- 增加
user_invite_codes和user_invite_relations表。 - 增加
user_invite_validity_policies、user_invite_recharge_progress、user_invite_event_consumption、user_invite_counters和user_outbox。 - 后台增加有效邀请策略配置,默认
required_recharge_coin_amount=80000。 - user-service 新用户创建时生成自己的邀请码。
LoginThirdParty首次创建用户时解析可选invite_code并绑定关系。CompleteOnboarding增加可选invite_code,仅在未完成资料且未绑定关系时处理。- 绑定成功后写
UserInvited,并通过 outbox 幂等维护invite_count。 - 消费钱包
WalletRechargeRecorded事件,累计被邀请用户充值金币,达到策略门槛后写UserInviteBecameValid并维护valid_invite_count。 GetUser或GetUserOverview增加当前用户的邀请码、邀请人数、有效邀请人数和当前有效门槛。- gateway HTTP request/response 增加字段映射。
- 加测试和基础监控。
Test Matrix
| Scenario | Expected |
|---|---|
| 新用户不填邀请码 | 注册成功,无 user_invite_relations |
| 新用户填有效邀请码 | 注册成功,关系表写入 inviter |
| 新用户填不存在邀请码 | 返回 INVALID_INVITE_CODE,不创建完成关系 |
| 邀请码跨 App | 返回 INVALID_INVITE_CODE |
| 邀请码跨区域 | 返回 INVALID_INVITE_CODE |
| 邀请人 disabled/banned | 返回 INVALID_INVITE_CODE |
| 用户尝试填自己的邀请码 | 返回 INVALID_INVITE_CODE |
| 已绑定关系后再次提交邀请码 | 原关系不变 |
| 已完成注册后提交邀请码 | 不修改原关系,不补填邀请人 |
| 三方身份再次登录带邀请码 | 登录成功但不修改邀请关系 |
| 并发提交两个邀请码 | 只有一条关系成功,另一条命中幂等或冲突 |
| 邀请码大小写/空格 | trim + uppercase 后解析 |
| 被邀请用户累计充值 79999 COIN | invite_count 已增加,valid_invite_count 不增加 |
| 被邀请用户累计充值达到 80000 COIN | 首次标记 user_invite_recharge_progress.status=valid,valid_invite_count 增加 1 |
| 被邀请用户一次充值超过门槛 | 只产生 1 个有效邀请 |
| 被邀请用户多次充值累计达到门槛 | 达标那笔交易触发有效邀请 |
| 同一充值事件重复消费 | 累计金额和有效邀请计数不重复增加 |
| 充值发生在绑定邀请码之前 | 默认不补算有效邀请,除非后台重算任务明确处理 |
| 后台把有效邀请门槛改为 100000 | 已经 valid 的历史事实不重算,新达成用户按新策略判断 |
| 我的页 overview | 返回当前用户自己的邀请码、invite_count、valid_invite_count,不实时扫关系或充值流水 |