# User ID Technical Design

本文档定义数值型用户主键、默认短号、临时靓号的职责边界、数据库模型、跨服务通信规则和修改流程。核心原则：`user_id` 是唯一用户主键，后端和客户端响应都使用它；`display_user_id` 只做展示、搜索、登录名和运营入口。

## Terminology

| Field | Meaning | Mutable | Unique | Used By Internal Services |
| --- | --- | --- | --- | --- |
| `user_id` | 数值型用户 ID，服务间主关联和客户端响应统一使用 | No | Yes | Yes |
| `default_display_user_id` | 用户本身的默认短号 | Yes by explicit change | Yes while owned | No |
| `current_display_user_id` | 当前对外展示、搜索、登录的短号 | Yes | Yes while effective | No |
| `pretty_display_user_id` | 用户申请的临时靓号 | Yes by lease | Yes while leased | No |
| `display_user_id` | API 中返回的当前展示号，等同 `current_display_user_id` | Yes | Yes while effective | No |
| `display_user_id_kind` | 当前展示号来源，`default` 或 `pretty` | Yes | No | No |
| `display_user_id_expires_at_ms` | 当前靓号过期时间；默认短号为空 | Yes | No | No |

示例：

```text
user_id: 918274650129384448
default_display_user_id: 163000
current_display_user_id: 888888
display_user_id_kind: pretty
display_user_id_expires_at_ms: 1777000000000
```

`user_id` 是内部关系主键，也可以作为接口响应中的用户 ID。`default_display_user_id` 是用户本身的号。临时靓号有效期内会覆盖当前展示号；过期后必须恢复到 `default_display_user_id`。

所有展示号唯一性都限定在 `app_code` 内。不同 App 可以使用同一个 `display_user_id`，但同一个 App 内只能有一个当前有效归属。

## Design Principles

1. HTTP、客户端 JSON 和 JWT claims 中的 `user_id` 一律返回数值型用户 ID 的字符串形式。
2. 跨服务通信一律传数值型 `user_id`。
3. 数据库主关联一律存数值型 `user_id`，并用 `app_code` 做租户作用域。
4. outbox、审计、账单、房间状态、IM session、活动记录一律存数值型 `user_id`。
5. `display_user_id` 只允许出现在展示、搜索、分享、登录名、客服查询和运营配置入口。
6. 任何通过 `display_user_id` 发起的请求，都必须先在 `user-service` 解析成内部 `user_id`，后续链路只传内部 `user_id`。
7. `display_user_id` 可修改，所以不能作为幂等键、外键、账务主体或事件主体。
8. 临时靓号不能修改 `user_id`，也不能删除或转移用户的默认短号。
9. 当前展示号的真实来源必须可解释：默认短号来自永久归属，临时靓号来自有效租约。
10. `ResolveDisplayUserID`、密码登录和用户资料查询都必须先处理过期靓号，再返回当前有效短号。
11. 靓号过期恢复必须事务化，不能只靠缓存 TTL。

## Ownership

`user-service` 是 `user_id` 和 `display_user_id` 的唯一 owner。

| Capability | Owner | Rule |
| --- | --- | --- |
| 生成 `user_id` | `user-service` | 注册或三方首次登录时生成 |
| 分配默认短号 | `user-service` | 新用户创建时按 App 序列分配，首号 `163000` |
| 修改默认短号 | `user-service` | 事务内检查唯一性和冷却期 |
| 申请临时靓号 | `user-service` | 创建靓号租约并覆盖当前展示号 |
| 过期临时靓号 | `user-service` | 恢复当前展示号为默认短号 |
| 解析 `display_user_id` | `user-service` | 只返回当前有效归属 |
| 房间状态 | `room-service` | 只保存 `user_id` |
| 钱包账务 | `wallet-service` | 只保存 `user_id` |
| IM 连接 | 腾讯云 IM | identifier 使用内部长 `user_id` 的十进制字符串 |
| 活动结算 | `activity-service` | 只保存 `user_id` |

其他服务不能自己缓存长期 `display_user_id -> user_id` 绑定作为事实来源。短期缓存必须有 TTL，并允许 user-service 失效或回源。

## Role Identity Facts

用户 ID 和短号不表达业务身份。主播、Agency、BD、BD Leader、coin_seller 都是挂在同一个 `user_id` 下的业务事实，不应该把身份塞进 `display_user_id` 或 JWT 文案里。

