43 KiB
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 的中文展示名,例如 东南亚、西亚,只用于配置和运营展示 |
不再保留这些源数据字段:
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,用腾讯云 IMdelete_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
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
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
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
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
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 分页更新:
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。
任务消费使用两段事务,避免锁状态只存在于未提交事务里:
- claim 事务用
FOR UPDATE SKIP LOCKED选取pending或locked_until_ms已过期的running任务,把任务更新为running,写入locked_by/locked_until_ms并递增attempt_count后立即提交。 - 处理事务按
task_id + locked_by + running再次锁定任务,校验 revision,分页更新users.region_id,最后把任务改回pending或标记completed/skipped并清空锁字段。
如果进程在 claim 提交后崩溃,running 状态会保留到 locked_until_ms,后续 worker 可以接管;如果进程在处理事务中崩溃,用户更新和 finish 会回滚,但已提交的 claim 仍会按 TTL 重试。
users Change
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
用户投影字段:
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 获取可选国家列表。该接口是公开读接口,返回值只包含当前开放国家。
GET /api/v1/countries
Response:
{
"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:
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}
创建国家请求:
{
"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 作为暂不开放国家。
禁用国家请求:
{
"reason": "not_open_for_registration"
}
禁用只把 enabled=false,不删除国家,不清空历史用户国家,也不改变 region_countries 历史配置。重新启用国家只把 enabled=true,后续 App 注册页和注册完成接口立即可以选择该国家。
创建区域请求:
{
"region_code": "SEA",
"name": "Southeast Asia",
"countries": ["SG", "MY", "TH", "ID", "VN", "PH"],
"sort_order": 10
}
替换国家请求:
{
"countries": ["SG", "MY", "TH"]
}
响应:
{
"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 的用户区域提交能力。
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 暴露给客户端:
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
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
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 业务方法。
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:
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 接口:
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
- 新增
countries、regions、region_countries、region_change_logs、user_region_rebuild_tasks,其中countries.enabled默认true。 users新增 nullableregion_id和索引,暂不收窄country字段长度。- 初始化国家表种子数据,默认全部
enabled=true,不开放国家由后台禁用。 - 增加
CountryQueryService.ListRegistrationCountries和GET /api/v1/countries,供 App 注册页获取开放国家。 - 代码读取时允许
country_id=0、region_id为空和国家表未命中的用户快照。 - 上线国家管理、区域管理和注册/改国家写入逻辑。
- 对历史
users.country做一次 rebuild,把已有用户填充region_id,并产出未命中国家表的异常列表。 - 后续如果需要强制所有用户都有国家或区域,再单独评估数据质量和产品规则。
本仓库当前 MySQL 初始化脚本需要同步更新:
services/user-service/deploy/mysql/initdb/001_user_service.sql
Backfill Strategy
历史用户回填按国家维度分页:
- 为每个已配置国家创建
user_region_rebuild_tasks。 - worker 查询该国家下
user_id > cursor_user_id的一页用户。 - 批量更新这些用户的
region_id。 - 推进 cursor。
- 没有更多用户时任务标记
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.protodocs/openapi/gateway.swagger.yamldocs/openapi/user.swagger.yaml
修改 proto 后必须运行:
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
文档实现后至少运行:
make proto
go test ./services/user-service/...
go test ./services/gateway-service/...
涉及数据库初始化脚本和 Docker 本地链路时运行:
docker compose config
docker compose build
docker compose up -d
docker compose ps
docker compose down
Implementation Order
- 新增
countries、区域相关数据库表和users.region_id,更新 initdb。 - 新增 country/region domain、repository、service。
- 抽取共享国家码归一和校验逻辑,注册和
ChangeUserCountry都使用国家表校验。 - 在注册链路中按
registration.Country解析 enabled country,再解析并写user.RegionID。 - 在
ChangeUserCountry事务中解析 enabled country 和 active region,并同步更新country/region_id。 - 追加 user proto 国家/区域投影字段,运行
make proto。 - 补 user-service 单测和 MySQL repository 测试。
- user-service 只保留
ListCountries、UpdateCountry、ListRegions、GetRegion、UpdateRegion、ReplaceRegionCountries等必要内部 RPC;国家创建/启停、区域创建/启停在hyapp-admin-server实现并写后台审计。 - 新增 rebuild task repository 和 worker,带 mapping revision 防旧任务覆盖。
hyapp-admin-server增加 admin HTTP endpoint,接入管理端鉴权后开放。- 更新 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这类地区码的中文展示名和产品文案口径。