// 本包承载 user-service 用户主数据的 MySQL 持久化。 package user import ( "context" "database/sql" "strings" "hyapp/pkg/appcode" userdomain "hyapp/services/user-service/internal/domain/user" "hyapp/services/user-service/internal/storage/mysql/shared" ) // Repository 是用户主数据在 MySQL 上的存储实现。 type Repository struct { // db 由父级 MySQL repository 持有,本包只复用连接池。 db *sql.DB } // New 基于共享 MySQL 连接池创建用户主数据存储对象。 func New(db *sql.DB) *Repository { return &Repository{db: db} } const userSelectColumns = ` app_code, 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, ''), CASE WHEN COALESCE(region_id, 0) = 0 THEN 0 WHEN EXISTS ( SELECT 1 FROM regions rg WHERE rg.app_code = users.app_code AND rg.region_id = users.region_id AND rg.status = 'active' AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED') LIMIT 1 ) THEN users.region_id ELSE 0 END, COALESCE((SELECT c.country_id FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), 0), COALESCE((SELECT c.country_name FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), ''), COALESCE((SELECT c.country_display_name FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), ''), COALESCE((SELECT c.iso_numeric FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), ''), COALESCE((SELECT c.phone_country_code FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), ''), COALESCE((SELECT c.enabled FROM countries c WHERE c.app_code = users.app_code AND c.country_code = users.country LIMIT 1), 0), CASE WHEN COALESCE(users.region_id, 0) = 0 OR NOT EXISTS ( SELECT 1 FROM regions rg WHERE rg.app_code = users.app_code AND rg.region_id = users.region_id AND rg.status = 'active' AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED') LIMIT 1 ) THEN 'GLOBAL' ELSE COALESCE((SELECT rg.region_code FROM regions rg WHERE rg.app_code = users.app_code AND rg.region_id = users.region_id LIMIT 1), '') END, CASE WHEN COALESCE(users.region_id, 0) = 0 OR NOT EXISTS ( SELECT 1 FROM regions rg WHERE rg.app_code = users.app_code AND rg.region_id = users.region_id AND rg.status = 'active' AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED') LIMIT 1 ) THEN 'GLOBAL' ELSE COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = users.app_code AND rg.region_id = users.region_id LIMIT 1), '') END, 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` func QueryUser(ctx context.Context, q shared.Queryer, clause string, args ...any) (userdomain.User, error) { // clause 由本包内部固定拼接,不能接受外部原始 SQL。 clause, args = withUserAppClause(ctx, clause, args...) 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.AppCode, &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 是跨认证/用户创建用户用例共享的事务片段。 // 它只在调用方已经开启事务后执行,避免业务层手动拼跨表事务。 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 ( app_code, 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, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) `, appcode.Normalize(user.AppCode), user.UserID, user.DefaultDisplayUserID, user.CurrentDisplayUserID, string(user.CurrentDisplayUserIDKind), shared.NullableString(user.Username), shared.NullableString(user.Gender), shared.NullableString(user.Country), shared.NullableRegionID(user.RegionID), shared.NullableString(user.InviteCode), shared.NullableString(user.RegisterIP), shared.NullableString(user.RegisterUserAgent), shared.NullableString(user.CountryByIP), shared.NullableString(user.RegisterDeviceID), shared.NullableString(user.RegisterDevice), shared.NullableString(user.RegisterOSVersion), shared.NullableString(user.Avatar), shared.NullableString(user.BirthDate), shared.NullableString(user.RegisterAppVersion), shared.NullableString(user.RegisterBuildNumber), shared.NullableString(user.RegisterSource), shared.NullableString(user.RegisterInstallChannel), shared.NullableString(user.RegisterCampaign), shared.NullableString(user.RegisterPlatform), shared.NullableString(user.Language), shared.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 (app_code, display_user_id, user_id, display_user_id_kind, status, assigned_at_ms, activated_at_ms, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, appcode.Normalize(user.AppCode), 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 app_code = ? AND user_id = ? ORDER BY changed_at_ms DESC LIMIT 1 `, appcode.FromContext(ctx), userID).Scan(&changedAtMs) if err == sql.ErrNoRows { return 0, nil } if err != nil { return 0, err } return changedAtMs, nil } func withUserAppClause(ctx context.Context, clause string, args ...any) (string, []any) { appCode := appcode.FromContext(ctx) trimmed := strings.TrimSpace(clause) if strings.HasPrefix(strings.ToUpper(trimmed), "WHERE ") { withoutWhere := strings.TrimSpace(trimmed[len("WHERE "):]) return "WHERE users.app_code = ? AND " + withoutWhere, append([]any{appCode}, args...) } if trimmed == "" { return "WHERE users.app_code = ?", []any{appCode} } return trimmed, args }