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

335 lines
11 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"
"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"
)
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 = ?
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 userdomain.Region{}, false, 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 _, 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)
}
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)
}
return regions, rows.Err()
}
// 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
}
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 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 := 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 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 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 {
for _, value := range values {
if value == target {
return true
}
}
return false
}