1017 lines
34 KiB
Go
1017 lines
34 KiB
Go
package mysql
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
mysqldriver "github.com/go-sql-driver/mysql"
|
||
"hyapp/pkg/xerr"
|
||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||
)
|
||
|
||
// ResolveEnabledCountryByCode 按国家码读取 App 可选国家,注册和改国家都会经过这里。
|
||
func (r *Repository) ResolveEnabledCountryByCode(ctx context.Context, countryCode string) (userdomain.Country, bool, error) {
|
||
return resolveCountryByCode(ctx, r.db, countryCode, true)
|
||
}
|
||
|
||
// ListRegistrationCountries 返回注册页可选国家,只包含 enabled 的国家。
|
||
func (r *Repository) ListRegistrationCountries(ctx context.Context) ([]userdomain.Country, error) {
|
||
return listCountries(ctx, r.db, userdomain.CountryFilter{Enabled: boolPtr(true)})
|
||
}
|
||
|
||
// ResolveActiveRegionByCountry 按国家码读取当前 active 区域映射;无映射返回 ok=false。
|
||
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.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.region_id = rc.region_id
|
||
WHERE rc.country_code = ? AND rc.status = ? AND rg.status = ?
|
||
LIMIT 1
|
||
`, country.CountryCode, userdomain.RegionStatusActive, userdomain.RegionStatusActive).Scan(
|
||
®ion.RegionID,
|
||
®ion.RegionCode,
|
||
®ion.Name,
|
||
®ion.Status,
|
||
®ion.SortOrder,
|
||
®ion.CreatedByUserID,
|
||
®ion.UpdatedByUserID,
|
||
®ion.CreatedAtMs,
|
||
®ion.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
|
||
}
|
||
|
||
// CreateCountry 创建国家主数据;enabled 是 App 注册页和改国家链路的唯一可用性开关。
|
||
func (r *Repository) CreateCountry(ctx context.Context, command userdomain.CreateCountryCommand) (userdomain.Country, error) {
|
||
result, err := r.db.ExecContext(ctx, `
|
||
INSERT INTO countries (
|
||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, command.CountryName, command.CountryCode, nullableString(command.ISOAlpha3), nullableString(command.ISONumeric), command.CountryDisplayName, nullableString(command.PhoneCountryCode), command.Flag, command.Enabled, command.SortOrder, nullableOperator(command.OperatorUserID), nullableOperator(command.OperatorUserID), command.NowMs, command.NowMs)
|
||
if err != nil {
|
||
return userdomain.Country{}, mapCountryDuplicateError(err)
|
||
}
|
||
countryID, err := result.LastInsertId()
|
||
if err != nil {
|
||
return userdomain.Country{}, err
|
||
}
|
||
|
||
return queryCountry(ctx, r.db, "WHERE country_id = ?", countryID)
|
||
}
|
||
|
||
// UpdateCountry 修改国家展示字段;country_code 不允许原地修改。
|
||
func (r *Repository) UpdateCountry(ctx context.Context, command userdomain.UpdateCountryCommand) (userdomain.Country, error) {
|
||
result, err := r.db.ExecContext(ctx, `
|
||
UPDATE countries
|
||
SET country_name = ?,
|
||
iso_alpha3 = ?,
|
||
iso_numeric = ?,
|
||
country_display_name = ?,
|
||
phone_country_code = ?,
|
||
flag = ?,
|
||
sort_order = ?,
|
||
updated_by_user_id = ?,
|
||
updated_at_ms = ?
|
||
WHERE country_id = ?
|
||
`, command.CountryName, nullableString(command.ISOAlpha3), nullableString(command.ISONumeric), command.CountryDisplayName, nullableString(command.PhoneCountryCode), command.Flag, command.SortOrder, nullableOperator(command.OperatorUserID), command.NowMs, command.CountryID)
|
||
if err != nil {
|
||
return userdomain.Country{}, mapCountryDuplicateError(err)
|
||
}
|
||
if affected, err := result.RowsAffected(); err != nil {
|
||
return userdomain.Country{}, err
|
||
} else if affected == 0 {
|
||
return userdomain.Country{}, xerr.New(xerr.CountryNotFound, "country not found")
|
||
}
|
||
|
||
return queryCountry(ctx, r.db, "WHERE country_id = ?", command.CountryID)
|
||
}
|
||
|
||
// ListCountries 返回国家主数据,enabled 为空时返回全部。
|
||
func (r *Repository) ListCountries(ctx context.Context, filter userdomain.CountryFilter) ([]userdomain.Country, error) {
|
||
return listCountries(ctx, r.db, filter)
|
||
}
|
||
|
||
// EnableCountry 重新开放国家给 App 注册和改国家链路。
|
||
func (r *Repository) EnableCountry(ctx context.Context, command userdomain.EnableCountryCommand) (userdomain.Country, error) {
|
||
result, err := r.db.ExecContext(ctx, `
|
||
UPDATE countries
|
||
SET enabled = ?, updated_by_user_id = ?, updated_at_ms = ?
|
||
WHERE country_id = ?
|
||
`, true, nullableOperator(command.OperatorUserID), command.NowMs, command.CountryID)
|
||
if err != nil {
|
||
return userdomain.Country{}, err
|
||
}
|
||
if affected, err := result.RowsAffected(); err != nil {
|
||
return userdomain.Country{}, err
|
||
} else if affected == 0 {
|
||
return userdomain.Country{}, xerr.New(xerr.CountryNotFound, "country not found")
|
||
}
|
||
|
||
return queryCountry(ctx, r.db, "WHERE country_id = ?", command.CountryID)
|
||
}
|
||
|
||
// DisableCountry 只关闭 App 选择入口,不删除国家,也不释放区域配置。
|
||
func (r *Repository) DisableCountry(ctx context.Context, command userdomain.DisableCountryCommand) (userdomain.Country, error) {
|
||
result, err := r.db.ExecContext(ctx, `
|
||
UPDATE countries
|
||
SET enabled = ?, updated_by_user_id = ?, updated_at_ms = ?
|
||
WHERE country_id = ?
|
||
`, false, nullableOperator(command.OperatorUserID), command.NowMs, command.CountryID)
|
||
if err != nil {
|
||
return userdomain.Country{}, err
|
||
}
|
||
if affected, err := result.RowsAffected(); err != nil {
|
||
return userdomain.Country{}, err
|
||
} else if affected == 0 {
|
||
return userdomain.Country{}, xerr.New(xerr.CountryNotFound, "country not found")
|
||
}
|
||
|
||
return queryCountry(ctx, r.db, "WHERE country_id = ?", command.CountryID)
|
||
}
|
||
|
||
// CreateRegion 创建区域并写 active 国家归属、审计和历史用户重算任务。
|
||
func (r *Repository) CreateRegion(ctx context.Context, command userdomain.CreateRegionCommand) (userdomain.Region, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
countries, err := canonicalCountryCodes(ctx, tx, command.Countries)
|
||
if err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
result, err := tx.ExecContext(ctx, `
|
||
INSERT INTO regions (region_code, name, status, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, command.RegionCode, command.Name, userdomain.RegionStatusActive, command.SortOrder, nullableOperator(command.OperatorUserID), nullableOperator(command.OperatorUserID), command.NowMs, command.NowMs)
|
||
if err != nil {
|
||
return userdomain.Region{}, mapRegionDuplicateError(err)
|
||
}
|
||
regionID, err := result.LastInsertId()
|
||
if err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
for _, country := range countries {
|
||
if _, err := tx.ExecContext(ctx, `
|
||
INSERT INTO region_countries (region_id, country_code, status, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
`, regionID, country, userdomain.RegionStatusActive, nullableOperator(command.OperatorUserID), nullableOperator(command.OperatorUserID), command.NowMs, command.NowMs); err != nil {
|
||
return userdomain.Region{}, mapRegionCountryDuplicateError(err)
|
||
}
|
||
if err := insertRebuildTask(ctx, tx, regionID, country, regionID, command.NowMs); err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
}
|
||
region := userdomain.Region{
|
||
RegionID: regionID,
|
||
RegionCode: command.RegionCode,
|
||
Name: command.Name,
|
||
Status: userdomain.RegionStatusActive,
|
||
Countries: append([]string(nil), countries...),
|
||
SortOrder: command.SortOrder,
|
||
CreatedByUserID: command.OperatorUserID,
|
||
UpdatedByUserID: command.OperatorUserID,
|
||
CreatedAtMs: command.NowMs,
|
||
UpdatedAtMs: command.NowMs,
|
||
}
|
||
if err := insertRegionChangeLog(ctx, tx, regionID, "create_region", nil, region, command.OperatorUserID, command.RequestID, command.NowMs); err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
|
||
return region, 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 region_id = ? FOR UPDATE", 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 region_id = ?
|
||
`, command.RegionCode, command.Name, command.SortOrder, nullableOperator(command.OperatorUserID), command.NowMs, 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 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{}
|
||
if filter.Status != "" {
|
||
query += " WHERE status = ?"
|
||
args = append(args, filter.Status)
|
||
}
|
||
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 region_id = ?", 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 region_id = ? FOR UPDATE", 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 region_id = ? AND status = ?
|
||
`, userdomain.RegionStatusDisabled, nullableOperator(command.OperatorUserID), command.NowMs, command.RegionID, userdomain.RegionStatusActive); err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
for _, country := range countries {
|
||
if _, err := tx.ExecContext(ctx, `
|
||
INSERT INTO region_countries (region_id, country_code, status, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
`, command.RegionID, country, userdomain.RegionStatusActive, nullableOperator(command.OperatorUserID), 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, 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)
|
||
}
|
||
|
||
// DisableRegion 停用区域并释放 active 国家归属。
|
||
func (r *Repository) DisableRegion(ctx context.Context, command userdomain.DisableRegionCommand) (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 region_id = ? FOR UPDATE", 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 status = ?, updated_by_user_id = ?, updated_at_ms = ?
|
||
WHERE region_id = ?
|
||
`, userdomain.RegionStatusDisabled, nullableOperator(command.OperatorUserID), command.NowMs, 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 region_id = ? AND status = ?
|
||
`, userdomain.RegionStatusDisabled, nullableOperator(command.OperatorUserID), command.NowMs, command.RegionID, userdomain.RegionStatusActive); err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
after := before
|
||
after.Status = userdomain.RegionStatusDisabled
|
||
after.Countries = nil
|
||
after.UpdatedByUserID = command.OperatorUserID
|
||
after.UpdatedAtMs = command.NowMs
|
||
for _, country := range before.Countries {
|
||
if err := insertRebuildTask(ctx, tx, command.RegionID, country, 0, command.NowMs); err != nil {
|
||
return userdomain.Region{}, err
|
||
}
|
||
}
|
||
if err := insertRegionChangeLog(ctx, tx, command.RegionID, "disable_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)
|
||
}
|
||
|
||
// ProcessNextRegionRebuildTask 先提交 task claim,再按 user_id cursor 分页刷新 users.region_id。
|
||
func (r *Repository) ProcessNextRegionRebuildTask(ctx context.Context, workerID string, nowMs int64, lockTTL time.Duration, batchSize int) (userdomain.RegionRebuildProcessResult, error) {
|
||
if lockTTL <= 0 {
|
||
lockTTL = 30 * time.Second
|
||
}
|
||
if batchSize <= 0 {
|
||
batchSize = 500
|
||
}
|
||
|
||
// claim 必须独立提交:running/locked_until/attempt_count 要对其他 worker 和运维可见。
|
||
task, ok, err := claimRegionRebuildTask(ctx, r.db, workerID, nowMs, lockTTL)
|
||
if err != nil {
|
||
return userdomain.RegionRebuildProcessResult{}, err
|
||
}
|
||
if !ok {
|
||
return userdomain.RegionRebuildProcessResult{NoTask: true}, nil
|
||
}
|
||
|
||
return r.processClaimedRegionRebuildTask(ctx, task.TaskID, workerID, nowMs, batchSize)
|
||
}
|
||
|
||
func (r *Repository) processClaimedRegionRebuildTask(ctx context.Context, taskID int64, workerID string, nowMs int64, batchSize int) (userdomain.RegionRebuildProcessResult, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userdomain.RegionRebuildProcessResult{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
task, ok, err := lockClaimedRegionRebuildTask(ctx, tx, taskID, workerID)
|
||
if err != nil {
|
||
return userdomain.RegionRebuildProcessResult{}, err
|
||
}
|
||
if !ok {
|
||
// task 在 claim 提交后被接管或人工改动时,本轮不应继续写 users。
|
||
return userdomain.RegionRebuildProcessResult{TaskID: taskID}, xerr.New(xerr.Conflict, "region rebuild task lock is lost")
|
||
}
|
||
result := userdomain.RegionRebuildProcessResult{
|
||
TaskID: task.TaskID,
|
||
CountryCode: task.CountryCode,
|
||
TargetRegionID: task.TargetRegionID,
|
||
Status: userdomain.RegionRebuildTaskStatusRunning,
|
||
CursorUserID: task.CursorUserID,
|
||
}
|
||
|
||
stale, err := newerRegionRebuildTaskExists(ctx, tx, task.CountryCode, task.MappingRevision, task.TaskID)
|
||
if err != nil {
|
||
return result, err
|
||
}
|
||
if stale {
|
||
// 旧任务不能覆盖新映射;直接标记 skipped,等待较新任务刷新历史用户。
|
||
if err := finishRegionRebuildTask(ctx, tx, task.TaskID, workerID, userdomain.RegionRebuildTaskStatusSkipped, task.CursorUserID, "stale mapping revision", nowMs); err != nil {
|
||
return result, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return result, err
|
||
}
|
||
result.Status = userdomain.RegionRebuildTaskStatusSkipped
|
||
return result, nil
|
||
}
|
||
|
||
userIDs, err := selectRegionRebuildUserIDs(ctx, tx, task.CountryCode, task.CursorUserID, batchSize)
|
||
if err != nil {
|
||
return result, err
|
||
}
|
||
if len(userIDs) == 0 {
|
||
if err := finishRegionRebuildTask(ctx, tx, task.TaskID, workerID, userdomain.RegionRebuildTaskStatusCompleted, task.CursorUserID, "", nowMs); err != nil {
|
||
return result, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return result, err
|
||
}
|
||
result.Status = userdomain.RegionRebuildTaskStatusCompleted
|
||
return result, nil
|
||
}
|
||
|
||
if err := updateUsersRegionByIDs(ctx, tx, userIDs, task.TargetRegionID, nowMs); err != nil {
|
||
return result, err
|
||
}
|
||
newCursor := userIDs[len(userIDs)-1]
|
||
status := userdomain.RegionRebuildTaskStatusPending
|
||
if len(userIDs) < batchSize {
|
||
status = userdomain.RegionRebuildTaskStatusCompleted
|
||
}
|
||
if err := finishRegionRebuildTask(ctx, tx, task.TaskID, workerID, status, newCursor, "", nowMs); err != nil {
|
||
return result, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return result, err
|
||
}
|
||
|
||
result.Status = status
|
||
result.ProcessedUsers = len(userIDs)
|
||
result.CursorUserID = newCursor
|
||
return result, nil
|
||
}
|
||
|
||
func queryCountry(ctx context.Context, q queryer, clause string, args ...any) (userdomain.Country, error) {
|
||
row := 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,
|
||
COALESCE(created_by_user_id, 0), COALESCE(updated_by_user_id, 0), created_at_ms, updated_at_ms
|
||
FROM countries
|
||
`+clause, args...)
|
||
return scanCountry(row)
|
||
}
|
||
|
||
func listCountries(ctx context.Context, q queryer, filter userdomain.CountryFilter) ([]userdomain.Country, error) {
|
||
query := `
|
||
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,
|
||
COALESCE(created_by_user_id, 0), COALESCE(updated_by_user_id, 0), created_at_ms, updated_at_ms
|
||
FROM countries`
|
||
args := []any{}
|
||
conditions := []string{}
|
||
if filter.Enabled != nil {
|
||
conditions = append(conditions, "enabled = ?")
|
||
args = append(args, *filter.Enabled)
|
||
}
|
||
if len(conditions) > 0 {
|
||
query += " WHERE " + strings.Join(conditions, " AND ")
|
||
}
|
||
query += " ORDER BY sort_order ASC, country_code ASC"
|
||
rows, err := queryRows(ctx, q, query, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
countries := []userdomain.Country{}
|
||
for rows.Next() {
|
||
country, err := scanCountry(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
countries = append(countries, country)
|
||
}
|
||
|
||
return countries, rows.Err()
|
||
}
|
||
|
||
func scanCountry(scanner interface{ Scan(dest ...any) error }) (userdomain.Country, error) {
|
||
var country userdomain.Country
|
||
err := scanner.Scan(
|
||
&country.CountryID,
|
||
&country.CountryName,
|
||
&country.CountryCode,
|
||
&country.ISOAlpha3,
|
||
&country.ISONumeric,
|
||
&country.CountryDisplayName,
|
||
&country.PhoneCountryCode,
|
||
&country.Flag,
|
||
&country.Enabled,
|
||
&country.SortOrder,
|
||
&country.CreatedByUserID,
|
||
&country.UpdatedByUserID,
|
||
&country.CreatedAtMs,
|
||
&country.UpdatedAtMs,
|
||
)
|
||
return country, err
|
||
}
|
||
|
||
func boolPtr(value bool) *bool {
|
||
return &value
|
||
}
|
||
|
||
func queryRegion(ctx context.Context, q queryer, clause string, args ...any) (userdomain.Region, error) {
|
||
row := q.QueryRowContext(ctx, `
|
||
SELECT 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.RegionID,
|
||
®ion.RegionCode,
|
||
®ion.Name,
|
||
®ion.Status,
|
||
®ion.SortOrder,
|
||
®ion.CreatedByUserID,
|
||
®ion.UpdatedByUserID,
|
||
®ion.CreatedAtMs,
|
||
®ion.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 region_id = ? AND status = ?
|
||
ORDER BY country_code ASC
|
||
`, 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 canonicalCountryCodes(ctx context.Context, q queryer, countries []string) ([]string, error) {
|
||
seen := make(map[string]struct{}, len(countries))
|
||
result := make([]string, 0, len(countries))
|
||
for _, input := range countries {
|
||
country, ok, err := resolveCountryByCode(ctx, q, input, false)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !ok {
|
||
return nil, xerr.New(xerr.InvalidArgument, "country is not supported")
|
||
}
|
||
if _, exists := seen[country.CountryCode]; exists {
|
||
// 区域国家列表不能重复,避免重复写入 active 国家归属。
|
||
return nil, xerr.New(xerr.InvalidArgument, "countries must not contain duplicate canonical codes")
|
||
}
|
||
seen[country.CountryCode] = struct{}{}
|
||
result = append(result, country.CountryCode)
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
func resolveCountryByCode(ctx context.Context, q queryer, countryCode string, requireEnabled bool) (userdomain.Country, bool, error) {
|
||
countryCode = userdomain.NormalizeCountryCode(countryCode)
|
||
clause := "WHERE country_code = ?"
|
||
args := []any{countryCode}
|
||
if requireEnabled {
|
||
clause += " AND enabled = ?"
|
||
args = append(args, true)
|
||
}
|
||
country, err := queryCountry(ctx, q, clause, args...)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.Country{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return userdomain.Country{}, false, err
|
||
}
|
||
|
||
return country, true, nil
|
||
}
|
||
|
||
func ensureCountriesAssignableToRegion(ctx context.Context, q queryer, countries []string, regionID int64) error {
|
||
for _, country := range countries {
|
||
mappedRegionID, mapped, err := activeRegionIDByCountry(ctx, q, country)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if mapped && mappedRegionID != regionID {
|
||
return xerr.New(xerr.RegionCountryConflict, "country already belongs to another active region")
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func activeRegionIDByCountry(ctx context.Context, q queryer, countryCode string) (int64, bool, error) {
|
||
var regionID int64
|
||
err := q.QueryRowContext(ctx, `
|
||
SELECT region_id
|
||
FROM region_countries
|
||
WHERE country_code = ? AND status = ?
|
||
LIMIT 1
|
||
`, countryCode, userdomain.RegionStatusActive).Scan(®ionID)
|
||
if err == sql.ErrNoRows {
|
||
return 0, false, nil
|
||
}
|
||
if err != nil {
|
||
return 0, false, err
|
||
}
|
||
|
||
return regionID, true, nil
|
||
}
|
||
|
||
func insertRebuildTask(ctx context.Context, tx *sql.Tx, regionID int64, countryCode string, targetRegionID int64, revision int64) error {
|
||
_, err := tx.ExecContext(ctx, `
|
||
INSERT INTO user_region_rebuild_tasks (region_id, country_code, target_region_id, mapping_revision, status, cursor_user_id, created_at_ms, updated_at_ms)
|
||
VALUES (?, ?, ?, ?, ?, 0, ?, ?)
|
||
`, nullableRegionID(regionID), countryCode, nullableRegionID(targetRegionID), revision, userdomain.RegionRebuildTaskStatusPending, revision, revision)
|
||
return err
|
||
}
|
||
|
||
func claimRegionRebuildTask(ctx context.Context, db *sql.DB, workerID string, nowMs int64, lockTTL time.Duration) (userdomain.UserRegionRebuildTask, bool, error) {
|
||
tx, err := db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userdomain.UserRegionRebuildTask{}, false, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
task, ok, err := selectClaimableRegionRebuildTask(ctx, tx, nowMs)
|
||
if err != nil {
|
||
return userdomain.UserRegionRebuildTask{}, false, err
|
||
}
|
||
if !ok {
|
||
return userdomain.UserRegionRebuildTask{}, false, nil
|
||
}
|
||
|
||
lockedUntilMs := nowMs + lockTTL.Milliseconds()
|
||
result, err := tx.ExecContext(ctx, `
|
||
UPDATE user_region_rebuild_tasks
|
||
SET status = ?, locked_by = ?, locked_until_ms = ?, attempt_count = attempt_count + 1, error_message = NULL, updated_at_ms = ?
|
||
WHERE task_id = ?
|
||
`, userdomain.RegionRebuildTaskStatusRunning, workerID, lockedUntilMs, nowMs, task.TaskID)
|
||
if err != nil {
|
||
return userdomain.UserRegionRebuildTask{}, false, err
|
||
}
|
||
if affected, err := result.RowsAffected(); err != nil {
|
||
return userdomain.UserRegionRebuildTask{}, false, err
|
||
} else if affected != 1 {
|
||
return userdomain.UserRegionRebuildTask{}, false, xerr.New(xerr.Conflict, "region rebuild task claim conflict")
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.UserRegionRebuildTask{}, false, err
|
||
}
|
||
task.Status = userdomain.RegionRebuildTaskStatusRunning
|
||
task.LockedBy = workerID
|
||
task.LockedUntilMs = lockedUntilMs
|
||
task.AttemptCount++
|
||
task.UpdatedAtMs = nowMs
|
||
|
||
return task, true, nil
|
||
}
|
||
|
||
func selectClaimableRegionRebuildTask(ctx context.Context, tx *sql.Tx, nowMs int64) (userdomain.UserRegionRebuildTask, bool, error) {
|
||
row := tx.QueryRowContext(ctx, `
|
||
SELECT task_id, region_id, country_code, target_region_id, mapping_revision, status, cursor_user_id,
|
||
locked_by, locked_until_ms, attempt_count, error_message, created_at_ms, updated_at_ms
|
||
FROM user_region_rebuild_tasks
|
||
WHERE status = ?
|
||
OR (status = ? AND COALESCE(locked_until_ms, 0) <= ?)
|
||
ORDER BY updated_at_ms ASC, task_id ASC
|
||
LIMIT 1
|
||
FOR UPDATE SKIP LOCKED
|
||
`, userdomain.RegionRebuildTaskStatusPending, userdomain.RegionRebuildTaskStatusRunning, nowMs)
|
||
task, err := scanRegionRebuildTask(row)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.UserRegionRebuildTask{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return userdomain.UserRegionRebuildTask{}, false, err
|
||
}
|
||
|
||
return task, true, nil
|
||
}
|
||
|
||
func lockClaimedRegionRebuildTask(ctx context.Context, tx *sql.Tx, taskID int64, workerID string) (userdomain.UserRegionRebuildTask, bool, error) {
|
||
row := tx.QueryRowContext(ctx, `
|
||
SELECT task_id, region_id, country_code, target_region_id, mapping_revision, status, cursor_user_id,
|
||
locked_by, locked_until_ms, attempt_count, error_message, created_at_ms, updated_at_ms
|
||
FROM user_region_rebuild_tasks
|
||
WHERE task_id = ? AND status = ? AND locked_by = ?
|
||
FOR UPDATE
|
||
`, taskID, userdomain.RegionRebuildTaskStatusRunning, workerID)
|
||
task, err := scanRegionRebuildTask(row)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.UserRegionRebuildTask{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return userdomain.UserRegionRebuildTask{}, false, err
|
||
}
|
||
|
||
return task, true, nil
|
||
}
|
||
|
||
func scanRegionRebuildTask(scanner interface{ Scan(dest ...any) error }) (userdomain.UserRegionRebuildTask, error) {
|
||
var task userdomain.UserRegionRebuildTask
|
||
var regionID sql.NullInt64
|
||
var targetRegionID sql.NullInt64
|
||
var lockedBy sql.NullString
|
||
var lockedUntil sql.NullInt64
|
||
var errorMessage sql.NullString
|
||
err := scanner.Scan(
|
||
&task.TaskID,
|
||
®ionID,
|
||
&task.CountryCode,
|
||
&targetRegionID,
|
||
&task.MappingRevision,
|
||
&task.Status,
|
||
&task.CursorUserID,
|
||
&lockedBy,
|
||
&lockedUntil,
|
||
&task.AttemptCount,
|
||
&errorMessage,
|
||
&task.CreatedAtMs,
|
||
&task.UpdatedAtMs,
|
||
)
|
||
if err != nil {
|
||
return userdomain.UserRegionRebuildTask{}, err
|
||
}
|
||
if regionID.Valid {
|
||
task.RegionID = regionID.Int64
|
||
}
|
||
if targetRegionID.Valid {
|
||
task.TargetRegionID = targetRegionID.Int64
|
||
}
|
||
if lockedBy.Valid {
|
||
task.LockedBy = lockedBy.String
|
||
}
|
||
if lockedUntil.Valid {
|
||
task.LockedUntilMs = lockedUntil.Int64
|
||
}
|
||
if errorMessage.Valid {
|
||
task.ErrorMessage = errorMessage.String
|
||
}
|
||
|
||
return task, nil
|
||
}
|
||
|
||
func newerRegionRebuildTaskExists(ctx context.Context, q queryer, countryCode string, revision int64, taskID int64) (bool, error) {
|
||
var one int
|
||
err := q.QueryRowContext(ctx, `
|
||
SELECT 1
|
||
FROM user_region_rebuild_tasks
|
||
WHERE country_code = ?
|
||
AND (mapping_revision > ? OR (mapping_revision = ? AND task_id > ?))
|
||
LIMIT 1
|
||
`, countryCode, revision, revision, taskID).Scan(&one)
|
||
if err == sql.ErrNoRows {
|
||
return false, nil
|
||
}
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
|
||
return true, nil
|
||
}
|
||
|
||
func selectRegionRebuildUserIDs(ctx context.Context, q queryer, countryCode string, cursorUserID int64, batchSize int) ([]int64, error) {
|
||
rows, err := queryRows(ctx, q, `
|
||
SELECT user_id
|
||
FROM users
|
||
WHERE country = ? AND user_id > ?
|
||
ORDER BY user_id ASC
|
||
LIMIT ?
|
||
`, countryCode, cursorUserID, batchSize)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
userIDs := make([]int64, 0, batchSize)
|
||
for rows.Next() {
|
||
var userID int64
|
||
if err := rows.Scan(&userID); err != nil {
|
||
return nil, err
|
||
}
|
||
userIDs = append(userIDs, userID)
|
||
}
|
||
|
||
return userIDs, rows.Err()
|
||
}
|
||
|
||
func updateUsersRegionByIDs(ctx context.Context, tx *sql.Tx, userIDs []int64, targetRegionID int64, nowMs int64) error {
|
||
if len(userIDs) == 0 {
|
||
return nil
|
||
}
|
||
placeholders := make([]string, 0, len(userIDs))
|
||
args := make([]any, 0, len(userIDs)+2)
|
||
args = append(args, nullableRegionID(targetRegionID), nowMs)
|
||
for _, userID := range userIDs {
|
||
placeholders = append(placeholders, "?")
|
||
args = append(args, userID)
|
||
}
|
||
_, err := tx.ExecContext(ctx, `
|
||
UPDATE users
|
||
SET region_id = ?, updated_at_ms = ?
|
||
WHERE user_id IN (`+strings.Join(placeholders, ",")+`)
|
||
`, args...)
|
||
return err
|
||
}
|
||
|
||
func finishRegionRebuildTask(ctx context.Context, tx *sql.Tx, taskID int64, workerID string, status string, cursorUserID int64, errorMessage string, nowMs int64) error {
|
||
result, err := tx.ExecContext(ctx, `
|
||
UPDATE user_region_rebuild_tasks
|
||
SET status = ?, cursor_user_id = ?, locked_by = NULL, locked_until_ms = NULL, error_message = ?, updated_at_ms = ?
|
||
WHERE task_id = ? AND status = ? AND locked_by = ?
|
||
`, status, cursorUserID, nullableString(errorMessage), nowMs, taskID, userdomain.RegionRebuildTaskStatusRunning, workerID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if affected, err := result.RowsAffected(); err != nil {
|
||
return err
|
||
} else if affected != 1 {
|
||
// finish 和 users 更新在同一个事务里;丢失 claim 时让事务回滚,避免旧 worker 覆盖新进度。
|
||
return xerr.New(xerr.Conflict, "region rebuild task lock is lost")
|
||
}
|
||
|
||
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 (region_id, operation, before_json, after_json, operator_user_id, request_id, created_at_ms)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
`, nullableRegionID(regionID), operation, beforeJSON, afterJSON, 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 mapCountryDuplicateError(err error) error {
|
||
var mysqlErr *mysqldriver.MySQLError
|
||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||
return xerr.New(xerr.Conflict, "country_code already exists")
|
||
}
|
||
return err
|
||
}
|
||
|
||
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 mapRegionCountryDuplicateError(err error) error {
|
||
var mysqlErr *mysqldriver.MySQLError
|
||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||
return xerr.New(xerr.RegionCountryConflict, "country already belongs to another active region")
|
||
}
|
||
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
|
||
}
|
||
|
||
func queryRows(ctx context.Context, q queryer, query string, args ...any) (*sql.Rows, error) {
|
||
switch typed := q.(type) {
|
||
case *sql.DB:
|
||
return typed.QueryContext(ctx, query, args...)
|
||
case *sql.Tx:
|
||
return typed.QueryContext(ctx, query, args...)
|
||
default:
|
||
return nil, fmt.Errorf("unsupported queryer %T", q)
|
||
}
|
||
}
|