feat: sync collectible star gifts

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

View file

@ -220,6 +220,25 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
}
case domain.MessageServiceActionStarGift:
return tgMessageActionStarGift(m.ServiceAction.StarGift)
case domain.MessageServiceActionStarGiftUnique:
action := m.ServiceAction.StarGiftUnique
if action == nil {
return &tg.MessageActionEmpty{}
}
out := &tg.MessageActionStarGiftUnique{
Upgrade: action.Upgrade, Saved: action.Saved, PrepaidUpgrade: action.PrepaidUpgrade,
Gift: tgUniqueStarGift(action.Gift),
}
if action.FromUserID != 0 {
out.SetFromID(&tg.PeerUser{UserID: action.FromUserID})
}
if peer := tgPeer(action.Peer); peer != nil {
out.SetPeer(peer)
}
if action.SavedID != 0 {
out.SetSavedID(action.SavedID)
}
return out
default:
return &tg.MessageActionEmpty{}
}

View file

@ -864,12 +864,27 @@ type GiftsService interface {
Catalog(ctx context.Context) ([]domain.StarGift, error)
CatalogHash(ctx context.Context) (int, error)
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)
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)
UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error)
Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error)
RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error)
ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error)
ListSavedFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error)
GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error)
ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error)
CountSaved(ctx context.Context, owner domain.Peer) (int, error)
ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error)
Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error)
ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error)
CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error)
UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error)
DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error)
ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error
SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error
}
// StarsService 抽象 Stars 本地账本app/stars余额查询、贷记/借记、流水分页。

View file

@ -82,7 +82,7 @@ func TestAccountGetWallPapersReturnsDefaultCatalog(t *testing.T) {
t.Fatalf("boxed response type = %T, want *tg.AccountWallPapers", box.WallPapers)
}
if wallpapers.Hash == 0 || len(wallpapers.Wallpapers) == 0 {
t.Fatalf("wallpapers = %+v, want stable Default catalog", wallpapers)
t.Fatalf("wallpapers = %+v, want stable default catalog", wallpapers)
}
}
@ -189,7 +189,7 @@ func TestAccountWallpaperSeedLookupAndAckRPCs(t *testing.T) {
}
}
func TestPaymentsGetStarGiftCollectionsReturnsEmptyAndValidatesPeer(t *testing.T) {
func TestPaymentsGetStarGiftCollectionsNoServiceFallbackAndValidatesPeer(t *testing.T) {
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), 1000000001)

View file

@ -30,25 +30,21 @@ func (r *Router) registerPayments(d *tg.ServerDispatcher) {
return tdesktop.StarGiftActiveAuctions(), nil
})
d.OnPaymentsGetStarGifts(r.onPaymentsGetStarGifts)
d.OnPaymentsGetStarGiftUpgradePreview(r.onPaymentsGetStarGiftUpgradePreview)
d.OnPaymentsGetUniqueStarGift(r.onPaymentsGetUniqueStarGift)
d.OnPaymentsGetPaymentForm(r.onPaymentsGetPaymentForm)
d.OnPaymentsSendStarsForm(r.onPaymentsSendStarsForm)
d.OnPaymentsGetSavedStarGifts(r.onPaymentsGetSavedStarGifts)
d.OnPaymentsGetSavedStarGift(r.onPaymentsGetSavedStarGift)
d.OnPaymentsSaveStarGift(r.onPaymentsSaveStarGift)
d.OnPaymentsConvertStarGift(r.onPaymentsConvertStarGift)
d.OnPaymentsGetStarGiftCollections(func(ctx context.Context, req *tg.PaymentsGetStarGiftCollectionsRequest) (tg.PaymentsStarGiftCollectionsClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if req == nil {
return nil, peerIDInvalidErr()
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
return nil, err
}
return tdesktop.StarGiftCollections(), nil
})
d.OnPaymentsUpgradeStarGift(r.onPaymentsUpgradeStarGift)
d.OnPaymentsGetStarGiftCollections(r.onPaymentsGetStarGiftCollections)
d.OnPaymentsCreateStarGiftCollection(r.onPaymentsCreateStarGiftCollection)
d.OnPaymentsUpdateStarGiftCollection(r.onPaymentsUpdateStarGiftCollection)
d.OnPaymentsDeleteStarGiftCollection(r.onPaymentsDeleteStarGiftCollection)
d.OnPaymentsReorderStarGiftCollections(r.onPaymentsReorderStarGiftCollections)
d.OnPaymentsToggleStarGiftsPinnedToTop(r.onPaymentsToggleStarGiftsPinnedToTop)
d.OnPaymentsGetStarsRevenueAdsAccountURL(func(ctx context.Context, peer tg.InputPeerClass) (*tg.PaymentsStarsRevenueAdsAccountURL, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {

View file

@ -0,0 +1,235 @@
package rpc
import (
"context"
"errors"
"strings"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func (r *Router) onPaymentsGetStarGiftCollections(ctx context.Context, req *tg.PaymentsGetStarGiftCollectionsRequest) (tg.PaymentsStarGiftCollectionsClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
if r.deps.Gifts == nil {
return &tg.PaymentsStarGiftCollections{Collections: []tg.StarGiftCollection{}}, nil
}
collections, err := r.deps.Gifts.ListCollections(ctx, owner)
if err != nil {
return nil, starGiftCollectionErr(err)
}
if req.Hash != 0 && req.Hash == domain.StarGiftCollectionsHash(collections) {
return &tg.PaymentsStarGiftCollectionsNotModified{}, nil
}
return &tg.PaymentsStarGiftCollections{Collections: tgStarGiftCollections(collections)}, nil
}
func (r *Router) onPaymentsCreateStarGiftCollection(ctx context.Context, req *tg.PaymentsCreateStarGiftCollectionRequest) (*tg.StarGiftCollection, error) {
if req == nil || r.deps.Gifts == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
return nil, err
}
ids, err := r.resolveStarGiftCollectionRefs(ctx, userID, owner, req.Stargift)
if err != nil {
return nil, err
}
collection, err := r.deps.Gifts.CreateCollection(ctx, owner, strings.TrimSpace(req.Title), ids)
if err != nil {
return nil, starGiftCollectionErr(err)
}
r.invalidateStarGiftOwnerProjection(owner)
out := tgStarGiftCollection(collection)
return &out, nil
}
func (r *Router) onPaymentsUpdateStarGiftCollection(ctx context.Context, req *tg.PaymentsUpdateStarGiftCollectionRequest) (*tg.StarGiftCollection, error) {
if req == nil || req.CollectionID <= 0 || r.deps.Gifts == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
return nil, err
}
patch := domain.StarGiftCollectionPatch{}
if title, ok := req.GetTitle(); ok {
title = strings.TrimSpace(title)
patch.Title = &title
}
if refs, ok := req.GetDeleteStargift(); ok {
patch.DeleteIDs, err = r.resolveStarGiftCollectionRefs(ctx, userID, owner, refs)
if err != nil {
return nil, err
}
}
if refs, ok := req.GetAddStargift(); ok {
patch.AddIDs, err = r.resolveStarGiftCollectionRefs(ctx, userID, owner, refs)
if err != nil {
return nil, err
}
}
if refs, ok := req.GetOrder(); ok {
patch.Order, err = r.resolveStarGiftCollectionRefs(ctx, userID, owner, refs)
if err != nil {
return nil, err
}
}
collection, err := r.deps.Gifts.UpdateCollection(ctx, owner, req.CollectionID, patch)
if err != nil {
return nil, starGiftCollectionErr(err)
}
r.invalidateStarGiftOwnerProjection(owner)
out := tgStarGiftCollection(collection)
return &out, nil
}
func (r *Router) onPaymentsDeleteStarGiftCollection(ctx context.Context, req *tg.PaymentsDeleteStarGiftCollectionRequest) (bool, error) {
if req == nil || req.CollectionID <= 0 || r.deps.Gifts == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
return false, err
}
deleted, err := r.deps.Gifts.DeleteCollection(ctx, owner, req.CollectionID)
if err != nil {
return false, starGiftCollectionErr(err)
}
if !deleted {
return false, starGiftCollectionErr(domain.ErrStarGiftCollectionNotFound)
}
r.invalidateStarGiftOwnerProjection(owner)
return true, nil
}
func (r *Router) onPaymentsReorderStarGiftCollections(ctx context.Context, req *tg.PaymentsReorderStarGiftCollectionsRequest) (bool, error) {
if req == nil || r.deps.Gifts == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
return false, err
}
if err := r.deps.Gifts.ReorderCollections(ctx, owner, req.Order); err != nil {
return false, starGiftCollectionErr(err)
}
return true, nil
}
func (r *Router) onPaymentsToggleStarGiftsPinnedToTop(ctx context.Context, req *tg.PaymentsToggleStarGiftsPinnedToTopRequest) (bool, error) {
if req == nil || r.deps.Gifts == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
return false, err
}
ids, err := r.resolveStarGiftCollectionRefs(ctx, userID, owner, req.Stargift)
if err != nil {
return false, err
}
if err := r.deps.Gifts.SetPinned(ctx, owner, ids); err != nil {
return false, starGiftCollectionErr(err)
}
r.invalidateStarGiftOwnerProjection(owner)
return true, nil
}
func (r *Router) resolveStarGiftCollectionRefs(ctx context.Context, userID int64, owner domain.Peer, refs []tg.InputSavedStarGiftClass) ([]int64, error) {
if len(refs) > domain.MaxStarGiftCollectionItems {
return nil, inputRequestInvalidErr()
}
domainRefs := make([]domain.SavedStarGiftRef, 0, len(refs))
for _, input := range refs {
ref, ok, err := r.starGiftRefFromInput(ctx, userID, input)
if err != nil {
return nil, err
}
if !ok || ref.Owner != owner {
return nil, starGiftInvalidErr()
}
domainRefs = append(domainRefs, ref)
}
ids, err := r.deps.Gifts.ResolveSavedIDs(ctx, owner, domainRefs)
if err != nil {
return nil, starGiftCollectionErr(err)
}
return ids, nil
}
func tgStarGiftCollections(in []domain.StarGiftCollection) []tg.StarGiftCollection {
out := make([]tg.StarGiftCollection, 0, len(in))
for _, collection := range in {
out = append(out, tgStarGiftCollection(collection))
}
return out
}
func tgStarGiftCollection(in domain.StarGiftCollection) tg.StarGiftCollection {
return tg.StarGiftCollection{
CollectionID: in.CollectionID,
Title: in.Title,
GiftsCount: len(in.GiftIDs),
Hash: in.Hash,
}
}
func starGiftCollectionErr(err error) error {
switch {
case errors.Is(err, domain.ErrStarGiftNotFound),
errors.Is(err, domain.ErrStarGiftCollectibleInvalid),
errors.Is(err, domain.ErrStarGiftCollectionNotFound),
errors.Is(err, domain.ErrStarGiftCollectionsFull):
return inputRequestInvalidErr()
default:
return internalErr()
}
}

