2026-06-08 00:55:30 +08:00

482 lines
17 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 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 读。