| Identity | Table | Can Coexist | Account Boundary |
| --- | --- | --- | --- |
| `host` | `host_profiles` | 可和 BD、BD Leader、coin_seller 并存 | 主播收益余额在 `wallet-service` |
| `agency owner` | `agencies` + owner membership | 可和 BD、BD Leader、coin_seller 并存 | Agency 工资结算后入钱包 |
| `bd` | `bd_profiles(role='bd')` | 可和 host、Agency、coin_seller 并存 | BD 工资结算后入钱包 |
| `bd_leader` | `bd_profiles(role='bd_leader')` | 可和 host、Agency、coin_seller 并存 | BD Leader 工资结算后入钱包 |
| `coin_seller` | `coin_seller_profiles` | 可和 Agency、BD、BD Leader 并存 | 币商专用资产 `COIN_SELLER_COIN` |

`coin_seller` 是币商身份，拥有独立于玩家普通 `COIN` 的币商金币账户。币商给玩家转金币时，内部链路只能使用数值型 `user_id`：gateway 校验当前用户是 active `coin_seller`，wallet-service 扣减该用户 `COIN_SELLER_COIN` 并给目标用户增加普通 `COIN`。`display_user_id` 只可用于后台或客户端搜索目标用户，解析后必须转换成内部 `user_id` 再进入账务。

## Database Model

### `users`

用户主表保存不可变主键、默认短号、当前展示号快照，以及注册资料和来源快照。

```sql
CREATE TABLE users (
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  user_id BIGINT NOT NULL PRIMARY KEY,
  default_display_user_id VARCHAR(32) NOT NULL,
  current_display_user_id VARCHAR(32) NOT NULL,
  current_display_user_id_kind VARCHAR(32) NOT NULL,
  current_display_user_id_expires_at_ms BIGINT NULL,
  username VARCHAR(64) NULL,
  gender VARCHAR(32) NULL,
  country VARCHAR(64) NULL,
  invite_code VARCHAR(64) NULL,
  register_ip VARCHAR(64) NULL,
  register_user_agent VARCHAR(512) NULL,
  country_by_ip VARCHAR(64) NULL,
  register_device_id VARCHAR(128) NULL,
  register_device VARCHAR(128) NULL,
  os_version VARCHAR(64) NULL,
  avatar VARCHAR(512) NULL,
  birth_date DATE NULL,
  app_version VARCHAR(64) NULL,
  build_number VARCHAR(64) NULL,
  source VARCHAR(64) NULL,
  install_channel VARCHAR(64) NULL,
  campaign VARCHAR(128) NULL,
  platform VARCHAR(16) NULL,
  language VARCHAR(32) NULL,
  timezone VARCHAR(64) NULL,
  status VARCHAR(32) NOT NULL,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  UNIQUE KEY uk_users_default_display_user_id (app_code, default_display_user_id),
  UNIQUE KEY uk_users_current_display_user_id (app_code, current_display_user_id)
);
```

`current_display_user_id` 是读优化快照，不是变更历史来源。它必须和 `user_display_user_ids` 的当前有效记录保持一致。

快照规则：

| Case | `current_display_user_id` | `current_display_user_id_kind` | `current_display_user_id_expires_at_ms` |
| --- | --- | --- | --- |
| 没有靓号 | `default_display_user_id` | `default` | `NULL` |
| 靓号有效 | 靓号 | `pretty` | 靓号过期时间 |
| 靓号过期后 | `default_display_user_id` | `default` | `NULL` |

### `user_display_user_ids`

短号归属历史表。默认短号和临时靓号都进入这张表。

```sql
CREATE TABLE user_display_user_ids (
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  id BIGINT NOT NULL PRIMARY KEY,
  display_user_id VARCHAR(32) NOT NULL,
  user_id BIGINT NOT NULL,
  display_user_id_kind VARCHAR(32) NOT NULL,
  status VARCHAR(32) NOT NULL,
  assigned_at_ms BIGINT NOT NULL,
  activated_at_ms BIGINT NULL,
  expires_at_ms BIGINT NULL,
  released_at_ms BIGINT NULL,
  release_reason VARCHAR(64) NULL,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  live_display_user_id VARCHAR(32)
    GENERATED ALWAYS AS (
      CASE
        WHEN status IN ('active', 'held', 'reserved', 'blocked') THEN display_user_id
        ELSE NULL
      END
    ) STORED,
  active_user_id BIGINT
    GENERATED ALWAYS AS (
      CASE WHEN status = 'active' THEN user_id ELSE NULL END
    ) STORED,
  UNIQUE KEY uk_user_display_user_ids_live (app_code, live_display_user_id),
  UNIQUE KEY uk_user_display_user_ids_active_user (app_code, active_user_id),
  KEY idx_user_display_user_ids_user_id (app_code, user_id)
);
```

