hyapp-server/docs/登录注册技术设计.md
2026-05-12 20:07:49 +08:00

50 KiB
Raw Blame History

Login And Registration Technical Design

本文档定义 v1 登录、三方注册登录、密码设置、邀请码归因和 token 生命周期。它只描述当前架构事实和接口边界,不记录实现流水账。邀请码详细架构见 Registration Invite Code Architecture

Scope

v1 只有一个用户创建入口:

  • 三方身份登录即注册。provider + provider_subject 不存在时创建用户、生成 default_display_user_id、设置当前 display_user_id、绑定三方身份并签发 token。
  • 注册邀请码是可选归因能力。非空邀请码如果有效,则在注册事务或资料补全事务内绑定邀请人;邀请码关系一旦绑定不可由客户端修改。

三方登录不拆成“先登录、发现未注册、再注册”两步。客户端调用三方 SDK 拿到 credential 后,只调用 LoginThirdParty。服务端根据 provider + provider_subject 决定登录还是创建用户,并通过 is_new_userprofile_completed 告诉客户端下一步是否进入资料补全页。

v1 的密码能力只有一个前置条件:

  • 用户必须已经通过三方登录拿到 access token之后才能设置密码。

v1 不支持:

  • 账号密码注册。
  • 用户名、邮箱、手机号作为独立登录账号。
  • 未登录状态设置密码。
  • 手机验证码登录。
  • 匿名游客登录。
  • 三方登录返回 NOT_REGISTERED 后再调用注册接口。
  • 多账号主动合并。
  • 客户端直接访问 user-service
  • gateway 保存密码、三方凭证或 refresh token。

Ownership

登录注册归属 user-servicegateway 只做外部 HTTP JSON 入口和 gRPC 协议转换。

