hyapp-server/docs/用户区域与国家开发.md
2026-05-13 16:07:25 +08:00

918 lines
43 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.

# User Region And Country Mapping Development Guide
本文档定义“区域”能力的需求和开发方案:区域包含多个国家;用户注册或修改国家时,如果国家命中某个区域,则用户属于该区域。区域后续可以由管理系统新建、修改和停用。
## Scope
| Capability | Owner | Outcome |
| --- | --- | --- |
| 国家主数据存储 | user DB | 保存可选国家、国家码和中文展示名 |
| 区域主数据存储 | user DB | 保存区域、国家归属和区域变更审计 |
| 注册归属区域 | `user-service` | 首次注册写入 `users.country` 时同步解析并写入 `users.region_id` |
| 修改国家归属区域 | `user-service` | `ChangeUserCountry` 成功后同步重算 `users.region_id` |
| App 国家/区域查询 | `user-service` | App 注册、改国家、用户投影只读国家和 active 区域映射 |
| 后台国家/区域管理 | `hyapp-admin-server` | 创建国家、启用/禁用国家、创建区域、启用/禁用区域属于后台管理后端能力;直接写 user DB 并写后台审计,不新增 user-service RPC 或 user-service 业务逻辑 |
| 后台修改用户国家 | `hyapp-admin-server` | App 用户列表中的国家修改属于后台操作;写 `users.country` 时同步按 active 国家映射刷新 `users.region_id`,不走 App 国家修改冷却 |
| 用户资料展示 | `user-service` | 用户投影可以返回当前国家和区域信息 |
## Non-Goals
| Non-goal | Reason |
| --- | --- |
| 不把区域放进 `room-service` | 区域是用户主数据维度,不属于房间状态 |
| 不用客户端直接提交 `region_id` | 区域必须由服务端按国家映射计算,避免客户端伪造 |
| 不用客户端直接提交 `country_id` | 客户端只提交 `country_code`,服务端按国家表解析内部 ID |
| 不用 `country_by_ip` 判定区域 | `country_by_ip` 是入口 IP 快照,只用于审计和风控辅助,不代表用户选择国家 |
| 不在 gateway 保存区域规则 | gateway 只做入口、鉴权、协议转换和内部 RPC 编排 |
| 不允许一个国家同时属于多个 active 区域 | 否则注册归属会产生歧义 |
## Current State
当前已经存在的事实:
- 注册资料由 `gateway-service``/api/v1/auth/third-party/login` 接收,转发给 `user-service.LoginThirdParty`
- 当前 `user-service` 只按两个大写字母校验注册 `country`;区域功能需要改成按国家表校验 2 到 3 位大写英文国家码。
- `users.country` 保存客户端选择国家的 `country_code`,不保存 `country_id`
- `country_by_ip` 只从可信 header 或 gateway 上下文生成,不接受客户端 JSON。
- App 用户自助修改国家走独立 RPC 和 HTTP API`ChangeUserCountry` / `/api/v1/users/me/country/change`
- 国家修改会写 `user_country_change_logs`,并有滚动 30 天冷却窗口。
- 后台管理端修改 App 用户国家走 `hyapp-admin-server` 的 App 用户管理接口,校验 `enabled=true` 国家后直接写用户库,并记录后台操作审计;该操作不写 App 自助国家修改冷却日志。
因此 App 侧区域解析、注册归属和用户自助改国家归属应该挂在 `user-service` 的用户主数据链路上;后台创建/启停国家和区域、后台修改用户国家属于 `hyapp-admin-server`
## Domain Rules
### Country
国家是用户资料、区域归属和注册页国家选择的基础主数据。客户端和外部 HTTP API 只使用 `country_code``country_id` 是服务端内部主键,便于管理端编辑和审计。
字段语义:
| Field | Rule |
| --- | --- |
| `country_id` | 服务端生成的内部主键,不给客户端提交 |
| `country_name` | 稳定英文或拼音名称,例如 `China``United States` |
| `country_code` | App 内部 canonical 国家码,首版按 ISO 3166-1 alpha-2 保存,例如 `CN``SG``XK` 作为产品覆盖需要的 user-assigned code |
| `iso_alpha3` | ISO 3166-1 alpha-3 代码,例如 `CHN``SGP`;没有正式 ISO 分配时允许为空或 user-assigned 值 |
| `iso_numeric` | ISO 3166-1 numeric / UNSD M49 三位数字代码,例如中国 `156`;没有正式数字码时为空 |
| `country_display_name` | 中文展示名,例如 `中国``美国` |
| `phone_country_code` | 国际电话区号,使用 E.164 国家呼叫码格式,例如中国 `+86`、新加坡 `+65`;没有可用电话元数据时为空 |
| `flag` | 国家或地区 emoji flag注册页展示用没有可展示 flag 时保存空字符串 |
| `enabled` | App 是否开放该国家,默认 `true``false` 时不出现在 App 注册国家列表,且不能被新注册或改国家选择 |
规则:
- `countries.country_code` 是本系统国家码权威来源。同一个真实国家只能有一条记录,运营配置必须避免同时把 `US``USA` 当成两个国家主键。
- `country_code` 创建后视为不可变。需要改码时新增国家记录并通过重算任务更新 `users.country``region_countries` 和审计口径。
- `iso_alpha3``iso_numeric``phone_country_code` 是国家主数据展示和搜索辅助字段,不参与用户区域归属判断。
- `iso_numeric` 必须按三位字符串保存,不能保存成整数,否则 `020` 这类前导零数字码会被破坏。
- `phone_country_code` 可以被多个国家或地区共享,例如 `US``CA` 都是 `+1`,因此不能加唯一约束。
- `flag` 只做展示,不参与国家归属、区域映射、排序和权限判断。
- `enabled` 是产品开放开关,不是删除标记。禁用国家不会清空历史用户的 `users.country`,只阻止后续注册和改国家选择。
- App 注册页国家列表只返回 `enabled=true` 的国家。
- 注册和改国家都必须先把输入 trim、转大写再校验 `country_code`,然后命中 `enabled=true``countries`
- 区域国家配置只要求国家记录存在;`enabled=false` 的国家允许预先配置到区域,但不允许 App 用户选择。
- `users.country` 保存 `country_code` 字符串;服务端展示时可以按 `country_code` 拼出 `country_id/country_name/country_display_name/iso_numeric/phone_country_code`
- `country_by_ip` 只做审计和风控快照;如果边缘层给出的国家码不在 `countries`user-service 应丢弃或记录为空,不能参与区域归属。
### Country Seed Data
`services/user-service/deploy/data/countries.json` 是国家初始化和后续映射配置的种子数据。它不是 `countries` 表的一比一结构。
每个国家对象必须保留这些字段:
| Field | Stored In `countries` | Rule |
| --- | --- | --- |
| `country_name` | yes | 英文国家名 |
| `country_code` | yes | App canonical 国家码 |
| `country_display_name` | yes | 中文展示名 |
| `iso_alpha3` | yes | ISO alpha-3 或 user-assigned 值 |
| `iso_numeric` | yes | 三位 numeric 字符串 |
| `phone_country_code` | yes | 国际电话区号 |
| `flag` | yes | App 展示用 emoji flag |
| `status` | yes | 默认 `active` |
| `sort_order` | yes | App 注册页排序 |
| `region` | no | 地理大区,例如 `Asia``Africa`,只用于运营筛选和后续人工调整参考 |
| `region_display_name` | no | `region` 的中文展示名,例如 `亚洲``非洲`,只用于配置和运营展示 |
| `subregion` | no | 地理子区域,例如 `South-Eastern Asia`,只用于运营筛选和后续人工调整参考 |
| `subregion_display_name` | no | `subregion` 的中文展示名,例如 `东南亚``西亚`,只用于配置和运营展示 |
不再保留这些源数据字段:
```text
independent
un_member
source_assignment_status
```
`region``region_display_name``subregion``subregion_display_name` 不能进入 `countries` 表。业务区域仍然以 `regions``region_countries` 为准,不能用种子数据里的地理 `region/subregion` 直接替代业务 `region_id`
当前开发态初始化会把种子数据导入三张表:
- `countries` 写入国家主数据字段,不写 `region/subregion` 文本。
- `regions` 只初始化三个后台可管理业务区:`MIDDLE_EAST`/中东区、`INDIA`/印度区、`EURASIA_AMERICA`/欧亚美区。
- `region_countries` 按产品分桶写 active 映射:中东国家进入中东区,印度相关国家进入印度区,其余已开放国家进入欧亚美区。
- `GLOBAL` 只作为用户没有显式区域时的内部兜底,不出现在后台区域管理列表。
### Region
区域是业务管理维度,不是地理标准库。它可以表示运营分区、价格分区、合规分区或内容分发分区。
字段语义:
| Field | Rule |
| --- | --- |
| `region_id` | 服务端生成的内部主键,不给客户端提交 |
| `region_code` | 稳定业务编码;当前初始化使用 `MIDDLE_EAST``INDIA``EURASIA_AMERICA` |
| `name` | 管理端展示名 |
| `status` | `active``disabled` |
| `countries` | 国家码列表,元素必须存在于 `countries.country_code` |
### Country Mapping
国家到区域的映射规则:
- 只要国家记录存在即可配置到区域,`enabled=false` 只影响 App 用户选择。
- 一个国家最多只能属于一个 active 区域。
- 种子内的开放国家都会归属到一个 active 业务区;没有显式映射时才使用 `GLOBAL` 内部兜底。
- 区域停用后,该区域下国家不再产生归属。
- 区域停用必须释放 active 国家归属,或把映射标记为 inactive确保这些国家可以重新配置到其他 active 区域。
- 管理端修改国家归属后,后续注册和改国家立即按新映射生效。
- 已有用户的区域快照需要被重算,不能长期停留在旧映射。
### User Region
用户区域由 `users.country` 和当前 active 国家映射计算。
推荐实现为:
- `countries` 是国家码的 source of truth。
- `region_countries` 是 active 国家归属的 source of truth。
- `users.region_id` 是查询用的物化快照。
- 注册和改国家在主事务中同步写 `users.region_id`
- 管理端调整区域国家时,创建重算任务,按 country 分页刷新已有用户的 `region_id`
- 后台创建 BD Leader 是关系管理命令,可以显式把目标用户迁到后台选择的 active 区域;该动作由 `hyapp-admin-server` 鉴权审计,由 user-service host domain 在同一事务里写 `users.region_id``bd_profiles.region_id`,不修改 `users.country`
- 后台修改 App 用户国家是用户资料管理命令,必须校验目标国家 `enabled=true`,并在同一次更新中按 active 国家映射写入新的 `users.region_id`;该动作不受普通用户国家修改冷却限制。
- 当国家修改导致 `old_region_id != new_region_id` 时,后端要调用 `activity-service``RemoveRegionBroadcastMember`,用腾讯云 IM `delete_group_member` 只把该用户从旧区域播报群移除;不能删除或解散区域群。
### User Region Change Sources
当前只有以下路径可以改变用户 `users.region_id`
| 场景 | 入口 | 是否修改 `users.country` | `users.region_id` 写入方式 | 审计/日志 |
| --- | --- | --- | --- | --- |
| 首次注册或首次完成资料 | App/gateway 调 user-service 注册/资料完成链路 | 是,写用户选择的 enabled 国家 | user-service 在同一事务内按 active 国家映射解析并写入;无映射时写空 | 注册/资料完成链路日志,不写国家修改冷却日志 |
| 用户主动修改国家 | App/gateway 调 `ChangeUserCountry` | 是,写新 enabled 国家 | user-service 在同一事务内按新国家 active 映射重算无映射时写空gateway 成功后 best-effort 移除旧区域群成员关系 | 写 `user_country_change_logs`受国家修改冷却限制IM 移除失败写 gateway 结构化日志 |
| 后台修改 App 用户国家 | `hyapp-admin-server` 的 App 用户管理接口 | 是,写后台选择的 enabled 国家 | `hyapp-admin-server` 在同一次用户更新中按 active 国家映射重算;无映射时写空;成功后 best-effort 移除旧区域群成员关系 | 写后台操作审计,不写 App 自助国家修改冷却日志IM 移除失败写 admin 结构化日志 |
| 管理端调整区域国家映射 | 后台创建/停用区域、替换区域国家归属后触发 rebuild | 否 | rebuild worker 按受影响 `country` 分页刷新历史用户 `region_id`,最终一致 | 写区域变更审计和 rebuild task不写用户国家修改日志 |
| 后台创建 BD Leader | `hyapp-admin-server` 的 host 关系管理命令 | 否 | user-service 校验所选区域 active锁目标用户行并在创建 `bd_profiles` 的同一事务内把目标用户迁到所选区域 | 写后台操作审计和 host 命令幂等结果,不写用户国家修改日志 |
除此之外不允许业务代码、gateway、客户端或后台页面直接修改 `users.region_id`。如果后续新增“人工迁移用户区域”能力,必须作为独立后台命令设计权限、审计、幂等和影响范围,不能复用普通用户改国家接口绕过冷却或日志。
这样可以同时满足:
- 注册路径同步归属区域。
- 用户列表和运营筛选可以直接按 `users.region_id` 建索引。
- 管理端调整规则后可以重算历史用户。
## Data Model
### `countries`
```sql
CREATE TABLE IF NOT EXISTS countries (
country_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
country_name VARCHAR(128) NOT NULL,
country_code VARCHAR(3) NOT NULL,
iso_alpha3 VARCHAR(3) NULL,
iso_numeric CHAR(3) NULL,
country_display_name VARCHAR(128) NOT NULL,
phone_country_code VARCHAR(8) NULL,
flag VARCHAR(16) NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
created_by_user_id BIGINT NULL,
updated_by_user_id BIGINT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
UNIQUE KEY uk_countries_code (country_code),
UNIQUE KEY uk_countries_iso_numeric (iso_numeric),
KEY idx_countries_phone_country_code (phone_country_code),
KEY idx_countries_app_enabled_sort (enabled, sort_order, country_code),
KEY idx_countries_sort (sort_order, country_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
`country_code` 继续使用 `VARCHAR(3)` 是为了兼容已有字段长度和少量 user-assigned code但首版业务校验按两位大写国家码收敛。服务端不维护国家码别名表客户端和运营后台必须提交最终 `country_code`
字段规则:
- `iso_alpha3` 只保存 ISO alpha-3 或明确的 user-assigned 值,空字符串入库前必须转为 `NULL`
- `iso_numeric` 只保存三位 numeric 字符串,空字符串入库前必须转为 `NULL`
- `uk_countries_iso_numeric` 允许多个 `NULL`,但阻止两个国家共享同一个正式 numeric 代码。
- `phone_country_code` 保存包含 `+` 的 E.164 国家呼叫码,例如 `+86`;没有电话区号的 ISO 区域保存 `NULL`
- `phone_country_code` 不唯一,因为多个国家或地区可以共享同一个国际电话区号。
- `flag` 保存 emoji flag作为 App 注册页展示字段;它不是国家唯一性或区域归属依据。
- `enabled` 默认 `TRUE`,确保初始化国家数据默认都可被 App 注册页展示;不开放的国家由后台显式禁用。
- `idx_countries_app_enabled_sort` 服务 App 注册国家列表,必须覆盖 `enabled=true ORDER BY sort_order,country_code`
### `regions`
```sql
CREATE TABLE IF NOT EXISTS regions (
region_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
region_code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_by_user_id BIGINT NULL,
updated_by_user_id BIGINT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
UNIQUE KEY uk_regions_code (region_code),
KEY idx_regions_status_sort (status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### `region_countries`
```sql
CREATE TABLE IF NOT EXISTS region_countries (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
region_id BIGINT NOT NULL,
country_code VARCHAR(3) NOT NULL,
status VARCHAR(32) NOT NULL,
created_by_user_id BIGINT NULL,
updated_by_user_id BIGINT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
active_country_code VARCHAR(3)
GENERATED ALWAYS AS (
CASE WHEN status = 'active' THEN country_code ELSE NULL END
) STORED,
UNIQUE KEY uk_region_countries_active_country (active_country_code),
KEY idx_region_countries_country_status (country_code, status),
KEY idx_region_countries_region_status (region_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
`uk_region_countries_active_country` 明确阻止一个国家同时挂到多个 active 区域,同时允许 disabled 区域保留历史映射记录。写入 `region_countries` 前必须确认 `country_code` 存在于 `countries`
### `region_change_logs`
```sql
CREATE TABLE IF NOT EXISTS region_change_logs (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
region_id BIGINT NULL,
operation VARCHAR(64) NOT NULL,
before_json JSON NULL,
after_json JSON NULL,
operator_user_id BIGINT NULL,
request_id VARCHAR(96) NOT NULL,
created_at_ms BIGINT NOT NULL,
KEY idx_region_change_logs_region (region_id, created_at_ms),
KEY idx_region_change_logs_request_id (request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
管理端变更必须写审计。审计只记录区域配置快照,不记录用户隐私资料。
### `user_region_rebuild_tasks`
```sql
CREATE TABLE IF NOT EXISTS user_region_rebuild_tasks (
task_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
region_id BIGINT NULL,
country_code VARCHAR(3) NOT NULL,
target_region_id BIGINT NULL,
mapping_revision BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
cursor_user_id BIGINT NOT NULL DEFAULT 0,
locked_by VARCHAR(128) NULL,
locked_until_ms BIGINT NULL,
attempt_count INT NOT NULL DEFAULT 0,
error_message VARCHAR(512) NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
KEY idx_user_region_rebuild_tasks_status (status, updated_at_ms),
KEY idx_user_region_rebuild_tasks_country (country_code, status),
KEY idx_user_region_rebuild_tasks_lock (status, locked_until_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
管理端调整国家映射时,为受影响国家创建重算任务。任务按 `user_id` 分页更新:
```sql
UPDATE users
SET region_id = ?, updated_at_ms = ?
WHERE country = ?
AND user_id > ?
ORDER BY user_id
LIMIT ?;
```
实际实现时 MySQL 不支持所有版本在 `UPDATE ... ORDER BY ... LIMIT` 下安全返回 cursor建议先查一页 `user_id`,再按 id 范围或 `IN (...)` 更新。
worker 执行任务前必须确认 `mapping_revision` 仍然等于该 `country_code` 当前最新映射版本。旧任务不能覆盖新映射,只能标记为 `skipped``completed`
任务消费使用两段事务,避免锁状态只存在于未提交事务里:
1. claim 事务用 `FOR UPDATE SKIP LOCKED` 选取 `pending``locked_until_ms` 已过期的 `running` 任务,把任务更新为 `running`,写入 `locked_by/locked_until_ms` 并递增 `attempt_count` 后立即提交。
2. 处理事务按 `task_id + locked_by + running` 再次锁定任务,校验 revision分页更新 `users.region_id`,最后把任务改回 `pending` 或标记 `completed/skipped` 并清空锁字段。
如果进程在 claim 提交后崩溃,`running` 状态会保留到 `locked_until_ms`,后续 worker 可以接管;如果进程在处理事务中崩溃,用户更新和 finish 会回滚,但已提交的 claim 仍会按 TTL 重试。
### `users` Change
```sql
ALTER TABLE users
ADD COLUMN region_id BIGINT NULL AFTER country,
ADD KEY idx_users_region_status (region_id, status),
ADD KEY idx_users_country_region (country, region_id);
```
`users.country` 保存 `country_code`。开发阶段直接按当前 schema 和测试收敛字段长度与校验规则,不保留旧结构分支。
不建议在 `users` 冗余 `country_name``country_display_name``region_code``region_name`。展示时由 `user-service``users.country``users.region_id` 查询国家/区域主数据,或由读模型拼装。
## API Design
### Public User Projection
用户投影字段:
```proto
message User {
...
string country = 11;
string avatar = 12;
string birth = 13;
string language = 14;
int64 country_id = 15;
string country_name = 16;
string country_display_name = 17;
int64 region_id = 18;
string region_code = 19;
string region_name = 20;
string iso_numeric = 21;
string phone_country_code = 22;
bool country_enabled = 23;
}
```
规则:
- `country``country_code`,例如 `CN``US`
- `country_id = 0` 表示当前 `country` 为空或未命中国家表。
- `country_display_name` 是中文展示名,面向需要中文国家描述的客户端或管理端。
- `iso_numeric` 是三位 numeric 字符串,客户端不能按数字解析。
- `phone_country_code` 是国际电话区号,可能为空,也可能被多个国家共享。
- `country_enabled` 表示当前国家是否仍向 App 开放;历史用户可能持有已禁用国家,因此该字段可能为 `false`
- `region_id = 0` 表示无区域归属。
- `region_code``region_name` 只用于展示或筛选,不参与权限判断。
`iso_numeric``phone_country_code``country_enabled` 必须追加在现有 `User` 字段之后,不能移动 `region_id/region_code/region_name` 的字段号。
### App Country List API
App 注册页通过 gateway 获取可选国家列表。该接口是公开读接口,返回值只包含当前开放国家。
```http
GET /api/v1/countries
```
Response:
```json
{
"countries": [
{
"country_id": 45,
"country_name": "China",
"country_code": "CN",
"iso_alpha3": "CHN",
"iso_numeric": "156",
"country_display_name": "中国",
"phone_country_code": "+86",
"enabled": true,
"sort_order": 10
}
]
}
```
规则:
- 只返回 `enabled=true` 的国家。
-`sort_order ASC, country_code ASC` 排序。
- `country_code` 是注册页提交给 `CompleteOnboarding` 的唯一国家值。
- `phone_country_code` 只用于注册页展示或后续手机号能力预填,不参与国家归属判断。
- 该接口可以由 gateway 短 TTL 缓存,但必须在后台启用/禁用国家后尽快失效。
### Admin Region APIs
管理端接口由 `hyapp-admin-server` 提供 HTTP 入口、后台鉴权、request envelope 和 `hyapp_admin.admin_operation_logs` 审计。以下动作是后台管理后端能力,直接在 admin-server 内写 user DB不再新增 user-service RPC 或 user-service 业务逻辑:
- 创建国家。
- 启用/禁用国家。
- 创建区域。
- 启用/禁用区域。
`user-service` 仍负责 App 注册、改国家、用户投影和区域重算 worker它可以保留必要的只读/更新类内部 RPC但不能承接上述后台写动作。
后台 BD Leader 创建不属于国家/区域主数据管理动作。它是 host 关系管理动作HTTP 入口、权限和操作审计仍在 `hyapp-admin-server`,关系事实和目标用户当前区域更新在 user-service host domain 内完成。
建议 HTTP
```http
POST /api/v1/admin/countries
GET /api/v1/admin/countries
GET /api/v1/admin/countries/{country_id}
PATCH /api/v1/admin/countries/{country_id}
POST /api/v1/admin/countries/{country_id}/enable
POST /api/v1/admin/countries/{country_id}/disable
DELETE /api/v1/admin/countries/{country_id}
POST /api/v1/admin/regions
GET /api/v1/admin/regions
GET /api/v1/admin/regions/{region_id}
PATCH /api/v1/admin/regions/{region_id}
PUT /api/v1/admin/regions/{region_id}/countries
POST /api/v1/admin/regions/{region_id}/enable
DELETE /api/v1/admin/regions/{region_id}
```
创建国家请求:
```json
{
"country_name": "China",
"country_code": "CN",
"iso_alpha3": "CHN",
"iso_numeric": "156",
"country_display_name": "中国",
"phone_country_code": "+86",
"enabled": true,
"sort_order": 10
}
```
`enabled` 不传时默认 `true`。后台可以创建后立即禁用,也可以创建时传 `false` 作为暂不开放国家。
禁用国家请求:
```json
{
"reason": "not_open_for_registration"
}
```
禁用只把 `enabled=false`,不删除国家,不清空历史用户国家,也不改变 `region_countries` 历史配置。重新启用国家只把 `enabled=true`,后续 App 注册页和注册完成接口立即可以选择该国家。
创建区域请求:
```json
{
"region_code": "SEA",
"name": "Southeast Asia",
"countries": ["SG", "MY", "TH", "ID", "VN", "PH"],
"sort_order": 10
}
```
替换国家请求:
```json
{
"countries": ["SG", "MY", "TH"]
}
```
响应:
```json
{
"region_id": 1001,
"region_code": "SEA",
"name": "Southeast Asia",
"status": "active",
"countries": ["SG", "MY", "TH"],
"updated_at_ms": 1710000000000
}
```
### Internal RPC
当前边界是:后台创建/启停类命令不进入 user-service RPC。`api/proto/user/v1/user.proto` 只保留 App 后端确实需要承接的查询和更新类能力;不要把后台创建国家、启用/禁用国家、创建区域、启用/禁用区域再加回 `CountryAdminService``RegionAdminService`
例外只限于 host 关系管理命令,例如 `UserHostAdminService.CreateBDLeader`。它可以携带经过后台鉴权审计的 `region_id`,用于把目标用户当前区域和 BD Leader 关系区域一次性写一致;这不是开放给 App 或 gateway 的用户区域提交能力。
```proto
service CountryAdminService {
rpc ListCountries(ListCountriesRequest) returns (ListCountriesResponse);
rpc UpdateCountry(UpdateCountryRequest) returns (CountryResponse);
}
service RegionAdminService {
rpc ListRegions(ListRegionsRequest) returns (ListRegionsResponse);
rpc GetRegion(GetRegionRequest) returns (RegionResponse);
rpc UpdateRegion(UpdateRegionRequest) returns (RegionResponse);
rpc ReplaceRegionCountries(ReplaceRegionCountriesRequest) returns (RegionResponse);
}
```
App 注册页读接口建议独立为普通查询服务,避免把管理 RPC 暴露给客户端:
```proto
service CountryQueryService {
rpc ListRegistrationCountries(ListRegistrationCountriesRequest) returns (ListRegistrationCountriesResponse);
}
message Country {
int64 country_id = 1;
string country_name = 2;
string country_code = 3;
string country_display_name = 4;
string status = 5;
int32 sort_order = 6;
int64 created_at_ms = 7;
int64 updated_at_ms = 8;
string iso_alpha3 = 9;
string iso_numeric = 10;
string phone_country_code = 11;
bool enabled = 12;
}
message ListRegistrationCountriesRequest {
RequestMeta meta = 1;
}
message ListRegistrationCountriesResponse {
repeated Country countries = 1;
}
```
`ListRegistrationCountries` 必须只返回 `enabled=true` 的国家;管理端 `ListCountries` 可以按 enabled 过滤并返回全部国家。
仍保留的管理 RPC 必须携带 `RequestMeta.request_id`,区域归属变更审计写入 `region_change_logs.request_id`。由 `hyapp-admin-server` 直写的后台动作同样必须带 request ID并写入后台操作审计`request_id` 仍只做追踪,不做幂等键。
## Registration Flow
```mermaid
sequenceDiagram
participant C as Client
participant G as Gateway
participant U as User Service
participant DB as MySQL
C->>G: POST /api/v1/auth/third-party/login(country=SG)
G->>U: LoginThirdParty(registration.country=SG)
U->>U: normalize country_code to SG
U->>DB: resolve enabled country SG
U->>DB: resolve active region by country SG
DB-->>U: region_id=SEA
U->>DB: create users(country=SG, region_id=SEA)
U-->>G: auth token and user projection
G-->>C: envelope response
```
Implementation notes:
- `LoginThirdParty` 首次注册才写 `users.region_id`
- 已存在三方身份的登录不因为请求体带 country 而修改国家或区域。
- `country` 非空时必须命中 `enabled=true``countries.country_code`,否则返回 `INVALID_ARGUMENT`
- 如果 `country` 为空或没有映射,`region_id` 写 NULL。
- 如果国家格式非法、国家表无记录或国家未启用,继续沿用现有 `INVALID_ARGUMENT`
## Country Change Flow
```mermaid
sequenceDiagram
participant C as Client
participant G as Gateway
participant U as User Service
participant DB as MySQL
C->>G: POST /api/v1/users/me/country/change(country=US)
G->>U: ChangeUserCountry(user_id, country=US)
U->>DB: lock users row
U->>DB: check country cooldown
U->>DB: resolve enabled country US
U->>DB: resolve active region by country US
U->>DB: update users(country=US, region_id=...)
U->>DB: insert user_country_change_logs
U-->>G: user projection and next_change_allowed_at_ms
G-->>C: envelope response
```
Rules:
- 修改为相同国家不写国家日志,也不消耗冷却期;如果区域映射已经变化,可以修正 `region_id`
- 改国家必须先通过 `enabled=true``countries` 校验;不能把国家表不存在或未启用的 code 写入 `users.country`
- 国家冷却判断仍以 `user_country_change_logs` 为准。
- 区域重算不能绕过国家修改冷却;管理端变更映射导致的重算不属于用户修改国家,不写 `user_country_change_logs`
## Admin Mapping Change Flow
管理端新增、停用或修改区域国家时,需要同时处理“未来生效”和“历史用户重算”。创建区域、启用/禁用区域由 `hyapp-admin-server` 直接写 user DB替换区域国家归属仍可调用 user-service 保留的 `ReplaceRegionCountries` 内部 RPC但不能把“创建区域”或“区域启停”重新下沉成 user-service 业务方法。
```mermaid
sequenceDiagram
participant A as Admin
participant G as Admin Server
participant U as User Service
participant DB as MySQL
participant W as Rebuild Worker
A->>G: CreateRegion / DisableRegion / ReplaceRegionCountries
G->>DB: create/disable region directly when it is an admin-only action
G->>U: ReplaceRegionCountries
U->>DB: validate countries exist and active mapping uniqueness
U->>DB: replace region_countries
U->>DB: insert region_change_logs
U->>DB: create rebuild tasks for affected countries
U-->>G: region snapshot
W->>DB: page users by affected country
W->>DB: update users.region_id
```
Consistency target:
- 新注册和用户主动改国家必须立即使用最新映射。
- 历史用户允许短暂 eventually consistent。
- 管理系统需要展示 rebuild task 状态,避免运营误以为历史用户已经全部迁移。
## Validation Rules
| Input | Rule | Error |
| --- | --- | --- |
| `country_code` | trim 后大写,首版按 `^[A-Z]{2}$` 校验,`XK` 作为产品覆盖需要保留;创建后不可变 | `INVALID_ARGUMENT` |
| `country_name` | 1 到 128 字符 | `INVALID_ARGUMENT` |
| `iso_alpha3` | 可空;非空时 trim 后大写,`^[A-Z]{3}$` | `INVALID_ARGUMENT` |
| `iso_numeric` | 可空;非空时必须是三位数字字符串 `^[0-9]{3}$`,不能转 int | `INVALID_ARGUMENT` |
| `country_display_name` | 1 到 128 字符,中文展示名 | `INVALID_ARGUMENT` |
| `phone_country_code` | 可空;非空时必须是 E.164 国家呼叫码格式,建议 `^\+[1-9][0-9]{0,2}$` | `INVALID_ARGUMENT` |
| `enabled` | 可空;创建国家默认 `true`,管理端启用/禁用只能写布尔值 | `INVALID_ARGUMENT` |
| `region_code` | 短业务码 trim 后大写;描述型 subregion 保留原值,允许英文字母、数字、空格、`_``-`,最长 64 | `INVALID_ARGUMENT` |
| `name` | 1 到 128 字符 | `INVALID_ARGUMENT` |
| `countries` | 非空数组,元素必须存在于 `countries.country_code` | `INVALID_ARGUMENT` |
| duplicate country | 同一次请求内不能重复 | `INVALID_ARGUMENT` |
| country already mapped | 已属于其他 active 区域 | `REGION_COUNTRY_CONFLICT` |
| disabled region update countries | 不允许给 disabled 区域改 countries | `FAILED_PRECONDITION` |
| disabled country selected by App | `enabled=false` 的国家不能被注册完成或改国家选择 | `INVALID_ARGUMENT` |
新增错误码建议:
| Code | HTTP | Meaning |
| --- | --- | --- |
| `REGION_NOT_FOUND` | 404 | 区域不存在 |
| `COUNTRY_NOT_FOUND` | 404 | 国家不存在或已停用 |
| `REGION_COUNTRY_CONFLICT` | 409 | 国家已归属其他区域 |
| `REGION_DISABLED` | 409 | 区域已停用,不允许继续变更国家 |
如果不想新增太多错误码,首版可以复用 `NOT_FOUND``CONFLICT``FAILED_PRECONDITION`,但 HTTP envelope 的 `code` 必须稳定。
## Service Design
### Domain Types
新增 `services/user-service/internal/domain/user/region.go`
```go
type Country struct {
CountryID int64
CountryName string
CountryCode string
ISOAlpha3 string
ISONumeric string
CountryDisplayName string
PhoneCountryCode string
Enabled bool
SortOrder int
CreatedAtMs int64
UpdatedAtMs int64
}
type Region struct {
RegionID int64
RegionCode string
Name string
Status string
Countries []string
SortOrder int
CreatedAtMs int64
UpdatedAtMs int64
}
type RegionCountryMapping struct {
RegionID int64
CountryCode string
}
```
### Repository Interfaces
user-service repository 只保留 App 读路径、列表、展示字段更新和区域国家替换所需能力。后台创建/启停类能力属于 `hyapp-admin-server`,不要放进 user-service repository 接口:
```go
type CountryRepository interface {
ResolveEnabledCountryByCode(ctx context.Context, countryCode string) (Country, bool, error)
ListRegistrationCountries(ctx context.Context) ([]Country, error)
ListCountries(ctx context.Context, filter CountryFilter) ([]Country, error)
UpdateCountry(ctx context.Context, command UpdateCountryCommand) (Country, error)
}
type RegionRepository interface {
ResolveActiveRegionByCountry(ctx context.Context, country string) (Region, bool, error)
ListRegions(ctx context.Context, filter RegionFilter) ([]Region, error)
GetRegion(ctx context.Context, regionID int64) (Region, error)
UpdateRegion(ctx context.Context, command UpdateRegionCommand) (Region, error)
ReplaceRegionCountries(ctx context.Context, command ReplaceRegionCountriesCommand) (Region, error)
}
type RegionRebuildRepository interface {
ProcessNextRegionRebuildTask(ctx context.Context, workerID string, nowMs int64, lockTTL time.Duration, batchSize int) (RegionRebuildProcessResult, error)
}
```
`auth.Repository.CreateThirdPartyUser` 当前只接收 `userdomain.User`,因此只要 `user.Country``user.RegionID` 被 service 填好,原子创建链路可以复用。国家表校验必须在创建用户事务前完成,避免把未知国家码写入 `users.country`
### Cache
国家表和国家到区域映射读频率高,管理变更低频。可以在 user-service 内加进程内缓存:
| Setting | Value |
| --- | --- |
| key | uppercase `country_code` |
| value | active `country_id`、展示字段、`enabled`、active `region_id` 或 empty marker |
| ttl | 30s 到 300s |
| invalidation | 后台由 `hyapp-admin-server` 直写 user DB如果 user-service 后续加缓存,必须通过短 TTL、版本校验或 Redis pub/sub 处理跨进程失效,不能假设管理命令会进入 user-service 进程 |
App 注册国家列表可以单独缓存:
| Setting | Value |
| --- | --- |
| key | `registration_countries:v1` |
| value | `enabled=true` 的国家投影 |
| ttl | 30s 到 300s |
| invalidation | 创建、启用、禁用、更新国家后失效;其中创建和启用/禁用由 admin-server 触发,不能依赖 user-service 本地内存自动失效 |
首版为保证管理变更后新注册和改国家立即生效,优先不加缓存,直接 MySQL 查询。等注册量上来后再加缓存一旦加缓存必须使用版本校验、Redis pub/sub 或其他跨实例失效机制,不能只依赖 TTL。
## Development Plan
1. 新增 `countries``regions``region_countries``region_change_logs``user_region_rebuild_tasks`,其中 `countries.enabled` 默认 `true`
2. `users` 新增 nullable `region_id` 和索引,暂不收窄 `country` 字段长度。
3. 初始化国家表种子数据,默认全部 `enabled=true`,不开放国家由后台禁用。
4. 增加 `CountryQueryService.ListRegistrationCountries``GET /api/v1/countries`,供 App 注册页获取开放国家。
5. 代码读取时允许 `country_id=0``region_id` 为空和国家表未命中的用户快照。
6. 上线国家管理、区域管理和注册/改国家写入逻辑。
6. 对历史 `users.country` 做一次 rebuild把已有用户填充 `region_id`,并产出未命中国家表的异常列表。
7. 后续如果需要强制所有用户都有国家或区域,再单独评估数据质量和产品规则。
本仓库当前 MySQL 初始化脚本需要同步更新:
- `services/user-service/deploy/mysql/initdb/001_user_service.sql`
## Backfill Strategy
历史用户回填按国家维度分页:
1. 为每个已配置国家创建 `user_region_rebuild_tasks`
2. worker 查询该国家下 `user_id > cursor_user_id` 的一页用户。
3. 批量更新这些用户的 `region_id`
4. 推进 cursor。
5. 没有更多用户时任务标记 `completed`
失败策略:
- 单页失败时处理事务回滚,任务保持 `running` 直到 `locked_until_ms` 过期。
- 下一次 claim 会递增 `attempt_count`,同一页按 cursor 重试。
- 重试必须幂等:重复把同一国家用户设置为同一 `region_id` 不应造成副作用。
- `failed` 状态只作为后续人工暂停或最大重试次数策略的扩展点,当前自动 worker 不主动写入。
## Admin Auth Boundary
管理端 API 必须要求管理员身份。后台入口属于 `hyapp-admin-server`,不是 gateway 普通 App API也不是 user-service 普通 RPC。创建国家、启用/禁用国家、创建区域、启用/禁用区域必须由 `hyapp-admin-server` 执行并记录后台操作审计。
上线管理端前必须补齐:
- admin-server 管理员鉴权和功能权限。
- 操作人 `operator_user_id` 透传。
- 管理端请求审计。
- 管理端接口频控或后台网段限制。
## OpenAPI And Proto Changes
涉及 proto 时不能复用字段编号。后台创建/启停类能力已明确由 `hyapp-admin-server` HTTP 接口承载,不应再向 user-service proto 追加对应 RPC。
需要修改:
- `api/proto/user/v1/user.proto`
- `docs/openapi/gateway.swagger.yaml`
- `docs/openapi/user.swagger.yaml`
修改 proto 后必须运行:
```bash
make proto
go test ./...
```
## Tests
### Unit Tests
| Case | Expected |
| --- | --- |
| admin-server 创建国家 `CN` | 国家表保存 `country_name/country_code/country_display_name` |
| admin-server 创建三位 canonical 国家码 `TLA` | `country_code=TLA` 合法 |
| admin-server 创建重复国家码 | 返回 `CONFLICT` |
| admin-server 创建小写国家码 `cn` | 归一为 `CN` |
| admin-server 创建非法国家码 `C1``ABCD` | 返回 `INVALID_ARGUMENT` |
| 注册国家命中区域 | 新用户 `Country=SG``RegionID=SEA` |
| 注册国家不存在或 disabled | 返回 `INVALID_ARGUMENT`,不创建用户 |
| 注册国家无映射 | 新用户 `Country=US``RegionID=0/null` |
| 注册国家小写 | `sg` 归一为 `SG` 后命中区域 |
| 已有三方登录再次传国家 | 不修改原国家和区域 |
| 修改国家命中区域 | `ChangeUserCountry` 同步更新 `country``region_id` |
| 修改国家不存在或 disabled | 返回 `INVALID_ARGUMENT`,不写国家日志 |
| 修改国家无映射 | `region_id` 清空 |
| 修改相同国家 | 不写国家日志,不消耗冷却,允许修正 stale `region_id` |
| 一个国家配置到两个区域 | 管理接口返回 `REGION_COUNTRY_CONFLICT` |
| 停用区域 | 后续解析该区域国家时返回无区域 |
| 停用国家 | 后续注册和改国家不能选择该国家;区域配置仍允许预配置已存在但未开放的国家 |
| rebuild task 重试 | 重复执行不会产生错误区域 |
| rebuild 旧任务 | mapping revision 过期时跳过,不覆盖新区域 |
### Integration Tests
| Flow | Expected |
| --- | --- |
| admin-server 创建国家 CN/SG/US | 管理端列表返回英文名、国家码和中文展示名 |
| admin-server 创建 SEA 区域并包含 SG | 新注册 SG 用户属于 SEA |
| 把 SG 从 SEA 移到 APAC | 后续注册 SG 用户属于 APAC历史 SG 用户通过 rebuild 更新为 APAC |
| 禁用 SEA | SEA 下国家不再解析,历史用户 rebuild 后 region 清空或迁到新区域 |
| 禁用国家 SG | 新注册和改国家不能选择 SG已有用户只保留历史 country 快照 |
| 国家修改冷却 | 区域重算不绕过已有国家冷却规则 |
## Verification Commands
文档实现后至少运行:
```bash
make proto
go test ./services/user-service/...
go test ./services/gateway-service/...
```
涉及数据库初始化脚本和 Docker 本地链路时运行:
```bash
docker compose config
docker compose build
docker compose up -d
docker compose ps
docker compose down
```
## Implementation Order
1. 新增 `countries`、区域相关数据库表和 `users.region_id`,更新 initdb。
2. 新增 country/region domain、repository、service。
3. 抽取共享国家码归一和校验逻辑,注册和 `ChangeUserCountry` 都使用国家表校验。
4. 在注册链路中按 `registration.Country` 解析 enabled country再解析并写 `user.RegionID`
5.`ChangeUserCountry` 事务中解析 enabled country 和 active region并同步更新 `country/region_id`
6. 追加 user proto 国家/区域投影字段,运行 `make proto`
7. 补 user-service 单测和 MySQL repository 测试。
8. user-service 只保留 `ListCountries``UpdateCountry``ListRegions``GetRegion``UpdateRegion``ReplaceRegionCountries` 等必要内部 RPC国家创建/启停、区域创建/启停在 `hyapp-admin-server` 实现并写后台审计。
9. 新增 rebuild task repository 和 worker带 mapping revision 防旧任务覆盖。
10. `hyapp-admin-server` 增加 admin HTTP endpoint接入管理端鉴权后开放。
11. 更新 OpenAPI 和管理系统联调文档。
## Acceptance Criteria
- 管理端可以创建区域并配置国家列表。
- 管理端可以创建国家,保存 `country_name/country_code/country_display_name`
- 国家码支持 2 到 3 位大写英文字母;客户端提交小写时服务端归一。
- 注册和改国家只能选择国家表中存在且 `enabled=true``country_code`
- 一个国家不能同时属于多个 active 区域。
- 用户注册时选择的国家如果命中区域,用户主记录可以查到对应区域。
- 用户资料投影可以返回国家码、国家内部 ID、国家名称和中文展示名。
- 用户修改国家成功后,区域同步变化。
- 用户修改国家失败或处于冷却期时,区域不能被修改。
- 管理端变更国家映射后,新注册用户立即按新映射生效。
- 历史用户可以通过 rebuild task 被重算到新区域。
- `country_by_ip` 不参与区域归属。
- gateway 不保存区域规则room-service 不感知区域。
- 当前处于开发阶段proto 字段按当前实现直接调整。
## Open Questions
上线前需要产品或管理端确认:
- 区域名称是否需要多语言。
- 国家从一个区域迁到另一个区域时,历史用户是否必须立即同步,还是允许后台 eventual consistency。
- 管理端是否需要查看每个区域的用户数量。
- disabled 区域下历史用户是清空 `region_id`,还是保留旧 region 作为历史标签。
- 国家表的初始种子数据来源是手工运营配置、ISO 导入,还是产品自定义列表。
- 两位或三位国家码都作为最终 `country_code` 处理,不做别名解析。
- `HK``MO``TW` 这类地区码的中文展示名和产品文案口径。