fix: sync stars topup and paid reaction settings

This commit is contained in:
A 2026-07-08 01:19:22 +08:00
parent 4e18d31e16
commit 1f11b0fd1b
12 changed files with 506 additions and 24 deletions

View file

@ -27,6 +27,9 @@ const tdesktopClient = "tdesktop"
// !premium_purchase_blocked 耦合在同一 flag——置 true 会同时隐藏送礼入口。star gift
// 已实现(Stars 账本),故必须 false 才能送礼;副作用是 premium 购买 UI 重现,但 premium
// 已自动授予(0094)、购买流走 stub getPaymentForm 优雅报错,可接受。
// - stars_purchase_blocked=false 必须显式下发DrKLO 缺省 starsLocked=true余额不足
// 送礼时若缺此 key 会走 showNoSupportDialog误弹「所在国家无法购买星星」。该 flag 只解
// 客户端充值/购买入口,实际 gift 扣款仍由 Stars 账本余额与 BALANCE_TOO_LOW 约束。
// - stargifts_blocked=false 必须显式下发DrKLO MessagesController:1752 缺省 stargiftsBlocked=
// true(屏蔽)GiftSheet:967 据此隐藏整个 star gift 送礼网格——缺 key 则送礼选择器恒空。
// - reactions_user_max_premium=3 与服务端 domain.MaxMessageReactionsPerUserPremium
@ -47,9 +50,9 @@ const tdesktopClient = "tdesktop"
// 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。
// - aicompose_tone_* 与 domain/app/ai 默认值一致TDesktop/DrKLO 创建/预览 tone 时
// 直接读取这些 key 做本地输入限制和示例数量。
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
const defaultAppConfigHash = 21 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
const defaultAppConfigHash = 22 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
// Service 提供客户端启动配置与国家区号目录。
//

View file