Capability Owner Rule
App 解析 gateway-service + user-service gateway 从包名、平台或显式 app_code 解析 Appuser-serviceapps 表是权威映射
HTTP JSON API gateway-service 只暴露 /api/v1/auth/*,统一返回 envelope
用户创建 user-service 只能由三方身份首次登录触发
注册完成状态 user-service 创建用户后默认未完成资料,资料补全后显式标记完成
邀请码归因 user-service 生成用户自己的邀请码,解析注册邀请码并写邀请关系事实
展示短号 user-service 新用户创建时生成默认短号,后续登录用当前有效短号;临时靓号有效期内会覆盖默认短号
密码身份 user-service 已登录用户手动设置后才存在
三方身份绑定 user-service provider + subject 唯一绑定用户
三方凭证校验 user-service 生产环境必须由 Firebase/provider verifier 校验 credential 后得到 subject
access token 签发 user-service 登录成功后签发
access token 校验 gateway-service 业务 API 和设置密码入口校验
refresh token user-service 只保存 hash支持轮换和失效
房间身份态 room-service 只消费已鉴权 actor_user_id,不处理登录
腾讯云 IM 身份态 gateway-service 用 access token 鉴权后签发腾讯云 IM UserSig不处理注册

gateway-service 不读取用户表,不校验密码 hash不调用 Firebase 或三方平台做最终身份判断。Firebase service account、Firebase project id 和三方 provider secret 只存在于 user-service verifier 配置或服务端密钥系统。

所有登录、注册、session、三方身份和短号能力都按 app_code 隔离。客户端包名 com.org.laluparty 当前映射为 lalu;后续 popoosiya 等 App 只新增 apps 映射和对应 provider 配置不复制登录服务。JWT 必须携带 app_code,刷新 token、设置密码和密码登录都必须在同一 app_code 内查询。

External HTTP API

所有接口使用 gateway 业务响应 envelope

{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {}
}

request_id 由 gateway 生成,并透传到内部 gRPC meta客户端不需要提交 X-Request-ID

Third Party Login Or Register

POST /api/v1/auth/third-party/login

Request:

{
  "provider": "firebase",
  "credential": "firebase_id_token",
  "username": "hy",
  "gender": "male",
  "country": "CN",
  "invite_code": "INV-1",
  "device_id": "device_xxx",
  "device": "iPhone 15",
  "os_version": "iOS 18.1",
  "avatar": "https://cdn.example/avatar.png",
  "birth": "2000-01-02",
  "app_version": "1.2.3",
  "build_number": "123",
  "source": "campaign-a",
  "install_channel": "app_store",
  "campaign": "spring_launch",
  "platform": "ios",
  "language": "zh-CN",
  "timezone": "Asia/Shanghai"
}

Response data:

{
  "user_id": "918274650129384448",
  "display_user_id": "163000",
  "default_display_user_id": "163000",
  "display_user_id_kind": "default",
  "display_user_id_expires_at_ms": 0,
  "session_id": "sess_xxx",
  "access_token": "jwt_xxx",
  "refresh_token": "refresh_xxx",
  "expires_in_sec": 1800,
  "token_type": "Bearer",
  "app_code": "lalu",
  "is_new_user": true,
  "profile_completed": false,
  "onboarding_status": "profile_required"
}

三方接口不拆注册和登录。新用户不会默认生成密码;注册上下文和来源快照写入 users,同时从 App 序列分配 default_display_user_id(首号 163000)、生成用户自己的邀请码,并在同一事务中创建 user_display_user_idsthird_party_identities 和首个 auth_sessionsbirth 必须是 yyyy-mm-ddplatform 当前只接受 androidiosgateway 只接收 app_version,不会读取误拼字段。客户端不能提交注册 IP、UA 或 IP 国家,register_ipregister_user_agent 来自 gateway 请求上下文,country_by_ip 来自服务端/可信边缘层根据入口 IP 形成的国家快照。provider_subject 只允许由 user-service 校验 provider credential 后得到,不能信任客户端直传,也不能把客户端传入的 credential 原样当成 subject。三方绑定唯一键是 app_code + provider + provider_subject,同一个 Firebase UID 可以在不同 App 下创建不同业务用户。

LoginThirdParty 中的 username/avatar/gender/country 只作为 provider 或客户端当次传入的注册资料快照不代表注册完成。App 注册页的权威提交点是 CompleteOnboarding(username, avatar, gender, country, optional invite_code);只有该接口成功后才设置 profile_completed=true

注册链路分成两类数据,不能混用:

Category Submit Time Source Fields
注册上下文 POST /api/v1/auth/third-party/login 客户端自动上报或 gateway 观察 device_idplatformdeviceos_versionapp_versionbuild_numberlanguagetimezonesourceinstall_channelcampaignregister_ipregister_user_agentcountry_by_ip
注册页用户资料 POST /api/v1/users/me/onboarding/complete 用户在注册页主动填写或选择 usernameavatargendercountry
注册邀请归因 POST /api/v1/auth/third-party/loginPOST /api/v1/users/me/onboarding/complete 用户选填的邀请码 invite_code

device_idplatform 是注册创建用户时必须登记的客户端上下文,不是注册页让用户填写的内容,也不进入 CompleteOnboarding request body。注册页只负责让用户完成可展示、可分区的最小资料。

invite_code 推荐在资料补全页提交。为了兼容客户端在三方登录前已经拿到邀请码的场景,LoginThirdParty 也保留可选 invite_code。已有用户再次登录时提交的邀请码不修改历史邀请关系。

客户端跳转规则:

  • is_new_user=true 表示本次调用首次创建了用户和三方绑定,客户端应进入资料补全页。
  • profile_completed=false 表示该用户仍未完成 App 注册资料,即使 is_new_user=false 也必须继续进入资料补全页。
  • is_new_user=false && profile_completed=true 才能直接进入 App 首页。
  • 服务端不返回 NOT_REGISTERED。三方身份不存在时由本接口原子创建credential 无效、provider 不允许或 provider 校验失败统一返回 AUTH_FAILED

LoginThirdParty 入参字段必须在 user-service 写库前完成校验,失败统一返回 INVALID_ARGUMENT,不能让 MySQL VARCHARDATE 约束错误泄漏成 gateway UPSTREAM_ERROR

Field Max Format
username 64 trimmed display name
gender 32 client enum/string snapshot
country 3 ISO 3166-1 alpha-2/alpha-3 canonical code, normalized uppercase
invite_code 64 optional invite code, trim and uppercase before resolving
register_ip 64 gateway observed IP only
register_user_agent 512 gateway observed User-Agent only
country_by_ip 2 trusted edge/server country code only
device_id 128 required, first refresh session binding
device 128 device model/description
os_version 64 client OS version
avatar 512 absolute http or https URL
birth date yyyy-mm-dd
app_version 64 client version
build_number 64 client build number
source 64 registration source
install_channel 64 install channel
campaign 128 attribution campaign
platform 16 android or ios
language 32 BCP 47 language tag subset, e.g. en-US
timezone 64 IANA timezone name, e.g. America/Los_Angeles

display_user_id 永远表示当前有效展示号。用户没有临时靓号时,它等于 default_display_user_id;用户有 active 靓号时,它等于靓号。

Third Party Credential Verification

生产环境的三方登录必须使用真实身份源 verifier。首版 App 已接入 Firebase Auth因此 provider=firebasecredential 统一表示 Firebase ID token。StaticThirdPartyVerifier 只能用于单元测试、本地隔离联调或明确的假 provider不能配置到线上 provider allowlist。

校验规则:

  • provider 必须命中 user-service 配置 allowlist未知 provider 返回 AUTH_FAILED
  • verifier 必须校验 credential 的签名、过期时间、issuer、audience/client_id/app_id、nonce 或等价防重放字段。
  • verifier 必须从 provider 验证后的 payload 或服务端换票结果中提取稳定 provider_subject;客户端不能直传 provider_subject
  • provider 返回可选 union id 时,provider_subject 仍然是登录绑定主键,provider_union_id 只做辅助字段。
  • provider 配置缺失、密钥缺失、JWKS/公钥加载失败或 provider 响应不可判定时必须 fail-closed返回 AUTH_FAILED 或服务端配置错误,不允许降级为静态 verifier。
  • verifier 不能把 credential、id token、auth code、provider access token 写入日志、审计表或 users

上线前必须按首批 provider 分别写清 credential 类型:

Provider Credential Required Checks
firebase Firebase ID token signature/Admin SDK or Firebase public keys, iss, aud, exp/iat/auth_time, non-empty sub, allowed firebase.sign_in_provider
apple identity token or authorization code signature/JWKS, issuer, audience, expiry, nonce when present
facebook access token or limited-login token app id, token validity, expiry, subject from provider API or signed token
tiktok auth code or access token app/client key, expiry, subject from provider API

如果首版仍使用本地静态 verifier 联调,配置名和注释必须明确标记为 local/mock线上配置示例不能把真实 provider 指向静态 verifier。

Firebase 首版是 App 三方登录的统一身份入口。客户端先用 Firebase Auth 完成 Google 登录,再调用 Firebase Auth 获取当前用户的 Firebase ID token并把该 token 作为 credential 提交给 gateway。gateway 只做协议转发不解析、不缓存、不落日志user-service 的 Firebase verifier 必须执行:

Check Rule
iss 必须是 https://securetoken.google.com/<firebase_project_id>
aud 必须等于当前 App 后端配置的 Firebase project id
exp/iat/auth_time token 未过期,签发和认证时间不能明显偏离当前时间
signature 优先使用 Firebase Admin SDK 校验;不用 Admin SDK 时必须按 Firebase 公钥和缓存过期时间校验
sub / uid 作为 provider_subject,不能为空,不能来自客户端 body
firebase.sign_in_provider 首版只开放 Google 时必须等于 google.com;后续开放 Apple/phone 等方式时改成配置 allowlist
email/email_verified/name/picture 只作为资料快照辅助字段,不能替代 sub 做绑定主键

Firebase verifier 出现项目 ID 缺失、issuer/audience 不匹配、签名错误、token 过期、sub 为空、sign_in_provider 不允许或公钥加载失败时,统一返回 AUTH_FAILED。服务端不能把 Firebase ID token 写入日志、审计或数据库。

Set Password

POST /api/v1/auth/password/set
Authorization: Bearer <access_token>

Request:

{
  "password": "plain_password"
}

Response data:

{
  "password_set": true
}

设置密码只接受已登录用户的 access token。客户端不能在 body 中自报 user_iddisplay_user_id。重复设置密码返回 PASSWORD_ALREADY_SET,后续如果要支持改密,必须新增独立改密接口和旧密码校验规则。

Update Profile

POST /api/v1/users/me/profile/update
Authorization: Bearer <access_token>

Request:

{
  "username": "hy",
  "avatar": "https://cdn.example/avatar.png",
  "birth": "2000-01-02"
}

该接口只修改用户名、头像和生日。birth 非空时必须是 yyyy-mm-dd;国家不能混在这个接口中修改。

未完成注册用户不通过该接口补齐注册页资料;注册页必须调用 CompleteOnboarding 一次性提交 username/avatar/gender/country

Change Country

POST /api/v1/users/me/country/change
Authorization: Bearer <access_token>

Request:

{
  "country": "US"
}

国家修改由 user-service 单独写 user_country_change_logs。同一用户按滚动 30 天冷却窗口只能成功修改一次;提交相同国家不写新日志,也不消耗新的冷却窗口。

未完成注册用户的首次国家选择不走该接口,而是跟随 CompleteOnboarding 在同一事务内写入。注册完成后的国家修改才进入冷却和审计链路。

Complete Onboarding

POST /api/v1/users/me/onboarding/complete
Authorization: Bearer <access_token>

Request:

{
  "username": "hy",
  "avatar": "https://cdn.example/avatar.png",
  "gender": "male",
  "country": "SG",
  "invite_code": "A1B2C3"
}

Response data:

{
  "user_id": "918274650129384448",
  "username": "hy",
  "avatar": "https://cdn.example/avatar.png",
  "country": "SG",
  "region_id": 1001,
  "profile_completed": true,
  "profile_completed_at_ms": 1777000000000,
  "onboarding_status": "completed",
  "invite": {
    "bound": true,
    "invite_code": "A1B2C3",
    "inviter_user_id": "10001"
  },
  "token": {
    "user_id": "918274650129384448",
    "session_id": "sess_xxx",
    "access_token": "jwt_xxx",
    "expires_in_sec": 1800,
    "token_type": "Bearer",
    "display_user_id": "163000",
    "profile_completed": true,
    "onboarding_status": "completed"
  }
}

该接口是注册页唯一提交入口。客户端在注册页一次性提交国家、用户名、头像和可选邀请码user-service 在同一事务内校验资料、解析国家和区域、按需绑定邀请关系、更新用户资料,并把用户标记为注册完成。

token.access_token 是注册完成后立即替换旧 access token 的新 JWT包含 profile_completed=true。该接口不轮换 refresh tokentoken.refresh_token 为空或不返回;客户端必须保留登录时已有的 refresh token。

注册页用户必填字段:

Field Required Reason
username yes 房间、资料页和 IM 展示需要稳定昵称
avatar yes App 注册页要求用户头像,房间和 IM 展示不使用空头像
country yes 房间列表按区域分发,必须有用户选择国家
region_id no 国家可以暂时没有区域映射,此时进入 GLOBAL 房间列表
invite_code no 注册邀请归因;非空时必须能解析到有效邀请人

注册上下文必需字段:

Field Required Submit Time Reason
device_id yes LoginThirdParty refresh session 绑定、风控和设备级频控基础字段
platform yes LoginThirdParty App 侧环境字段,只允许 androidios
app_version/build_number/os_version no LoginThirdParty 注册来源排障和灰度分析字段,不阻塞 onboarding 完成

完成规则:

  • username 必须 trim 后非空,并满足 users.username 长度限制。
  • avatar 必须是合法 httphttps URL并满足 users.avatar 长度限制。
  • country 必须命中 active countries.country_code,并同步解析 region_id
  • invite_code 非空时必须能解析到同一 app_code 下的 active 用户,且不能是当前用户自己的邀请码。
  • 邀请关系只能在未完成资料且尚未绑定邀请关系时创建;已完成注册后不能再补填邀请码。
  • 初次完成注册时设置国家不消耗国家修改冷却期;注册完成后的国家修改才走 ChangeUserCountry 和 30 天冷却。
  • 如果用户已经 profile_completed=true,该接口不再修改资料;客户端需要改资料时使用资料修改接口,改国家时使用国家修改接口。
  • CompleteOnboarding 成功后必须返回新的 access token客户端立即替换旧 tokenrefresh token 不轮换。

如果必填字段缺失或格式非法,返回 INVALID_ARGUMENT。如果非空邀请码无法解析为有效邀请人,返回 INVALID_INVITE_CODE。如果资料未完成用户访问被门禁保护的业务能力,返回 PROFILE_REQUIREDPROFILE_REQUIRED 只用于已登录但资料未完成的业务门禁,不用于三方登录接口。

App Login State Machine

客户端登录页不需要先问服务端“是否注册”。状态机固定为:

unauthenticated
  -> provider_sdk_login
  -> POST /api/v1/auth/third-party/login
  -> token_received
  -> if is_new_user || !profile_completed: onboarding_required
  -> else: authenticated_home

资料补全页状态机:

onboarding_required
  -> POST /api/v1/users/me/onboarding/complete(username, avatar, gender, country, optional invite_code)
  -> token_received(profile_completed=true)
  -> authenticated_home

客户端判断规则:

  • 只要已经拿到 access token就认为用户主身份已经创建。
  • 资料未完成不是未注册;它是已登录用户的 App 准入状态。
  • App 启动后如果本地已有 token应先用业务接口或用户资料接口刷新 profile_completed,不要只依赖本地缓存。
  • is_new_user 只在本次三方登录响应中有意义;长期准入判断必须使用 profile_completed
  • CompleteOnboarding 成功响应内的 token.access_token 是解除 profile gate 的唯一新快照,客户端必须立即替换旧 access token。

Profile Gate

资料未完成用户允许访问的接口:

API Allowed Reason
POST /api/v1/users/me/onboarding/complete yes 一次性提交国家、用户名和头像并固化完成状态
POST /api/v1/auth/password/set yes 允许补齐密码身份
POST /api/v1/auth/token/refresh yes 避免补资料时 token 过期导致重登
POST /api/v1/auth/logout yes 用户必须始终能退出

资料未完成用户默认禁止访问:

API / Capability Rule
房间列表 返回 PROFILE_REQUIRED
创建房间 返回 PROFILE_REQUIRED
进入房间 返回 PROFILE_REQUIRED
腾讯云 IM UserSig 返回 PROFILE_REQUIRED,避免未完成资料用户进入实时系统
腾讯云 RTC Token 返回 PROFILE_REQUIRED
钱包和付费行为 返回 PROFILE_REQUIRED

gateway 是 profile gate 的外部入口执行点。为了避免每个业务请求都同步打 user-service首版在 access token 中携带 profile_completed 快照;高风险接口仍可回查 user-service。用户完成 onboarding 后,客户端必须使用 CompleteOnboardingResponse.token.access_token 进入房间、IM、RTC 或付费链路。

Password Login

POST /api/v1/auth/account/login

Request:

{
  "account": "163000",
  "password": "plain_password",
  "device_id": "device_xxx"
}

account 就是用户当前有效短号。gateway 会统一转成 user-service 的 LoginPasswordRequest.display_user_id

Response data:

{
  "user_id": "918274650129384448",
  "display_user_id": "163000",
  "default_display_user_id": "163000",
  "display_user_id_kind": "default",
  "display_user_id_expires_at_ms": 0,
  "session_id": "sess_xxx",
  "access_token": "jwt_xxx",
  "refresh_token": "refresh_xxx",
  "expires_in_sec": 1800,
  "token_type": "Bearer"
}

短号不存在、用户未设置密码和密码错误都返回 AUTH_FAILED。这三个分支不能给客户端更细错误,避免登录入口变成用户枚举接口。

密码登录只接受当前有效 display_user_id。如果用户申请了临时靓号,默认短号在靓号有效期内不能用于密码登录;靓号过期后,默认短号恢复登录能力。

本地联调 initdb 预置测试账号:账号 123456,密码 1234567;账号 12345678,密码 12345678

Refresh Token

POST /api/v1/auth/token/refresh

Request:

{
  "refresh_token": "refresh_xxx",
  "device_id": "device_xxx"
}

Response data:

{
  "user_id": "918274650129384448",
  "display_user_id": "163000",
  "default_display_user_id": "163000",
  "display_user_id_kind": "default",
  "display_user_id_expires_at_ms": 0,
  "session_id": "sess_xxx",
  "access_token": "jwt_xxx",
  "refresh_token": "refresh_new_xxx",
  "expires_in_sec": 1800,
  "token_type": "Bearer"
}

refresh token 必须轮换。旧 refresh token 使用成功后立即失效。

Logout

POST /api/v1/auth/logout

Request:

{
  "refresh_token": "refresh_xxx",
  "session_id": "sess_xxx"
}

Response data:

{
  "revoked": true
}

Internal gRPC Contract

api/proto/user/v1/auth.proto 承载 auth RPC

service AuthService {
  rpc LoginPassword(LoginPasswordRequest) returns (AuthResponse);
  rpc LoginThirdParty(LoginThirdPartyRequest) returns (AuthResponse);
  rpc SetPassword(SetPasswordRequest) returns (SetPasswordResponse);
  rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse);
  rpc Logout(LogoutRequest) returns (LogoutResponse);
}

关键消息:

message LoginPasswordRequest {
  RequestMeta meta = 1;
  string display_user_id = 2;
  string password = 3;
}

message LoginThirdPartyRequest {
  RequestMeta meta = 1;
  string provider = 2;
  string credential = 3;
  string username = 4;
  string gender = 5;
  string country = 6;
  string invite_code = 7;
  string device_id = 8;
  string device = 9;
  string avatar = 10;
  string birth = 11;
  string app_version = 12;
  string source = 13;
  string platform = 14;
  string language = 15;
  string os_version = 16;
  string build_number = 17;
  string timezone = 18;
  string install_channel = 19;
  string campaign = 20;
}

message SetPasswordRequest {
  RequestMeta meta = 1;
  int64 user_id = 2;
  string password = 3;
}

message AuthToken {
  int64 user_id = 1;
  string session_id = 2;
  string access_token = 3;
  string refresh_token = 4;
  int64 expires_in_sec = 5;
  string token_type = 6;
  string display_user_id = 7;
  string default_display_user_id = 8;
  string display_user_id_kind = 9;
  int64 display_user_id_expires_at_ms = 10;
  bool profile_completed = 11;
  string onboarding_status = 12;
  string app_code = 13;
}

message AuthResponse {
  AuthToken token = 1;
  bool is_new_user = 2;
  bool profile_completed = 3;
  string onboarding_status = 4;
}

SetPasswordRequest.user_id 必须来自 gateway 已校验 access token 后的上下文,不能来自客户端 JSON body。

api/proto/user/v1/user.proto 需要扩展用户资料和 onboarding RPC

message User {
  // existing fields...
  bool profile_completed = 21;
  int64 profile_completed_at_ms = 22;
  string onboarding_status = 23;
}

message CompleteOnboardingRequest {
  RequestMeta meta = 1;
  int64 user_id = 2;
  string username = 3;
  string avatar = 4;
  string country = 5;
  string gender = 6;
  string invite_code = 7;
}

message CompleteOnboardingResponse {
  User user = 1;
  bool profile_completed = 2;
  int64 profile_completed_at_ms = 3;
  string onboarding_status = 4;
  AuthToken token = 5;
}

service UserService {
  // existing rpc...
  rpc CompleteOnboarding(CompleteOnboardingRequest) returns (CompleteOnboardingResponse);
}

字段语义:

  • profile_completed=false 表示用户已经创建但未完成 App 最小注册资料。
  • profile_completed_at_ms=0 表示从未完成 onboarding。
  • onboarding_status 首版只允许 profile_requiredcompleted,后续可以追加 country_requiredage_required 等更细状态。
  • CompleteOnboardingRequest.username/avatar/gender/country 是注册页必填字段,不能从客户端拆成多次部分提交。
  • CompleteOnboardingRequest.invite_code 是注册页选填字段;非空时必须解析到有效邀请人,否则返回 INVALID_INVITE_CODE
  • CompleteOnboardingResponse.token.access_token 是注册完成后用于替换旧 JWT 的新 access token它复用当前 session_id,不轮换 refresh token。

Data Model

users

用户主记录保存用户全局身份、当前短号快照、注册资料和注册来源快照。三方 credential、密码明文、refresh token 原文都不能进入这张表。

所有 auth 相关表都带 app_code

Table App Scope
users 用户主记录按 app_code 写入,展示短号唯一键带 app_code
password_accounts 主键是 app_code + user_id
third_party_identities 唯一键是 app_code + provider + provider_subject
auth_sessions refresh token hash 唯一键带 app_code
login_audit 登录审计查询索引带 app_code
display_user_id_sequences 每个 App 独立短号序列,首个 next_value163000
user_invite_codes 用户自己的邀请码按 app_code + code 唯一
user_invite_relations 被邀请用户按 app_code + invited_user_id 最多一条关系
Column Type Rule
user_id bigint primary key
default_display_user_id varchar user's permanent default short id
current_display_user_id varchar current effective short id; pretty id can cover default id
current_display_user_id_kind varchar default or pretty
current_display_user_id_expires_at_ms bigint null for default id
username varchar registration display name snapshot
gender varchar client submitted gender value
country varchar ISO 3166-1 alpha-2/alpha-3 canonical country code
invite_code varchar optional submitted invite code snapshot; not the authoritative inviter relation
register_ip varchar gateway observed request ip snapshot
register_user_agent varchar gateway observed User-Agent snapshot
country_by_ip varchar server/edge inferred country from entry IP
register_device_id varchar registration device id and first session device binding
register_device varchar client device model or description
os_version varchar client OS version
avatar varchar absolute http/https avatar URL
birth_date date yyyy-mm-dd
app_version varchar registration client version
build_number varchar registration client build number
source varchar registration source channel
install_channel varchar install channel
campaign varchar attribution campaign
platform varchar android or ios
language varchar BCP 47 language tag subset
timezone varchar client IANA timezone
profile_completed tinyint/bool whether App minimum registration profile is complete
profile_completed_at_ms bigint completion time, 0 or null means incomplete
onboarding_status varchar profile_required or completed
status varchar active, disabled, banned
created_at_ms bigint creation time
updated_at_ms bigint update time

新用户创建时:

  • profile_completed=false
  • profile_completed_at_ms=0
  • onboarding_status=profile_required

CompleteOnboarding 成功时:

  • 校验 username/avatar/gender/country
  • 如果提交了 invite_code 且尚未绑定邀请关系,解析并写 user_invite_relations
  • 写入 users.usernameusers.avatarusers.genderusers.countryusers.region_id
  • 设置 profile_completed=true
  • 设置 profile_completed_at_ms=now
  • 设置 onboarding_status=completed

资料完成状态只能由 user-service 修改gateway 和客户端不能直接提交该字段。

user_invite_codes

用户自己的邀请码表。新用户创建时必须生成一条 active primary code供用户在我的页复制分享。

Column Type Rule
invite_code_id bigint primary id
code varchar trim + uppercase 后保存,app_code + code 唯一
owner_user_id bigint code owner user
status varchar active or disabled
is_primary tinyint/bool 首版一个用户只有一个 primary code
created_at_ms bigint creation time
updated_at_ms bigint update time

邀请码不能复用 current_display_user_id。展示短号可能因为靓号、运营调整或过期发生变化;邀请码需要长期稳定,才能解释历史邀请关系。

user_invite_relations

用户邀请关系事实表。它是“谁邀请了谁”的权威来源。

Column Type Rule
invited_user_id bigint 被邀请用户,app_code + invited_user_id 主键
inviter_user_id bigint 邀请人用户
invite_code_id bigint 使用的邀请码 ID
invite_code varchar 绑定时的邀请码快照
source varchar login or onboarding
bound_at_ms bigint relation binding time
request_id varchar gateway trace id
created_at_ms bigint creation time

关系绑定规则:

  • 一个用户最多一条邀请关系。
  • 非空邀请码不存在、停用、跨 App、owner 非 active 或 owner 是当前用户时,返回 INVALID_INVITE_CODE
  • 已有三方身份再次登录时不能通过 invite_code 修改邀请人。
  • 历史关系不因为邀请码后续停用或邀请人资料变化而删除。

password_accounts

密码身份表。它表达“该用户已经设置过密码”,不表达独立账号体系。

Column Type Rule
user_id bigint primary key
password_hash varchar never store plain password
hash_alg varchar e.g. bcrypt
created_at_ms bigint creation time
updated_at_ms bigint update time

密码登录时先用 users.current_display_user_id 解析当前用户,再按 user_id 读取 password_accounts。这样用户修改默认短号或申请临时靓号后,密码登录自动跟随当前有效短号,不需要同步更新密码表。

如果 current_display_user_id_kind=prettycurrent_display_user_id_expires_at_ms <= now,登录前必须先触发 user-service 的靓号懒过期流程,把当前短号恢复为默认短号后再继续解析。

third_party_identities

三方身份绑定表。

Column Type Rule
id bigint primary key
user_id bigint owner user
provider varchar provider name
provider_subject varchar provider user id, required
provider_union_id varchar optional
created_at_ms bigint creation time
updated_at_ms bigint update time

唯一约束:

unique(app_code, provider, provider_subject)

如果 provider 支持 union id也不能只依赖 union id。provider_subject 是登录身份的稳定主键。

user_country_change_logs

用户国家修改日志表,作为冷却期判断和审计来源。

Column Type Rule
id bigint primary key
user_id bigint changed user
old_country varchar nullable when previous country is empty
new_country varchar required
request_id varchar gateway trace id
changed_at_ms bigint change time

冷却期检查必须和 users 行更新在同一事务中完成,不能只依赖 gateway 侧判断。

auth_sessions

refresh token 和会话状态表。

Column Type Rule
session_id varchar primary key
user_id bigint session owner
refresh_token_hash varchar unique
device_id varchar client device
expires_at_ms bigint refresh expiry
revoked_at_ms bigint null means active
created_at_ms bigint creation time
updated_at_ms bigint update time

refresh token 原文只返回给客户端一次,服务端只保存 hash。

login_audit

登录审计表,不参与主链路判断。

Column Type Rule
id bigint primary key
request_id varchar trace id
user_id bigint nullable on failed login
login_type varchar password, third_party, refresh, set_password
provider varchar nullable
result varchar success, failed
failure_code varchar nullable
client_ip varchar gateway observed ip
user_agent varchar gateway observed ua
created_at_ms bigint event time

审计失败不能影响登录主链路,但不能记录明文密码、三方 credential 或 refresh token 原文。

Core Flows

Third Party Login Or Register

sequenceDiagram
  participant C as Client
  participant G as gateway-service
  participant U as user-service
  participant P as Firebase Auth / Provider

  C->>G: POST /api/v1/auth/third-party/login
  G->>U: LoginThirdParty(provider, credential, registration_context)
  U->>P: verify Firebase ID token or provider credential
  P-->>U: provider_subject(uid/sub)
  U->>U: find identity
  alt identity exists
    U->>U: check user status
    U->>U: create session
  else identity missing
    U->>U: create user + registration context + default_display_user_id + own invite code
    opt invite_code present
      U->>U: resolve invite code + bind inviter relation
    end
    U->>U: create third_party identity + session
  end
  U-->>G: AuthToken + is_new_user + profile_completed
  G-->>C: envelope(data=AuthToken)

三方 credential 校验必须在 user-service 内完成。gateway 不保存 Firebase service account、provider app secret 或公钥缓存状态。

生产路径中,U->>P: verify Firebase ID token or provider credential 必须是真实 Firebase/Admin SDK 校验、真实 provider 校验或服务端换票。静态 verifier 把 credential 当 subject 的实现只允许在本地测试替身中使用,不能用于线上注册入口。

新用户首次登录会直接得到 tokenprofile_completed=false。客户端必须跳转资料补全页,而不是再次调用注册接口。registration_context 来自客户端环境和 gateway 请求上下文,不等于用户完成资料。

Complete Onboarding

sequenceDiagram
  participant C as Client
  participant G as gateway-service
  participant U as user-service

  C->>G: POST /api/v1/users/me/onboarding/complete(username, avatar, gender, country, optional invite_code)
  G->>G: verify access token
  G->>U: CompleteOnboarding(user_id from token, username, avatar, gender, country, invite_code)
  U->>U: validate username/avatar/gender/country
  U->>U: resolve active country and region_id
  opt invite_code present and no relation exists
    U->>U: resolve invite code and bind inviter relation
  end
  U->>U: update profile + mark profile_completed
  U->>U: re-sign access token with profile_completed=true
  U-->>G: User + profile_completed=true + AuthToken(access_token only)
  G-->>C: envelope(data=completion + token)

CompleteOnboarding 是注册页的原子提交点,避免用户资料已经更新但国家或完成状态失败造成半完成状态。注册完成后的资料修改仍走 UpdateUserProfile,国家修改仍走 ChangeUserCountry。由于 gateway profile gate 读取 access token 快照,完成 onboarding 后必须使用响应里的新 access token 才能解除 PROFILE_REQUIRED

Set Password

sequenceDiagram
  participant C as Client
  participant G as gateway-service
  participant U as user-service

  C->>G: POST /api/v1/auth/password/set + access token
  G->>G: verify access token
  G->>U: SetPassword(user_id from token, password)
  U->>U: check user status
  U->>U: hash password
  U->>U: insert password_accounts by user_id
  U-->>G: password_set=true
  G-->>C: envelope(data={password_set:true})

password_accounts.user_id 是主键。重复设置说明用户已经有密码身份,返回 PASSWORD_ALREADY_SET

Password Login

sequenceDiagram
  participant C as Client
  participant G as gateway-service
  participant U as user-service

  C->>G: POST /api/v1/auth/account/login
  G->>U: LoginPassword(display_user_id, password)
  U->>U: expire pretty display_user_id if needed
  U->>U: resolve current display_user_id
  U->>U: lookup password by user_id
  U->>U: verify password hash
  U->>U: check user status
  U->>U: create session
  U-->>G: AuthToken
  G-->>C: envelope(data=AuthToken)

display_user_id 是登录名,不是另一个账号体系。默认短号变更后旧短号不能继续登录;临时靓号有效期内默认短号不能登录;临时靓号过期后旧靓号不能继续登录。

Refresh Token

sequenceDiagram
  participant C as Client
  participant G as gateway-service
  participant U as user-service

  C->>G: POST /api/v1/auth/token/refresh
  G->>U: RefreshToken
  U->>U: hash refresh token
  U->>U: find active session
  U->>U: revoke old refresh token
  U->>U: create replacement refresh token
  U->>U: expire pretty display_user_id if needed
  U->>U: reload user current display_user_id
  U-->>G: new AuthToken
  G-->>C: envelope(data=AuthToken)

Refresh 返回当前 display_user_id避免客户端持有旧短号。临时靓号已经过期时Refresh 必须返回默认短号。

Token Strategy

v1 使用:

  • access_token: JWT短有效期默认 30 分钟。
  • refresh_token: opaque random token默认 30 天,只保存 hash。
  • session_id: 服务端会话 ID用于 logout、审计和多端管理。

JWT claims

{
  "sub": "10001",
  "user_id": "10001",
  "display_user_id": "163000",
  "default_display_user_id": "163000",
  "display_user_id_kind": "default",
  "display_user_id_expires_at_ms": 0,
  "profile_completed": false,
  "onboarding_status": "profile_required",
  "app_code": "lalu",
  "sid": "sess_xxx",
  "iat": 1777000000,
  "exp": 1777001800,
  "typ": "access"
}

JWT 内的短号字段只是签发时的展示快照,不是身份判断依据。user_id 是数值型用户主键,但在 JWT claim 和 HTTP JSON 中必须用字符串承载,避免 64 位 ID 被 JSON number 丢精度gateway 解析后再用 int64 调后端 RPC。如果临时靓号在 access token 有效期内过期,客户端需要通过 Refresh 或用户身份接口拿到恢复后的默认短号。

首版继续沿用本地 HS256 配置。中长期建议迁移到非对称签名:

  • user-service 持私钥签发。
  • gateway-service 持公钥校验。
  • 公钥通过配置或 JWKS 缓存分发。

腾讯云 IM 不直接校验业务 JWT。客户端先用 access token 调 /api/v1/im/usersig,再用返回的 UserSig 登录腾讯云 IM。

Error Codes

gateway HTTP envelope 的 code 不使用数字。

Code HTTP Meaning
OK 200 success
INVALID_JSON 400 request body is invalid JSON
INVALID_ARGUMENT 400 field format or required field invalid
INVALID_INVITE_CODE 400 submitted invite code does not resolve to a valid inviter
RATE_LIMITED 429 public auth rate limit exceeded
AUTH_FAILED 401 account missing, password wrong, no password set, or provider rejected
UNAUTHORIZED 401 missing or invalid access token
PROFILE_REQUIRED 403 authenticated user has not completed required registration profile
PASSWORD_ALREADY_SET 409 user already has password identity
COUNTRY_CHANGE_COOLDOWN 409 country change is within the 30-day cooldown window
USER_DISABLED 403 user is disabled or banned
SESSION_EXPIRED 401 refresh token expired
SESSION_REVOKED 401 refresh token revoked
UPSTREAM_ERROR 502 user-service unavailable or returned unexpected error
INTERNAL_ERROR 500 gateway internal failure

密码登录时,短号不存在、未设置密码和密码错误都映射为 AUTH_FAILED

Public Auth Rate Limit

gateway 对三方注册登录、密码登录、refresh 和 logout 做 Redis 固定窗口频控。当前频控发生在 JSON 解码后、调用 user-service 前;命中频控时返回 HTTP 429 和 RATE_LIMITED,请求不会进入 user-service。

Dimension Applies To Purpose
ip third-party, password, refresh, logout 限制单入口 IP 对 public auth 的总请求量
device_id third-party, password, refresh 限制单设备安装批量注册或刷新
provider + ip third-party 限制单 IP 对同一三方 provider 批量建号
account + ip password 限制单 IP 对同一短号喷射尝试
account password 限制同一短号被跨 IP 喷射尝试
session_id + ip logout 限制 logout 重放

Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所有维度,避免部分 key 已递增但后续维度超限的非原子状态。公网可控的 provider、device_id、account、session_id 和入口 IP 在 Redis key 中只保留固定长度摘要,避免超长输入污染 key 空间。auth_rate_limit.enabled=true 时 gateway 启动必须连通 Redis运行期 Redis 调用失败会 fail-closed 返回 HTTP 502 和 UPSTREAM_ERROR,避免入口在依赖故障时无保护放量。内存 backend 只用于单元测试或显式关闭 Redis 频控后的本地构造。

Security Rules

  • 明文密码只允许存在于 gateway 入参对象和 user-service hash 前的内存短路径中,不能写日志。
  • password_hash 必须使用强 hash不能使用 SHA、MD5 或可逆加密。
  • refresh token 原文不能入库,只能入库 hash。
  • 三方 provider secret、Firebase service account 和 Firebase project id 只属于 user-service 配置或服务端密钥系统。
  • 生产三方 verifier 必须校验 Firebase ID token 或 provider credential 的真实性和用途,不能接受客户端自报 subject不能把静态 verifier 配到真实 provider。
  • 注册完成必须由 user-service 在同一事务内写入 username/avatar/gender/country/region_id/profile_completed,不能由 gateway 分多步拼状态。
  • 邀请码解析和邀请关系绑定必须由 user-service 在注册或资料补全事务内完成,不能由 gateway 先查后写。
  • 邀请关系不能只保存邀请码字符串;必须保存 inviter_user_id 和绑定时的邀请码快照。
  • 登录失败日志不能包含明文密码、三方 credential、refresh token 原文。
  • gateway 统一过滤内部错误,客户端只拿稳定错误码和 request_id
  • access token 校验失败不访问 user-service,避免每个业务请求都打用户服务。
  • 设置密码必须从 access token 取 user_id,不接受客户端 body 指定用户。
  • access token 中的 display_user_id 不能作为权限、账务、房间或 IM 身份依据。

Development Contract

当前项目处于开发阶段没有线上旧客户端或旧服务版本需要保留HTTP、protobuf 和数据库契约按当前代码直接收敛:

  • HTTP JSON 只接受当前文档字段,旧拼写和废弃字段不再读取。
  • protobuf 按当前字段编号生成,删除的旧字段不保留解析分支。
  • 新增 provider 必须通过配置 allowlist 控制,不能让客户端任意传 provider 后触发未知逻辑。
  • 首版 Firebase 登录必须配置 provider=firebase、Firebase project id、Firebase verifier 和 firebase.sign_in_provider allowlist不能只把 firebase 加入静态 allowlist。
  • 新增直接 provider 必须同时新增专用 verifier、配置项和测试不能只把 provider 名加入静态 allowlist。
  • 三方登录创建用户必须使用事务,保证 users 注册上下文、user_display_user_idsthird_party_identities 不出现半绑定。
  • 三方登录创建新用户时必须同时生成用户自己的邀请码。
  • 注册页只调用 CompleteOnboarding(username, avatar, gender, country, optional invite_code);不要让客户端分别调用资料、国家和完成状态三个接口拼注册流程,也不要把 device_id/platform 放进注册页表单。
  • CompleteOnboarding 成功后必须返回新的 access token否则旧 access token 的 profile_completed=false 仍会被 gateway profile gate 拦截。
  • 已有邀请关系或已完成资料的用户不能通过再次登录或再次 onboarding 修改邀请人。
  • 设置密码必须使用 user_id 主键幂等判断,不能引入独立账号唯一键。
  • 登录响应中的 display_user_id 表示当前有效短号,服务端权限、账务、房间和 IM 身份一律使用长 user_id

Test Matrix

必须覆盖:

  • 三方身份首次登录创建用户、生成 default_display_user_id,并返回当前 display_user_idis_new_user=true
  • 三方新用户首次登录返回 profile_completed=falseonboarding_status=profile_required
  • 三方新用户创建时写入 device_id/platform/app_version/build_number/os_version/language/timezone 等注册上下文,但不把这些字段作为注册页用户输入。
  • 注册页提交 username/avatar/gender/countryuser-service 在同一事务内写用户资料、解析 region_id 并返回 profile_completed=true
  • 新用户创建时生成自己的 active 邀请码,GET /api/v1/users/me/overview 可以展示该邀请码。
  • 注册页提交有效 invite_codeuser-service 写 user_invite_relations(invited_user_id, inviter_user_id)
  • 注册页提交不存在、停用、跨 App、owner 非 active 或自己的邀请码时返回 INVALID_INVITE_CODE
  • 已有三方身份再次登录时传入新 invite_code 不改变原邀请关系。
  • 注册页完成后旧 access token 仍被 profile gate 拒绝;CompleteOnboardingResponse.token.access_token 能访问房间列表、IM UserSig、RTC Token 和付费链路。
  • 注册完成缺少 username、缺少 avatar、缺少 gender、缺少 country、头像 URL 非法或国家不存在时返回 INVALID_ARGUMENT
  • 未完成注册用户访问房间列表、创建房间、进房、IM UserSig、RTC Token 或付费行为时返回 PROFILE_REQUIRED
  • 初次注册完成写入国家不消耗国家修改冷却;注册完成后的国家修改才受 COUNTRY_CHANGE_COOLDOWN 约束。
  • Firebase verifier 拒绝未知 provider、伪造签名、错误 issuer/audience、过期 token、空 sub、不允许的 firebase.sign_in_provider 和 Firebase 配置缺失。
  • 直接 provider verifier 拒绝伪造签名、错误 audience、过期 credential、nonce 不匹配和 provider 配置缺失。
  • 真实 provider 配置不能回退到 StaticThirdPartyVerifier;静态 verifier 只在测试或 local/mock 配置中出现。
  • 三方注册上下文和可选资料快照写入 users;任意写入 users 的注册字段超过 schema 长度,或 birth/platform/country/language/timezone/avatar 格式非法时返回 INVALID_ARGUMENT
  • 用户资料接口只修改 username/avatar/birth,非法生日返回 INVALID_ARGUMENT
  • 用户国家接口写 user_country_change_logs,冷却期内第二次修改返回 COUNTRY_CHANGE_COOLDOWN
  • 三方身份再次登录返回同一个 user_idis_new_user=false
  • 三方新用户未设置密码前,短号密码登录返回 AUTH_FAILED
  • 已登录用户设置密码成功。
  • 重复设置密码返回 PASSWORD_ALREADY_SET
  • 设置密码后可使用当前 account + password 登录。
  • 短号不存在和密码错误都返回 AUTH_FAILED
  • 用户有 active 靓号时,密码登录必须使用靓号,默认短号返回 AUTH_FAILED
  • 靓号过期后,密码登录必须恢复使用默认短号,旧靓号返回 AUTH_FAILED
  • Refresh 在靓号过期后返回默认短号和 display_user_id_kind=default
  • 被禁用用户密码登录返回 USER_DISABLED
  • refresh token 成功轮换,旧 token 再次使用失败。
  • logout 后 refresh token 失败。
  • gateway auth API 响应始终使用 {code,message,request_id,data}
  • gateway 在 SetPassword 中只从 access token 传递 user_id
  • public auth 频控命中时返回 RATE_LIMITEDRedis 频控 backend 异常时 fail-closed不进入 user-service。

Open Decisions

这些点需要在实现前定死,不能靠代码猜:

  • 首批三方 provider 名单,以及哪些走 Firebase Auth哪些走直接 provider verifier。
  • Firebase project id、service account/公钥校验方式、允许的 firebase.sign_in_provider 列表。
  • 每个直接 provider 的 credential 类型、client_id/app_id、issuer/audience、nonce 策略和公钥/JWKS 缓存策略。
  • 密码复杂度策略。
  • access token 有效期和 refresh token 有效期。
  • HS256 继续使用多久,以及迁移非对称签名的时间点。