package postgres import ( "context" "encoding/json" "errors" "fmt" "sort" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "telesrv/internal/domain" "telesrv/internal/store" "telesrv/internal/store/postgres/sqlcgen" ) // UserStore 用 PostgreSQL 实现 store.UserStore。 type UserStore struct { db sqlcgen.DBTX q *sqlcgen.Queries } const officialUsernameClaimAttempts = 3 var errOfficialUsernameClaimRetry = errors.New("official username claim changed concurrently") // OfficialUsernameClaimResult reports the authoritative 777000 username // reconciliation performed during startup. DisplacedUserID is set only when an // ordinary account's editable username was cleared; bots, other built-in users, // channels and collectible names are never silently seized. type OfficialUsernameClaimResult struct { Official domain.User DisplacedUserID int64 Changed bool } // NewUserStore 基于 pgx 连接池(或事务)创建 UserStore。 func NewUserStore(db sqlcgen.DBTX) *UserStore { return &UserStore{db: db, q: sqlcgen.New(db)} } func (s *UserStore) ByID(ctx context.Context, id int64) (domain.User, bool, error) { row, err := s.q.GetUserByID(ctx, id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, false, nil } return domain.User{}, false, fmt.Errorf("get user by id: %w", err) } return userFromModel(row), true, nil } func (s *UserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) { if len(ids) == 0 { return nil, nil } rows, err := s.q.GetUsersByIDs(ctx, ids) if err != nil { return nil, fmt.Errorf("get users by ids: %w", err) } out := make([]domain.User, 0, len(rows)) for _, row := range rows { out = append(out, userFromModel(row)) } return out, nil } func (s *UserStore) ByPhone(ctx context.Context, phone string) (domain.User, bool, error) { // bot 行 phone 为空串(0090 起 phone 唯一性只覆盖非空值),空查询必须判未找到。 if phone == "" { return domain.User{}, false, nil } row, err := s.q.GetUserByPhone(ctx, phone) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, false, nil } return domain.User{}, false, fmt.Errorf("get user by phone: %w", err) } return userFromModel(row), true, nil } // ByEmail looks up an email-signup account by its signup_email (see // domain.NewEmailSignupDisplayPhone). Ordinary phone accounts never match // since signup_email is '' for them and the index excludes empty values. func (s *UserStore) ByEmail(ctx context.Context, email string) (domain.User, bool, error) { email = strings.TrimSpace(email) if email == "" { return domain.User{}, false, nil } row, err := s.q.GetUserBySignupEmail(ctx, email) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, false, nil } return domain.User{}, false, fmt.Errorf("get user by signup email: %w", err) } return userFromModel(row), true, nil } func (s *UserStore) ByPhones(ctx context.Context, phones []string) ([]domain.User, error) { filtered := make([]string, 0, len(phones)) for _, phone := range phones { if phone != "" { filtered = append(filtered, phone) } } phones = filtered if len(phones) == 0 { return nil, nil } rows, err := s.q.GetUsersByPhones(ctx, phones) if err != nil { return nil, fmt.Errorf("get users by phones: %w", err) } out := make([]domain.User, 0, len(rows)) for _, row := range rows { out = append(out, userFromModel(row)) } return out, nil } func (s *UserStore) ByUsername(ctx context.Context, username string) (domain.User, bool, error) { username = strings.TrimSpace(strings.TrimPrefix(username, "@")) if username == "" { return domain.User{}, false, nil } row, err := s.q.GetUserByUsername(ctx, username) if err != nil { if errors.Is(err, pgx.ErrNoRows) { // The scalar users.username column only holds the editable slot, so a // collectible username resolves through the registry instead. This is a // fallback rather than the primary path: the fast lookup above stays // untouched for every pre-existing username. return s.byCollectibleUsername(ctx, strings.ToLower(username)) } return domain.User{}, false, fmt.Errorf("get user by username: %w", err) } return userFromModel(row), true, nil } // byCollectibleUsername resolves an active collectible username to its holder. // An inactive (client-hidden) name stays occupied but must not resolve. func (s *UserStore) byCollectibleUsername(ctx context.Context, usernameLower string) (domain.User, bool, error) { owner, found, err := getPeerUsernameOwner(ctx, s.db, usernameLower, false) if err != nil { return domain.User{}, false, fmt.Errorf("get user by collectible username: %w", err) } if !found || !owner.collectible || !owner.active || owner.peerType != peerUsernameTypeUser { return domain.User{}, false, nil } return s.ByID(ctx, owner.peerID) } func (s *UserStore) CheckUsername(ctx context.Context, userID int64, username string) (bool, error) { usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) if usernameLower == "" { return true, nil } return peerUsernameAvailable(ctx, s.db, usernameLower, peerUsernameTypeUser, userID) } // CheckUsernameAdmin is CheckUsername without the operator reserved-username // blocklist check, for the admin console deliberately assigning a reserved // word to a specific account. func (s *UserStore) CheckUsernameAdmin(ctx context.Context, userID int64, username string) (bool, error) { usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) if usernameLower == "" { return true, nil } return peerUsernameAvailableChecked(ctx, s.db, usernameLower, peerUsernameTypeUser, userID, false) } func (s *UserStore) Search(ctx context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error) { query = strings.ToLower(strings.TrimSpace(query)) if currentUserID == 0 || query == "" { return domain.UserSearchResult{}, nil } if limit <= 0 || limit > 50 { limit = 50 } rows, err := s.q.SearchUsers(ctx, sqlcgen.SearchUsersParams{ CurrentUserID: currentUserID, QueryLower: query, QueryLike: escapeLike(query), PhoneQuery: phoneQuery, LimitCount: int32(limit), }) if err != nil { return domain.UserSearchResult{}, fmt.Errorf("search users: %w", err) } out := domain.UserSearchResult{ MyResults: make([]domain.User, 0, len(rows)), Results: make([]domain.User, 0, len(rows)), } for _, row := range rows { collectible := mustDecodeEmojiStatusCollectible(row.EmojiStatusCollectibleID, row.EmojiStatusCollectible) u := domain.User{ ID: row.ID, AccessHash: row.AccessHash, Phone: row.Phone, FirstName: row.FirstName, LastName: row.LastName, About: row.About, Username: row.Username, CountryCode: row.CountryCode, Verified: row.Verified, Support: row.Support, Bot: row.IsBot, BotInfoVersion: int(row.BotInfoVersion), PremiumUntil: premiumUntilFromModel(row.PremiumExpiresAt), EmojiStatusDocumentID: row.EmojiStatusDocumentID, EmojiStatusUntil: int(row.EmojiStatusUntil), EmojiStatusCollectible: collectible, Color: peerColorFromModel(row.ColorSet, row.Color, row.ColorBackgroundEmojiID), ProfileColor: peerColorFromModel(row.ProfileColorSet, row.ProfileColor, row.ProfileColorBackgroundEmojiID), LinkedCommunityID: row.LinkedCommunityID, LastSeenAt: int(row.LastSeenAt), Contact: row.Contact, Mutual: row.Mutual, } if row.Contact { out.MyResults = append(out.MyResults, u) } else { out.Results = append(out.Results, u) } } return out, nil } func (s *UserStore) UpdateProfile(ctx context.Context, userID int64, firstName, lastName, about string) (domain.User, error) { row, err := s.q.UpdateUserProfile(ctx, sqlcgen.UpdateUserProfileParams{ ID: userID, FirstName: firstName, LastName: lastName, About: about, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrFirstNameInvalid } return domain.User{}, fmt.Errorf("update user profile: %w", err) } return userFromModel(row), nil } // UpdatePhone force-sets a user's phone number. Used only by the admin // panel -- the user-facing change-phone flow (internal/app/account) requires // a verified code and lives in internal/store/postgres/phone_change.go. func (s *UserStore) UpdatePhone(ctx context.Context, userID int64, phone string) (domain.User, error) { row, err := s.q.UpdateUserPhone(ctx, sqlcgen.UpdateUserPhoneParams{ ID: userID, Phone: phone, }) if err != nil { if isUniqueConstraint(err, "users_phone_unique_idx") { return domain.User{}, domain.ErrPhoneNumberOccupied } if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrUserNotFound } return domain.User{}, fmt.Errorf("update user phone: %w", err) } return userFromModel(row), nil } func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) { return s.updateUsernameChecked(ctx, userID, username, true) } // UpdateUsernameAdmin is UpdateUsername without the operator reserved-username // blocklist check, for the admin console deliberately assigning a reserved // word to a specific account. func (s *UserStore) UpdateUsernameAdmin(ctx context.Context, userID int64, username string) (domain.User, error) { return s.updateUsernameChecked(ctx, userID, username, false) } func (s *UserStore) updateUsernameChecked(ctx context.Context, userID int64, username string, checkReserved bool) (domain.User, error) { username = strings.TrimSpace(strings.TrimPrefix(username, "@")) usernameLower := strings.ToLower(username) beginner, ok := s.db.(txBeginner) if !ok { return domain.User{}, fmt.Errorf("update user username: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.User{}, fmt.Errorf("begin update user username: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() qtx := s.q.WithTx(tx) var lockedUserID int64 if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`, userID).Scan(&lockedUserID); err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrUsernameNotOccupied } return domain.User{}, fmt.Errorf("lock user for username update: %w", err) } if err := replacePeerUsernameTxChecked(ctx, tx, peerUsernameTypeUser, userID, username, usernameLower, checkReserved); err != nil { return domain.User{}, err } row, err := qtx.UpdateUserUsername(ctx, sqlcgen.UpdateUserUsernameParams{ ID: userID, Username: username, }) if err != nil { if isUniqueConstraint(err, "users_username_lower_unique_idx") { return domain.User{}, domain.ErrUsernameOccupied } if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrUsernameNotOccupied } return domain.User{}, fmt.Errorf("update user username: %w", err) } if err := tx.Commit(ctx); err != nil { return domain.User{}, fmt.Errorf("commit update user username: %w", err) } committed = true return userFromModel(row), nil } // ClaimOfficialUsername makes the configured product username authoritative // for the official 777000 account. If an ordinary user currently owns that // editable username, the user's slot is cleared and 777000 claims it in the same // transaction. The method deliberately refuses to seize bots, other system // users, channels, collectible assets or non-editable registry rows. func (s *UserStore) ClaimOfficialUsername(ctx context.Context, username string) (OfficialUsernameClaimResult, error) { username = strings.TrimSpace(strings.TrimPrefix(username, "@")) usernameLower := strings.ToLower(username) if usernameLower == "" { return OfficialUsernameClaimResult{}, domain.ErrUsernameInvalid } var lastErr error for attempt := 0; attempt < officialUsernameClaimAttempts; attempt++ { result, err := s.claimOfficialUsernameOnce(ctx, username, usernameLower) if err == nil { return result, nil } if ctx.Err() != nil || (!errors.Is(err, errOfficialUsernameClaimRetry) && !isRetryablePostgresTxError(err)) { return OfficialUsernameClaimResult{}, err } lastErr = err } return OfficialUsernameClaimResult{}, lastErr } func (s *UserStore) claimOfficialUsernameOnce(ctx context.Context, username, usernameLower string) (OfficialUsernameClaimResult, error) { beginner, ok := s.db.(txBeginner) if !ok { return OfficialUsernameClaimResult{}, fmt.Errorf("claim official username: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return OfficialUsernameClaimResult{}, fmt.Errorf("begin official username claim: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() // Discover the user rows that can participate, then lock them in numeric // order. Ordinary UpdateUsername locks its user before the registry row; this // order avoids reversing that dependency during a rolling restart. lockIDs := map[int64]struct{}{domain.OfficialSystemUserID: {}} if holderID, found, err := usernameScalarHolder(ctx, tx, usernameLower); err != nil { return OfficialUsernameClaimResult{}, err } else if found { lockIDs[holderID] = struct{}{} } if owner, found, err := getPeerUsernameOwner(ctx, tx, usernameLower, false); err != nil { return OfficialUsernameClaimResult{}, err } else if found && owner.peerType == peerUsernameTypeUser { lockIDs[owner.peerID] = struct{}{} } ids := make([]int64, 0, len(lockIDs)) for id := range lockIDs { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) type lockedUser struct { bot bool } locked := make(map[int64]lockedUser, len(ids)) rows, err := tx.Query(ctx, ` SELECT id, is_bot FROM users WHERE id = ANY($1::bigint[]) AND deleted_at IS NULL ORDER BY id FOR UPDATE`, ids) if err != nil { return OfficialUsernameClaimResult{}, fmt.Errorf("lock users for official username claim: %w", err) } for rows.Next() { var id int64 var item lockedUser if err := rows.Scan(&id, &item.bot); err != nil { rows.Close() return OfficialUsernameClaimResult{}, fmt.Errorf("scan user for official username claim: %w", err) } locked[id] = item } if err := rows.Err(); err != nil { rows.Close() return OfficialUsernameClaimResult{}, fmt.Errorf("iterate users for official username claim: %w", err) } rows.Close() if _, found := locked[domain.OfficialSystemUserID]; !found { return OfficialUsernameClaimResult{}, domain.ErrUserNotFound } // Re-read both ownership facts after the row locks. A newly observed user was // not locked in the stable order above, so retry the whole transaction. owner, ownerFound, err := getPeerUsernameOwner(ctx, tx, usernameLower, true) if err != nil { return OfficialUsernameClaimResult{}, err } holderID, holderFound, err := usernameScalarHolder(ctx, tx, usernameLower) if err != nil { return OfficialUsernameClaimResult{}, err } if holderFound { if _, found := locked[holderID]; !found { return OfficialUsernameClaimResult{}, errOfficialUsernameClaimRetry } } if ownerFound && owner.peerType == peerUsernameTypeUser { if _, found := locked[owner.peerID]; !found { return OfficialUsernameClaimResult{}, errOfficialUsernameClaimRetry } } ordinaryUser := func(userID int64) bool { item, found := locked[userID] return found && !item.bot && !domain.IsSystemUserID(userID) } if holderFound && holderID != domain.OfficialSystemUserID && !ordinaryUser(holderID) { return OfficialUsernameClaimResult{}, domain.ErrUsernameOccupied } if ownerFound { allowedOfficialSlot := owner.matches(peerUsernameTypeUser, domain.OfficialSystemUserID) && owner.editable && !owner.collectible allowedOrdinarySlot := owner.peerType == peerUsernameTypeUser && owner.editable && !owner.collectible && ordinaryUser(owner.peerID) if !allowedOfficialSlot && !allowedOrdinarySlot { return OfficialUsernameClaimResult{}, domain.ErrUsernameOccupied } } qtx := s.q.WithTx(tx) officialRow, err := qtx.GetUserByID(ctx, domain.OfficialSystemUserID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return OfficialUsernameClaimResult{}, domain.ErrUserNotFound } return OfficialUsernameClaimResult{}, fmt.Errorf("get official user during username claim: %w", err) } if holderFound && holderID == domain.OfficialSystemUserID && ownerFound && owner.matches(peerUsernameTypeUser, domain.OfficialSystemUserID) && owner.editable && !owner.collectible && officialRow.Username == username { return OfficialUsernameClaimResult{Official: userFromModel(officialRow)}, nil } result := OfficialUsernameClaimResult{Changed: true} if holderFound && holderID != domain.OfficialSystemUserID { if _, err := tx.Exec(ctx, `UPDATE users SET username = '', updated_at = now() WHERE id = $1`, holderID); err != nil { return OfficialUsernameClaimResult{}, fmt.Errorf("clear displaced product username: %w", err) } result.DisplacedUserID = holderID } if _, err := tx.Exec(ctx, `DELETE FROM peer_usernames WHERE username_lower = $1`, usernameLower); err != nil { return OfficialUsernameClaimResult{}, fmt.Errorf("release product username registry slot: %w", err) } if err := deletePeerUsernameTx(ctx, tx, peerUsernameTypeUser, domain.OfficialSystemUserID); err != nil { return OfficialUsernameClaimResult{}, err } if _, err := tx.Exec(ctx, ` INSERT INTO peer_usernames (username_lower, peer_type, peer_id, username, active, editable, sort_order, collectible_id) VALUES ($1, 'user', $2, $3, true, true, 0, NULL)`, usernameLower, domain.OfficialSystemUserID, username); err != nil { if isUniqueViolation(err) { return OfficialUsernameClaimResult{}, errOfficialUsernameClaimRetry } return OfficialUsernameClaimResult{}, fmt.Errorf("claim official username registry slot: %w", err) } officialRow, err = qtx.UpdateUserUsername(ctx, sqlcgen.UpdateUserUsernameParams{ ID: domain.OfficialSystemUserID, Username: username, }) if err != nil { if isUniqueConstraint(err, "users_username_lower_unique_idx") { return OfficialUsernameClaimResult{}, errOfficialUsernameClaimRetry } return OfficialUsernameClaimResult{}, fmt.Errorf("update official username: %w", err) } if err := tx.Commit(ctx); err != nil { return OfficialUsernameClaimResult{}, fmt.Errorf("commit official username claim: %w", err) } committed = true result.Official = userFromModel(officialRow) return result, nil } func usernameScalarHolder(ctx context.Context, db sqlcgen.DBTX, usernameLower string) (int64, bool, error) { var userID int64 err := db.QueryRow(ctx, ` SELECT id FROM users WHERE deleted_at IS NULL AND lower(username) = $1 LIMIT 1`, usernameLower).Scan(&userID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return 0, false, nil } return 0, false, fmt.Errorf("get scalar username holder: %w", err) } return userID, true, nil } func (s *UserStore) UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error { if lastSeenAt <= 0 { return nil } if err := s.q.UpdateUserLastSeen(ctx, sqlcgen.UpdateUserLastSeenParams{ ID: userID, LastSeenAt: int64(lastSeenAt), }); err != nil { return fmt.Errorf("update user last seen: %w", err) } return nil } // UpdateLastSeenBatch applies a set of monotonic presence watermarks with one // PostgreSQL round trip. Duplicate user IDs are collapsed to their maximum // timestamp before the query so UPDATE ... FROM never has an ambiguous source // row. Missing/deleted users are intentionally ignored, matching the ordinary // UpdateLastSeen WHERE boundary. func (s *UserStore) UpdateLastSeenBatch(ctx context.Context, updates []store.UserLastSeenUpdate) error { latest := make(map[int64]int, len(updates)) for _, update := range updates { if update.UserID == 0 || update.LastSeenAt <= 0 { continue } if current := latest[update.UserID]; update.LastSeenAt > current { latest[update.UserID] = update.LastSeenAt } } if len(latest) == 0 { return nil } userIDs := make([]int64, 0, len(latest)) for userID := range latest { userIDs = append(userIDs, userID) } sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] }) lastSeen := make([]int64, len(userIDs)) for index, userID := range userIDs { lastSeen[index] = int64(latest[userID]) } if _, err := s.db.Exec(ctx, ` WITH incoming AS MATERIALIZED ( SELECT user_id, last_seen_at FROM unnest($1::bigint[], $2::bigint[]) AS value(user_id, last_seen_at) ), locked AS MATERIALIZED ( SELECT target.id, incoming.last_seen_at FROM users AS target JOIN incoming ON incoming.user_id = target.id WHERE target.deleted_at IS NULL ORDER BY target.id FOR UPDATE OF target ) UPDATE users AS target SET last_seen_at = GREATEST(target.last_seen_at, locked.last_seen_at), updated_at = now() FROM locked WHERE target.id = locked.id `, userIDs, lastSeen); err != nil { return fmt.Errorf("update user last seen batch: %w", err) } return nil } func (s *UserStore) Create(ctx context.Context, u domain.User) (domain.User, error) { u.Username = strings.TrimSpace(strings.TrimPrefix(u.Username, "@")) beginner, ok := s.db.(txBeginner) if !ok { return domain.User{}, fmt.Errorf("create user: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.User{}, fmt.Errorf("begin create user: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() qtx := s.q.WithTx(tx) row, err := qtx.CreateUser(ctx, sqlcgen.CreateUserParams{ AccessHash: u.AccessHash, Phone: u.Phone, SignupEmail: u.SignupEmail, FirstName: u.FirstName, LastName: u.LastName, Username: u.Username, CountryCode: u.CountryCode, PremiumExpiresAt: premiumUntilToModel(u.PremiumUntil), }) if err != nil { if isUniqueConstraint(err, "users_username_lower_unique_idx") { return domain.User{}, domain.ErrUsernameOccupied } return domain.User{}, fmt.Errorf("create user: %w", err) } usernameLower := strings.ToLower(row.Username) if usernameLower != "" { if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, row.Username, usernameLower); err != nil { return domain.User{}, err } } if err := tx.Commit(ctx); err != nil { return domain.User{}, fmt.Errorf("commit create user: %w", err) } committed = true return userFromModel(row), nil } // SetPremiumUntil 把会员到期时间设为绝对 Unix 秒(0 = 清除会员)。 func (s *UserStore) SetPremiumUntil(ctx context.Context, userID int64, until int) (domain.User, error) { row, err := s.q.SetUserPremiumUntil(ctx, sqlcgen.SetUserPremiumUntilParams{ ID: userID, PremiumExpiresAt: premiumUntilToModel(until), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrUserNotFound } return domain.User{}, fmt.Errorf("set user premium until: %w", err) } return userFromModel(row), nil } // SetVerified 设置/取消用户认证标记。 func (s *UserStore) SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error) { row, err := s.q.SetUserVerified(ctx, sqlcgen.SetUserVerifiedParams{ ID: userID, Verified: verified, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrUserNotFound } return domain.User{}, fmt.Errorf("set user verified: %w", err) } return userFromModel(row), nil } // SetSupport 设置/取消用户的 support 标记(官方客服账号)。 func (s *UserStore) SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error) { row, err := s.q.SetUserSupport(ctx, sqlcgen.SetUserSupportParams{ ID: userID, Support: support, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrUserNotFound } return domain.User{}, fmt.Errorf("set user support: %w", err) } return userFromModel(row), nil } // SetScamFake 设置/取消用户的 scam 与 fake 标记(bot 复用同一路径)。 func (s *UserStore) SetScamFake(ctx context.Context, userID int64, scam, fake bool) (domain.User, error) { if scam && fake { return domain.User{}, domain.ErrPeerModerationFlagsInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.User{}, fmt.Errorf("set user scam/fake: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.User{}, fmt.Errorf("begin set user scam/fake: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() qtx := s.q.WithTx(tx) var currentScam, currentFake bool if err := tx.QueryRow(ctx, ` SELECT scam, fake FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(¤tScam, ¤tFake); err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrUserNotFound } return domain.User{}, fmt.Errorf("lock user scam/fake: %w", err) } if currentScam == scam && currentFake == fake { row, err := qtx.GetUserByID(ctx, userID) if err != nil { return domain.User{}, fmt.Errorf("reload unchanged user scam/fake: %w", err) } if err := tx.Commit(ctx); err != nil { return domain.User{}, fmt.Errorf("commit unchanged user scam/fake: %w", err) } committed = true return userFromModel(row), nil } row, err := qtx.SetUserScamFake(ctx, sqlcgen.SetUserScamFakeParams{ ID: userID, Scam: scam, Fake: fake, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrUserNotFound } return domain.User{}, fmt.Errorf("set user scam/fake: %w", err) } if err := tx.Commit(ctx); err != nil { return domain.User{}, fmt.Errorf("commit user scam/fake: %w", err) } committed = true return userFromModel(row), nil } const maxModerationFlagAudience = 4096 // ModerationFlagAudience returns the bounded set of accounts that can already // observe the target through a direct contact or private dialog. It is used // only for best-effort, non-PTS updateUser fanout after the authoritative flag // mutation commits. func (s *UserStore) ModerationFlagAudience(ctx context.Context, userID int64, limit int) ([]int64, error) { if limit > maxModerationFlagAudience { limit = maxModerationFlagAudience } return moderationFlagAudience(ctx, s.db, userID, limit) } func moderationFlagAudience(ctx context.Context, db sqlcgen.DBTX, userID int64, limit int) ([]int64, error) { if userID <= 0 || limit <= 0 { return nil, nil } rows, err := db.Query(ctx, ` SELECT picked.user_id FROM ( SELECT candidates.user_id FROM ( SELECT $1::bigint AS user_id, 0 AS priority, 2147483647::bigint AS activity UNION ALL SELECT contact_user_id, 1, 0 FROM contacts WHERE user_id = $1 UNION ALL SELECT user_id, 1, 0 FROM contacts WHERE contact_user_id = $1 UNION ALL SELECT peer_id, 2, top_message_date FROM dialogs WHERE user_id = $1 AND peer_type = 'user' UNION ALL SELECT user_id, 2, top_message_date FROM dialogs WHERE peer_type = 'user' AND peer_id = $1 ) candidates JOIN users u ON u.id = candidates.user_id AND u.deleted_at IS NULL GROUP BY candidates.user_id ORDER BY min(candidates.priority), max(candidates.activity) DESC, candidates.user_id LIMIT $2 ) picked ORDER BY picked.user_id`, userID, limit) if err != nil { return nil, fmt.Errorf("list moderation flag audience: %w", err) } defer rows.Close() out := make([]int64, 0) for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("scan moderation flag audience: %w", err) } if id != 0 { out = append(out, id) } } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate moderation flag audience: %w", err) } return out, nil } // SweepExpiredPremium 清空到期会员行并返回清理后的用户。 func (s *UserStore) SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error) { if limit <= 0 { return nil, nil } rows, err := s.q.SweepExpiredPremium(ctx, sqlcgen.SweepExpiredPremiumParams{ Now: pgtype.Timestamptz{Time: time.Unix(now, 0).UTC(), Valid: true}, LimitCount: int32(limit), }) if err != nil { return nil, fmt.Errorf("sweep expired premium: %w", err) } out := make([]domain.User, 0, len(rows)) for _, row := range rows { out = append(out, userFromModel(row)) } return out, nil } // UpdateEmojiStatus atomically replaces the complete emoji-status snapshot. func (s *UserStore) UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) { collectibleJSON, collectibleID, err := encodeEmojiStatusCollectible(status) if err != nil { return domain.User{}, err } params := sqlcgen.UpdateUserEmojiStatusParams{ ID: userID, EmojiStatusDocumentID: status.DocumentID, EmojiStatusUntil: int64(status.Until), EmojiStatusCollectibleID: collectibleID, EmojiStatusCollectible: collectibleJSON, } var row sqlcgen.User if status.Collectible.Empty() { row, err = updateEmojiStatusRow(ctx, s.db, s.q, userID, status, params) } else { // Serialize selection against transfer/export/burn. RPC-level ownership // checks are advisory; this lock is the write-boundary invariant that // prevents a concurrent lifecycle commit from leaving a non-owned gift // installed after its invalidation trigger already ran. err = withTx(ctx, s.db, "update collectible emoji status", func(tx pgx.Tx) error { row, err = updateEmojiStatusRow(ctx, tx, sqlcgen.New(tx), userID, status, params) return err }) } if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrUserNotFound } if errors.Is(err, domain.ErrEmojiStatusCollectibleInvalid) { return domain.User{}, err } return domain.User{}, fmt.Errorf("update user emoji status: %w", err) } return userFromModel(row), nil } // UpdateEmojiStatusWithEvent commits the user snapshot, allocated pts event // and dispatch outbox row as one aggregate transaction. This is the production // boundary used by account.updateEmojiStatus; no success can expose a users // row whose change is absent from updates.getDifference. func (s *UserStore) UpdateEmojiStatusWithEvent(ctx context.Context, userID int64, status domain.UserEmojiStatus, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.User, domain.UpdateEvent, error) { collectibleJSON, collectibleID, err := encodeEmojiStatusCollectible(status) if err != nil { return domain.User{}, domain.UpdateEvent{}, err } if event.Type != domain.UpdateEventUserEmojiStatus || event.EmojiStatus != status || event.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) { return domain.User{}, domain.UpdateEvent{}, domain.ErrEmojiStatusCollectibleInvalid } params := sqlcgen.UpdateUserEmojiStatusParams{ ID: userID, EmojiStatusDocumentID: status.DocumentID, EmojiStatusUntil: int64(status.Until), EmojiStatusCollectibleID: collectibleID, EmojiStatusCollectible: collectibleJSON, } var row sqlcgen.User err = withTx(ctx, s.db, "update emoji status with event", func(tx pgx.Tx) error { row, err = updateEmojiStatusRow(ctx, tx, sqlcgen.New(tx), userID, status, params) if err != nil { return err } event, err = NewUpdateEventStore(tx).AppendAllocatedWithDispatch( ctx, userID, event, excludeAuthKeyID, excludeSessionID, ) return err }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.UpdateEvent{}, domain.ErrUserNotFound } if errors.Is(err, domain.ErrEmojiStatusCollectibleInvalid) { return domain.User{}, domain.UpdateEvent{}, err } return domain.User{}, domain.UpdateEvent{}, fmt.Errorf("update user emoji status with event: %w", err) } return userFromModel(row), event, nil } func updateEmojiStatusRow(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, userID int64, status domain.UserEmojiStatus, params sqlcgen.UpdateUserEmojiStatusParams) (sqlcgen.User, error) { // telesrv has no collectible-gift ownership left to verify against, so a // collectible emoji status can never be legitimately set. if !status.Collectible.Empty() { return sqlcgen.User{}, domain.ErrEmojiStatusCollectibleInvalid } return q.UpdateUserEmojiStatus(ctx, params) } // UpdateBirthday 更新用户生日(零值 Birthday 表示清除)。 func (s *UserStore) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) { row, err := s.q.UpdateUserBirthday(ctx, sqlcgen.UpdateUserBirthdayParams{ ID: userID, BirthdayDay: int32(birthday.Day), BirthdayMonth: int32(birthday.Month), BirthdayYear: int32(birthday.Year), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrUserNotFound } return domain.User{}, fmt.Errorf("update user birthday: %w", err) } return userFromModel(row), nil } // UpdatePersonalChannel 设置/清除资料页个人频道(channelID=0 表示清除)。 func (s *UserStore) UpdatePersonalChannel(ctx context.Context, userID int64, channelID int64) (domain.User, error) { row, err := s.q.UpdateUserPersonalChannel(ctx, sqlcgen.UpdateUserPersonalChannelParams{ ID: userID, PersonalChannelID: channelID, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrUserNotFound } return domain.User{}, fmt.Errorf("update user personal channel: %w", err) } return userFromModel(row), nil } func (s *UserStore) UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) { if forProfile { row, err := s.q.UpdateUserProfileColor(ctx, sqlcgen.UpdateUserProfileColorParams{ ID: userID, ColorSet: color.HasColor, Color: int32(color.Color), BackgroundEmojiID: color.BackgroundEmojiID, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrUserNotFound } return domain.User{}, fmt.Errorf("update user profile color: %w", err) } return userFromModel(row), nil } row, err := s.q.UpdateUserColor(ctx, sqlcgen.UpdateUserColorParams{ ID: userID, ColorSet: color.HasColor, Color: int32(color.Color), BackgroundEmojiID: color.BackgroundEmojiID, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrUserNotFound } return domain.User{}, fmt.Errorf("update user color: %w", err) } return userFromModel(row), nil } // premiumUntilFromModel 把可空 timestamptz 转为 Unix 秒(NULL → 0)。 func premiumUntilFromModel(t pgtype.Timestamptz) int { if !t.Valid { return 0 } return int(t.Time.Unix()) } // premiumUntilToModel 把 Unix 秒转为可空 timestamptz(<=0 → NULL)。 func premiumUntilToModel(until int) pgtype.Timestamptz { if until <= 0 { return pgtype.Timestamptz{} } return pgtype.Timestamptz{Time: time.Unix(int64(until), 0).UTC(), Valid: true} } func escapeLike(s string) string { var b strings.Builder b.Grow(len(s)) for _, r := range s { if r == '%' || r == '_' || r == '\\' { b.WriteRune('\\') } b.WriteRune(r) } return b.String() } func userFromModel(r sqlcgen.User) domain.User { collectible := mustDecodeEmojiStatusCollectible(r.EmojiStatusCollectibleID, r.EmojiStatusCollectible) u := domain.User{ ID: r.ID, AccessHash: r.AccessHash, Phone: r.Phone, SignupEmail: r.SignupEmail, FirstName: r.FirstName, LastName: r.LastName, About: r.About, Username: r.Username, CountryCode: r.CountryCode, Verified: r.Verified, Scam: r.Scam, Fake: r.Fake, Support: r.Support, Bot: r.IsBot, BotInfoVersion: int(r.BotInfoVersion), PremiumUntil: premiumUntilFromModel(r.PremiumExpiresAt), EmojiStatusDocumentID: r.EmojiStatusDocumentID, EmojiStatusUntil: int(r.EmojiStatusUntil), EmojiStatusCollectible: collectible, Birthday: domain.Birthday{Day: int(r.BirthdayDay), Month: int(r.BirthdayMonth), Year: int(r.BirthdayYear)}, PersonalChannelID: r.PersonalChannelID, LinkedCommunityID: r.LinkedCommunityID, Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID), ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID), LastSeenAt: int(r.LastSeenAt), Deleted: r.DeletedAt.Valid, DeletionSource: domain.AccountDeletionSource(r.DeletionSource), DeletionReason: r.DeletionReason, CreatedAt: r.CreatedAt.Time, AccountDeleteAt: r.AccountDeleteAt.Time, } if r.DeletedAt.Valid { u.DeletedAt = r.DeletedAt.Time.Unix() return u.DeletedTombstone() } return u } func encodeEmojiStatusCollectible(status domain.UserEmojiStatus) ([]byte, *int64, error) { if !status.Valid() { return nil, nil, domain.ErrEmojiStatusCollectibleInvalid } if status.Collectible.Empty() { return []byte(`{}`), nil, nil } raw, err := json.Marshal(status.Collectible) if err != nil { return nil, nil, fmt.Errorf("encode collectible emoji status: %w", err) } id := status.Collectible.CollectibleID return raw, &id, nil } func mustDecodeEmojiStatusCollectible(id *int64, raw []byte) domain.EmojiStatusCollectible { var collectible domain.EmojiStatusCollectible if err := json.Unmarshal(raw, &collectible); err != nil { panic(fmt.Sprintf("invalid users.emoji_status_collectible JSON: %v", err)) } if id == nil { if !collectible.Empty() { panic("users emoji-status invariant: snapshot exists without collectible id") } return domain.EmojiStatusCollectible{} } if !collectible.Valid() || collectible.CollectibleID != *id { panic("users emoji-status invariant: incomplete or mismatched collectible snapshot") } return collectible } func peerColorFromModel(hasColor bool, color int32, backgroundEmojiID int64) domain.PeerColor { return domain.PeerColor{ HasColor: hasColor, Color: int(color), BackgroundEmojiID: backgroundEmojiID, } }