@ -6,7 +6,7 @@ import (
"testing"
)
// TestAppConfigPremiumKeys 断言 premium 相关 key 完整下发且 hash 已递增:
// TestAppConfigPremiumKeys 断言 premium / Stars 相关 key 完整下发且 hash 已递增:
// premium_purchase_blocked 必须显式为 false——客户端把 star gift「Send a Gift」入口与
// premiumCanBuy()=!premium_purchase_blocked 耦合,置 true 会同时隐藏送礼入口;
// reactions_user_max_premium 必须与服务端 enforcement 档位一致。
@ -25,6 +25,10 @@ func TestAppConfigPremiumKeys(t *testing.T) {
if blocked, ok := decoded["premium_purchase_blocked"].(bool); !ok || blocked {
t.Fatalf("premium_purchase_blocked = %v, want false (star gift 送礼入口耦合此 flag)", decoded["premium_purchase_blocked"])
}
// DrKLO 缺省 starsLocked=true缺 key 时余额不足送礼会误弹「所在国家无法购买星星」。
if blocked, ok := decoded["stars_purchase_blocked"].(bool); !ok || blocked {
t.Fatalf("stars_purchase_blocked = %v, want false (DrKLO starsPurchaseAvailable 据此解锁充值入口)", decoded["stars_purchase_blocked"])
}
// DrKLO 缺省 stargiftsBlocked=true 会隐藏 star gift 送礼网格,必须显式下发 false。
if blocked, ok := decoded["stargifts_blocked"].(bool); !ok || blocked {
t.Fatalf("stargifts_blocked = %v, want false (DrKLO GiftSheet 据此隐藏礼物网格)", decoded["stargifts_blocked"])

View file

@ -178,6 +178,9 @@ func TestBroadcastChannelAcceptsFullReactionCatalog(t *testing.T) {
if fullChannel.GetPaidReactionsAvailable() {
t.Fatalf("full channel paid reactions = true, want false without paid_enabled flag")
}
if !fullChannel.GetPaidMediaAllowed() {
t.Fatalf("broadcast full channel paid_media_allowed = false, want true for Android paid reaction editor")
}
}
func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
@ -253,6 +256,159 @@ func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
if fullChannel.GetPaidReactionsAvailable() {
t.Fatalf("paid reactions after explicit false = true, want false")
}
if !fullChannel.GetPaidMediaAllowed() {
t.Fatalf("broadcast paid_media_allowed after paid disable = false, want capability preserved")
}
}
func TestSetChatAvailableReactionsStripsTDesktopPaidSentinel(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 101, Phone: "15550002204", FirstName: "Owner"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "TDesktop Paid Sentinel",
Broadcast: true,
})
if err != nil {
t.Fatalf("create broadcast channel: %v", err)
}
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
tdesktopReq := &tg.MessagesSetChatAvailableReactionsRequest{
Peer: peer,
AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{
&tg.ReactionPaid{},
&tg.ReactionEmoji{Emoticon: "\U0001f44d"},
&tg.ReactionEmoji{Emoticon: "\u2764"},
&tg.ReactionPaid{},
}},
}
tdesktopReq.SetReactionsLimit(11)
tdesktopReq.SetPaidEnabled(true)
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), tdesktopReq); err != nil {
t.Fatalf("set TDesktop paid sentinel reactions: %v", err)
}
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get full channel after TDesktop sentinel set: %v", err)
}
fullChannel := full.FullChat.(*tg.ChannelFull)
if !fullChannel.GetPaidReactionsAvailable() {
t.Fatalf("paid reactions after TDesktop sentinel set = false, want true")
}
some := mustChannelFullSomeReactions(t, full)
if len(some.Reactions) != 2 {
t.Fatalf("stored reactions after stripping sentinel = %d, want 2", len(some.Reactions))
}
for i, reaction := range some.Reactions {
if _, ok := reaction.(*tg.ReactionPaid); ok {
t.Fatalf("stored reaction[%d] = reactionPaid, want paid state only in paid_reactions_available", i)
}
}
fullCatalog := make([]tg.ReactionClass, 0, domain.MaxChannelReactionTypes+1)
fullCatalog = append(fullCatalog, &tg.ReactionPaid{})
for i := 0; i < domain.MaxChannelReactionTypes; i++ {
fullCatalog = append(fullCatalog, &tg.ReactionEmoji{Emoticon: fmt.Sprintf("r%03d", i)})
}
maxReq := &tg.MessagesSetChatAvailableReactionsRequest{
Peer: peer,
AvailableReactions: &tg.ChatReactionsSome{Reactions: fullCatalog},
}
maxReq.SetPaidEnabled(true)
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), maxReq); err != nil {
t.Fatalf("set max normal reactions plus paid sentinel: %v", err)
}
full, err = r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get full channel after max sentinel set: %v", err)
}
some = mustChannelFullSomeReactions(t, full)
if len(some.Reactions) != domain.MaxChannelReactionTypes {
t.Fatalf("stored reactions after max sentinel set = %d, want %d", len(some.Reactions), domain.MaxChannelReactionTypes)
}
}
func TestChannelFullPaidReactionCapabilityOnlyBroadcast(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 101, Phone: "15550002202", FirstName: "Owner"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
broadcastCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "Paid Reaction Broadcast",
Broadcast: true,
})
if err != nil {
t.Fatalf("create broadcast channel: %v", err)
}
broadcast := broadcastCreated.(*tg.Updates).Chats[0].(*tg.Channel)
broadcastFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash})
if err != nil {
t.Fatalf("get broadcast full channel: %v", err)
}
broadcastFullChannel := broadcastFull.FullChat.(*tg.ChannelFull)
if !broadcastFullChannel.GetPaidMediaAllowed() {
t.Fatalf("broadcast paid_media_allowed = false, want true")
}
if broadcastFullChannel.GetPaidReactionsAvailable() {
t.Fatalf("broadcast paid_reactions_available = true before paid_enabled, want false")
}
enablePaid := &tg.MessagesSetChatAvailableReactionsRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash},
AvailableReactions: &tg.ChatReactionsAll{},
}
enablePaid.SetPaidEnabled(true)
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), enablePaid); err != nil {
t.Fatalf("enable broadcast paid reactions: %v", err)
}
broadcastFull, err = r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash})
if err != nil {
t.Fatalf("get broadcast full channel after paid enable: %v", err)
}
broadcastFullChannel = broadcastFull.FullChat.(*tg.ChannelFull)
if !broadcastFullChannel.GetPaidMediaAllowed() || !broadcastFullChannel.GetPaidReactionsAvailable() {
t.Fatalf("broadcast flags after enable: paid_media_allowed=%v paid_reactions_available=%v, want both true",
broadcastFullChannel.GetPaidMediaAllowed(), broadcastFullChannel.GetPaidReactionsAvailable())
}
megaCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "Paid Reaction Mega",
Megagroup: true,
})
if err != nil {
t.Fatalf("create megagroup: %v", err)
}
mega := megaCreated.(*tg.Updates).Chats[0].(*tg.Channel)
enableMegaPaid := &tg.MessagesSetChatAvailableReactionsRequest{
Peer: &tg.InputPeerChannel{ChannelID: mega.ID, AccessHash: mega.AccessHash},
AvailableReactions: &tg.ChatReactionsAll{},
}
enableMegaPaid.SetPaidEnabled(true)
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), enableMegaPaid); err != nil {
t.Fatalf("set megagroup paid_enabled request: %v", err)
}
megaFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: mega.ID, AccessHash: mega.AccessHash})
if err != nil {
t.Fatalf("get megagroup full channel: %v", err)
}
megaFullChannel := megaFull.FullChat.(*tg.ChannelFull)
if megaFullChannel.GetPaidMediaAllowed() || megaFullChannel.GetPaidReactionsAvailable() {
t.Fatalf("megagroup flags: paid_media_allowed=%v paid_reactions_available=%v, want both false",
megaFullChannel.GetPaidMediaAllowed(), megaFullChannel.GetPaidReactionsAvailable())
}
}
func TestAndroidChannelReactionEditorProjectsDefaultEmojiAsDocuments(t *testing.T) {

View file

@ -107,12 +107,16 @@ func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsReques
policy.Type = domain.ChannelReactionPolicyAll
policy.AllowCustom = reactions.AllowCustom
case *tg.ChatReactionsSome:
if len(reactions.Reactions) > domain.MaxChannelReactionTypes {
return domain.ChannelReactionPolicy{}, limitInvalidErr()
}
policy.Type = domain.ChannelReactionPolicySome
seen := make(map[string]struct{}, len(reactions.Reactions))
for _, reaction := range reactions.Reactions {
// TDesktop models the paid toggle as a pseudo reaction in the
// selector and may submit reactionPaid in chatReactionsSome. The
// durable paid state is carried by paid_enabled, not the normal
// reaction whitelist.
if paidReactionSentinel(reaction) {
continue
}
parsed, err := domainMessageReactionFromTL(reaction)
if err != nil {
return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID")
@ -122,6 +126,9 @@ func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsReques
if _, ok := seen[key]; ok {
continue
}
if len(seen) >= domain.MaxChannelReactionTypes {
return domain.ChannelReactionPolicy{}, limitInvalidErr()
}
seen[key] = struct{}{}
switch parsed.Type {
case domain.MessageReactionEmoji:
@ -135,3 +142,8 @@ func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsReques
}
return policy, nil
}
func paidReactionSentinel(reaction tg.ReactionClass) bool {
paid, ok := reaction.(*tg.ReactionPaid)
return ok && paid != nil
}

View file

@ -569,15 +569,19 @@ func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.Channel
if ch.ReactionPolicy.Limit > 0 {
full.SetReactionsLimit(ch.ReactionPolicy.Limit)
}
if ch.Broadcast && !ch.Megagroup {
// Android uses paid_media_allowed as the capability gate for showing the
// paid-reaction setting; paid_reactions_available below remains the saved
// on/off state.
full.SetPaidMediaAllowed(true)
full.SetStargiftsAvailable(true)
}
// paid_reactions_available reflects the saved chat policy, not mere broadcast
// capability. Android counts this flag as an extra available reaction in the
// settings row, so advertising it without paid_enabled corrupts the UI count.
if ch.ReactionPolicy.PaidEnabled {
if ch.Broadcast && !ch.Megagroup && ch.ReactionPolicy.PaidEnabled {
full.SetPaidReactionsAvailable(true)
}
if ch.Broadcast && !ch.Megagroup {
full.SetStargiftsAvailable(true)
}
return full
}

View file

@ -126,6 +126,10 @@ func balanceTooLowErr() error { return tgerr.New(400, "BALANCE_TOO_LOW") }
func starsAmountInvalidErr() error { return tgerr.New(400, "STARS_AMOUNT_INVALID") }
func starsFormAmountMismatchErr() error { return tgerr.New(406, "STARS_FORM_AMOUNT_MISMATCH") }
func formIDEmptyErr() error { return tgerr.New(400, "FORM_ID_EMPTY") }
func suggestedPostPeerInvalidErr() error { return tgerr.New(400, "SUGGESTED_POST_PEER_INVALID") }
func storyIDInvalidErr() error { return tgerr.New(400, "STORY_ID_INVALID") }

View file

@ -15,7 +15,7 @@ import (
// gift/auction/revenue 第一阶段兼容桩。
func (r *Router) registerPayments(d *tg.ServerDispatcher) {
d.OnPaymentsGetStarsTopupOptions(func(ctx context.Context) ([]tg.StarsTopupOption, error) {
return []tg.StarsTopupOption{}, nil
return devStarsTopupOptions(), nil
})
// premium 订阅赠送 telesrv 不实现无支付流返回空选项。关键作用TDesktop 送礼框
// ShowStarGiftBox 的 ready() 门控要求 getPremiumGiftCodeOptions 成功返回(on_next)才置

View file

@ -16,6 +16,14 @@ import (
func starGiftInvalidErr() error { return tgerr.New(400, "STARGIFT_INVALID") }
func devStarsTopupOptions() []tg.StarsTopupOption {
return []tg.StarsTopupOption{
{Stars: 1000, Currency: "USD", Amount: 99},
{Stars: 2500, Currency: "USD", Amount: 199},
{Stars: 5000, Currency: "USD", Amount: 399},
}
}
// onPaymentsGetStarGifts 返回可购买礼物目录hash 命中返回 NotModified
func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.PaymentsStarGiftsClass, error) {
if r.deps.Gifts == nil {
@ -41,13 +49,35 @@ func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.Payme
}, nil
}
// onPaymentsGetPaymentForm 仅处理 inputInvoiceStarGift返回 paymentFormStarGift。
// onPaymentsGetPaymentForm 处理 Stars 专用 invoice
// - inputInvoiceStarGift 返回 paymentFormStarGift。
// - inputInvoiceStars(inputStorePaymentStarsTopup) 返回 paymentFormStars。
//
// 崩溃约束star gift invoice 必须返 paymentFormStarGift#b425cfe1TDesktop 单分支 match
// Invoice.Prices 必须非空DrKLO/TDesktop 读 prices.front())。
// Stars 表单 Invoice.Prices 必须非空且 Currency=XTRDrKLO/TDesktop 读 prices.front())。
func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsGetPaymentFormRequest) (tg.PaymentsPaymentFormClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
purpose, ok := starsTopupPurpose(inv)
if !ok {
return nil, notImplementedErr()
}
if r.deps.Stars == nil {
return nil, notImplementedErr()
}
if _, _, err := r.validateStarsTopupPurpose(ctx, userID, purpose); err != nil {
return nil, err
}
return r.starsTopupPaymentForm(userID, purpose), nil
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
return nil, notImplementedErr()
@ -55,10 +85,6 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
if r.deps.Gifts == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer); err != nil {
return nil, err
}
@ -75,21 +101,29 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
}, nil
}
// onPaymentsSendStarsForm 仅处理 inputInvoiceStarGiftDebit→投递/记账,
// 返回 paymentResult{updates}(含 updateStarsBalance用户礼物还含私聊服务消息。失败补偿退款。
// onPaymentsSendStarsForm 处理 star gift 与 Stars topup
// - star gift: Debit→投递/记账,失败补偿退款。
// - topup: 校验测试包白名单→Credit 本地账本。
//
// 返回 paymentResult{updates}(含 updateStarsBalance用户礼物还含私聊服务消息
// 崩溃约束:必须返回合法 paymentResult{非空 Updates}DrKLO 强转)。
func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSendStarsFormRequest) (tg.PaymentsPaymentResultClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
return r.sendStarsTopupForm(ctx, userID, req.FormID, inv)
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
return nil, notImplementedErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer)
if err != nil {
return nil, err
@ -149,6 +183,83 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
return &tg.PaymentsPaymentResult{Updates: updates}, nil
}
func starsTopupPurpose(inv *tg.InputInvoiceStars) (*tg.InputStorePaymentStarsTopup, bool) {
if inv == nil {
return nil, false
}
purpose, ok := inv.Purpose.(*tg.InputStorePaymentStarsTopup)
return purpose, ok && purpose != nil
}
func (r *Router) validateStarsTopupPurpose(ctx context.Context, userID int64, purpose *tg.InputStorePaymentStarsTopup) (tg.StarsTopupOption, domain.Peer, error) {
if purpose == nil || purpose.Stars <= 0 || purpose.Amount <= 0 || purpose.Currency == "" {
return tg.StarsTopupOption{}, domain.Peer{}, starsAmountInvalidErr()
}
var matched tg.StarsTopupOption
found := false
for _, opt := range devStarsTopupOptions() {
if opt.Stars == purpose.Stars && opt.Currency == purpose.Currency && opt.Amount == purpose.Amount {
matched = opt
found = true
break
}
}
if !found {
return tg.StarsTopupOption{}, domain.Peer{}, starsFormAmountMismatchErr()
}
peer := domain.Peer{}
if purpose.SpendPurposePeer != nil {
var err error
peer, err = r.checkedDomainPeerFromInputPeer(ctx, userID, purpose.SpendPurposePeer)
if err != nil {
return tg.StarsTopupOption{}, domain.Peer{}, err
}
}
return matched, peer, nil
}
func (r *Router) starsTopupPaymentForm(userID int64, purpose *tg.InputStorePaymentStarsTopup) *tg.PaymentsPaymentFormStars {
return &tg.PaymentsPaymentFormStars{
FormID: starsTopupFormID(userID, purpose.Stars, purpose.Currency, purpose.Amount),
BotID: domain.OfficialSystemUserID,
Title: "Telegram Stars",
Description: "telesrv dev Stars top-up",
Invoice: tg.Invoice{
Currency: "XTR",
Prices: []tg.LabeledPrice{{Label: "Telegram Stars", Amount: purpose.Stars}},
},
Users: tgUsersForViewer(userID, []domain.User{domain.OfficialSystemUser()}),
}
}
func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStars) (tg.PaymentsPaymentResultClass, error) {
purpose, ok := starsTopupPurpose(inv)
if !ok {
return nil, notImplementedErr()
}
if formID == 0 {
return nil, formIDEmptyErr()
}
if r.deps.Stars == nil {
return nil, notImplementedErr()
}
_, peer, err := r.validateStarsTopupPurpose(ctx, userID, purpose)
if err != nil {
return nil, err
}
if formID != starsTopupFormID(userID, purpose.Stars, purpose.Currency, purpose.Amount) {
return nil, starsFormAmountMismatchErr()
}
if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil {
return nil, starsErr(err)
}
balance, err := r.deps.Stars.Credit(ctx, userID, purpose.Stars, domain.StarsReasonTopup, peer, "Stars top-up", "telesrv dev purchase")
if err != nil {
return nil, starsErr(err)
}
return &tg.PaymentsPaymentResult{Updates: starsBalanceUpdates(balance.Balance, r.clock.Now().Unix())}, nil
}
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string) (*tg.Updates, error) {
// 2. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message)
@ -668,6 +779,29 @@ func starGiftFormID(userID, giftID int64) int64 {
return id
}
func starsTopupFormID(userID, stars int64, currency string, amount int64) int64 {
id := userID*0x9e3779b1 ^ (stars << 7) ^ (amount << 13) ^ 0x5354415253
for _, ch := range currency {
id = id*131 + int64(ch)
}
if id < 0 {
id = ^id
}
if id == 0 {
id = 0x5354
}
return id
}
func starsBalanceUpdates(balance int64, unixDate int64) *tg.Updates {
return &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance}}},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: int(unixDate),
}
}
func giftPriceLabel(g domain.StarGift) string {
if g.Title != "" {
return g.Title

View file

@ -6,6 +6,7 @@ import (
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
@ -383,3 +384,95 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
t.Fatalf("sender balance = %d, want 1000 unchanged", bal.Balance)
}
}
func TestStarsTopupInvoiceFallbackCreditsBalance(t *testing.T) {
r, sender, _, _ := starGiftTestRouter(t)
ctx := context.Background()
senderCtx := WithUserID(ctx, sender.ID)
opt := devStarsTopupOptions()[1]
inv := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{
Stars: opt.Stars,
Currency: opt.Currency,
Amount: opt.Amount,
}}
formRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if err != nil {
t.Fatalf("getPaymentForm topup: %v", err)
}
form, ok := formRes.(*tg.PaymentsPaymentFormStars)
if !ok {
t.Fatalf("form = %T, want *tg.PaymentsPaymentFormStars", formRes)
}
if form.FormID != starsTopupFormID(sender.ID, opt.Stars, opt.Currency, opt.Amount) {
t.Fatalf("form id = %d, want deterministic topup id", form.FormID)
}
if form.BotID != domain.OfficialSystemUserID || len(form.Users) != 1 {
t.Fatalf("form bot/users = %d/%d, want official system user", form.BotID, len(form.Users))
}
if form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != opt.Stars {
t.Fatalf("form invoice = %+v, want XTR + 1 price %d", form.Invoice, opt.Stars)
}
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID + 1, Invoice: inv}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
t.Fatalf("sendStarsForm bad form err = %v, want STARS_FORM_AMOUNT_MISMATCH", err)
}
if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 1000 {
t.Fatalf("balance after bad form = %d, want 1000 unchanged", bal.Balance)
}
payRes, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv})
if err != nil {
t.Fatalf("sendStarsForm topup: %v", err)
}
pay, ok := payRes.(*tg.PaymentsPaymentResult)
if !ok {
t.Fatalf("pay result = %T, want *tg.PaymentsPaymentResult", payRes)
}
updates, ok := pay.Updates.(*tg.Updates)
if !ok {
t.Fatalf("pay updates = %T, want *tg.Updates", pay.Updates)
}
foundBalance := false
for _, up := range updates.Updates {
if balance, ok := up.(*tg.UpdateStarsBalance); ok {
foundBalance = true
if amt, ok := balance.Balance.(*tg.StarsAmount); !ok || amt.Amount != 3500 {
t.Fatalf("updateStarsBalance = %#v, want 3500", balance.Balance)
}
}
}
if !foundBalance {
t.Fatalf("payment updates missing updateStarsBalance: %#v", updates.Updates)
}
if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 3500 {
t.Fatalf("balance after topup = %d, want 3500", bal.Balance)
}
page, err := r.deps.Stars.ListTransactions(ctx, sender.ID, "", 10)
if err != nil {
t.Fatalf("list transactions: %v", err)
}
hasTopup := false
for _, tx := range page.Transactions {
if tx.Reason == domain.StarsReasonTopup && tx.Amount == opt.Stars {
hasTopup = true
}
}
if !hasTopup {
t.Fatalf("transactions missing topup %d: %+v", opt.Stars, page.Transactions)
}
}
func TestStarsTopupRejectsUnlistedAmount(t *testing.T) {
r, sender, _, _ := starGiftTestRouter(t)
ctx := WithUserID(context.Background(), sender.ID)
inv := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{
Stars: 2501,
Currency: "USD",
Amount: 199,
}}
_, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
t.Fatalf("getPaymentForm unlisted err = %v, want STARS_FORM_AMOUNT_MISMATCH", err)
}
}