`status` 建议：

```text
active
held
expired
released
reserved
blocked
```

状态含义：

| Status | Meaning |
| --- | --- |
| `active` | 当前可被普通业务解析的展示号 |
| `held` | 默认短号仍归用户所有，但被临时靓号覆盖，普通业务不解析 |
| `expired` | 临时靓号到期 |
| `released` | 曾经归属用户，已经释放 |
| `reserved` | 系统预留，不允许普通用户申请 |
| `blocked` | 运营阻断，不允许分配 |

生成列用于模拟部分唯一索引：同一个短号不能同时处于 `active`、`held`、`reserved` 或 `blocked`；同一个用户只能有一个 `active` 当前展示号。历史 `expired`、`released` 记录可以保留多条。

### `pretty_display_user_id_leases`

靓号租约表保存临时覆盖关系。它是“过期恢复默认短号”的事实来源。

```sql
CREATE TABLE pretty_display_user_id_leases (
  lease_id VARCHAR(64) NOT NULL PRIMARY KEY,
  display_user_id VARCHAR(32) NOT NULL,
  user_id BIGINT NOT NULL,
  status VARCHAR(32) NOT NULL,
  starts_at_ms BIGINT NOT NULL,
  expires_at_ms BIGINT NOT NULL,
  payment_receipt_id VARCHAR(64) NULL,
  source VARCHAR(32) NOT NULL,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  active_display_user_id VARCHAR(32)
    GENERATED ALWAYS AS (
      CASE WHEN status = 'active' THEN display_user_id ELSE NULL END
    ) STORED,
  active_user_id BIGINT
    GENERATED ALWAYS AS (
      CASE WHEN status = 'active' THEN user_id ELSE NULL END
    ) STORED,
  UNIQUE KEY uk_pretty_display_user_id_active (active_display_user_id),
  UNIQUE KEY uk_pretty_display_user_id_active_user (active_user_id),
  KEY idx_pretty_display_user_id_leases_user (user_id, status),
  KEY idx_pretty_display_user_id_leases_expire (status, expires_at_ms)
);
```

`status` 建议：

```text
active
expired
cancelled
refunded
```

首版规则：

- 一个用户同一时间只能有一个 active 靓号租约。
- 一个靓号同一时间只能租给一个用户。
- 申请新靓号不会改变 `default_display_user_id`。
- 靓号有效期内，默认短号处于 `held`，不能被其他用户申请。
- 靓号过期后，必须把 `users.current_display_user_id` 恢复为 `users.default_display_user_id`。

### `display_user_id_change_logs`

短号修改审计表。

```sql
CREATE TABLE display_user_id_change_logs (
  id BIGINT NOT NULL PRIMARY KEY,
  user_id BIGINT NOT NULL,
  old_display_user_id VARCHAR(32) NOT NULL,
  new_display_user_id VARCHAR(32) NOT NULL,
  old_display_user_id_kind VARCHAR(32) NOT NULL,
  new_display_user_id_kind VARCHAR(32) NOT NULL,
  change_type VARCHAR(32) NOT NULL,
  lease_id VARCHAR(64) NULL,
  reason VARCHAR(64) NOT NULL,
  operator_user_id BIGINT NULL,
  request_id VARCHAR(64) NOT NULL,
  created_at_ms BIGINT NOT NULL,
  KEY idx_display_user_id_change_logs_user_id (user_id),
  KEY idx_display_user_id_change_logs_request_id (request_id)
);
```

修改短号必须写审计。审计失败不能静默忽略；如果审计和主变更不能同事务完成，需要进入 user-service outbox 补偿。

### `user_country_change_logs`

国家修改日志只服务资料审计和冷却期，不参与 `display_user_id` 解析。

