feat: sync official star gift lifecycle tooling

Sync telesrv 4c0e2d9 (feat: complete official star gift lifecycle and admin tooling).

Public adjustments: skipped private docs/deploy-nginx/README source changes, kept iamxvbaba/td public dependency, replaced local/private sample IP and orange seed label.
This commit is contained in:
A 2026-07-19 03:11:35 +08:00
parent f2c2fd0236
commit 14bf7d1e20
92 changed files with 14768 additions and 727 deletions

View file

@ -284,6 +284,8 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
}
case domain.ChannelActionStarGift:
return tgMessageActionStarGift(action.StarGift)
case domain.ChannelActionStarGiftUnique:
return tgMessageActionStarGiftUnique(action.StarGiftUnique)
case domain.ChannelActionSetChatWallpaper:
if wallpaper := tgWallpaper(action.Wallpaper); wallpaper != nil {
return &tg.MessageActionSetChatWallPaper{Wallpaper: wallpaper}

View file

@ -221,29 +221,69 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
case domain.MessageServiceActionStarGift:
return tgMessageActionStarGift(m.ServiceAction.StarGift)
case domain.MessageServiceActionStarGiftUnique:
action := m.ServiceAction.StarGiftUnique
return tgMessageActionStarGiftUnique(m.ServiceAction.StarGiftUnique)
case domain.MessageServiceActionStarGiftOffer:
action := m.ServiceAction.StarGiftOffer
if action == nil {
return &tg.MessageActionEmpty{}
}
out := &tg.MessageActionStarGiftUnique{
Upgrade: action.Upgrade, Saved: action.Saved, PrepaidUpgrade: action.PrepaidUpgrade,
Gift: tgUniqueStarGift(action.Gift),
return &tg.MessageActionStarGiftPurchaseOffer{Accepted: action.Accepted, Declined: action.Declined,
Gift: tgUniqueStarGift(action.Gift), Price: tgStarGiftAmount(action.Price), ExpiresAt: action.ExpiresAt}
case domain.MessageServiceActionStarGiftOfferDeclined:
action := m.ServiceAction.StarGiftOfferDeclined
if action == nil {
return &tg.MessageActionEmpty{}
}
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
return &tg.MessageActionStarGiftPurchaseOfferDeclined{Expired: action.Expired,
Gift: tgUniqueStarGift(action.Gift), Price: tgStarGiftAmount(action.Price)}
default:
return &tg.MessageActionEmpty{}
}
}
func tgMessageActionStarGiftUnique(action *domain.MessageStarGiftUniqueAction) tg.MessageActionClass {
if action == nil {
return &tg.MessageActionEmpty{}
}
out := &tg.MessageActionStarGiftUnique{
Upgrade: action.Upgrade, Saved: action.Saved, PrepaidUpgrade: action.PrepaidUpgrade,
Transferred: action.Transferred, Refunded: action.Refunded, Assigned: action.Assigned,
FromOffer: action.FromOffer, Craft: action.Craft,
Gift: tgUniqueStarGift(action.Gift),
}
if action.CanExportAt > 0 {
out.SetCanExportAt(action.CanExportAt)
}
if action.TransferStars > 0 {
out.SetTransferStars(action.TransferStars)
}
if action.ResaleAmount != nil {
out.SetResaleAmount(tgStarGiftAmount(*action.ResaleAmount))
}
if action.CanTransferAt > 0 {
out.SetCanTransferAt(action.CanTransferAt)
}
if action.CanResellAt > 0 {
out.SetCanResellAt(action.CanResellAt)
}
if action.DropOriginalDetailsStars > 0 {
out.SetDropOriginalDetailsStars(action.DropOriginalDetailsStars)
}
if action.CanCraftAt > 0 {
out.SetCanCraftAt(action.CanCraftAt)
}
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
}
func tgPeerList(peers []domain.Peer) []tg.PeerClass {
out := make([]tg.PeerClass, 0, len(peers))
for _, peer := range peers {

View file

@ -872,6 +872,7 @@ type GiftsService interface {
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)
UpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, 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)
@ -879,13 +880,36 @@ type GiftsService interface {
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)
ConvertAggregate(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, 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
ListResale(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error)
ValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error)
SetListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error)
Transfer(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error)
PurchaseResale(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error)
SendOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error)
ResolveOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error)
ListCraft(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error)
Craft(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error)
AuctionState(ctx context.Context, userID, giftID int64, slug string, now int) (domain.StarGiftAuction, error)
ActiveAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error)
AuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error)
BidAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error)
PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error)
PrepayUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error)
DropOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error)
SetNotifications(ctx context.Context, userID, channelID int64, enabled bool) error
Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error)
TonBalance(ctx context.Context, userID int64) (int64, error)
TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error)
IssuePurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error)
ValidatePurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error
Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error)
}
// StarsService 抽象 Stars 本地账本(app/stars):余额查询、贷记/借记、流水分页。

View file

