package postgres import ( "container/list" "context" "errors" "fmt" "sync" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "telesrv/internal/domain" "telesrv/internal/store" "telesrv/internal/store/postgres/sqlcgen" ) // MediaStore 用 PostgreSQL 实现 store.MediaStore(媒体元数据 + blob 索引)。 type MediaStore struct { db sqlcgen.DBTX q *sqlcgen.Queries documents *documentMetaCache } // NewMediaStore 基于 pgx 连接池(或事务)创建 MediaStore。 func NewMediaStore(db sqlcgen.DBTX) *MediaStore { return &MediaStore{ db: db, q: sqlcgen.New(db), documents: newDocumentMetaCache(documentMetaCacheCapacity), } } // bytesOrEmpty 把 nil []byte 归一为空切片,避免落入 NOT NULL bytea 列时被当作 NULL。 func bytesOrEmpty(b []byte) []byte { if b == nil { return []byte{} } return b } var _ store.MediaStore = (*MediaStore)(nil) // ---- 上传分片 ---- func (s *MediaStore) SaveFilePart(ctx context.Context, part domain.UploadPart) error { backend := string(part.Backend) if backend == "" { backend = string(domain.MediaBackendLocalFS) } sha := part.SHA256 if sha == nil { sha = []byte{} } return s.q.SaveUploadPart(ctx, sqlcgen.SaveUploadPartParams{ OwnerUserID: part.OwnerUserID, FileID: part.FileID, Part: int32(part.Part), TotalParts: int32(part.TotalParts), IsBig: part.Big, Backend: backend, ObjectKey: part.ObjectKey, Size: part.Size, Sha256: sha, }) } func (s *MediaStore) UploadPartUsage(ctx context.Context, ownerUserID int64) (domain.UploadPartUsage, error) { row, err := s.q.GetUploadPartUsage(ctx, ownerUserID) if err != nil { return domain.UploadPartUsage{}, err } return domain.UploadPartUsage{ Bytes: row.Bytes, Parts: int(row.Parts), Files: int(row.Files), }, nil } func (s *MediaStore) UploadPartSlot(ctx context.Context, ownerUserID, fileID int64, part int) (domain.UploadPartSlot, error) { row, err := s.q.GetUploadPartSlot(ctx, sqlcgen.GetUploadPartSlotParams{ OwnerUserID: ownerUserID, FileID: fileID, Part: int32(part), }) if err != nil { return domain.UploadPartSlot{}, err } found := row.ExistingBytes >= 0 existingBytes := row.ExistingBytes if !found { existingBytes = 0 } return domain.UploadPartSlot{ ExistingBytes: existingBytes, ObjectKey: row.ObjectKey, FileParts: int(row.FileParts), Found: found, }, nil } func (s *MediaStore) LoadFileParts(ctx context.Context, ownerUserID, fileID int64) ([]domain.UploadPart, error) { rows, err := s.q.ListUploadParts(ctx, sqlcgen.ListUploadPartsParams{OwnerUserID: ownerUserID, FileID: fileID}) if err != nil { return nil, err } out := make([]domain.UploadPart, 0, len(rows)) for _, r := range rows { out = append(out, domain.UploadPart{ OwnerUserID: ownerUserID, FileID: fileID, Part: int(r.Part), TotalParts: int(r.TotalParts), Big: r.IsBig, Backend: domain.MediaBackend(r.Backend), ObjectKey: r.ObjectKey, Size: r.Size, SHA256: r.Sha256, }) } return out, nil } func (s *MediaStore) DeleteFileParts(ctx context.Context, ownerUserID, fileID int64) ([]string, error) { return s.q.DeleteUploadParts(ctx, sqlcgen.DeleteUploadPartsParams{OwnerUserID: ownerUserID, FileID: fileID}) } func (s *MediaStore) DeleteExpiredUploadParts(ctx context.Context, before time.Time, limit int) ([]string, error) { if limit <= 0 { return nil, nil } return s.q.DeleteExpiredUploadParts(ctx, sqlcgen.DeleteExpiredUploadPartsParams{ Before: pgtype.Timestamptz{Time: before, Valid: true}, BatchLimit: int32(limit), }) } // ---- blob 索引 ---- func (s *MediaStore) PutFileBlob(ctx context.Context, blob domain.FileBlob) error { backend := string(blob.Backend) if backend == "" { backend = string(domain.MediaBackendLocalFS) } sha := blob.SHA256 if sha == nil { sha = []byte{} // 列为 NOT NULL;nil []byte 会被 pgx 当作 NULL。 } return s.q.PutFileBlob(ctx, sqlcgen.PutFileBlobParams{ LocationKey: blob.LocationKey, Backend: backend, ObjectKey: blob.ObjectKey, Size: blob.Size, Sha256: sha, MimeType: blob.MimeType, }) } func (s *MediaStore) GetFileBlob(ctx context.Context, locationKey string) (domain.FileBlob, bool, error) { row, err := s.q.GetFileBlob(ctx, locationKey) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.FileBlob{}, false, nil } return domain.FileBlob{}, false, err } return domain.FileBlob{ LocationKey: row.LocationKey, Backend: domain.MediaBackend(row.Backend), ObjectKey: row.ObjectKey, Size: row.Size, SHA256: row.Sha256, MimeType: row.MimeType, }, true, nil } // GetFileBlobs 一发 ANY 查询批量取多个 location_key 的 blob 元数据,替代逐个 GetFileBlob 往返 // (启动预热曾对 ~2400 个 blob 各打一次 PG,是启动期 N+1)。缺失的 key 不在返回 map 中。 func (s *MediaStore) GetFileBlobs(ctx context.Context, locationKeys []string) (map[string]domain.FileBlob, error) { if len(locationKeys) == 0 { return nil, nil } rows, err := s.db.Query(ctx, ` SELECT location_key, backend, object_key, size, sha256, mime_type FROM file_blobs WHERE location_key = ANY($1::text[])`, locationKeys) if err != nil { return nil, fmt.Errorf("get file blobs: %w", err) } defer rows.Close() out := make(map[string]domain.FileBlob, len(locationKeys)) for rows.Next() { var ( blob domain.FileBlob backend string ) if err := rows.Scan(&blob.LocationKey, &backend, &blob.ObjectKey, &blob.Size, &blob.SHA256, &blob.MimeType); err != nil { return nil, fmt.Errorf("scan file blob: %w", err) } blob.Backend = domain.MediaBackend(backend) out[blob.LocationKey] = blob } if err := rows.Err(); err != nil { return nil, err } return out, nil } func (s *MediaStore) GetSeedState(ctx context.Context, key string) (string, bool, error) { var hash string if err := s.db.QueryRow(ctx, ` SELECT content_hash FROM seed_states WHERE key = $1`, key).Scan(&hash); err != nil { if errors.Is(err, pgx.ErrNoRows) { return "", false, nil } return "", false, fmt.Errorf("get seed state: %w", err) } return hash, true, nil } func (s *MediaStore) PutSeedState(ctx context.Context, key, hash string) error { _, err := s.db.Exec(ctx, ` INSERT INTO seed_states (key, content_hash) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET content_hash = EXCLUDED.content_hash, updated_at = now()`, key, hash) if err != nil { return fmt.Errorf("put seed state: %w", err) } return nil } // ---- 文档 ---- func (s *MediaStore) PutDocument(ctx context.Context, doc domain.Document) error { attrs, err := jsonArrayOrEmpty(doc.Attributes) if err != nil { return err } thumbs, err := jsonArrayOrEmpty(doc.Thumbs) if err != nil { return err } if err := s.q.PutDocument(ctx, sqlcgen.PutDocumentParams{ ID: doc.ID, AccessHash: doc.AccessHash, FileReference: bytesOrEmpty(doc.FileReference), Date: int32(doc.Date), MimeType: doc.MimeType, Size: doc.Size, DcID: int32(doc.DCID), AttributesJson: attrs, ThumbsJson: thumbs, }); err != nil { return err } s.documents.put(doc.ID, doc) return nil } func (s *MediaStore) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) { if doc, ok := s.documents.get(id); ok { return doc, true, nil } row, err := s.q.GetDocument(ctx, id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.Document{}, false, nil } return domain.Document{}, false, err } doc, err := documentFromRow(row) if err != nil { return domain.Document{}, false, err } s.documents.put(doc.ID, doc) return doc, true, nil } func (s *MediaStore) GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) { if len(ids) == 0 { return nil, nil } unique := make([]int64, 0, len(ids)) seen := make(map[int64]struct{}, len(ids)) found := make(map[int64]domain.Document, len(ids)) var missing []int64 for _, id := range ids { if id == 0 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} unique = append(unique, id) if doc, ok := s.documents.get(id); ok { found[id] = doc continue } missing = append(missing, id) } if len(unique) == 0 { return nil, nil } if len(missing) > 0 { rows, err := s.q.GetDocuments(ctx, missing) if err != nil { return nil, err } for _, r := range rows { doc, err := documentFromRow(sqlcgen.GetDocumentRow(r)) if err != nil { return nil, err } s.documents.put(doc.ID, doc) found[doc.ID] = cloneDocument(doc) } } out := make([]domain.Document, 0, len(found)) for _, id := range unique { doc, ok := found[id] if !ok { continue } out = append(out, cloneDocument(doc)) } return out, nil } const documentMetaCacheCapacity = 1 << 16 // documentMetaCache keeps immutable document metadata hot for message/media // hydration. PutDocument refreshes entries synchronously, so same-process reads // observe newly generated access_hash/file_reference without waiting for TTL. type documentMetaCache struct { mu sync.Mutex cap int ll *list.List m map[int64]*list.Element } type documentMetaEntry struct { id int64 doc domain.Document } func newDocumentMetaCache(capacity int) *documentMetaCache { if capacity <= 0 { capacity = 1 } return &documentMetaCache{ cap: capacity, ll: list.New(), m: make(map[int64]*list.Element, capacity), } } func (c *documentMetaCache) get(id int64) (domain.Document, bool) { if c == nil || id == 0 { return domain.Document{}, false } c.mu.Lock() defer c.mu.Unlock() el, ok := c.m[id] if !ok { return domain.Document{}, false } c.ll.MoveToFront(el) return cloneDocument(el.Value.(*documentMetaEntry).doc), true } func (c *documentMetaCache) put(id int64, doc domain.Document) { if c == nil || id == 0 { return } copied := cloneDocument(doc) c.mu.Lock() defer c.mu.Unlock() if el, ok := c.m[id]; ok { el.Value.(*documentMetaEntry).doc = copied c.ll.MoveToFront(el) return } c.m[id] = c.ll.PushFront(&documentMetaEntry{id: id, doc: copied}) if c.ll.Len() > c.cap { oldest := c.ll.Back() if oldest != nil { c.ll.Remove(oldest) delete(c.m, oldest.Value.(*documentMetaEntry).id) } } } func cloneDocument(doc domain.Document) domain.Document { doc.FileReference = append([]byte(nil), doc.FileReference...) if len(doc.Attributes) > 0 { attrs := make([]domain.DocumentAttribute, len(doc.Attributes)) copy(attrs, doc.Attributes) for i := range attrs { attrs[i].Waveform = append([]byte(nil), attrs[i].Waveform...) } doc.Attributes = attrs } if len(doc.Thumbs) > 0 { thumbs := make([]domain.PhotoSize, len(doc.Thumbs)) copy(thumbs, doc.Thumbs) for i := range thumbs { thumbs[i].Bytes = append([]byte(nil), thumbs[i].Bytes...) thumbs[i].Sizes = append([]int(nil), thumbs[i].Sizes...) thumbs[i].BackgroundColors = append([]int(nil), thumbs[i].BackgroundColors...) } doc.Thumbs = thumbs } return doc } func documentFromRow(row sqlcgen.GetDocumentRow) (domain.Document, error) { attrs, err := decodeDocumentAttributes(row.AttributesJson) if err != nil { return domain.Document{}, err } thumbs, err := decodePhotoSizes(row.ThumbsJson) if err != nil { return domain.Document{}, err } return domain.Document{ ID: row.ID, AccessHash: row.AccessHash, FileReference: row.FileReference, Date: int(row.Date), MimeType: row.MimeType, Size: row.Size, DCID: int(row.DcID), Attributes: attrs, Thumbs: thumbs, }, nil } // ---- 照片 ---- func (s *MediaStore) PutPhoto(ctx context.Context, photo domain.Photo) error { sizes, err := jsonArrayOrEmpty(photo.Sizes) if err != nil { return err } return s.q.PutPhoto(ctx, sqlcgen.PutPhotoParams{ ID: photo.ID, AccessHash: photo.AccessHash, FileReference: bytesOrEmpty(photo.FileReference), Date: int32(photo.Date), DcID: int32(photo.DCID), HasStickers: photo.HasStickers, SizesJson: sizes, }) } func (s *MediaStore) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) { row, err := s.q.GetPhoto(ctx, id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.Photo{}, false, nil } return domain.Photo{}, false, err } photo, err := photoFromFields(row.ID, row.AccessHash, row.FileReference, int(row.Date), int(row.DcID), row.HasStickers, row.SizesJson) if err != nil { return domain.Photo{}, false, err } return photo, true, nil } type photoScanner interface { Scan(dest ...any) error } func scanPhotoRow(row photoScanner) (domain.Photo, error) { var ( id int64 accessHash int64 fileReference []byte date int32 dcID int32 hasStickers bool sizesJSON string ) if err := row.Scan(&id, &accessHash, &fileReference, &date, &dcID, &hasStickers, &sizesJSON); err != nil { return domain.Photo{}, err } return photoFromFields(id, accessHash, fileReference, int(date), int(dcID), hasStickers, sizesJSON) } func photoFromFields(id, accessHash int64, fileReference []byte, date, dcID int, hasStickers bool, sizesJSON string) (domain.Photo, error) { sizes, err := decodePhotoSizes(sizesJSON) if err != nil { return domain.Photo{}, err } return domain.Photo{ ID: id, AccessHash: accessHash, FileReference: fileReference, Date: date, DCID: dcID, HasStickers: hasStickers, Sizes: sizes, }, nil } // ---- 贴纸集 ---- func (s *MediaStore) PutStickerSet(ctx context.Context, set domain.StickerSet) error { thumbs, err := jsonArrayOrEmpty(set.Thumbs) if err != nil { return err } docIDs, err := jsonArrayOrEmpty(set.DocumentIDs) if err != nil { return err } packs, err := jsonArrayOrEmpty(set.Packs) if err != nil { return err } kind := string(set.Kind) if kind == "" { kind = string(domain.StickerSetKindStickers) } return s.q.PutStickerSet(ctx, sqlcgen.PutStickerSetParams{ ID: set.ID, AccessHash: set.AccessHash, ShortName: set.ShortName, Title: set.Title, Count: int32(set.Count), Hash: int32(set.Hash), SetKind: kind, Official: set.Official, Animated: set.Animated, Videos: set.Videos, Emojis: set.Emojis, Masks: set.Masks, Installed: set.Installed, Archived: set.Archived, InstalledDate: int32(set.InstalledDate), ThumbDocumentID: set.ThumbDocumentID, ThumbsJson: thumbs, ThumbDcID: int32(set.ThumbDCID), ThumbVersion: int32(set.ThumbVersion), DocumentIdsJson: docIDs, PacksJson: packs, SortOrder: int32(set.SortOrder), SystemKey: set.SystemKey, }) } func (s *MediaStore) GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) { row, err := s.q.GetStickerSetByID(ctx, id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.StickerSet{}, false, nil } return domain.StickerSet{}, false, err } return stickerSetFromRow(row) } func (s *MediaStore) GetStickerSetByShortName(ctx context.Context, shortName string) (domain.StickerSet, bool, error) { row, err := s.q.GetStickerSetByShortName(ctx, shortName) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.StickerSet{}, false, nil } return domain.StickerSet{}, false, err } return stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(row)) } func (s *MediaStore) GetStickerSetBySystemKey(ctx context.Context, systemKey string) (domain.StickerSet, bool, error) { row, err := s.q.GetStickerSetBySystemKey(ctx, systemKey) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.StickerSet{}, false, nil } return domain.StickerSet{}, false, err } return stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(row)) } func (s *MediaStore) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) { rows, err := s.q.ListStickerSetsByKind(ctx, string(kind)) if err != nil { return nil, err } out := make([]domain.StickerSet, 0, len(rows)) for _, r := range rows { set, _, err := stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(r)) if err != nil { return nil, err } out = append(out, set) } return out, nil } func (s *MediaStore) CountStickerSets(ctx context.Context) (int, error) { n, err := s.q.CountStickerSets(ctx) return int(n), err } func stickerSetFromRow(row sqlcgen.GetStickerSetByIDRow) (domain.StickerSet, bool, error) { thumbs, err := decodePhotoSizes(row.ThumbsJson) if err != nil { return domain.StickerSet{}, false, err } docIDs, err := decodeInt64Slice(row.DocumentIdsJson) if err != nil { return domain.StickerSet{}, false, err } packs, err := decodeStickerPacks(row.PacksJson) if err != nil { return domain.StickerSet{}, false, err } return domain.StickerSet{ ID: row.ID, AccessHash: row.AccessHash, ShortName: row.ShortName, Title: row.Title, Count: int(row.Count), Hash: int(row.Hash), Kind: domain.StickerSetKind(row.SetKind), Official: row.Official, Animated: row.Animated, Videos: row.Videos, Emojis: row.Emojis, Masks: row.Masks, Installed: row.Installed, Archived: row.Archived, InstalledDate: int(row.InstalledDate), ThumbDocumentID: row.ThumbDocumentID, Thumbs: thumbs, ThumbDCID: int(row.ThumbDcID), ThumbVersion: int(row.ThumbVersion), DocumentIDs: docIDs, Packs: packs, SortOrder: int(row.SortOrder), SystemKey: row.SystemKey, }, true, nil } // ---- 可用 reaction ---- func (s *MediaStore) PutAvailableReaction(ctx context.Context, r domain.AvailableReaction) error { return s.q.PutAvailableReaction(ctx, sqlcgen.PutAvailableReactionParams{ Reaction: r.Reaction, Title: r.Title, Inactive: r.Inactive, Premium: r.Premium, StaticIconID: r.StaticIconID, AppearAnimationID: r.AppearAnimationID, SelectAnimationID: r.SelectAnimationID, ActivateAnimationID: r.ActivateAnimationID, EffectAnimationID: r.EffectAnimationID, AroundAnimationID: r.AroundAnimationID, CenterIconID: r.CenterIconID, SortOrder: int32(r.Order), }) } func (s *MediaStore) ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) { rows, err := s.q.ListAvailableReactions(ctx) if err != nil { return nil, err } out := make([]domain.AvailableReaction, 0, len(rows)) for _, r := range rows { out = append(out, domain.AvailableReaction{ Reaction: r.Reaction, Title: r.Title, Inactive: r.Inactive, Premium: r.Premium, StaticIconID: r.StaticIconID, AppearAnimationID: r.AppearAnimationID, SelectAnimationID: r.SelectAnimationID, ActivateAnimationID: r.ActivateAnimationID, EffectAnimationID: r.EffectAnimationID, AroundAnimationID: r.AroundAnimationID, CenterIconID: r.CenterIconID, Order: int(r.SortOrder), }) } return out, nil } func (s *MediaStore) CountAvailableReactions(ctx context.Context) (int, error) { n, err := s.q.CountAvailableReactions(ctx) return int(n), err } // ---- 头像历史 ---- func (s *MediaStore) AddProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) error { kind = normalizeProfilePhotoKind(kind) next, err := s.nextProfilePhotoOrder(ctx, ownerType, ownerID, kind) if err != nil { return err } _, err = s.db.Exec(ctx, ` INSERT INTO profile_photos (owner_peer_type, owner_peer_id, kind, photo_id, date, active, sort_order) VALUES ($1, $2, $3, $4, $5, true, $6) ON CONFLICT (owner_peer_type, owner_peer_id, kind, photo_id) DO UPDATE SET date = EXCLUDED.date, active = true, sort_order = EXCLUDED.sort_order `, string(ownerType), ownerID, string(kind), photoID, date, next+1) return err } func (s *MediaStore) CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, bool, error) { kind = normalizeProfilePhotoKind(kind) row := s.db.QueryRow(ctx, ` SELECT photo_id FROM profile_photos WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND kind = $3 AND active ORDER BY sort_order DESC LIMIT 1 `, string(ownerType), ownerID, string(kind)) var id int64 err := row.Scan(&id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return 0, false, nil } return 0, false, err } return id, true, nil } func (s *MediaStore) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { return s.CurrentProfilePhotosKind(ctx, ownerType, ownerIDs, domain.ProfilePhotoKindProfile) } func (s *MediaStore) CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) { if len(ownerIDs) == 0 { return map[int64]domain.ProfilePhotoRef{}, nil } kind = normalizeProfilePhotoKind(kind) rows, err := s.db.Query(ctx, ` SELECT DISTINCT ON (pp.owner_peer_id) pp.owner_peer_id, pp.photo_id, ph.dc_id, ph.sizes::text AS sizes_json FROM profile_photos pp JOIN photos ph ON ph.id = pp.photo_id WHERE pp.owner_peer_type = $1 AND pp.owner_peer_id = ANY($2::bigint[]) AND pp.kind = $3 AND pp.active ORDER BY pp.owner_peer_id, pp.sort_order DESC `, string(ownerType), ownerIDs, string(kind)) if err != nil { return nil, err } defer rows.Close() out := make(map[int64]domain.ProfilePhotoRef, len(ownerIDs)) for rows.Next() { var ownerID, photoID int64 var dcID int32 var sizesJSON string if err := rows.Scan(&ownerID, &photoID, &dcID, &sizesJSON); err != nil { return nil, err } sizes, err := decodePhotoSizes(sizesJSON) if err != nil { return nil, err } out[ownerID] = domain.ProfilePhotoRef{ PhotoID: photoID, DCID: int(dcID), Stripped: domain.StrippedFromSizes(sizes), HasVideo: domain.PhotoHasVideo(sizes), } } if err := rows.Err(); err != nil { return nil, err } return out, nil } func (s *MediaStore) ListProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]int64, int, error) { kind = normalizeProfilePhotoKind(kind) rows, err := s.db.Query(ctx, ` SELECT photo_id FROM profile_photos WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND kind = $3 AND active AND ($4::bigint <= 0 OR photo_id < $4::bigint) ORDER BY sort_order DESC OFFSET $5 LIMIT $6 `, string(ownerType), ownerID, string(kind), maxID, offset, limit) if err != nil { return nil, 0, err } defer rows.Close() ids := make([]int64, 0, limit) for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, 0, err } ids = append(ids, id) } if err := rows.Err(); err != nil { return nil, 0, err } var total int err = s.db.QueryRow(ctx, ` SELECT count(*)::int FROM profile_photos WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND kind = $3 AND active `, string(ownerType), ownerID, string(kind)).Scan(&total) if err != nil { return nil, 0, err } return ids, total, nil } func (s *MediaStore) ListProfilePhotoDetailsKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]domain.Photo, int, error) { kind = normalizeProfilePhotoKind(kind) if limit <= 0 { return nil, 0, nil } var rows pgx.Rows var err error if offset < 0 && maxID > 0 { rows, err = s.db.Query(ctx, ` SELECT ph.id, ph.access_hash, ph.file_reference, ph.date, ph.dc_id, ph.has_stickers, ph.sizes::text AS sizes_json FROM profile_photos pp JOIN photos ph ON ph.id = pp.photo_id WHERE pp.owner_peer_type = $1 AND pp.owner_peer_id = $2 AND pp.kind = $3 AND pp.active AND pp.photo_id = $4 ORDER BY pp.sort_order DESC LIMIT $5 `, string(ownerType), ownerID, string(kind), maxID, limit) } else { if offset < 0 { offset = 0 } rows, err = s.db.Query(ctx, ` SELECT ph.id, ph.access_hash, ph.file_reference, ph.date, ph.dc_id, ph.has_stickers, ph.sizes::text AS sizes_json FROM profile_photos pp JOIN photos ph ON ph.id = pp.photo_id WHERE pp.owner_peer_type = $1 AND pp.owner_peer_id = $2 AND pp.kind = $3 AND pp.active AND ($4::bigint <= 0 OR pp.photo_id < $4::bigint) ORDER BY pp.sort_order DESC OFFSET $5 LIMIT $6 `, string(ownerType), ownerID, string(kind), maxID, offset, limit) } if err != nil { return nil, 0, err } defer rows.Close() photos := make([]domain.Photo, 0, limit) for rows.Next() { photo, err := scanPhotoRow(rows) if err != nil { return nil, 0, err } photos = append(photos, photo) } if err := rows.Err(); err != nil { return nil, 0, err } var total int err = s.db.QueryRow(ctx, ` SELECT count(*)::int FROM profile_photos WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND kind = $3 AND active `, string(ownerType), ownerID, string(kind)).Scan(&total) if err != nil { return nil, 0, err } return photos, total, nil } func (s *MediaStore) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error) { return s.DeleteProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoIDs) } func (s *MediaStore) DeleteProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) ([]int64, error) { if len(photoIDs) == 0 { return nil, nil } kind = normalizeProfilePhotoKind(kind) rows, err := s.db.Query(ctx, ` UPDATE profile_photos SET active = false WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND kind = $3 AND photo_id = ANY($4::bigint[]) AND active RETURNING photo_id `, string(ownerType), ownerID, string(kind), photoIDs) if err != nil { return nil, err } defer rows.Close() deleted := make([]int64, 0, len(photoIDs)) for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, err } deleted = append(deleted, id) } if err := rows.Err(); err != nil { return nil, err } return deleted, nil } func (s *MediaStore) nextProfilePhotoOrder(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, error) { var maxOrder int64 err := s.db.QueryRow(ctx, ` SELECT COALESCE(MAX(sort_order), 0)::bigint FROM profile_photos WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND kind = $3 `, string(ownerType), ownerID, string(kind)).Scan(&maxOrder) return maxOrder, err } func normalizeProfilePhotoKind(kind domain.ProfilePhotoKind) domain.ProfilePhotoKind { if kind == domain.ProfilePhotoKindFallback { return kind } return domain.ProfilePhotoKindProfile }