hyapp-server/docs/user-region-country-development.md
2026-04-29 12:43:05 +08:00

726 lines
30 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` | 稳定 canonical 国家码,统一大写,格式为 `^[A-Z]{2,3}$` |
| `country_display_name` | 中文展示名,例如 `中国``美国` |
| `status` | `active``disabled`disabled 国家不能被新注册、改国家或区域配置选中 |
规则:
- `countries.country_code` 是本系统 canonical 国家码的权威来源。同一个真实国家只能有一条 canonical 记录,不能同时把 `US``USA` 当成两个国家主键。
- `country_aliases.alias_code` 只用于输入兼容,例如 `USA -> US`。alias 解析后必须写回 canonical `country_code`
- `country_code` 创建后视为不可变。需要改码时新增 canonical 国家记录并通过迁移任务更新 `users.country``region_countries` 和审计口径。
- 注册、改国家、区域国家配置都必须先把输入 trim、转大写再校验 `^[A-Z]{2,3}$`,然后通过 alias/canonical 解析命中 active `countries`
- `users.country` 继续保存 canonical `country_code` 字符串,保持现有 proto 和 HTTP API 兼容;服务端展示时可以按 `country_code` 拼出 `country_id/country_name/country_display_name`
- `country_by_ip` 只做审计和风控快照;如果边缘层给出的国家码不在 `countries`user-service 应丢弃或记录为空,不能参与区域归属。
### Region
区域是业务管理维度,不是地理标准库。它可以表示运营分区、价格分区、合规分区或内容分发分区。
字段语义:
| Field | Rule |
| --- | --- |
| `region_id` | 服务端生成的内部主键,不给客户端提交 |
| `region_code` | 稳定业务编码,例如 `SEA``LATAM``MENA` |
| `name` | 管理端展示名 |
| `status` | `active``disabled` |
| `countries` | 国家码列表,元素必须存在于 active `countries.country_code` |
### Country Mapping
国家到区域的映射规则:
- 只有 active 国家才能配置到区域。
- 一个国家最多只能属于一个 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,
country_display_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_countries_code (country_code),
KEY idx_countries_status_sort (status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
`country_code` 使用 `VARCHAR(3)` 而不是 `CHAR(2)`,因为 canonical code 保留两位或三位的产品选择空间。首版 seed 采用 alpha-2 作为 canonical需要兼容 alpha-3 输入时写入 `country_aliases`,不能在 `countries` 里再建一个独立国家。
### `country_aliases`
```sql
CREATE TABLE IF NOT EXISTS country_aliases (
alias_code VARCHAR(3) NOT NULL PRIMARY KEY,
country_code VARCHAR(3) NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
KEY idx_country_aliases_country (country_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
alias 只参与解析输入。注册、改国家和区域配置收到 `USA` 时解析到 `US``users.country``region_countries.country_code` 和 rebuild task 都保存 `US`
### `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` 存在于 active `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`。首个迁移不强制把 `country VARCHAR(64)` 收窄到 `VARCHAR(3)`,避免滚动发布期间旧脏数据或旧服务写入失败;代码上线并清理历史数据后,可以单独评估收窄字段长度。
不建议在 `users` 冗余 `country_name``country_display_name``region_code``region_name`。展示时由 `user-service``users.country``users.region_id` 查询国家/区域主数据,或由读模型拼装。
## API Design
### Public User Projection
现有用户投影可以追加字段,保持 proto/API 向后兼容:
```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;
}
```
规则:
- 老客户端可以忽略新增字段。
- `country` 仍然是 canonical `country_code`,例如 `CN``US`;客户端传入的 alias 不原样保存。
- `country_id = 0` 表示当前 `country` 为空或未命中国家表。
- `country_display_name` 是中文展示名,面向需要中文国家描述的客户端或管理端。
- `region_id = 0` 表示无区域归属。
- `region_code``region_name` 只用于展示或筛选,不参与权限判断。
### 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}/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",
"country_display_name": "中国",
"sort_order": 10
}
```
创建区域请求:
```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 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);
}
```
所有管理 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 active 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` 非空时必须命中 active `countries.country_code`,否则返回 `INVALID_ARGUMENT`
- 如果 `country` 为空或没有映射,`region_id` 写 NULL。
- 如果国家格式非法或国家表无 active 记录,继续沿用现有 `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 active 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`
- 改国家必须先通过 active `countries` 校验;不能把国家表不存在或 disabled 的 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 active countries and 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,3}$`,创建后不可变 | `INVALID_ARGUMENT` |
| `country_name` | 1 到 128 字符 | `INVALID_ARGUMENT` |
| `country_display_name` | 1 到 128 字符,中文展示名 | `INVALID_ARGUMENT` |
| `region_code` | trim 后大写,建议 `^[A-Z0-9_]{2,32}$` | `INVALID_ARGUMENT` |
| `name` | 1 到 128 字符 | `INVALID_ARGUMENT` |
| `countries` | 非空数组,元素是 active `countries.country_code` | `INVALID_ARGUMENT` |
| duplicate country | 同一次请求内不能重复 | `INVALID_ARGUMENT` |
| country already mapped | 已属于其他 active 区域 | `REGION_COUNTRY_CONFLICT` |
| disabled region update countries | 不允许给 disabled 区域改 countries | `FAILED_PRECONDITION` |
新增错误码建议:
| 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
CountryDisplayName string
Status string
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 {
ResolveActiveCountryByCode(ctx context.Context, countryCode string) (Country, bool, error)
CreateCountry(ctx context.Context, command CreateCountryCommand) (Country, error)
UpdateCountry(ctx context.Context, command UpdateCountryCommand) (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`、展示字段、active `region_id` 或 empty marker |
| ttl | 30s 到 300s |
| invalidation | 管理端变更后清本进程缓存;多实例依赖短 TTL 或后续 Redis pub/sub |
首版为保证管理变更后新注册和改国家立即生效,优先不加缓存,直接 MySQL 查询。等注册量上来后再加缓存一旦加缓存必须使用版本校验、Redis pub/sub 或其他跨实例失效机制,不能只依赖 TTL。
## Database Migration Plan
生产迁移必须兼容滚动发布:
1. 新增 `countries``country_aliases``regions``region_countries``region_change_logs``user_region_rebuild_tasks`
2. `users` 新增 nullable `region_id` 和索引,暂不收窄 `country` 字段长度。
3. 初始化 canonical 国家表种子数据和 alias 映射;例如只 seed `US``countries`,把 `USA -> US` 写入 `country_aliases`
4. 代码读取时兼容 `country_id=0``region_id` 为空和国家表未命中的历史用户。
5. 上线国家管理、区域管理和注册/改国家写入逻辑。
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` 合法 |
| 创建 alias 国家码 `USA` | 返回 `CONFLICT`,因为 `USA``US` 的 alias |
| 创建小写国家码 `cn` | 归一为 `CN` |
| 创建非法国家码 `C1``ABCD` | 返回 `INVALID_ARGUMENT` |
| 注册国家命中区域 | 新用户 `Country=SG``RegionID=SEA` |
| 注册国家不存在或 disabled | 返回 `INVALID_ARGUMENT`,不创建用户 |
| 注册国家无映射 | 新用户 `Country=US``RegionID=0/null` |
| 注册国家小写 | `sg` 归一为 `SG` 后命中区域 |
| 已有三方登录再次传国家 | 不修改原国家和区域 |
| 修改国家命中区域 | `ChangeUserCountry` 同步更新 `country``region_id` |
| 修改为 alias 国家码 | `ChangeUserCountry("USA")` 写入 canonical `US` |
| 修改国家不存在或 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` 解析 active country再解析并写 `user.RegionID`
5.`ChangeUserCountry` 事务中解析 active country 和 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 位大写英文字母;客户端提交小写时服务端归一。
- 注册和改国家只能选择 active 国家表中存在的 `country_code`
- 一个国家不能同时属于多个 active 区域。
- 用户注册时选择的国家如果命中区域,用户主记录可以查到对应区域。
- 用户资料投影可以返回国家码、国家内部 ID、国家名称和中文展示名。
- 用户修改国家成功后,区域同步变化。
- 用户修改国家失败或处于冷却期时,区域不能被修改。
- 管理端变更国家映射后,新注册用户立即按新映射生效。
- 历史用户可以通过 rebuild task 被重算到新区域。
- `country_by_ip` 不参与区域归属。
- gateway 不保存区域规则room-service 不感知区域。
- 所有新增 proto 字段向后兼容,老客户端忽略区域字段不受影响。
## Open Questions
上线前需要产品或管理端确认:
- 区域名称是否需要多语言。
- 国家从一个区域迁到另一个区域时,历史用户是否必须立即同步,还是允许后台 eventual consistency。
- 管理端是否需要查看每个区域的用户数量。
- disabled 区域下历史用户是清空 `region_id`,还是保留旧 region 作为历史标签。
- 国家表的初始种子数据来源是手工运营配置、ISO 导入,还是产品自定义列表。
- 三位国家码如果作为输入兼容,应通过 `country_aliases` 映射到 canonical 国家;不要直接作为同一真实国家的第二条 `countries` 记录。
- `HK``MO``TW` 这类地区码的中文展示名和产品文案口径。