# 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.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`。 - 后台创建 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`;该动作不受普通用户国家修改冷却限制。 ### 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 映射重算;无映射时写空 | 写 `user_country_change_logs`,受国家修改冷却限制 | | 后台修改 App 用户国家 | `hyapp-admin-server` 的 App 用户管理接口 | 是,写后台选择的 enabled 国家 | `hyapp-admin-server` 在同一次用户更新中按 active 国家映射重算;无映射时写空 | 写后台操作审计,不写 App 自助国家修改冷却日志 | | 管理端调整区域国家映射 | 后台创建/停用区域、替换区域国家归属后触发 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` 这类地区码的中文展示名和产品文案口径。