hyapp-server/docs/注册邀请码架构.md

24 KiB
Raw Blame History

Registration Invite Code Architecture

本文定义 App 注册邀请码能力。用户注册时可以选填邀请码;如果该邀请码属于某个有效用户,则该用户成为新用户的邀请人。邀请码归因是用户关系事实,不能只把字符串放在 users.invite_code 里。

Goals

  • 每个用户有一个可展示、可复制的永久邀请码。
  • 新用户注册或完成资料时可以选填邀请码。
  • 非空邀请码必须能解析到同一 app_code、同一 region_id 下的有效邀请人。
  • 邀请关系一旦绑定不可被客户端修改。
  • 邀请关系必须落 MySQLRedis 只能做短 TTL 查询缓存。
  • 邀请统计必须拆成 invite_countvalid_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 默认门槛是被邀请人累计成功充值 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 原子提交。

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_countvalid_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 首版只需要 activedisabled
  • 一个 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 首版可取 loginonboarding
  • 历史关系不因为邀请人被封禁、改资料或换码而删除。

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_iapcoin_seller_transfermanual_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 首版使用 pendingvalid
  • 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_idtransaction_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_countUserInvited 事实推进。
  • valid_invite_countUserInviteBecameValid 事实推进。
  • 计数写入必须幂等;重复消费同一事件不能重复增加。
  • 如果计数异常,允许后台重算:invite_countuser_invite_relations 汇总,valid_invite_countuser_invite_recharge_progress.status='valid' 汇总。

users.invite_code

现有 users.invite_code 只保留“注册时提交的邀请码字符串快照”。它不能作为权威邀请关系来源,因为它没有 inviter_user_id,也无法处理 code 换绑、停用、跨 App 和历史审计。

新代码应优先读取 user_invite_relationsusers.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、同 region_id
  7. 插入 user_invite_relations
  8. 创建 third_party_identitiesauth_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

有效邀请不在注册链路内计算。它由钱包充值事实异步推进:

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 至少包含:

{
  "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

  1. 增加 user_invite_codesuser_invite_relations 表。
  2. 增加 user_invite_validity_policiesuser_invite_recharge_progressuser_invite_event_consumptionuser_invite_countersuser_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. GetUserGetUserOverview 增加当前用户的邀请码、邀请人数、有效邀请人数和当前有效门槛。
  10. gateway HTTP request/response 增加字段映射。
  11. 加测试和基础监控。

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=validvalid_invite_count 增加 1
被邀请用户一次充值超过门槛 只产生 1 个有效邀请
被邀请用户多次充值累计达到门槛 达标那笔交易触发有效邀请
同一充值事件重复消费 累计金额和有效邀请计数不重复增加
充值发生在绑定邀请码之前 默认不补算有效邀请,除非后台重算任务明确处理
后台把有效邀请门槛改为 100000 已经 valid 的历史事实不重算,新达成用户按新策略判断
我的页 overview 返回当前用户自己的邀请码、invite_countvalid_invite_count,不实时扫关系或充值流水