View file

@ -175,6 +175,71 @@ WHERE channel_id = $1 AND message_id = $2 AND reaction_type = $3`, env.channelID
}
}
func TestChannelStoreSetAvailableReactionsRefreshesRowCache(t *testing.T) {
pool := testPool(t) // 未设 DSN 会 t.Skip
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 94, Phone: "+1892" + suffix + "04", FirstName: "PolicyCacheOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
var channelID int64
t.Cleanup(func() {
if channelID != 0 {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
}
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
rowCache := NewChannelRowCache(100)
channels := NewChannelStore(pool, WithChannelRowCache(rowCache))
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Reaction Policy Cache " + suffix,
Broadcast: true,
Date: 1700001010,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID = created.Channel.ID
if _, err := channels.GetChannel(ctx, owner.ID, channelID); err != nil {
t.Fatalf("warm GetChannel: %v", err)
}
cached, ok := rowCache.get(channelID)
if !ok {
t.Fatalf("GetChannel should warm row cache")
}
if cached.ReactionPolicy.PaidEnabled {
t.Fatalf("cached paid enabled before update = true, want false")
}
if _, err := channels.SetAvailableReactions(ctx, owner.ID, channelID, domain.ChannelReactionPolicy{
Type: domain.ChannelReactionPolicyAll,
AllowCustom: true,
Limit: 9,
PaidEnabled: true,
}); err != nil {
t.Fatalf("set paid reaction policy: %v", err)
}
view, err := channels.GetChannel(ctx, owner.ID, channelID)
if err != nil {
t.Fatalf("get channel after set policy: %v", err)
}
if !view.Channel.ReactionPolicy.PaidEnabled || view.Channel.ReactionPolicy.Limit != 9 || !view.Channel.ReactionPolicy.AllowCustom {
t.Fatalf("view policy after set = %+v, want paid+limit+allow_custom from write-through cache", view.Channel.ReactionPolicy)
}
cached, ok = rowCache.get(channelID)
if !ok {
t.Fatalf("row cache missing after set policy")
}
if !cached.ReactionPolicy.PaidEnabled || cached.ReactionPolicy.Limit != 9 || !cached.ReactionPolicy.AllowCustom {
t.Fatalf("cached policy after set = %+v, want paid+limit+allow_custom", cached.ReactionPolicy)
}
}
func TestChannelStoreUniqueReactionsLimitOnlyBlocksNewKinds(t *testing.T) {
env := newReactionPolicyTestEnv(t, false)
ctx := context.Background()

View file

@ -36,5 +36,8 @@ func (s *ChannelStore) SetAvailableReactions(ctx context.Context, userID, channe
}); err != nil {
return domain.Channel{}, err
}
if s.rowCache != nil {
s.rowCache.put(channel)
}
return channel, nil
}

View file

@ -174,7 +174,11 @@ if ($schema) {
$parts = $schema -split "\|", 2
Write-Host "schema_version=$($parts[0])"
Write-Host "schema_dirty=$($parts[1])"
if ($parts.Count -gt 1 -and $parts[1] -eq "f") {
$schemaDirty = ""
if ($parts.Count -gt 1) {
$schemaDirty = $parts[1].Trim().ToLowerInvariant()
}
if ($schemaDirty -in @("f", "false", "0")) {
Write-Ok "schema is clean"
} else {
Add-Failure "schema_migrations is dirty"