hyapp-server/docs/auth-login-register-technical-design.md
2026-05-08 18:48:29 +08:00

1112 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Login And Registration Technical Design
本文档定义 v1 登录、三方注册登录、密码设置、邀请码归因和 token 生命周期。它只描述当前架构事实和接口边界,不记录实现流水账。邀请码详细架构见 [Registration Invite Code Architecture](./registration-invite-code-architecture.md)。
## Scope
v1 只有一个用户创建入口:
- 三方身份登录即注册。`provider + provider_subject` 不存在时创建用户、生成 `default_display_user_id`、设置当前 `display_user_id`、绑定三方身份并签发 token。
- 注册邀请码是可选归因能力。非空邀请码如果有效,则在注册事务或资料补全事务内绑定邀请人;邀请码关系一旦绑定不可由客户端修改。
三方登录不拆成“先登录、发现未注册、再注册”两步。客户端调用三方 SDK 拿到 credential 后,只调用 `LoginThirdParty`。服务端根据 `provider + provider_subject` 决定登录还是创建用户,并通过 `is_new_user``profile_completed` 告诉客户端下一步是否进入资料补全页。
v1 的密码能力只有一个前置条件:
- 用户必须已经通过三方登录拿到 access token之后才能设置密码。
v1 不支持:
- 账号密码注册。
- 用户名、邮箱、手机号作为独立登录账号。
- 未登录状态设置密码。
- 手机验证码登录。
- 匿名游客登录。
- 三方登录返回 `NOT_REGISTERED` 后再调用注册接口。
- 多账号主动合并。
- 客户端直接访问 `user-service`
- gateway 保存密码、三方凭证或 refresh token。
## Ownership
登录注册归属 `user-service`gateway 只做外部 HTTP JSON 入口和 gRPC 协议转换。
| Capability | Owner | Rule |
| --- | --- | --- |
| App 解析 | `gateway-service` + `user-service` | gateway 从包名、平台或显式 `app_code` 解析 App`user-service``apps` 表是权威映射 |
| 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`;后续 `popoo``siya` 等 App 只新增 `apps` 映射和对应 provider 配置不复制登录服务。JWT 必须携带 `app_code`,刷新 token、设置密码和密码登录都必须在同一 `app_code` 内查询。
## External HTTP API
所有接口使用 gateway 业务响应 envelope
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {}
}
```
`request_id` 来自 `X-Request-ID` 或由 gateway 生成,并透传到内部 gRPC meta。
### Third Party Login Or Register
```text
POST /api/v1/auth/third-party/login
```
Request:
```json
{
"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`:
```json
{
"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_ids``third_party_identities` 和首个 `auth_sessions``birth` 必须是 `yyyy-mm-dd``platform` 当前只接受 `android``ios`gateway 只接收 `app_version`,不会读取误拼字段。客户端不能提交注册 IP、UA 或 IP 国家,`register_ip``register_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_id``platform``device``os_version``app_version``build_number``language``timezone``source``install_channel``campaign``register_ip``register_user_agent``country_by_ip` |
| 注册页用户资料 | `POST /api/v1/users/me/onboarding/complete` | 用户在注册页主动填写或选择 | `username``avatar``gender``country` |
| 注册邀请归因 | `POST /api/v1/auth/third-party/login``POST /api/v1/users/me/onboarding/complete` | 用户选填的邀请码 | `invite_code` |
`device_id``platform` 是注册创建用户时必须登记的客户端上下文,不是注册页让用户填写的内容,也不进入 `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 `VARCHAR``DATE` 约束错误泄漏成 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=firebase``credential` 统一表示 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
```text
POST /api/v1/auth/password/set
Authorization: Bearer <access_token>
```
Request:
```json
{
"password": "plain_password"
}
```
Response `data`:
```json
{
"password_set": true
}
```
设置密码只接受已登录用户的 access token。客户端不能在 body 中自报 `user_id``display_user_id`。重复设置密码返回 `PASSWORD_ALREADY_SET`,后续如果要支持改密,必须新增独立改密接口和旧密码校验规则。
### Update Profile
```text
POST /api/v1/users/me/profile/update
Authorization: Bearer <access_token>
```
Request:
```json
{
"username": "hy",
"avatar": "https://cdn.example/avatar.png",
"birth": "2000-01-02"
}
```
该接口只修改用户名、头像和生日。`birth` 非空时必须是 `yyyy-mm-dd`;国家不能混在这个接口中修改。
未完成注册用户不通过该接口补齐注册页资料;注册页必须调用 `CompleteOnboarding` 一次性提交 `username/avatar/gender/country`
### Change Country
```text
POST /api/v1/users/me/country/change
Authorization: Bearer <access_token>
```
Request:
```json
{
"country": "US"
}
```
国家修改由 user-service 单独写 `user_country_change_logs`。同一用户按滚动 30 天冷却窗口只能成功修改一次;提交相同国家不写新日志,也不消耗新的冷却窗口。
未完成注册用户的首次国家选择不走该接口,而是跟随 `CompleteOnboarding` 在同一事务内写入。注册完成后的国家修改才进入冷却和审计链路。
### Complete Onboarding
```text
POST /api/v1/users/me/onboarding/complete
Authorization: Bearer <access_token>
```
Request:
```json
{
"username": "hy",
"avatar": "https://cdn.example/avatar.png",
"gender": "male",
"country": "SG",
"invite_code": "A1B2C3"
}
```
Response `data`:
```json
{
"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 token`token.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 侧环境字段,只允许 `android``ios` |
| `app_version/build_number/os_version` | no | `LoginThirdParty` | 注册来源排障和灰度分析字段,不阻塞 onboarding 完成 |
完成规则:
- `username` 必须 trim 后非空,并满足 `users.username` 长度限制。
- `avatar` 必须是合法 `http``https` 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_REQUIRED``PROFILE_REQUIRED` 只用于已登录但资料未完成的业务门禁,不用于三方登录接口。
## App Login State Machine
客户端登录页不需要先问服务端“是否注册”。状态机固定为:
```text
unauthenticated
-> provider_sdk_login
-> POST /api/v1/auth/third-party/login
-> token_received
-> if is_new_user || !profile_completed: onboarding_required
-> else: authenticated_home
```
资料补全页状态机:
```text
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
```text
POST /api/v1/auth/account/login
```
Request:
```json
{
"account": "163000",
"password": "plain_password",
"device_id": "device_xxx"
}
```
`account` 就是用户当前有效短号。gateway 会统一转成 user-service 的 `LoginPasswordRequest.display_user_id`
Response `data`:
```json
{
"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
```text
POST /api/v1/auth/token/refresh
```
Request:
```json
{
"refresh_token": "refresh_xxx",
"device_id": "device_xxx"
}
```
Response `data`:
```json
{
"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
```text
POST /api/v1/auth/logout
```
Request:
```json
{
"refresh_token": "refresh_xxx",
"session_id": "sess_xxx"
}
```
Response `data`:
```json
{
"revoked": true
}
```
## Internal gRPC Contract
`api/proto/user/v1/auth.proto` 承载 auth RPC
```protobuf
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);
}
```
关键消息:
```protobuf
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
```protobuf
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_required``completed`,后续可以追加 `country_required``age_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_value``163000` |
| `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.username``users.avatar``users.gender``users.country``users.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=pretty``current_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 |
唯一约束:
```text
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
```mermaid
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 的实现只允许在本地测试替身中使用,不能用于线上注册入口。
新用户首次登录会直接得到 token`profile_completed=false`。客户端必须跳转资料补全页,而不是再次调用注册接口。`registration_context` 来自客户端环境和 gateway 请求上下文,不等于用户完成资料。
### Complete Onboarding
```mermaid
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
```mermaid
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
```mermaid
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
```mermaid
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
```json
{
"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_ids``third_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_id``is_new_user=true`
- 三方新用户首次登录返回 `profile_completed=false``onboarding_status=profile_required`
- 三方新用户创建时写入 `device_id/platform/app_version/build_number/os_version/language/timezone` 等注册上下文,但不把这些字段作为注册页用户输入。
- 注册页提交 `username/avatar/gender/country`user-service 在同一事务内写用户资料、解析 `region_id` 并返回 `profile_completed=true`
- 新用户创建时生成自己的 active 邀请码,`GET /api/v1/users/me/overview` 可以展示该邀请码。
- 注册页提交有效 `invite_code`user-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_id``is_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_LIMITED`Redis 频控 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 继续使用多久,以及迁移非对称签名的时间点。