From 35d3908660f571668f358aab948903b73db6cbdc Mon Sep 17 00:00:00 2001 From: iamxvbaba <28732408+iamxvbaba@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:12:01 +0800 Subject: [PATCH] fix: sync harden star gift upgrade projections --- ...ar_gift_prepaid_viewer_projection.down.sql | 4 + ...star_gift_prepaid_viewer_projection.up.sql | 183 ++++++++++++++++++ internal/app/stargifts/animation_test.go | 6 + internal/app/stargifts/service.go | 20 +- internal/rpc/convert_messages.go | 2 +- internal/rpc/deps.go | 1 + internal/rpc/payments_star_gift_unique.go | 2 +- internal/rpc/payments_star_gifts.go | 34 +++- internal/rpc/payments_star_gifts_rpc_test.go | 136 ++++++++++++- internal/store/memory/star_gift.go | 43 ++++ .../store/postgres/star_gift_collectibles.go | 128 +++++++++--- ...star_gift_collectibles_integration_test.go | 16 ++ .../star_gift_lifecycle_integration_test.go | 114 ++++++++++- .../postgres/star_gift_lifecycle_test.go | 50 +++++ .../postgres/star_gift_private_projection.go | 68 +++++++ internal/store/postgres/star_gift_purchase.go | 1 + internal/store/star_gift.go | 4 + 17 files changed, 772 insertions(+), 40 deletions(-) create mode 100644 deploy/migrations/0163_star_gift_prepaid_viewer_projection.down.sql create mode 100644 deploy/migrations/0163_star_gift_prepaid_viewer_projection.up.sql diff --git a/deploy/migrations/0163_star_gift_prepaid_viewer_projection.down.sql b/deploy/migrations/0163_star_gift_prepaid_viewer_projection.down.sql new file mode 100644 index 00000000..76ba726c --- /dev/null +++ b/deploy/migrations/0163_star_gift_prepaid_viewer_projection.down.sql @@ -0,0 +1,4 @@ +-- Data-only viewer projection and durable PTS repair. Reintroducing the leaked +-- owner hash or receiver-only capability into the sender box would be unsafe, +-- and emitted updateEditMessage events cannot be retracted. +SELECT 1; diff --git a/deploy/migrations/0163_star_gift_prepaid_viewer_projection.up.sql b/deploy/migrations/0163_star_gift_prepaid_viewer_projection.up.sql new file mode 100644 index 00000000..22dd3388 --- /dev/null +++ b/deploy/migrations/0163_star_gift_prepaid_viewer_projection.up.sql @@ -0,0 +1,183 @@ +-- inputInvoiceStarGiftPrepaidUpgrade is for prepaying someone else's gift: +-- its peer is the gift owner. Earlier user-gift purchases copied both +-- receiver-only can_upgrade and non-owner prepaid_upgrade_hash into both +-- private message boxes. DrKLO therefore preferred the prepaid invoice on the +-- owner's incoming card and substituted the private dialog peer (the sender), +-- which correctly failed STARGIFT_INVALID. +-- +-- Repair both viewer projections and publish a real account-scoped edit for +-- every changed box. The saved-gift aggregate and its owner/hash remain intact; +-- only the wire capability surface changes. + +LOCK TABLE public.peer_star_gifts, public.message_boxes, + public.private_messages IN SHARE ROW EXCLUSIVE MODE; + +CREATE TEMP TABLE star_gift_prepaid_viewer_sources ON COMMIT DROP AS +SELECT gift.id AS saved_gift_id, + gift.owner_peer_id AS owner_user_id, + gift.from_user_id AS sender_user_id, + gift.msg_id AS owner_box_id, + gift.gift_id, + gift.prepaid_upgrade_hash, + owner_box.message_sender_id, + owner_box.private_message_id +FROM public.peer_star_gifts gift +JOIN public.message_boxes owner_box + ON owner_box.owner_user_id = gift.owner_peer_id + AND owner_box.box_id = gift.msg_id + AND NOT owner_box.deleted +WHERE gift.owner_peer_type = 'user' + AND gift.lifecycle_status = 'active' + AND gift.unique_gift_id IS NULL + AND gift.prepaid_upgrade_stars = 0 + AND gift.prepaid_upgrade_hash <> ''; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM public.peer_star_gifts gift + WHERE gift.owner_peer_type = 'user' + AND gift.lifecycle_status = 'active' + AND gift.unique_gift_id IS NULL + AND gift.prepaid_upgrade_stars = 0 + AND gift.prepaid_upgrade_hash <> '' + AND NOT EXISTS ( + SELECT 1 + FROM star_gift_prepaid_viewer_sources source + WHERE source.saved_gift_id = gift.id + ) + ) THEN + RAISE EXCEPTION 'active user prepaid-upgrade entitlement is missing its owner message box'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM star_gift_prepaid_viewer_sources source + JOIN public.message_boxes box + ON box.message_sender_id = source.message_sender_id + AND box.private_message_id = source.private_message_id + AND NOT box.deleted + WHERE box.owner_user_id NOT IN (source.owner_user_id, source.sender_user_id) + OR box.media #>> '{service_action,kind}' IS DISTINCT FROM 'star_gift' + OR box.media #>> '{service_action,star_gift,gift_id}' IS DISTINCT FROM source.gift_id::text + ) THEN + RAISE EXCEPTION 'ordinary star gift private projections disagree with the saved aggregate'; + END IF; +END +$$; + +CREATE TEMP TABLE star_gift_prepaid_viewer_repairs ( + owner_user_id bigint NOT NULL, + box_id integer NOT NULL, + peer_type text NOT NULL, + peer_id bigint NOT NULL, + message_sender_id bigint NOT NULL, + private_message_id bigint NOT NULL, + repaired_media jsonb NOT NULL, + PRIMARY KEY (owner_user_id, box_id) +) ON COMMIT DROP; + +INSERT INTO star_gift_prepaid_viewer_repairs( + owner_user_id, box_id, peer_type, peer_id, + message_sender_id, private_message_id, repaired_media +) +SELECT box.owner_user_id, + box.box_id, + box.peer_type, + box.peer_id, + box.message_sender_id, + box.private_message_id, + CASE + WHEN box.owner_user_id = source.owner_user_id THEN + box.media #- '{service_action,star_gift,prepaid_upgrade_hash}' + ELSE + box.media #- '{service_action,star_gift,can_upgrade}' + END +FROM star_gift_prepaid_viewer_sources source +JOIN public.message_boxes box + ON box.message_sender_id = source.message_sender_id + AND box.private_message_id = source.private_message_id + AND NOT box.deleted +WHERE box.owner_user_id IN (source.owner_user_id, source.sender_user_id) + AND box.media IS DISTINCT FROM CASE + WHEN box.owner_user_id = source.owner_user_id THEN + box.media #- '{service_action,star_gift,prepaid_upgrade_hash}' + ELSE + box.media #- '{service_action,star_gift,can_upgrade}' + END; + +DO $$ +DECLARE + repair_row record; + next_pts integer; + event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer; +BEGIN + FOR repair_row IN + SELECT owner_user_id, box_id, peer_type, peer_id, repaired_media + FROM star_gift_prepaid_viewer_repairs + ORDER BY owner_user_id, box_id + LOOP + INSERT INTO public.user_update_watermarks(user_id, contiguous_pts) + VALUES(repair_row.owner_user_id, 0) + ON CONFLICT(user_id) DO NOTHING; + + UPDATE public.user_update_watermarks + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + WHERE user_id = repair_row.owner_user_id + RETURNING contiguous_pts INTO next_pts; + + UPDATE public.message_boxes + SET media = repair_row.repaired_media, + pts = next_pts + WHERE owner_user_id = repair_row.owner_user_id + AND box_id = repair_row.box_id + AND NOT deleted; + + INSERT INTO public.user_update_events( + user_id, pts, pts_count, date, event_type, + message_box_id, peer_type, peer_id + ) VALUES ( + repair_row.owner_user_id, next_pts, 1, event_date, 'edit_message', + repair_row.box_id, repair_row.peer_type, repair_row.peer_id + ); + + INSERT INTO public.dispatch_outbox( + target_user_id, pts, event_type, + exclude_auth_key_id, exclude_session_id + ) VALUES(repair_row.owner_user_id, next_pts, 'edit_message', 0, 0); + END LOOP; +END +$$; + +-- A shared private-message envelope is not a viewer projection. Keep neither +-- the owner-only nor non-owner-only capability there; both durable boxes above +-- remain authoritative for replay and history. +UPDATE public.private_messages private_message +SET media = private_message.media + #- '{service_action,star_gift,prepaid_upgrade_hash}' + #- '{service_action,star_gift,can_upgrade}' +FROM star_gift_prepaid_viewer_sources source +WHERE private_message.sender_user_id = source.message_sender_id + AND private_message.id = source.private_message_id; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM star_gift_prepaid_viewer_sources source + JOIN public.message_boxes box + ON box.message_sender_id = source.message_sender_id + AND box.private_message_id = source.private_message_id + AND NOT box.deleted + WHERE (box.owner_user_id = source.owner_user_id AND + box.media #> '{service_action,star_gift,prepaid_upgrade_hash}' IS NOT NULL) + OR (box.owner_user_id = source.sender_user_id AND + box.owner_user_id <> source.owner_user_id AND + box.media #> '{service_action,star_gift,can_upgrade}' IS NOT NULL) + ) THEN + RAISE EXCEPTION 'star gift prepaid viewer projection repair did not converge'; + END IF; +END +$$; diff --git a/internal/app/stargifts/animation_test.go b/internal/app/stargifts/animation_test.go index 65f2cc47..ebb115d9 100644 --- a/internal/app/stargifts/animation_test.go +++ b/internal/app/stargifts/animation_test.go @@ -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) + } } diff --git a/internal/app/stargifts/service.go b/internal/app/stargifts/service.go index 4db8cdb4..9a20e100 100644 --- a/internal/app/stargifts/service.go +++ b/internal/app/stargifts/service.go @@ -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) diff --git a/internal/rpc/convert_messages.go b/internal/rpc/convert_messages.go index 0c5485a9..40f9f979 100644 --- a/internal/rpc/convert_messages.go +++ b/internal/rpc/convert_messages.go @@ -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: diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 186b8f31..032a2581 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -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) diff --git a/internal/rpc/payments_star_gift_unique.go b/internal/rpc/payments_star_gift_unique.go index 6f9cd424..7d229277 100644 --- a/internal/rpc/payments_star_gift_unique.go +++ b/internal/rpc/payments_star_gift_unique.go @@ -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() } diff --git a/internal/rpc/payments_star_gifts.go b/internal/rpc/payments_star_gifts.go index c8faae39..749f88c5 100644 --- a/internal/rpc/payments_star_gifts.go +++ b/internal/rpc/payments_star_gifts.go @@ -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) } } diff --git a/internal/rpc/payments_star_gifts_rpc_test.go b/internal/rpc/payments_star_gifts_rpc_test.go index 2b6047d0..e5e1c1d5 100644 --- a/internal/rpc/payments_star_gifts_rpc_test.go +++ b/internal/rpc/payments_star_gifts_rpc_test.go @@ -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) diff --git a/internal/store/memory/star_gift.go b/internal/store/memory/star_gift.go index 82d97893..7b31ce48 100644 --- a/internal/store/memory/star_gift.go +++ b/internal/store/memory/star_gift.go @@ -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 { diff --git a/internal/store/postgres/star_gift_collectibles.go b/internal/store/postgres/star_gift_collectibles.go index d942b863..69a94854 100644 --- a/internal/store/postgres/star_gift_collectibles.go +++ b/internal/store/postgres/star_gift_collectibles.go @@ -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) } diff --git a/internal/store/postgres/star_gift_collectibles_integration_test.go b/internal/store/postgres/star_gift_collectibles_integration_test.go index fb124c3d..62624d32 100644 --- a/internal/store/postgres/star_gift_collectibles_integration_test.go +++ b/internal/store/postgres/star_gift_collectibles_integration_test.go @@ -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) diff --git a/internal/store/postgres/star_gift_lifecycle_integration_test.go b/internal/store/postgres/star_gift_lifecycle_integration_test.go index 68e02cfc..6320a25a 100644 --- a/internal/store/postgres/star_gift_lifecycle_integration_test.go +++ b/internal/store/postgres/star_gift_lifecycle_integration_test.go @@ -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 { diff --git a/internal/store/postgres/star_gift_lifecycle_test.go b/internal/store/postgres/star_gift_lifecycle_test.go index 7e844cc7..21b6f371 100644 --- a/internal/store/postgres/star_gift_lifecycle_test.go +++ b/internal/store/postgres/star_gift_lifecycle_test.go @@ -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} diff --git a/internal/store/postgres/star_gift_private_projection.go b/internal/store/postgres/star_gift_private_projection.go index 8ed76d18..6da74946 100644 --- a/internal/store/postgres/star_gift_private_projection.go +++ b/internal/store/postgres/star_gift_private_projection.go @@ -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) diff --git a/internal/store/postgres/star_gift_purchase.go b/internal/store/postgres/star_gift_purchase.go index b3da2d65..68796cfe 100644 --- a/internal/store/postgres/star_gift_purchase.go +++ b/internal/store/postgres/star_gift_purchase.go @@ -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 { diff --git a/internal/store/star_gift.go b/internal/store/star_gift.go index 916b45b2..7d81d2d6 100644 --- a/internal/store/star_gift.go +++ b/internal/store/star_gift.go @@ -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)