393 lines
14 KiB
Go
393 lines
14 KiB
Go
package region
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"slices"
|
||
"sort"
|
||
"strings"
|
||
|
||
mysqldriver "github.com/go-sql-driver/mysql"
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||
"hyapp/services/user-service/internal/storage/mysql/shared"
|
||
)
|
||
|
||
const retiredRegionCodeSQLList = "'Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED'"
|
||
|
||
func (r *Repository) ResolveActiveRegionByCountry(ctx context.Context, countryCode string) (userdomain.Region, bool, error) {
|
||
country, ok, err := resolveCountryByCode(ctx, r.db, countryCode, true)
|
||
if err != nil || !ok {
|
||
return userdomain.Region{}, false, err
|
||
}
|
||
var region userdomain.Region
|
||
err = r.db.QueryRowContext(ctx, `
|
||
SELECT rg.app_code, rg.region_id, rg.region_code, rg.name, rg.status, rg.sort_order,
|
||
COALESCE(rg.created_by_user_id, 0), COALESCE(rg.updated_by_user_id, 0), rg.created_at_ms, rg.updated_at_ms
|
||
FROM region_countries rc
|
||
INNER JOIN regions rg ON rg.app_code = rc.app_code AND rg.region_id = rc.region_id
|
||
WHERE rc.app_code = ? AND rc.country_code = ? AND rc.status = ? AND rg.status = ?
|
||
AND rg.region_code NOT IN (`+retiredRegionCodeSQLList+`)
|
||
LIMIT 1
|
||
`, appcode.FromContext(ctx), country.CountryCode, userdomain.RegionStatusActive, userdomain.RegionStatusActive).Scan(
|
||
®ion.AppCode,
|
||
®ion.RegionID,
|
||
®ion.RegionCode,
|
||
®ion.Name,
|
||
®ion.Status,
|
||
®ion.SortOrder,
|
||
®ion.CreatedByUserID,
|
||
®ion.UpdatedByUserID,
|
||
®ion.CreatedAtMs,
|
||
®ion.UpdatedAtMs,
|
||
)
|
||
if err == sql.ErrNoRows {
|
||
return globalRegionFromContext(ctx), true, nil
|
||
}
|
||
if err != nil {
|
||
return userdomain.Region{}, false, err
|
||
}
|
||
countries, err := activeCountriesForRegion(ctx, r.db, region.RegionID)
|
||
if err != nil {
|
||
return userdomain.Region{}, false, err
|
||
}
|
||
region.Countries = countries
|
||
|
||
return region, true, nil
|
||
}
|
||
|
||
// UpdateRegion 修改区域展示字段,不改变国家归属。
|
||
|
||
func (r *Repository) UpdateRegion(ctx context.Context, command userdomain.UpdateRegionCommand) (userdomain.Region, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
before, err := queryRegion(ctx, tx, "WHERE app_code = ? AND region_id = ? FOR UPDATE", appcode.Normalize(command.AppCode), command.RegionID)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.Region{}, xerr.New(xerr.RegionNotFound, "region not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
if userdomain.IsRetiredRegionCode(before.RegionCode) {
|
||
return userdomain.Region{}, xerr.New(xerr.RegionNotFound, "region not found")
|
||
}
|
||
if userdomain.IsGlobalRegionCode(before.RegionCode) && !userdomain.IsGlobalRegionCode(command.RegionCode) {
|
||
return userdomain.Region{}, xerr.New(xerr.InvalidArgument, "global region code cannot be changed")
|
||
}
|
||
if _, err := tx.ExecContext(ctx, `
|
||
UPDATE regions
|
||
SET region_code = ?, name = ?, sort_order = ?, updated_by_user_id = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND region_id = ?
|
||
`, command.RegionCode, command.Name, command.SortOrder, shared.NullableOperator(command.OperatorUserID), command.NowMs, appcode.Normalize(command.AppCode), command.RegionID); err != nil {
|
||
return userdomain.Region{}, mapRegionDuplicateError(err)
|
||
}
|
||
after := before
|
||
after.RegionCode = command.RegionCode
|
||
after.Name = command.Name
|
||
after.SortOrder = command.SortOrder
|
||
after.UpdatedByUserID = command.OperatorUserID
|
||
after.UpdatedAtMs = command.NowMs
|
||
if err := insertRegionChangeLog(ctx, tx, command.RegionID, "update_region", before, after, command.OperatorUserID, command.RequestID, command.NowMs); err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
|
||
return r.GetRegion(ctx, command.RegionID)
|
||
}
|
||
|
||
// ListRegions 返回区域主数据列表,countries 只包含 active 映射。
|
||
|
||
func (r *Repository) ListRegions(ctx context.Context, filter userdomain.RegionFilter) ([]userdomain.Region, error) {
|
||
query := `
|
||
SELECT app_code, region_id, region_code, name, status, sort_order,
|
||
COALESCE(created_by_user_id, 0), COALESCE(updated_by_user_id, 0), created_at_ms, updated_at_ms
|
||
FROM regions`
|
||
args := []any{appcode.FromContext(ctx)}
|
||
conditions := []string{"app_code = ?"}
|
||
if filter.Status != "" {
|
||
conditions = append(conditions, "status = ?")
|
||
args = append(args, filter.Status)
|
||
}
|
||
conditions = append(conditions, "region_code NOT IN ("+retiredRegionCodeSQLList+")")
|
||
query += " WHERE " + strings.Join(conditions, " AND ")
|
||
query += " ORDER BY sort_order ASC, region_code ASC"
|
||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
regions := []userdomain.Region{}
|
||
hasGlobal := false
|
||
for rows.Next() {
|
||
region, err := scanRegion(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if userdomain.IsGlobalRegionCode(region.RegionCode) {
|
||
hasGlobal = true
|
||
}
|
||
countries, err := activeCountriesForRegion(ctx, r.db, region.RegionID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
region.Countries = countries
|
||
if userdomain.IsGlobalRegionCode(region.RegionCode) {
|
||
region.RegionID = 0
|
||
region.Countries = []string{}
|
||
}
|
||
regions = append(regions, region)
|
||
}
|
||
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
if !hasGlobal && (filter.Status == "" || filter.Status == userdomain.RegionStatusActive) {
|
||
regions = append([]userdomain.Region{globalRegionFromContext(ctx)}, regions...)
|
||
}
|
||
|
||
return regions, nil
|
||
}
|
||
|
||
// GetRegion 返回单个区域及其 active 国家列表。
|
||
|
||
func (r *Repository) GetRegion(ctx context.Context, regionID int64) (userdomain.Region, error) {
|
||
region, err := queryRegion(ctx, r.db, "WHERE app_code = ? AND region_id = ?", appcode.FromContext(ctx), regionID)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.Region{}, xerr.New(xerr.RegionNotFound, "region not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
if userdomain.IsRetiredRegionCode(region.RegionCode) {
|
||
return userdomain.Region{}, xerr.New(xerr.RegionNotFound, "region not found")
|
||
}
|
||
|
||
return region, nil
|
||
}
|
||
|
||
// ReplaceRegionCountries 原子替换区域 active 国家归属,并为受影响国家创建 rebuild task。
|
||
|
||
func (r *Repository) ReplaceRegionCountries(ctx context.Context, command userdomain.ReplaceRegionCountriesCommand) (userdomain.Region, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
before, err := queryRegion(ctx, tx, "WHERE app_code = ? AND region_id = ? FOR UPDATE", appcode.Normalize(command.AppCode), command.RegionID)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.Region{}, xerr.New(xerr.RegionNotFound, "region not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
if userdomain.IsRetiredRegionCode(before.RegionCode) {
|
||
return userdomain.Region{}, xerr.New(xerr.RegionNotFound, "region not found")
|
||
}
|
||
if userdomain.IsGlobalRegionCode(before.RegionCode) {
|
||
return userdomain.Region{}, xerr.New(xerr.InvalidArgument, "global region countries are implicit")
|
||
}
|
||
if before.Status != userdomain.RegionStatusActive {
|
||
return userdomain.Region{}, xerr.New(xerr.RegionDisabled, "region is disabled")
|
||
}
|
||
countries, err := canonicalCountryCodes(ctx, tx, command.Countries)
|
||
if err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
if err := releaseRetiredCountryMappings(ctx, tx, appcode.Normalize(command.AppCode), countries, command.OperatorUserID, command.NowMs); err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
if err := ensureCountriesAssignableToRegion(ctx, tx, countries, command.RegionID); err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
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 = ?
|
||
`, userdomain.RegionStatusDisabled, shared.NullableOperator(command.OperatorUserID), command.NowMs, appcode.Normalize(command.AppCode), command.RegionID, userdomain.RegionStatusActive); err != nil {
|
||
return userdomain.Region{}, 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.Normalize(command.AppCode), command.RegionID, country, userdomain.RegionStatusActive, shared.NullableOperator(command.OperatorUserID), shared.NullableOperator(command.OperatorUserID), command.NowMs, command.NowMs); err != nil {
|
||
return userdomain.Region{}, mapRegionCountryDuplicateError(err)
|
||
}
|
||
}
|
||
after := before
|
||
after.Countries = append([]string(nil), countries...)
|
||
after.UpdatedByUserID = command.OperatorUserID
|
||
after.UpdatedAtMs = command.NowMs
|
||
for _, country := range unionCountryCodes(before.Countries, countries) {
|
||
targetRegionID := int64(0)
|
||
if stringSliceContains(countries, country) {
|
||
targetRegionID = command.RegionID
|
||
}
|
||
if err := insertRebuildTask(ctx, tx, appcode.Normalize(command.AppCode), command.RegionID, country, targetRegionID, command.NowMs); err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
}
|
||
if err := insertRegionChangeLog(ctx, tx, command.RegionID, "replace_region_countries", before, after, command.OperatorUserID, command.RequestID, command.NowMs); err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
|
||
return r.GetRegion(ctx, command.RegionID)
|
||
}
|
||
|
||
// ProcessNextRegionRebuildTask 先提交 task claim,再按 user_id cursor 分页刷新 users.region_id。
|
||
|
||
func queryRegion(ctx context.Context, q queryer, clause string, args ...any) (userdomain.Region, error) {
|
||
row := q.QueryRowContext(ctx, `
|
||
SELECT app_code, region_id, region_code, name, status, sort_order,
|
||
COALESCE(created_by_user_id, 0), COALESCE(updated_by_user_id, 0), created_at_ms, updated_at_ms
|
||
FROM regions
|
||
`+clause, args...)
|
||
region, err := scanRegion(row)
|
||
if err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
countries, err := activeCountriesForRegion(ctx, q, region.RegionID)
|
||
if err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
region.Countries = countries
|
||
|
||
return region, nil
|
||
}
|
||
|
||
func scanRegion(scanner interface{ Scan(dest ...any) error }) (userdomain.Region, error) {
|
||
var region userdomain.Region
|
||
err := scanner.Scan(
|
||
®ion.AppCode,
|
||
®ion.RegionID,
|
||
®ion.RegionCode,
|
||
®ion.Name,
|
||
®ion.Status,
|
||
®ion.SortOrder,
|
||
®ion.CreatedByUserID,
|
||
®ion.UpdatedByUserID,
|
||
®ion.CreatedAtMs,
|
||
®ion.UpdatedAtMs,
|
||
)
|
||
return region, err
|
||
}
|
||
|
||
func globalRegionFromContext(ctx context.Context) userdomain.Region {
|
||
return userdomain.Region{
|
||
AppCode: appcode.FromContext(ctx),
|
||
RegionID: 0,
|
||
RegionCode: userdomain.GlobalRegionCode,
|
||
Name: userdomain.GlobalRegionCode,
|
||
Status: userdomain.RegionStatusActive,
|
||
Countries: []string{},
|
||
}
|
||
}
|
||
|
||
func activeCountriesForRegion(ctx context.Context, q queryer, regionID int64) ([]string, error) {
|
||
rows, err := queryRows(ctx, q, `
|
||
SELECT country_code
|
||
FROM region_countries
|
||
WHERE app_code = ? AND region_id = ? AND status = ?
|
||
ORDER BY country_code ASC
|
||
`, appcode.FromContext(ctx), regionID, userdomain.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 releaseRetiredCountryMappings(ctx context.Context, tx *sql.Tx, appCode string, countries []string, operatorUserID 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+`)
|
||
`, userdomain.RegionStatusDisabled, shared.NullableOperator(operatorUserID), nowMs, appcode.Normalize(appCode), country, userdomain.RegionStatusActive); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func insertRegionChangeLog(ctx context.Context, tx *sql.Tx, regionID int64, operation string, before any, after any, operatorUserID int64, requestID string, nowMs int64) error {
|
||
beforeJSON, err := marshalNullableJSON(before)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
afterJSON, err := marshalNullableJSON(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 (?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, appcode.FromContext(ctx), shared.NullableRegionID(regionID), operation, beforeJSON, afterJSON, shared.NullableOperator(operatorUserID), requestID, nowMs)
|
||
return err
|
||
}
|
||
|
||
func marshalNullableJSON(value any) (any, error) {
|
||
if value == nil {
|
||
return nil, nil
|
||
}
|
||
payload, err := json.Marshal(value)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return string(payload), nil
|
||
}
|
||
|
||
func mapRegionDuplicateError(err error) error {
|
||
var mysqlErr *mysqldriver.MySQLError
|
||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||
return xerr.New(xerr.Conflict, "region_code already exists")
|
||
}
|
||
return err
|
||
}
|
||
|
||
func unionCountryCodes(left []string, right []string) []string {
|
||
seen := make(map[string]struct{}, len(left)+len(right))
|
||
result := make([]string, 0, len(left)+len(right))
|
||
for _, country := range append(append([]string(nil), left...), right...) {
|
||
if _, exists := seen[country]; exists {
|
||
continue
|
||
}
|
||
seen[country] = struct{}{}
|
||
result = append(result, country)
|
||
}
|
||
sort.Strings(result)
|
||
|
||
return result
|
||
}
|
||
|
||
func stringSliceContains(values []string, target string) bool {
|
||
return slices.Contains(values, target)
|
||
}
|