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

265 lines
9.4 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.

// 本包承载 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, ''),
COALESCE(region_id, 0),
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),
COALESCE((SELECT rg.region_code FROM regions rg WHERE rg.app_code = users.app_code AND rg.region_id = users.region_id LIMIT 1), ''),
COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = users.app_code AND 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`
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
}