fix: sync harden star gift upgrade projections

This commit is contained in:
iamxvbaba 2026-08-01 00:12:01 +08:00
parent ee74d941bb
commit 35d3908660
17 changed files with 772 additions and 40 deletions

View file

@ -166,4 +166,10 @@ func TestCreateCatalogBundleMaterializesPublishableCollectibleDocuments(t *testi
if !pattern.Attributes[1].TextColor {
t.Fatalf("pattern render attribute = %+v, want text_color", pattern.Attributes[1])
}
preview, found, err := svc.CollectiblePreviewSample(ctx, result.Catalog.Gift.ID)
if err != nil || !found || len(preview.Models) != 2 || len(preview.Patterns) != 2 || len(preview.Backdrops) != 2 ||
preview.Models[0].Animation == nil || len(preview.Models[0].Animation.JSON) != 0 ||
preview.Patterns[0].Animation == nil || len(preview.Patterns[0].Animation.JSON) != 0 {
t.Fatalf("collectible preview sample = found:%v err:%v value:%+v", found, err, preview)
}
}

View file

@ -442,10 +442,22 @@ func collectibleDocumentAttributes(kind domain.StarGiftCollectibleAttributeKind)
}
func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
return s.collectiblePreview(ctx, giftID, 0)
}
// CollectiblePreviewSample returns the small randomized working set consumed by official-client
// upgrade rollers. The complete published pool remains available through CollectiblePreview for
// payments.getStarGiftUpgradeAttributes and the admin editor.
func (s *Service) CollectiblePreviewSample(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
const attributesPerKind = 3
return s.collectiblePreview(ctx, giftID, attributesPerKind)
}
func (s *Service) collectiblePreview(ctx context.Context, giftID int64, samplePerKind int) (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)
revision, ok, err := s.store.ActiveCollectibleProjection(ctx, giftID, samplePerKind)
if err != nil || !ok || !revision.Published {
return domain.StarGiftUpgradePreview{}, false, err
}
@ -899,9 +911,11 @@ func (s *Service) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs
func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
if gift.UniqueGiftID == 0 && gift.PrepaidUpgradeStars == 0 && gift.PrepaidUpgradeHash == "" && s.store != nil {
if revision, ok, err := s.store.ActiveCollectibleRevision(ctx, gift.GiftID); err != nil {
availability, err := s.store.CollectibleAvailability(ctx, []int64{gift.GiftID})
if err != nil {
return 0, err
} else if ok && revision.Published && revision.Issued < revision.SupplyTotal {
}
if current, ok := availability[gift.GiftID]; ok && current.Issued < current.SupplyTotal {
var token [32]byte
if _, err := rand.Read(token[:]); err != nil {
return 0, fmt.Errorf("generate prepaid star gift upgrade hash: %w", err)

View file

@ -243,7 +243,7 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
Peers: tgPeerList(shared.Peers),
}
case domain.MessageServiceActionStarGift:
return tgMessageActionStarGift(m.ServiceAction.StarGift)
return tgMessageActionStarGiftForViewer(m.ServiceAction.StarGift, msg.OwnerUserID)
case domain.MessageServiceActionStarGiftUnique:
return tgMessageActionStarGiftUnique(m.ServiceAction.StarGiftUnique)
case domain.MessageServiceActionStarGiftOffer:

View file

@ -1118,6 +1118,7 @@ type GiftsService interface {
GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error)
GiftRevisionByID(ctx context.Context, revisionID int64) (domain.StarGift, bool, error)
CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
CollectiblePreviewSample(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error)
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error)

View file

@ -233,7 +233,7 @@ func (r *Router) onPaymentsGetStarGiftUpgradePreview(ctx context.Context, giftID
if giftID <= 0 || r.deps.Gifts == nil {
return nil, starGiftInvalidErr()
}
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, giftID)
preview, found, err := r.deps.Gifts.CollectiblePreviewSample(ctx, giftID)
if err != nil {
return nil, internalErr()
}

View file

@ -876,7 +876,7 @@ func (r *Router) tgSavedStarGiftsResponse(ctx context.Context, viewerUserID int6
if err != nil {
return nil, err
}
projected := tgSavedStarGifts(gifts, catalog, availability)
projected := tgSavedStarGifts(viewerUserID, gifts, catalog, availability)
out := &tg.PaymentsSavedStarGifts{
Count: count,
Gifts: projected,
@ -990,6 +990,30 @@ func tgStarGift(g domain.StarGift) *tg.StarGift {
}
// tgMessageActionStarGift 把礼物服务消息载荷投影为 messageActionStarGift。
func tgMessageActionStarGiftForViewer(in *domain.MessageStarGiftAction, viewerUserID int64) tg.MessageActionClass {
if in == nil {
return &tg.MessageActionEmpty{}
}
ownerUserID := in.PeerUserID
if ownerUserID == 0 && in.To.Type == domain.PeerTypeUser {
ownerUserID = in.To.ID
}
if ownerUserID <= 0 || viewerUserID <= 0 {
return tgMessageActionStarGift(in)
}
projected := *in
if viewerUserID == ownerUserID {
// The owner upgrades through InputSavedStarGift. Exposing the separate
// prepayment hash makes DrKLO prefer the wrong invoice family and use the
// private dialog peer (the sender) as the alleged gift owner.
projected.PrepaidUpgradeHash = ""
} else {
// Telegram defines messageActionStarGift.can_upgrade as receiver-only.
projected.CanUpgrade = false
}
return tgMessageActionStarGift(&projected)
}
func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionClass {
if in == nil {
return &tg.MessageActionEmpty{}
@ -1088,7 +1112,7 @@ func (r *Router) resolveStarGiftCatalog(ctx context.Context, gifts []domain.Save
}
// tgSavedStarGifts 把已收到礼物实例投影为 []tg.SavedStarGift。
func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.StarGift, availability map[int64]domain.StarGiftCollectibleAvailability) []tg.SavedStarGift {
func tgSavedStarGifts(viewerUserID int64, gifts []domain.SavedStarGift, catalog map[int64]domain.StarGift, availability map[int64]domain.StarGiftCollectibleAvailability) []tg.SavedStarGift {
out := make([]tg.SavedStarGift, 0, len(gifts))
for _, g := range gifts {
item := tg.SavedStarGift{
@ -1126,7 +1150,11 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
item.SetUpgradeStars(g.PrepaidUpgradeStars)
item.CanUpgrade = true
}
if g.PrepaidUpgradeHash != "" && g.PrepaidUpgradeStars == 0 && canIssue {
// This hash starts the separate "prepay someone else's upgrade"
// invoice. The owner must use InputSavedStarGift instead; otherwise
// DrKLO prefers the hash path and substitutes the private dialog peer.
ownerIsViewer := g.Owner.Type == domain.PeerTypeUser && g.Owner.ID == viewerUserID
if g.PrepaidUpgradeHash != "" && g.PrepaidUpgradeStars == 0 && canIssue && !ownerIsViewer {
item.SetPrepaidUpgradeHash(g.PrepaidUpgradeHash)
}
}

View file

@ -2,6 +2,7 @@ package rpc
import (
"context"
"fmt"
"testing"
"github.com/iamxvbaba/td/bin"
@ -304,7 +305,7 @@ func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleA
historical.ID: {UpgradeStars: 75, SupplyTotal: 500, Issued: 12},
}
projected := tgSavedStarGifts([]domain.SavedStarGift{saved}, map[int64]domain.StarGift{historical.RevisionID: historical}, availability)
projected := tgSavedStarGifts(0, []domain.SavedStarGift{saved}, map[int64]domain.StarGift{historical.RevisionID: historical}, availability)
if len(projected) != 1 || !projected[0].CanUpgrade {
t.Fatalf("saved gift = %#v, want current pool to make historical gift upgradable", projected)
}
@ -339,7 +340,7 @@ func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleA
}
availability[historical.ID] = domain.StarGiftCollectibleAvailability{UpgradeStars: 75, SupplyTotal: 500, Issued: 500}
soldOut := tgSavedStarGifts([]domain.SavedStarGift{saved}, map[int64]domain.StarGift{historical.RevisionID: historical}, availability)[0]
soldOut := tgSavedStarGifts(0, []domain.SavedStarGift{saved}, map[int64]domain.StarGift{historical.RevisionID: historical}, availability)[0]
if soldOut.CanUpgrade {
t.Fatal("sold-out collectible pool must not advertise upgrade")
}
@ -349,7 +350,7 @@ func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleA
t.Fatal("sold-out catalog projection must not expose upgrade_stars")
}
saved.PrepaidUpgradeStars = 75
soldOutPrepaid := tgSavedStarGifts([]domain.SavedStarGift{saved}, map[int64]domain.StarGift{historical.RevisionID: historical}, availability)[0]
soldOutPrepaid := tgSavedStarGifts(0, []domain.SavedStarGift{saved}, map[int64]domain.StarGift{historical.RevisionID: historical}, availability)[0]
if soldOutPrepaid.CanUpgrade {
t.Fatal("sold-out prepaid gift must not advertise an upgrade the aggregate will reject")
}
@ -375,7 +376,7 @@ func TestSavedStarGiftProjectionPreservesCollectibleLifecycle(t *testing.T) {
CanExportAt: exportAt, TransferStars: 25, CanTransferAt: transferAt, CanResellAt: resellAt,
DropOriginalDetailsStars: 30, CanCraftAt: readyAt,
}
projected := tgSavedStarGifts([]domain.SavedStarGift{saved}, nil, nil)
projected := tgSavedStarGifts(0, []domain.SavedStarGift{saved}, nil, nil)
if len(projected) != 1 {
t.Fatalf("saved lifecycle projection count = %d", len(projected))
}
@ -401,7 +402,7 @@ func TestSavedStarGiftProjectionPreservesCollectibleLifecycle(t *testing.T) {
}
}
assertLifecycle(t, projected[0])
zero := tgSavedStarGifts([]domain.SavedStarGift{{
zero := tgSavedStarGifts(0, []domain.SavedStarGift{{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 7102}, GiftID: giftID, RevisionID: revision,
MsgID: 45, Date: 101, UniqueGiftID: unique.ID, Unique: &unique,
}}, nil, nil)[0]
@ -427,7 +428,7 @@ func TestSavedStarGiftProjectionPreservesCollectibleLifecycle(t *testing.T) {
channelSaved.Owner = domain.Peer{Type: domain.PeerTypeChannel, ID: 8102}
channelSaved.MsgID = 0
channelSaved.SavedID = 51
channelProjected := tgSavedStarGifts([]domain.SavedStarGift{channelSaved}, nil, nil)[0]
channelProjected := tgSavedStarGifts(0, []domain.SavedStarGift{channelSaved}, nil, nil)[0]
if _, ok := channelProjected.GetCanCraftAt(); ok {
t.Fatal("channel can_craft_at must be absent until channel Craft is executable")
}
@ -584,6 +585,59 @@ func TestMessageStarGiftProjectionSeparatesPaidPriceFromPrepaidAmount(t *testing
}
}
func TestStarGiftPrepaidUpgradeProjectionIsViewerScoped(t *testing.T) {
const (
senderID = int64(7101)
ownerID = int64(7102)
giftID = int64(8101)
revision = int64(9101)
)
action := &domain.MessageStarGiftAction{
GiftID: giftID, PeerUserID: ownerID, To: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID},
CanUpgrade: true, PrepaidUpgradeHash: "prepaid-upgrade-hash-0123456789",
}
ownerMessage := domain.Message{
ID: 20, OwnerUserID: ownerID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID},
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift, StarGift: action,
}},
}
ownerAction := tgMessage(ownerMessage).(*tg.MessageService).Action.(*tg.MessageActionStarGift)
if !ownerAction.CanUpgrade {
t.Fatal("owner message lost receiver-only can_upgrade")
}
if hash, ok := ownerAction.GetPrepaidUpgradeHash(); ok || hash != "" {
t.Fatalf("owner message exposed prepaid hash %q set=%v", hash, ok)
}
senderMessage := ownerMessage
senderMessage.OwnerUserID = senderID
senderMessage.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: ownerID}
senderMessage.Out = true
senderAction := tgMessage(senderMessage).(*tg.MessageService).Action.(*tg.MessageActionStarGift)
if senderAction.CanUpgrade {
t.Fatal("sender message exposed receiver-only can_upgrade")
}
if hash, ok := senderAction.GetPrepaidUpgradeHash(); !ok || hash != action.PrepaidUpgradeHash {
t.Fatalf("sender message prepaid hash = %q set=%v", hash, ok)
}
saved := domain.SavedStarGift{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID}, GiftID: giftID, RevisionID: revision,
MsgID: 20, Date: 100, PrepaidUpgradeHash: action.PrepaidUpgradeHash,
}
catalog := map[int64]domain.StarGift{revision: {ID: giftID, RevisionID: revision}}
availability := map[int64]domain.StarGiftCollectibleAvailability{giftID: {UpgradeStars: 25, SupplyTotal: 10}}
ownerSaved := tgSavedStarGifts(ownerID, []domain.SavedStarGift{saved}, catalog, availability)[0]
if hash, ok := ownerSaved.GetPrepaidUpgradeHash(); ok || hash != "" {
t.Fatalf("owner saved gift exposed prepaid hash %q set=%v", hash, ok)
}
viewerSaved := tgSavedStarGifts(senderID, []domain.SavedStarGift{saved}, catalog, availability)[0]
if hash, ok := viewerSaved.GetPrepaidUpgradeHash(); !ok || hash != action.PrepaidUpgradeHash {
t.Fatalf("non-owner saved gift prepaid hash = %q set=%v", hash, ok)
}
}
func TestStarGiftUpgradeRPCReplaysCommittedReceiptAfterTerminalTransition(t *testing.T) {
r, sender, owner, gift := starGiftTestRouter(t)
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
@ -761,6 +815,76 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
}
}
func TestStarGiftUpgradePreviewBoundsRandomSampleWithoutShrinkingFullAttributes(t *testing.T) {
r, _, owner, gift := starGiftTestRouter(t)
ctx := WithUserID(context.Background(), owner.ID)
giftService, ok := r.deps.Gifts.(*appstargifts.Service)
if !ok {
t.Fatalf("gift service = %T", r.deps.Gifts)
}
models := make([]domain.StarGiftCollectibleAttribute, 0, 7)
patterns := make([]domain.StarGiftCollectibleAttribute, 0, 6)
backdrops := make([]domain.StarGiftCollectibleAttribute, 0, 6)
for i := 0; i < 6; i++ {
models = append(models, collectibleRPCAttribute(domain.StarGiftCollectibleModel, int64(8200+i), fmt.Sprintf("Model %d", i)))
patterns = append(patterns, collectibleRPCAttribute(domain.StarGiftCollectiblePattern, int64(8300+i), fmt.Sprintf("Pattern %d", i)))
backdrops = append(backdrops, collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, int64(8400+i), fmt.Sprintf("Backdrop %d", i)))
}
crafted := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8299, "Crafted")
crafted.Crafted = true
crafted.RarityKind = domain.StarGiftRarityLegendary
crafted.RarityPermille = 0
models = append(models, crafted)
if _, err := giftService.PublishCollectibleRevision(context.Background(), domain.StarGiftCollectibleWrite{
GiftID: gift.ID, UpgradeStars: 75, SupplyTotal: 500, SlugPrefix: "bounded-preview",
Models: models, Patterns: patterns, Backdrops: backdrops, Actor: "test", CommandID: "bounded-preview",
}); err != nil {
t.Fatalf("publish collectible pool: %v", err)
}
preview, err := r.onPaymentsGetStarGiftUpgradePreview(ctx, gift.ID)
if err != nil {
t.Fatalf("get bounded upgrade preview: %v", err)
}
counts := map[domain.StarGiftCollectibleAttributeKind]int{}
identities := map[string]struct{}{}
for _, attribute := range preview.SampleAttributes {
var kind domain.StarGiftCollectibleAttributeKind
var identity string
switch value := attribute.(type) {
case *tg.StarGiftAttributeModel:
kind = domain.StarGiftCollectibleModel
if value.Crafted {
t.Fatal("ordinary upgrade preview included crafted model")
}
identity = fmt.Sprintf("model:%d", value.Document.GetID())
case *tg.StarGiftAttributePattern:
kind = domain.StarGiftCollectiblePattern
identity = fmt.Sprintf("pattern:%d", value.Document.GetID())
case *tg.StarGiftAttributeBackdrop:
kind = domain.StarGiftCollectibleBackdrop
identity = fmt.Sprintf("backdrop:%d", value.BackdropID)
default:
t.Fatalf("preview attribute = %T", attribute)
}
if _, duplicate := identities[identity]; duplicate {
t.Fatalf("duplicate preview identity %q", identity)
}
identities[identity] = struct{}{}
counts[kind]++
}
if len(preview.SampleAttributes) != 9 || counts[domain.StarGiftCollectibleModel] != 3 ||
counts[domain.StarGiftCollectiblePattern] != 3 || counts[domain.StarGiftCollectibleBackdrop] != 3 {
t.Fatalf("bounded preview count = %d kinds=%v, want three per kind", len(preview.SampleAttributes), counts)
}
all, err := r.onPaymentsGetStarGiftUpgradeAttributes(ctx, gift.ID)
if err != nil || len(all.Attributes) != 19 {
t.Fatalf("complete upgrade attributes = %d err=%v, want 19", len(all.Attributes), err)
}
}
func TestStarGiftCollectionsCRUDFilterOrderAndPin(t *testing.T) {
r, _, owner, gift := starGiftTestRouter(t)
ctx := WithUserID(context.Background(), owner.ID)

View file

@ -2,6 +2,7 @@ package memory
import (
"context"
"math/rand/v2"
"sort"
"strings"
"sync"
@ -253,6 +254,20 @@ func (s *StarGiftStore) ActiveCollectibleRevision(_ context.Context, giftID int6
return cloneCollectibleRevision(revision), ok, nil
}
func (s *StarGiftStore) ActiveCollectibleProjection(_ context.Context, giftID int64, samplePerKind int) (domain.StarGiftCollectibleRevision, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
revision, ok := s.collectibles[giftID]
if !ok {
return domain.StarGiftCollectibleRevision{}, false, nil
}
projection := cloneCollectibleRevision(revision)
projection.Models = projectCollectibleAttributes(projection.Models, domain.StarGiftCollectibleModel, samplePerKind)
projection.Patterns = projectCollectibleAttributes(projection.Patterns, domain.StarGiftCollectiblePattern, samplePerKind)
projection.Backdrops = projectCollectibleAttributes(projection.Backdrops, domain.StarGiftCollectibleBackdrop, samplePerKind)
return projection, true, nil
}
func (s *StarGiftStore) CollectibleAvailability(_ context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
s.mu.Lock()
defer s.mu.Unlock()
@ -849,6 +864,34 @@ func cloneCollectibleRevision(in domain.StarGiftCollectibleRevision) domain.Star
return out
}
func projectCollectibleAttributes(in []domain.StarGiftCollectibleAttribute, kind domain.StarGiftCollectibleAttributeKind, samplePerKind int) []domain.StarGiftCollectibleAttribute {
out := in
if samplePerKind > 0 {
out = make([]domain.StarGiftCollectibleAttribute, 0, len(in))
for _, attribute := range in {
if attribute.RarityKind != domain.StarGiftRarityPermille || attribute.RarityPermille <= 0 ||
(kind == domain.StarGiftCollectibleModel && attribute.Crafted) {
continue
}
out = append(out, attribute)
}
for i := 0; i < len(out) && i < samplePerKind; i++ {
j := i + rand.IntN(len(out)-i)
out[i], out[j] = out[j], out[i]
}
if len(out) > samplePerKind {
out = out[:samplePerKind]
}
}
for i := range out {
if out[i].Animation != nil {
out[i].Animation.JSON = nil
out[i].Animation.TGS = nil
}
}
return out
}
func cloneStarGiftCollections(in []domain.StarGiftCollection) []domain.StarGiftCollection {
out := make([]domain.StarGiftCollection, len(in))
for i, collection := range in {

View file

@ -149,15 +149,9 @@ UPDATE star_gift_catalog SET collectible_revision_id=$2, updated_at=now() WHERE
}
func (s *StarGiftStore) ActiveCollectibleRevision(ctx context.Context, giftID int64) (domain.StarGiftCollectibleRevision, bool, error) {
var revisionID int64
err := s.db.QueryRow(ctx, `
SELECT collectible_revision_id FROM star_gift_catalog
WHERE gift_id=$1 AND collectible_revision_id IS NOT NULL`, giftID).Scan(&revisionID)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftCollectibleRevision{}, false, nil
}
if err != nil {
return domain.StarGiftCollectibleRevision{}, false, fmt.Errorf("get active collectible revision: %w", err)
revisionID, ok, err := activeCollectibleRevisionID(ctx, s.db, giftID)
if err != nil || !ok {
return domain.StarGiftCollectibleRevision{}, ok, err
}
revision, err := collectibleRevisionByID(ctx, s.db, revisionID)
if err != nil {
@ -166,6 +160,32 @@ WHERE gift_id=$1 AND collectible_revision_id IS NOT NULL`, giftID).Scan(&revisio
return revision, true, nil
}
func (s *StarGiftStore) ActiveCollectibleProjection(ctx context.Context, giftID int64, samplePerKind int) (domain.StarGiftCollectibleRevision, bool, error) {
revisionID, ok, err := activeCollectibleRevisionID(ctx, s.db, giftID)
if err != nil || !ok {
return domain.StarGiftCollectibleRevision{}, ok, err
}
revision, err := collectibleRevisionProjectionByID(ctx, s.db, revisionID, samplePerKind)
if err != nil {
return domain.StarGiftCollectibleRevision{}, false, err
}
return revision, true, nil
}
func activeCollectibleRevisionID(ctx context.Context, db sqlcgen.DBTX, giftID int64) (int64, bool, error) {
var revisionID int64
err := db.QueryRow(ctx, `
SELECT collectible_revision_id FROM star_gift_catalog
WHERE gift_id=$1 AND collectible_revision_id IS NOT NULL`, giftID).Scan(&revisionID)
if errors.Is(err, pgx.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, fmt.Errorf("get active collectible revision: %w", err)
}
return revisionID, true, nil
}
func (s *StarGiftStore) CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
out := make(map[int64]domain.StarGiftCollectibleAvailability, len(giftIDs))
if len(giftIDs) == 0 {
@ -195,6 +215,19 @@ WHERE c.gift_id=ANY($1) AND r.status='published'`, giftIDs)
}
func collectibleRevisionByID(ctx context.Context, db sqlcgen.DBTX, revisionID int64) (domain.StarGiftCollectibleRevision, error) {
return readCollectibleRevisionByID(ctx, db, revisionID, collectibleRevisionReadOptions{includeAnimationJSON: true})
}
func collectibleRevisionProjectionByID(ctx context.Context, db sqlcgen.DBTX, revisionID int64, samplePerKind int) (domain.StarGiftCollectibleRevision, error) {
return readCollectibleRevisionByID(ctx, db, revisionID, collectibleRevisionReadOptions{samplePerKind: samplePerKind})
}
type collectibleRevisionReadOptions struct {
includeAnimationJSON bool
samplePerKind int
}
func readCollectibleRevisionByID(ctx context.Context, db sqlcgen.DBTX, revisionID int64, options collectibleRevisionReadOptions) (domain.StarGiftCollectibleRevision, error) {
var revision domain.StarGiftCollectibleRevision
var status string
var publishedAt pgtype.Timestamptz
@ -213,40 +246,67 @@ FROM star_gift_collectible_revisions WHERE id=$1`, revisionID).Scan(
revision.PublishedAt = publishedAt.Time
}
var err error
if revision.Models, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectibleModel); err != nil {
if revision.Models, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectibleModel, options); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if revision.Patterns, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectiblePattern); err != nil {
if revision.Patterns, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectiblePattern, options); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if revision.Backdrops, err = listCollectibleBackdrops(ctx, db, revisionID); err != nil {
if revision.Backdrops, err = listCollectibleBackdrops(ctx, db, revisionID, options); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
return revision, nil
}
func listAnimatedCollectibleAttributes(ctx context.Context, db sqlcgen.DBTX, revisionID int64, kind domain.StarGiftCollectibleAttributeKind) ([]domain.StarGiftCollectibleAttribute, error) {
func listAnimatedCollectibleAttributes(ctx context.Context, db sqlcgen.DBTX, revisionID int64, kind domain.StarGiftCollectibleAttributeKind, options collectibleRevisionReadOptions) ([]domain.StarGiftCollectibleAttribute, error) {
table := "star_gift_collectible_models"
if kind == domain.StarGiftCollectiblePattern {
table = "star_gift_collectible_patterns"
} else if kind != domain.StarGiftCollectibleModel {
return nil, domain.ErrStarGiftCollectibleInvalid
}
craftedExpression := "false"
if kind == domain.StarGiftCollectibleModel {
craftedExpression = "a.crafted"
}
animationJSONExpression := "''::text"
if options.includeAnimationJSON {
animationJSONExpression = "a.animation_json::text"
}
prefix := ""
from := fmt.Sprintf("%s a JOIN documents d ON d.id=a.document_id", table)
args := []any{revisionID}
if options.samplePerKind > 0 {
extra := ""
if kind == domain.StarGiftCollectibleModel {
extra = " AND NOT crafted"
}
prefix = fmt.Sprintf(`WITH picked AS MATERIALIZED (
SELECT id FROM %s
WHERE collectible_revision_id=$1 AND rarity_kind='permille' AND rarity_permille > 0%s
ORDER BY random()
LIMIT $2
)`, table, extra)
from = fmt.Sprintf("picked p JOIN %s a ON a.id=p.id JOIN documents d ON d.id=a.document_id", table)
args = append(args, options.samplePerKind)
}
rows, err := db.Query(ctx, fmt.Sprintf(`
%s
SELECT a.id, a.collectible_revision_id, a.name, a.rarity_kind, COALESCE(a.rarity_permille,0),
%s, COALESCE(a.official_document_id,0), a.sort_order,
a.animation_json::text, a.animation_sha256, a.source_name, a.source_format,
%s, a.animation_sha256, a.source_name, a.source_format,
a.width, a.height, a.frame_rate, a.in_point, a.out_point,
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 %s a JOIN documents d ON d.id=a.document_id
WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`,
FROM %s
%s
ORDER BY a.sort_order, a.id`, prefix, craftedExpression, animationJSONExpression, from,
func() string {
if kind == domain.StarGiftCollectibleModel {
return "a.crafted"
if options.samplePerKind > 0 {
return ""
}
return "false"
}(), table), revisionID)
return "WHERE a.collectible_revision_id=$1"
}()), args...)
if err != nil {
return nil, fmt.Errorf("list collectible %s attributes: %w", kind, err)
}
@ -275,11 +335,29 @@ WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`,
return out, rows.Err()
}
func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64) ([]domain.StarGiftCollectibleAttribute, error) {
rows, err := db.Query(ctx, `
SELECT id, collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
text_color, rarity_kind, COALESCE(rarity_permille,0), sort_order
FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, revisionID)
func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64, options collectibleRevisionReadOptions) ([]domain.StarGiftCollectibleAttribute, error) {
prefix := ""
from := "star_gift_collectible_backdrops a"
where := "WHERE a.collectible_revision_id=$1"
args := []any{revisionID}
if options.samplePerKind > 0 {
prefix = `WITH picked AS MATERIALIZED (
SELECT id FROM star_gift_collectible_backdrops
WHERE collectible_revision_id=$1 AND rarity_kind='permille' AND rarity_permille > 0
ORDER BY random()
LIMIT $2
)`
from = "picked p JOIN star_gift_collectible_backdrops a ON a.id=p.id"
where = ""
args = append(args, options.samplePerKind)
}
rows, err := db.Query(ctx, fmt.Sprintf(`
%s
SELECT a.id, a.collectible_revision_id, a.name, a.backdrop_id, a.center_color, a.edge_color, a.pattern_color,
a.text_color, a.rarity_kind, COALESCE(a.rarity_permille,0), a.sort_order
FROM %s
%s
ORDER BY a.sort_order, a.id`, prefix, from, where), args...)
if err != nil {
return nil, fmt.Errorf("list collectible backdrops: %w", err)
}

View file

@ -70,6 +70,22 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
poolRevision.Models[1].RarityPermille != 0 || poolRevision.Models[0].OfficialDocumentID != 5100000000000000001 {
t.Fatalf("published pool = %+v", poolRevision)
}
storedRevision, found, err := gifts.ActiveCollectibleRevision(ctx, entry.Gift.ID)
if err != nil || !found || storedRevision.Models[0].Animation == nil || len(storedRevision.Models[0].Animation.JSON) == 0 {
t.Fatalf("full active collectible revision = found:%v err:%v value:%+v", found, err, storedRevision)
}
fullProjection, found, err := gifts.ActiveCollectibleProjection(ctx, entry.Gift.ID, 0)
if err != nil || !found || len(fullProjection.Models) != 3 || len(fullProjection.Patterns) != 2 || len(fullProjection.Backdrops) != 2 ||
fullProjection.Models[0].Animation == nil || len(fullProjection.Models[0].Animation.JSON) != 0 ||
fullProjection.Patterns[0].Animation == nil || len(fullProjection.Patterns[0].Animation.JSON) != 0 {
t.Fatalf("complete collectible projection = found:%v err:%v value:%+v", found, err, fullProjection)
}
sampleProjection, found, err := gifts.ActiveCollectibleProjection(ctx, entry.Gift.ID, 1)
if err != nil || !found || len(sampleProjection.Models) != 1 || len(sampleProjection.Patterns) != 1 || len(sampleProjection.Backdrops) != 1 ||
sampleProjection.Models[0].Crafted || sampleProjection.Models[0].RarityKind != domain.StarGiftRarityPermille ||
sampleProjection.Models[0].Animation == nil || len(sampleProjection.Models[0].Animation.JSON) != 0 {
t.Fatalf("sampled collectible projection = found:%v err:%v value:%+v", found, err, sampleProjection)
}
availability, err := gifts.CollectibleAvailability(ctx, []int64{entry.Gift.ID, entry.Gift.ID + 1})
if err != nil {
t.Fatalf("collectible availability: %v", err)

View file

@ -89,15 +89,23 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
}
ordinaryAction := purchased.Send.RecipientMessage.Media.ServiceAction.StarGift
if ordinaryAction == nil || !ordinaryAction.CanUpgrade || ordinaryAction.PrepaidUpgrade ||
ordinaryAction.UpgradePriceStars != 100 || ordinaryAction.UpgradeStars != 0 {
ordinaryAction.UpgradePriceStars != 100 || ordinaryAction.UpgradeStars != 0 ||
ordinaryAction.PrepaidUpgradeHash != "" {
t.Fatalf("ordinary purchase action mixed paid price with prepaid amount: %+v", ordinaryAction)
}
senderOrdinaryAction := purchased.Send.SenderMessage.Media.ServiceAction.StarGift
if senderOrdinaryAction == nil || senderOrdinaryAction.CanUpgrade ||
senderOrdinaryAction.PrepaidUpgradeHash != purchased.Saved.PrepaidUpgradeHash {
t.Fatalf("ordinary sender purchase projection = %+v", senderOrdinaryAction)
}
replayedPurchase, err := lifecycle.PurchaseStarGift(ctx, purchaseReq)
if err != nil || !replayedPurchase.Duplicate || replayedPurchase.Saved.ID != purchased.Saved.ID || replayedPurchase.Balance.Balance != 9950 ||
replayedPurchase.Send.SenderMessage.ID != purchased.Send.SenderMessage.ID ||
replayedPurchase.Send.RecipientMessage.ID != purchased.Send.RecipientMessage.ID {
t.Fatalf("purchase replay = %+v err %v", replayedPurchase, err)
}
verifyPrepaidViewerProjectionMigration(t, ctx, pool, purchased.Saved.ID, owner.ID, buyer.ID,
purchased.Send.RecipientMessage.ID, purchased.Send.SenderMessage.ID)
target, price, err := lifecycle.PrepaidUpgradeTarget(ctx, ownerPeer, purchased.Saved.PrepaidUpgradeHash)
if err != nil || target.ID != purchased.Saved.ID || price != 100 {
@ -1526,6 +1534,110 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='edit_message'`, userID, pts).
assertMigratedAction(payerUserID, payerPrepayMessageID, 0)
}
func verifyPrepaidViewerProjectionMigration(
t *testing.T,
ctx context.Context,
pool *pgxpool.Pool,
savedGiftID int64,
ownerUserID int64,
senderUserID int64,
ownerMessageID int,
senderMessageID int,
) {
t.Helper()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin prepaid viewer migration probe: %v", err)
}
defer func() { _ = tx.Rollback(context.Background()) }()
var hash string
if err := tx.QueryRow(ctx, `SELECT prepaid_upgrade_hash FROM peer_star_gifts WHERE id=$1`, savedGiftID).Scan(&hash); err != nil || hash == "" {
t.Fatalf("load prepaid hash for viewer migration probe: hash=%q err=%v", hash, err)
}
var ownerPtsBefore, senderPtsBefore int
if err := tx.QueryRow(ctx, `SELECT contiguous_pts FROM user_update_watermarks WHERE user_id=$1`, ownerUserID).Scan(&ownerPtsBefore); err != nil {
t.Fatalf("load owner watermark before viewer migration: %v", err)
}
if err := tx.QueryRow(ctx, `SELECT contiguous_pts FROM user_update_watermarks WHERE user_id=$1`, senderUserID).Scan(&senderPtsBefore); err != nil {
t.Fatalf("load sender watermark before viewer migration: %v", err)
}
var messageSenderID, privateMessageID int64
if err := tx.QueryRow(ctx, `SELECT message_sender_id,private_message_id FROM message_boxes
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, ownerUserID, ownerMessageID).
Scan(&messageSenderID, &privateMessageID); err != nil {
t.Fatalf("load ordinary gift message root for viewer migration: %v", err)
}
if _, err := tx.Exec(ctx, `UPDATE message_boxes
SET media=jsonb_set(jsonb_set(media,'{service_action,star_gift,can_upgrade}','true'::jsonb,true),
'{service_action,star_gift,prepaid_upgrade_hash}',to_jsonb($3::text),true)
WHERE (owner_user_id=$1 AND box_id=$4) OR (owner_user_id=$2 AND box_id=$5)`,
ownerUserID, senderUserID, hash, ownerMessageID, senderMessageID); err != nil {
t.Fatalf("restore stale prepaid viewer boxes: %v", err)
}
if _, err := tx.Exec(ctx, `UPDATE private_messages
SET media=jsonb_set(jsonb_set(media,'{service_action,star_gift,can_upgrade}','true'::jsonb,true),
'{service_action,star_gift,prepaid_upgrade_hash}',to_jsonb($3::text),true)
WHERE sender_user_id=$1 AND id=$2`, messageSenderID, privateMessageID, hash); err != nil {
t.Fatalf("restore stale prepaid shared message: %v", err)
}
migrationSQL, err := deploy.Migrations.ReadFile("migrations/0163_star_gift_prepaid_viewer_projection.up.sql")
if err != nil {
t.Fatalf("read prepaid viewer migration: %v", err)
}
if _, err := tx.Exec(ctx, string(migrationSQL)); err != nil {
t.Fatalf("apply prepaid viewer migration probe: %v", err)
}
assertProjection := func(userID int64, messageID int, wantCanUpgrade bool, wantHash string, ptsBefore int) {
t.Helper()
var mediaJSON string
var pts int
if err := tx.QueryRow(ctx, `SELECT media::text,pts FROM message_boxes
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, userID, messageID).Scan(&mediaJSON, &pts); err != nil {
t.Fatalf("load migrated viewer box %d/%d: %v", userID, messageID, err)
}
media, err := decodeMessageMedia(mediaJSON)
action := privateStarGiftAction(media)
if err != nil || action == nil || action.CanUpgrade != wantCanUpgrade || action.PrepaidUpgradeHash != wantHash {
t.Fatalf("migrated viewer box %d/%d = %+v err=%v", userID, messageID, action, err)
}
if pts != ptsBefore+1 {
t.Fatalf("migrated viewer box %d/%d pts=%d want=%d", userID, messageID, pts, ptsBefore+1)
}
var eventCount, outboxCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events
WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`, userID, pts, messageID).Scan(&eventCount); err != nil {
t.Fatalf("load migrated viewer event: %v", err)
}
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
WHERE target_user_id=$1 AND pts=$2 AND event_type='edit_message'`, userID, pts).Scan(&outboxCount); err != nil {
t.Fatalf("load migrated viewer outbox: %v", err)
}
if eventCount != 1 || outboxCount != 1 {
t.Fatalf("migrated viewer event/outbox counts=%d/%d", eventCount, outboxCount)
}
}
assertProjection(ownerUserID, ownerMessageID, true, "", ownerPtsBefore)
assertProjection(senderUserID, senderMessageID, false, hash, senderPtsBefore)
var sharedHash, sharedCanUpgrade *string
if err := tx.QueryRow(ctx, `SELECT media #>> '{service_action,star_gift,prepaid_upgrade_hash}',
media #>> '{service_action,star_gift,can_upgrade}' FROM private_messages WHERE sender_user_id=$1 AND id=$2`,
messageSenderID, privateMessageID).Scan(&sharedHash, &sharedCanUpgrade); err != nil {
t.Fatalf("load migrated shared viewer projection: %v", err)
}
if sharedHash != nil || sharedCanUpgrade != nil {
t.Fatalf("shared viewer projection retained hash/can_upgrade: hash=%v can=%v", sharedHash, sharedCanUpgrade)
}
var storedHash string
if err := tx.QueryRow(ctx, `SELECT prepaid_upgrade_hash FROM peer_star_gifts WHERE id=$1`, savedGiftID).Scan(&storedHash); err != nil || storedHash != hash {
t.Fatalf("viewer migration changed aggregate hash=%q want=%q err=%v", storedHash, hash, err)
}
}
func craftedSourceEditForUserAndGift(result domain.StarGiftCraftResult, userID, uniqueGiftID int64) domain.EditedMessageForUser {
for _, edit := range result.SourceEdits {
if edit.UserID != userID {

View file

@ -1,11 +1,61 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestProjectPrivateStarGiftPurchaseScopesViewerCapabilities(t *testing.T) {
media := &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{
PeerUserID: 200, To: domain.Peer{Type: domain.PeerTypeUser, ID: 200},
CanUpgrade: true, PrepaidUpgradeHash: "prepaid-upgrade-hash-0123456789",
},
},
}
req := &domain.SendPrivateTextRequest{SenderUserID: 100, RecipientUserID: 200, Media: media}
projection, err := projectPrivateStarGiftPurchase(context.Background(), nil, req)
if err != nil {
t.Fatalf("project purchase: %v", err)
}
shared := privateStarGiftAction(projection.Shared)
sender := privateStarGiftAction(projection.Sender)
recipient := privateStarGiftAction(projection.Recipient)
if shared == nil || shared.CanUpgrade || shared.PrepaidUpgradeHash != "" {
t.Fatalf("shared projection retained viewer capability: %+v", shared)
}
if sender == nil || sender.CanUpgrade || sender.PrepaidUpgradeHash == "" {
t.Fatalf("sender projection = %+v, want hash without can_upgrade", sender)
}
if recipient == nil || !recipient.CanUpgrade || recipient.PrepaidUpgradeHash != "" {
t.Fatalf("recipient projection = %+v, want can_upgrade without hash", recipient)
}
if original := privateStarGiftAction(media); original == nil || !original.CanUpgrade || original.PrepaidUpgradeHash == "" {
t.Fatalf("source projection was mutated: %+v", original)
}
selfReq := &domain.SendPrivateTextRequest{SenderUserID: 200, RecipientUserID: 200, Media: media}
selfProjection, err := projectPrivateStarGiftPurchase(context.Background(), nil, selfReq)
if err != nil {
t.Fatalf("project self purchase: %v", err)
}
self := privateStarGiftAction(selfProjection.Sender)
if self == nil || !self.CanUpgrade || self.PrepaidUpgradeHash != "" {
t.Fatalf("self-owner projection = %+v, want can_upgrade without hash", self)
}
bad := *req
bad.RecipientUserID = 300
if _, err := projectPrivateStarGiftPurchase(context.Background(), nil, &bad); err == nil {
t.Fatal("mismatched gift owner and recipient was accepted")
}
}
func TestTransferUniqueActionSavedIDNamespace(t *testing.T) {
saved := domain.SavedStarGift{SavedID: 42, CanCraftAt: 1_780_000_123}
unique := domain.UniqueStarGift{ID: 7}

View file

@ -54,6 +54,12 @@ func projectPrivateStarGiftSourceRef(
sharedAction.GiftMsgID = 0
senderAction.GiftMsgID = 0
recipientAction.GiftMsgID = 0
// messageActionStarGift.can_upgrade is receiver-only. A separate
// prepayment notification shares one logical private message, but its
// payer box must not advertise owner actions.
sharedAction.CanUpgrade = false
senderAction.CanUpgrade = req.SenderUserID == sourceOwnerUserID && senderAction.CanUpgrade
recipientAction.CanUpgrade = req.RecipientUserID == sourceOwnerUserID && recipientAction.CanUpgrade
if req.SenderUserID == sourceOwnerUserID {
senderAction.GiftMsgID = sourceOwnerBoxID
} else {
@ -66,6 +72,66 @@ func projectPrivateStarGiftSourceRef(
return privateSendMediaProjection{Shared: shared, Sender: sender, Recipient: recipient}, nil
}
// projectPrivateStarGiftPurchase scopes the two mutually exclusive actions of
// an ordinary user gift to the correct account-local message box:
// - the gift owner/receiver may upgrade it and must not receive the separate
// prepayment hash;
// - the non-owner sender may use the hash to prepay for the owner's upgrade,
// but must not receive the receiver-only can_upgrade capability.
//
// The shared logical envelope carries neither viewer-only field. This keeps a
// future read/replay path from accidentally treating it as either participant's
// projection while the two message_boxes remain the durable wire truth.
func projectPrivateStarGiftPurchase(
_ context.Context,
_ pgx.Tx,
req *domain.SendPrivateTextRequest,
) (privateSendMediaProjection, error) {
if req == nil || req.Media == nil || req.SenderUserID <= 0 || req.RecipientUserID <= 0 {
return privateSendMediaProjection{}, fmt.Errorf("project private star gift purchase: invalid scope")
}
action := privateStarGiftAction(req.Media)
if action == nil {
return privateSendMediaProjection{}, fmt.Errorf("project private star gift purchase: unsupported media")
}
ownerUserID := action.PeerUserID
if ownerUserID == 0 && action.To.Type == domain.PeerTypeUser {
ownerUserID = action.To.ID
}
if ownerUserID <= 0 || ownerUserID != req.RecipientUserID {
return privateSendMediaProjection{}, fmt.Errorf(
"project private star gift purchase: owner %d does not match recipient %d",
ownerUserID, req.RecipientUserID,
)
}
shared, err := cloneMessageMedia(req.Media)
if err != nil {
return privateSendMediaProjection{}, err
}
sender, err := cloneMessageMedia(req.Media)
if err != nil {
return privateSendMediaProjection{}, err
}
recipient, err := cloneMessageMedia(req.Media)
if err != nil {
return privateSendMediaProjection{}, err
}
sharedAction := privateStarGiftAction(shared)
senderAction := privateStarGiftAction(sender)
recipientAction := privateStarGiftAction(recipient)
sharedAction.PrepaidUpgradeHash = ""
sharedAction.CanUpgrade = false
if req.SenderUserID == ownerUserID {
senderAction.PrepaidUpgradeHash = ""
} else {
senderAction.CanUpgrade = false
}
recipientAction.PrepaidUpgradeHash = ""
return privateSendMediaProjection{Shared: shared, Sender: sender, Recipient: recipient}, nil
}
func cloneMessageMedia(media *domain.MessageMedia) (*domain.MessageMedia, error) {
encoded, err := encodeMessageMedia(media)
if err != nil {
@ -97,6 +163,8 @@ func encodeSharedPrivateStarGiftMedia(media *domain.MessageMedia) ([]byte, error
action.UpgradeMsgID = 0
if action.PeerUserID > 0 || action.To.Type == domain.PeerTypeUser {
action.SavedID = 0
action.PrepaidUpgradeHash = ""
action.CanUpgrade = false
}
case privateStarGiftUniqueAction(shared) != nil:
action := privateStarGiftUniqueAction(shared)

View file

@ -131,6 +131,7 @@ func (s *StarGiftLifecycleStore) PurchaseStarGift(ctx context.Context, req domai
result.Gift, result.Saved, result.Balance = gift, saved, balance
return nil
},
projectMedia: projectPrivateStarGiftPurchase,
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
msgID := sent.RecipientMessage.ID
if msgID <= 0 {

View file

@ -26,6 +26,10 @@ type StarGiftStore interface {
// PublishCollectibleRevision validates and atomically publishes a new immutable attribute pool.
PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error)
ActiveCollectibleRevision(ctx context.Context, giftID int64) (domain.StarGiftCollectibleRevision, bool, error)
// ActiveCollectibleProjection omits heavyweight animation bodies from read-only client/admin
// projections. samplePerKind=0 returns the complete attribute metadata; a positive value
// returns at most that many randomly selected ordinary-upgrade attributes per kind.
ActiveCollectibleProjection(ctx context.Context, giftID int64, samplePerKind int) (domain.StarGiftCollectibleRevision, bool, error)
CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error)
CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)