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 <> ?") args = append(args, userdomain.GlobalRegionCode) 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{} for rows.Next() { region, err := scanRegion(rows) if err != nil { return nil, err } countries, err := activeCountriesForRegion(ctx, r.db, region.RegionID) if err != nil { return nil, err } region.Countries = countries regions = append(regions, region) } if err := rows.Err(); err != nil { return nil, err } 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) }