2026-04-29 12:43:05 +08:00

378 lines
14 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.

// 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 LEFT JOIN country_aliases ca ON ca.country_code = c.country_code AND ca.alias_code = users.country WHERE c.country_code = users.country OR ca.alias_code = users.country ORDER BY CASE WHEN ca.alias_code = users.country THEN 0 ELSE 1 END LIMIT 1), 0),
COALESCE((SELECT c.country_name FROM countries c LEFT JOIN country_aliases ca ON ca.country_code = c.country_code AND ca.alias_code = users.country WHERE c.country_code = users.country OR ca.alias_code = users.country ORDER BY CASE WHEN ca.alias_code = users.country THEN 0 ELSE 1 END LIMIT 1), ''),
COALESCE((SELECT c.country_display_name FROM countries c LEFT JOIN country_aliases ca ON ca.country_code = c.country_code AND ca.alias_code = users.country WHERE c.country_code = users.country OR ca.alias_code = users.country ORDER BY CASE WHEN ca.alias_code = users.country THEN 0 ELSE 1 END LIMIT 1), ''),
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, ''),
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.NormalizeDisplayUserIDState(), nil
}
// 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
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.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,
&status,
&user.CreatedAtMs,
&user.UpdatedAtMs,
)
if err != nil {
return userdomain.User{}, err
}
user.Status = userdomain.Status(status)
user.CurrentDisplayUserIDKind = userdomain.DisplayUserIDKind(kind)
// Normalize 兼容迁移窗口中默认短号或 kind 为空的历史数据。
return user.NormalizeDisplayUserIDState(), 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。
user = user.NormalizeDisplayUserIDState()
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,
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), 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 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
}