From 1f11b0fd1bdd12e6d57a8faac3b4a112204f6cf0 Mon Sep 17 00:00:00 2001 From: A Date: Wed, 8 Jul 2026 01:19:22 +0800 Subject: [PATCH] fix: sync stars topup and paid reaction settings --- internal/app/help/service.go | 7 +- internal/app/help/service_premium_test.go | 6 +- .../rpc/channels_legacy_settings_rpc_test.go | 156 +++++++++++++++++ internal/rpc/channels_read_reactions.go | 18 +- internal/rpc/convert_channels_core.go | 12 +- internal/rpc/errors.go | 4 + internal/rpc/payments.go | 2 +- internal/rpc/payments_star_gifts.go | 158 ++++++++++++++++-- internal/rpc/payments_star_gifts_rpc_test.go | 93 +++++++++++ ...hannel_reaction_policy_integration_test.go | 65 +++++++ .../postgres/channel_reaction_settings.go | 3 + scripts/check-local-runtime.ps1 | 6 +- 12 files changed, 506 insertions(+), 24 deletions(-) diff --git a/internal/app/help/service.go b/internal/app/help/service.go index 6d182ee4..ce33c53c 100644 --- a/internal/app/help/service.go +++ b/internal/app/help/service.go @@ -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 提供客户端启动配置与国家区号目录。 // diff --git a/internal/app/help/service_premium_test.go b/internal/app/help/service_premium_test.go index 72c757a1..db9312d3 100644 --- a/internal/app/help/service_premium_test.go +++ b/internal/app/help/service_premium_test.go @@ -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"]) diff --git a/internal/rpc/channels_legacy_settings_rpc_test.go b/internal/rpc/channels_legacy_settings_rpc_test.go index 4d9eff2b..459b7a95 100644 --- a/internal/rpc/channels_legacy_settings_rpc_test.go +++ b/internal/rpc/channels_legacy_settings_rpc_test.go @@ -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) { diff --git a/internal/rpc/channels_read_reactions.go b/internal/rpc/channels_read_reactions.go index 9ab9c277..75d0110a 100644 --- a/internal/rpc/channels_read_reactions.go +++ b/internal/rpc/channels_read_reactions.go @@ -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 +} diff --git a/internal/rpc/convert_channels_core.go b/internal/rpc/convert_channels_core.go index e0f3f0da..eb99a6a0 100644 --- a/internal/rpc/convert_channels_core.go +++ b/internal/rpc/convert_channels_core.go @@ -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 } diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index f58920da..c5e5441c 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -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") } diff --git a/internal/rpc/payments.go b/internal/rpc/payments.go index c5249d6d..c3c5e1f3 100644 --- a/internal/rpc/payments.go +++ b/internal/rpc/payments.go @@ -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)才置 diff --git a/internal/rpc/payments_star_gifts.go b/internal/rpc/payments_star_gifts.go index 0fe0ae7a..a91871e1 100644 --- a/internal/rpc/payments_star_gifts.go +++ b/internal/rpc/payments_star_gifts.go @@ -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#b425cfe1(TDesktop 单分支 match), -// Invoice.Prices 必须非空(DrKLO/TDesktop 读 prices.front())。 +// Stars 表单 Invoice.Prices 必须非空且 Currency=XTR(DrKLO/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 仅处理 inputInvoiceStarGift:Debit→投递/记账, -// 返回 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 diff --git a/internal/rpc/payments_star_gifts_rpc_test.go b/internal/rpc/payments_star_gifts_rpc_test.go index 45690614..db92d0bc 100644 --- a/internal/rpc/payments_star_gifts_rpc_test.go +++ b/internal/rpc/payments_star_gifts_rpc_test.go @@ -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) + } +} diff --git a/internal/store/postgres/channel_reaction_policy_integration_test.go b/internal/store/postgres/channel_reaction_policy_integration_test.go index f62748a8..fc75bae4 100644 --- a/internal/store/postgres/channel_reaction_policy_integration_test.go +++ b/internal/store/postgres/channel_reaction_policy_integration_test.go @@ -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() diff --git a/internal/store/postgres/channel_reaction_settings.go b/internal/store/postgres/channel_reaction_settings.go index 9d6b5d11..c59b70d2 100644 --- a/internal/store/postgres/channel_reaction_settings.go +++ b/internal/store/postgres/channel_reaction_settings.go @@ -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 } diff --git a/scripts/check-local-runtime.ps1 b/scripts/check-local-runtime.ps1 index 762fd762..933e4353 100644 --- a/scripts/check-local-runtime.ps1 +++ b/scripts/check-local-runtime.ps1 @@ -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"