289 lines
9.4 KiB
Go
289 lines
9.4 KiB
Go
package user
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||
"hyapp/services/user-service/internal/storage/mysql/shared"
|
||
)
|
||
|
||
func (r *Repository) GetUser(ctx context.Context, userID int64) (userdomain.User, error) {
|
||
// 普通查询不加锁,靓号过期恢复由 service 层判断后调用 identity 事务。
|
||
user, err := 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)+1)
|
||
args = append(args, appcode.FromContext(ctx))
|
||
for _, userID := range userIDs {
|
||
placeholders = append(placeholders, "?")
|
||
args = append(args, userID)
|
||
}
|
||
|
||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||
SELECT %s
|
||
FROM users
|
||
WHERE app_code = ? AND 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()
|
||
}
|
||
|
||
// ListUserIDs returns active user IDs in ascending order for cursor-based backend fanout.
|
||
func (r *Repository) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error) {
|
||
conditions := []string{"app_code = ?", "status = ?", "user_id > ?"}
|
||
args := []any{appcode.FromContext(ctx), string(userdomain.StatusActive), filter.CursorUserID}
|
||
switch filter.TargetScope {
|
||
case userdomain.UserIDTargetRegion:
|
||
conditions = append(conditions, "region_id = ?")
|
||
args = append(args, filter.RegionID)
|
||
case userdomain.UserIDTargetCountry:
|
||
conditions = append(conditions, "country = ?")
|
||
args = append(args, filter.Country)
|
||
}
|
||
args = append(args, filter.PageSize)
|
||
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT user_id
|
||
FROM users
|
||
WHERE `+strings.Join(conditions, " AND ")+`
|
||
ORDER BY user_id ASC
|
||
LIMIT ?`, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
userIDs := make([]int64, 0, filter.PageSize)
|
||
for rows.Next() {
|
||
var userID int64
|
||
if err := rows.Scan(&userID); err != nil {
|
||
return nil, err
|
||
}
|
||
userIDs = append(userIDs, userID)
|
||
}
|
||
return userIDs, 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 shared.MapDisplayDuplicateError(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 := 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.Gender != nil {
|
||
user.Gender = *command.Gender
|
||
}
|
||
if command.BirthDate != nil {
|
||
user.BirthDate = *command.BirthDate
|
||
}
|
||
user.UpdatedAtMs = command.UpdatedAtMs
|
||
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE users
|
||
SET username = ?, avatar = ?, gender = ?, birth_date = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND user_id = ?
|
||
`, shared.NullableString(user.Username), shared.NullableString(user.Avatar), shared.NullableString(user.Gender), shared.NullableString(user.BirthDate), user.UpdatedAtMs, appcode.Normalize(command.AppCode), 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 := 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 = ?,
|
||
gender = ?,
|
||
country = ?,
|
||
region_id = ?,
|
||
profile_completed = ?,
|
||
profile_completed_at_ms = ?,
|
||
onboarding_status = ?,
|
||
updated_at_ms = ?
|
||
WHERE user_id = ?
|
||
AND app_code = ?
|
||
`, command.Username, command.Avatar, command.Gender, command.Country, shared.NullableRegionID(command.RegionID), true, command.CompletedAtMs, string(userdomain.OnboardingStatusCompleted), command.CompletedAtMs, command.UserID, appcode.Normalize(command.AppCode))
|
||
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 := 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 app_code = ? AND user_id = ?
|
||
`, shared.NullableRegionID(user.RegionID), user.UpdatedAtMs, appcode.Normalize(command.AppCode), 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 app_code = ? AND user_id = ?
|
||
`, shared.NullableString(user.Country), shared.NullableRegionID(user.RegionID), user.UpdatedAtMs, appcode.Normalize(command.AppCode), command.UserID)
|
||
if err != nil {
|
||
return userdomain.User{}, 0, err
|
||
}
|
||
_, err = tx.ExecContext(ctx, `
|
||
INSERT INTO user_country_change_logs (app_code, user_id, old_country, new_country, request_id, changed_at_ms)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
`, appcode.Normalize(command.AppCode), command.UserID, shared.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 快照字段;既服务普通查询,也服务短号事务中的 FOR UPDATE 读。
|