2026-05-02 13:02:38 +08:00

604 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 regionStatusActive = "active"
const regionStatusDisabled = "disabled"
const regionRebuildTaskStatusPending = "pending"
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")
)
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()
countries, err := canonicalCountryCodes(ctx, tx, req.Countries)
if err != nil {
return nil, err
}
nowMs := time.Now().UnixMilli()
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
}
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(
&region.RegionID,
&region.RegionCode,
&region.Name,
&region.Status,
&region.SortOrder,
&region.CreatedAtMs,
&region.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 &region, 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 req.Name == "" || utf8.RuneCountInString(req.Name) > maxRegionNameLength {
return false
}
return len(req.Countries) > 0
}
func validRegionCode(code string) bool {
return shortRegionCodePattern.MatchString(code) || descriptiveRegionCodePattern.MatchString(code)
}
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 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
}