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

@ -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)