676 lines
24 KiB
Go
676 lines
24 KiB
Go
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 regionRebuildTaskStatusRunning = "running"
|
||
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
|
||
jobStore countryCodeRenameJobStore
|
||
roomClient countryCodeRoomClient
|
||
}
|
||
|
||
func NewService(userClient userclient.Client, userDB *sql.DB, jobStore countryCodeRenameJobStore, roomClient countryCodeRoomClient) *Service {
|
||
return &Service{userClient: userClient, userDB: userDB, jobStore: jobStore, roomClient: roomClient}
|
||
}
|
||
|
||
// 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
|
||
}
|