View file

@ -0,0 +1,278 @@
package rpc
import (
"context"
"errors"
"fmt"
"strings"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func (r *Router) starGiftUpgradePaymentForm(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftUpgrade) (tg.PaymentsPaymentFormClass, error) {
saved, preview, err := r.starGiftUpgradeTarget(ctx, userID, inv.Stargift)
if err != nil {
return nil, err
}
return &tg.PaymentsPaymentFormStarGift{
FormID: starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails),
Invoice: tg.Invoice{
Currency: "XTR",
Prices: []tg.LabeledPrice{{Label: "Star gift upgrade", Amount: preview.UpgradeStars}},
},
}, nil
}
func (r *Router) sendStarGiftUpgradeForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftUpgrade) (tg.PaymentsPaymentResultClass, error) {
saved, preview, err := r.starGiftUpgradeTarget(ctx, userID, inv.Stargift)
if err != nil {
return nil, err
}
wantFormID := starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails)
if formID == 0 || formID != wantFormID {
return nil, starsFormAmountMismatchErr()
}
result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{
UserID: userID, Ref: domain.SavedStarGiftRef{Owner: saved.Owner, MsgID: saved.MsgID},
KeepOriginalDetails: inv.KeepOriginalDetails, ChargeStars: preview.UpgradeStars,
FormID: formID, CommandKey: fmt.Sprintf("paid:%d:%d:%t", saved.ID, formID, inv.KeepOriginalDetails),
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionIDOrZero(ctx),
})
if err != nil {
return nil, starGiftUpgradeErr(err)
}
r.invalidateStarGiftOwnerProjection(saved.Owner)
updates := r.tgStarGiftUpgradeUpdates(ctx, userID, result, true)
return &tg.PaymentsPaymentResult{Updates: updates}, nil
}
func (r *Router) onPaymentsUpgradeStarGift(ctx context.Context, req *tg.PaymentsUpgradeStarGiftRequest) (tg.UpdatesClass, error) {
if req == nil || r.deps.Gifts == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
saved, _, err := r.starGiftUpgradeTarget(ctx, userID, req.Stargift)
if err != nil {
return nil, err
}
if saved.PrepaidUpgradeStars <= 0 {
return nil, starGiftInvalidErr()
}
result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{
UserID: userID, Ref: domain.SavedStarGiftRef{Owner: saved.Owner, MsgID: saved.MsgID},
KeepOriginalDetails: req.KeepOriginalDetails, RequirePrepaid: true,
CommandKey: fmt.Sprintf("prepaid:%d:%t", saved.ID, req.KeepOriginalDetails),
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionIDOrZero(ctx),
})
if err != nil {
return nil, starGiftUpgradeErr(err)
}
r.invalidateStarGiftOwnerProjection(saved.Owner)
return r.tgStarGiftUpgradeUpdates(ctx, userID, result, false), nil
}
func (r *Router) starGiftUpgradeTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, domain.StarGiftUpgradePreview, error) {
if r.deps.Gifts == nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, notImplementedErr()
}
ref, ok, err := r.starGiftRefFromInput(ctx, userID, input)
if err != nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, err
}
if !ok || ref.Owner.Type != domain.PeerTypeUser || ref.Owner.ID != userID {
// Channel gift upgrades require a channel pts aggregate and are not silently
// routed through the private-message transaction.
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
}
saved, found, err := r.deps.Gifts.GetSaved(ctx, ref)
if err != nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, internalErr()
}
if !found || saved.Converted || saved.UniqueGiftID != 0 {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
}
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, saved.GiftID)
if err != nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, internalErr()
}
if !found || preview.UpgradeStars <= 0 || preview.Issued >= preview.SupplyTotal {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
}
return saved, preview, nil
}
func (r *Router) tgStarGiftUpgradeUpdates(ctx context.Context, ownerUserID int64, result domain.StarGiftUpgradeResult, includeBalance bool) *tg.Updates {
message, event := result.Send.RecipientMessage, result.Send.RecipientEvent
if result.Send.SenderMessage.OwnerUserID == ownerUserID {
message, event = result.Send.SenderMessage, result.Send.SenderEvent
}
updates := tgPrivateMessageUpdates(event, message, 0, false,
r.usersForMessageUpdate(ctx, ownerUserID, message),
r.chatsForMessageUpdate(ctx, ownerUserID, message))
if includeBalance {
updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.Balance.Balance}})
}
return updates
}
func starGiftUpgradeFormID(userID, savedGiftID, stars int64, keepOriginal bool) int64 {
id := userID*0x9e3779b1 ^ savedGiftID<<11 ^ stars<<19 ^ 0x55504752414445
if keepOriginal {
id ^= 0x4b454550
}
if id < 0 {
id = ^id
}
if id == 0 {
id = 1
}
return id
}
func starGiftUpgradeErr(err error) error {
switch {
case errors.Is(err, domain.ErrStarsInsufficient):
return starsErr(err)
case errors.Is(err, domain.ErrStarGiftNotFound),
errors.Is(err, domain.ErrStarGiftAlreadyConverted),
errors.Is(err, domain.ErrStarGiftAlreadyUpgraded),
errors.Is(err, domain.ErrStarGiftCollectibleUnavailable),
errors.Is(err, domain.ErrStarGiftCollectibleSoldOut),
errors.Is(err, domain.ErrStarGiftCollectibleInvalid):
return starGiftInvalidErr()
default:
return internalErr()
}
}
func sessionIDOrZero(ctx context.Context) int64 {
sessionID, _ := SessionIDFrom(ctx)
return sessionID
}
func (r *Router) onPaymentsGetStarGiftUpgradePreview(ctx context.Context, giftID int64) (*tg.PaymentsStarGiftUpgradePreview, error) {
if giftID <= 0 || r.deps.Gifts == nil {
return nil, starGiftInvalidErr()
}
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, giftID)
if err != nil {
return nil, internalErr()
}
if !found || preview.Issued >= preview.SupplyTotal {
return nil, starGiftInvalidErr()
}
return &tg.PaymentsStarGiftUpgradePreview{
SampleAttributes: tgStarGiftPreviewAttributes(preview),
Prices: []tg.StarGiftUpgradePrice{},
NextPrices: []tg.StarGiftUpgradePrice{},
}, nil
}
func (r *Router) onPaymentsGetUniqueStarGift(ctx context.Context, slug string) (*tg.PaymentsUniqueStarGift, error) {
if r.deps.Gifts == nil || strings.TrimSpace(slug) == "" {
return nil, starGiftInvalidErr()
}
viewerUserID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
unique, found, err := r.deps.Gifts.UniqueBySlug(ctx, slug)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, starGiftInvalidErr()
}
out := &tg.PaymentsUniqueStarGift{
Gift: tgUniqueStarGift(unique),
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
switch unique.Owner.Type {
case domain.PeerTypeUser:
ids := []int64{unique.Owner.ID}
if unique.KeepOriginalDetails && !unique.OriginalNameHidden && unique.OriginalFromUserID != 0 && unique.OriginalFromUserID != unique.Owner.ID {
ids = append(ids, unique.OriginalFromUserID)
}
out.Users = tgUsersForViewer(viewerUserID, r.domainUsersForIDs(ctx, viewerUserID, ids))
case domain.PeerTypeChannel:
out.Chats = r.tgChatsForChannelIDs(ctx, viewerUserID, []int64{unique.Owner.ID})
}
return out, nil
}
func tgStarGiftPreviewAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass {
out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops))
for _, attribute := range preview.Models {
out = append(out, tgStarGiftAttribute(attribute))
}
for _, attribute := range preview.Patterns {
out = append(out, tgStarGiftAttribute(attribute))
}
for _, attribute := range preview.Backdrops {
out = append(out, tgStarGiftAttribute(attribute))
}
return out
}
func tgStarGiftAttribute(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeClass {
rarity := &tg.StarGiftAttributeRarity{Permille: attribute.RarityPermille}
switch attribute.Kind {
case domain.StarGiftCollectibleModel:
document := tg.DocumentClass(&tg.DocumentEmpty{})
if attribute.Document != nil {
document = tgDocument(*attribute.Document)
}
return &tg.StarGiftAttributeModel{Name: attribute.Name, Document: document, Rarity: rarity}
case domain.StarGiftCollectiblePattern:
document := tg.DocumentClass(&tg.DocumentEmpty{})
if attribute.Document != nil {
document = tgDocument(*attribute.Document)
}
return &tg.StarGiftAttributePattern{Name: attribute.Name, Document: document, Rarity: rarity}
case domain.StarGiftCollectibleBackdrop:
return &tg.StarGiftAttributeBackdrop{
Name: attribute.Name, BackdropID: attribute.BackdropID,
CenterColor: attribute.CenterColor, EdgeColor: attribute.EdgeColor,
PatternColor: attribute.PatternColor, TextColor: attribute.TextColor, Rarity: rarity,
}
default:
return &tg.StarGiftAttributeBackdrop{Name: attribute.Name, Rarity: rarity}
}
}
func tgUniqueStarGift(unique domain.UniqueStarGift) *tg.StarGiftUnique {
attributes := []tg.StarGiftAttributeClass{
tgStarGiftAttribute(unique.Model),
tgStarGiftAttribute(unique.Pattern),
tgStarGiftAttribute(unique.Backdrop),
}
if unique.KeepOriginalDetails && unique.OriginalOwner.ID != 0 {
original := &tg.StarGiftAttributeOriginalDetails{
RecipientID: tgPeer(unique.OriginalOwner),
Date: unique.OriginalDate,
}
if unique.OriginalFromUserID != 0 && !unique.OriginalNameHidden {
original.SetSenderID(&tg.PeerUser{UserID: unique.OriginalFromUserID})
}
if unique.OriginalMessage != "" {
original.SetMessage(tg.TextWithEntities{Text: unique.OriginalMessage})
}
attributes = append(attributes, original)
}
out := &tg.StarGiftUnique{
ID: unique.ID, GiftID: unique.GiftID, Title: unique.Title, Slug: unique.Slug, Num: unique.Num,
Attributes: attributes, AvailabilityIssued: unique.AvailabilityIssued, AvailabilityTotal: unique.AvailabilityTotal,
}
if owner := tgPeer(unique.Owner); owner != nil {
out.SetOwnerID(owner)
}
return out
}

View file

@ -77,6 +77,9 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
}
return r.starsTopupPaymentForm(userID, purpose), nil
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
return r.starGiftUpgradePaymentForm(ctx, userID, inv)
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
@ -85,18 +88,32 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
if r.deps.Gifts == nil {
return nil, notImplementedErr()
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer); err != nil {
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer)
if err != nil {
return nil, err
}
if inv.IncludeUpgrade && peer.Type != domain.PeerTypeUser {
// Channel upgrades remain blocked until they can advance channel pts and
// publish a durable channel update. Never collect a prepaid upgrade that
// the recipient cannot consume.
return nil, starGiftInvalidErr()
}
gift, err := r.starGiftFromCatalog(ctx, inv.GiftID)
if err != nil {
return nil, err
}
upgradeStars := int64(0)
if inv.IncludeUpgrade {
if gift.UpgradeStars <= 0 || gift.UpgradeIssued >= gift.UpgradeTotal {
return nil, starGiftInvalidErr()
}
upgradeStars = gift.UpgradeStars
}
return &tg.PaymentsPaymentFormStarGift{
FormID: starGiftFormID(userID, inv.GiftID),
FormID: starGiftFormIDWithUpgrade(userID, peer, gift, inv.IncludeUpgrade),
Invoice: tg.Invoice{
Currency: "XTR",
Prices: []tg.LabeledPrice{{Label: giftPriceLabel(gift), Amount: gift.Stars}},
Prices: []tg.LabeledPrice{{Label: giftPriceLabel(gift), Amount: gift.Stars + upgradeStars}},
},
}, nil
}
@ -119,6 +136,9 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
return r.sendStarsTopupForm(ctx, userID, req.FormID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
return r.sendStarGiftUpgradeForm(ctx, userID, req.FormID, inv)
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
@ -131,6 +151,9 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
if (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
if inv.IncludeUpgrade && peer.Type != domain.PeerTypeUser {
return nil, starGiftInvalidErr()
}
if r.deps.Stars == nil || r.deps.Gifts == nil {
return nil, notImplementedErr()
}
@ -144,13 +167,24 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
if err != nil {
return nil, err
}
upgradeStars := int64(0)
if inv.IncludeUpgrade {
if gift.UpgradeStars <= 0 || gift.UpgradeIssued >= gift.UpgradeTotal {
return nil, starGiftInvalidErr()
}
upgradeStars = gift.UpgradeStars
}
if req.FormID != starGiftFormIDWithUpgrade(userID, peer, gift, inv.IncludeUpgrade) {
return nil, starsFormAmountMismatchErr()
}
giftMessage := ""
if m, ok := inv.GetMessage(); ok {
giftMessage = clampGiftMessage(m.Text)
}
// 1. Debit 送礼人不足→BALANCE_TOO_LOW
balance, err := r.deps.Stars.Debit(ctx, userID, gift.Stars, domain.StarsReasonGift, peer, "Star gift", gift.Title)
purchaseStars := gift.Stars + upgradeStars
balance, err := r.deps.Stars.Debit(ctx, userID, purchaseStars, domain.StarsReasonGift, peer, "Star gift", gift.Title)
if err != nil {
return nil, starsErr(err)
}
@ -158,14 +192,14 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
var updates *tg.Updates
switch peer.Type {
case domain.PeerTypeUser:
updates, err = r.sendStarGiftToUser(ctx, userID, peer.ID, gift, inv.HideName, giftMessage)
updates, err = r.sendStarGiftToUser(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars)
case domain.PeerTypeChannel:
updates, err = r.sendStarGiftToChannel(ctx, userID, peer.ID, gift, inv.HideName, giftMessage)
default:
err = domain.ErrStarGiftInvalid
}
if err != nil {
r.refundStarGift(ctx, userID, peer, gift)
r.refundStarGift(ctx, userID, peer, gift, purchaseStars)
return nil, internalErr()
}
@ -260,23 +294,25 @@ func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, i
return &tg.PaymentsPaymentResult{Updates: starsBalanceUpdates(balance.Balance, r.clock.Now().Unix())}, nil
}
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string) (*tg.Updates, error) {
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (*tg.Updates, error) {
// 2. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message)
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message, prepaidUpgradeStars)
if err != nil {
return nil, err
}
// 3. 记账收礼人收到一份礼物实例msg_id = 收礼人侧消息 id
if _, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID},
FromUserID: senderID,
GiftID: gift.ID,
MsgID: send.RecipientMessage.ID,
Date: send.RecipientMessage.Date,
NameHidden: hideName,
Unsaved: false,
ConvertStars: gift.ConvertStars,
Message: message,
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID},
FromUserID: senderID,
GiftID: gift.ID,
RevisionID: gift.RevisionID,
MsgID: send.RecipientMessage.ID,
Date: send.RecipientMessage.Date,
NameHidden: hideName,
Unsaved: false,
ConvertStars: gift.ConvertStars,
PrepaidUpgradeStars: prepaidUpgradeStars,
Message: message,
}); err != nil {
return nil, err
}
@ -294,21 +330,25 @@ func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID
action := domain.ChannelMessageAction{
Type: domain.ChannelActionStarGift,
StarGift: &domain.MessageStarGiftAction{
GiftID: gift.ID,
Stars: gift.Stars,
ConvertStars: gift.ConvertStars,
Title: gift.Title,
Sticker: &sticker,
Message: message,
FromUserID: senderID,
NameHidden: hideName,
Saved: true,
GiftID: gift.ID,
Stars: gift.Stars,
ConvertStars: gift.ConvertStars,
Title: gift.Title,
Sticker: &sticker,
Message: message,
FromUserID: senderID,
NameHidden: hideName,
Saved: true,
CanUpgrade: false,
PrepaidUpgrade: false,
UpgradeStars: 0,
},
}
savedID, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
FromUserID: senderID,
GiftID: gift.ID,
RevisionID: gift.RevisionID,
MsgID: 0,
SavedID: 0,
Date: now,
@ -335,7 +375,7 @@ func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID
}
// deliverStarGift 经 SendPrivateText 把 messageActionStarGift 服务消息投递到收礼人私聊。
func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string) (domain.SendPrivateTextResult, error) {
func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SendPrivateTextResult, error) {
recipientBlocked, err := r.peerBlocksUser(ctx, senderID, recipientID)
if err != nil {
return domain.SendPrivateTextResult{}, err
@ -347,16 +387,19 @@ func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int6
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{
GiftID: gift.ID,
Stars: gift.Stars,
ConvertStars: gift.ConvertStars,
Title: gift.Title,
Sticker: &sticker,
Message: message,
FromUserID: senderID,
PeerUserID: recipientID,
NameHidden: hideName,
Saved: true,
GiftID: gift.ID,
Stars: gift.Stars,
ConvertStars: gift.ConvertStars,
Title: gift.Title,
Sticker: &sticker,
Message: message,
FromUserID: senderID,
PeerUserID: recipientID,
NameHidden: hideName,
Saved: true,
CanUpgrade: gift.UpgradeStars > 0,
PrepaidUpgrade: prepaidUpgradeStars > 0,
UpgradeStars: gift.UpgradeStars,
},
},
}
@ -374,8 +417,8 @@ func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int6
}
// refundStarGift 补偿退款(投递/记账失败时把已 Debit 的星退回)。
func (r *Router) refundStarGift(ctx context.Context, userID int64, peer domain.Peer, gift domain.StarGift) {
if _, err := r.deps.Stars.Credit(ctx, userID, gift.Stars, domain.StarsReasonGift, peer, "Star gift refund", gift.Title); err != nil {
func (r *Router) refundStarGift(ctx context.Context, userID int64, peer domain.Peer, gift domain.StarGift, amount int64) {
if _, err := r.deps.Stars.Credit(ctx, userID, amount, domain.StarsReasonGift, peer, "Star gift refund", gift.Title); err != nil {
r.log.Error("star gift refund failed", zap.Int64("user_id", userID), zap.Int64("gift_id", gift.ID), zap.Error(err))
}
}
@ -396,11 +439,27 @@ func (r *Router) onPaymentsGetSavedStarGifts(ctx context.Context, req *tg.Paymen
if r.deps.Gifts == nil {
return emptySavedStarGifts(), nil
}
page, err := r.deps.Gifts.ListSaved(ctx, owner, req.ExcludeUnsaved, req.Offset, req.Limit)
collectionID, _ := req.GetCollectionID()
page, err := r.deps.Gifts.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
Owner: owner,
ExcludeUnsaved: req.ExcludeUnsaved,
ExcludeSaved: req.ExcludeSaved,
ExcludeUnlimited: req.ExcludeUnlimited,
ExcludeUnique: req.ExcludeUnique,
ExcludeUpgradable: req.ExcludeUpgradable,
ExcludeUnupgradable: req.ExcludeUnupgradable,
CollectionID: collectionID,
Offset: req.Offset,
Limit: req.Limit,
})
if err != nil {
return nil, internalErr()
}
return r.tgSavedStarGiftsResponse(ctx, userID, page.Gifts, page.Count, page.NextOffset), nil
response, err := r.tgSavedStarGiftsResponse(ctx, userID, page.Gifts, page.Count, page.NextOffset)
if err != nil {
return nil, internalErr()
}
return response, nil
}
// onPaymentsGetSavedStarGift 按 InputSavedStarGift 引用取指定礼物。
@ -429,7 +488,11 @@ func (r *Router) onPaymentsGetSavedStarGift(ctx context.Context, refs []tg.Input
gifts = append(gifts, g)
}
}
return r.tgSavedStarGiftsResponse(ctx, userID, gifts, len(gifts), ""), nil
response, err := r.tgSavedStarGiftsResponse(ctx, userID, gifts, len(gifts), "")
if err != nil {
return nil, internalErr()
}
return response, nil
}
// onPaymentsSaveStarGift 切换礼物在资料的展示unsave=true 隐藏)。
@ -565,9 +628,15 @@ func (r *Router) starGiftRefFromInput(ctx context.Context, userID int64, ref tg.
}
func (r *Router) ensureCanManageStarGiftOwner(ctx context.Context, userID int64, owner domain.Peer) error {
if owner.Type != domain.PeerTypeChannel {
if owner.Type == domain.PeerTypeUser {
if owner.ID != userID {
return peerIDInvalidErr()
}
return nil
}
if owner.Type != domain.PeerTypeChannel {
return peerIDInvalidErr()
}
if r.deps.Channels == nil {
return notImplementedErr()
}
@ -590,11 +659,41 @@ func (r *Router) invalidateStarGiftOwnerProjection(owner domain.Peer) {
}
}
func (r *Router) tgSavedStarGiftsResponse(ctx context.Context, viewerUserID int64, gifts []domain.SavedStarGift, count int, nextOffset string) *tg.PaymentsSavedStarGifts {
catalog := r.resolveStarGiftCatalog(ctx, gifts)
func (r *Router) tgSavedStarGiftsResponse(ctx context.Context, viewerUserID int64, gifts []domain.SavedStarGift, count int, nextOffset string) (*tg.PaymentsSavedStarGifts, error) {
uniqueIDs := make([]int64, 0)
seenUnique := make(map[int64]struct{})
for _, gift := range gifts {
if gift.UniqueGiftID != 0 {
if _, seen := seenUnique[gift.UniqueGiftID]; !seen {
seenUnique[gift.UniqueGiftID] = struct{}{}
uniqueIDs = append(uniqueIDs, gift.UniqueGiftID)
}
}
}
if len(uniqueIDs) > 0 {
uniques, err := r.deps.Gifts.UniqueByIDs(ctx, uniqueIDs)
if err != nil {
return nil, err
}
for i := range gifts {
if unique, ok := uniques[gifts[i].UniqueGiftID]; ok {
copy := unique
gifts[i].Unique = &copy
}
}
}
catalog, err := r.resolveStarGiftCatalog(ctx, gifts)
if err != nil {
return nil, err
}
availability, err := r.resolveStarGiftCollectibleAvailability(ctx, gifts)
if err != nil {
return nil, err
}
projected := tgSavedStarGifts(gifts, catalog, availability)
out := &tg.PaymentsSavedStarGifts{
Count: count,
Gifts: tgSavedStarGifts(gifts, catalog),
Gifts: projected,
Chats: []tg.ChatClass{},
}
if ids := savedStarGiftUserIDs(gifts); len(ids) > 0 {
@ -605,7 +704,35 @@ func (r *Router) tgSavedStarGiftsResponse(ctx context.Context, viewerUserID int6
if nextOffset != "" {
out.SetNextOffset(nextOffset)
}
return out
return out, nil
}
func (r *Router) resolveStarGiftCollectibleAvailability(ctx context.Context, gifts []domain.SavedStarGift) (map[int64]domain.StarGiftCollectibleAvailability, error) {
out := make(map[int64]domain.StarGiftCollectibleAvailability)
if r.deps.Gifts == nil {
return out, nil
}
ids := make([]int64, 0, len(gifts))
seen := make(map[int64]struct{}, len(gifts))
for _, gift := range gifts {
if gift.UniqueGiftID != 0 {
continue
}
if gift.Owner.Type != domain.PeerTypeUser {
// Channel upgrade RPCs are deliberately blocked until the channel pts
// aggregate exists, so do not advertise a dead-end action.
continue
}
if _, ok := seen[gift.GiftID]; ok {
continue
}
seen[gift.GiftID] = struct{}{}
ids = append(ids, gift.GiftID)
}
if len(ids) == 0 {
return out, nil
}
return r.deps.Gifts.CollectibleAvailability(ctx, ids)
}
func emptySavedStarGifts() *tg.PaymentsSavedStarGifts {
@ -636,6 +763,9 @@ func tgStarGift(g domain.StarGift) *tg.StarGift {
if g.Title != "" {
gift.SetTitle(g.Title)
}
if g.UpgradeStars > 0 && g.UpgradeIssued < g.UpgradeTotal {
gift.SetUpgradeStars(g.UpgradeStars)
}
return gift
}
@ -667,6 +797,14 @@ func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionC
if in.Converted {
action.Converted = true
}
action.CanUpgrade = in.CanUpgrade
action.PrepaidUpgrade = in.PrepaidUpgrade
if in.UpgradeStars > 0 {
action.SetUpgradeStars(in.UpgradeStars)
}
if in.UpgradeMsgID > 0 {
action.SetUpgradeMsgID(in.UpgradeMsgID)
}
if in.ConvertStars > 0 {
action.SetConvertStars(in.ConvertStars)
}
@ -687,30 +825,38 @@ func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionC
return action
}
// resolveStarGiftCatalog 解析这批 saved gift 涉及的目录项giftID → StarGift供下发完整贴纸/星价
func (r *Router) resolveStarGiftCatalog(ctx context.Context, gifts []domain.SavedStarGift) map[int64]domain.StarGift {
// resolveStarGiftCatalog 解析这批 saved gift 涉及的不可变目录版本revisionID → StarGift
func (r *Router) resolveStarGiftCatalog(ctx context.Context, gifts []domain.SavedStarGift) (map[int64]domain.StarGift, error) {
out := make(map[int64]domain.StarGift, len(gifts))
if r.deps.Gifts == nil {
return out
return out, nil
}
for _, g := range gifts {
if _, ok := out[g.GiftID]; ok {
if g.RevisionID == 0 {
return nil, domain.ErrStarGiftInvalid
}
if _, ok := out[g.RevisionID]; ok {
continue
}
if gift, found, err := r.deps.Gifts.GiftByID(ctx, g.GiftID); err == nil && found {
out[g.GiftID] = gift
gift, found, err := r.deps.Gifts.GiftRevisionByID(ctx, g.RevisionID)
if err != nil {
return nil, err
}
if !found {
return nil, domain.ErrStarGiftInvalid
}
out[g.RevisionID] = gift
}
return out
return out, nil
}
// tgSavedStarGifts 把已收到礼物实例投影为 []tg.SavedStarGift。
func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.StarGift) []tg.SavedStarGift {
func tgSavedStarGifts(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{
Date: g.Date,
Gift: tgSavedStarGiftGift(g, catalog),
Gift: tgSavedStarGiftGift(g, catalog, availability),
}
if g.NameHidden {
item.NameHidden = true
@ -733,19 +879,47 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
if g.Message != "" {
item.SetMessage(tg.TextWithEntities{Text: g.Message})
}
if g.UniqueGiftID == 0 {
current, available := availability[g.GiftID]
canIssue := available && current.UpgradeStars > 0 && current.Issued < current.SupplyTotal
if canIssue {
item.CanUpgrade = true
}
if g.PrepaidUpgradeStars > 0 && canIssue {
item.SetUpgradeStars(g.PrepaidUpgradeStars)
item.CanUpgrade = true
}
}
if g.PinnedOrder > 0 {
item.PinnedToTop = true
}
if len(g.CollectionIDs) > 0 {
item.SetCollectionID(append([]int(nil), g.CollectionIDs...))
}
if g.Unique != nil {
item.SetGiftNum(g.Unique.Num)
}
out = append(out, item)
}
return out
}
// tgSavedStarGiftGift 把 SavedStarGift 内嵌礼物投影为 tg.StarGift优先用目录解析出完整贴纸/星价
// (客户端据有效 sticker 渲染目录缺失礼物已下架时兜底最小形态。convert_stars 用实例值。
func tgSavedStarGiftGift(g domain.SavedStarGift, catalog map[int64]domain.StarGift) tg.StarGiftClass {
if gift, ok := catalog[g.GiftID]; ok {
// tgSavedStarGiftGift 按收到时的不可变 revision 投影,目录停用或后续改版不影响历史显示。
func tgSavedStarGiftGift(g domain.SavedStarGift, catalog map[int64]domain.StarGift, availability map[int64]domain.StarGiftCollectibleAvailability) tg.StarGiftClass {
if g.Unique != nil {
return tgUniqueStarGift(*g.Unique)
}
if gift, ok := catalog[g.RevisionID]; ok {
if current, ok := availability[g.GiftID]; ok {
gift.UpgradeStars = current.UpgradeStars
gift.UpgradeTotal = current.SupplyTotal
gift.UpgradeIssued = current.Issued
}
out := tgStarGift(gift)
out.ConvertStars = g.ConvertStars
return out
}
// resolveStarGiftCatalog 在进入投影前保证每个 revision 都存在;该分支仅保留类型完备性。
return &tg.StarGift{
ID: g.GiftID,
Sticker: &tg.DocumentEmpty{},
@ -770,8 +944,18 @@ func savedStarGiftUserIDs(gifts []domain.SavedStarGift) []int64 {
return ids
}
func starGiftFormID(userID, giftID int64) int64 {
id := userID*0x9e3779b1 ^ (giftID << 7) ^ 0x5347494654
func starGiftFormID(userID int64, peer domain.Peer, gift domain.StarGift) int64 {
return starGiftFormIDWithUpgrade(userID, peer, gift, false)
}
func starGiftFormIDWithUpgrade(userID int64, peer domain.Peer, gift domain.StarGift, includeUpgrade bool) int64 {
id := userID*0x9e3779b1 ^ (gift.ID << 7) ^ (gift.RevisionID << 11) ^ (gift.Stars << 17) ^ (peer.ID << 23) ^ 0x5347494654
if includeUpgrade {
id ^= gift.UpgradeStars<<29 ^ 0x55504752414445
}
for _, ch := range string(peer.Type) {
id = id*131 + int64(ch)
}
if id == 0 {
id = 0x5347
}

View file

@ -4,6 +4,7 @@ import (
"context"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
@ -18,12 +19,6 @@ import (
"telesrv/internal/store/memory"
)
type stubGiftCatalog struct{ gifts []domain.StarGift }
func (s stubGiftCatalog) BuildStarGiftCatalog(_ context.Context) ([]domain.StarGift, error) {
return s.gifts, nil
}
func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain.StarGift) {
t.Helper()
ctx := context.Background()
@ -40,10 +35,12 @@ func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain
t.Fatalf("create recipient: %v", err)
}
gift := domain.StarGift{
ID: 8001, Stars: 50, ConvertStars: 50, Title: "Cake",
Sticker: domain.Document{ID: 700, AccessHash: 7, DCID: 2, MimeType: "image/webp"},
ID: 8001, RevisionID: 9001, Stars: 50, ConvertStars: 50, Title: "Cake",
Sticker: domain.Document{ID: 700, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
}
gifts := appstargifts.NewService(memory.NewStarGiftStore(), stubGiftCatalog{[]domain.StarGift{gift}})
giftStore := memory.NewStarGiftStore()
giftStore.SeedCatalog([]domain.StarGift{gift})
gifts := appstargifts.NewService(giftStore, nil, 2)
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Users: appusers.NewService(users),
Messages: appmessages.NewService(msgStore, dialogs),
@ -54,6 +51,328 @@ func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain
return r, sender, recipient, gift
}
type uniqueGiftRPCService struct {
GiftsService
unique domain.UniqueStarGift
}
func (s *uniqueGiftRPCService) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) {
return s.unique, slug == s.unique.Slug, nil
}
func collectibleRPCAttribute(kind domain.StarGiftCollectibleAttributeKind, id int64, name string) domain.StarGiftCollectibleAttribute {
attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Name: name, RarityPermille: 1000}
if kind == domain.StarGiftCollectibleBackdrop {
attribute.BackdropID = int(id)
attribute.CenterColor = 0x112233
attribute.EdgeColor = 0x223344
attribute.PatternColor = 0x334455
attribute.TextColor = 0xffffff
return attribute
}
attribute.Document = &domain.Document{
ID: id, AccessHash: id + 1, FileReference: []byte("collectible-rpc"), Date: 1700000000,
MimeType: "application/x-tgsticker", Size: 3, DCID: 2,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}, {Kind: domain.DocAttrFilename, FileName: "gift.tgs"}},
}
attribute.Animation = &domain.StarGiftAnimation{
SourceName: "gift.tgs", SourceFormat: domain.StarGiftAnimationTGS,
JSON: []byte(`{"v":"5.7"}`), TGS: []byte("tgs"), SHA256: make([]byte, 32), Width: 512, Height: 512,
}
attribute.Blob = &domain.FileBlob{LocationKey: "doc:test", Backend: domain.MediaBackendLocalFS, ObjectKey: "test", Size: 3}
return attribute
}
func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleAvailability(t *testing.T) {
historical := domain.StarGift{
ID: 8001, RevisionID: 9001, Stars: 50, ConvertStars: 25, Title: "Historical Cake",
Sticker: domain.Document{ID: 700, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker"},
}
saved := domain.SavedStarGift{GiftID: historical.ID, RevisionID: historical.RevisionID, MsgID: 44, Date: 100, ConvertStars: historical.ConvertStars}
availability := map[int64]domain.StarGiftCollectibleAvailability{
historical.ID: {UpgradeStars: 75, SupplyTotal: 500, Issued: 12},
}
projected := tgSavedStarGifts([]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)
}
gift, ok := projected[0].Gift.(*tg.StarGift)
if !ok {
t.Fatalf("saved gift inner = %T, want *tg.StarGift", projected[0].Gift)
}
if gift.Title != historical.Title || gift.Stars != historical.Stars || gift.ConvertStars != historical.ConvertStars {
t.Fatalf("historical snapshot changed: %#v", gift)
}
if upgradeStars, ok := gift.GetUpgradeStars(); !ok || upgradeStars != 75 {
t.Fatalf("upgrade_stars = %d ok=%v, want current price 75", upgradeStars, ok)
}
for _, profile := range []tg.LayerProfile{tg.LayerProfile227, tg.LayerProfile228} {
wire := &tg.PaymentsSavedStarGifts{Count: 1, Gifts: projected, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}
encoded := &bin.Buffer{}
if err := tg.EncodeLayer(profile, tg.LayerConstructorPaymentsSavedStarGiftsType(), wire, encoded); err != nil {
t.Fatalf("encode Layer %d saved gift: %v", profile, err)
}
decoded, err := tg.DecodeLayer(profile, tg.LayerConstructorPaymentsSavedStarGiftsType(), &bin.Buffer{Buf: encoded.Buf})
if err != nil {
t.Fatalf("decode Layer %d saved gift: %v", profile, err)
}
inner, ok := decoded.Gifts[0].Gift.(*tg.StarGift)
if !ok || !decoded.Gifts[0].CanUpgrade || inner.UpgradeStars != 75 {
t.Fatalf("Layer %d projection lost upgrade flags: %#v", profile, decoded.Gifts[0])
}
}
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]
if soldOut.CanUpgrade {
t.Fatal("sold-out collectible pool must not advertise upgrade")
}
if gift, ok := soldOut.Gift.(*tg.StarGift); !ok {
t.Fatalf("sold-out inner = %T, want *tg.StarGift", soldOut.Gift)
} else if _, ok := gift.GetUpgradeStars(); ok {
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]
if soldOutPrepaid.CanUpgrade {
t.Fatal("sold-out prepaid gift must not advertise an upgrade the aggregate will reject")
}
if _, ok := soldOutPrepaid.GetUpgradeStars(); ok {
t.Fatal("sold-out prepaid gift must not expose stale prepaid upgrade_stars")
}
}
func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *testing.T) {
r, sender, owner, gift := starGiftTestRouter(t)
ctx := context.Background()
ownerCtx := WithUserID(ctx, owner.ID)
giftService, ok := r.deps.Gifts.(*appstargifts.Service)
if !ok {
t.Fatalf("gift service = %T", r.deps.Gifts)
}
model := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8101, "Aurora")
pattern := collectibleRPCAttribute(domain.StarGiftCollectiblePattern, 8102, "Orbit")
backdrop := collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 1, "Midnight")
if _, err := giftService.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: gift.ID, UpgradeStars: 75, SupplyTotal: 500, SlugPrefix: "cake",
Models: []domain.StarGiftCollectibleAttribute{model}, Patterns: []domain.StarGiftCollectibleAttribute{pattern},
Backdrops: []domain.StarGiftCollectibleAttribute{backdrop}, Actor: "test", CommandID: "collectible-rpc",
}); err != nil {
t.Fatalf("publish collectible pool: %v", err)
}
if _, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, FromUserID: sender.ID,
GiftID: gift.ID, RevisionID: gift.RevisionID, MsgID: 444, Date: 1700000000, ConvertStars: gift.ConvertStars,
}); err != nil {
t.Fatalf("record upgrade target: %v", err)
}
preview, err := r.onPaymentsGetStarGiftUpgradePreview(ownerCtx, gift.ID)
if err != nil || len(preview.SampleAttributes) != 3 {
t.Fatalf("upgrade preview = %#v err %v", preview, err)
}
invoice := &tg.InputInvoiceStarGiftUpgrade{Stargift: &tg.InputSavedStarGiftUser{MsgID: 444}}
formClass, err := r.onPaymentsGetPaymentForm(ownerCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice})
if err != nil {
t.Fatalf("get upgrade payment form: %v", err)
}
form, ok := formClass.(*tg.PaymentsPaymentFormStarGift)
if !ok || form.FormID == 0 || form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != 75 {
t.Fatalf("upgrade payment form = %T %#v", formClass, formClass)
}
unique := domain.UniqueStarGift{
ID: 9200000000000001, GiftID: gift.ID, Title: gift.Title, Slug: "cake-1", Num: 1,
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
Model: model, Pattern: pattern, Backdrop: backdrop,
AvailabilityIssued: 1, AvailabilityTotal: 500, KeepOriginalDetails: true,
OriginalFromUserID: sender.ID, OriginalOwner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
OriginalDate: 1700000000, OriginalMessage: "hello",
}
r.deps.Gifts = &uniqueGiftRPCService{GiftsService: r.deps.Gifts, unique: unique}
uniqueResponse, err := r.onPaymentsGetUniqueStarGift(WithUserID(ctx, sender.ID), unique.Slug)
if err != nil {
t.Fatalf("get unique gift = %#v err %v", uniqueResponse, err)
}
uniqueGift, ok := uniqueResponse.Gift.(*tg.StarGiftUnique)
if !ok || uniqueGift.Slug != unique.Slug || len(uniqueGift.Attributes) != 4 || len(uniqueResponse.Users) != 2 {
t.Fatalf("get unique gift = %#v", uniqueResponse)
}
message := domain.Message{Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Gift: unique, FromUserID: sender.ID, Peer: unique.Owner, Upgrade: true, Saved: true,
},
}}}
action, ok := tgMessageServiceAction(message).(*tg.MessageActionStarGiftUnique)
if !ok {
t.Fatalf("unique service action type = %T", tgMessageServiceAction(message))
}
projectedGift, giftOK := action.Gift.(*tg.StarGiftUnique)
if !giftOK || !action.Upgrade || !action.Saved || projectedGift.ID != unique.ID || projectedGift.Slug != unique.Slug {
t.Fatalf("unique service action = %#v", tgMessageServiceAction(message))
}
if peer, ok := action.GetPeer(); !ok {
t.Fatal("unique service action missing owner peer")
} else if user, ok := peer.(*tg.PeerUser); !ok || user.UserID != owner.ID {
t.Fatalf("unique service action peer = %#v", peer)
}
for _, profile := range []tg.LayerProfile{tg.LayerProfile227, tg.LayerProfile228} {
responseWire := &bin.Buffer{}
if err := tg.EncodeLayer(profile, tg.LayerConstructorPaymentsUniqueStarGiftType(), uniqueResponse, responseWire); err != nil {
t.Fatalf("encode Layer %d unique response: %v", profile, err)
}
decodedResponse, err := tg.DecodeLayer(profile, tg.LayerConstructorPaymentsUniqueStarGiftType(), &bin.Buffer{Buf: responseWire.Buf})
if err != nil {
t.Fatalf("decode Layer %d unique response: %v", profile, err)
}
decodedGift, ok := decodedResponse.Gift.(*tg.StarGiftUnique)
if !ok || decodedGift.Slug != unique.Slug || len(decodedGift.Attributes) != 4 {
t.Fatalf("Layer %d unique response lost fields: %#v", profile, decodedResponse.Gift)
}
actionWire := &bin.Buffer{}
if err := tg.EncodeLayer(profile, tg.LayerConstructorMessageActionStarGiftUniqueType(), action, actionWire); err != nil {
t.Fatalf("encode Layer %d unique action: %v", profile, err)
}
decodedAction, err := tg.DecodeLayer(profile, tg.LayerConstructorMessageActionStarGiftUniqueType(), &bin.Buffer{Buf: actionWire.Buf})
if err != nil {
t.Fatalf("decode Layer %d unique action: %v", profile, err)
}
if decodedActionGift, ok := decodedAction.Gift.(*tg.StarGiftUnique); !ok || !decodedAction.Upgrade || decodedActionGift.Slug != unique.Slug {
t.Fatalf("Layer %d unique action lost fields: %#v", profile, decodedAction)
}
}
}
func TestStarGiftCollectionsCRUDFilterOrderAndPin(t *testing.T) {
r, _, owner, gift := starGiftTestRouter(t)
ctx := WithUserID(context.Background(), owner.ID)
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
for _, msgID := range []int{101, 102} {
if _, err := r.deps.Gifts.RecordSavedGift(context.Background(), domain.SavedStarGift{
Owner: ownerPeer, GiftID: gift.ID, RevisionID: gift.RevisionID,
MsgID: msgID, Date: 1700000000 + msgID, ConvertStars: gift.ConvertStars,
}); err != nil {
t.Fatalf("record saved gift %d: %v", msgID, err)
}
}
ref101 := tg.InputSavedStarGiftClass(&tg.InputSavedStarGiftUser{MsgID: 101})
ref102 := tg.InputSavedStarGiftClass(&tg.InputSavedStarGiftUser{MsgID: 102})
first, err := r.onPaymentsCreateStarGiftCollection(ctx, &tg.PaymentsCreateStarGiftCollectionRequest{
Peer: &tg.InputPeerSelf{}, Title: " Favorites ", Stargift: []tg.InputSavedStarGiftClass{ref101},
})
if err != nil {
t.Fatalf("create first collection: %v", err)
}
second, err := r.onPaymentsCreateStarGiftCollection(ctx, &tg.PaymentsCreateStarGiftCollectionRequest{
Peer: &tg.InputPeerSelf{}, Title: "Archive", Stargift: []tg.InputSavedStarGiftClass{ref102},
})
if err != nil {
t.Fatalf("create second collection: %v", err)
}
if first.Title != "Favorites" || first.GiftsCount != 1 || second.GiftsCount != 1 {
t.Fatalf("created collections = %#v / %#v", first, second)
}
listedClass, err := r.onPaymentsGetStarGiftCollections(ctx, &tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerSelf{}})
if err != nil {
t.Fatalf("list collections: %v", err)
}
listed, ok := listedClass.(*tg.PaymentsStarGiftCollections)
if !ok || len(listed.Collections) != 2 {
t.Fatalf("list collections = %T %#v, want two", listedClass, listedClass)
}
domainCollections, err := r.deps.Gifts.ListCollections(context.Background(), ownerPeer)
if err != nil {
t.Fatalf("list domain collections: %v", err)
}
if notModified, err := r.onPaymentsGetStarGiftCollections(ctx, &tg.PaymentsGetStarGiftCollectionsRequest{
Peer: &tg.InputPeerSelf{}, Hash: domain.StarGiftCollectionsHash(domainCollections),
}); err != nil {
t.Fatalf("hash list collections: %v", err)
} else if _, ok := notModified.(*tg.PaymentsStarGiftCollectionsNotModified); !ok {
t.Fatalf("hash response = %T, want not modified", notModified)
}
update := &tg.PaymentsUpdateStarGiftCollectionRequest{Peer: &tg.InputPeerSelf{}, CollectionID: first.CollectionID}
update.SetTitle("Best")
update.SetAddStargift([]tg.InputSavedStarGiftClass{ref102})
update.SetOrder([]tg.InputSavedStarGiftClass{ref102, ref101})
updated, err := r.onPaymentsUpdateStarGiftCollection(ctx, update)
if err != nil {
t.Fatalf("update collection: %v", err)
}
if updated.Title != "Best" || updated.GiftsCount != 2 {
t.Fatalf("updated collection = %#v", updated)
}
filteredReq := &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}, Limit: 10}
filteredReq.SetCollectionID(first.CollectionID)
filtered, err := r.onPaymentsGetSavedStarGifts(ctx, filteredReq)
if err != nil {
t.Fatalf("filter saved gifts by collection: %v", err)
}
if filtered.Count != 2 || len(filtered.Gifts) != 2 {
t.Fatalf("filtered gifts count=%d len=%d, want 2/2", filtered.Count, len(filtered.Gifts))
}
for _, saved := range filtered.Gifts {
ids, ok := saved.GetCollectionID()
if !ok || len(ids) == 0 || ids[0] != first.CollectionID {
t.Fatalf("saved gift collection projection = %#v", saved)
}
}
if ok, err := r.onPaymentsToggleStarGiftsPinnedToTop(ctx, &tg.PaymentsToggleStarGiftsPinnedToTopRequest{
Peer: &tg.InputPeerSelf{}, Stargift: []tg.InputSavedStarGiftClass{ref101, ref102},
}); err != nil || !ok {
t.Fatalf("pin gifts = %v err %v", ok, err)
}
pinned, err := r.onPaymentsGetSavedStarGifts(ctx, &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}, Limit: 10})
if err != nil {
t.Fatalf("list pinned gifts: %v", err)
}
if len(pinned.Gifts) != 2 || !pinned.Gifts[0].PinnedToTop || !pinned.Gifts[1].PinnedToTop {
t.Fatalf("pinned projection = %#v", pinned.Gifts)
}
if ok, err := r.onPaymentsReorderStarGiftCollections(ctx, &tg.PaymentsReorderStarGiftCollectionsRequest{
Peer: &tg.InputPeerSelf{}, Order: []int{second.CollectionID, first.CollectionID},
}); err != nil || !ok {
t.Fatalf("reorder collections = %v err %v", ok, err)
}
reorderedClass, err := r.onPaymentsGetStarGiftCollections(ctx, &tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerSelf{}})
if err != nil {
t.Fatalf("list reordered collections: %v", err)
}
reordered := reorderedClass.(*tg.PaymentsStarGiftCollections)
if reordered.Collections[0].CollectionID != second.CollectionID {
t.Fatalf("reordered collections = %#v", reordered.Collections)
}
if ok, err := r.onPaymentsDeleteStarGiftCollection(ctx, &tg.PaymentsDeleteStarGiftCollectionRequest{
Peer: &tg.InputPeerSelf{}, CollectionID: first.CollectionID,
}); err != nil || !ok {
t.Fatalf("delete collection = %v err %v", ok, err)
}
afterDelete, err := r.onPaymentsGetSavedStarGifts(ctx, &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}, Limit: 10})
if err != nil {
t.Fatalf("list gifts after collection delete: %v", err)
}
for _, saved := range afterDelete.Gifts {
if ids, ok := saved.GetCollectionID(); ok {
for _, id := range ids {
if id == first.CollectionID {
t.Fatalf("deleted collection %d leaked in saved gift %#v", id, saved)
}
}
}
}
}
// 完整 star gift sagacatalog → getPaymentForm(paymentFormStarGift) → sendStarsForm(扣费+服务消息
// +paymentResult) → 收礼人 getSavedStarGifts → save/convert。
func TestStarGiftSaga(t *testing.T) {
@ -214,6 +533,21 @@ func TestStarGiftChannelSaga(t *testing.T) {
channel := created.Channel
channelPeer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
channelInput := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
giftService := r.deps.Gifts.(*appstargifts.Service)
if _, err := giftService.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: gift.ID, UpgradeStars: 75, SupplyTotal: 10, SlugPrefix: "channel-cake",
Models: []domain.StarGiftCollectibleAttribute{collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8201, "Aurora")},
Patterns: []domain.StarGiftCollectibleAttribute{collectibleRPCAttribute(domain.StarGiftCollectiblePattern, 8202, "Orbit")},
Backdrops: []domain.StarGiftCollectibleAttribute{collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 2, "Midnight")},
Actor: "test", CommandID: "channel-collectible-rpc",
}); err != nil {
t.Fatalf("publish channel collectible pool: %v", err)
}
if _, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: &tg.InputInvoiceStarGift{
Peer: channelPeer, GiftID: gift.ID, IncludeUpgrade: true,
}}); err == nil {
t.Fatal("channel include_upgrade must be rejected while channel upgrade is blocked")
}
inv := &tg.InputInvoiceStarGift{
Peer: channelPeer,
GiftID: gift.ID,
@ -265,6 +599,9 @@ func TestStarGiftChannelSaga(t *testing.T) {
if savedRes.Count != 1 || len(savedRes.Gifts) != 1 {
t.Fatalf("channel saved gifts = count %d len %d, want 1/1", savedRes.Count, len(savedRes.Gifts))
}
if savedRes.Gifts[0].CanUpgrade {
t.Fatal("channel saved gift must not advertise upgrade while channel aggregate is blocked")
}
savedID, ok := savedRes.Gifts[0].GetSavedID()
if !ok || savedID <= 0 {
t.Fatalf("saved gift saved_id = %d ok %v, want positive", savedID, ok)
@ -366,17 +703,20 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
msgStore := memory.NewMessageStore(dialogs)
sender, _ := users.Create(ctx, domain.User{AccessHash: 7201, Phone: "15550007201", FirstName: "Poor"})
recipient, _ := users.Create(ctx, domain.User{AccessHash: 7202, Phone: "15550007202", FirstName: "Rich"})
gift := domain.StarGift{ID: 8002, Stars: 5000, ConvertStars: 5000, Title: "Expensive",
Sticker: domain.Document{ID: 701, AccessHash: 7, DCID: 2, MimeType: "image/webp"}}
gift := domain.StarGift{ID: 8002, RevisionID: 9002, Stars: 5000, ConvertStars: 5000, Title: "Expensive",
Sticker: domain.Document{ID: 701, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}}
giftStore := memory.NewStarGiftStore()
giftStore.SeedCatalog([]domain.StarGift{gift})
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Users: appusers.NewService(users),
Messages: appmessages.NewService(msgStore, dialogs),
Stars: appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(1000)), // < 5000
Gifts: appstargifts.NewService(memory.NewStarGiftStore(), stubGiftCatalog{[]domain.StarGift{gift}}),
Gifts: appstargifts.NewService(giftStore, nil, 2),
}, zaptest.NewLogger(t), clock.System)
senderCtx := WithUserID(ctx, sender.ID)
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID}
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{Invoice: inv}); err == nil {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: starGiftFormID(sender.ID, peer, gift), Invoice: inv}); err == nil {
t.Fatalf("over-budget gift should error BALANCE_TOO_LOW")
}
// 余额未变。
@ -385,6 +725,25 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
}
}
func TestStarGiftFormBindsCatalogRevisionAndPrice(t *testing.T) {
r, sender, recipient, gift := starGiftTestRouter(t)
ctx := WithUserID(context.Background(), sender.ID)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}
base := starGiftFormID(sender.ID, peer, gift)
changedRevision := gift
changedRevision.RevisionID++
changedPrice := gift
changedPrice.Stars++
changedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID + 1}
if base == starGiftFormID(sender.ID, peer, changedRevision) || base == starGiftFormID(sender.ID, peer, changedPrice) || base == starGiftFormID(sender.ID, changedPeer, gift) {
t.Fatal("star gift form id must bind revision, price and recipient")
}
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID}
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: base + 1, Invoice: inv}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
t.Fatalf("bad form err=%v", err)
}
}
func TestStarsTopupInvoiceFallbackCreditsBalance(t *testing.T) {
r, sender, _, _ := starGiftTestRouter(t)
ctx := context.Background()