feat: sync collectible star gifts

This commit is contained in:
A 2026-07-16 12:38:53 +08:00
parent 47fcf0ea41
commit 5ecf4e912d
64 changed files with 7559 additions and 403 deletions

View file

@ -1,97 +0,0 @@
package files
import (
"context"
"fmt"
"telesrv/internal/domain"
)
// Star gift 目录:从已 seed 的 animated_emoji 集按 emoticon 精选贴纸文档合成(复用文档行与
// blob不复制字节镜像 EnsureDefaultEmojiStatusSet。目录是静态的不入库。
// 礼物 ID 取明显隔离的常量段避免撞键。
const starGiftIDBase int64 = 8_888_000_000_000_000
type starGiftSeed struct {
id int64
emoticon string
stars int64
title string
}
// starGiftSeeds 是固定礼物目录emoticon 需在 animated_emoji 集里,否则该礼物被跳过)。
// convert_stars = starsv1 全额转换,视作用新购 Stars 买入)。
var starGiftSeeds = []starGiftSeed{
{starGiftIDBase + 1, "❤", 15, "Heart"},
{starGiftIDBase + 2, "\U0001f382", 50, "Cake"}, // 🎂
{starGiftIDBase + 3, "\U0001f389", 100, "Party"}, // 🎉
{starGiftIDBase + 4, "\U0001f525", 250, "Fire"}, // 🔥
{starGiftIDBase + 5, "\U0001f3c6", 500, "Trophy"}, // 🏆
{starGiftIDBase + 6, "\U0001f48e", 1000, "Diamond"}, // 💎
{starGiftIDBase + 7, "\U0001f680", 2500, "Rocket"}, // 🚀
}
// BuildStarGiftCatalog 合成可购买礼物目录:解析每个 seed emoticon 的贴纸文档,跳过未 seed 的。
// animated_emoji 未 seed 时返回空目录(客户端显示空礼物面板,购买流仍可对已知 gift_id 工作)。
func (s *Service) BuildStarGiftCatalog(ctx context.Context) ([]domain.StarGift, error) {
source, found, err := s.media.GetStickerSetBySystemKey(ctx, "animated_emoji")
if err != nil {
return nil, fmt.Errorf("lookup animated_emoji set for star gifts: %w", err)
}
if !found || len(source.Packs) == 0 {
return nil, nil
}
byEmoticon := make(map[string]int64, len(source.Packs))
for _, pack := range source.Packs {
key := normalizeStatusEmoticon(pack.Emoticon)
if key == "" || len(pack.DocumentIDs) == 0 {
continue
}
if _, ok := byEmoticon[key]; !ok {
byEmoticon[key] = pack.DocumentIDs[0]
}
}
// 收集要加载的文档 id去重
docIDs := make([]int64, 0, len(starGiftSeeds))
chosen := make([]starGiftSeed, 0, len(starGiftSeeds))
seen := make(map[int64]struct{})
for _, seed := range starGiftSeeds {
id, ok := byEmoticon[normalizeStatusEmoticon(seed.emoticon)]
if !ok || id == 0 {
continue
}
chosen = append(chosen, seed)
if _, dup := seen[id]; !dup {
seen[id] = struct{}{}
docIDs = append(docIDs, id)
}
}
if len(chosen) == 0 {
return nil, nil
}
docs, err := s.media.GetDocuments(ctx, docIDs)
if err != nil {
return nil, fmt.Errorf("load star gift sticker documents: %w", err)
}
docByID := make(map[int64]domain.Document, len(docs))
for _, d := range docs {
docByID[d.ID] = d
}
catalog := make([]domain.StarGift, 0, len(chosen))
for _, seed := range chosen {
id := byEmoticon[normalizeStatusEmoticon(seed.emoticon)]
doc, ok := docByID[id]
if !ok || doc.ID == 0 {
continue
}
catalog = append(catalog, domain.StarGift{
ID: seed.id,
Stars: seed.stars,
ConvertStars: seed.stars,
Title: seed.title,
Sticker: doc,
})
}
return catalog, nil
}

View file

