585 lines
21 KiB
Go
585 lines
21 KiB
Go
package user
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||
invitestorage "hyapp/services/user-service/internal/storage/mysql/invite"
|
||
"hyapp/services/user-service/internal/storage/mysql/shared"
|
||
)
|
||
|
||
const countryChangeSessionRevokeReason = "USER_COUNTRY_CHANGED"
|
||
|
||
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
|
||
}
|
||
if err := r.hydrateInviteOverview(ctx, &user); err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
|
||
return user, nil
|
||
}
|
||
|
||
// GetMyProfileStats 只读取预聚合统计表,不能实时扫描访客、关注或好友明细。
|
||
func (r *Repository) GetMyProfileStats(ctx context.Context, userID int64) (userdomain.ProfileStats, error) {
|
||
if r == nil || r.db == nil {
|
||
return userdomain.ProfileStats{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||
}
|
||
appCode := appcode.FromContext(ctx)
|
||
stats := userdomain.ProfileStats{AppCode: appCode, UserID: userID}
|
||
err := r.db.QueryRowContext(ctx, `
|
||
SELECT visitors_count, following_count, friends_count, updated_at_ms
|
||
FROM user_profile_stats
|
||
WHERE app_code = ? AND user_id = ?`,
|
||
appCode, userID,
|
||
).Scan(&stats.VisitorsCount, &stats.FollowingCount, &stats.FriendsCount, &stats.UpdatedAtMs)
|
||
if err == sql.ErrNoRows {
|
||
return stats, nil
|
||
}
|
||
if err != nil {
|
||
return userdomain.ProfileStats{}, err
|
||
}
|
||
return stats, 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)
|
||
}
|
||
if _, err := invitestorage.EnsurePrimaryCodeForUser(ctx, tx, user.AppCode, user.UserID, user.CreatedAtMs); err != nil {
|
||
return err
|
||
}
|
||
if err := InsertUserRegisteredOutbox(ctx, tx, user); err != nil {
|
||
return err
|
||
}
|
||
|
||
return tx.Commit()
|
||
}
|
||
|
||
// InsertUserRegisteredOutbox 写入 App 自然注册完成事实。
|
||
// 调用方必须放在用户资料完成事务内执行;三方首次登录只创建 profile_required 占位账号,不能提前进入新增用户统计。
|
||
func InsertUserRegisteredOutbox(ctx context.Context, tx *sql.Tx, user userdomain.User) error {
|
||
if userdomain.ExcludedFromRegistrationStats(user.RegisterSource) {
|
||
// 全站机器人和后台快捷账号只复用用户资料、头像和短号参与运营或联调,不是真实 App 自然注册;
|
||
// 这里不写 UserRegistered 事实,避免实时统计、留存 cohort 和注册奖励把后台造号当新增用户。
|
||
return nil
|
||
}
|
||
if !user.ProfileCompleted {
|
||
// 用户填完 username/avatar/gender/country 并固化 region_id 前,不能算注册成功;
|
||
// 否则统计服务会把三方占位账号按空国家落进“未知国家”,且后续补资料无法修正 cohort。
|
||
return nil
|
||
}
|
||
countryID, err := userRegisteredCountryID(ctx, tx, user)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
registeredAtMS := user.ProfileCompletedAtMs
|
||
if registeredAtMS <= 0 {
|
||
// 兼容少量历史/测试路径直接创建 completed 用户但未写完成时间;事件时间至少保持有效,避免 MQ 编码失败。
|
||
registeredAtMS = user.CreatedAtMs
|
||
}
|
||
payload := map[string]any{
|
||
"user_id": user.UserID,
|
||
"default_display_user_id": user.DefaultDisplayUserID,
|
||
"country": user.Country,
|
||
"country_id": countryID,
|
||
"country_by_ip": user.CountryByIP,
|
||
"region_id": user.RegionID,
|
||
"invite_code": user.InviteCode,
|
||
"register_device_id": user.RegisterDeviceID,
|
||
"register_platform": user.RegisterPlatform,
|
||
"register_install_channel": user.RegisterInstallChannel,
|
||
"register_campaign": user.RegisterCampaign,
|
||
"created_at_ms": user.CreatedAtMs,
|
||
"profile_completed_at_ms": registeredAtMS,
|
||
}
|
||
payloadJSON, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = tx.ExecContext(ctx, `
|
||
INSERT IGNORE INTO user_outbox (app_code, event_id, event_type, aggregate_type, aggregate_id, status, payload_json, created_at_ms, updated_at_ms)
|
||
VALUES (?, ?, 'UserRegistered', 'user', ?, 'pending', ?, ?, ?)
|
||
`, appcode.Normalize(user.AppCode), fmt.Sprintf("UserRegistered:%d", user.UserID), user.UserID, string(payloadJSON), registeredAtMS, registeredAtMS)
|
||
return err
|
||
}
|
||
|
||
func userRegisteredCountryID(ctx context.Context, tx *sql.Tx, user userdomain.User) (int64, error) {
|
||
if user.CountryID > 0 {
|
||
// 查询路径已经带出国家主数据时直接复用,避免多一次 SQL。
|
||
return user.CountryID, nil
|
||
}
|
||
country := strings.ToUpper(strings.TrimSpace(user.Country))
|
||
if country == "" {
|
||
// 正常 onboarding 完成路径必须有国家;这里只给历史或测试 completed 用户保留全局兜底,避免事件写入失败。
|
||
return 0, nil
|
||
}
|
||
var countryID int64
|
||
err := tx.QueryRowContext(ctx, `
|
||
SELECT country_id
|
||
FROM countries
|
||
WHERE app_code = ? AND country_code = ?
|
||
LIMIT 1
|
||
`, appcode.Normalize(user.AppCode), country).Scan(&countryID)
|
||
if err == sql.ErrNoRows {
|
||
// 国家码格式合法但历史主数据缺失时不阻断注册,统计进入全局国家维度,和查询投影的兜底语义一致。
|
||
return 0, nil
|
||
}
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return countryID, nil
|
||
}
|
||
|
||
func insertUserRegionChangedOutbox(ctx context.Context, tx *sql.Tx, command userdomain.CountryChangeCommand, oldCountry string, oldRegionID int64) error {
|
||
source := strings.TrimSpace(command.Source)
|
||
if source == "" {
|
||
source = "unknown"
|
||
}
|
||
payload := map[string]any{
|
||
"user_id": command.UserID,
|
||
"old_country": oldCountry,
|
||
"new_country": command.NewCountry,
|
||
"old_region_id": oldRegionID,
|
||
"new_region_id": command.NewRegionID,
|
||
"source": source,
|
||
"request_id": strings.TrimSpace(command.RequestID),
|
||
"changed_at_ms": command.ChangedAtMs,
|
||
}
|
||
payloadJSON, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
eventID := fmt.Sprintf("UserRegionChanged:%s:%d:%d:%d:%d", source, command.UserID, oldRegionID, command.NewRegionID, command.ChangedAtMs)
|
||
_, err = tx.ExecContext(ctx, `
|
||
INSERT IGNORE INTO user_outbox (app_code, event_id, event_type, aggregate_type, aggregate_id, status, payload_json, created_at_ms, updated_at_ms)
|
||
VALUES (?, ?, 'UserRegionChanged', 'user', ?, 'pending', ?, ?, ?)
|
||
`, appcode.Normalize(command.AppCode), eventID, command.UserID, string(payloadJSON), command.ChangedAtMs, command.ChangedAtMs)
|
||
return err
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// UpdateUserProfileBackground 只修改个人信息页背景图,避免通用资料 PATCH 误承担上传/背景语义。
|
||
func (r *Repository) UpdateUserProfileBackground(ctx context.Context, command userdomain.ProfileBackgroundUpdateCommand) (userdomain.User, error) {
|
||
// 背景图更新锁定 users 行,确保返回给 gateway 的用户资料就是本次写入后的快照。
|
||
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
|
||
}
|
||
|
||
user.ProfileBgImg = command.ProfileBgImg
|
||
user.UpdatedAtMs = command.UpdatedAtMs
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE users
|
||
SET profile_bg_img = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND user_id = ?
|
||
`, shared.NullableString(user.ProfileBgImg), 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)
|
||
}
|
||
var inviteBindingErr error
|
||
inviteSnapshot := command.InviteCode
|
||
if command.InviteCode != "" {
|
||
user.InviteBinding, inviteBindingErr = invitestorage.BindRelation(ctx, tx, invitestorageBindCommand(command))
|
||
if inviteBindingErr != nil {
|
||
return userdomain.User{}, inviteBindingErr
|
||
}
|
||
if user.InviteBinding.InviteCode != "" {
|
||
inviteSnapshot = user.InviteBinding.InviteCode
|
||
}
|
||
}
|
||
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE users
|
||
SET username = ?,
|
||
avatar = ?,
|
||
gender = ?,
|
||
country = ?,
|
||
region_id = ?,
|
||
invite_code = CASE WHEN ? = '' THEN invite_code ELSE ? END,
|
||
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), inviteSnapshot, inviteSnapshot, true, command.CompletedAtMs, string(userdomain.OnboardingStatusCompleted), command.CompletedAtMs, command.UserID, appcode.Normalize(command.AppCode))
|
||
if err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
user.Username = command.Username
|
||
user.Avatar = command.Avatar
|
||
user.Gender = command.Gender
|
||
user.Country = command.Country
|
||
user.RegionID = command.RegionID
|
||
if inviteSnapshot != "" {
|
||
user.InviteCode = inviteSnapshot
|
||
}
|
||
user.ProfileCompleted = true
|
||
user.ProfileCompletedAtMs = command.CompletedAtMs
|
||
user.OnboardingStatus = userdomain.OnboardingStatusCompleted
|
||
user.UpdatedAtMs = command.CompletedAtMs
|
||
if err := InsertUserRegisteredOutbox(ctx, tx, user); err != nil {
|
||
// UserRegistered 是新增用户、留存 cohort 和注册奖励的事实;必须和首次资料完成同事务提交。
|
||
return userdomain.User{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
|
||
updated, err := r.GetUser(ctx, command.UserID)
|
||
updated.InviteBinding = user.InviteBinding
|
||
return updated, err
|
||
}
|
||
|
||
// ChangeUserCountry 修改国家并写日志;冷却期、outbox 和登录 session 撤销必须在同一事务内完成。
|
||
|
||
func (r *Repository) ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userdomain.User{}, 0, nil, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
user, err := QueryUser(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.UserID)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.User{}, 0, nil, xerr.New(xerr.NotFound, "user not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.User{}, 0, nil, err
|
||
}
|
||
lastChangedAt, err := latestCountryChangedAt(ctx, tx, command.UserID)
|
||
if err != nil {
|
||
return userdomain.User{}, 0, nil, err
|
||
}
|
||
nextAllowedAt := int64(0)
|
||
if lastChangedAt > 0 && command.CooldownMs > 0 {
|
||
nextAllowedAt = lastChangedAt + command.CooldownMs
|
||
}
|
||
revokedSessionIDs := make([]string, 0)
|
||
if user.Country == command.NewCountry {
|
||
// 修改为相同国家不写日志,也不消耗冷却期;映射变化时允许修正 stale region_id。
|
||
oldRegionID := user.RegionID
|
||
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, nil, err
|
||
}
|
||
if err := insertUserRegionChangedOutbox(ctx, tx, command, user.Country, oldRegionID); err != nil {
|
||
return userdomain.User{}, 0, nil, err
|
||
}
|
||
revokedSessionIDs, err = revokeActiveUserSessionsForCountryChange(ctx, tx, command, appcode.Normalize(command.AppCode))
|
||
if err != nil {
|
||
return userdomain.User{}, 0, nil, err
|
||
}
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.User{}, 0, nil, err
|
||
}
|
||
updated, err := r.GetUser(ctx, command.UserID)
|
||
return updated, nextAllowedAt, revokedSessionIDs, err
|
||
}
|
||
if nextAllowedAt > 0 && command.ChangedAtMs < nextAllowedAt {
|
||
return userdomain.User{}, nextAllowedAt, nil, xerr.New(xerr.CountryChangeCooldown, "country change is cooling down")
|
||
}
|
||
|
||
oldCountry := user.Country
|
||
oldRegionID := user.RegionID
|
||
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, nil, 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, nil, err
|
||
}
|
||
if err := insertUserRegionChangedOutbox(ctx, tx, command, oldCountry, oldRegionID); err != nil {
|
||
return userdomain.User{}, 0, nil, err
|
||
}
|
||
revokedSessionIDs, err = revokeActiveUserSessionsForCountryChange(ctx, tx, command, appcode.Normalize(command.AppCode))
|
||
if err != nil {
|
||
return userdomain.User{}, 0, nil, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.User{}, 0, nil, err
|
||
}
|
||
|
||
updated, err := r.GetUser(ctx, command.UserID)
|
||
nextAllowedAt = int64(0)
|
||
if command.CooldownMs > 0 {
|
||
nextAllowedAt = command.ChangedAtMs + command.CooldownMs
|
||
}
|
||
return updated, nextAllowedAt, revokedSessionIDs, err
|
||
}
|
||
|
||
func revokeActiveUserSessionsForCountryChange(ctx context.Context, tx *sql.Tx, command userdomain.CountryChangeCommand, appCode string) ([]string, error) {
|
||
sessionIDs := make([]string, 0)
|
||
rows, err := tx.QueryContext(ctx, `
|
||
SELECT session_id
|
||
FROM auth_sessions
|
||
WHERE app_code = ? AND user_id = ? AND expires_at_ms > ? AND revoked_at_ms IS NULL
|
||
FOR UPDATE
|
||
`, appCode, command.UserID, command.ChangedAtMs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for rows.Next() {
|
||
var sessionID string
|
||
if err := rows.Scan(&sessionID); err != nil {
|
||
_ = rows.Close()
|
||
return nil, err
|
||
}
|
||
sessionIDs = append(sessionIDs, sessionID)
|
||
}
|
||
if err := rows.Close(); err != nil {
|
||
return nil, err
|
||
}
|
||
if len(sessionIDs) == 0 {
|
||
return sessionIDs, nil
|
||
}
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE auth_sessions
|
||
SET revoked_at_ms = ?, revoked_reason = ?, revoked_request_id = ?, revoked_by = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND user_id = ? AND expires_at_ms > ? AND revoked_at_ms IS NULL
|
||
`, command.ChangedAtMs, countryChangeSessionRevokeReason, strings.TrimSpace(command.RequestID), "country_change", command.ChangedAtMs, appCode, command.UserID, command.ChangedAtMs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return sessionIDs, nil
|
||
}
|
||
|
||
func (r *Repository) hydrateInviteOverview(ctx context.Context, user *userdomain.User) error {
|
||
if user == nil || user.UserID <= 0 {
|
||
return nil
|
||
}
|
||
overview, err := invitestorage.New(r.db).GetOverview(ctx, user.AppCode, user.UserID, user.RegionID, timeNowMs())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
user.InviteOverview = overview
|
||
return nil
|
||
}
|
||
|
||
// GetInviteAttribution 按被邀请用户读取邀请归因;主用户仓储只做委托,邀请表结构仍由 invite 仓储维护。
|
||
func (r *Repository) GetInviteAttribution(ctx context.Context, invitedUserID int64) (invitedomain.Attribution, error) {
|
||
if r == nil || r.db == nil {
|
||
return invitedomain.Attribution{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||
}
|
||
return invitestorage.New(r.db).GetAttribution(ctx, appcode.FromContext(ctx), invitedUserID)
|
||
}
|
||
|
||
func invitestorageBindCommand(command userdomain.CompleteOnboardingCommand) invitedomain.BindCommand {
|
||
return invitedomain.BindCommand{
|
||
AppCode: command.AppCode,
|
||
InvitedUserID: command.UserID,
|
||
InvitedRegionID: command.RegionID,
|
||
InviteCode: command.InviteCode,
|
||
Source: "complete_onboarding",
|
||
RequestID: command.RequestID,
|
||
BoundAtMs: command.CompletedAtMs,
|
||
}
|
||
}
|
||
|
||
func timeNowMs() int64 {
|
||
return time.Now().UnixMilli()
|
||
}
|
||
|
||
// QueryUser 读取 users 快照字段;既服务普通查询,也服务短号事务中的 FOR UPDATE 读。
|