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

30 KiB
Raw Blame History

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 APIChangeUserCountry / /api/v1/users/me/country/change
  • 国家修改会写 user_country_change_logs,并有滚动 30 天冷却窗口。

因此区域功能应该挂在 user-service 的用户主数据链路上。

Domain Rules

Country

国家是用户资料和区域归属的基础主数据。客户端和外部 HTTP API 只使用 country_codecountry_id 是服务端内部主键,便于管理端编辑和审计。

字段语义:

Field Rule
country_id 服务端生成的内部主键,不给客户端提交
country_name 稳定英文或拼音名称,例如 ChinaUnited States
country_code 稳定 canonical 国家码,统一大写,格式为 ^[A-Z]{2,3}$
country_display_name 中文展示名,例如 中国美国
status activedisableddisabled 国家不能被新注册、改国家或区域配置选中

规则:

  • countries.country_code 是本系统 canonical 国家码的权威来源。同一个真实国家只能有一条 canonical 记录,不能同时把 USUSA 当成两个国家主键。
  • country_aliases.alias_code 只用于输入兼容,例如 USA -> US。alias 解析后必须写回 canonical country_code
  • country_code 创建后视为不可变。需要改码时新增 canonical 国家记录并通过迁移任务更新 users.countryregion_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 只做审计和风控快照;如果边缘层给出的国家码不在 countriesuser-service 应丢弃或记录为空,不能参与区域归属。

Region

区域是业务管理维度,不是地理标准库。它可以表示运营分区、价格分区、合规分区或内容分发分区。

字段语义:

Field Rule
region_id 服务端生成的内部主键,不给客户端提交
region_code 稳定业务编码,例如 SEALATAMMENA
name 管理端展示名
status activedisabled
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

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

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 时解析到 USusers.countryregion_countries.country_code 和 rebuild task 都保存 US

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 存在于 active 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 当前最新映射版本。旧任务不能覆盖新映射,只能标记为 skippedcompleted

任务消费使用两段事务,避免锁状态只存在于未提交事务里:

  1. claim 事务用 FOR UPDATE SKIP LOCKED 选取 pendinglocked_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

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_namecountry_display_nameregion_coderegion_name。展示时由 user-serviceusers.countryusers.region_id 查询国家/区域主数据,或由读模型拼装。

API Design

Public User Projection

现有用户投影可以追加字段,保持 proto/API 向后兼容:

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,例如 CNUS;客户端传入的 alias 不原样保存。
  • country_id = 0 表示当前 country 为空或未命中国家表。
  • country_display_name 是中文展示名,面向需要中文国家描述的客户端或管理端。
  • region_id = 0 表示无区域归属。
  • region_coderegion_name 只用于展示或筛选,不参与权限判断。

Admin Region APIs

管理端接口后续放在 gateway admin API 下gateway 负责管理端鉴权和 request envelopeuser-service 执行业务事务。

建议 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

创建国家请求:

{
  "country_name": "China",
  "country_code": "CN",
  "country_display_name": "中国",
  "sort_order": 10
}

创建区域请求:

{
  "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

推荐在 api/proto/user/v1/user.proto 追加 CountryAdminServiceRegionAdminService,或至少把二者放入独立管理 service。为了避免普通用户 RPC 和管理 RPC 权限混淆,不要把管理方法直接混入普通 UserService

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_idrequest_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 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

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

管理端新增或修改区域国家时,需要同时处理“未来生效”和“历史用户重算”。

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_FOUNDCONFLICTFAILED_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
    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 能力:

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.Countryuser.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. 新增 countriescountry_aliasesregionsregion_countriesregion_change_logsuser_region_rebuild_tasks
  2. users 新增 nullable region_id 和索引,暂不收窄 country 字段长度。
  3. 初始化 canonical 国家表种子数据和 alias 映射;例如只 seed UScountries,把 USA -> US 写入 country_aliases
  4. 代码读取时兼容 country_id=0region_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 后必须运行:

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,因为 USAUS 的 alias
创建小写国家码 cn 归一为 CN
创建非法国家码 C1ABCD 返回 INVALID_ARGUMENT
注册国家命中区域 新用户 Country=SGRegionID=SEA
注册国家不存在或 disabled 返回 INVALID_ARGUMENT,不创建用户
注册国家无映射 新用户 Country=USRegionID=0/null
注册国家小写 sg 归一为 SG 后命中区域
已有三方登录再次传国家 不修改原国家和区域
修改国家命中区域 ChangeUserCountry 同步更新 countryregion_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

文档实现后至少运行:

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

  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 记录。
  • HKMOTW 这类地区码的中文展示名和产品文案口径。