owpengram-server/internal/app/stargifts/service.go
2026-07-16 12:38:53 +08:00

413 lines
14 KiB
Go

// Package stargifts implements the durable Star Gift catalog and received-gift state.
package stargifts
import (
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"strings"
"sync"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// BlobBackend is the content-addressed media boundary used by the catalog importer.
type BlobBackend interface {
Name() string
Put(ctx context.Context, data []byte) (string, error)
Get(ctx context.Context, objectKey string) ([]byte, error)
}
type Service struct {
store store.StarGiftStore
upgrades store.StarGiftUpgradeStore
blobs BlobBackend
dc int
mu sync.RWMutex
built bool
gifts []domain.StarGift
byID map[int64]domain.StarGift
hash int
}
type Option func(*Service)
func WithUpgradeStore(upgrades store.StarGiftUpgradeStore) Option {
return func(service *Service) { service.upgrades = upgrades }
}
func NewService(st store.StarGiftStore, blobs BlobBackend, dc int, opts ...Option) *Service {
service := &Service{store: st, blobs: blobs, dc: dc}
for _, opt := range opts {
opt(service)
}
return service
}
func (s *Service) ensureCatalog(ctx context.Context) error {
s.mu.RLock()
built := s.built
s.mu.RUnlock()
if built {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.built {
return nil
}
if s.store == nil {
return fmt.Errorf("star gift store is not configured")
}
gifts, err := s.store.Catalog(ctx)
if err != nil {
return err
}
s.gifts = gifts
s.byID = make(map[int64]domain.StarGift, len(gifts))
for _, gift := range gifts {
s.byID[gift.ID] = gift
}
s.hash = domain.StarGiftCatalogHash(gifts)
s.built = true
return nil
}
func (s *Service) Catalog(ctx context.Context) ([]domain.StarGift, error) {
if err := s.ensureCatalog(ctx); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
return append([]domain.StarGift(nil), s.gifts...), nil
}
func (s *Service) CatalogHash(ctx context.Context) (int, error) {
if err := s.ensureCatalog(ctx); err != nil {
return 0, err
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.hash, nil
}
func (s *Service) GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error) {
if err := s.ensureCatalog(ctx); err != nil {
return domain.StarGift{}, false, err
}
s.mu.RLock()
defer s.mu.RUnlock()
gift, ok := s.byID[id]
return gift, ok, nil
}
func (s *Service) GiftRevisionByID(ctx context.Context, revisionID int64) (domain.StarGift, bool, error) {
if s == nil || s.store == nil {
return domain.StarGift{}, false, nil
}
return s.store.CatalogRevision(ctx, revisionID)
}
// InvalidateStarGiftCatalog implements the shared PostgreSQL read-model listener boundary.
func (s *Service) InvalidateStarGiftCatalog() {
if s == nil {
return
}
s.mu.Lock()
s.built = false
s.gifts = nil
s.byID = nil
s.hash = 0
s.mu.Unlock()
}
func (s *Service) FlushStarGiftCatalog() { s.InvalidateStarGiftCatalog() }
func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
if s == nil || s.store == nil || s.blobs == nil {
return domain.StarGiftCatalogEntry{}, fmt.Errorf("star gift catalog importer is not configured")
}
write.Title = strings.TrimSpace(write.Title)
if write.Stars <= 0 || write.ConvertStars < 0 || write.ConvertStars > write.Stars ||
write.Animation.Width != 512 || write.Animation.Height != 512 || len(write.Animation.TGS) == 0 ||
len([]rune(write.Title)) > domain.MaxStarGiftTitleRunes {
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftInvalid
}
objectKey, err := s.blobs.Put(ctx, write.Animation.TGS)
if err != nil {
return domain.StarGiftCatalogEntry{}, fmt.Errorf("store star gift animation: %w", err)
}
documentID, err := randomPositiveInt64()
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
fileReference := make([]byte, 16)
if _, err := rand.Read(fileReference); err != nil {
return domain.StarGiftCatalogEntry{}, fmt.Errorf("generate star gift file reference: %w", err)
}
write.Document = domain.Document{
ID: documentID,
AccessHash: accessHash,
FileReference: fileReference,
Date: int(time.Now().Unix()),
MimeType: "application/x-tgsticker",
Size: int64(len(write.Animation.TGS)),
DCID: s.dc,
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
{Kind: domain.DocAttrSticker, Alt: "🎁"},
{Kind: domain.DocAttrFilename, FileName: "gift.tgs"},
},
}
write.Blob = domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", documentID),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(write.Animation.TGS)),
SHA256: append([]byte(nil), write.Animation.SHA256...),
MimeType: "application/x-tgsticker",
}
entry, err := s.store.CreateCatalogRevision(ctx, write)
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
s.InvalidateStarGiftCatalog()
return entry, nil
}
func (s *Service) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
changed, err := s.store.SetCatalogEnabled(ctx, giftID, enabled)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return changed, err
}
func (s *Service) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) {
changed, err := s.store.SetCatalogSortOrder(ctx, giftID, sortOrder)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return changed, err
}
func (s *Service) AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error) {
return s.store.AnimationJSON(ctx, giftID)
}
func (s *Service) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
if s == nil || s.store == nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible store is not configured")
}
revision, err := s.store.PublishCollectibleRevision(ctx, write)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return revision, err
}
// CreateCollectibleRevision materializes the normalized model/pattern animations and then
// atomically publishes the complete immutable attribute pool. Callers must pass animations
// produced by PrepareAnimation; partial revisions are never exposed to clients.
func (s *Service) CreateCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
if s == nil || s.store == nil || s.blobs == nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible importer is not configured")
}
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
if err := domain.ValidateStarGiftCollectibleDraft(write); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
materialize := func(attributes []domain.StarGiftCollectibleAttribute) error {
for i := range attributes {
animation := attributes[i].Animation
if animation == nil {
return domain.ErrStarGiftCollectibleInvalid
}
objectKey, err := s.blobs.Put(ctx, animation.TGS)
if err != nil {
return fmt.Errorf("store collectible %s animation: %w", attributes[i].Kind, err)
}
documentID, err := randomPositiveInt64()
if err != nil {
return err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return err
}
fileReference := make([]byte, 16)
if _, err := rand.Read(fileReference); err != nil {
return fmt.Errorf("generate collectible file reference: %w", err)
}
attributes[i].Document = &domain.Document{
ID: documentID, AccessHash: accessHash, FileReference: fileReference,
Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker",
Size: int64(len(animation.TGS)), DCID: s.dc,
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
{Kind: domain.DocAttrSticker, Alt: "🎁"},
{Kind: domain.DocAttrFilename, FileName: string(attributes[i].Kind) + ".tgs"},
},
}
attributes[i].Blob = &domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", documentID), Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey, Size: int64(len(animation.TGS)),
SHA256: append([]byte(nil), animation.SHA256...), MimeType: "application/x-tgsticker",
}
}
return nil
}
if err := materialize(write.Models); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if err := materialize(write.Patterns); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
return s.PublishCollectibleRevision(ctx, write)
}
func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
if s == nil || s.store == nil || giftID <= 0 {
return domain.StarGiftUpgradePreview{}, false, nil
}
revision, ok, err := s.store.ActiveCollectibleRevision(ctx, giftID)
if err != nil || !ok || !revision.Published {
return domain.StarGiftUpgradePreview{}, false, err
}
return domain.StarGiftUpgradePreview{
GiftID: giftID, Revision: revision.Revision, UpgradeStars: revision.UpgradeStars, SupplyTotal: revision.SupplyTotal,
Issued: revision.Issued, Models: revision.Models, Patterns: revision.Patterns, Backdrops: revision.Backdrops,
SlugPrefix: revision.SlugPrefix,
}, true, nil
}
func (s *Service) CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
if s == nil || s.store == nil || len(giftIDs) == 0 {
return map[int64]domain.StarGiftCollectibleAvailability{}, nil
}
return s.store.CollectibleAvailability(ctx, giftIDs)
}
func (s *Service) CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
if s == nil || s.store == nil {
return nil, false, nil
}
return s.store.CollectibleAnimationJSON(ctx, giftID, kind, attributeID)
}
func (s *Service) UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) {
if s == nil || s.store == nil {
return domain.UniqueStarGift{}, false, nil
}
return s.store.UniqueBySlug(ctx, slug)
}
func (s *Service) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) {
if s == nil || s.store == nil {
return domain.UniqueStarGift{}, false, nil
}
return s.store.UniqueByID(ctx, uniqueGiftID)
}
func (s *Service) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) {
if s == nil || s.store == nil || len(uniqueGiftIDs) == 0 {
return map[int64]domain.UniqueStarGift{}, nil
}
return s.store.UniqueByIDs(ctx, uniqueGiftIDs)
}
func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
if s == nil || s.upgrades == nil {
return domain.StarGiftUpgradeResult{}, fmt.Errorf("star gift upgrade store is not configured")
}
result, err := s.upgrades.UpgradeStarGift(ctx, req)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
func (s *Service) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
return s.store.ListCollections(ctx, owner)
}
func (s *Service) CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) {
return s.store.CreateCollection(ctx, owner, title, savedGiftIDs)
}
func (s *Service) UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) {
return s.store.UpdateCollection(ctx, owner, collectionID, patch)
}
func (s *Service) DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error) {
return s.store.DeleteCollection(ctx, owner, collectionID)
}
func (s *Service) ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error {
return s.store.ReorderCollections(ctx, owner, collectionIDs)
}
func (s *Service) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error {
return s.store.SetPinned(ctx, owner, savedGiftIDs)
}
func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
return s.store.Create(ctx, gift)
}
func (s *Service) ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
return s.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
Owner: owner, ExcludeUnsaved: excludeUnsaved, Offset: offset, Limit: limit,
})
}
func (s *Service) ListSavedFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) {
offset := filter.Offset
if len(offset) > domain.MaxStarGiftsOffsetBytes {
filter.Offset = ""
}
if filter.Limit <= 0 || filter.Limit > domain.MaxSavedStarGiftsLimit {
filter.Limit = domain.MaxSavedStarGiftsLimit
}
return s.store.ListByOwnerFiltered(ctx, filter)
}
func (s *Service) GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
return s.store.GetByRef(ctx, ref)
}
func (s *Service) ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) {
return s.store.ResolveSavedIDs(ctx, owner, refs)
}
func (s *Service) CountSaved(ctx context.Context, owner domain.Peer) (int, error) {
return s.store.CountByOwner(ctx, owner)
}
func (s *Service) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) {
return s.store.SetUnsaved(ctx, ref, unsaved)
}
func (s *Service) Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
return s.store.MarkConverted(ctx, ref)
}
func randomPositiveInt64() (int64, error) {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {
return 0, fmt.Errorf("generate star gift id: %w", err)
}
id := int64(binary.LittleEndian.Uint64(raw[:]) & 0x7fffffffffffffff)
if id == 0 {
id = 1
}
return id, nil
}