@ -137,6 +137,10 @@ func starsFormAmountMismatchErr() error { return tgerr.New(406, "STARS_FORM_AMOU
func formIDEmptyErr() error { return tgerr.New(400, "FORM_ID_EMPTY") }
func formExpiredErr() error { return tgerr.New(400, "FORM_EXPIRED") }
func purposeInvalidErr() error { return tgerr.New(400, "PURPOSE_INVALID") }
func suggestedPostPeerInvalidErr() error { return tgerr.New(400, "SUGGESTED_POST_PEER_INVALID") }
func storyIDInvalidErr() error { return tgerr.New(400, "STORY_ID_INVALID") }

View file

@ -33,12 +33,11 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
registerRPC[*tg.PaymentsGetStarsTransactionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTransactions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTransactionsRequest) (any, error) {
return r.onPaymentsGetStarsTransactions(ctx, layerRequest)
})
registerRPC[*tg.PaymentsCheckCanSendGiftRequest](d, tlprofile.SemanticMethodPaymentsCheckCanSendGift, func(ctx context.Context, req *tg.PaymentsCheckCanSendGiftRequest) (any, error) {
return r.onPaymentsCheckCanSendGift(ctx, req)
})
registerRPC[*tg.PaymentsGetStarGiftActiveAuctionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftActiveAuctions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftActiveAuctionsRequest) (any, error) {
hash := layerRequest.
Hash
_ = hash
return tdesktop.StarGiftActiveAuctions(), nil
return r.onPaymentsGetStarGiftActiveAuctions(ctx, layerRequest)
})
registerRPC[*tg.PaymentsGetStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGifts, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftsRequest) (any, error) {
return r.onPaymentsGetStarGifts(ctx, layerRequest.
@ -48,10 +47,19 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
return r.onPaymentsGetStarGiftUpgradePreview(ctx, layerRequest.
GiftID)
})
registerRPC[*tg.PaymentsGetStarGiftUpgradeAttributesRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftUpgradeAttributes, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftUpgradeAttributesRequest) (any, error) {
return r.onPaymentsGetStarGiftUpgradeAttributes(ctx, layerRequest.GiftID)
})
registerRPC[*tg.PaymentsGetUniqueStarGiftRequest](d, tlprofile.SemanticMethodPaymentsGetUniqueStarGift, func(ctx context.Context, layerRequest *tg.PaymentsGetUniqueStarGiftRequest) (any, error) {
return r.onPaymentsGetUniqueStarGift(ctx, layerRequest.
Slug)
})
registerRPC[*tg.PaymentsGetUniqueStarGiftValueInfoRequest](d, tlprofile.SemanticMethodPaymentsGetUniqueStarGiftValueInfo, func(ctx context.Context, req *tg.PaymentsGetUniqueStarGiftValueInfoRequest) (any, error) {
return r.onPaymentsGetUniqueStarGiftValueInfo(ctx, req)
})
registerRPC[*tg.PaymentsGetResaleStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetResaleStarGifts, func(ctx context.Context, req *tg.PaymentsGetResaleStarGiftsRequest) (any, error) {
return r.onPaymentsGetResaleStarGifts(ctx, req)
})
registerRPC[*tg.PaymentsGetPaymentFormRequest](d, tlprofile.SemanticMethodPaymentsGetPaymentForm, func(ctx context.Context, layerRequest *tg.PaymentsGetPaymentFormRequest) (any, error) {
return r.onPaymentsGetPaymentForm(ctx, layerRequest)
})
@ -75,6 +83,36 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
registerRPC[*tg.PaymentsUpgradeStarGiftRequest](d, tlprofile.SemanticMethodPaymentsUpgradeStarGift, func(ctx context.Context, layerRequest *tg.PaymentsUpgradeStarGiftRequest) (any, error) {
return r.onPaymentsUpgradeStarGift(ctx, layerRequest)
})
registerRPC[*tg.PaymentsUpdateStarGiftPriceRequest](d, tlprofile.SemanticMethodPaymentsUpdateStarGiftPrice, func(ctx context.Context, req *tg.PaymentsUpdateStarGiftPriceRequest) (any, error) {
return r.onPaymentsUpdateStarGiftPrice(ctx, req)
})
registerRPC[*tg.PaymentsTransferStarGiftRequest](d, tlprofile.SemanticMethodPaymentsTransferStarGift, func(ctx context.Context, req *tg.PaymentsTransferStarGiftRequest) (any, error) {
return r.onPaymentsTransferStarGift(ctx, req)
})
registerRPC[*tg.PaymentsGetStarGiftWithdrawalURLRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftWithdrawalURL, func(ctx context.Context, req *tg.PaymentsGetStarGiftWithdrawalURLRequest) (any, error) {
return r.onPaymentsGetStarGiftWithdrawalURL(ctx, req)
})
registerRPC[*tg.PaymentsSendStarGiftOfferRequest](d, tlprofile.SemanticMethodPaymentsSendStarGiftOffer, func(ctx context.Context, req *tg.PaymentsSendStarGiftOfferRequest) (any, error) {
return r.onPaymentsSendStarGiftOffer(ctx, req)
})
registerRPC[*tg.PaymentsResolveStarGiftOfferRequest](d, tlprofile.SemanticMethodPaymentsResolveStarGiftOffer, func(ctx context.Context, req *tg.PaymentsResolveStarGiftOfferRequest) (any, error) {
return r.onPaymentsResolveStarGiftOffer(ctx, req)
})
registerRPC[*tg.PaymentsGetCraftStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetCraftStarGifts, func(ctx context.Context, req *tg.PaymentsGetCraftStarGiftsRequest) (any, error) {
return r.onPaymentsGetCraftStarGifts(ctx, req)
})
registerRPC[*tg.PaymentsCraftStarGiftRequest](d, tlprofile.SemanticMethodPaymentsCraftStarGift, func(ctx context.Context, req *tg.PaymentsCraftStarGiftRequest) (any, error) {
return r.onPaymentsCraftStarGift(ctx, req)
})
registerRPC[*tg.PaymentsGetStarGiftAuctionStateRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftAuctionState, func(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionStateRequest) (any, error) {
return r.onPaymentsGetStarGiftAuctionState(ctx, req)
})
registerRPC[*tg.PaymentsGetStarGiftAuctionAcquiredGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftAuctionAcquiredGifts, func(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionAcquiredGiftsRequest) (any, error) {
return r.onPaymentsGetStarGiftAuctionAcquiredGifts(ctx, req)
})
registerRPC[*tg.PaymentsToggleChatStarGiftNotificationsRequest](d, tlprofile.SemanticMethodPaymentsToggleChatStarGiftNotifications, func(ctx context.Context, req *tg.PaymentsToggleChatStarGiftNotificationsRequest) (any, error) {
return r.onPaymentsToggleChatStarGiftNotifications(ctx, req)
})
registerRPC[*tg.PaymentsGetStarGiftCollectionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftCollections, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftCollectionsRequest) (any, error) {
return r.onPaymentsGetStarGiftCollections(ctx, layerRequest)
})
@ -108,36 +146,116 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
return &tg.PaymentsStarsRevenueAdsAccountURL{URL: "https://ads.telegram.org/"}, nil
})
registerRPC[*tg.PaymentsGetStarsRevenueStatsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsRevenueStats, func(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (any, 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.StarsRevenueStats(req.GetTon()), nil
return r.onPaymentsGetStarsRevenueStats(ctx, req)
})
}
// onPaymentsGetStarsStatus 返回当前账号的 Stars 余额(首读时惰性授予起始余额)。
// 响应必须是 payments.starsStatus(balance/chats/users 都是必填,空 vector 即可)——
// 两端客户端无条件读取 balance(DrKLO StarsAmount 反序列化 / TDesktop vbalance())。
func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) {
if req != nil && req.GetTon() {
// TON 余额未建模:返回 0 nanoton 的合法响应。
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
if r.deps.Stars == nil {
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
// onPaymentsGetStarsRevenueStats exposes real channel Star Gift proceeds from
// the same peer-scoped ledger as getStarsStatus/getStarsTransactions. Personal
// and bot revenue remain the bounded compatibility response because their
// revenue bucket is distinct from the general Stars balance and is not modeled.
func (r *Router) onPaymentsGetStarsRevenueStats(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (*tg.PaymentsStarsRevenueStats, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if req == nil {
return nil, peerIDInvalidErr()
}
owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
ton := req.GetTon()
if owner.Type != domain.PeerTypeChannel {
return tdesktop.StarsRevenueStats(ton), nil
}
if err := r.checkStarGiftOwnerPermission(ctx, userID, owner); err != nil {
return nil, err
}
ledger, ok := r.deps.Gifts.(channelGiftLedgerReader)
if !ok {
return tdesktop.StarsRevenueStats(ton), nil
}
var balance int64
if ton {
balance, err = ledger.ChannelTonBalance(ctx, owner.ID)
} else {
balance, err = ledger.ChannelStarsBalance(ctx, owner.ID)
}
if err != nil {
return nil, internalErr()
}
stats := tdesktop.StarsRevenueStats(ton)
var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance}
if ton {
amount = &tg.StarsTonAmount{Amount: balance}
}
// Channel ledgers currently only receive collectible conversion/marketplace
// proceeds and have no withdrawal/debit path, so balance equals lifetime
// revenue. Withdrawal stays disabled because no external payout exists.
stats.Status.CurrentBalance = amount
stats.Status.AvailableBalance = amount
stats.Status.OverallRevenue = amount
return stats, nil
}
type channelGiftLedgerReader interface {
ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error)
ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error)
ChannelTonBalance(ctx context.Context, channelID int64) (int64, error)
ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error)
}
// onPaymentsGetStarsStatus 返回请求 peer 的 Stars/本地 TON 余额。个人与频道账本
// 严格隔离;频道读取要求 Star Gift 管理权限,不能把频道收益投影到执行 RPC 的管理员。
// 响应必须是 payments.starsStatus(balance/chats/users 都是必填,空 vector 即可)——
// 两端客户端无条件读取 balance(DrKLO StarsAmount 反序列化 / TDesktop vbalance())。
func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) {
userID, owner, err := r.starGiftLedgerOwner(ctx, req)
if err != nil {
return nil, err
}
ton := req != nil && req.GetTon()
if owner.Type == domain.PeerTypeChannel {
ledger, ok := r.deps.Gifts.(channelGiftLedgerReader)
if !ok {
if ton {
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
var balance int64
if ton {
balance, err = ledger.ChannelTonBalance(ctx, owner.ID)
} else {
balance, err = ledger.ChannelStarsBalance(ctx, owner.ID)
}
if err != nil {
return nil, internalErr()
}
var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance}
if ton {
amount = &tg.StarsTonAmount{Amount: balance}
}
out := emptyStarsStatus(amount)
out.Chats = r.tgChatsForChannelIDs(ctx, userID, []int64{owner.ID})
return out, nil
}
if ton {
if r.deps.Gifts == nil {
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
balance, err := r.deps.Gifts.TonBalance(ctx, userID)
if err != nil {
return nil, internalErr()
}
return emptyStarsStatus(&tg.StarsTonAmount{Amount: balance}), nil
}
if r.deps.Stars == nil {
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
bal, err := r.deps.Stars.GetBalance(ctx, userID)
if err != nil {
return nil, starsErr(err)
@ -148,24 +266,82 @@ func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsG
// onPaymentsGetStarsTransactions 返回 keyset 分页的 Stars 流水(同 starsStatus 信封)。
// 末页必须省略 next_offset(flag 不置),否则 DrKLO 会无限翻页。
func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (*tg.PaymentsStarsStatus, error) {
if req != nil && req.GetTon() {
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
if r.deps.Stars == nil {
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
userID, _, err := r.currentUserID(ctx)
userID, owner, err := r.starGiftTransactionLedgerOwner(ctx, req)
if err != nil {
return nil, internalErr()
return nil, err
}
offset := ""
limit := domain.MaxStarsTransactionsLimit
offset, limit := "", domain.MaxStarsTransactionsLimit
if req != nil {
offset = req.Offset
if req.Limit > 0 {
limit = req.Limit
}
}
ton := req != nil && req.GetTon()
if owner.Type == domain.PeerTypeChannel {
ledger, ok := r.deps.Gifts.(channelGiftLedgerReader)
if !ok {
if ton {
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
if ton {
page, err := ledger.ChannelTonTransactions(ctx, owner.ID, offset, limit)
if err != nil {
return nil, internalErr()
}
out := emptyStarsStatus(&tg.StarsTonAmount{Amount: page.Balance})
if txns := tgTonTransactions(page.Transactions); len(txns) > 0 {
out.SetHistory(txns)
}
if page.NextOffset != "" {
out.SetNextOffset(page.NextOffset)
}
r.enrichChannelTonLedgerStatus(ctx, userID, owner.ID, page.Transactions, out)
return out, nil
}
page, err := ledger.ChannelStarsTransactions(ctx, owner.ID, offset, limit)
if err != nil {
return nil, internalErr()
}
out := emptyStarsStatus(&tg.StarsAmount{Amount: page.Balance})
if txns := tgStarsTransactions(page.Transactions); len(txns) > 0 {
out.SetHistory(txns)
}
if page.NextOffset != "" {
out.SetNextOffset(page.NextOffset)
}
r.enrichChannelStarsLedgerStatus(ctx, userID, owner.ID, page.Transactions, out)
return out, nil
}
if ton {
if r.deps.Gifts == nil {
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
page, err := r.deps.Gifts.TonTransactions(ctx, userID, offset, limit)
if err != nil {
return nil, internalErr()
}
out := emptyStarsStatus(&tg.StarsTonAmount{Amount: page.Balance})
if txns := tgTonTransactions(page.Transactions); len(txns) > 0 {
out.SetHistory(txns)
}
if page.NextOffset != "" {
out.SetNextOffset(page.NextOffset)
}
ids := make([]int64, 0)
for _, txn := range page.Transactions {
if txn.Peer.Type == domain.PeerTypeUser {
ids = append(ids, txn.Peer.ID)
}
}
out.Users = tgUsersForViewer(userID, r.domainUsersForIDs(ctx, userID, uniqueInt64(ids)))
return out, nil
}
if r.deps.Stars == nil {
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
page, err := r.deps.Stars.ListTransactions(ctx, userID, offset, limit)
if err != nil {
return nil, starsErr(err)
@ -184,6 +360,71 @@ func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.Pay
return out, nil
}
func (r *Router) starGiftLedgerOwner(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (int64, domain.Peer, error) {
if req == nil {
return 0, domain.Peer{}, peerIDInvalidErr()
}
return r.starGiftLedgerOwnerForPeer(ctx, req.Peer)
}
func (r *Router) starGiftTransactionLedgerOwner(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (int64, domain.Peer, error) {
if req == nil {
return 0, domain.Peer{}, peerIDInvalidErr()
}
return r.starGiftLedgerOwnerForPeer(ctx, req.Peer)
}
func (r *Router) starGiftLedgerOwnerForPeer(ctx context.Context, input tg.InputPeerClass) (int64, domain.Peer, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return 0, domain.Peer{}, internalErr()
}
owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
if err != nil {
return 0, domain.Peer{}, err
}
if owner.Type == domain.PeerTypeUser {
if owner.ID != userID {
return 0, domain.Peer{}, peerIDInvalidErr()
}
return userID, owner, nil
}
if err := r.checkStarGiftOwnerPermission(ctx, userID, owner); err != nil {
return 0, domain.Peer{}, err
}
return userID, owner, nil
}
func (r *Router) enrichChannelStarsLedgerStatus(ctx context.Context, viewerID, ownerChannelID int64, txns []domain.StarsTransaction, out *tg.PaymentsStarsStatus) {
userIDs := make([]int64, 0, len(txns))
channelIDs := []int64{ownerChannelID}
for _, txn := range txns {
switch txn.Peer.Type {
case domain.PeerTypeUser:
userIDs = append(userIDs, txn.Peer.ID)
case domain.PeerTypeChannel:
channelIDs = append(channelIDs, txn.Peer.ID)
}
}
out.Users = tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(userIDs)))
out.Chats = r.tgChatsForChannelIDs(ctx, viewerID, uniqueInt64(channelIDs))
}
func (r *Router) enrichChannelTonLedgerStatus(ctx context.Context, viewerID, ownerChannelID int64, txns []domain.TonTransaction, out *tg.PaymentsStarsStatus) {
userIDs := make([]int64, 0, len(txns))
channelIDs := []int64{ownerChannelID}
for _, txn := range txns {
switch txn.Peer.Type {
case domain.PeerTypeUser:
userIDs = append(userIDs, txn.Peer.ID)
case domain.PeerTypeChannel:
channelIDs = append(channelIDs, txn.Peer.ID)
}
}
out.Users = tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(userIDs)))
out.Chats = r.tgChatsForChannelIDs(ctx, viewerID, uniqueInt64(channelIDs))
}
// emptyStarsStatus 构造一个合法的最小 payments.starsStatus(chats/users 非空 vector 但可空)。
func emptyStarsStatus(balance tg.StarsAmountClass) *tg.PaymentsStarsStatus {
return &tg.PaymentsStarsStatus{
@ -214,6 +455,49 @@ func tgStarsTransactions(in []domain.StarsTransaction) []tg.StarsTransaction {
item.Reaction = true
case domain.StarsReasonGift:
item.Gift = true
case domain.StarsReasonGiftUpgrade:
item.StargiftUpgrade = true
case domain.StarsReasonGiftResale:
item.StargiftResale = true
case domain.StarsReasonGiftPrepaid:
item.StargiftPrepaidUpgrade = true
case domain.StarsReasonGiftDrop:
item.StargiftDropOriginalDetails = true
case domain.StarsReasonGiftAuction:
item.StargiftAuctionBid = true
case domain.StarsReasonGiftOffer:
item.Offer = true
}
out = append(out, item)
}
return out
}
func tgTonTransactions(in []domain.TonTransaction) []tg.StarsTransaction {
out := make([]tg.StarsTransaction, 0, len(in))
for _, t := range in {
item := tg.StarsTransaction{ID: strconv.FormatInt(t.ID, 10), Amount: &tg.StarsTonAmount{Amount: t.Amount},
Date: t.Date, Peer: tgStarsTransactionPeer(domain.StarsTransaction{Peer: t.Peer, Reason: t.Reason})}
if t.Amount > 0 {
item.Refund = true
}
if t.Title != "" {
item.SetTitle(t.Title)
}
if t.Description != "" {
item.SetDescription(t.Description)
}
switch t.Reason {
case domain.StarsReasonGiftResale:
item.StargiftResale = true
case domain.StarsReasonGiftOffer:
item.Offer = true
case domain.StarsReasonGiftAuction:
item.StargiftAuctionBid = true
case domain.StarsReasonGiftPrepaid:
item.StargiftPrepaidUpgrade = true
case domain.StarsReasonGiftDrop:
item.StargiftDropOriginalDetails = true
}
out = append(out, item)
}

View file

@ -0,0 +1,98 @@
package rpc
import (
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/domain"
)
func TestStarGiftCatalogProjectionKeepsSaleDatesBehindSoldOutFlag(t *testing.T) {
base := domain.StarGift{
ID: 8001,
RevisionID: 9001,
Stars: 100,
ConvertStars: 85,
Title: "Fresh Socks",
FirstSaleDate: 100,
LastSaleDate: 200,
Sticker: domain.Document{
ID: 700,
AccessHash: 7,
DCID: 2,
MimeType: "application/x-tgsticker",
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
},
}
tests := []struct {
name string
gift domain.StarGift
wantSoldOut bool
wantSaleDate bool
}{
{name: "unlimited live gift with operational sale history", gift: base},
{name: "limited live gift", gift: func() domain.StarGift {
gift := base
gift.Limited = true
gift.AvailabilityRemains = 9
gift.AvailabilityTotal = 10
return gift
}()},
{name: "sold out gift", gift: func() domain.StarGift {
gift := base
gift.Limited = true
gift.SoldOut = true
gift.AvailabilityTotal = 10
return gift
}(), wantSoldOut: true, wantSaleDate: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for _, profile := range []tlprofile.Profile{
tlprofile.Profile225,
tlprofile.Profile226,
tlprofile.Profile227,
tlprofile.Profile228,
} {
response := &tg.PaymentsStarGifts{
Hash: 1,
Gifts: []tg.StarGiftClass{tgStarGift(test.gift)},
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
wire := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, response, wire); err != nil {
t.Fatalf("encode Layer %d catalog: %v", profile, err)
}
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Buf}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode Layer %d catalog: %v", profile, err)
}
decoded, ok := decodedObject.(*tg.PaymentsStarGifts)
if !ok || len(decoded.Gifts) != 1 {
t.Fatalf("decode Layer %d catalog = %T %#v", profile, decodedObject, decodedObject)
}
gift, ok := decoded.Gifts[0].(*tg.StarGift)
if !ok {
t.Fatalf("decode Layer %d gift = %T", profile, decoded.Gifts[0])
}
if gift.SoldOut != test.wantSoldOut {
t.Fatalf("Layer %d sold_out = %v, want %v", profile, gift.SoldOut, test.wantSoldOut)
}
first, firstSet := gift.GetFirstSaleDate()
last, lastSet := gift.GetLastSaleDate()
if firstSet != test.wantSaleDate || lastSet != test.wantSaleDate {
t.Fatalf("Layer %d sale date flags = (%v,%v), want %v", profile, firstSet, lastSet, test.wantSaleDate)
}
if test.wantSaleDate && (first != test.gift.FirstSaleDate || last != test.gift.LastSaleDate) {
t.Fatalf("Layer %d sale dates = (%d,%d), want (%d,%d)", profile, first, last, test.gift.FirstSaleDate, test.gift.LastSaleDate)
}
}
})
}
}

File diff suppressed because it is too large Load diff

View file

@ -26,18 +26,37 @@ func (r *Router) starGiftUpgradePaymentForm(ctx context.Context, userID int64, i
}
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)
saved, err := r.starGiftUpgradeSavedTarget(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()
commandKey := fmt.Sprintf("paid:%d:%d:%t", saved.ID, formID, inv.KeepOriginalDetails)
receipt, replay, err := r.deps.Gifts.UpgradeReceipt(ctx, userID, commandKey)
if err != nil {
return nil, internalErr()
}
chargeStars := int64(0)
if replay {
if receipt.SourceSavedGiftID != saved.ID || receipt.FormID != formID || receipt.RequirePrepaid ||
receipt.KeepOriginalDetails != inv.KeepOriginalDetails || receipt.ChargeStars <= 0 {
return nil, starGiftInvalidErr()
}
chargeStars = receipt.ChargeStars
} else {
preview, err := r.starGiftUpgradePreviewForSaved(ctx, saved)
if err != nil {
return nil, err
}
wantFormID := starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails)
if formID == 0 || formID != wantFormID {
return nil, starsFormAmountMismatchErr()
}
chargeStars = preview.UpgradeStars
}
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),
UserID: userID, Ref: starGiftUpgradeSavedRef(saved),
KeepOriginalDetails: inv.KeepOriginalDetails, ChargeStars: chargeStars,
FormID: formID, CommandKey: commandKey,
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionIDOrZero(ctx),
})
@ -57,17 +76,32 @@ func (r *Router) onPaymentsUpgradeStarGift(ctx context.Context, req *tg.Payments
if err != nil {
return nil, internalErr()
}
saved, _, err := r.starGiftUpgradeTarget(ctx, userID, req.Stargift)
saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, req.Stargift)
if err != nil {
return nil, err
}
if saved.PrepaidUpgradeStars <= 0 {
return nil, starGiftInvalidErr()
commandKey := fmt.Sprintf("prepaid:%d:%t", saved.ID, req.KeepOriginalDetails)
receipt, replay, err := r.deps.Gifts.UpgradeReceipt(ctx, userID, commandKey)
if err != nil {
return nil, internalErr()
}
if replay {
if receipt.SourceSavedGiftID != saved.ID || receipt.FormID != 0 || !receipt.RequirePrepaid ||
receipt.KeepOriginalDetails != req.KeepOriginalDetails || receipt.ChargeStars != 0 {
return nil, starGiftInvalidErr()
}
} else {
if _, err := r.starGiftUpgradePreviewForSaved(ctx, saved); 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},
UserID: userID, Ref: starGiftUpgradeSavedRef(saved),
KeepOriginalDetails: req.KeepOriginalDetails, RequirePrepaid: true,
CommandKey: fmt.Sprintf("prepaid:%d:%t", saved.ID, req.KeepOriginalDetails),
CommandKey: commandKey,
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionIDOrZero(ctx),
})
@ -79,33 +113,60 @@ func (r *Router) onPaymentsUpgradeStarGift(ctx context.Context, req *tg.Payments
}
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)
saved, err := r.starGiftUpgradeSavedTarget(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()
preview, err := r.starGiftUpgradePreviewForSaved(ctx, saved)
return saved, preview, err
}
func (r *Router) starGiftUpgradeSavedTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, error) {
if r.deps.Gifts == nil {
return domain.SavedStarGift{}, notImplementedErr()
}
ref, ok, err := r.starGiftRefFromInput(ctx, userID, input)
if err != nil {
return domain.SavedStarGift{}, err
}
if !ok {
return domain.SavedStarGift{}, starGiftInvalidErr()
}
if err := r.checkStarGiftOwnerPermission(ctx, userID, ref.Owner); err != nil {
return domain.SavedStarGift{}, err
}
saved, found, err := r.deps.Gifts.GetSaved(ctx, ref)
if err != nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, internalErr()
return domain.SavedStarGift{}, internalErr()
}
if !found || saved.Converted || saved.UniqueGiftID != 0 {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
if !found {
return domain.SavedStarGift{}, starGiftInvalidErr()
}
return saved, nil
}
func (r *Router) starGiftUpgradePreviewForSaved(ctx context.Context, saved domain.SavedStarGift) (domain.StarGiftUpgradePreview, error) {
if saved.Converted || saved.UniqueGiftID != 0 {
return domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
}
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, saved.GiftID)
if err != nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, internalErr()
return domain.StarGiftUpgradePreview{}, internalErr()
}
if !found || preview.UpgradeStars <= 0 || preview.Issued >= preview.SupplyTotal {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
return domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
}
return saved, preview, nil
return preview, nil
}
func starGiftUpgradeSavedRef(saved domain.SavedStarGift) domain.SavedStarGiftRef {
ref := domain.SavedStarGiftRef{Owner: saved.Owner}
if saved.Owner.Type == domain.PeerTypeChannel {
ref.SavedID = saved.SavedID
} else {
ref.MsgID = saved.MsgID
}
return ref
}
func (r *Router) tgStarGiftUpgradeUpdates(ctx context.Context, ownerUserID int64, result domain.StarGiftUpgradeResult, includeBalance bool) *tg.Updates {
@ -116,6 +177,17 @@ func (r *Router) tgStarGiftUpgradeUpdates(ctx context.Context, ownerUserID int64
updates := tgPrivateMessageUpdates(event, message, 0, false,
r.usersForMessageUpdate(ctx, ownerUserID, message),
r.chatsForMessageUpdate(ctx, ownerUserID, message))
for _, edit := range result.SourceEdits {
if edit.UserID != ownerUserID {
continue
}
if update := tgOtherUpdateFromEvent(edit.Event); update != nil {
updates.Updates = append(updates.Updates, update)
if edit.Event.Date > updates.Date {
updates.Date = edit.Event.Date
}
}
}
if includeBalance {
updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.Balance.Balance}})
}
@ -175,6 +247,20 @@ func (r *Router) onPaymentsGetStarGiftUpgradePreview(ctx context.Context, giftID
}, nil
}
func (r *Router) onPaymentsGetStarGiftUpgradeAttributes(ctx context.Context, giftID int64) (*tg.PaymentsStarGiftUpgradeAttributes, 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 {
return nil, starGiftInvalidErr()
}
return &tg.PaymentsStarGiftUpgradeAttributes{Attributes: tgAllStarGiftAttributes(preview)}, nil
}
func (r *Router) onPaymentsGetUniqueStarGift(ctx context.Context, slug string) (*tg.PaymentsUniqueStarGift, error) {
if r.deps.Gifts == nil || strings.TrimSpace(slug) == "" {
return nil, starGiftInvalidErr()
@ -211,6 +297,9 @@ func (r *Router) onPaymentsGetUniqueStarGift(ctx context.Context, slug string) (
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 {
if attribute.Crafted {
continue
}
out = append(out, tgStarGiftAttribute(attribute))
}
for _, attribute := range preview.Patterns {
@ -222,15 +311,25 @@ func tgStarGiftPreviewAttributes(preview domain.StarGiftUpgradePreview) []tg.Sta
return out
}
func tgAllStarGiftAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass {
out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops))
for _, attributes := range [][]domain.StarGiftCollectibleAttribute{preview.Models, preview.Patterns, preview.Backdrops} {
for _, attribute := range attributes {
out = append(out, tgStarGiftAttribute(attribute))
}
}
return out
}
func tgStarGiftAttribute(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeClass {
rarity := &tg.StarGiftAttributeRarity{Permille: attribute.RarityPermille}
rarity := tgStarGiftAttributeRarity(attribute)
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}
return &tg.StarGiftAttributeModel{Name: attribute.Name, Document: document, Rarity: rarity, Crafted: attribute.Crafted}
case domain.StarGiftCollectiblePattern:
document := tg.DocumentClass(&tg.DocumentEmpty{})
if attribute.Document != nil {
@ -248,6 +347,21 @@ func tgStarGiftAttribute(attribute domain.StarGiftCollectibleAttribute) tg.StarG
}
}
func tgStarGiftAttributeRarity(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeRarityClass {
switch attribute.RarityKind {
case domain.StarGiftRarityUncommon:
return &tg.StarGiftAttributeRarityUncommon{}
case domain.StarGiftRarityRare:
return &tg.StarGiftAttributeRarityRare{}
case domain.StarGiftRarityEpic:
return &tg.StarGiftAttributeRarityEpic{}
case domain.StarGiftRarityLegendary:
return &tg.StarGiftAttributeRarityLegendary{}
default:
return &tg.StarGiftAttributeRarity{Permille: attribute.RarityPermille}
}
}
func tgUniqueStarGift(unique domain.UniqueStarGift) *tg.StarGiftUnique {
attributes := []tg.StarGiftAttributeClass{
tgStarGiftAttribute(unique.Model),
@ -268,11 +382,73 @@ func tgUniqueStarGift(unique domain.UniqueStarGift) *tg.StarGiftUnique {
attributes = append(attributes, original)
}
out := &tg.StarGiftUnique{
RequirePremium: unique.RequirePremium, ResaleTonOnly: unique.ResaleTonOnly,
ThemeAvailable: unique.ThemeAvailable, Burned: unique.Burned, Crafted: unique.Crafted,
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 {
if unique.OwnerAddress != "" {
out.SetOwnerAddress(unique.OwnerAddress)
} else if owner := tgPeer(unique.Owner); owner != nil {
out.SetOwnerID(owner)
} else if unique.OwnerName != "" {
out.SetOwnerName(unique.OwnerName)
}
if unique.GiftAddress != "" {
out.SetGiftAddress(unique.GiftAddress)
}
if unique.ResellAmount != nil {
out.SetResellAmount([]tg.StarsAmountClass{tgStarGiftAmount(*unique.ResellAmount)})
}
if peer := tgPeer(unique.ReleasedBy); peer != nil {
out.SetReleasedBy(peer)
}
if unique.ValueAmount > 0 {
out.SetValueAmount(unique.ValueAmount)
}
if unique.ValueCurrency != "" {
out.SetValueCurrency(unique.ValueCurrency)
}
if unique.ValueUSD > 0 {
out.SetValueUsdAmount(unique.ValueUSD)
}
if peer := tgPeer(unique.ThemePeer); peer != nil {
out.SetThemePeer(peer)
}
if peer := tgPeer(unique.Host); peer != nil {
out.SetHostID(peer)
}
if unique.OfferMinStars > 0 && unique.Owner.Type == domain.PeerTypeUser {
out.SetOfferMinStars(unique.OfferMinStars)
}
if unique.CraftChancePermille > 0 {
out.SetCraftChancePermille(unique.CraftChancePermille)
}
return out
}
func tgStarGiftAmount(amount domain.StarGiftAmount) tg.StarsAmountClass {
if amount.Currency == domain.StarGiftCurrencyTON {
return &tg.StarsTonAmount{Amount: amount.Amount}
}
return &tg.StarsAmount{Amount: amount.Amount, Nanos: amount.Nanos}
}
func domainStarGiftAmount(amount tg.StarsAmountClass) (domain.StarGiftAmount, bool) {
switch value := amount.(type) {
case *tg.StarsAmount:
if value == nil {
return domain.StarGiftAmount{}, false
}
out := domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: value.Amount, Nanos: value.Nanos}
return out, out.Valid()
case *tg.StarsTonAmount:
if value == nil {
return domain.StarGiftAmount{}, false
}
out := domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: value.Amount}
return out, out.Valid()
default:
return domain.StarGiftAmount{}, false
}
}

View file

@ -2,7 +2,11 @@ package rpc
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"strings"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
@ -80,6 +84,21 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
return r.starGiftUpgradePaymentForm(ctx, userID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftTransfer); ok {
return r.starGiftTransferPaymentForm(ctx, userID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftResale); ok {
return r.starGiftResalePaymentForm(ctx, userID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftAuctionBid); ok {
return r.starGiftAuctionBidPaymentForm(ctx, userID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftPrepaidUpgrade); ok {
return r.starGiftPrepaidUpgradePaymentForm(ctx, userID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftDropOriginalDetails); ok {
return r.starGiftDropDetailsPaymentForm(ctx, userID, inv)
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
@ -92,16 +111,13 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
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
}
if gift.RequirePremium && !r.viewerPremium(ctx, userID) {
return nil, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
}
upgradeStars := int64(0)
if inv.IncludeUpgrade {
if gift.UpgradeStars <= 0 || gift.UpgradeIssued >= gift.UpgradeTotal {
@ -109,8 +125,21 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
}
upgradeStars = gift.UpgradeStars
}
giftMessage := ""
if m, ok := inv.GetMessage(); ok {
giftMessage = clampGiftMessage(m.Text)
}
now := int(r.clock.Now().Unix())
form, err := r.deps.Gifts.IssuePurchaseForm(ctx, domain.StarGiftPurchaseForm{
BuyerUserID: userID, To: peer, GiftID: gift.ID, RevisionID: gift.RevisionID,
IncludeUpgrade: inv.IncludeUpgrade, HideName: inv.HideName, Message: giftMessage,
ChargeStars: gift.Stars + upgradeStars, IssuedAt: now, ExpiresAt: now + 600,
})
if err != nil {
return nil, starGiftLifecycleErr(err)
}
return &tg.PaymentsPaymentFormStarGift{
FormID: starGiftFormIDWithUpgrade(userID, peer, gift, inv.IncludeUpgrade),
FormID: form.FormID,
Invoice: tg.Invoice{
Currency: "XTR",
Prices: []tg.LabeledPrice{{Label: giftPriceLabel(gift), Amount: gift.Stars + upgradeStars}},
@ -139,11 +168,29 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
return r.sendStarGiftUpgradeForm(ctx, userID, req.FormID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftTransfer); ok {
return r.sendStarGiftTransferForm(ctx, userID, req.FormID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftResale); ok {
return r.sendStarGiftResaleForm(ctx, userID, req.FormID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftAuctionBid); ok {
return r.sendStarGiftAuctionBidForm(ctx, userID, req.FormID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftPrepaidUpgrade); ok {
return r.sendStarGiftPrepaidUpgradeForm(ctx, userID, req.FormID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftDropOriginalDetails); ok {
return r.sendStarGiftDropDetailsForm(ctx, userID, req.FormID, inv)
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
return nil, notImplementedErr()
}
if req.FormID == 0 {
return nil, formIDEmptyErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer)
if err != nil {
return nil, err
@ -151,15 +198,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()
}
if peer.Type == domain.PeerTypeUser && r.deps.Messages == nil {
return nil, notImplementedErr()
}
if peer.Type == domain.PeerTypeChannel && r.deps.Channels == nil {
return nil, notImplementedErr()
}
@ -167,6 +208,10 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
if err != nil {
return nil, err
}
buyerPremium := r.viewerPremium(ctx, userID)
if gift.RequirePremium && !buyerPremium {
return nil, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
}
upgradeStars := int64(0)
if inv.IncludeUpgrade {
if gift.UpgradeStars <= 0 || gift.UpgradeIssued >= gift.UpgradeTotal {
@ -174,27 +219,58 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
}
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)
}
now := int(r.clock.Now().Unix())
purchaseReq := domain.StarGiftPurchaseRequest{BuyerUserID: userID, BuyerPremium: buyerPremium, To: peer,
GiftID: gift.ID, RevisionID: gift.RevisionID, IncludeUpgrade: inv.IncludeUpgrade, HideName: inv.HideName, Message: giftMessage,
ChargeStars: gift.Stars + upgradeStars, FormID: req.FormID, CommandKey: fmt.Sprintf("purchase:%d", req.FormID), Date: now,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}
recipientBlocked := false
if peer.Type == domain.PeerTypeUser {
recipientBlocked, err = r.peerBlocksUser(ctx, userID, peer.ID)
if err != nil {
return nil, internalErr()
}
}
if capability, ok := r.deps.Gifts.(interface{ AtomicPurchaseConfigured() bool }); ok && !capability.AtomicPurchaseConfigured() {
if err := r.deps.Gifts.ValidatePurchaseForm(ctx, purchaseReq); err != nil {
return nil, starGiftLifecycleErr(err)
}
if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil {
return nil, starsErr(err)
}
return r.sendStarGiftMemoryPurchase(ctx, userID, peer, gift, inv, giftMessage, upgradeStars)
}
if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil {
return nil, starsErr(err)
}
purchaseReq.RecipientBlocked = recipientBlocked
result, err := r.deps.Gifts.Purchase(ctx, purchaseReq)
if err != nil {
return nil, starGiftLifecycleErr(err)
}
updates := r.starGiftSendUpdates(ctx, userID, result.Send)
appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, result.Balance.Balance)
r.invalidateStarGiftOwner(peer)
return &tg.PaymentsPaymentResult{Updates: updates}, nil
}
// 1. Debit 送礼人(不足→BALANCE_TOO_LOW)。
func (r *Router) sendStarGiftMemoryPurchase(ctx context.Context, userID int64, peer domain.Peer, gift domain.StarGift,
inv *tg.InputInvoiceStarGift, giftMessage string, upgradeStars int64) (tg.PaymentsPaymentResultClass, error) {
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)
}
var updates *tg.Updates
switch peer.Type {
case domain.PeerTypeUser:
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)
updates, err = r.sendStarGiftToChannel(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars)
default:
err = domain.ErrStarGiftInvalid
}
@ -202,18 +278,10 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
r.refundStarGift(ctx, userID, peer, gift, purchaseStars)
return nil, internalErr()
}
// 4. 构建送礼人 Updates(服务消息 + updateStarsBalance)。
if updates != nil {
updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}})
} else {
updates = &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}}},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: int(r.clock.Now().Unix()),
}
if updates == nil {
updates = emptyGiftUpdates(r.clock.Now().Unix())
}
appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, balance.Balance)
return &tg.PaymentsPaymentResult{Updates: updates}, nil
}
@ -295,8 +363,16 @@ func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, i
}
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (*tg.Updates, error) {
prepaidUpgradeHash := ""
if prepaidUpgradeStars == 0 && gift.UpgradeStars > 0 && gift.UpgradeIssued < gift.UpgradeTotal {
var token [32]byte
if _, err := rand.Read(token[:]); err != nil {
return nil, err
}
prepaidUpgradeHash = base64.RawURLEncoding.EncodeToString(token[:])
}
// 2. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message, prepaidUpgradeStars)
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message, prepaidUpgradeStars, prepaidUpgradeHash)
if err != nil {
return nil, err
}
@ -312,6 +388,7 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i
Unsaved: false,
ConvertStars: gift.ConvertStars,
PrepaidUpgradeStars: prepaidUpgradeStars,
PrepaidUpgradeHash: prepaidUpgradeHash,
Message: message,
}); err != nil {
return nil, err
@ -324,38 +401,40 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i
return tgPrivateMessageUpdates(send.SenderEvent, send.SenderMessage, 0, false, users, chats), nil
}
func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID int64, gift domain.StarGift, hideName bool, message string) (*tg.Updates, error) {
func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (*tg.Updates, error) {
now := int(r.clock.Now().Unix())
sticker := gift.Sticker
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,
CanUpgrade: false,
PrepaidUpgrade: false,
UpgradeStars: 0,
GiftID: gift.ID,
Stars: gift.Stars,
ConvertStars: gift.ConvertStars,
Title: gift.Title,
Sticker: &sticker,
Message: message,
FromUserID: senderID,
NameHidden: hideName,
Saved: true,
CanUpgrade: gift.UpgradeStars > 0,
PrepaidUpgrade: prepaidUpgradeStars > 0,
UpgradePriceStars: gift.UpgradeStars,
UpgradeStars: prepaidUpgradeStars,
},
}
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,
NameHidden: hideName,
Unsaved: false,
ConvertStars: gift.ConvertStars,
Message: message,
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
FromUserID: senderID,
GiftID: gift.ID,
RevisionID: gift.RevisionID,
MsgID: 0,
SavedID: 0,
Date: now,
NameHidden: hideName,
Unsaved: false,
ConvertStars: gift.ConvertStars,
PrepaidUpgradeStars: prepaidUpgradeStars,
Message: message,
})
if err != nil {
return nil, err
@ -375,7 +454,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, prepaidUpgradeStars int64) (domain.SendPrivateTextResult, error) {
func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64, prepaidUpgradeHash string) (domain.SendPrivateTextResult, error) {
recipientBlocked, err := r.peerBlocksUser(ctx, senderID, recipientID)
if err != nil {
return domain.SendPrivateTextResult{}, err
@ -387,19 +466,21 @@ 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,
CanUpgrade: gift.UpgradeStars > 0,
PrepaidUpgrade: prepaidUpgradeStars > 0,
UpgradeStars: gift.UpgradeStars,
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,
PrepaidUpgradeHash: prepaidUpgradeHash,
UpgradePriceStars: gift.UpgradeStars,
UpgradeStars: prepaidUpgradeStars,
},
},
}
@ -529,13 +610,15 @@ func (r *Router) onPaymentsSaveStarGift(ctx context.Context, req *tg.PaymentsSav
return true, nil
}
// onPaymentsConvertStarGift 把收到的礼物转换回 Stars(Credit + 标记 converted)。
// onPaymentsConvertStarGift atomically destroys the regular gift and credits
// the owner-scoped internal Stars ledger. Channel proceeds never leak to the
// acting administrator's personal balance.
func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSavedStarGiftClass) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if r.deps.Gifts == nil || r.deps.Stars == nil {
if r.deps.Gifts == nil {
return false, notImplementedErr()
}
dref, ok, err := r.starGiftRefFromInput(ctx, userID, ref)
@ -545,10 +628,42 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave
if !ok {
return false, starGiftInvalidErr()
}
if dref.Owner.Type == domain.PeerTypeChannel {
return false, notImplementedErr()
if err := r.ensureCanManageStarGiftOwner(ctx, userID, dref.Owner); err != nil {
return false, err
}
saved, err := r.deps.Gifts.Convert(ctx, dref)
// The isolated memory RPC adapter intentionally has no aggregate store. Keep
// its conversion primitive usable for tests, but never use this split write
// path when the production lifecycle coordinator is configured. Channel
// balances have no memory adapter because crediting an administrator would
// violate owner-scoped accounting.
if converter, ok := r.deps.Gifts.(interface {
AtomicPurchaseConfigured() bool
Convert(context.Context, domain.SavedStarGiftRef) (domain.SavedStarGift, error)
}); ok && !converter.AtomicPurchaseConfigured() {
if dref.Owner.Type != domain.PeerTypeUser || dref.Owner.ID != userID {
return false, notImplementedErr()
}
updated, convertErr := converter.Convert(ctx, dref)
if convertErr != nil {
if errors.Is(convertErr, domain.ErrStarGiftNotFound) || errors.Is(convertErr, domain.ErrStarGiftAlreadyConverted) {
return false, starGiftInvalidErr()
}
return false, internalErr()
}
if updated.ConvertStars > 0 {
if _, creditErr := r.deps.Stars.Credit(ctx, userID, updated.ConvertStars, domain.StarsReasonGift,
dref.Owner, "Star gift conversion", "Converted Star Gift"); creditErr != nil {
return false, internalErr()
}
}
r.invalidateStarGiftOwnerProjection(dref.Owner)
return true, nil
}
result, err := r.deps.Gifts.ConvertAggregate(ctx, domain.StarGiftConvertRequest{
ActorUserID: userID,
Ref: dref,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
switch {
case errors.Is(err, domain.ErrStarGiftNotFound):
@ -559,15 +674,8 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave
return false, internalErr()
}
}
if saved.ConvertStars > 0 {
fromPeer := domain.Peer{Type: domain.PeerTypeUser, ID: saved.FromUserID}
if _, err := r.deps.Stars.Credit(ctx, userID, saved.ConvertStars, domain.StarsReasonGift, fromPeer, "Star gift conversion", ""); err != nil {
r.log.Error("star gift convert credit failed", zap.Int64("user_id", userID), zap.Int("msg_id", dref.MsgID), zap.Error(err))
return false, internalErr()
}
}
// 转换移除一份展示礼物 → 失效 owner full 投影。
r.invalidateStarGiftOwnerProjection(dref.Owner)
r.invalidateStarGiftOwnerProjection(result.Saved.Owner)
return true, nil
}
@ -622,6 +730,24 @@ func (r *Router) starGiftRefFromInput(ctx context.Context, userID int64, ref tg.
return domain.SavedStarGiftRef{}, false, peerIDInvalidErr()
}
return domain.SavedStarGiftRef{Owner: owner, SavedID: v.SavedID}, true, nil
case *tg.InputSavedStarGiftSlug:
if v == nil || r.deps.Gifts == nil {
return domain.SavedStarGiftRef{}, false, nil
}
slug := strings.ToLower(strings.TrimSpace(v.Slug))
if slug == "" || len(slug) > domain.MaxStarGiftSlugBytes {
return domain.SavedStarGiftRef{}, false, nil
}
unique, found, err := r.deps.Gifts.UniqueBySlug(ctx, slug)
if err != nil {
return domain.SavedStarGiftRef{}, false, internalErr()
}
if !found || unique.Slug == "" || unique.Owner.ID == 0 ||
(unique.Owner.Type != domain.PeerTypeUser && unique.Owner.Type != domain.PeerTypeChannel) {
return domain.SavedStarGiftRef{}, false, nil
}
resolved := domain.SavedStarGiftRef{Owner: unique.Owner, Slug: strings.ToLower(strings.TrimSpace(unique.Slug))}
return resolved, resolved.Valid(), nil
default:
return domain.SavedStarGiftRef{}, false, nil
}
@ -718,11 +844,6 @@ func (r *Router) resolveStarGiftCollectibleAvailability(ctx context.Context, gif
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
}
@ -755,10 +876,24 @@ func tgStarGifts(catalog []domain.StarGift) []tg.StarGiftClass {
// tgStarGift 把目录项投影为 tg.StarGift(Sticker 须为带 sticker 属性的有效 Document)。
func tgStarGift(g domain.StarGift) *tg.StarGift {
gift := &tg.StarGift{
ID: g.ID,
Sticker: tgDocument(g.Sticker),
Stars: g.Stars,
ConvertStars: g.ConvertStars,
Limited: g.Limited, SoldOut: g.SoldOut, Birthday: g.Birthday,
RequirePremium: g.RequirePremium, LimitedPerUser: g.LimitedPerUser,
PeerColorAvailable: g.PeerColorAvailable, Auction: g.Auction,
ID: g.ID, Sticker: tgDocument(g.Sticker), Stars: g.Stars, ConvertStars: g.ConvertStars,
}
if g.Limited {
gift.SetAvailabilityRemains(g.AvailabilityRemains)
gift.SetAvailabilityTotal(g.AvailabilityTotal)
}
if g.AvailabilityResale > 0 {
gift.SetAvailabilityResale(g.AvailabilityResale)
}
// sold_out, first_sale_date and last_sale_date share TL flags.1. The store
// retains sale timestamps for live gifts as operational facts, but exposing
// either timestamp would make every client decode the gift as sold out.
if g.SoldOut {
gift.SetFirstSaleDate(g.FirstSaleDate)
gift.SetLastSaleDate(g.LastSaleDate)
}
if g.Title != "" {
gift.SetTitle(g.Title)
@ -766,6 +901,31 @@ func tgStarGift(g domain.StarGift) *tg.StarGift {
if g.UpgradeStars > 0 && g.UpgradeIssued < g.UpgradeTotal {
gift.SetUpgradeStars(g.UpgradeStars)
}
if g.ResellMinStars > 0 {
gift.SetResellMinStars(g.ResellMinStars)
}
if releasedBy := tgPeer(g.ReleasedBy); releasedBy != nil {
gift.SetReleasedBy(releasedBy)
}
if g.LimitedPerUser {
gift.SetPerUserTotal(g.PerUserTotal)
gift.SetPerUserRemains(g.PerUserRemains)
}
if g.LockedUntilDate > 0 {
gift.SetLockedUntilDate(g.LockedUntilDate)
}
if g.Auction {
gift.SetAuctionSlug(g.AuctionSlug)
gift.SetGiftsPerRound(g.GiftsPerRound)
gift.SetAuctionStartDate(g.AuctionStartDate)
}
if g.UpgradeVariants > 0 {
gift.SetUpgradeVariants(g.UpgradeVariants)
}
if g.Background != nil {
gift.SetBackground(tg.StarGiftBackground{CenterColor: g.Background.CenterColor,
EdgeColor: g.Background.EdgeColor, TextColor: g.Background.TextColor})
}
return gift
}
@ -787,6 +947,9 @@ func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionC
if in.Title != "" {
gift.SetTitle(in.Title)
}
if in.UpgradePriceStars > 0 {
gift.SetUpgradeStars(in.UpgradePriceStars)
}
action := &tg.MessageActionStarGift{Gift: gift}
if in.NameHidden {
action.NameHidden = true
@ -799,12 +962,26 @@ func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionC
}
action.CanUpgrade = in.CanUpgrade
action.PrepaidUpgrade = in.PrepaidUpgrade
action.UpgradeSeparate = in.UpgradeSeparate
action.AuctionAcquired = in.AuctionAcquired
if in.UpgradeStars > 0 {
action.SetUpgradeStars(in.UpgradeStars)
}
if in.UpgradeMsgID > 0 {
action.SetUpgradeMsgID(in.UpgradeMsgID)
}
if in.PrepaidUpgradeHash != "" {
action.SetPrepaidUpgradeHash(in.PrepaidUpgradeHash)
}
if in.GiftMsgID > 0 {
action.SetGiftMsgID(in.GiftMsgID)
}
if in.GiftNum > 0 {
action.SetGiftNum(in.GiftNum)
}
if to := tgPeer(in.To); to != nil {
action.SetToID(to)
}
if in.ConvertStars > 0 {
action.SetConvertStars(in.ConvertStars)
}
@ -889,6 +1066,9 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
item.SetUpgradeStars(g.PrepaidUpgradeStars)
item.CanUpgrade = true
}
if g.PrepaidUpgradeHash != "" && g.PrepaidUpgradeStars == 0 && canIssue {
item.SetPrepaidUpgradeHash(g.PrepaidUpgradeHash)
}
}
if g.PinnedOrder > 0 {
item.PinnedToTop = true
@ -898,6 +1078,8 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
}
if g.Unique != nil {
item.SetGiftNum(g.Unique.Num)
} else if g.GiftNum > 0 {
item.SetGiftNum(g.GiftNum)
}
out = append(out, item)
}
@ -944,24 +1126,6 @@ func savedStarGiftUserIDs(gifts []domain.SavedStarGift) []int64 {
return ids
}
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
}
return id
}
func starsTopupFormID(userID, stars int64, currency string, amount int64) int64 {
id := userID*0x9e3779b1 ^ (stars << 7) ^ (amount << 13) ^ 0x5354415253
for _, ch := range currency {

View file

@ -21,6 +21,10 @@ import (
)
func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain.StarGift) {
return starGiftTestRouterWithPremium(t, false)
}
func starGiftTestRouterWithPremium(t *testing.T, requirePremium bool) (*Router, domain.User, domain.User, domain.StarGift) {
t.Helper()
ctx := context.Background()
users := memory.NewUserStore()
@ -36,7 +40,7 @@ func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain
t.Fatalf("create recipient: %v", err)
}
gift := domain.StarGift{
ID: 8001, RevisionID: 9001, Stars: 50, ConvertStars: 50, Title: "Cake",
ID: 8001, RevisionID: 9001, Stars: 50, ConvertStars: 50, Title: "Cake", RequirePremium: requirePremium,
Sticker: domain.Document{ID: 700, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
}
giftStore := memory.NewStarGiftStore()
@ -52,6 +56,33 @@ func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain
return r, sender, recipient, gift
}
func TestStarGiftPurchaseRequiresActivePremium(t *testing.T) {
r, sender, recipient, gift := starGiftTestRouterWithPremium(t, true)
ctx := WithUserID(context.Background(), sender.ID)
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID}
if _, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}); !tgerr.Is(err, "PREMIUM_ACCOUNT_REQUIRED") {
t.Fatalf("non-premium gift form err = %v, want PREMIUM_ACCOUNT_REQUIRED", err)
}
premium, ok := r.deps.Users.(UserPremiumService)
if !ok {
t.Fatalf("users service %T does not implement premium grants", r.deps.Users)
}
if _, err := premium.GrantPremium(context.Background(), sender.ID, 1); err != nil {
t.Fatalf("grant premium: %v", err)
}
formRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if err != nil {
t.Fatalf("premium gift form: %v", err)
}
form, ok := formRes.(*tg.PaymentsPaymentFormStarGift)
if !ok {
t.Fatalf("premium gift form = %T", formRes)
}
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); err != nil {
t.Fatalf("premium gift purchase: %v", err)
}
}
type uniqueGiftRPCService struct {
GiftsService
unique domain.UniqueStarGift
@ -61,8 +92,139 @@ func (s *uniqueGiftRPCService) UniqueBySlug(_ context.Context, slug string) (dom
return s.unique, slug == s.unique.Slug, nil
}
type craftStarGiftRPCService struct {
GiftsService
uniques map[string]domain.UniqueStarGift
saved map[int64]domain.SavedStarGift
result domain.StarGiftCraftResult
craftReq domain.StarGiftCraftRequest
craftCall int
}
func (s *craftStarGiftRPCService) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) {
unique, ok := s.uniques[slug]
return unique, ok, nil
}
func (s *craftStarGiftRPCService) GetSaved(_ context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
for _, saved := range s.saved {
if saved.Owner != ref.Owner {
continue
}
if ref.Slug != "" {
unique, ok := s.uniques[ref.Slug]
if ok && unique.ID == saved.UniqueGiftID {
return saved, true, nil
}
continue
}
if saved.MsgID == ref.MsgID {
return saved, true, nil
}
}
return domain.SavedStarGift{}, false, nil
}
func (s *craftStarGiftRPCService) Craft(_ context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) {
s.craftCall++
s.craftReq = req
return s.result, nil
}
func TestCraftStarGiftAcceptsOfficialSlugAndCanonicalizesAliases(t *testing.T) {
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 7102}
service := &craftStarGiftRPCService{
uniques: map[string]domain.UniqueStarGift{
"official-8001-2": {ID: 902, Slug: "official-8001-2", Owner: owner, SourceSavedGiftID: 52},
},
saved: map[int64]domain.SavedStarGift{
50: {ID: 50, Owner: owner, MsgID: 115, UniqueGiftID: 901, UpgradeMsgID: 116},
52: {ID: 52, Owner: owner, MsgID: 111, UniqueGiftID: 902, UpgradeMsgID: 112},
},
result: domain.StarGiftCraftResult{Chance: 500, SourceEdits: []domain.EditedMessageForUser{{
UserID: owner.ID,
Message: domain.Message{ID: 116, OwnerUserID: owner.ID, Peer: owner, From: owner, Date: 100},
Event: domain.UpdateEvent{UserID: owner.ID, Type: domain.UpdateEventEditMessage, Pts: 41, PtsCount: 1,
Date: 100, Message: domain.Message{ID: 116, OwnerUserID: owner.ID, Peer: owner, From: owner, Date: 100}},
}}},
}
r := New(Config{DC: 2}, Deps{Gifts: service}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), owner.ID)
updates, err := r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{
&tg.InputSavedStarGiftUser{MsgID: 115},
&tg.InputSavedStarGiftSlug{Slug: "OFFICIAL-8001-2"},
}})
if err != nil || updates == nil {
t.Fatalf("craft mixed official refs: updates=%T err=%v", updates, err)
}
if service.craftCall != 1 || service.craftReq.CommandKey != "rpc:50,52" || len(service.craftReq.Refs) != 2 ||
service.craftReq.Refs[1].Slug != "official-8001-2" {
t.Fatalf("craft request = %+v calls=%d", service.craftReq, service.craftCall)
}
full, ok := updates.(*tg.Updates)
if !ok || len(full.Updates) != 2 {
t.Fatalf("craft failure updates = %T %#v", updates, updates)
}
if edit, ok := full.Updates[0].(*tg.UpdateEditMessage); !ok || edit.Pts != 41 || edit.PtsCount != 1 {
t.Fatalf("craft failure source update = %T %#v", full.Updates[0], full.Updates[0])
}
if _, ok := full.Updates[1].(*tg.UpdateStarGiftCraftFail); !ok {
t.Fatalf("craft terminal update = %T %#v", full.Updates[1], full.Updates[1])
}
service.craftCall = 0
_, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{
&tg.InputSavedStarGiftUser{MsgID: 111},
&tg.InputSavedStarGiftSlug{Slug: "official-8001-2"},
}})
if !tgerr.Is(err, "STARGIFT_INVALID") || service.craftCall != 0 {
t.Fatalf("duplicate aliases err=%v craft calls=%d", err, service.craftCall)
}
_, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{
&tg.InputSavedStarGiftUser{MsgID: 116},
}})
if !tgerr.Is(err, "STARGIFT_INVALID") || service.craftCall != 0 {
t.Fatalf("upgrade message id accepted as gift identity: err=%v craft calls=%d", err, service.craftCall)
}
}
type upgradeReplayRPCService struct {
GiftsService
saved domain.SavedStarGift
receipt domain.StarGiftUpgradeReceipt
result domain.StarGiftUpgradeResult
upgradeCalls int
previewCalls int
lastRequest domain.StarGiftUpgradeRequest
}
func (s *upgradeReplayRPCService) GetSaved(_ context.Context, _ domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
return s.saved, true, nil
}
func (s *upgradeReplayRPCService) UpgradeReceipt(_ context.Context, userID int64, _ string) (domain.StarGiftUpgradeReceipt, bool, error) {
if userID != s.receipt.UserID {
return domain.StarGiftUpgradeReceipt{}, false, nil
}
return s.receipt, true, nil
}
func (s *upgradeReplayRPCService) CollectiblePreview(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) {
s.previewCalls++
return domain.StarGiftUpgradePreview{}, false, nil
}
func (s *upgradeReplayRPCService) Upgrade(_ context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
s.upgradeCalls++
s.lastRequest = req
return s.result, nil
}
func collectibleRPCAttribute(kind domain.StarGiftCollectibleAttributeKind, id int64, name string) domain.StarGiftCollectibleAttribute {
attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Name: name, RarityPermille: 1000}
attribute := domain.StarGiftCollectibleAttribute{
Kind: kind, Name: name, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
}
if kind == domain.StarGiftCollectibleBackdrop {
attribute.BackdropID = int(id)
attribute.CenterColor = 0x112233
@ -148,6 +310,120 @@ func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleA
}
}
func TestMessageStarGiftProjectionSeparatesPaidPriceFromPrepaidAmount(t *testing.T) {
ordinary, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{
GiftID: 8001, Stars: 50, ConvertStars: 25, CanUpgrade: true, UpgradePriceStars: 75,
}).(*tg.MessageActionStarGift)
if !ok {
t.Fatalf("ordinary action = %T", ordinary)
}
ordinaryGift, ok := ordinary.Gift.(*tg.StarGift)
if !ok {
t.Fatalf("ordinary inner gift = %T", ordinary.Gift)
}
if price, set := ordinaryGift.GetUpgradeStars(); !set || price != 75 {
t.Fatalf("ordinary inner upgrade_stars = %d set=%v, want paid price 75", price, set)
}
if amount, set := ordinary.GetUpgradeStars(); set || amount != 0 || ordinary.PrepaidUpgrade {
t.Fatalf("ordinary outer upgrade_stars = %d set=%v prepaid=%v, want absent", amount, set, ordinary.PrepaidUpgrade)
}
prepaid, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{
GiftID: 8001, Stars: 50, ConvertStars: 25, CanUpgrade: true, PrepaidUpgrade: true,
UpgradePriceStars: 75, UpgradeStars: 75,
}).(*tg.MessageActionStarGift)
if !ok {
t.Fatalf("prepaid action = %T", prepaid)
}
if amount, set := prepaid.GetUpgradeStars(); !set || amount != 75 || !prepaid.PrepaidUpgrade {
t.Fatalf("prepaid outer upgrade_stars = %d set=%v prepaid=%v, want 75", amount, set, prepaid.PrepaidUpgrade)
}
upgraded, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{
GiftID: 8001, Stars: 50, ConvertStars: 25, UpgradeMsgID: 88,
}).(*tg.MessageActionStarGift)
if !ok {
t.Fatalf("upgraded action = %T", upgraded)
}
if msgID, set := upgraded.GetUpgradeMsgID(); !set || msgID != 88 {
t.Fatalf("upgrade_msg_id = %d set=%v, want 88", msgID, set)
}
for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} {
wire := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, ordinary, wire); err != nil {
t.Fatalf("encode Layer %d ordinary action: %v", profile, err)
}
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Buf}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode Layer %d ordinary action: %v", profile, err)
}
decoded, ok := decodedObject.(*tg.MessageActionStarGift)
if !ok {
t.Fatalf("decode Layer %d action = %T", profile, decodedObject)
}
inner, ok := decoded.Gift.(*tg.StarGift)
if !ok || inner.UpgradeStars != 75 || decoded.UpgradeStars != 0 || decoded.PrepaidUpgrade {
t.Fatalf("Layer %d ordinary action lost paid/prepaid split: %#v", profile, decoded)
}
upgradedWire := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, upgraded, upgradedWire); err != nil {
t.Fatalf("encode Layer %d upgraded action: %v", profile, err)
}
decodedUpgradedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: upgradedWire.Buf}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode Layer %d upgraded action: %v", profile, err)
}
decodedUpgraded, ok := decodedUpgradedObject.(*tg.MessageActionStarGift)
if !ok || !decodedUpgraded.Upgraded || decodedUpgraded.UpgradeMsgID != 88 || decodedUpgraded.CanUpgrade {
t.Fatalf("Layer %d upgraded action lost transition flags: %#v", profile, decodedUpgradedObject)
}
}
}
func TestStarGiftUpgradeRPCReplaysCommittedReceiptAfterTerminalTransition(t *testing.T) {
r, sender, owner, gift := starGiftTestRouter(t)
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
saved := domain.SavedStarGift{
ID: 47, Owner: ownerPeer, FromUserID: sender.ID, GiftID: gift.ID, RevisionID: gift.RevisionID,
MsgID: 105, UniqueGiftID: 9200000000000004,
}
result := domain.StarGiftUpgradeResult{
Saved: saved, Unique: domain.UniqueStarGift{ID: saved.UniqueGiftID, GiftID: gift.ID, Owner: ownerPeer},
Balance: domain.StarsBalance{UserID: owner.ID, Balance: 1000}, Duplicate: true,
Send: domain.SendPrivateTextResult{
RecipientMessage: domain.Message{ID: 107, OwnerUserID: owner.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Date: 1700000001},
RecipientEvent: domain.UpdateEvent{UserID: owner.ID, Pts: 42, PtsCount: 1, Date: 1700000001},
},
}
service := &upgradeReplayRPCService{GiftsService: r.deps.Gifts, saved: saved, result: result,
receipt: domain.StarGiftUpgradeReceipt{UserID: owner.ID, SourceSavedGiftID: saved.ID,
UniqueGiftID: saved.UniqueGiftID, RequirePrepaid: true, KeepOriginalDetails: true, BalanceAfter: 1000}}
r.deps.Gifts = service
ctx := WithUserID(context.Background(), owner.ID)
if _, err := r.onPaymentsUpgradeStarGift(ctx, &tg.PaymentsUpgradeStarGiftRequest{
KeepOriginalDetails: true, Stargift: &tg.InputSavedStarGiftUser{MsgID: saved.MsgID},
}); err != nil {
t.Fatalf("replay prepaid upgrade after terminal transition: %v", err)
}
if service.upgradeCalls != 1 || service.previewCalls != 0 || !service.lastRequest.RequirePrepaid || service.lastRequest.ChargeStars != 0 {
t.Fatalf("prepaid replay calls=%d preview=%d req=%+v", service.upgradeCalls, service.previewCalls, service.lastRequest)
}
const paidFormID int64 = -7611777087885039132
service.receipt = domain.StarGiftUpgradeReceipt{UserID: owner.ID, SourceSavedGiftID: saved.ID,
FormID: paidFormID, UniqueGiftID: saved.UniqueGiftID, ChargeStars: 25,
KeepOriginalDetails: true, BalanceAfter: 975}
service.upgradeCalls, service.previewCalls = 0, 0
if _, err := r.sendStarGiftUpgradeForm(ctx, owner.ID, paidFormID, &tg.InputInvoiceStarGiftUpgrade{
KeepOriginalDetails: true, Stargift: &tg.InputSavedStarGiftUser{MsgID: saved.MsgID},
}); err != nil {
t.Fatalf("replay paid upgrade after terminal transition: %v", err)
}
if service.upgradeCalls != 1 || service.previewCalls != 0 || service.lastRequest.ChargeStars != 25 || service.lastRequest.FormID != paidFormID {
t.Fatalf("paid replay calls=%d preview=%d req=%+v", service.upgradeCalls, service.previewCalls, service.lastRequest)
}
}
func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *testing.T) {
r, sender, owner, gift := starGiftTestRouter(t)
ctx := context.Background()
@ -157,11 +433,15 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
t.Fatalf("gift service = %T", r.deps.Gifts)
}
model := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8101, "Aurora")
crafted := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8103, "Crafted Aurora")
crafted.Crafted = true
crafted.RarityKind = domain.StarGiftRarityLegendary
crafted.RarityPermille = 0
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},
Models: []domain.StarGiftCollectibleAttribute{model, crafted}, Patterns: []domain.StarGiftCollectibleAttribute{pattern},
Backdrops: []domain.StarGiftCollectibleAttribute{backdrop}, Actor: "test", CommandID: "collectible-rpc",
}); err != nil {
t.Fatalf("publish collectible pool: %v", err)
@ -177,6 +457,17 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
if err != nil || len(preview.SampleAttributes) != 3 {
t.Fatalf("upgrade preview = %#v err %v", preview, err)
}
attributes, err := r.onPaymentsGetStarGiftUpgradeAttributes(ownerCtx, gift.ID)
if err != nil || len(attributes.Attributes) != 4 {
t.Fatalf("upgrade attributes = %#v err %v", attributes, err)
}
craftedTG, ok := attributes.Attributes[1].(*tg.StarGiftAttributeModel)
if !ok || !craftedTG.Crafted {
t.Fatalf("crafted attribute = %T %#v", attributes.Attributes[1], attributes.Attributes[1])
}
if _, ok := craftedTG.Rarity.(*tg.StarGiftAttributeRarityLegendary); !ok {
t.Fatalf("crafted rarity = %T", craftedTG.Rarity)
}
invoice := &tg.InputInvoiceStarGiftUpgrade{Stargift: &tg.InputSavedStarGiftUser{MsgID: 444}}
formClass, err := r.onPaymentsGetPaymentForm(ownerCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice})
if err != nil {
@ -208,7 +499,7 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
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,
Gift: unique, FromUserID: sender.ID, Peer: unique.Owner, SavedID: 444, Upgrade: true, Saved: true,
},
}}}
action, ok := tgMessageServiceAction(message).(*tg.MessageActionStarGiftUnique)
@ -224,6 +515,9 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
} else if user, ok := peer.(*tg.PeerUser); !ok || user.UserID != owner.ID {
t.Fatalf("unique service action peer = %#v", peer)
}
if savedID, ok := action.GetSavedID(); !ok || savedID != 444 {
t.Fatalf("unique service action saved_id = %d set=%v, want 444", savedID, ok)
}
for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} {
responseWire := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, uniqueResponse, responseWire); err != nil {
@ -254,7 +548,7 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
if !ok {
t.Fatalf("decode Layer %d unique action type = %T", profile, decodedActionObject)
}
if decodedActionGift, ok := decodedAction.Gift.(*tg.StarGiftUnique); !ok || !decodedAction.Upgrade || decodedActionGift.Slug != unique.Slug {
if decodedActionGift, ok := decodedAction.Gift.(*tg.StarGiftUnique); !ok || !decodedAction.Upgrade || decodedAction.SavedID != 444 || decodedActionGift.Slug != unique.Slug {
t.Fatalf("Layer %d unique action lost fields: %#v", profile, decodedAction)
}
}
@ -556,10 +850,15 @@ func TestStarGiftChannelSaga(t *testing.T) {
}); err != nil {
t.Fatalf("publish channel collectible pool: %v", err)
}
if _, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: &tg.InputInvoiceStarGift{
upgradeFormRes, 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")
}})
if err != nil {
t.Fatalf("getPaymentForm(channel include_upgrade): %v", err)
}
upgradeForm, ok := upgradeFormRes.(*tg.PaymentsPaymentFormStarGift)
if !ok || len(upgradeForm.Invoice.Prices) != 1 || upgradeForm.Invoice.Prices[0].Amount != gift.Stars+75 {
t.Fatalf("channel include_upgrade form = %T %+v, want total %d", upgradeFormRes, upgradeFormRes, gift.Stars+75)
}
inv := &tg.InputInvoiceStarGift{
Peer: channelPeer,
@ -612,8 +911,8 @@ 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")
if !savedRes.Gifts[0].CanUpgrade {
t.Fatal("channel saved gift must advertise upgrade when a collectible pool is available")
}
savedID, ok := savedRes.Gifts[0].GetSavedID()
if !ok || savedID <= 0 {
@ -728,8 +1027,12 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
}, 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}
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 {
formRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if err != nil {
t.Fatalf("get expensive gift form: %v", err)
}
form := formRes.(*tg.PaymentsPaymentFormStarGift)
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); !tgerr.Is(err, "BALANCE_TOO_LOW") {
t.Fatalf("over-budget gift should error BALANCE_TOO_LOW")
}
// 余额未变。
@ -738,22 +1041,60 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
}
}
func TestStarGiftFormBindsCatalogRevisionAndPrice(t *testing.T) {
func TestStarGiftPurchaseFormsAreFreshAndBindPurpose(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)
firstRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if err != nil {
t.Fatalf("first form: %v", err)
}
secondRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if err != nil {
t.Fatalf("second form: %v", err)
}
first := firstRes.(*tg.PaymentsPaymentFormStarGift)
second := secondRes.(*tg.PaymentsPaymentFormStarGift)
if first.FormID == 0 || second.FormID == 0 || first.FormID == second.FormID {
t.Fatalf("fresh form ids = %d/%d, want distinct non-zero TL longs", first.FormID, second.FormID)
}
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: first.FormID + second.FormID, Invoice: inv}); !tgerr.Is(err, "FORM_EXPIRED") {
t.Fatalf("unknown form err=%v, want FORM_EXPIRED", err)
}
tampered := *inv
tampered.HideName = true
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: first.FormID, Invoice: &tampered}); !tgerr.Is(err, "PURPOSE_INVALID") {
t.Fatalf("tampered form err=%v, want PURPOSE_INVALID", err)
}
}
func TestStarGiftCanPurchaseSameCatalogGiftTwice(t *testing.T) {
r, sender, recipient, gift := starGiftTestRouter(t)
ctx := WithUserID(context.Background(), sender.ID)
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID}
var formIDs []int64
for i := 0; i < 2; i++ {
formRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if err != nil {
t.Fatalf("get form %d: %v", i, err)
}
form := formRes.(*tg.PaymentsPaymentFormStarGift)
formIDs = append(formIDs, form.FormID)
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); err != nil {
t.Fatalf("purchase %d: %v", i, err)
}
}
if formIDs[0] == formIDs[1] {
t.Fatalf("repeated purchase reused form id %d", formIDs[0])
}
saved, err := r.onPaymentsGetSavedStarGifts(WithUserID(context.Background(), recipient.ID), &tg.PaymentsGetSavedStarGiftsRequest{
Peer: &tg.InputPeerSelf{}, Limit: 10,
})
if err != nil {
t.Fatalf("get recipient gifts: %v", err)
}
if saved.Count != 2 || len(saved.Gifts) != 2 {
t.Fatalf("recipient gifts = count %d len %d, want two independent gifts", saved.Count, len(saved.Gifts))
}
}

