458 lines
16 KiB
Go
458 lines
16 KiB
Go
// Package mysql 实现 user-service 的 MySQL 持久化边界。
|
||
package mysql
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"hyapp/pkg/xerr"
|
||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||
)
|
||
|
||
const userSelectColumns = `
|
||
user_id,
|
||
default_display_user_id,
|
||
current_display_user_id,
|
||
current_display_user_id_kind,
|
||
COALESCE(current_display_user_id_expires_at_ms, 0),
|
||
COALESCE(username, ''),
|
||
COALESCE(gender, ''),
|
||
COALESCE(country, ''),
|
||
COALESCE(region_id, 0),
|
||
COALESCE((SELECT c.country_id FROM countries c WHERE c.country_code = users.country LIMIT 1), 0),
|
||
COALESCE((SELECT c.country_name FROM countries c WHERE c.country_code = users.country LIMIT 1), ''),
|
||
COALESCE((SELECT c.country_display_name FROM countries c WHERE c.country_code = users.country LIMIT 1), ''),
|
||
COALESCE((SELECT c.iso_numeric FROM countries c WHERE c.country_code = users.country LIMIT 1), ''),
|
||
COALESCE((SELECT c.phone_country_code FROM countries c WHERE c.country_code = users.country LIMIT 1), ''),
|
||
COALESCE((SELECT c.enabled FROM countries c WHERE c.country_code = users.country LIMIT 1), 0),
|
||
COALESCE((SELECT rg.region_code FROM regions rg WHERE rg.region_id = users.region_id LIMIT 1), ''),
|
||
COALESCE((SELECT rg.name FROM regions rg WHERE rg.region_id = users.region_id LIMIT 1), ''),
|
||
COALESCE(invite_code, ''),
|
||
COALESCE(register_ip, ''),
|
||
COALESCE(register_user_agent, ''),
|
||
COALESCE(country_by_ip, ''),
|
||
COALESCE(register_device_id, ''),
|
||
COALESCE(register_device, ''),
|
||
COALESCE(os_version, ''),
|
||
COALESCE(avatar, ''),
|
||
COALESCE(DATE_FORMAT(birth_date, '%Y-%m-%d'), ''),
|
||
COALESCE(app_version, ''),
|
||
COALESCE(build_number, ''),
|
||
COALESCE(source, ''),
|
||
COALESCE(install_channel, ''),
|
||
COALESCE(campaign, ''),
|
||
COALESCE(platform, ''),
|
||
COALESCE(language, ''),
|
||
COALESCE(timezone, ''),
|
||
COALESCE(profile_completed, 0),
|
||
COALESCE(profile_completed_at_ms, 0),
|
||
COALESCE(onboarding_status, 'profile_required'),
|
||
status,
|
||
created_at_ms,
|
||
updated_at_ms`
|
||
|
||
// GetUser 从 users 表读取用户主状态;调用方负责按业务时钟触发懒过期。
|
||
func (r *Repository) GetUser(ctx context.Context, userID int64) (userdomain.User, error) {
|
||
// 普通查询不加锁,靓号过期恢复由 service 层判断后调用 identity 事务。
|
||
user, err := r.queryUser(ctx, r.db, "WHERE user_id = ?", userID)
|
||
if err == sql.ErrNoRows {
|
||
// users 表无记录时返回稳定 NotFound。
|
||
return userdomain.User{}, xerr.New(xerr.NotFound, "user not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
|
||
return user, nil
|
||
}
|
||
|
||
// BatchGetUsers 批量读取用户主状态,缺失用户不会出现在返回 map 中。
|
||
func (r *Repository) BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error) {
|
||
// 调用方已校验 userIDs 非空且合法,这里直接构造 IN 查询。
|
||
placeholders := make([]string, 0, len(userIDs))
|
||
args := make([]any, 0, len(userIDs))
|
||
for _, userID := range userIDs {
|
||
placeholders = append(placeholders, "?")
|
||
args = append(args, userID)
|
||
}
|
||
|
||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||
SELECT %s
|
||
FROM users
|
||
WHERE user_id IN (%s)
|
||
`, userSelectColumns, strings.Join(placeholders, ",")), args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
result := make(map[int64]userdomain.User, len(userIDs))
|
||
for rows.Next() {
|
||
// 缺失用户不会返回,调用方按 map key 判断存在性。
|
||
user, err := scanUser(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result[user.UserID] = user
|
||
}
|
||
|
||
return result, rows.Err()
|
||
}
|
||
|
||
// CreateUserWithIdentity 是用户创建的事务入口。
|
||
// 它同时写 users 和默认短号 active 记录,避免出现没有默认门牌号的用户。
|
||
func (r *Repository) CreateUserWithIdentity(ctx context.Context, user userdomain.User, identity userdomain.Identity) error {
|
||
// 用户和默认短号必须在一个事务里创建,避免出现孤立用户。
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
if err := insertUserIdentity(ctx, tx, user, identity); err != nil {
|
||
// 唯一键冲突转换成 display_user_id exists,供 service 有限重试。
|
||
return mapDuplicateError(err)
|
||
}
|
||
|
||
return tx.Commit()
|
||
}
|
||
|
||
// UpdateUserProfile 修改用户基础资料并返回更新后的 users 快照。
|
||
func (r *Repository) UpdateUserProfile(ctx context.Context, command userdomain.ProfileUpdateCommand) (userdomain.User, error) {
|
||
// 资料更新锁定 users 行,避免和国家修改、短号懒过期覆盖 updated_at_ms。
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
user, err := r.queryUser(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.UserID)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.User{}, xerr.New(xerr.NotFound, "user not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
if command.Username != nil {
|
||
user.Username = *command.Username
|
||
}
|
||
if command.Avatar != nil {
|
||
user.Avatar = *command.Avatar
|
||
}
|
||
if command.BirthDate != nil {
|
||
user.BirthDate = *command.BirthDate
|
||
}
|
||
user.UpdatedAtMs = command.UpdatedAtMs
|
||
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE users
|
||
SET username = ?, avatar = ?, birth_date = ?, updated_at_ms = ?
|
||
WHERE user_id = ?
|
||
`, nullableString(user.Username), nullableString(user.Avatar), nullableString(user.BirthDate), user.UpdatedAtMs, command.UserID)
|
||
if err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
|
||
return user, nil
|
||
}
|
||
|
||
// CompleteOnboarding 是注册页资料固化的事务入口。
|
||
// 该方法不写 user_country_change_logs,确保首次国家选择不消耗改国家冷却窗口。
|
||
func (r *Repository) CompleteOnboarding(ctx context.Context, command userdomain.CompleteOnboardingCommand) (userdomain.User, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
user, err := r.queryUser(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.UserID)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.User{}, xerr.New(xerr.NotFound, "user not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
if user.ProfileCompleted {
|
||
// 已完成用户不能用注册页接口重写资料;提交入口保持幂等只返回当前事实。
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
return r.GetUser(ctx, command.UserID)
|
||
}
|
||
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE users
|
||
SET username = ?,
|
||
avatar = ?,
|
||
country = ?,
|
||
region_id = ?,
|
||
profile_completed = ?,
|
||
profile_completed_at_ms = ?,
|
||
onboarding_status = ?,
|
||
updated_at_ms = ?
|
||
WHERE user_id = ?
|
||
`, command.Username, command.Avatar, command.Country, nullableRegionID(command.RegionID), true, command.CompletedAtMs, string(userdomain.OnboardingStatusCompleted), command.CompletedAtMs, command.UserID)
|
||
if err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
|
||
return r.GetUser(ctx, command.UserID)
|
||
}
|
||
|
||
// ChangeUserCountry 修改国家并写日志;冷却期检查和日志写入必须在同一事务内完成。
|
||
func (r *Repository) ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userdomain.User{}, 0, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
user, err := r.queryUser(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.UserID)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.User{}, 0, xerr.New(xerr.NotFound, "user not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.User{}, 0, err
|
||
}
|
||
lastChangedAt, err := latestCountryChangedAt(ctx, tx, command.UserID)
|
||
if err != nil {
|
||
return userdomain.User{}, 0, err
|
||
}
|
||
nextAllowedAt := int64(0)
|
||
if lastChangedAt > 0 && command.CooldownMs > 0 {
|
||
nextAllowedAt = lastChangedAt + command.CooldownMs
|
||
}
|
||
if user.Country == command.NewCountry {
|
||
// 修改为相同国家不写日志,也不消耗冷却期;映射变化时允许修正 stale region_id。
|
||
if user.RegionID != command.NewRegionID {
|
||
user.RegionID = command.NewRegionID
|
||
user.UpdatedAtMs = command.ChangedAtMs
|
||
if _, err := tx.ExecContext(ctx, `
|
||
UPDATE users
|
||
SET region_id = ?, updated_at_ms = ?
|
||
WHERE user_id = ?
|
||
`, nullableRegionID(user.RegionID), user.UpdatedAtMs, command.UserID); err != nil {
|
||
return userdomain.User{}, 0, err
|
||
}
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.User{}, 0, err
|
||
}
|
||
updated, err := r.GetUser(ctx, command.UserID)
|
||
return updated, nextAllowedAt, err
|
||
}
|
||
if nextAllowedAt > 0 && command.ChangedAtMs < nextAllowedAt {
|
||
return userdomain.User{}, nextAllowedAt, xerr.New(xerr.CountryChangeCooldown, "country change is cooling down")
|
||
}
|
||
|
||
oldCountry := user.Country
|
||
user.Country = command.NewCountry
|
||
user.RegionID = command.NewRegionID
|
||
user.UpdatedAtMs = command.ChangedAtMs
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE users
|
||
SET country = ?, region_id = ?, updated_at_ms = ?
|
||
WHERE user_id = ?
|
||
`, nullableString(user.Country), nullableRegionID(user.RegionID), user.UpdatedAtMs, command.UserID)
|
||
if err != nil {
|
||
return userdomain.User{}, 0, err
|
||
}
|
||
_, err = tx.ExecContext(ctx, `
|
||
INSERT INTO user_country_change_logs (user_id, old_country, new_country, request_id, changed_at_ms)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
`, command.UserID, nullableString(oldCountry), command.NewCountry, command.RequestID, command.ChangedAtMs)
|
||
if err != nil {
|
||
return userdomain.User{}, 0, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.User{}, 0, err
|
||
}
|
||
|
||
updated, err := r.GetUser(ctx, command.UserID)
|
||
return updated, command.ChangedAtMs + command.CooldownMs, err
|
||
}
|
||
|
||
// queryUser 读取 users 快照字段;既服务普通查询,也服务 identity 事务中的 FOR UPDATE 读。
|
||
func (r *Repository) queryUser(ctx context.Context, q queryer, clause string, args ...any) (userdomain.User, error) {
|
||
// clause 由本包内部固定拼接,不能接受外部原始 SQL。
|
||
row := q.QueryRowContext(ctx, `
|
||
SELECT `+userSelectColumns+`
|
||
FROM users
|
||
`+clause, args...)
|
||
|
||
return scanUser(row)
|
||
}
|
||
|
||
// scanUser 固定 users 投影到领域模型的转换。
|
||
func scanUser(scanner interface {
|
||
Scan(dest ...any) error
|
||
}) (userdomain.User, error) {
|
||
// scanUser 是 users 表到领域模型的唯一转换点。
|
||
var user userdomain.User
|
||
var status string
|
||
var kind string
|
||
var onboardingStatus string
|
||
err := scanner.Scan(
|
||
&user.UserID,
|
||
&user.DefaultDisplayUserID,
|
||
&user.CurrentDisplayUserID,
|
||
&kind,
|
||
&user.CurrentDisplayUserIDExpiresAtMs,
|
||
&user.Username,
|
||
&user.Gender,
|
||
&user.Country,
|
||
&user.RegionID,
|
||
&user.CountryID,
|
||
&user.CountryName,
|
||
&user.CountryDisplayName,
|
||
&user.ISONumeric,
|
||
&user.PhoneCountryCode,
|
||
&user.CountryEnabled,
|
||
&user.RegionCode,
|
||
&user.RegionName,
|
||
&user.InviteCode,
|
||
&user.RegisterIP,
|
||
&user.RegisterUserAgent,
|
||
&user.CountryByIP,
|
||
&user.RegisterDeviceID,
|
||
&user.RegisterDevice,
|
||
&user.RegisterOSVersion,
|
||
&user.Avatar,
|
||
&user.BirthDate,
|
||
&user.RegisterAppVersion,
|
||
&user.RegisterBuildNumber,
|
||
&user.RegisterSource,
|
||
&user.RegisterInstallChannel,
|
||
&user.RegisterCampaign,
|
||
&user.RegisterPlatform,
|
||
&user.Language,
|
||
&user.Timezone,
|
||
&user.ProfileCompleted,
|
||
&user.ProfileCompletedAtMs,
|
||
&onboardingStatus,
|
||
&status,
|
||
&user.CreatedAtMs,
|
||
&user.UpdatedAtMs,
|
||
)
|
||
if err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
|
||
user.Status = userdomain.Status(status)
|
||
user.CurrentDisplayUserIDKind = userdomain.DisplayUserIDKind(kind)
|
||
user.OnboardingStatus = userdomain.OnboardingStatus(onboardingStatus)
|
||
if user.OnboardingStatus == "" {
|
||
if user.ProfileCompleted {
|
||
user.OnboardingStatus = userdomain.OnboardingStatusCompleted
|
||
} else {
|
||
user.OnboardingStatus = userdomain.OnboardingStatusProfileRequired
|
||
}
|
||
}
|
||
return user, nil
|
||
}
|
||
|
||
// insertUserIdentity 是跨 auth/user 创建用户用例共享的事务片段。
|
||
// 它只在调用方已经开启事务后执行,避免 service 层手动拼跨表事务。
|
||
func insertUserIdentity(ctx context.Context, tx *sql.Tx, user userdomain.User, identity userdomain.Identity) error {
|
||
// 调用方已开启事务,本函数只负责插入 users 和默认 active display_user_id。
|
||
if user.DefaultDisplayUserID == "" {
|
||
// 创建路径允许 user 中未预填默认短号,以同事务创建的 identity 为准。
|
||
user.DefaultDisplayUserID = identity.DisplayUserID
|
||
}
|
||
if user.CurrentDisplayUserID == "" {
|
||
user.CurrentDisplayUserID = identity.DisplayUserID
|
||
}
|
||
if user.CurrentDisplayUserIDKind == "" {
|
||
// 新用户默认短号永远是 default kind。
|
||
user.CurrentDisplayUserIDKind = userdomain.DisplayUserIDKindDefault
|
||
}
|
||
|
||
_, err := tx.ExecContext(ctx, `
|
||
INSERT INTO users (
|
||
user_id,
|
||
default_display_user_id,
|
||
current_display_user_id,
|
||
current_display_user_id_kind,
|
||
current_display_user_id_expires_at_ms,
|
||
username,
|
||
gender,
|
||
country,
|
||
region_id,
|
||
invite_code,
|
||
register_ip,
|
||
register_user_agent,
|
||
country_by_ip,
|
||
register_device_id,
|
||
register_device,
|
||
os_version,
|
||
avatar,
|
||
birth_date,
|
||
app_version,
|
||
build_number,
|
||
source,
|
||
install_channel,
|
||
campaign,
|
||
platform,
|
||
language,
|
||
timezone,
|
||
profile_completed,
|
||
profile_completed_at_ms,
|
||
onboarding_status,
|
||
status,
|
||
created_at_ms,
|
||
updated_at_ms
|
||
)
|
||
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, user.UserID, user.DefaultDisplayUserID, user.CurrentDisplayUserID, string(user.CurrentDisplayUserIDKind), nullableString(user.Username), nullableString(user.Gender), nullableString(user.Country), nullableRegionID(user.RegionID), nullableString(user.InviteCode), nullableString(user.RegisterIP), nullableString(user.RegisterUserAgent), nullableString(user.CountryByIP), nullableString(user.RegisterDeviceID), nullableString(user.RegisterDevice), nullableString(user.RegisterOSVersion), nullableString(user.Avatar), nullableString(user.BirthDate), nullableString(user.RegisterAppVersion), nullableString(user.RegisterBuildNumber), nullableString(user.RegisterSource), nullableString(user.RegisterInstallChannel), nullableString(user.RegisterCampaign), nullableString(user.RegisterPlatform), nullableString(user.Language), nullableString(user.Timezone), user.ProfileCompleted, user.ProfileCompletedAtMs, onboardingStatusForInsert(user), string(user.Status), user.CreatedAtMs, user.UpdatedAtMs)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
_, err = tx.ExecContext(ctx, `
|
||
INSERT INTO user_display_user_ids (display_user_id, user_id, display_user_id_kind, status, assigned_at_ms, activated_at_ms, created_at_ms, updated_at_ms)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, identity.DisplayUserID, user.UserID, string(userdomain.DisplayUserIDKindDefault), string(userdomain.DisplayUserIDStatusActive), user.CreatedAtMs, user.CreatedAtMs, user.CreatedAtMs, user.UpdatedAtMs)
|
||
// user_display_user_ids 唯一约束保证默认短号不和 active/held/reserved/blocked 记录冲突。
|
||
return err
|
||
}
|
||
|
||
func onboardingStatusForInsert(user userdomain.User) string {
|
||
// 创建路径默认保持未完成状态;只有 CompleteOnboarding 能把状态固化为 completed。
|
||
if user.OnboardingStatus != "" {
|
||
return string(user.OnboardingStatus)
|
||
}
|
||
if user.ProfileCompleted {
|
||
return string(userdomain.OnboardingStatusCompleted)
|
||
}
|
||
|
||
return string(userdomain.OnboardingStatusProfileRequired)
|
||
}
|
||
|
||
func latestCountryChangedAt(ctx context.Context, tx *sql.Tx, userID int64) (int64, error) {
|
||
// users 行已 FOR UPDATE;按同一用户串行化后读取最新日志即可防止并发绕过冷却。
|
||
var changedAtMs int64
|
||
err := tx.QueryRowContext(ctx, `
|
||
SELECT changed_at_ms
|
||
FROM user_country_change_logs
|
||
WHERE user_id = ?
|
||
ORDER BY changed_at_ms DESC
|
||
LIMIT 1
|
||
`, userID).Scan(&changedAtMs)
|
||
if err == sql.ErrNoRows {
|
||
return 0, nil
|
||
}
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
return changedAtMs, nil
|
||
}
|