328 lines
12 KiB
Go
328 lines
12 KiB
Go
// 本包承载 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((
|
||
SELECT pdi.pretty_id
|
||
FROM pretty_display_user_id_leases pdl
|
||
LEFT JOIN pretty_display_ids pdi
|
||
ON pdi.app_code = pdl.app_code
|
||
AND pdi.assigned_lease_id = pdl.lease_id
|
||
AND pdi.assigned_user_id = pdl.user_id
|
||
WHERE pdl.app_code = users.app_code
|
||
AND pdl.user_id = users.user_id
|
||
AND pdl.status = 'active'
|
||
ORDER BY pdl.starts_at_ms DESC
|
||
LIMIT 1
|
||
), ''),
|
||
COALESCE((
|
||
SELECT pdl.display_user_id
|
||
FROM pretty_display_user_id_leases pdl
|
||
WHERE pdl.app_code = users.app_code
|
||
AND pdl.user_id = users.user_id
|
||
AND pdl.status = 'active'
|
||
ORDER BY pdl.starts_at_ms DESC
|
||
LIMIT 1
|
||
), ''),
|
||
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(profile_bg_img, ''),
|
||
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.PrettyID,
|
||
&user.PrettyDisplayUserID,
|
||
&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.ProfileBgImg,
|
||
&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
|
||
}
|
||
// profile_bg_img 是非空列;新建账号没有背景图时必须写空字符串,不能复用 NullableString 写成 SQL NULL。
|
||
profileBgImg := strings.TrimSpace(user.ProfileBgImg)
|
||
|
||
_, 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,
|
||
profile_bg_img,
|
||
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), profileBgImg, 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
|
||
}
|