```sql
CREATE TABLE user_country_change_logs (
  id BIGINT NOT NULL PRIMARY KEY,
  user_id BIGINT NOT NULL,
  old_country VARCHAR(64) NULL,
  new_country VARCHAR(64) NOT NULL,
  request_id VARCHAR(96) NOT NULL,
  changed_at_ms BIGINT NOT NULL,
  KEY idx_user_country_change_logs_user_changed (user_id, changed_at_ms),
  KEY idx_user_country_change_logs_request_id (request_id)
);
```

同一用户修改国家按滚动 30 天冷却。冷却期检查必须在锁定 `users` 行后的事务内读取最新日志，不能只依赖 gateway 侧判断。

## ID Generation

### `user_id`

`user_id` 使用 int64，在服务间 RPC、数据库关系、客户端响应和内部审计中统一使用。人工输入、搜索、分享和登录名使用 `display_user_id`。

要求：

- `user_id` 全局唯一。
- 不可变。
- 客户端不能自报或修改 `user_id`。
- `user_id` 不依赖数据库自增暴露业务规模。

可选实现：

```text
Snowflake-like ID
Sonyflake-like ID
database segment allocator
```

当前实现由 user-service 创建 `user_id`。

### `display_user_id`

`display_user_id` 推荐用字符串保存，即使首版只允许数字。

原因：

- 后续可能支持靓号、带前缀号、运营号段。
- 字符串可以保留前导零。
- 可以统一处理大小写和格式归一化。

首版格式可以定为：

```text
^[1-9][0-9]{5,10}$
```

格式规则必须由 user-service 校验。客户端校验只做体验，不作为安全边界。

## Pretty Display User ID Model

临时靓号是短号的一种，但它不是永久改号。它只是在有效期内覆盖当前展示号。

有效展示号计算规则：

```text
if active_pretty_lease exists and now < expires_at_ms:
  display_user_id = pretty_display_user_id
  display_user_id_kind = pretty
  display_user_id_expires_at_ms = expires_at_ms
else:
  display_user_id = default_display_user_id
  display_user_id_kind = default
  display_user_id_expires_at_ms = null
```

首版不做多个靓号叠加。用户已有 active 靓号时，再申请其他靓号返回 `DISPLAY_USER_ID_PRETTY_ACTIVE`。如果后续要支持续期，只允许对同一个靓号延长 `expires_at_ms`，不能生成第二条 active 租约。

默认短号和临时靓号的关系：

| Scenario | Behavior |
| --- | --- |
| 新用户注册 | 分配默认短号，当前展示号等于默认短号 |
| 申请靓号成功 | 当前展示号改为靓号，默认短号变为 `held` |
| 靓号有效期内搜索 | 只能通过靓号解析到用户 |
| 靓号有效期内密码登录 | 只能通过当前靓号登录 |
| 靓号过期 | 当前展示号恢复默认短号 |
| 靓号过期后搜索 | 靓号不再解析，默认短号重新解析 |

默认短号在 `held` 期间仍属于原用户，不能被其他用户申请、租赁或永久占用。

## Cross-Service Contract

内部 proto 字段统一使用：

```protobuf
int64 user_id = 1;
```

禁止在内部主链路使用：

```protobuf
string display_user_id = 1;
```

允许 user-service 暴露解析接口：

```protobuf
service UserIdentityService {
  rpc ResolveDisplayUserID(ResolveDisplayUserIDRequest) returns (ResolveDisplayUserIDResponse);
  rpc GetUserIdentity(GetUserIdentityRequest) returns (GetUserIdentityResponse);
  rpc ApplyPrettyDisplayUserID(ApplyPrettyDisplayUserIDRequest) returns (ApplyPrettyDisplayUserIDResponse);
}

message ResolveDisplayUserIDRequest {
  string display_user_id = 1;
}

message ResolveDisplayUserIDResponse {
  int64 user_id = 1;
  string display_user_id = 2;
  string status = 3;
  string default_display_user_id = 4;
  string display_user_id_kind = 5;
  int64 display_user_id_expires_at_ms = 6;
}

message GetUserIdentityRequest {
  int64 user_id = 1;
}

message GetUserIdentityResponse {
  int64 user_id = 1;
  string display_user_id = 2;
  string status = 3;
  string default_display_user_id = 4;
  string display_user_id_kind = 5;
  int64 display_user_id_expires_at_ms = 6;
}

message ApplyPrettyDisplayUserIDRequest {
  int64 user_id = 1;
  string pretty_display_user_id = 2;
  int64 lease_duration_ms = 3;
  string payment_receipt_id = 4;
}

message ApplyPrettyDisplayUserIDResponse {
  int64 user_id = 1;
  string display_user_id = 2;
  string default_display_user_id = 3;
  string display_user_id_kind = 4;
  int64 display_user_id_expires_at_ms = 5;
  string lease_id = 6;
}
```