View file

@ -98,3 +98,113 @@ func TestOnPaymentsGetStarsStatusNilDeps(t *testing.T) {
}
_ = domain.DefaultStarsStartingGrant
}
type channelLedgerGifts struct {
GiftsService
starsBalance int64
tonBalance int64
starsPage domain.StarsTransactionPage
tonPage domain.TonTransactionPage
}
func (s *channelLedgerGifts) ChannelStarsBalance(context.Context, int64) (int64, error) {
return s.starsBalance, nil
}
func (s *channelLedgerGifts) ChannelStarsTransactions(context.Context, int64, string, int) (domain.StarsTransactionPage, error) {
return s.starsPage, nil
}
func (s *channelLedgerGifts) ChannelTonBalance(context.Context, int64) (int64, error) {
return s.tonBalance, nil
}
func (s *channelLedgerGifts) ChannelTonTransactions(context.Context, int64, string, int) (domain.TonTransactionPage, error) {
return s.tonPage, nil
}
type channelLedgerChannels struct {
ChannelsService
view domain.ChannelView
}
func (s *channelLedgerChannels) ResolveChannel(context.Context, int64, int64) (domain.ChannelView, error) {
return s.view, nil
}
func (s *channelLedgerChannels) GetChannels(context.Context, int64, []int64) ([]domain.ChannelView, error) {
return []domain.ChannelView{s.view}, nil
}
func TestPaymentsStarsLedgerUsesRequestedChannelOwner(t *testing.T) {
const viewerID, channelID int64 = 1000000001, 2000000001
view := domain.ChannelView{
Channel: domain.Channel{ID: channelID, AccessHash: 9876, Title: "Gift Channel", Broadcast: true, CreatorUserID: viewerID},
Self: domain.ChannelMember{ChannelID: channelID, UserID: viewerID, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive},
}
gifts := &channelLedgerGifts{
starsBalance: 20,
tonBalance: 900,
starsPage: domain.StarsTransactionPage{Balance: 20, Transactions: []domain.StarsTransaction{{
ID: 1, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, Amount: 20, Date: 10, Reason: domain.StarsReasonGift,
}}},
tonPage: domain.TonTransactionPage{Balance: 900, Transactions: []domain.TonTransaction{{
ID: 2, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2000000002}, GiftID: 9, Amount: 900, Date: 11, Reason: domain.StarsReasonGiftResale,
}}},
}
r := New(Config{}, Deps{Gifts: gifts, Channels: &channelLedgerChannels{view: view}}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), viewerID)
peer := &tg.InputPeerChannel{ChannelID: channelID, AccessHash: view.Channel.AccessHash}
status, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: peer})
if err != nil {
t.Fatalf("get channel stars status: %v", err)
}
if amount, ok := status.Balance.(*tg.StarsAmount); !ok || amount.Amount != 20 || len(status.Chats) != 1 {
t.Fatalf("channel stars status = %+v chats=%d", status.Balance, len(status.Chats))
}
revenue, err := r.onPaymentsGetStarsRevenueStats(ctx, &tg.PaymentsGetStarsRevenueStatsRequest{Peer: peer})
if err != nil {
t.Fatalf("get channel stars revenue: %v", err)
}
if current, ok := revenue.Status.CurrentBalance.(*tg.StarsAmount); !ok || current.Amount != 20 {
t.Fatalf("channel stars revenue current = %+v", revenue.Status.CurrentBalance)
}
if overall, ok := revenue.Status.OverallRevenue.(*tg.StarsAmount); !ok || overall.Amount != 20 || revenue.Status.WithdrawalEnabled {
t.Fatalf("channel stars revenue overall = %+v withdrawal=%v", revenue.Status.OverallRevenue, revenue.Status.WithdrawalEnabled)
}
txnReq := &tg.PaymentsGetStarsTransactionsRequest{Peer: peer, Limit: 20}
txnReq.SetTon(true)
transactions, err := r.onPaymentsGetStarsTransactions(ctx, txnReq)
if err != nil {
t.Fatalf("get channel ton transactions: %v", err)
}
history, ok := transactions.GetHistory()
if amount, amountOK := transactions.Balance.(*tg.StarsTonAmount); !amountOK || amount.Amount != 900 || !ok || len(history) != 1 || !history[0].StargiftResale {
t.Fatalf("channel ton transactions = balance=%+v history=%+v", transactions.Balance, history)
}
revenueReq := &tg.PaymentsGetStarsRevenueStatsRequest{Peer: peer}
revenueReq.SetTon(true)
tonRevenue, err := r.onPaymentsGetStarsRevenueStats(ctx, revenueReq)
if err != nil {
t.Fatalf("get channel ton revenue: %v", err)
}
if current, ok := tonRevenue.Status.CurrentBalance.(*tg.StarsTonAmount); !ok || current.Amount != 900 {
t.Fatalf("channel ton revenue current = %+v", tonRevenue.Status.CurrentBalance)
}
}
func TestPaymentsStarsLedgerRejectsNonAdminChannelReader(t *testing.T) {
const viewerID, channelID int64 = 1000000001, 2000000001
view := domain.ChannelView{
Channel: domain.Channel{ID: channelID, AccessHash: 9876, Title: "Gift Channel", Broadcast: true},
Self: domain.ChannelMember{ChannelID: channelID, UserID: viewerID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive},
}
r := New(Config{}, Deps{Gifts: &channelLedgerGifts{}, Channels: &channelLedgerChannels{view: view}}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), viewerID)
_, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: view.Channel.AccessHash}})
if err == nil {
t.Fatal("non-admin channel ledger read unexpectedly succeeded")
}
}