@ -0,0 +1,190 @@
package stargifts
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"math"
"path/filepath"
"strings"
"time"
"telesrv/internal/domain"
)
// PrepareAnimation normalizes a .tgs or plain Lottie JSON (.json/.lottie) into the
// single canonical pair used by both the Telegram download path and admin preview.
func (s *Service) PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
return prepareAnimation(fileName, data)
}
func prepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
fileName = strings.TrimSpace(filepath.Base(fileName))
ext := strings.ToLower(filepath.Ext(fileName))
format := domain.StarGiftAnimationLottie
var rawJSON []byte
if ext == ".tgs" || isGzip(data) {
format = domain.StarGiftAnimationTGS
if int64(len(data)) == 0 || int64(len(data)) > domain.MaxStarGiftTGSBytes {
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
}
var err error
rawJSON, err = decompressSingleTGS(data)
if err != nil {
return domain.StarGiftAnimation{}, err
}
} else {
if ext != ".json" && ext != ".lottie" {
return domain.StarGiftAnimation{}, fmt.Errorf("%w: expected .tgs, .json or plain .lottie", domain.ErrStarGiftFileInvalid)
}
if int64(len(data)) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes {
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
}
rawJSON = data
}
normalized, meta, err := normalizeAndValidateLottie(rawJSON)
if err != nil {
return domain.StarGiftAnimation{}, err
}
tgs, err := gzipLottie(normalized)
if err != nil || int64(len(tgs)) > domain.MaxStarGiftTGSBytes {
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
}
sum := sha256.Sum256(tgs)
return domain.StarGiftAnimation{
SourceName: fileName,
SourceFormat: format,
JSON: normalized,
TGS: tgs,
SHA256: append([]byte(nil), sum[:]...),
Width: meta.W,
Height: meta.H,
FrameRate: meta.FrameRate,
InPoint: meta.InPoint,
OutPoint: meta.OutPoint,
}, nil
}
type lottieMetadata struct {
Version string `json:"v"`
W int `json:"w"`
H int `json:"h"`
FrameRate float64 `json:"fr"`
InPoint float64 `json:"ip"`
OutPoint float64 `json:"op"`
Layers []json.RawMessage `json:"layers"`
Assets []json.RawMessage `json:"assets"`
}
func normalizeAndValidateLottie(data []byte) ([]byte, lottieMetadata, error) {
data = bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}))
if len(data) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes || !json.Valid(data) {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
var root any
if err := dec.Decode(&root); err != nil {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
if _, ok := root.(map[string]any); !ok {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
if containsLottieExpression(root) {
return nil, lottieMetadata{}, fmt.Errorf("%w: expressions are not allowed", domain.ErrStarGiftFileInvalid)
}
var meta lottieMetadata
if err := json.Unmarshal(data, &meta); err != nil {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
frameSpan := meta.OutPoint - meta.InPoint
if meta.Version == "" || meta.W != 512 || meta.H != 512 ||
math.IsNaN(meta.FrameRate) || math.IsInf(meta.FrameRate, 0) || meta.FrameRate <= 0 || meta.FrameRate > domain.MaxStarGiftAnimationFrameRate ||
math.IsNaN(meta.InPoint) || math.IsInf(meta.InPoint, 0) || meta.InPoint < 0 ||
math.IsNaN(meta.OutPoint) || math.IsInf(meta.OutPoint, 0) || meta.OutPoint <= meta.InPoint ||
frameSpan > meta.FrameRate*domain.MaxStarGiftAnimationSeconds || len(meta.Layers) == 0 {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
// Telegram animated stickers are self-contained. Reject remote or embedded image assets;
// pre-composition assets with only an id/layers payload remain valid.
for _, raw := range meta.Assets {
var asset map[string]json.RawMessage
if json.Unmarshal(raw, &asset) != nil {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
for _, key := range []string{"p", "u"} {
if value := asset[key]; len(value) > 0 && string(value) != `""` && string(value) != "null" {
return nil, lottieMetadata{}, fmt.Errorf("%w: external assets are not allowed", domain.ErrStarGiftFileInvalid)
}
}
}
var compact bytes.Buffer
if err := json.Compact(&compact, data); err != nil || int64(compact.Len()) > domain.MaxStarGiftLottieBytes {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
return compact.Bytes(), meta, nil
}
func containsLottieExpression(value any) bool {
switch node := value.(type) {
case map[string]any:
for key, child := range node {
if key == "x" {
if expression, ok := child.(string); ok && strings.TrimSpace(expression) != "" {
return true
}
}
if containsLottieExpression(child) {
return true
}
}
case []any:
for _, child := range node {
if containsLottieExpression(child) {
return true
}
}
}
return false
}
func isGzip(data []byte) bool {
return len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b
}
func decompressSingleTGS(data []byte) ([]byte, error) {
reader := bytes.NewReader(data)
gz, err := gzip.NewReader(reader)
if err != nil {
return nil, domain.ErrStarGiftFileInvalid
}
gz.Multistream(false)
raw, readErr := io.ReadAll(io.LimitReader(gz, domain.MaxStarGiftLottieBytes+1))
closeErr := gz.Close()
if readErr != nil || closeErr != nil || int64(len(raw)) > domain.MaxStarGiftLottieBytes || reader.Len() != 0 {
return nil, domain.ErrStarGiftFileInvalid
}
return raw, nil
}
func gzipLottie(data []byte) ([]byte, error) {
var out bytes.Buffer
gz, err := gzip.NewWriterLevel(&out, gzip.BestCompression)
if err != nil {
return nil, err
}
gz.Header.ModTime = time.Unix(0, 0)
gz.Header.OS = 255
if _, err := gz.Write(data); err != nil {
_ = gz.Close()
return nil, err
}
if err := gz.Close(); err != nil {
return nil, err
}
return out.Bytes(), nil
}

View file

@ -0,0 +1,93 @@
package stargifts
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
const validGiftLottie = `{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"layers":[{"ty":4,"nm":"gift"}],"assets":[]}`
func TestPrepareAnimationNormalizesLottieAndTGS(t *testing.T) {
fromJSON, err := prepareAnimation("gift.lottie", []byte(" \n"+validGiftLottie+"\n"))
if err != nil {
t.Fatalf("prepare lottie: %v", err)
}
if fromJSON.SourceFormat != domain.StarGiftAnimationLottie || len(fromJSON.TGS) == 0 || fromJSON.Width != 512 || fromJSON.Height != 512 {
t.Fatalf("prepared lottie = %+v", fromJSON)
}
fromTGS, err := prepareAnimation("gift.tgs", fromJSON.TGS)
if err != nil {
t.Fatalf("prepare tgs: %v", err)
}
if fromTGS.SourceFormat != domain.StarGiftAnimationTGS || string(fromTGS.JSON) != string(fromJSON.JSON) || hex.EncodeToString(fromTGS.SHA256) != hex.EncodeToString(fromJSON.SHA256) {
t.Fatalf("tgs round trip differs: json=%v hash=%x/%x", string(fromTGS.JSON) == string(fromJSON.JSON), fromTGS.SHA256, fromJSON.SHA256)
}
}
func TestPrepareAnimationRejectsExternalAssetAndExpression(t *testing.T) {
for name, raw := range map[string]string{
"external": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":512,"h":512,"layers":[{}],"assets":[{"p":"https://example.test/x.png"}]}`,
"expression": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":512,"h":512,"layers":[{"ks":{"o":{"x":"time*10"}}}]}`,
"wrong-size": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":256,"h":256,"layers":[{}]}`,
"frame-rate": `{"v":"5.7","fr":121,"ip":0,"op":30,"w":512,"h":512,"layers":[{}]}`,
"duration": `{"v":"5.7","fr":30,"ip":0,"op":901,"w":512,"h":512,"layers":[{}]}`,
} {
t.Run(name, func(t *testing.T) {
if _, err := prepareAnimation("gift.json", []byte(raw)); !errors.Is(err, domain.ErrStarGiftFileInvalid) {
t.Fatalf("err=%v, want ErrStarGiftFileInvalid", err)
}
})
}
}
type testGiftBlob struct{ data map[string][]byte }
func (b *testGiftBlob) Name() string { return "localfs" }
func (b *testGiftBlob) Put(_ context.Context, data []byte) (string, error) {
sum := sha256.Sum256(data)
key := hex.EncodeToString(sum[:])
b.data[key] = append([]byte(nil), data...)
return key, nil
}
func (b *testGiftBlob) Get(_ context.Context, key string) ([]byte, error) {
return append([]byte(nil), b.data[key]...), nil
}
func TestCreateCatalogRevisionPreservesHistoricalRevision(t *testing.T) {
ctx := context.Background()
store := memory.NewStarGiftStore()
svc := NewService(store, &testGiftBlob{data: map[string][]byte{}}, 2)
animation, err := svc.PrepareAnimation("gift.json", []byte(validGiftLottie))
if err != nil {
t.Fatal(err)
}
first, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 1, Title: "First", Animation: animation,
})
if err != nil {
t.Fatalf("create first: %v", err)
}
second, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
GiftID: first.Gift.ID, Stars: 80, ConvertStars: 40, Enabled: true, SortOrder: 1, Title: "Second", Animation: animation,
})
if err != nil {
t.Fatalf("create second: %v", err)
}
current, found, _ := svc.GiftByID(ctx, first.Gift.ID)
if !found || current.RevisionID != second.Gift.RevisionID || current.Stars != 80 {
t.Fatalf("current=%+v found=%v", current, found)
}
historical, found, _ := svc.GiftRevisionByID(ctx, first.Gift.RevisionID)
if !found || historical.Stars != 50 || historical.Title != "First" {
t.Fatalf("historical=%+v found=%v", historical, found)
}
if _, err := svc.SetCatalogEnabled(ctx, first.Gift.ID+999, false); !errors.Is(err, domain.ErrStarGiftNotFound) {
t.Fatalf("disable missing err=%v, want ErrStarGiftNotFound", err)
}
}

View file

@ -1,124 +1,413 @@
// Package stargifts 实现 Star 礼物应用服务:礼物目录(从 seed 合成、懒加载缓存)+ peer 收到的
// 礼物实例 CRUD。扣费/退款/服务消息投递由 rpc 层编排(复用 Stars 账本 + SendPrivateText
// 本层只管目录与持久化。
// 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"
)
// CatalogProvider 合成礼物目录app/files 实现)。
type CatalogProvider interface {
BuildStarGiftCatalog(ctx context.Context) ([]domain.StarGift, error)
// 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)
}
// Service 是 Star 礼物应用服务。
type Service struct {
store store.StarGiftStore
catalog CatalogProvider
store store.StarGiftStore
upgrades store.StarGiftUpgradeStore
blobs BlobBackend
dc int
mu sync.Mutex
mu sync.RWMutex
built bool
gifts []domain.StarGift
byID map[int64]domain.StarGift
hash int
}
// NewService 创建 Star 礼物服务。
func NewService(st store.StarGiftStore, catalog CatalogProvider) *Service {
return &Service{store: st, catalog: catalog}
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
}
// ensureCatalog 懒加载并缓存目录(静态数据,构建一次)。
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
}
gifts, err := s.catalog.BuildStarGiftCatalog(ctx)
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 _, g := range gifts {
s.byID[g.ID] = g
for _, gift := range gifts {
s.byID[gift.ID] = gift
}
s.hash = domain.StarGiftCatalogHash(gifts)
s.built = true
return nil
}
// Catalog 返回礼物目录。
func (s *Service) Catalog(ctx context.Context) ([]domain.StarGift, error) {
if err := s.ensureCatalog(ctx); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.StarGift, len(s.gifts))
copy(out, s.gifts)
return out, nil
s.mu.RLock()
defer s.mu.RUnlock()
return append([]domain.StarGift(nil), s.gifts...), nil
}
// CatalogHash 返回目录 hashgetStarGifts NotModified 判定)。
func (s *Service) CatalogHash(ctx context.Context) (int, error) {
if err := s.ensureCatalog(ctx); err != nil {
return 0, err
}
s.mu.Lock()
defer s.mu.Unlock()
s.mu.RLock()
defer s.mu.RUnlock()
return s.hash, nil
}
// GiftByID 返回目录中指定礼物,不存在返回 ok=false。
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.Lock()
defer s.mu.Unlock()
g, ok := s.byID[id]
return g, ok, nil
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)
}
// RecordSavedGift 持久化一条收到的礼物实例,返回行 id。
func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
return s.store.Create(ctx, gift)
}
// ListSaved 分页返回某 owner 收到的礼物。
func (s *Service) ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
if len(offset) > domain.MaxStarGiftsOffsetBytes {
offset = ""
}
if limit <= 0 || limit > domain.MaxSavedStarGiftsLimit {
limit = domain.MaxSavedStarGiftsLimit
}
return s.store.ListByOwner(ctx, owner, excludeUnsaved, offset, limit)
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)
}
// GetSaved 按协议引用取礼物实例。
func (s *Service) GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
return s.store.GetByRef(ctx, ref)
}
// CountSaved 返回某 owner 展示在资料的礼物数full.stargifts_count
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)
}
// ToggleSaved 切换礼物在资料的展示saveStarGift
func (s *Service) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) {
return s.store.SetUnsaved(ctx, ref, unsaved)
}
// Convert 把礼物标记为已转换convertStarGift返回该行供调用方据 ConvertStars 入账。
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
}