`ResolveDisplayUserID` 只能返回当前有效归属。默认短号被靓号覆盖时处于 `held`，普通解析接口不能返回它。历史归属查询必须使用运营或审计接口，不能给普通业务链路使用。

## Service Usage Rules

### gateway-service

gateway 可以接收客户端传入的 `display_user_id`，但只能用于调用 `user-service.ResolveDisplayUserID`。

解析后：

```text
display_user_id -> user_id -> downstream RPC
```

gateway 不缓存永久映射。

密码登录入口也按当前展示号解析。用户有 active 靓号时，默认短号暂时不能登录；靓号过期后，默认短号恢复登录能力。

### room-service

Room Cell 中只保存：

```text
OwnerID int64
HostID int64
MicSeat.UserID int64
OnlineUsers map[int64]
AdminUsers map[int64]bool
BanUsers map[int64]bool
MuteUsers map[int64]bool
```

房间状态、snapshot、command log、outbox 事件都不能保存 `display_user_id` 作为身份字段。

如果房间广播需要展示短号，由客户端或网关资料接口通过 user-service 补全；Room Cell 不承担资料拼装。

### Tencent Cloud IM

腾讯云 IM identifier 只使用不可变长 `user_id` 的十进制字符串：

```text
user_id
```

单聊消息、系统通知、群消息和后续回调校验都使用 `user_id`。

客户端通过短号发起私聊时，必须先解析成 `user_id`。

### wallet-service

账务主体只允许是：

```text
user_id
```

账单、ledger、幂等记录、余额账户都不能用 `display_user_id`。

### activity-service

活动积分、奖励、风控、排行榜内部主体只用 `user_id`。

活动榜单展示短号时，展示层再补资料。活动结算不能依赖短号。

## User Creation Flow

新用户创建时必须同时分配 `user_id` 和 `default_display_user_id`。创建完成后，`current_display_user_id` 等于默认短号。

```mermaid
sequenceDiagram
  participant U as user-service
  participant DB as MySQL

  U->>U: generate user_id
  U->>U: allocate default_display_user_id from 163000 sequence
  U->>DB: begin transaction
  U->>DB: insert users(default=current)
  U->>DB: insert user_display_user_ids(default,active)
  U->>DB: commit
```

如果默认短号唯一冲突，重新生成候选短号后重试。重试次数必须有限，超过后返回 `DISPLAY_USER_ID_ALLOCATE_FAILED`。

## Change Default Display User ID Flow

修改默认短号必须事务化。首版建议禁止在 active 靓号期间修改默认短号，避免“过期恢复哪个号”的用户理解成本；如果业务必须支持，需要在变更日志中明确记录新的恢复目标。

```mermaid
sequenceDiagram
  participant C as Client
  participant G as gateway-service
  participant U as user-service
  participant DB as MySQL

  C->>G: ChangeDisplayUserID
  G->>U: ChangeDisplayUserID(user_id,new_default_display_user_id)
  U->>U: validate format and cooldown
  U->>DB: begin transaction
  U->>DB: lock user row
  U->>DB: reject if active pretty lease exists
  U->>DB: lock current default display_user_id
  U->>DB: check new display_user_id uniqueness
  U->>DB: mark old default display_user_id released
  U->>DB: insert new default display_user_id active
  U->>DB: update users.default_display_user_id
  U->>DB: update users.current_display_user_id
  U->>DB: insert display_user_id_change_logs
  U->>DB: commit
```

修改成功后，user-service 可以发布用户资料变更事件，供客户端资料缓存或服务端资料缓存刷新。腾讯云 IM 的 identifier 不随短号变化。

## Apply Pretty Display User ID Flow

申请临时靓号必须先确认靓号可用、租期合法、支付结果有效。金币扣费或订单支付归属账务服务；user-service 只消费可信的 `payment_receipt_id` 或运营授权结果。

