package postgres import ( "context" "errors" "fmt" "strings" "github.com/jackc/pgx/v5" "telesrv/internal/domain" "telesrv/internal/store/postgres/sqlcgen" ) // StarGiftStore 用 PostgreSQL 实现 store.StarGiftStore(peer 收到的 Star 礼物实例)。 type StarGiftStore struct { db sqlcgen.DBTX } // NewStarGiftStore 基于 pgx 连接池(或事务)创建 StarGiftStore。 func NewStarGiftStore(db sqlcgen.DBTX) *StarGiftStore { return &StarGiftStore{db: db} } const starGiftCatalogSelect = ` SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title, r.limited, r.sold_out, r.birthday, r.require_premium, r.limited_per_user, r.peer_color_available, r.auction, c.availability_remains, r.availability_total, c.availability_resale, c.first_sale_date, c.last_sale_date, c.resell_min_stars, COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0), r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round, r.auction_start_date, r.upgrade_variants, r.background_center_color IS NOT NULL, COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0), COALESCE(r.background_text_color, 0), COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0), d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, d.attributes::text, d.thumbs::text FROM star_gift_catalog c JOIN star_gift_catalog_revisions r ON r.id = c.active_revision_id LEFT JOIN star_gift_collectible_revisions cr ON cr.id = c.collectible_revision_id AND cr.status = 'published' JOIN documents d ON d.id = r.document_id` func (s *StarGiftStore) Catalog(ctx context.Context) ([]domain.StarGift, error) { rows, err := s.db.Query(ctx, starGiftCatalogSelect+` WHERE c.enabled ORDER BY c.sort_order, c.gift_id`) if err != nil { return nil, fmt.Errorf("list star gift catalog: %w", err) } defer rows.Close() out := make([]domain.StarGift, 0) for rows.Next() { gift, err := scanCatalogGift(rows) if err != nil { return nil, err } out = append(out, gift) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate star gift catalog: %w", err) } return out, nil } func (s *StarGiftStore) CatalogGift(ctx context.Context, giftID int64) (domain.StarGift, bool, error) { if giftID <= 0 { return domain.StarGift{}, false, nil } gift, err := scanCatalogGift(s.db.QueryRow(ctx, starGiftCatalogSelect+` WHERE c.enabled AND c.gift_id = $1`, giftID)) if errors.Is(err, pgx.ErrNoRows) { return domain.StarGift{}, false, nil } if err != nil { return domain.StarGift{}, false, err } return gift, true, nil } func (s *StarGiftStore) CatalogRevision(ctx context.Context, revisionID int64) (domain.StarGift, bool, error) { if revisionID <= 0 { return domain.StarGift{}, false, nil } gift, err := scanCatalogGift(s.db.QueryRow(ctx, ` SELECT r.gift_id, r.id, r.stars, r.convert_stars, r.title, r.limited, r.sold_out, r.birthday, r.require_premium, r.limited_per_user, r.peer_color_available, r.auction, c.availability_remains, r.availability_total, c.availability_resale, c.first_sale_date, c.last_sale_date, c.resell_min_stars, COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0), r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round, r.auction_start_date, r.upgrade_variants, r.background_center_color IS NOT NULL, COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0), COALESCE(r.background_text_color, 0), COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0), d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, d.attributes::text, d.thumbs::text FROM star_gift_catalog_revisions r JOIN star_gift_catalog c ON c.gift_id = r.gift_id LEFT JOIN star_gift_collectible_revisions cr ON cr.id = c.collectible_revision_id AND cr.status = 'published' JOIN documents d ON d.id = r.document_id WHERE r.id = $1`, revisionID)) if errors.Is(err, pgx.ErrNoRows) { return domain.StarGift{}, false, nil } if err != nil { return domain.StarGift{}, false, err } return gift, true, nil } func scanCatalogGift(row rowScanner) (domain.StarGift, error) { var gift domain.StarGift var attrsJSON, thumbsJSON string var releasedByType string var releasedByID int64 var hasBackground bool var background domain.StarGiftBackground if err := row.Scan( &gift.ID, &gift.RevisionID, &gift.Stars, &gift.ConvertStars, &gift.Title, &gift.Limited, &gift.SoldOut, &gift.Birthday, &gift.RequirePremium, &gift.LimitedPerUser, &gift.PeerColorAvailable, &gift.Auction, &gift.AvailabilityRemains, &gift.AvailabilityTotal, &gift.AvailabilityResale, &gift.FirstSaleDate, &gift.LastSaleDate, &gift.ResellMinStars, &releasedByType, &releasedByID, &gift.PerUserTotal, &gift.LockedUntilDate, &gift.AuctionSlug, &gift.GiftsPerRound, &gift.AuctionStartDate, &gift.UpgradeVariants, &hasBackground, &background.CenterColor, &background.EdgeColor, &background.TextColor, &gift.UpgradeStars, &gift.UpgradeTotal, &gift.UpgradeIssued, &gift.Sticker.ID, &gift.Sticker.AccessHash, &gift.Sticker.FileReference, &gift.Sticker.Date, &gift.Sticker.MimeType, &gift.Sticker.Size, &gift.Sticker.DCID, &attrsJSON, &thumbsJSON, ); err != nil { return domain.StarGift{}, err } if releasedByType != "" && releasedByID > 0 { gift.ReleasedBy = domain.Peer{Type: domain.PeerType(releasedByType), ID: releasedByID} } if hasBackground { gift.Background = &background } if gift.LimitedPerUser { gift.PerUserRemains = gift.PerUserTotal } attrs, err := decodeDocumentAttributes(attrsJSON) if err != nil { return domain.StarGift{}, fmt.Errorf("decode star gift document attributes: %w", err) } thumbs, err := decodePhotoSizes(thumbsJSON) if err != nil { return domain.StarGift{}, fmt.Errorf("decode star gift document thumbs: %w", err) } gift.Sticker.Attributes = attrs gift.Sticker.Thumbs = thumbs if !gift.Sticker.IsSticker() || gift.Sticker.MimeType != "application/x-tgsticker" { return domain.StarGift{}, fmt.Errorf("invalid star gift revision %d document %d", gift.RevisionID, gift.Sticker.ID) } return gift, nil } func (s *StarGiftStore) CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) { if write.Stars <= 0 || write.ConvertStars < 0 || write.ConvertStars > write.Stars || write.Document.ID <= 0 || !write.Document.IsSticker() || write.Document.MimeType != "application/x-tgsticker" || len(write.Animation.JSON) == 0 || len(write.Animation.SHA256) != 32 { return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftInvalid } var entry domain.StarGiftCatalogEntry err := withTx(ctx, s.db, "create star gift catalog revision", func(tx pgx.Tx) error { giftID := write.GiftID var revisionID int64 if err := tx.QueryRow(ctx, `SELECT nextval('star_gift_catalog_revision_id_seq')`).Scan(&revisionID); err != nil { return fmt.Errorf("allocate star gift revision id: %w", err) } revision := 1 if giftID == 0 { if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended('star_gift_catalog', 0))`); err != nil { return fmt.Errorf("lock star gift catalog capacity: %w", err) } var catalogCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_catalog`).Scan(&catalogCount); err != nil { return fmt.Errorf("count star gift catalog: %w", err) } if catalogCount >= domain.MaxStarGiftCatalogSize { return domain.ErrStarGiftCatalogFull } if err := tx.QueryRow(ctx, `SELECT nextval('star_gift_catalog_gift_id_seq')`).Scan(&giftID); err != nil { return fmt.Errorf("allocate star gift id: %w", err) } if _, err := tx.Exec(ctx, ` INSERT INTO star_gift_catalog ( gift_id, active_revision_id, enabled, sort_order, availability_remains, availability_resale, resell_min_stars, first_sale_date, last_sale_date ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, giftID, revisionID, write.Enabled, write.SortOrder, write.AvailabilityRemains, write.AvailabilityResale, write.ResellMinStars, write.FirstSaleDate, write.LastSaleDate); err != nil { return fmt.Errorf("insert star gift catalog: %w", err) } } else { var ignored int64 if err := tx.QueryRow(ctx, ` SELECT active_revision_id FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, giftID).Scan(&ignored); err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.ErrStarGiftNotFound } return fmt.Errorf("lock star gift catalog: %w", err) } if err := tx.QueryRow(ctx, ` SELECT COALESCE(MAX(revision), 0) + 1 FROM star_gift_catalog_revisions WHERE gift_id = $1`, giftID).Scan(&revision); err != nil { return fmt.Errorf("lock star gift catalog: %w", err) } } media := NewMediaStore(tx) if err := media.PutDocument(ctx, write.Document); err != nil { return fmt.Errorf("put star gift document: %w", err) } if err := media.PutFileBlob(ctx, write.Blob); err != nil { return fmt.Errorf("put star gift blob: %w", err) } if _, err := tx.Exec(ctx, ` INSERT INTO star_gift_catalog_revisions ( id, gift_id, revision, title, stars, convert_stars, document_id, animation_json, animation_sha256, source_name, source_format, width, height, frame_rate, in_point, out_point, created_by, command_id, official_gift_id, source_manifest_sha256, official_source, limited, sold_out, birthday, require_premium, limited_per_user, peer_color_available, auction, availability_total, released_by_peer_type, released_by_peer_id, per_user_total, locked_until_date, auction_slug, gifts_per_round, auction_start_date, upgrade_variants, background_center_color, background_edge_color, background_text_color ) VALUES ( $1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18, NULLIF($19::bigint,0),$20,$21::jsonb,$22,$23,$24,$25,$26,$27,$28,$29, $30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40 )`, revisionID, giftID, revision, write.Title, write.Stars, write.ConvertStars, write.Document.ID, string(write.Animation.JSON), write.Animation.SHA256, write.Animation.SourceName, string(write.Animation.SourceFormat), write.Animation.Width, write.Animation.Height, write.Animation.FrameRate, write.Animation.InPoint, write.Animation.OutPoint, write.Actor, write.CommandID, write.OfficialGiftID, nullableSHA256(write.SourceManifestSHA256), nullableOfficialGiftJSON(write.OfficialSourceJSON), write.Limited, write.SoldOut, write.Birthday, write.RequirePremium, write.LimitedPerUser, write.PeerColorAvailable, write.Auction, write.AvailabilityTotal, nullableStarGiftPeerType(write.ReleasedBy), nullableStarGiftPeerID(write.ReleasedBy), write.PerUserTotal, write.LockedUntilDate, write.AuctionSlug, write.GiftsPerRound, write.AuctionStartDate, write.UpgradeVariants, nullableBackgroundColor(write.Background, "center"), nullableBackgroundColor(write.Background, "edge"), nullableBackgroundColor(write.Background, "text"), ); err != nil { return fmt.Errorf("insert star gift revision: %w", err) } if write.GiftID != 0 { if _, err := tx.Exec(ctx, ` UPDATE star_gift_catalog SET active_revision_id=$2, enabled=$3, sort_order=$4, availability_remains=$5, availability_resale=$6, resell_min_stars=$7, first_sale_date=$8, last_sale_date=$9, updated_at=now() WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder, write.AvailabilityRemains, write.AvailabilityResale, write.ResellMinStars, write.FirstSaleDate, write.LastSaleDate); err != nil { return fmt.Errorf("activate star gift revision: %w", err) } } write.GiftID = giftID var err error entry, err = catalogEntryByID(ctx, tx, giftID) return err }) if err != nil { return domain.StarGiftCatalogEntry{}, err } return entry, nil } func nullableStarGiftPeerType(peer domain.Peer) any { if peer.ID <= 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) { return nil } return string(peer.Type) } func nullableStarGiftPeerID(peer domain.Peer) any { if nullableStarGiftPeerType(peer) == nil { return nil } return peer.ID } func nullableBackgroundColor(background *domain.StarGiftBackground, component string) any { if background == nil { return nil } switch component { case "center": return background.CenterColor case "edge": return background.EdgeColor default: return background.TextColor } } func (s *StarGiftStore) CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) { var result domain.StarGiftCatalogBundleResult err := withTx(ctx, s.db, "create star gift catalog bundle", func(tx pgx.Tx) error { nested := NewStarGiftStore(tx) entry, err := nested.CreateCatalogRevision(ctx, write.Catalog) if err != nil { return err } result.Catalog = entry if write.Collectible != nil { collectibleWrite := *write.Collectible collectibleWrite.GiftID = entry.Gift.ID revision, err := nested.PublishCollectibleRevision(ctx, collectibleWrite) if err != nil { return err } result.Collectible = &revision entry, err = catalogEntryByID(ctx, tx, entry.Gift.ID) if err != nil { return err } result.Catalog = entry } return nil }) return result, err } func (s *StarGiftStore) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) { tag, err := s.db.Exec(ctx, ` UPDATE star_gift_catalog SET enabled=$2, updated_at=now() WHERE gift_id=$1 AND enabled IS DISTINCT FROM $2`, giftID, enabled) if err != nil { return false, fmt.Errorf("set star gift enabled: %w", err) } if tag.RowsAffected() > 0 { return true, nil } var exists bool if err := s.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM star_gift_catalog WHERE gift_id=$1)`, giftID).Scan(&exists); err != nil { return false, fmt.Errorf("check star gift enabled target: %w", err) } if !exists { return false, domain.ErrStarGiftNotFound } return false, nil } func (s *StarGiftStore) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) { tag, err := s.db.Exec(ctx, ` UPDATE star_gift_catalog SET sort_order=$2, updated_at=now() WHERE gift_id=$1 AND sort_order IS DISTINCT FROM $2`, giftID, sortOrder) if err != nil { return false, fmt.Errorf("set star gift sort order: %w", err) } if tag.RowsAffected() > 0 { return true, nil } var exists bool if err := s.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM star_gift_catalog WHERE gift_id=$1)`, giftID).Scan(&exists); err != nil { return false, fmt.Errorf("check star gift sort target: %w", err) } if !exists { return false, domain.ErrStarGiftNotFound } return false, nil } func (s *StarGiftStore) AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error) { var raw []byte err := s.db.QueryRow(ctx, ` SELECT r.animation_json::text FROM star_gift_catalog c JOIN star_gift_catalog_revisions r ON r.id = c.active_revision_id WHERE c.gift_id=$1`, giftID).Scan(&raw) if errors.Is(err, pgx.ErrNoRows) { return nil, false, nil } if err != nil { return nil, false, fmt.Errorf("get star gift animation: %w", err) } return raw, true, nil } func catalogEntryByID(ctx context.Context, db sqlcgen.DBTX, giftID int64) (domain.StarGiftCatalogEntry, error) { row := db.QueryRow(ctx, ` SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title, r.limited, r.sold_out, r.birthday, r.require_premium, r.limited_per_user, r.peer_color_available, r.auction, c.availability_remains, r.availability_total, c.availability_resale, c.first_sale_date, c.last_sale_date, c.resell_min_stars, COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0), r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round, r.auction_start_date, r.upgrade_variants, r.background_center_color IS NOT NULL, COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0), COALESCE(r.background_text_color, 0), COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0), d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, d.attributes::text, d.thumbs::text, c.enabled, c.sort_order, r.revision, r.source_name, r.source_format, r.animation_sha256, r.width, r.height, r.frame_rate, r.created_by, c.updated_at, (SELECT COUNT(*) FROM peer_star_gifts p WHERE p.gift_id=c.gift_id) FROM star_gift_catalog c JOIN star_gift_catalog_revisions r ON r.id=c.active_revision_id LEFT JOIN star_gift_collectible_revisions cr ON cr.id=c.collectible_revision_id AND cr.status='published' JOIN documents d ON d.id=r.document_id WHERE c.gift_id=$1`, giftID) var entry domain.StarGiftCatalogEntry var attrsJSON, thumbsJSON, sourceFormat string var releasedByType string var releasedByID int64 var hasBackground bool var background domain.StarGiftBackground if err := row.Scan( &entry.Gift.ID, &entry.Gift.RevisionID, &entry.Gift.Stars, &entry.Gift.ConvertStars, &entry.Gift.Title, &entry.Gift.Limited, &entry.Gift.SoldOut, &entry.Gift.Birthday, &entry.Gift.RequirePremium, &entry.Gift.LimitedPerUser, &entry.Gift.PeerColorAvailable, &entry.Gift.Auction, &entry.Gift.AvailabilityRemains, &entry.Gift.AvailabilityTotal, &entry.Gift.AvailabilityResale, &entry.Gift.FirstSaleDate, &entry.Gift.LastSaleDate, &entry.Gift.ResellMinStars, &releasedByType, &releasedByID, &entry.Gift.PerUserTotal, &entry.Gift.LockedUntilDate, &entry.Gift.AuctionSlug, &entry.Gift.GiftsPerRound, &entry.Gift.AuctionStartDate, &entry.Gift.UpgradeVariants, &hasBackground, &background.CenterColor, &background.EdgeColor, &background.TextColor, &entry.Gift.UpgradeStars, &entry.Gift.UpgradeTotal, &entry.Gift.UpgradeIssued, &entry.Gift.Sticker.ID, &entry.Gift.Sticker.AccessHash, &entry.Gift.Sticker.FileReference, &entry.Gift.Sticker.Date, &entry.Gift.Sticker.MimeType, &entry.Gift.Sticker.Size, &entry.Gift.Sticker.DCID, &attrsJSON, &thumbsJSON, &entry.Enabled, &entry.SortOrder, &entry.Revision, &entry.SourceName, &sourceFormat, &entry.AnimationSHA, &entry.Width, &entry.Height, &entry.FrameRate, &entry.CreatedBy, &entry.UpdatedAt, &entry.ReceivedCount, ); err != nil { return domain.StarGiftCatalogEntry{}, err } if releasedByType != "" && releasedByID > 0 { entry.Gift.ReleasedBy = domain.Peer{Type: domain.PeerType(releasedByType), ID: releasedByID} } if hasBackground { entry.Gift.Background = &background } if entry.Gift.LimitedPerUser { entry.Gift.PerUserRemains = entry.Gift.PerUserTotal } attrs, err := decodeDocumentAttributes(attrsJSON) if err != nil { return domain.StarGiftCatalogEntry{}, err } thumbs, err := decodePhotoSizes(thumbsJSON) if err != nil { return domain.StarGiftCatalogEntry{}, err } entry.Gift.Sticker.Attributes = attrs entry.Gift.Sticker.Thumbs = thumbs entry.SourceFormat = domain.StarGiftAnimationFormat(sourceFormat) entry.AnimationSize = entry.Gift.Sticker.Size return entry, nil } func (s *StarGiftStore) Create(ctx context.Context, gift domain.SavedStarGift) (int64, error) { if !validSavedStarGift(gift) { return 0, domain.ErrStarGiftInvalid } var id int64 err := s.db.QueryRow(ctx, ` WITH next_id AS ( SELECT nextval(pg_get_serial_sequence('public.peer_star_gifts', 'id'))::bigint AS id ) INSERT INTO peer_star_gifts (id, owner_peer_type, owner_peer_id, from_user_id, gift_id, catalog_revision_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, prepaid_upgrade_stars, prepaid_upgrade_hash, gift_num, message) SELECT next_id.id, $1,$2,$3,$4,$5,$6, CASE WHEN $1 = 'channel' AND $7::bigint = 0 THEN next_id.id ELSE $7::bigint END, $8,$9,$10,false,$11,$12,$13,$14,$15 FROM next_id RETURNING id`, string(gift.Owner.Type), gift.Owner.ID, gift.FromUserID, gift.GiftID, gift.RevisionID, gift.MsgID, gift.SavedID, gift.Date, gift.NameHidden, gift.Unsaved, gift.ConvertStars, gift.PrepaidUpgradeStars, gift.PrepaidUpgradeHash, gift.GiftNum, gift.Message).Scan(&id) if err != nil { return 0, fmt.Errorf("create star gift: %w", err) } return id, nil } func (s *StarGiftStore) ListByOwner(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) { return s.ListByOwnerFiltered(ctx, domain.SavedStarGiftFilter{ Owner: owner, ExcludeUnsaved: excludeUnsaved, Offset: offset, Limit: limit, }) } func (s *StarGiftStore) ListByOwnerFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) { owner, offset, limit := filter.Owner, filter.Offset, filter.Limit if !validStarGiftOwner(owner) { return domain.SavedStarGiftPage{}, nil } if limit <= 0 || limit > domain.MaxSavedStarGiftsLimit { limit = domain.MaxSavedStarGiftsLimit } joins := ` JOIN star_gift_catalog c ON c.gift_id = p.gift_id LEFT JOIN star_gift_collectible_revisions acr ON acr.id = c.collectible_revision_id AND acr.status = 'published'` conditions := []string{"p.owner_peer_type = $1", "p.owner_peer_id = $2", "p.lifecycle_status = 'active'"} args := []any{string(owner.Type), owner.ID} if filter.ExcludeUnsaved { conditions = append(conditions, "NOT p.unsaved") } if filter.ExcludeSaved { conditions = append(conditions, "p.unsaved") } if filter.ExcludeUnique { conditions = append(conditions, "p.unique_gift_id IS NULL") } // telesrv ordinary catalog gifts are currently unlimited. Unique gifts are // collectibles and therefore survive exclude_unlimited. if filter.ExcludeUnlimited { conditions = append(conditions, "p.unique_gift_id IS NOT NULL") } upgradable := `(p.unique_gift_id IS NULL AND acr.id IS NOT NULL AND acr.upgrade_stars > 0 AND acr.issued < acr.supply_total)` if filter.ExcludeUpgradable { conditions = append(conditions, "NOT "+upgradable) } if filter.ExcludeUnupgradable { conditions = append(conditions, upgradable) } if filter.CollectionID > 0 { args = append(args, filter.CollectionID) conditions = append(conditions, fmt.Sprintf(`EXISTS ( SELECT 1 FROM star_gift_collection_items ci JOIN star_gift_collections cc ON cc.collection_id = ci.collection_id WHERE ci.saved_gift_id = p.id AND ci.collection_id = $%d AND cc.owner_peer_type = p.owner_peer_type AND cc.owner_peer_id = p.owner_peer_id)`, len(args))) } where := strings.Join(conditions, " AND ") countQuery := `SELECT COUNT(*) FROM peer_star_gifts p ` + joins + ` WHERE ` + where var total int if err := s.db.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { return domain.SavedStarGiftPage{}, fmt.Errorf("count star gifts: %w", err) } page := domain.SavedStarGiftPage{Count: total} profileOrder := filter.CollectionID == 0 if cursor, ok := domain.DecodeSavedStarGiftListCursor(offset); ok { if profileOrder && cursor.PinnedOrder > 0 { args = append(args, cursor.PinnedOrder, cursor.ID) where += fmt.Sprintf(` AND ( p.pinned_order = 0 OR p.pinned_order > $%d OR (p.pinned_order = $%d AND p.id < $%d) )`, len(args)-1, len(args)-1, len(args)) } else { args = append(args, cursor.ID) if profileOrder { where += fmt.Sprintf(" AND p.pinned_order = 0 AND p.id < $%d", len(args)) } else { where += fmt.Sprintf(" AND p.id < $%d", len(args)) } } } orderBy := "ORDER BY p.id DESC" if profileOrder { orderBy = "ORDER BY (p.pinned_order = 0), p.pinned_order, p.id DESC" } args = append(args, limit+1) limitPlaceholder := len(args) rows, err := s.db.Query(ctx, ` SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id, p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num, p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at, p.drop_original_details_stars, p.can_craft_at, p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order, COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id) FROM star_gift_collection_items i JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id), ARRAY[]::integer[]) FROM peer_star_gifts p `+joins+` WHERE `+where+` `+orderBy+` LIMIT $`+fmt.Sprint(limitPlaceholder), args...) if err != nil { return domain.SavedStarGiftPage{}, fmt.Errorf("list star gifts: %w", err) } defer rows.Close() gifts := make([]domain.SavedStarGift, 0, limit) for rows.Next() { g, err := scanSavedStarGift(rows) if err != nil { return domain.SavedStarGiftPage{}, err } gifts = append(gifts, g) } if err := rows.Err(); err != nil { return domain.SavedStarGiftPage{}, fmt.Errorf("iterate star gifts: %w", err) } if len(gifts) > limit { gifts = gifts[:limit] last := gifts[len(gifts)-1] pinnedOrder := 0 if profileOrder { pinnedOrder = last.PinnedOrder } page.NextOffset = domain.EncodeSavedStarGiftListCursor(pinnedOrder, last.ID) } page.Gifts = gifts return page, nil } func (s *StarGiftStore) ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) { if !validStarGiftOwner(owner) || len(refs) > domain.MaxStarGiftCollectionItems { return nil, domain.ErrStarGiftCollectibleInvalid } if len(refs) == 0 { return []int64{}, nil } type resolveKey struct { value int64 slug string } keys := make([]resolveKey, 0, len(refs)) values := make([]int64, 0, len(refs)) slugs := make([]string, 0, len(refs)) seenKeys := make(map[string]struct{}, len(refs)) for _, ref := range refs { if ref.Owner != owner || !ref.Valid() { return nil, domain.ErrStarGiftNotFound } if ref.Slug != "" { slug := strings.ToLower(strings.TrimSpace(ref.Slug)) key := "slug:" + slug if _, duplicate := seenKeys[key]; duplicate { return nil, domain.ErrStarGiftCollectibleInvalid } seenKeys[key] = struct{}{} keys = append(keys, resolveKey{slug: slug}) slugs = append(slugs, slug) continue } value := int64(ref.MsgID) if owner.Type == domain.PeerTypeChannel { value = ref.SavedID } key := fmt.Sprintf("id:%d", value) if _, duplicate := seenKeys[key]; duplicate { return nil, domain.ErrStarGiftCollectibleInvalid } seenKeys[key] = struct{}{} keys = append(keys, resolveKey{value: value}) values = append(values, value) } query := `SELECT p.saved_id::bigint, COALESCE(u.slug, ''), p.id FROM peer_star_gifts p LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active' AND (p.saved_id::bigint=ANY($3::bigint[]) OR u.slug=ANY($4::text[]))` if owner.Type == domain.PeerTypeUser { query = `SELECT ref.msg_id, COALESCE(u.slug, ''), p.id FROM peer_star_gifts p LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id CROSS JOIN LATERAL ( SELECT p.msg_id::bigint AS msg_id UNION ALL SELECT r.msg_id::bigint FROM star_gift_user_message_refs r WHERE r.saved_gift_id=p.id AND r.owner_user_id=p.owner_peer_id ) ref WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active' AND (ref.msg_id=ANY($3::bigint[]) OR u.slug=ANY($4::text[]))` } rows, err := s.db.Query(ctx, query, string(owner.Type), owner.ID, values, slugs) if err != nil { return nil, fmt.Errorf("resolve saved star gifts: %w", err) } defer rows.Close() resolvedValues := make(map[int64]int64, len(values)) resolvedSlugs := make(map[string]int64, len(slugs)) for rows.Next() { var primaryValue, id int64 var slug string if err := rows.Scan(&primaryValue, &slug, &id); err != nil { return nil, fmt.Errorf("scan resolved saved star gift: %w", err) } if existing := resolvedValues[primaryValue]; existing != 0 && existing != id { return nil, domain.ErrStarGiftCollectibleInvalid } resolvedValues[primaryValue] = id if slug != "" { if existing := resolvedSlugs[slug]; existing != 0 && existing != id { return nil, domain.ErrStarGiftCollectibleInvalid } resolvedSlugs[slug] = id } } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate resolved saved star gifts: %w", err) } out := make([]int64, 0, len(keys)) seenIDs := make(map[int64]struct{}, len(keys)) for _, key := range keys { id := resolvedValues[key.value] if key.slug != "" { id = resolvedSlugs[key.slug] } if id == 0 { return nil, domain.ErrStarGiftNotFound } if _, duplicate := seenIDs[id]; duplicate { return nil, domain.ErrStarGiftCollectibleInvalid } seenIDs[id] = struct{}{} out = append(out, id) } return out, nil } func (s *StarGiftStore) GetByRef(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) { if !ref.Valid() { return domain.SavedStarGift{}, false, nil } where, args := savedStarGiftRefWhere(ref) row := s.db.QueryRow(ctx, ` SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id, p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num, p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at, p.drop_original_details_stars, p.can_craft_at, p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order, COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id) FROM star_gift_collection_items i JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id), ARRAY[]::integer[]) FROM peer_star_gifts p WHERE `+where, args...) g, err := scanSavedStarGift(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.SavedStarGift{}, false, nil } return domain.SavedStarGift{}, false, err } return g, true, nil } func (s *StarGiftStore) CountByOwner(ctx context.Context, owner domain.Peer) (int, error) { if !validStarGiftOwner(owner) { return 0, nil } var n int if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND lifecycle_status='active' AND NOT unsaved`, string(owner.Type), owner.ID).Scan(&n); err != nil { return 0, fmt.Errorf("count star gifts: %w", err) } return n, nil } func (s *StarGiftStore) SetUnsaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) { if !ref.Valid() { return false, domain.ErrStarGiftNotFound } changed := false err := withTx(ctx, s.db, "set star gift unsaved", func(tx pgx.Tx) error { if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(ref.Owner)); err != nil { return err } where, args := savedStarGiftRefWhere(ref) var savedID int64 var pinnedOrder int err := tx.QueryRow(ctx, `SELECT id,pinned_order FROM peer_star_gifts WHERE `+where+` AND lifecycle_status='active' FOR UPDATE`, args...).Scan(&savedID, &pinnedOrder) if errors.Is(err, pgx.ErrNoRows) { return nil } if err != nil { return err } if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET unsaved=$2,pinned_order=CASE WHEN $2 THEN 0 ELSE pinned_order END WHERE id=$1`, savedID, unsaved); err != nil { return err } if unsaved && pinnedOrder > 0 { // The positive-order unique index is immediate. Move the bounded // vector one vacant slot at a time so no transient duplicate order // can be observed by PostgreSQL. for order := pinnedOrder + 1; order <= domain.MaxPinnedStarGifts; order++ { if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$4 WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND pinned_order=$3`, string(ref.Owner.Type), ref.Owner.ID, order, order-1); err != nil { return err } } } changed = true return nil }) if err != nil { return false, fmt.Errorf("set star gift unsaved: %w", err) } return changed, nil } func (s *StarGiftStore) MarkConverted(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) { if !ref.Valid() { return domain.SavedStarGift{}, domain.ErrStarGiftNotFound } out := domain.SavedStarGift{} err := withTx(ctx, s.db, "convert star gift", func(tx pgx.Tx) error { if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(ref.Owner)); err != nil { return fmt.Errorf("lock star gift owner collections: %w", err) } where, args := savedStarGiftRefWhere(ref) row := tx.QueryRow(ctx, ` SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id, p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars, p.prepaid_upgrade_hash, p.gift_num, p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at, p.drop_original_details_stars, p.can_craft_at, p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order, COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id) FROM star_gift_collection_items i JOIN star_gift_collections c ON c.collection_id=i.collection_id WHERE i.saved_gift_id=p.id), ARRAY[]::integer[]) FROM peer_star_gifts p WHERE `+where+` FOR UPDATE`, args...) g, err := scanSavedStarGift(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.ErrStarGiftNotFound } return err } if g.Converted { return domain.ErrStarGiftAlreadyConverted } if g.UniqueGiftID != 0 { return domain.ErrStarGiftAlreadyUpgraded } if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET converted = true, lifecycle_status='converted', unsaved = true, pinned_order = 0 WHERE id = $1`, g.ID); err != nil { return fmt.Errorf("mark star gift converted: %w", err) } if err := removeSavedGiftFromCollections(ctx, tx, g.Owner, g.ID); err != nil { return err } g.Converted = true g.LifecycleStatus = domain.StarGiftLifecycleConverted g.Unsaved = true g.PinnedOrder = 0 g.CollectionIDs = nil out = g return nil }) if err != nil { return domain.SavedStarGift{}, err } return out, nil } func scanSavedStarGift(row rowScanner) (domain.SavedStarGift, error) { var g domain.SavedStarGift var ownerType string if err := row.Scan(&g.ID, &ownerType, &g.Owner.ID, &g.FromUserID, &g.GiftID, &g.RevisionID, &g.MsgID, &g.SavedID, &g.Date, &g.NameHidden, &g.Unsaved, &g.Converted, &g.ConvertStars, &g.PrepaidUpgradeStars, &g.PrepaidUpgradeHash, &g.GiftNum, &g.LifecycleStatus, &g.TransferStars, &g.CanExportAt, &g.CanTransferAt, &g.CanResellAt, &g.DropOriginalDetailsStars, &g.CanCraftAt, &g.Message, &g.UniqueGiftID, &g.UpgradeMsgID, &g.PinnedOrder, &g.CollectionIDs); err != nil { return domain.SavedStarGift{}, err } g.Owner.Type = domain.PeerType(ownerType) return g, nil } func savedStarGiftRefWhere(ref domain.SavedStarGiftRef) (string, []any) { args := []any{string(ref.Owner.Type), ref.Owner.ID} if ref.Slug != "" { args = append(args, strings.ToLower(strings.TrimSpace(ref.Slug))) return "owner_peer_type = $1 AND owner_peer_id = $2 AND unique_gift_id = (SELECT id FROM unique_star_gifts WHERE slug = $3)", args } switch ref.Owner.Type { case domain.PeerTypeChannel: args = append(args, ref.SavedID) return "owner_peer_type = $1 AND owner_peer_id = $2 AND saved_id = $3", args default: args = append(args, ref.MsgID) return `owner_peer_type = $1 AND owner_peer_id = $2 AND ( msg_id = $3 OR EXISTS ( SELECT 1 FROM star_gift_user_message_refs r WHERE r.saved_gift_id = id AND r.owner_user_id = $2 AND r.msg_id = $3 ))`, args } } func validSavedStarGift(g domain.SavedStarGift) bool { if g.GiftID == 0 || g.RevisionID == 0 || !validStarGiftOwner(g.Owner) { return false } switch g.Owner.Type { case domain.PeerTypeUser: return g.MsgID > 0 && g.SavedID == 0 case domain.PeerTypeChannel: return g.MsgID == 0 && g.SavedID >= 0 default: return false } } func validStarGiftOwner(owner domain.Peer) bool { return owner.ID != 0 && (owner.Type == domain.PeerTypeUser || owner.Type == domain.PeerTypeChannel) }