package countryregion import ( "context" "database/sql" "encoding/json" "errors" "regexp" "strings" "time" "unicode/utf8" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/userclient" mysqlDriver "github.com/go-sql-driver/mysql" ) const countryRegionCaller = "hyapp-admin-server" const globalRegionCode = "GLOBAL" const regionStatusActive = "active" const regionStatusDisabled = "disabled" const regionRebuildTaskStatusPending = "pending" const retiredRegionCodeSQLList = "'Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED'" const maxCountryNameLength = 128 const maxCountryDisplayNameLength = 128 const maxCountryFlagLength = 16 const maxRegionNameLength = 128 var ( countryCodePattern = regexp.MustCompile(`^[A-Z]{2,3}$`) isoAlpha3Pattern = regexp.MustCompile(`^[A-Z]{3}$`) isoNumericPattern = regexp.MustCompile(`^[0-9]{3}$`) phoneCountryCodePattern = regexp.MustCompile(`^\+[1-9][0-9]{0,2}$`) shortRegionCodePattern = regexp.MustCompile(`^[A-Za-z0-9_]{2,32}$`) descriptiveRegionCodePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 _-]{1,63}$`) errCountryNotFound = errors.New("country not found") errCountryInvalid = errors.New("country payload invalid") errCountryConflict = errors.New("country already exists") errRegionNotFound = errors.New("region not found") errRegionInvalid = errors.New("region payload invalid") errRegionConflict = errors.New("region already exists") errRegionCountryMapping = errors.New("country already belongs to another active region") errGlobalRegionProtected = errors.New("global region is protected") ) type Service struct { userClient userclient.Client userDB *sql.DB } func NewService(userClient userclient.Client, userDB *sql.DB) *Service { return &Service{userClient: userClient, userDB: userDB} } // ListCountries 只编排 user-service 已有 ListCountries RPC,不直接读 user-service 数据库。 func (s *Service) ListCountries(ctx context.Context, requestID string, enabled *bool) ([]*userclient.Country, error) { return s.userClient.ListCountries(ctx, userclient.ListCountriesRequest{ RequestID: strings.TrimSpace(requestID), Caller: countryRegionCaller, Enabled: enabled, }) } // GetCountry 复用国家列表做详情查询,避免为了后台详情新增 user-service 契约。 func (s *Service) GetCountry(ctx context.Context, requestID string, countryID int64) (*userclient.Country, error) { // user-service 当前已有列表 RPC、没有单国家详情 RPC;国家主数据规模很小,后台详情复用列表结果避免新增服务契约。 countries, err := s.ListCountries(ctx, requestID, nil) if err != nil { return nil, err } for _, country := range countries { if country != nil && country.CountryID == countryID { return country, nil } } return nil, errCountryNotFound } // CreateCountry 是后台管理创建国家主数据能力,直接写 user DB,不新增 user-service 后台 RPC。 func (s *Service) CreateCountry(ctx context.Context, actorID int64, requestID string, req createCountryRequest) (*userclient.Country, error) { if s.userDB == nil { return nil, errors.New("user db is not configured") } req = normalizeCreateCountryRequest(req) if !validCountryCreateRequest(req) || strings.TrimSpace(requestID) == "" { return nil, errCountryInvalid } enabled := true if req.Enabled != nil { enabled = *req.Enabled } nowMs := time.Now().UnixMilli() appCode := appctx.FromContext(ctx) result, err := s.userDB.ExecContext(ctx, ` INSERT INTO countries ( app_code, country_name, country_code, iso_alpha3, iso_numeric, country_display_name, phone_country_code, flag, enabled, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, appCode, req.CountryName, req.CountryCode, nullableString(req.ISOAlpha3), nullableString(req.ISONumeric), req.CountryDisplayName, nullableString(req.PhoneCountryCode), req.Flag, enabled, req.SortOrder, nullableInt64(actorID), nullableInt64(actorID), nowMs, nowMs) if err != nil { return nil, mapCountryWriteError(err) } countryID, err := result.LastInsertId() if err != nil { return nil, err } return queryCountry(ctx, s.userDB, countryID) } // UpdateCountry 不转发 countryCode,保持“国家码创建后不可变”的 app 后端规则。 func (s *Service) UpdateCountry(ctx context.Context, actorID int64, countryID int64, requestID string, req updateCountryRequest) (*userclient.Country, error) { return s.userClient.UpdateCountry(ctx, userclient.UpdateCountryRequest{ RequestID: strings.TrimSpace(requestID), Caller: countryRegionCaller, CountryID: countryID, CountryName: strings.TrimSpace(req.CountryName), CountryDisplayName: strings.TrimSpace(req.CountryDisplayName), SortOrder: req.SortOrder, OperatorUserID: actorID, ISOAlpha3: strings.TrimSpace(req.ISOAlpha3), ISONumeric: strings.TrimSpace(req.ISONumeric), PhoneCountryCode: strings.TrimSpace(req.PhoneCountryCode), Flag: strings.TrimSpace(req.Flag), }) } // EnableCountry 是后台管理状态开关,只恢复 App 可选入口,不改历史用户国家和区域快照。 func (s *Service) EnableCountry(ctx context.Context, actorID int64, countryID int64, requestID string) (*userclient.Country, error) { return s.setCountryEnabled(ctx, actorID, countryID, strings.TrimSpace(requestID), true) } // DisableCountry 是国家删除接口背后的后台软删除语义:禁用 App 可选状态,不硬删主数据。 func (s *Service) DisableCountry(ctx context.Context, actorID int64, countryID int64, requestID string) (*userclient.Country, error) { return s.setCountryEnabled(ctx, actorID, countryID, strings.TrimSpace(requestID), false) } // ListRegions 只编排 user-service 已有 ListRegions RPC,不在 admin-server 维护区域副本。 func (s *Service) ListRegions(ctx context.Context, requestID string, status string) ([]*userclient.Region, error) { return s.userClient.ListRegions(ctx, userclient.ListRegionsRequest{ RequestID: strings.TrimSpace(requestID), Caller: countryRegionCaller, Status: strings.TrimSpace(status), }) } // GetRegion 按 regionID 读取区域和 active 国家码列表。 func (s *Service) GetRegion(ctx context.Context, requestID string, regionID int64) (*userclient.Region, error) { return s.userClient.GetRegion(ctx, userclient.GetRegionRequest{ RequestID: strings.TrimSpace(requestID), Caller: countryRegionCaller, RegionID: regionID, }) } // CreateRegion 是后台管理创建区域能力,直接写 user DB 并创建历史用户区域重算任务。 func (s *Service) CreateRegion(ctx context.Context, actorID int64, requestID string, req createRegionRequest) (*userclient.Region, error) { if s.userDB == nil { return nil, errors.New("user db is not configured") } req = normalizeCreateRegionRequest(req) if !validRegionCreateRequest(req) || strings.TrimSpace(requestID) == "" { return nil, errRegionInvalid } tx, err := s.userDB.BeginTx(ctx, nil) if err != nil { return nil, err } defer tx.Rollback() nowMs := time.Now().UnixMilli() countries := []string{} if !isGlobalRegionCode(req.RegionCode) { countries, err = canonicalCountryCodes(ctx, tx, req.Countries) if err != nil { return nil, err } if err := releaseRetiredCountryMappings(ctx, tx, countries, actorID, nowMs); err != nil { return nil, err } if err := ensureCountriesAssignableToNewRegion(ctx, tx, countries); err != nil { return nil, err } } appCode := appctx.FromContext(ctx) result, err := tx.ExecContext(ctx, ` INSERT INTO regions (app_code, region_code, name, status, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, appCode, req.RegionCode, req.Name, regionStatusActive, req.SortOrder, nullableInt64(actorID), nullableInt64(actorID), nowMs, nowMs) if err != nil { return nil, mapRegionWriteError(err) } regionID, err := result.LastInsertId() if err != nil { return nil, err } for _, country := range countries { if _, err := tx.ExecContext(ctx, ` INSERT INTO region_countries (app_code, region_id, country_code, status, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, appCode, regionID, country, regionStatusActive, nullableInt64(actorID), nullableInt64(actorID), nowMs, nowMs); err != nil { return nil, mapRegionCountryWriteError(err) } if err := insertRegionRebuildTask(ctx, tx, regionID, country, regionID, nowMs); err != nil { return nil, err } } region := &userclient.Region{ RegionID: regionID, RegionCode: req.RegionCode, Name: req.Name, Status: regionStatusActive, Countries: append([]string(nil), countries...), SortOrder: req.SortOrder, CreatedAtMs: nowMs, UpdatedAtMs: nowMs, } if err := insertRegionChangeLog(ctx, tx, regionID, "create_region", nil, region, actorID, strings.TrimSpace(requestID), nowMs); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, err } return queryRegion(ctx, s.userDB, regionID) } // UpdateRegion 只修改区域展示字段,不改变 countries,避免编辑表单误触发归属重算。 func (s *Service) UpdateRegion(ctx context.Context, actorID int64, regionID int64, requestID string, req updateRegionRequest) (*userclient.Region, error) { return s.userClient.UpdateRegion(ctx, userclient.UpdateRegionRequest{ RequestID: strings.TrimSpace(requestID), Caller: countryRegionCaller, RegionID: regionID, RegionCode: strings.TrimSpace(req.RegionCode), Name: strings.TrimSpace(req.Name), SortOrder: req.SortOrder, OperatorUserID: actorID, }) } // ReplaceRegionCountries 单独替换国家归属;user-service 会负责释放旧映射并创建用户区域重算任务。 func (s *Service) ReplaceRegionCountries(ctx context.Context, actorID int64, regionID int64, requestID string, req replaceRegionCountriesRequest) (*userclient.Region, error) { return s.userClient.ReplaceRegionCountries(ctx, userclient.ReplaceRegionCountriesRequest{ RequestID: strings.TrimSpace(requestID), Caller: countryRegionCaller, RegionID: regionID, Countries: append([]string(nil), req.Countries...), OperatorUserID: actorID, }) } // EnableRegion 是后台管理状态开关,只更新 user DB 区域状态,不新增 user-service 契约。 func (s *Service) EnableRegion(ctx context.Context, actorID int64, regionID int64, requestID string) (*userclient.Region, error) { return s.setRegionEnabled(ctx, actorID, regionID, strings.TrimSpace(requestID), true) } // DisableRegion 是后台管理状态开关:停用区域并释放 active 国家归属。 func (s *Service) DisableRegion(ctx context.Context, actorID int64, regionID int64, requestID string) (*userclient.Region, error) { return s.setRegionEnabled(ctx, actorID, regionID, strings.TrimSpace(requestID), false) } func (s *Service) setRegionEnabled(ctx context.Context, actorID int64, regionID int64, requestID string, enabled bool) (*userclient.Region, error) { if s.userDB == nil { return nil, errors.New("user db is not configured") } tx, err := s.userDB.BeginTx(ctx, nil) if err != nil { return nil, err } defer tx.Rollback() before, err := queryRegionForUpdate(ctx, tx, regionID) if err != nil { return nil, err } if !enabled && isGlobalRegionCode(before.RegionCode) { return nil, errGlobalRegionProtected } nowMs := time.Now().UnixMilli() nextStatus := regionStatusDisabled operation := "disable_region" if enabled { nextStatus = regionStatusActive operation = "enable_region" } if _, err := tx.ExecContext(ctx, ` UPDATE regions SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND region_id = ? `, nextStatus, nullableInt64(actorID), nowMs, appctx.FromContext(ctx), regionID); err != nil { return nil, err } after := *before after.Status = nextStatus after.UpdatedAtMs = nowMs if !enabled { if _, err := tx.ExecContext(ctx, ` UPDATE region_countries SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND region_id = ? AND status = ? `, regionStatusDisabled, nullableInt64(actorID), nowMs, appctx.FromContext(ctx), regionID, regionStatusActive); err != nil { return nil, err } for _, country := range before.Countries { if err := insertRegionRebuildTask(ctx, tx, regionID, country, 0, nowMs); err != nil { return nil, err } } after.Countries = nil } if err := insertRegionChangeLog(ctx, tx, regionID, operation, before, &after, actorID, requestID, nowMs); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, err } return queryRegion(ctx, s.userDB, regionID) } func (s *Service) setCountryEnabled(ctx context.Context, actorID int64, countryID int64, requestID string, enabled bool) (*userclient.Country, error) { if s.userDB == nil { return nil, errors.New("user db is not configured") } if countryID <= 0 || strings.TrimSpace(requestID) == "" { return nil, errCountryInvalid } result, err := s.userDB.ExecContext(ctx, ` UPDATE countries SET enabled = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND country_id = ? `, enabled, nullableInt64(actorID), time.Now().UnixMilli(), appctx.FromContext(ctx), countryID) if err != nil { return nil, err } if affected, err := result.RowsAffected(); err != nil { return nil, err } else if affected == 0 { return nil, errCountryNotFound } return queryCountry(ctx, s.userDB, countryID) } type regionQueryer interface { QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row } func queryCountry(ctx context.Context, q regionQueryer, countryID int64) (*userclient.Country, error) { var country userclient.Country if err := q.QueryRowContext(ctx, ` SELECT country_id, country_name, country_code, COALESCE(iso_alpha3, ''), COALESCE(iso_numeric, ''), country_display_name, COALESCE(phone_country_code, ''), COALESCE(flag, ''), enabled, sort_order, created_at_ms, updated_at_ms FROM countries WHERE app_code = ? AND country_id = ? `, appctx.FromContext(ctx), countryID).Scan( &country.CountryID, &country.CountryName, &country.CountryCode, &country.ISOAlpha3, &country.ISONumeric, &country.CountryDisplayName, &country.PhoneCountryCode, &country.Flag, &country.Enabled, &country.SortOrder, &country.CreatedAtMs, &country.UpdatedAtMs, ); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, errCountryNotFound } return nil, err } return &country, nil } func queryRegionForUpdate(ctx context.Context, tx *sql.Tx, regionID int64) (*userclient.Region, error) { return scanRegion(ctx, tx, ` SELECT region_id, region_code, name, status, sort_order, created_at_ms, updated_at_ms FROM regions WHERE app_code = ? AND region_id = ? FOR UPDATE `, appctx.FromContext(ctx), regionID) } func queryRegion(ctx context.Context, q regionQueryer, regionID int64) (*userclient.Region, error) { return scanRegion(ctx, q, ` SELECT region_id, region_code, name, status, sort_order, created_at_ms, updated_at_ms FROM regions WHERE app_code = ? AND region_id = ? `, appctx.FromContext(ctx), regionID) } func scanRegion(ctx context.Context, q regionQueryer, query string, args ...any) (*userclient.Region, error) { var region userclient.Region if err := q.QueryRowContext(ctx, query, args...).Scan( ®ion.RegionID, ®ion.RegionCode, ®ion.Name, ®ion.Status, ®ion.SortOrder, ®ion.CreatedAtMs, ®ion.UpdatedAtMs, ); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, errRegionNotFound } return nil, err } countries, err := activeCountriesForRegion(ctx, q, region.RegionID) if err != nil { return nil, err } region.Countries = countries return ®ion, nil } func activeCountriesForRegion(ctx context.Context, q regionQueryer, regionID int64) ([]string, error) { rows, err := q.QueryContext(ctx, ` SELECT country_code FROM region_countries WHERE app_code = ? AND region_id = ? AND status = ? ORDER BY country_code ASC `, appctx.FromContext(ctx), regionID, regionStatusActive) if err != nil { return nil, err } defer rows.Close() countries := []string{} for rows.Next() { var country string if err := rows.Scan(&country); err != nil { return nil, err } countries = append(countries, country) } return countries, rows.Err() } func insertRegionRebuildTask(ctx context.Context, tx *sql.Tx, regionID int64, countryCode string, targetRegionID int64, nowMs int64) error { _, err := tx.ExecContext(ctx, ` INSERT INTO user_region_rebuild_tasks (app_code, region_id, country_code, target_region_id, mapping_revision, status, cursor_user_id, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?) `, appctx.FromContext(ctx), nullableInt64(regionID), countryCode, nullableInt64(targetRegionID), nowMs, regionRebuildTaskStatusPending, nowMs, nowMs) return err } func insertRegionChangeLog(ctx context.Context, tx *sql.Tx, regionID int64, operation string, before *userclient.Region, after *userclient.Region, actorID int64, requestID string, nowMs int64) error { beforeJSON, err := nullableJSON(before) if err != nil { return err } afterJSON, err := nullableJSON(after) if err != nil { return err } _, err = tx.ExecContext(ctx, ` INSERT INTO region_change_logs (app_code, region_id, operation, before_json, after_json, operator_user_id, request_id, created_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, appctx.FromContext(ctx), nullableInt64(regionID), operation, beforeJSON, afterJSON, nullableInt64(actorID), requestID, nowMs) return err } func nullableJSON(value any) (any, error) { if value == nil { return nil, nil } body, err := json.Marshal(value) if err != nil { return nil, err } return string(body), nil } func nullableInt64(value int64) any { if value <= 0 { return nil } return value } func nullableString(value string) any { if strings.TrimSpace(value) == "" { return nil } return value } func normalizeCreateCountryRequest(req createCountryRequest) createCountryRequest { req.CountryName = strings.TrimSpace(req.CountryName) req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode)) req.ISOAlpha3 = strings.ToUpper(strings.TrimSpace(req.ISOAlpha3)) req.ISONumeric = strings.TrimSpace(req.ISONumeric) req.CountryDisplayName = strings.TrimSpace(req.CountryDisplayName) req.PhoneCountryCode = strings.TrimSpace(req.PhoneCountryCode) req.Flag = strings.TrimSpace(req.Flag) return req } func validCountryCreateRequest(req createCountryRequest) bool { if !countryCodePattern.MatchString(req.CountryCode) { return false } if req.CountryName == "" || utf8.RuneCountInString(req.CountryName) > maxCountryNameLength { return false } if req.CountryDisplayName == "" || utf8.RuneCountInString(req.CountryDisplayName) > maxCountryDisplayNameLength { return false } if req.ISOAlpha3 != "" && !isoAlpha3Pattern.MatchString(req.ISOAlpha3) { return false } if req.ISONumeric != "" && !isoNumericPattern.MatchString(req.ISONumeric) { return false } if req.PhoneCountryCode != "" && !phoneCountryCodePattern.MatchString(req.PhoneCountryCode) { return false } return utf8.RuneCountInString(req.Flag) <= maxCountryFlagLength } func mapCountryWriteError(err error) error { var mysqlErr *mysqlDriver.MySQLError if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { return errCountryConflict } return err } func normalizeCreateRegionRequest(req createRegionRequest) createRegionRequest { req.RegionCode = normalizeRegionCode(req.RegionCode) req.Name = strings.TrimSpace(req.Name) req.Countries = append([]string(nil), req.Countries...) return req } func normalizeRegionCode(code string) string { code = strings.TrimSpace(code) if shortRegionCodePattern.MatchString(code) { return strings.ToUpper(code) } return strings.Join(strings.Fields(code), " ") } func validRegionCreateRequest(req createRegionRequest) bool { if !validRegionCode(req.RegionCode) { return false } if isRetiredRegionCode(req.RegionCode) { return false } if req.Name == "" || utf8.RuneCountInString(req.Name) > maxRegionNameLength { return false } if isGlobalRegionCode(req.RegionCode) { return len(req.Countries) == 0 } return len(req.Countries) > 0 } func validRegionCode(code string) bool { return shortRegionCodePattern.MatchString(code) || descriptiveRegionCodePattern.MatchString(code) } func isGlobalRegionCode(code string) bool { return normalizeRegionCode(code) == globalRegionCode } func isRetiredRegionCode(code string) bool { switch strings.ToUpper(normalizeRegionCode(code)) { case "AUSTRALIA AND NEW ZEALAND", "CARIBBEAN", "MELANESIA", "MICRONESIA", "POLYNESIA", "SOUTHERN AFRICA", "UNSPECIFIED": return true default: return false } } func canonicalCountryCodes(ctx context.Context, q regionQueryer, countries []string) ([]string, error) { if len(countries) == 0 { return nil, errRegionInvalid } seen := make(map[string]struct{}, len(countries)) result := make([]string, 0, len(countries)) for _, input := range countries { countryCode := strings.ToUpper(strings.TrimSpace(input)) if !countryCodePattern.MatchString(countryCode) { return nil, errRegionInvalid } var canonical string err := q.QueryRowContext(ctx, ` SELECT country_code FROM countries WHERE app_code = ? AND country_code = ? `, appctx.FromContext(ctx), countryCode).Scan(&canonical) if errors.Is(err, sql.ErrNoRows) { return nil, errRegionInvalid } if err != nil { return nil, err } if _, exists := seen[canonical]; exists { return nil, errRegionInvalid } seen[canonical] = struct{}{} result = append(result, canonical) } return result, nil } func ensureCountriesAssignableToNewRegion(ctx context.Context, q regionQueryer, countries []string) error { for _, country := range countries { var existingRegionID int64 err := q.QueryRowContext(ctx, ` SELECT region_id FROM region_countries WHERE app_code = ? AND country_code = ? AND status = ? LIMIT 1 `, appctx.FromContext(ctx), country, regionStatusActive).Scan(&existingRegionID) if errors.Is(err, sql.ErrNoRows) { continue } if err != nil { return err } return errRegionCountryMapping } return nil } func releaseRetiredCountryMappings(ctx context.Context, tx *sql.Tx, countries []string, actorID int64, nowMs int64) error { for _, country := range countries { if _, err := tx.ExecContext(ctx, ` UPDATE region_countries rc INNER JOIN regions rg ON rg.app_code = rc.app_code AND rg.region_id = rc.region_id SET rc.status = ?, rc.updated_by_user_id = ?, rc.updated_at_ms = ? WHERE rc.app_code = ? AND rc.country_code = ? AND rc.status = ? AND rg.region_code IN (`+retiredRegionCodeSQLList+`) `, regionStatusDisabled, nullableInt64(actorID), nowMs, appctx.FromContext(ctx), country, regionStatusActive); err != nil { return err } } return nil } func mapRegionWriteError(err error) error { var mysqlErr *mysqlDriver.MySQLError if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { return errRegionConflict } return err } func mapRegionCountryWriteError(err error) error { var mysqlErr *mysqlDriver.MySQLError if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { return errRegionCountryMapping } return err }