```mermaid
sequenceDiagram
  participant C as Client
  participant G as gateway-service
  participant U as user-service
  participant DB as MySQL

  C->>G: ApplyPrettyDisplayUserID
  G->>U: ApplyPrettyDisplayUserID(user_id,pretty_display_user_id,duration,receipt)
  U->>U: validate format, duration, receipt
  U->>DB: begin transaction
  U->>DB: lock user row
  U->>DB: reject if user already has active pretty lease
  U->>DB: check pretty display_user_id availability
  U->>DB: mark default display_user_id held
  U->>DB: insert pretty_display_user_id_leases(active)
  U->>DB: insert user_display_user_ids(pretty,active,expires_at_ms)
  U->>DB: update users.current_display_user_id=pretty
  U->>DB: insert display_user_id_change_logs(change_type=pretty_apply)
  U->>DB: commit
```

申请成功后：

- `ResolveDisplayUserID(pretty)` 返回该用户。
- `ResolveDisplayUserID(default)` 不返回该用户。
- `GetUserIdentity(user_id)` 返回 `display_user_id_kind=pretty` 和 `display_user_id_expires_at_ms`。
- 密码登录使用当前靓号，默认短号暂时不能作为登录名。

## Expire Pretty Display User ID Flow

靓号过期有两条触发路径：

| Path | Rule |
| --- | --- |
| 定时任务 | 扫描 `pretty_display_user_id_leases(status=active, expires_at_ms<=now)` |
| 懒过期 | `ResolveDisplayUserID`、`GetUserIdentity`、密码登录发现快照过期时触发 |

过期恢复必须走同一段事务逻辑：

```mermaid
sequenceDiagram
  participant U as user-service
  participant DB as MySQL

  U->>DB: begin transaction
  U->>DB: lock user row
  U->>DB: lock active pretty lease
  U->>DB: mark pretty lease expired
  U->>DB: mark pretty display_user_id expired
  U->>DB: mark default display_user_id active
  U->>DB: update users.current_display_user_id=default
  U->>DB: clear users.current_display_user_id_expires_at_ms
  U->>DB: insert display_user_id_change_logs(change_type=pretty_expire)
  U->>DB: commit
```

过期成功后：

- 旧靓号不能继续登录、搜索或分享解析。
- 默认短号恢复为当前展示号。
- user-service 必须失效 `user_id -> display_user_id` 和相关 `display_user_id -> user_id` 缓存。
- 如果过期事务失败，不能只删缓存；必须重试事务或告警。

## Display User ID Reuse

默认建议：短号释放或靓号过期后进入冷却期，不立即复用。

状态流转：

```text
default: active -> held -> active
default change: active -> released -> reserved -> active
pretty lease: active -> expired -> reserved -> active
```

首版可以先不复用已释放默认短号；靓号是否可再次出租由运营库存规则决定，但必须有冷却期和历史归属审计。

如果后续要复用：

- 需要冷却期。
- 需要运营规则。
- 需要历史归属审计。
- 普通业务查询只能返回当前有效归属。

## API And Proto Additions

建议在：

```text
api/proto/user/v1/user.proto
```

增加：

```protobuf
message UserIdentity {
  int64 user_id = 1;
  string display_user_id = 2;
  string status = 3;
  string default_display_user_id = 4;
  string display_user_id_kind = 5;
  int64 display_user_id_expires_at_ms = 6;
}

message GetUserIdentityRequest {
  int64 user_id = 1;
}

message GetUserIdentityResponse {
  UserIdentity identity = 1;
}

message ResolveDisplayUserIDRequest {
  string display_user_id = 1;
}

message ResolveDisplayUserIDResponse {
  UserIdentity identity = 1;
}

message ChangeDisplayUserIDRequest {
  RequestMeta meta = 1;
  int64 user_id = 2;
  string new_display_user_id = 3;
}

message ChangeDisplayUserIDResponse {
  UserIdentity identity = 1;
}

message ApplyPrettyDisplayUserIDRequest {
  RequestMeta meta = 1;
  int64 user_id = 2;
  string pretty_display_user_id = 3;
  int64 lease_duration_ms = 4;
  string payment_receipt_id = 5;
}

message ApplyPrettyDisplayUserIDResponse {
  UserIdentity identity = 1;
  string lease_id = 2;
}

message ExpirePrettyDisplayUserIDRequest {
  RequestMeta meta = 1;
  int64 user_id = 2;
  string lease_id = 3;
}

message ExpirePrettyDisplayUserIDResponse {
  UserIdentity identity = 1;
}
```

HTTP 外部接口由 gateway 暴露：