View file

@ -9,40 +9,28 @@ import (
"telesrv/internal/store/memory"
)
type fakeCatalog struct {
gifts []domain.StarGift
calls int
}
func (f *fakeCatalog) BuildStarGiftCatalog(_ context.Context) ([]domain.StarGift, error) {
f.calls++
return f.gifts, nil
}
func newTestService(gifts []domain.StarGift) (*Service, *fakeCatalog) {
cat := &fakeCatalog{gifts: gifts}
return NewService(memory.NewStarGiftStore(), cat), cat
func newTestService(gifts []domain.StarGift) (*Service, *memory.StarGiftStore) {
st := memory.NewStarGiftStore()
st.SeedCatalog(gifts)
return NewService(st, nil, 2), st
}
func TestCatalogCachedAndHash(t *testing.T) {
gifts := []domain.StarGift{
{ID: 1, Stars: 15, ConvertStars: 15, Title: "Heart"},
{ID: 2, Stars: 50, ConvertStars: 50, Title: "Cake"},
{ID: 1, RevisionID: 11, Stars: 15, ConvertStars: 15, Title: "Heart"},
{ID: 2, RevisionID: 12, Stars: 50, ConvertStars: 50, Title: "Cake"},
}
svc, cat := newTestService(gifts)
svc, _ := newTestService(gifts)
ctx := context.Background()
got, err := svc.Catalog(ctx)
if err != nil || len(got) != 2 {
t.Fatalf("catalog = %d err %v, want 2", len(got), err)
}
// 再取一次不重新构建(缓存)
// 再取一次命中进程内目录缓存
if _, err := svc.Catalog(ctx); err != nil {
t.Fatalf("catalog#2: %v", err)
}
if cat.calls != 1 {
t.Fatalf("BuildStarGiftCatalog called %d times, want 1 (cached)", cat.calls)
}
hash, err := svc.CatalogHash(ctx)
if err != nil || hash != domain.StarGiftCatalogHash(gifts) {
t.Fatalf("hash = %d err %v, want %d", hash, err, domain.StarGiftCatalogHash(gifts))
@ -61,11 +49,15 @@ func TestSavedGiftLifecycle(t *testing.T) {
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
id, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: owner, FromUserID: 2002, GiftID: 1, MsgID: 50, Date: 1700000000, ConvertStars: 15,
Owner: owner, FromUserID: 2002, GiftID: 1, RevisionID: 11, MsgID: 50, Date: 1700000000, ConvertStars: 15,
})
if err != nil || id == 0 {
t.Fatalf("RecordSavedGift = %d err %v", id, err)
}
collection, err := svc.CreateCollection(ctx, owner, "Inbox", []int64{id})
if err != nil || len(collection.GiftIDs) != 1 {
t.Fatalf("CreateCollection = %+v err %v", collection, err)
}
page, err := svc.ListSaved(ctx, owner, false, "", 100)
if err != nil || len(page.Gifts) != 1 || page.Count != 1 {
@ -99,6 +91,11 @@ func TestSavedGiftLifecycle(t *testing.T) {
if len(after.Gifts) != 0 {
t.Fatalf("list after convert = %d, want 0", len(after.Gifts))
}
collections, err := svc.ListCollections(ctx, owner)
if err != nil || len(collections) != 1 || len(collections[0].GiftIDs) != 0 ||
collections[0].Hash != domain.StarGiftCollectionHash("Inbox", nil) {
t.Fatalf("collection after convert = %+v err %v, want empty membership and refreshed hash", collections, err)
}
// 重复转换被拒。
if _, err := svc.Convert(ctx, ref); !errors.Is(err, domain.ErrStarGiftAlreadyConverted) {
t.Fatalf("double convert err = %v, want ErrStarGiftAlreadyConverted", err)
@ -111,7 +108,7 @@ func TestChannelSavedGiftAllocatesSavedIDWithoutMessage(t *testing.T) {
owner := domain.Peer{Type: domain.PeerTypeChannel, ID: 2001}
savedID, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: owner, FromUserID: 1001, GiftID: 1, MsgID: 0, SavedID: 0,
Owner: owner, FromUserID: 1001, GiftID: 1, RevisionID: 11, MsgID: 0, SavedID: 0,
Date: 1700000000, ConvertStars: 15,
})
if err != nil || savedID == 0 {
@ -133,7 +130,7 @@ func TestSavedGiftPagination(t *testing.T) {
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
for i := 0; i < 5; i++ {
if _, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: owner, FromUserID: 2002, GiftID: 1, MsgID: 100 + i, Date: 1700000000 + i, ConvertStars: 15,
Owner: owner, FromUserID: 2002, GiftID: 1, RevisionID: 11, MsgID: 100 + i, Date: 1700000000 + i, ConvertStars: 15,
}); err != nil {
t.Fatalf("record#%d: %v", i, err)
}