539 lines
24 KiB
Markdown
539 lines
24 KiB
Markdown
# Registration Invite Code Architecture
|
||
|
||
本文定义 App 注册邀请码能力。用户注册时可以选填邀请码;如果该邀请码属于某个有效用户,则该用户成为新用户的邀请人。邀请码归因是用户关系事实,不能只把字符串放在 `users.invite_code` 里。
|
||
|
||
## Goals
|
||
|
||
- 每个用户有一个可展示、可复制的永久邀请码。
|
||
- 新用户注册或完成资料时可以选填邀请码。
|
||
- 非空邀请码必须能解析到同一 `app_code` 下的有效邀请人。
|
||
- 邀请关系一旦绑定不可被客户端修改。
|
||
- 邀请关系必须落 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` 隔离;`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` 默认门槛是被邀请人累计成功充值 `80000` `COIN`,门槛必须由后台策略配置,不能写死在代码里。
|
||
- 有效邀请只统计钱包标记为“充值”的 `COIN` 账务事实;送礼收入、奖励、后台普通调账、资源赠送和当前余额变化都不能直接计入。
|
||
- 币商给玩家转金币如果已经由 `wallet-service.wallet_recharge_records` 记录为用户充值,可以计入;是否计入 Apple/Google、线下人工充值、币商转账等类型由策略的 `eligible_recharge_types` 控制。
|
||
- 同一个被邀请用户只能让邀请人成为一次有效邀请;充值超过门槛后不重复增加。
|
||
- 有效状态一旦达成,首版不因后续消费减少而回退;如果后续接入退款、拒付或作弊处理,需要后台风控撤销或新增负向事件重算。
|
||
- 邀请人后续被封禁不删除历史关系和历史有效事实,但可以让其邀请码失效,避免继续产生新关系。
|
||
|
||
## App Flow
|
||
|
||
首版兼容两种客户端采集时机:
|
||
|
||
1. 客户端在三方登录前已经拿到邀请码:提交到 `POST /api/v1/auth/third-party/login`。
|
||
2. 客户端在资料补全页让用户输入邀请码:提交到 `POST /api/v1/users/me/onboarding/complete`。
|
||
|
||
推荐 App 把邀请码放在资料补全页,因为当前架构是“三方登录即创建未完成用户”,注册页用户资料都在 `CompleteOnboarding` 原子提交。
|
||
|
||
```mermaid
|
||
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
|
||
|
||
```http
|
||
POST /api/v1/auth/third-party/login
|
||
```
|
||
|
||
`invite_code` 仍然是可选字段。只有本次请求首次创建用户时,该字段才可能绑定邀请关系;已有用户登录时忽略该字段,不允许通过重新登录改归因。
|
||
|
||
### Complete Onboarding
|
||
|
||
```http
|
||
POST /api/v1/users/me/onboarding/complete
|
||
Authorization: Bearer <access_token>
|
||
```
|
||
|
||
Request:
|
||
|
||
```json
|
||
{
|
||
"username": "Alice",
|
||
"avatar": "https://cdn.example/avatar.png",
|
||
"gender": "female",
|
||
"country": "SG",
|
||
"invite_code": "A1B2C3"
|
||
}
|
||
```
|
||
|
||
Response:
|
||
|
||
```json
|
||
{
|
||
"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` 返回:
|
||
|
||
```json
|
||
{
|
||
"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`
|
||
|
||
```sql
|
||
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`
|
||
|
||
```sql
|
||
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` 后成为有效邀请。
|
||
|
||
```sql
|
||
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`
|
||
|
||
该表保存每个被邀请用户距离有效邀请的累计进度。它不是钱包余额来源,只消费钱包充值事实。
|
||
|
||
```sql
|
||
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`
|
||
|
||
钱包充值事件可能重投,邀请模块必须记录消费幂等。
|
||
|
||
```sql
|
||
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。
|
||
|
||
```sql
|
||
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
|
||
|
||
新用户创建事务内建议顺序:
|
||
|
||
1. 校验 provider credential。
|
||
2. 锁定或分配 `user_id` 和默认短号。
|
||
3. 创建 `users`。
|
||
4. 创建用户自己的 `user_invite_codes`。
|
||
5. 如果请求带 `invite_code`,解析并锁定 code owner 的 `users` 行。
|
||
6. 校验 owner active、非自己、同 `app_code`。
|
||
7. 插入 `user_invite_relations`。
|
||
8. 创建 `third_party_identities` 和 `auth_sessions`。
|
||
9. 写 `UserRegistered` / `UserInvited` outbox 事件。
|
||
|
||
`CompleteOnboarding` 绑定邀请码时:
|
||
|
||
1. 锁当前 `users` 行。
|
||
2. 如果 `profile_completed=true`,不允许再绑定邀请码。
|
||
3. 如果已有 `user_invite_relations`,不修改原关系。
|
||
4. 如果请求带非空 `invite_code`,解析 code,锁 inviter 用户行,插入关系。
|
||
5. 更新资料、国家、区域和 `profile_completed`。
|
||
6. 返回新的 access token。
|
||
|
||
关系插入和资料完成必须在同一事务内提交,避免用户看到注册完成但邀请关系丢失。
|
||
|
||
邀请关系绑定成功后,同事务或同一 outbox 消费链路需要初始化 `user_invite_recharge_progress(status=pending)` 并幂等增加 `user_invite_counters.invite_count`。如果计数更新失败,关系事实不能回滚;必须通过 `UserInvited` outbox 重试补偿计数。
|
||
|
||
## Effective Invite Flow
|
||
|
||
有效邀请不在注册链路内计算。它由钱包充值事实异步推进:
|
||
|
||
```mermaid
|
||
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
|
||
|
||
注册链路只写事实,不同步发奖励。后续奖励通过事件消费:
|
||
|
||
- `UserRegistered`
|
||
- `UserInvited`
|
||
- `UserInviteBecameValid`
|
||
|
||
`UserInvited` payload 至少包含:
|
||
|
||
```json
|
||
{
|
||
"app_code": "lalu",
|
||
"invited_user_id": "20001",
|
||
"inviter_user_id": "10001",
|
||
"invite_code": "A1B2C3",
|
||
"source": "onboarding",
|
||
"bound_at_ms": 1777996800000
|
||
}
|
||
```
|
||
|
||
`UserInviteBecameValid` payload 至少包含:
|
||
|
||
```json
|
||
{
|
||
"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
|
||
|
||
1. 增加 `user_invite_codes` 和 `user_invite_relations` 表。
|
||
2. 增加 `user_invite_validity_policies`、`user_invite_recharge_progress`、`user_invite_event_consumption`、`user_invite_counters` 和 `user_outbox`。
|
||
3. 后台增加有效邀请策略配置,默认 `required_recharge_coin_amount=80000`。
|
||
4. user-service 新用户创建时生成自己的邀请码。
|
||
5. `LoginThirdParty` 首次创建用户时解析可选 `invite_code` 并绑定关系。
|
||
6. `CompleteOnboarding` 增加可选 `invite_code`,仅在未完成资料且未绑定关系时处理。
|
||
7. 绑定成功后写 `UserInvited`,并通过 outbox 幂等维护 `invite_count`。
|
||
8. 消费钱包 `WalletRechargeRecorded` 事件,累计被邀请用户充值金币,达到策略门槛后写 `UserInviteBecameValid` 并维护 `valid_invite_count`。
|
||
9. `GetUser` 或 `GetUserOverview` 增加当前用户的邀请码、邀请人数、有效邀请人数和当前有效门槛。
|
||
10. gateway HTTP request/response 增加字段映射。
|
||
11. 加测试和基础监控。
|
||
|
||
## Test Matrix
|
||
|
||
| Scenario | Expected |
|
||
| --- | --- |
|
||
| 新用户不填邀请码 | 注册成功,无 `user_invite_relations` |
|
||
| 新用户填有效邀请码 | 注册成功,关系表写入 inviter |
|
||
| 新用户填不存在邀请码 | 返回 `INVALID_INVITE_CODE`,不创建完成关系 |
|
||
| 邀请码跨 App | 返回 `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`,不实时扫关系或充值流水 |
|