2026-05-12 09:53:20 +08:00

393 lines
14 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 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(
&region.AppCode,
&region.RegionID,
&region.RegionCode,
&region.Name,
&region.Status,
&region.SortOrder,
&region.CreatedByUserID,
&region.UpdatedByUserID,
&region.CreatedAtMs,
&region.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(
&region.AppCode,
&region.RegionID,
&region.RegionCode,
&region.Name,
&region.Status,
&region.SortOrder,
&region.CreatedByUserID,
&region.UpdatedByUserID,
&region.CreatedAtMs,
&region.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)
}