hyapp-server/docs/user-region-country-development.md
2026-04-30 02:30:32 +08:00

891 lines
38 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-service` | 保存可选国家、国家码和中文展示名 |
| 区域主数据 | `user-service` | 保存区域、国家归属和管理审计 |
| 注册归属区域 | `user-service` | 首次注册写入 `users.country` 时同步解析并写入 `users.region_id` |
| 修改国家归属区域 | `user-service` | `ChangeUserCountry` 成功后同步重算 `users.region_id` |
| 管理端区域配置 | `gateway-service` + `user-service` | 管理端通过 HTTP 调 gatewaygateway 调 user-service 管理 RPC |
| 用户资料展示 | `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。
- 用户修改国家走独立 RPC 和 HTTP API`ChangeUserCountry` / `/api/v1/users/me/country/change`
- 国家修改会写 `user_country_change_logs`,并有滚动 30 天冷却窗口。
因此区域功能应该挂在 `user-service` 的用户主数据链路上。
## 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.region_code` 使用 `subregion` 原值,`regions.name` 使用 `subregion_display_name`
- `region_countries` 按每个国家的 `subregion` 建立 active 映射。
- 一级 `region/region_display_name` 不入库,不参与业务区域判断。
- 没有 `subregion` 的少数地区统一落到 `UNSPECIFIED`,避免注册和用户投影出现无区域主记录。
### Region
区域是业务管理维度,不是地理标准库。它可以表示运营分区、价格分区、合规分区或内容分发分区。
字段语义:
| Field | Rule |
| --- | --- |
| `region_id` | 服务端生成的内部主键,不给客户端提交 |
| `region_code` | 稳定业务编码;当前初始化直接使用 `countries.json.subregion`,例如 `South-Eastern Asia` |
| `name` | 管理端展示名 |
| `status` | `active``disabled` |
| `countries` | 国家码列表,元素必须存在于 `countries.country_code` |
### Country Mapping
国家到区域的映射规则:
- 只要国家记录存在即可配置到区域,`enabled=false` 只影响 App 用户选择。
- 一个国家最多只能属于一个 active 区域。
- 国家不属于任何区域时,用户 `region_id` 为空。
- 区域停用后,该区域下国家不再产生归属。
- 区域停用必须释放 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`
这样可以同时满足:
- 注册路径同步归属区域。
- 用户列表和运营筛选可以直接按 `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
管理端接口后续放在 gateway admin API 下gateway 负责管理端鉴权和 request envelopeuser-service 执行业务事务。
建议 HTTP
```http
POST /api/v1/admin/countries
GET /api/v1/admin/countries
PUT /api/v1/admin/countries/{country_id}
POST /api/v1/admin/countries/{country_id}/enable
POST /api/v1/admin/countries/{country_id}/disable
POST /api/v1/admin/regions
GET /api/v1/admin/regions
GET /api/v1/admin/regions/{region_id}
PUT /api/v1/admin/regions/{region_id}
POST /api/v1/admin/regions/{region_id}/countries/replace
POST /api/v1/admin/regions/{region_id}/disable
```
创建国家请求:
```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
推荐在 `api/proto/user/v1/user.proto` 追加 `CountryAdminService``RegionAdminService`,或至少把二者放入独立管理 service。为了避免普通用户 RPC 和管理 RPC 权限混淆,不要把管理方法直接混入普通 `UserService`
```proto
service CountryAdminService {
rpc CreateCountry(CreateCountryRequest) returns (CountryResponse);
rpc ListCountries(ListCountriesRequest) returns (ListCountriesResponse);
rpc UpdateCountry(UpdateCountryRequest) returns (CountryResponse);
rpc EnableCountry(EnableCountryRequest) returns (CountryResponse);
rpc DisableCountry(DisableCountryRequest) returns (CountryResponse);
}
service RegionAdminService {
rpc CreateRegion(CreateRegionRequest) returns (RegionResponse);
rpc ListRegions(ListRegionsRequest) returns (ListRegionsResponse);
rpc GetRegion(GetRegionRequest) returns (RegionResponse);
rpc UpdateRegion(UpdateRegionRequest) returns (RegionResponse);
rpc ReplaceRegionCountries(ReplaceRegionCountriesRequest) returns (RegionResponse);
rpc DisableRegion(DisableRegionRequest) 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``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
管理端新增或修改区域国家时,需要同时处理“未来生效”和“历史用户重算”。
```mermaid
sequenceDiagram
participant A as Admin
participant G as Gateway Admin API
participant U as User Service
participant DB as MySQL
participant W as Rebuild Worker
A->>G: ReplaceRegionCountries(region=SEA, countries=[SG,MY])
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 能力:
```go
type CountryRepository interface {
ResolveEnabledCountryByCode(ctx context.Context, countryCode string) (Country, bool, error)
ListRegistrationCountries(ctx context.Context) ([]Country, error)
CreateCountry(ctx context.Context, command CreateCountryCommand) (Country, error)
UpdateCountry(ctx context.Context, command UpdateCountryCommand) (Country, error)
EnableCountry(ctx context.Context, command EnableCountryCommand) (Country, error)
ListCountries(ctx context.Context, filter CountryFilter) ([]Country, error)
DisableCountry(ctx context.Context, command DisableCountryCommand) (Country, error)
}
type RegionRepository interface {
ResolveActiveRegionByCountry(ctx context.Context, country string) (Region, bool, error)
CreateRegion(ctx context.Context, command CreateRegionCommand) (Region, error)
ReplaceRegionCountries(ctx context.Context, command ReplaceRegionCountriesCommand) (Region, error)
ListRegions(ctx context.Context, filter RegionFilter) ([]Region, error)
DisableRegion(ctx context.Context, command DisableRegionCommand) (Region, error)
CreateRegionRebuildTasks(ctx context.Context, countries []string) 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 | 管理端变更后清本进程缓存;多实例依赖短 TTL 或后续 Redis pub/sub |
App 注册国家列表可以单独缓存:
| Setting | Value |
| --- | --- |
| key | `registration_countries:v1` |
| value | `enabled=true` 的国家投影 |
| ttl | 30s 到 300s |
| invalidation | 创建、启用、禁用、更新国家后失效 |
首版为保证管理变更后新注册和改国家立即生效,优先不加缓存,直接 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 必须要求管理员身份。当前仓库尚未形成完整 admin auth 体系,因此首版实现时可以先只加 user-service 内部 RPC 和测试,不对公网暴露 HTTP admin endpoint。
上线公网管理端前必须补齐:
- gateway admin 鉴权。
- 操作人 `operator_user_id` 透传。
- 管理端请求审计。
- 管理端接口频控或后台网段限制。
## OpenAPI And Proto Changes
涉及 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 |
| --- | --- |
| 创建国家 `CN` | 国家表保存 `country_name/country_code/country_display_name` |
| 创建三位 canonical 国家码 `TLA` | `country_code=TLA` 合法 |
| 创建重复国家码 | 返回 `CONFLICT` |
| 创建小写国家码 `cn` | 归一为 `CN` |
| 创建非法国家码 `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 |
| --- | --- |
| 创建国家 CN/SG/US | 管理端列表返回英文名、国家码和中文展示名 |
| 创建 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. 新增 CountryAdminService、RegionAdminService 内部 RPC 和管理审计。
9. 新增 rebuild task repository 和 worker带 mapping revision 防旧任务覆盖。
10. gateway 增加 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` 这类地区码的中文展示名和产品文案口径。