```text
GET /api/v1/users/by-display-user-id/{display_user_id}
GET /api/v1/users/{user_id}/identity
POST /api/v1/users/me/display-id/change
POST /api/v1/users/me/display-id/pretty/apply
```

具体路径可以后续和 OpenAPI 一起定，但内部 proto 必须先保证主链路只用 `user_id`。

## Error Reasons

user-service 需要增加这些 reason：

| Reason | gRPC Code | Meaning |
| --- | --- | --- |
| `DISPLAY_USER_ID_INVALID` | `InvalidArgument` | 短号格式非法 |
| `DISPLAY_USER_ID_EXISTS` | `AlreadyExists` | 短号已被当前有效、持有、预留或阻断 |
| `DISPLAY_USER_ID_COOLDOWN` | `FailedPrecondition` | 修改短号冷却期未结束 |
| `COUNTRY_CHANGE_COOLDOWN` | `FailedPrecondition` | 修改国家冷却期未结束 |
| `DISPLAY_USER_ID_NOT_FOUND` | `NotFound` | 当前有效短号不存在 |
| `DISPLAY_USER_ID_ALLOCATE_FAILED` | `Internal` | 默认短号分配失败 |
| `DISPLAY_USER_ID_PRETTY_NOT_AVAILABLE` | `FailedPrecondition` | 靓号不可申请、已被租用或被预留 |
| `DISPLAY_USER_ID_PRETTY_ACTIVE` | `FailedPrecondition` | 用户已有 active 靓号租约 |
| `DISPLAY_USER_ID_LEASE_EXPIRED` | `FailedPrecondition` | 靓号租约已经过期 |
| `DISPLAY_USER_ID_PAYMENT_REQUIRED` | `FailedPrecondition` | 申请靓号缺少有效支付凭证 |

gateway 映射到 HTTP envelope 时沿用相同 `code`。

## Caching Rules

允许缓存：

```text
user_id -> display_user_id
display_user_id -> user_id
```

限制：

- `display_user_id -> user_id` 必须短 TTL。
- 靓号缓存 TTL 不能超过 `display_user_id_expires_at_ms - now`。
- 申请靓号后必须失效默认短号和新靓号相关缓存。
- 靓号过期后必须失效旧靓号、默认短号和用户身份缓存。
- 修改默认短号后必须主动失效相关缓存。
- 账务、房间、活动不能依赖缓存中的短号做身份判断。
- 缓存 miss 必须回源 user-service。
- 缓存命中也必须尊重 `expires_at_ms`，不能返回已过期靓号。

## Migration Impact

后续修改现有 proto 时：

- 所有跨服务用户字段保持 `int64 user_id`。
- 如果要额外返回短号，只能追加 `string display_user_id`，不能替换 `user_id`。
- 如果返回当前展示号，必须同时考虑追加 `display_user_id_kind` 和 `display_user_id_expires_at_ms`，避免客户端把临时靓号误认为永久短号。
- 历史 outbox 事件不补写短号。
- 客户端显示短号时通过用户资料接口或缓存补全。

## Test Matrix

必须覆盖：

- 新用户创建时生成唯一 `user_id` 和默认 `display_user_id`。
- 默认短号冲突时可重试。
- 通过当前有效 `display_user_id` 解析到正确 `user_id`。
- 不存在的短号返回 `DISPLAY_USER_ID_NOT_FOUND`。
- 修改短号成功后旧短号不再解析到用户。
- 修改短号遇到已占用短号返回 `DISPLAY_USER_ID_EXISTS`。
- 冷却期内修改返回 `DISPLAY_USER_ID_COOLDOWN`。
- 申请靓号成功后，当前展示号变为靓号，默认短号进入 `held`。
- 靓号有效期内，默认短号不能被普通解析接口解析。
- 靓号有效期内，密码登录使用当前靓号。
- 用户已有 active 靓号时再次申请其他靓号返回 `DISPLAY_USER_ID_PRETTY_ACTIVE`。
- 靓号过期后，当前展示号恢复默认短号。
- 靓号过期后，旧靓号不再解析到用户。
- 懒过期和定时过期并发执行时，只能有一个事务成功。
- 房间、钱包、IM、活动 proto 和存储仍只使用 `user_id` 作为主体。

## Final Rule

`user_id` 是系统身份，`default_display_user_id` 是用户本身的门牌号，临时靓号只是限时覆盖当前门牌号。

服务之间传 `user_id`。客户端展示、搜索、分享可以用 `display_user_id`。
