// 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 }