package postgres import ( "context" "database/sql" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "telesrv/internal/domain" "telesrv/internal/store/postgres/sqlcgen" ) // PasswordStore 用 PostgreSQL 实现 store.PasswordStore。 type PasswordStore struct { db sqlcgen.DBTX q *sqlcgen.Queries } // NewPasswordStore 基于 pgx 连接池(或事务)创建 PasswordStore。 func NewPasswordStore(db sqlcgen.DBTX) *PasswordStore { return &PasswordStore{db: db, q: sqlcgen.New(db)} } func (s *PasswordStore) GetByUser(ctx context.Context, userID int64) (domain.PasswordSettings, bool, error) { row := s.db.QueryRow(ctx, ` SELECT has_recovery, has_secure_values, has_password, hint, email_unconfirmed_pattern, login_email_pattern, secure_random, current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p, srp_id, srp_verifier, srp_b_secret, srp_b, recovery_email, recovery_code, recovery_code_expires_at, login_email FROM account_passwords WHERE user_id = $1`, userID) var settings domain.PasswordSettings var salt1, salt2, p []byte var recoveryExpires sql.NullTime if err := row.Scan( &settings.HasRecovery, &settings.HasSecureValues, &settings.HasPassword, &settings.Hint, &settings.EmailUnconfirmedPattern, &settings.LoginEmailPattern, &settings.SecureRandom, &salt1, &salt2, &settings.NewAlgo.G, &p, &settings.SRPID, &settings.SRPVerifier, &settings.SRPBSecret, &settings.SRPB, &settings.RecoveryEmail, &settings.RecoveryCode, &recoveryExpires, &settings.LoginEmail, ); err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.PasswordSettings{}, false, nil } return domain.PasswordSettings{}, false, fmt.Errorf("get account password: %w", err) } if len(salt1) > 0 || len(salt2) > 0 || len(p) > 0 || settings.NewAlgo.G != 0 { settings.CurrentAlgo = &domain.PasswordKDFAlgo{ Salt1: append([]byte(nil), salt1...), Salt2: append([]byte(nil), salt2...), G: settings.NewAlgo.G, P: append([]byte(nil), p...), } } settings.NewAlgo.Salt1 = append([]byte(nil), salt1...) settings.NewAlgo.Salt2 = append([]byte(nil), salt2...) settings.NewAlgo.P = append([]byte(nil), p...) if recoveryExpires.Valid { settings.RecoveryCodeExpiresAt = recoveryExpires.Time.Unix() } settings.SecureRandom = append([]byte(nil), settings.SecureRandom...) settings.SRPVerifier = append([]byte(nil), settings.SRPVerifier...) settings.SRPBSecret = append([]byte(nil), settings.SRPBSecret...) settings.SRPB = append([]byte(nil), settings.SRPB...) return settings, true, nil } func (s *PasswordStore) Save(ctx context.Context, userID int64, settings domain.PasswordSettings) error { algo := settings.NewAlgo if settings.CurrentAlgo != nil { algo = *settings.CurrentAlgo } var recoveryExpires any if settings.RecoveryCodeExpiresAt > 0 { recoveryExpires = time.Unix(settings.RecoveryCodeExpiresAt, 0) } _, err := s.db.Exec(ctx, ` INSERT INTO account_passwords ( user_id, has_recovery, has_secure_values, has_password, hint, email_unconfirmed_pattern, login_email_pattern, secure_random, current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p, srp_id, srp_verifier, srp_b_secret, srp_b, recovery_email, recovery_code, recovery_code_expires_at, login_email ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) ON CONFLICT (user_id) DO UPDATE SET has_recovery = EXCLUDED.has_recovery, has_secure_values = EXCLUDED.has_secure_values, has_password = EXCLUDED.has_password, hint = EXCLUDED.hint, email_unconfirmed_pattern = EXCLUDED.email_unconfirmed_pattern, login_email_pattern = EXCLUDED.login_email_pattern, secure_random = EXCLUDED.secure_random, current_algo_salt1 = EXCLUDED.current_algo_salt1, current_algo_salt2 = EXCLUDED.current_algo_salt2, current_algo_g = EXCLUDED.current_algo_g, current_algo_p = EXCLUDED.current_algo_p, srp_id = EXCLUDED.srp_id, srp_verifier = EXCLUDED.srp_verifier, srp_b_secret = EXCLUDED.srp_b_secret, srp_b = EXCLUDED.srp_b, recovery_email = EXCLUDED.recovery_email, recovery_code = EXCLUDED.recovery_code, recovery_code_expires_at = EXCLUDED.recovery_code_expires_at, login_email = EXCLUDED.login_email, updated_at = now()`, userID, settings.HasRecovery, settings.HasSecureValues, settings.HasPassword, settings.Hint, settings.EmailUnconfirmedPattern, settings.LoginEmailPattern, nonNilBytea(settings.SecureRandom), nonNilBytea(algo.Salt1), nonNilBytea(algo.Salt2), algo.G, nonNilBytea(algo.P), settings.SRPID, nonNilBytea(settings.SRPVerifier), nonNilBytea(settings.SRPBSecret), nonNilBytea(settings.SRPB), settings.RecoveryEmail, settings.RecoveryCode, recoveryExpires, settings.LoginEmail, ) if err != nil { return fmt.Errorf("upsert account password: %w", err) } return nil } func nonNilBytea(in []byte) []byte { if in != nil { return in } return []byte{} } func (s *PasswordStore) GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, bool, error) { row := s.db.QueryRow(ctx, ` SELECT messages_notify_from, stories_notify_from, poll_votes_notify_from, show_previews, default_reaction_type, default_reaction_value, paid_privacy_kind, paid_privacy_peer_type, paid_privacy_peer_id FROM account_reaction_settings WHERE user_id = $1`, userID) var messagesFrom, storiesFrom, pollVotesFrom string var defaultType, defaultValue string var paidKind string var paidPeerType sql.NullString var paidPeerID sql.NullInt64 settings := domain.DefaultAccountReactionSettings() if err := row.Scan( &messagesFrom, &storiesFrom, &pollVotesFrom, &settings.Notify.ShowPreviews, &defaultType, &defaultValue, &paidKind, &paidPeerType, &paidPeerID, ); err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.AccountReactionSettings{}, false, nil } return domain.AccountReactionSettings{}, false, fmt.Errorf("get account reaction settings: %w", err) } settings.Notify.MessagesFrom = domain.ReactionNotifyFrom(messagesFrom) settings.Notify.StoriesFrom = domain.ReactionNotifyFrom(storiesFrom) settings.Notify.PollVotesFrom = domain.ReactionNotifyFrom(pollVotesFrom) if reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(defaultType), defaultValue); ok { settings.DefaultReaction = reaction } settings.PaidPrivacy = domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyKind(paidKind)} if settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyPeer && paidPeerType.Valid && paidPeerID.Valid { peer := domain.Peer{Type: domain.PeerType(paidPeerType.String), ID: paidPeerID.Int64} settings.PaidPrivacy.Peer = &peer } return settings, true, nil } func (s *PasswordStore) SaveReactionSettings(ctx context.Context, userID int64, settings domain.AccountReactionSettings) error { var paidPeerType any var paidPeerID any if settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyPeer && settings.PaidPrivacy.Peer != nil { paidPeerType = string(settings.PaidPrivacy.Peer.Type) paidPeerID = settings.PaidPrivacy.Peer.ID } if _, err := s.db.Exec(ctx, ` INSERT INTO account_reaction_settings ( user_id, messages_notify_from, stories_notify_from, poll_votes_notify_from, show_previews, default_reaction_type, default_reaction_value, paid_privacy_kind, paid_privacy_peer_type, paid_privacy_peer_id ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (user_id) DO UPDATE SET messages_notify_from = EXCLUDED.messages_notify_from, stories_notify_from = EXCLUDED.stories_notify_from, poll_votes_notify_from = EXCLUDED.poll_votes_notify_from, show_previews = EXCLUDED.show_previews, default_reaction_type = EXCLUDED.default_reaction_type, default_reaction_value = EXCLUDED.default_reaction_value, paid_privacy_kind = EXCLUDED.paid_privacy_kind, paid_privacy_peer_type = EXCLUDED.paid_privacy_peer_type, paid_privacy_peer_id = EXCLUDED.paid_privacy_peer_id, updated_at = now()`, userID, string(settings.Notify.MessagesFrom), string(settings.Notify.StoriesFrom), string(settings.Notify.PollVotesFrom), settings.Notify.ShowPreviews, string(settings.DefaultReaction.Type), settings.DefaultReaction.Value(), string(settings.PaidPrivacy.Kind), paidPeerType, paidPeerID, ); err != nil { return fmt.Errorf("save account reaction settings: %w", err) } return nil } func (s *PasswordStore) GetAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, bool, error) { row := s.db.QueryRow(ctx, ` SELECT archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders, hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button, noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent FROM account_settings WHERE user_id = $1`, userID) settings := domain.DefaultAccountSettings() gp := &settings.GlobalPrivacy if err := row.Scan( &gp.ArchiveAndMuteNewNoncontactPeers, &gp.KeepArchivedUnmuted, &gp.KeepArchivedFolders, &gp.HideReadMarks, &gp.NewNoncontactPeersRequirePremium, &gp.DisplayGiftsButton, &gp.NoncontactPeersPaidStars, &settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent, ); err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.AccountSettings{}, false, nil } return domain.AccountSettings{}, false, fmt.Errorf("get account settings: %w", err) } return settings, true, nil } func (s *PasswordStore) SaveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) error { gp := settings.GlobalPrivacy if _, err := s.db.Exec(ctx, ` INSERT INTO account_settings ( user_id, archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders, hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button, noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT (user_id) DO UPDATE SET archive_and_mute_new_noncontact_peers = EXCLUDED.archive_and_mute_new_noncontact_peers, keep_archived_unmuted = EXCLUDED.keep_archived_unmuted, keep_archived_folders = EXCLUDED.keep_archived_folders, hide_read_marks = EXCLUDED.hide_read_marks, new_noncontact_peers_require_premium = EXCLUDED.new_noncontact_peers_require_premium, display_gifts_button = EXCLUDED.display_gifts_button, noncontact_peers_paid_stars = EXCLUDED.noncontact_peers_paid_stars, account_ttl_days = EXCLUDED.account_ttl_days, sensitive_content_enabled = EXCLUDED.sensitive_content_enabled, contact_signup_silent = EXCLUDED.contact_signup_silent, updated_at = now()`, userID, gp.ArchiveAndMuteNewNoncontactPeers, gp.KeepArchivedUnmuted, gp.KeepArchivedFolders, gp.HideReadMarks, gp.NewNoncontactPeersRequirePremium, gp.DisplayGiftsButton, gp.NoncontactPeersPaidStars, settings.NormalizedTTLDays(), settings.SensitiveContentEnabled, settings.ContactSignUpSilent, ); err != nil { return fmt.Errorf("save account settings: %w", err) } return nil } func notifyScopeColumns(scope domain.NotifyScope) (kind, peerType string, peerID int64, topicID int) { kind = string(scope.Kind) if scope.Kind == domain.NotifyScopePeer { return kind, string(scope.Peer.Type), scope.Peer.ID, scope.TopicID } return kind, "", 0, 0 } func scanNotifySettings(row pgx.Row) (domain.PeerNotifySettings, error) { var showPreviews, silent, storiesMuted, storiesHideSender sql.NullBool var muteUntil sql.NullInt32 if err := row.Scan(&showPreviews, &silent, &muteUntil, &storiesMuted, &storiesHideSender); err != nil { return domain.PeerNotifySettings{}, err } out := domain.PeerNotifySettings{} if showPreviews.Valid { out.ShowPreviews = &showPreviews.Bool } if silent.Valid { out.Silent = &silent.Bool } if muteUntil.Valid { v := int(muteUntil.Int32) out.MuteUntil = &v } if storiesMuted.Valid { out.StoriesMuted = &storiesMuted.Bool } if storiesHideSender.Valid { out.StoriesHideSender = &storiesHideSender.Bool } return out, nil } func (s *PasswordStore) GetNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope) (domain.PeerNotifySettings, bool, error) { kind, peerType, peerID, topicID := notifyScopeColumns(scope) row := s.db.QueryRow(ctx, ` SELECT show_previews, silent, mute_until, stories_muted, stories_hide_sender FROM notify_settings WHERE owner_user_id = $1 AND scope_kind = $2 AND peer_type = $3 AND peer_id = $4 AND topic_id = $5`, ownerUserID, kind, peerType, peerID, topicID) settings, err := scanNotifySettings(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.PeerNotifySettings{}, false, nil } return domain.PeerNotifySettings{}, false, fmt.Errorf("get notify settings: %w", err) } return settings, true, nil } func (s *PasswordStore) SaveNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope, settings domain.PeerNotifySettings) error { kind, peerType, peerID, topicID := notifyScopeColumns(scope) if _, err := s.db.Exec(ctx, ` INSERT INTO notify_settings ( owner_user_id, scope_kind, peer_type, peer_id, topic_id, show_previews, silent, mute_until, stories_muted, stories_hide_sender ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (owner_user_id, scope_kind, peer_type, peer_id, topic_id) DO UPDATE SET show_previews = EXCLUDED.show_previews, silent = EXCLUDED.silent, mute_until = EXCLUDED.mute_until, stories_muted = EXCLUDED.stories_muted, stories_hide_sender = EXCLUDED.stories_hide_sender, updated_at = now()`, ownerUserID, kind, peerType, peerID, topicID, nullableBool(settings.ShowPreviews), nullableBool(settings.Silent), nullableInt(settings.MuteUntil), nullableBool(settings.StoriesMuted), nullableBool(settings.StoriesHideSender), ); err != nil { return fmt.Errorf("save notify settings: %w", err) } return nil } func (s *PasswordStore) ResetNotifySettings(ctx context.Context, ownerUserID int64) error { if _, err := s.db.Exec(ctx, `DELETE FROM notify_settings WHERE owner_user_id = $1`, ownerUserID); err != nil { return fmt.Errorf("reset notify settings: %w", err) } return nil } func (s *PasswordStore) GetPeerNotifySettings(ctx context.Context, ownerUserID int64, peers []domain.Peer) (map[domain.Peer]domain.PeerNotifySettings, error) { out := make(map[domain.Peer]domain.PeerNotifySettings, len(peers)) if len(peers) == 0 { return out, nil } types := make([]string, 0, len(peers)) ids := make([]int64, 0, len(peers)) for _, p := range peers { types = append(types, string(p.Type)) ids = append(ids, p.ID) } rows, err := s.db.Query(ctx, ` SELECT peer_type, peer_id, show_previews, silent, mute_until, stories_muted, stories_hide_sender FROM notify_settings WHERE owner_user_id = $1 AND scope_kind = 'peer' AND topic_id = 0 AND (peer_type, peer_id) IN (SELECT * FROM unnest($2::text[], $3::bigint[]))`, ownerUserID, types, ids) if err != nil { return nil, fmt.Errorf("batch get notify settings: %w", err) } defer rows.Close() for rows.Next() { var peerType string var peerID int64 var showPreviews, silent, storiesMuted, storiesHideSender sql.NullBool var muteUntil sql.NullInt32 if err := rows.Scan(&peerType, &peerID, &showPreviews, &silent, &muteUntil, &storiesMuted, &storiesHideSender); err != nil { return nil, fmt.Errorf("scan notify settings: %w", err) } settings := domain.PeerNotifySettings{} if showPreviews.Valid { settings.ShowPreviews = &showPreviews.Bool } if silent.Valid { settings.Silent = &silent.Bool } if muteUntil.Valid { v := int(muteUntil.Int32) settings.MuteUntil = &v } if storiesMuted.Valid { settings.StoriesMuted = &storiesMuted.Bool } if storiesHideSender.Valid { settings.StoriesHideSender = &storiesHideSender.Bool } out[domain.Peer{Type: domain.PeerType(peerType), ID: peerID}] = settings } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate notify settings: %w", err) } return out, nil } func (s *PasswordStore) AllPeerNotifySettings(ctx context.Context, ownerUserID int64) (map[domain.Peer]domain.PeerNotifySettings, error) { // owner-scoped 单查询,走部分索引 notify_settings_owner_peer_idx(scope_kind='peer' AND topic_id=0)。 rows, err := s.db.Query(ctx, ` SELECT peer_type, peer_id, show_previews, silent, mute_until, stories_muted, stories_hide_sender FROM notify_settings WHERE owner_user_id = $1 AND scope_kind = 'peer' AND topic_id = 0`, ownerUserID) if err != nil { return nil, fmt.Errorf("all peer notify settings: %w", err) } defer rows.Close() out := make(map[domain.Peer]domain.PeerNotifySettings) for rows.Next() { var peerType string var peerID int64 var showPreviews, silent, storiesMuted, storiesHideSender sql.NullBool var muteUntil sql.NullInt32 if err := rows.Scan(&peerType, &peerID, &showPreviews, &silent, &muteUntil, &storiesMuted, &storiesHideSender); err != nil { return nil, fmt.Errorf("scan all peer notify settings: %w", err) } settings := domain.PeerNotifySettings{} if showPreviews.Valid { settings.ShowPreviews = &showPreviews.Bool } if silent.Valid { settings.Silent = &silent.Bool } if muteUntil.Valid { v := int(muteUntil.Int32) settings.MuteUntil = &v } if storiesMuted.Valid { settings.StoriesMuted = &storiesMuted.Bool } if storiesHideSender.Valid { settings.StoriesHideSender = &storiesHideSender.Bool } out[domain.Peer{Type: domain.PeerType(peerType), ID: peerID}] = settings } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate all peer notify settings: %w", err) } return out, nil } func (s *PasswordStore) ListNotifyExceptions(ctx context.Context, ownerUserID int64) ([]domain.NotifyException, error) { rows, err := s.db.Query(ctx, ` SELECT peer_type, peer_id, topic_id, show_previews, silent, mute_until, stories_muted, stories_hide_sender FROM notify_settings WHERE owner_user_id = $1 AND scope_kind = 'peer' ORDER BY peer_type, peer_id, topic_id`, ownerUserID) if err != nil { return nil, fmt.Errorf("list notify exceptions: %w", err) } defer rows.Close() out := make([]domain.NotifyException, 0) for rows.Next() { var peerType string var peerID int64 var topicID int var showPreviews, silent, storiesMuted, storiesHideSender sql.NullBool var muteUntil sql.NullInt32 if err := rows.Scan(&peerType, &peerID, &topicID, &showPreviews, &silent, &muteUntil, &storiesMuted, &storiesHideSender); err != nil { return nil, fmt.Errorf("scan notify exception: %w", err) } settings := domain.PeerNotifySettings{} if showPreviews.Valid { settings.ShowPreviews = &showPreviews.Bool } if silent.Valid { settings.Silent = &silent.Bool } if muteUntil.Valid { v := int(muteUntil.Int32) settings.MuteUntil = &v } if storiesMuted.Valid { settings.StoriesMuted = &storiesMuted.Bool } if storiesHideSender.Valid { settings.StoriesHideSender = &storiesHideSender.Bool } if settings.IsZero() { continue // 全默认行(如曾静音后取消)不算异常 } out = append(out, domain.NotifyException{ Peer: domain.Peer{Type: domain.PeerType(peerType), ID: peerID}, TopicID: topicID, Settings: settings, }) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate notify exceptions: %w", err) } return out, nil } func (s *PasswordStore) SaveStickerCollectionItem(ctx context.Context, userID int64, kind domain.StickerCollectionKind, documentID int64, unsave bool, now, max int) error { if userID == 0 || documentID == 0 { return domain.ErrStickerInvalid } if unsave { if _, err := s.db.Exec(ctx, `DELETE FROM user_sticker_collections WHERE owner_user_id = $1 AND kind = $2 AND document_id = $3`, userID, string(kind), documentID); err != nil { return fmt.Errorf("unsave sticker collection item: %w", err) } return nil } if max <= 0 { max = domain.MaxStickerCollectionItems(kind) } return withTx(ctx, s.db, "save sticker collection item", func(tx pgx.Tx) error { if _, err := tx.Exec(ctx, ` INSERT INTO user_sticker_collections (owner_user_id, kind, document_id, used_at) VALUES ($1, $2, $3, $4) ON CONFLICT (owner_user_id, kind, document_id) DO UPDATE SET used_at = EXCLUDED.used_at`, userID, string(kind), documentID, now); err != nil { return fmt.Errorf("upsert sticker collection item: %w", err) } // 截断超上界:单次有序窗口扫描(索引 user_sticker_collections_order_idx 服务 // used_at DESC 排序),按 ctid 删除排名 > max 的旧项,避免 NOT IN 双扫全集。 if _, err := tx.Exec(ctx, ` DELETE FROM user_sticker_collections WHERE ctid IN ( SELECT ctid FROM ( SELECT ctid, ROW_NUMBER() OVER (ORDER BY used_at DESC, document_id DESC) AS rn FROM user_sticker_collections WHERE owner_user_id = $1 AND kind = $2 ) t WHERE rn > $3 )`, userID, string(kind), max); err != nil { return fmt.Errorf("trim sticker collection: %w", err) } return nil }) } func (s *PasswordStore) ListStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind, limit int) ([]domain.StickerCollectionItem, error) { if userID == 0 { return nil, nil } if limit <= 0 || limit > domain.MaxStickerCollectionItems(kind) { limit = domain.MaxStickerCollectionItems(kind) } rows, err := s.db.Query(ctx, ` SELECT document_id, used_at FROM user_sticker_collections WHERE owner_user_id = $1 AND kind = $2 ORDER BY used_at DESC, document_id DESC LIMIT $3`, userID, string(kind), limit) if err != nil { return nil, fmt.Errorf("list sticker collection: %w", err) } defer rows.Close() out := make([]domain.StickerCollectionItem, 0, limit) for rows.Next() { var item domain.StickerCollectionItem if err := rows.Scan(&item.DocumentID, &item.Date); err != nil { return nil, fmt.Errorf("scan sticker collection item: %w", err) } out = append(out, item) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate sticker collection: %w", err) } return out, nil } func (s *PasswordStore) ClearStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind) error { if _, err := s.db.Exec(ctx, `DELETE FROM user_sticker_collections WHERE owner_user_id = $1 AND kind = $2`, userID, string(kind)); err != nil { return fmt.Errorf("clear sticker collection: %w", err) } return nil } func (s *PasswordStore) InstallUserStickerSet(ctx context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error { if userID == 0 || setID == 0 { return domain.ErrStickerInvalid } orderValue := int64(installedDate) << 32 _, err := s.db.Exec(ctx, ` INSERT INTO user_sticker_sets (owner_user_id, sticker_set_id, set_kind, archived, installed_date, order_value, updated_at) VALUES ($1, $2, $3, $4, $5, $6, now()) ON CONFLICT (owner_user_id, sticker_set_id) DO UPDATE SET set_kind = EXCLUDED.set_kind, archived = EXCLUDED.archived, installed_date = CASE WHEN user_sticker_sets.installed_date = 0 THEN EXCLUDED.installed_date ELSE user_sticker_sets.installed_date END, order_value = EXCLUDED.order_value, updated_at = now()`, userID, setID, string(kind), archived, installedDate, orderValue) if err != nil { return fmt.Errorf("install user sticker set: %w", err) } return nil } func (s *PasswordStore) UninstallUserStickerSet(ctx context.Context, userID int64, setID int64) error { if _, err := s.db.Exec(ctx, ` DELETE FROM user_sticker_sets WHERE owner_user_id = $1 AND sticker_set_id = $2`, userID, setID); err != nil { return fmt.Errorf("uninstall user sticker set: %w", err) } return nil } func (s *PasswordStore) SetUserStickerSetArchived(ctx context.Context, userID int64, setID int64, archived bool, now int) error { orderValue := int64(now) << 32 _, err := s.db.Exec(ctx, ` UPDATE user_sticker_sets SET archived = $3, order_value = CASE WHEN $3::boolean = false AND $4::bigint > 0 THEN $4::bigint ELSE order_value END, updated_at = now() WHERE owner_user_id = $1 AND sticker_set_id = $2`, userID, setID, archived, orderValue) if err != nil { return fmt.Errorf("set user sticker set archived: %w", err) } return nil } func (s *PasswordStore) ReorderUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, order []int64, now int) error { if len(order) == 0 { return nil } return withTx(ctx, s.db, "reorder user sticker sets", func(tx pgx.Tx) error { orderValue := int64(now) << 32 for _, id := range order { if _, err := tx.Exec(ctx, ` UPDATE user_sticker_sets SET order_value = $4, updated_at = now() WHERE owner_user_id = $1 AND set_kind = $2 AND sticker_set_id = $3`, userID, string(kind), id, orderValue); err != nil { return fmt.Errorf("update user sticker set order: %w", err) } orderValue-- } return nil }) } func (s *PasswordStore) ListUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, archived *bool, offsetID int64, limit int) ([]domain.UserStickerSet, int, error) { if userID == 0 { return nil, 0, nil } if limit <= 0 || limit > domain.MaxInstalledStickerSets { limit = domain.MaxInstalledStickerSets } var archivedFilter any if archived != nil { archivedFilter = *archived } rows, err := s.db.Query(ctx, ` WITH ordered AS ( SELECT owner_user_id, sticker_set_id, set_kind, archived, installed_date, order_value, ROW_NUMBER() OVER (ORDER BY order_value DESC, sticker_set_id DESC) AS rn, COUNT(*) OVER () AS total FROM user_sticker_sets WHERE owner_user_id = $1 AND set_kind = $2 AND ($3::boolean IS NULL OR archived = $3::boolean) ), page AS ( SELECT COALESCE((SELECT rn FROM ordered WHERE sticker_set_id = $4), 0) AS offset_rn ) SELECT owner_user_id, sticker_set_id, set_kind, archived, installed_date, order_value, total FROM ordered, page WHERE ordered.rn > page.offset_rn ORDER BY ordered.rn ASC LIMIT $5`, userID, string(kind), archivedFilter, offsetID, limit) if err != nil { return nil, 0, fmt.Errorf("list user sticker sets: %w", err) } defer rows.Close() out := make([]domain.UserStickerSet, 0, limit) total := 0 for rows.Next() { var ( item domain.UserStickerSet kindText string ) if err := rows.Scan(&item.OwnerUserID, &item.StickerSetID, &kindText, &item.Archived, &item.InstalledDate, &item.OrderValue, &total); err != nil { return nil, 0, fmt.Errorf("scan user sticker set: %w", err) } item.Kind = domain.StickerSetKind(kindText) out = append(out, item) } if err := rows.Err(); err != nil { return nil, 0, fmt.Errorf("iterate user sticker sets: %w", err) } return out, total, nil } func nullableBool(v *bool) any { if v == nil { return nil } return *v } func nullableInt(v *int) any { if v == nil { return nil } return *v } func (s *PasswordStore) SaveMusic(ctx context.Context, req domain.SaveMusicRequest) error { if req.UserID == 0 || req.Document.ID == 0 || !req.Document.IsMusic() { return domain.ErrDocumentInvalid } return withTx(ctx, s.db, "save account music", func(tx pgx.Tx) error { rows, err := tx.Query(ctx, ` SELECT document_id FROM saved_music WHERE user_id = $1 ORDER BY sort_order ASC, document_id ASC FOR UPDATE`, req.UserID) if err != nil { return fmt.Errorf("lock saved music: %w", err) } defer rows.Close() current := make([]int64, 0, domain.MaxSavedMusicItems) for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return fmt.Errorf("scan saved music id: %w", err) } current = append(current, id) } if err := rows.Err(); err != nil { return fmt.Errorf("scan saved music ids: %w", err) } if req.Unsave { if _, err := tx.Exec(ctx, `DELETE FROM saved_music WHERE user_id = $1 AND document_id = $2`, req.UserID, req.Document.ID); err != nil { return fmt.Errorf("delete saved music: %w", err) } return nil } exists := false for _, id := range current { if id == req.Document.ID { exists = true break } } if req.AfterDocumentID == req.Document.ID { if exists { return nil } return domain.ErrDocumentInvalid } next := make([]int64, 0, len(current)+1) afterIndex := -1 for _, id := range current { if id == req.Document.ID { continue } if id == req.AfterDocumentID { afterIndex = len(next) } next = append(next, id) } if req.AfterDocumentID != 0 { if afterIndex < 0 { return domain.ErrDocumentInvalid } next = append(next, 0) copy(next[afterIndex+2:], next[afterIndex+1:]) next[afterIndex+1] = req.Document.ID } else { next = append([]int64{req.Document.ID}, next...) } if len(next) > domain.MaxSavedMusicItems { next = next[:domain.MaxSavedMusicItems] } if _, err := tx.Exec(ctx, `DELETE FROM saved_music WHERE user_id = $1`, req.UserID); err != nil { return fmt.Errorf("clear saved music order: %w", err) } for i, id := range next { if _, err := tx.Exec(ctx, ` INSERT INTO saved_music (user_id, document_id, sort_order, created_at, updated_at) VALUES ($1, $2, $3, now(), now())`, req.UserID, id, i+1); err != nil { return fmt.Errorf("insert saved music: %w", err) } } return nil }) } func (s *PasswordStore) ListSavedMusicIDs(ctx context.Context, userID int64, limit int) ([]int64, error) { if userID == 0 { return nil, nil } if limit <= 0 || limit > domain.MaxSavedMusicItems { limit = domain.MaxSavedMusicItems } rows, err := s.db.Query(ctx, ` SELECT document_id FROM saved_music WHERE user_id = $1 ORDER BY sort_order ASC, document_id ASC LIMIT $2`, userID, limit) if err != nil { return nil, fmt.Errorf("list saved music ids: %w", err) } defer rows.Close() ids := make([]int64, 0, limit) for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("scan saved music id: %w", err) } ids = append(ids, id) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("scan saved music ids: %w", err) } return ids, nil } func (s *PasswordStore) ListSavedMusic(ctx context.Context, userID int64, offset, limit int) (domain.SavedMusicList, error) { out := domain.SavedMusicList{UserID: userID} if userID == 0 { return out, nil } if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM saved_music WHERE user_id = $1`, userID).Scan(&out.Count); err != nil { return domain.SavedMusicList{}, fmt.Errorf("count saved music: %w", err) } if limit <= 0 || offset < 0 || offset >= out.Count { return out, nil } if limit > domain.MaxSavedMusicItems { limit = domain.MaxSavedMusicItems } docs, err := s.querySavedMusicDocuments(ctx, ` SELECT d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, d.attributes::text AS attributes_json, d.thumbs::text AS thumbs_json FROM saved_music sm JOIN documents d ON d.id = sm.document_id WHERE sm.user_id = $1 ORDER BY sm.sort_order ASC, sm.document_id ASC OFFSET $2 LIMIT $3`, userID, offset, limit) if err != nil { return domain.SavedMusicList{}, err } out.Documents = docs return out, nil } func (s *PasswordStore) GetSavedMusicByIDs(ctx context.Context, userID int64, ids []int64) (domain.SavedMusicList, error) { out := domain.SavedMusicList{UserID: userID} if userID == 0 || len(ids) == 0 { return out, nil } if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM saved_music WHERE user_id = $1`, userID).Scan(&out.Count); err != nil { return domain.SavedMusicList{}, fmt.Errorf("count saved music: %w", err) } docs, err := s.querySavedMusicDocuments(ctx, ` SELECT d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, d.attributes::text AS attributes_json, d.thumbs::text AS thumbs_json FROM saved_music sm JOIN documents d ON d.id = sm.document_id WHERE sm.user_id = $1 AND sm.document_id = ANY($2::bigint[]) ORDER BY sm.sort_order ASC, sm.document_id ASC`, userID, ids) if err != nil { return domain.SavedMusicList{}, err } byID := make(map[int64]domain.Document, len(docs)) for _, doc := range docs { byID[doc.ID] = doc } seen := make(map[int64]struct{}, len(ids)) for _, id := range ids { if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} if doc, ok := byID[id]; ok { out.Documents = append(out.Documents, doc) } } return out, nil } func (s *PasswordStore) querySavedMusicDocuments(ctx context.Context, sql string, args ...any) ([]domain.Document, error) { rows, err := s.db.Query(ctx, sql, args...) if err != nil { return nil, fmt.Errorf("query saved music documents: %w", err) } defer rows.Close() docs := make([]domain.Document, 0) for rows.Next() { var row sqlcgen.GetDocumentRow if err := rows.Scan( &row.ID, &row.AccessHash, &row.FileReference, &row.Date, &row.MimeType, &row.Size, &row.DcID, &row.AttributesJson, &row.ThumbsJson, ); err != nil { return nil, fmt.Errorf("scan saved music document: %w", err) } doc, err := documentFromRow(row) if err != nil { return nil, err } if doc.IsMusic() { docs = append(docs, doc) } } if err := rows.Err(); err != nil { return nil, fmt.Errorf("scan saved music documents: %w", err) } return docs, nil }