# 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 ``` 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`,不实时扫关系或充值流水 |