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() } func insertUserRegisteredOutbox(ctx context.Context, tx *sql.Tx, user userdomain.User) error { payload := map[string]any{ "user_id": user.UserID, "default_display_user_id": user.DefaultDisplayUserID, "country": user.Country, "country_id": user.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, } 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), user.CreatedAtMs, user.CreatedAtMs) return err } func insertUserRegionChangedOutbox(ctx context.Context, tx *sql.Tx, command userdomain.CountryChangeCommand, oldCountry string, oldRegionID int64) error { if oldRegionID == command.NewRegionID { // 下游只同步当前区域归属读模型;同区域改国家不产生区域迁移事实,避免无意义重放刷新房间和 IM 分组。 return nil } 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 } // 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 } 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 } 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 读。