From 7e0f9d1e62909ffbcad8cd0cefecf05a8c6e246b Mon Sep 17 00:00:00 2001 From: iamxvbaba <28732408+iamxvbaba@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:51:03 +0800 Subject: [PATCH] feat(stars): sync implement unified purchase flow --- cmd/telesrv/main.go | 4 +- .../0166_unified_stars_purchase.down.sql | 41 ++ .../0166_unified_stars_purchase.up.sql | 81 +++ internal/app/help/service.go | 12 +- internal/app/help/service_premium_test.go | 20 + internal/app/stars/service.go | 73 ++- internal/compat/android/stars.go | 24 + internal/compat/android/stars_test.go | 30 + internal/compat/tdesktop/defaults.go | 18 +- .../compat/tdesktop/startup_stubs_test.go | 27 + internal/domain/media.go | 16 + internal/domain/stars.go | 102 +++- internal/rpc/convert_media.go | 19 + internal/rpc/payments.go | 62 ++ internal/rpc/payments_star_gifts.go | 478 ++++++++++++--- internal/rpc/payments_star_gifts_rpc_test.go | 256 +++++++- .../rpc/payments_stars_friend_gift_test.go | 116 +++- internal/rpc/payments_stars_rpc_test.go | 21 + internal/rpc/router_dispatch_test.go | 4 + internal/rpc/update_peer_refs.go | 7 + .../store/postgres/channel_message_send.go | 36 +- .../store/postgres/stars_gift_purchase.go | 555 +++++++++++++++--- .../stars_gift_purchase_integration_test.go | 285 ++++++++- internal/store/stars.go | 18 +- internal/web/server.go | 38 ++ internal/web/server_test.go | 26 + 26 files changed, 2087 insertions(+), 282 deletions(-) create mode 100644 deploy/migrations/0166_unified_stars_purchase.down.sql create mode 100644 deploy/migrations/0166_unified_stars_purchase.up.sql create mode 100644 internal/compat/android/stars.go create mode 100644 internal/compat/android/stars_test.go diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index de12c529..b0d50523 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -916,10 +916,10 @@ func run(logger *zap.Logger) error { encryptedQueueStore := postgres.NewEncryptedQueueStore(pool) secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator) starsStore := postgres.NewStarsStore(pool) - starsGiftPurchaseStore := postgres.NewStarsGiftPurchaseStore(pool, messageStore) + starsPurchaseStore := postgres.NewStarsPurchaseStore(pool, messageStore, channelStore) starsService := stars.NewService(starsStore, stars.WithStartingGrant(cfg.StarsStartingGrant), - stars.WithGiftPurchaseStore(starsGiftPurchaseStore)) + stars.WithPurchaseStore(starsPurchaseStore)) starGiftStore := postgres.NewStarGiftStore(pool) starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore, postgres.WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{ TransferStars: cfg.StarGiftTransferStars, DropOriginalDetailsStars: cfg.StarGiftDropOriginalDetailsStars, diff --git a/deploy/migrations/0166_unified_stars_purchase.down.sql b/deploy/migrations/0166_unified_stars_purchase.down.sql new file mode 100644 index 00000000..226a2a74 --- /dev/null +++ b/deploy/migrations/0166_unified_stars_purchase.down.sql @@ -0,0 +1,41 @@ +DROP TABLE IF EXISTS public.stars_giveaways; + +DELETE FROM public.stars_purchase_commands WHERE kind <> 'gift'; +DELETE FROM public.stars_purchase_forms WHERE kind <> 'gift'; + +ALTER TABLE public.stars_purchase_commands + DROP CONSTRAINT stars_purchase_commands_shape_check; +ALTER TABLE public.stars_purchase_commands + RENAME COLUMN balance_after TO recipient_balance_after; +ALTER TABLE public.stars_purchase_commands + DROP COLUMN spend_peer_id, + DROP COLUMN spend_peer_type, + DROP COLUMN purpose_json, + DROP COLUMN kind, + ALTER COLUMN recipient_user_id SET NOT NULL, + ADD CONSTRAINT stars_gift_purchase_commands_shape_check CHECK ( + buyer_user_id > 0 AND recipient_user_id > 0 AND + buyer_user_id <> recipient_user_id AND form_id <> 0 AND + octet_length(request_fingerprint) = 32 AND stars > 0 AND amount > 0 AND + char_length(currency) = 3 AND recipient_balance_after >= 0 AND + transaction_id <> '' AND created_at > 0); +ALTER TABLE public.stars_purchase_commands + RENAME TO stars_gift_purchase_commands; + +ALTER TABLE public.stars_purchase_forms + DROP CONSTRAINT stars_purchase_forms_shape_check, + DROP COLUMN spend_peer_id, + DROP COLUMN spend_peer_type, + DROP COLUMN purpose_json, + DROP COLUMN kind, + ALTER COLUMN recipient_user_id SET NOT NULL, + ADD CONSTRAINT stars_gift_purchase_forms_shape_check CHECK ( + buyer_user_id > 0 AND recipient_user_id > 0 AND + buyer_user_id <> recipient_user_id AND form_id <> 0 AND + stars > 0 AND amount > 0 AND char_length(currency) = 3 AND + currency = upper(currency) AND issued_at > 0 AND + expires_at = issued_at + 600); +ALTER INDEX public.stars_purchase_forms_expiry_idx + RENAME TO stars_gift_purchase_forms_expiry_idx; +ALTER TABLE public.stars_purchase_forms + RENAME TO stars_gift_purchase_forms; diff --git a/deploy/migrations/0166_unified_stars_purchase.up.sql b/deploy/migrations/0166_unified_stars_purchase.up.sql new file mode 100644 index 00000000..4eb8e0c2 --- /dev/null +++ b/deploy/migrations/0166_unified_stars_purchase.up.sql @@ -0,0 +1,81 @@ +ALTER TABLE public.stars_gift_purchase_forms + RENAME TO stars_purchase_forms; +ALTER INDEX public.stars_gift_purchase_forms_expiry_idx + RENAME TO stars_purchase_forms_expiry_idx; + +ALTER TABLE public.stars_purchase_forms + DROP CONSTRAINT stars_gift_purchase_forms_shape_check, + ADD COLUMN kind text NOT NULL DEFAULT 'gift', + ADD COLUMN spend_peer_type text, + ADD COLUMN spend_peer_id bigint, + ADD COLUMN purpose_json jsonb NOT NULL DEFAULT '{}'::jsonb, + ALTER COLUMN recipient_user_id DROP NOT NULL; +ALTER TABLE public.stars_purchase_forms + ALTER COLUMN kind DROP DEFAULT, + ADD CONSTRAINT stars_purchase_forms_shape_check CHECK ( + kind IN ('topup', 'gift', 'giveaway') AND buyer_user_id > 0 AND form_id <> 0 AND + ((kind IN ('topup', 'giveaway') AND recipient_user_id IS NULL) OR + (kind = 'gift' AND recipient_user_id > 0 AND buyer_user_id <> recipient_user_id)) AND + ((spend_peer_type IS NULL AND spend_peer_id IS NULL) OR + (kind = 'topup' AND spend_peer_type IN ('user', 'channel') AND spend_peer_id > 0)) AND + ((kind IN ('topup', 'gift') AND purpose_json = '{}'::jsonb) OR + (kind = 'giveaway' AND jsonb_typeof(purpose_json) = 'object' AND purpose_json <> '{}'::jsonb)) AND + stars > 0 AND amount > 0 AND char_length(currency) = 3 AND + currency = upper(currency) AND issued_at > 0 AND + expires_at = issued_at + 600); + +ALTER TABLE public.stars_gift_purchase_commands + RENAME TO stars_purchase_commands; +ALTER TABLE public.stars_purchase_commands + DROP CONSTRAINT stars_gift_purchase_commands_shape_check, + ADD COLUMN kind text NOT NULL DEFAULT 'gift', + ADD COLUMN spend_peer_type text, + ADD COLUMN spend_peer_id bigint, + ADD COLUMN purpose_json jsonb NOT NULL DEFAULT '{}'::jsonb, + ALTER COLUMN recipient_user_id DROP NOT NULL; +ALTER TABLE public.stars_purchase_commands + RENAME COLUMN recipient_balance_after TO balance_after; +ALTER TABLE public.stars_purchase_commands + ALTER COLUMN kind DROP DEFAULT, + ADD CONSTRAINT stars_purchase_commands_shape_check CHECK ( + kind IN ('topup', 'gift', 'giveaway') AND buyer_user_id > 0 AND form_id <> 0 AND + ((kind IN ('topup', 'giveaway') AND recipient_user_id IS NULL) OR + (kind = 'gift' AND recipient_user_id > 0 AND buyer_user_id <> recipient_user_id)) AND + ((spend_peer_type IS NULL AND spend_peer_id IS NULL) OR + (kind = 'topup' AND spend_peer_type IN ('user', 'channel') AND spend_peer_id > 0)) AND + ((kind IN ('topup', 'gift') AND purpose_json = '{}'::jsonb) OR + (kind = 'giveaway' AND jsonb_typeof(purpose_json) = 'object' AND purpose_json <> '{}'::jsonb)) AND + octet_length(request_fingerprint) = 32 AND stars > 0 AND amount > 0 AND + char_length(currency) = 3 AND balance_after >= 0 AND + transaction_id <> '' AND created_at > 0); + +CREATE TABLE public.stars_giveaways ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + buyer_user_id bigint NOT NULL, + form_id bigint NOT NULL, + channel_id bigint NOT NULL, + launch_message_id integer NOT NULL, + random_id bigint NOT NULL, + stars bigint NOT NULL, + users integer NOT NULL, + per_user_stars bigint NOT NULL, + yearly_boosts integer NOT NULL, + until_date integer NOT NULL, + purpose_json jsonb NOT NULL, + state text NOT NULL DEFAULT 'active', + created_at integer NOT NULL, + CONSTRAINT stars_giveaways_form_fk FOREIGN KEY (buyer_user_id, form_id) + REFERENCES public.stars_purchase_forms(buyer_user_id, form_id) ON DELETE RESTRICT, + CONSTRAINT stars_giveaways_form_unique UNIQUE (buyer_user_id, form_id), + CONSTRAINT stars_giveaways_random_unique UNIQUE (buyer_user_id, channel_id, random_id), + CONSTRAINT stars_giveaways_launch_unique UNIQUE (channel_id, launch_message_id), + CONSTRAINT stars_giveaways_shape_check CHECK ( + buyer_user_id > 0 AND form_id <> 0 AND channel_id > 0 AND launch_message_id > 0 AND + random_id <> 0 AND stars > 0 AND users > 0 AND per_user_stars > 0 AND + users::bigint * per_user_stars = stars AND yearly_boosts >= 0 AND until_date > created_at AND + jsonb_typeof(purpose_json) = 'object' AND purpose_json <> '{}'::jsonb AND + state IN ('active', 'completed', 'cancelled') AND created_at > 0) +); + +CREATE INDEX stars_giveaways_channel_state_until_idx + ON public.stars_giveaways(channel_id, state, until_date, id); diff --git a/internal/app/help/service.go b/internal/app/help/service.go index 11a3a3cb..7cf59e16 100644 --- a/internal/app/help/service.go +++ b/internal/app/help/service.go @@ -8,6 +8,7 @@ import ( "strconv" "sync" + compatandroid "telesrv/internal/compat/android" "telesrv/internal/domain" "telesrv/internal/seed/catalog" "telesrv/internal/store" @@ -55,10 +56,10 @@ const tdesktopClient = "tdesktop" // // WebK directly calls Array.some on fragment_prefixes while rendering user profiles, // so this compatibility key must always remain an array, even when it is empty. -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","fragment_prefixes":["888"],"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,"stargifts_pinned_to_top_limit":6,"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,"bot_verification_description_length_limit":70,"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","fragment_prefixes":["888"],"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,"stargifts_pinned_to_top_limit":6,"giveaway_gifts_purchase_available":true,"giveaway_boosts_per_premium":4,"giveaway_countries_max":10,"giveaway_add_peers_max":10,"giveaway_period_max":604800,"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,"bot_verification_description_length_limit":70,"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 tdesktopNoForwardsAppConfig = `,"no_forwards_request_expire_period":86400` -const defaultAppConfigHash = 26 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。 +const defaultAppConfigHash = 27 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。 // Service 提供客户端启动配置与国家区号目录。 // @@ -117,15 +118,16 @@ func defaultAppConfig(mapboxToken string) domain.AppConfig { } func defaultAppConfigJSON(mapboxToken string) []byte { + androidInvoiceBilling := `,"premium_playmarket_direct_currency_list":` + compatandroid.DirectInvoiceCurrenciesJSON() if mapboxToken == "" { - return []byte(tdesktopDefaultAppConfigBase + tdesktopNoForwardsAppConfig + `}`) + return []byte(tdesktopDefaultAppConfigBase + tdesktopNoForwardsAppConfig + androidInvoiceBilling + `}`) } token, err := json.Marshal(mapboxToken) if err != nil { - return []byte(tdesktopDefaultAppConfigBase + tdesktopNoForwardsAppConfig + `}`) + return []byte(tdesktopDefaultAppConfigBase + tdesktopNoForwardsAppConfig + androidInvoiceBilling + `}`) } tokenJSON := string(token) - return []byte(tdesktopDefaultAppConfigBase + tdesktopNoForwardsAppConfig + `,"tdesktop_config_map":{"maps":` + tokenJSON + `,"geo":` + tokenJSON + `,"bmaps":` + tokenJSON + `,"bgeo":` + tokenJSON + `}}`) + return []byte(tdesktopDefaultAppConfigBase + tdesktopNoForwardsAppConfig + androidInvoiceBilling + `,"tdesktop_config_map":{"maps":` + tokenJSON + `,"geo":` + tokenJSON + `,"bmaps":` + tokenJSON + `,"bgeo":` + tokenJSON + `}}`) } func defaultAppConfigHashFor(mapboxToken string) int { diff --git a/internal/app/help/service_premium_test.go b/internal/app/help/service_premium_test.go index 6f58a6ea..fafbd33f 100644 --- a/internal/app/help/service_premium_test.go +++ b/internal/app/help/service_premium_test.go @@ -42,6 +42,13 @@ func TestAppConfigPremiumKeys(t *testing.T) { if blocked, ok := decoded["stargifts_blocked"].(bool); !ok || blocked { t.Fatalf("stargifts_blocked = %v, want false (DrKLO GiftSheet 据此隐藏礼物网格)", decoded["stargifts_blocked"]) } + if available, ok := decoded["giveaway_gifts_purchase_available"].(bool); !ok || !available { + t.Fatalf("giveaway_gifts_purchase_available = %v, want true", decoded["giveaway_gifts_purchase_available"]) + } + directCurrencies, ok := decoded["premium_playmarket_direct_currency_list"].([]any) + if !ok || len(directCurrencies) == 0 || !containsJSONCurrency(directCurrencies, "USD") { + t.Fatalf("premium_playmarket_direct_currency_list = %#v, want non-empty list containing USD", decoded["premium_playmarket_direct_currency_list"]) + } if posting, ok := decoded["rich_message_posting"].(string); !ok || posting != "enabled" { t.Fatalf("rich_message_posting = %v, want enabled (TDesktop 富文本编辑入口默认打开)", decoded["rich_message_posting"]) } @@ -50,6 +57,10 @@ func TestAppConfigPremiumKeys(t *testing.T) { t.Fatalf("fragment_prefixes = %#v, want [\"888\"]", decoded["fragment_prefixes"]) } wantNumbers := map[string]float64{ + "giveaway_boosts_per_premium": 4, + "giveaway_countries_max": 10, + "giveaway_add_peers_max": 10, + "giveaway_period_max": 604800, "reactions_user_max_default": 1, "reactions_user_max_premium": 3, "boosts_channel_level_max": 100, @@ -99,6 +110,15 @@ func TestAppConfigPremiumKeys(t *testing.T) { } } +func containsJSONCurrency(values []any, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + func TestAppConfigOmitsMapboxTokenByDefault(t *testing.T) { cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0, 0) if err != nil || notModified { diff --git a/internal/app/stars/service.go b/internal/app/stars/service.go index 085f15ff..e29b1c89 100644 --- a/internal/app/stars/service.go +++ b/internal/app/stars/service.go @@ -13,10 +13,10 @@ import ( // Service 是 Stars 账本应用服务。 type Service struct { - store store.StarsStore - giftStore store.StarsGiftPurchaseStore - grantAmount int64 - now func() time.Time + store store.StarsStore + purchaseStore store.StarsPurchaseStore + grantAmount int64 + now func() time.Time } // Option 配置 Service。 @@ -27,9 +27,9 @@ func WithStartingGrant(amount int64) Option { return func(s *Service) { s.grantAmount = amount } } -// WithGiftPurchaseStore enables the atomic fiat Stars-gift checkout aggregate. -func WithGiftPurchaseStore(st store.StarsGiftPurchaseStore) Option { - return func(s *Service) { s.giftStore = st } +// WithPurchaseStore enables the atomic fiat Stars checkout aggregate. +func WithPurchaseStore(st store.StarsPurchaseStore) Option { + return func(s *Service) { s.purchaseStore = st } } // WithClock 注入时钟(测试用)。 @@ -96,24 +96,55 @@ func (s *Service) ListTransactions(ctx context.Context, userID int64, query doma return s.store.ListTransactions(ctx, userID, query) } -// IssueGiftPurchaseForm persists a short-lived, exact checkout intent. -func (s *Service) IssueGiftPurchaseForm(ctx context.Context, form domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error) { - if s.giftStore == nil || form.BuyerUserID <= 0 || form.RecipientUserID <= 0 || - form.BuyerUserID == form.RecipientUserID || form.Stars <= 0 || form.Amount <= 0 || - form.Currency == "" || form.IssuedAt <= 0 || form.ExpiresAt != form.IssuedAt+600 { - return domain.StarsGiftPurchaseForm{}, domain.ErrStarsGiftFormInvalid +// IssuePurchaseForm persists a short-lived, exact checkout intent. +func (s *Service) IssuePurchaseForm(ctx context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) { + if s.purchaseStore == nil || !validPurchaseForm(form) { + return domain.StarsPurchaseForm{}, domain.ErrStarsPurchaseFormInvalid } - return s.giftStore.IssueStarsGiftPurchaseForm(ctx, form) + return s.purchaseStore.IssueStarsPurchaseForm(ctx, form) } -// PurchaseGift settles one exact persisted form. Package validation remains at +// Purchase settles one exact persisted form. Package validation remains at // the RPC boundary as well, while the store revalidates the persisted tuple // under lock before performing any write. -func (s *Service) PurchaseGift(ctx context.Context, req domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error) { - if s.giftStore == nil || req.FormID == 0 || req.BuyerUserID <= 0 || req.RecipientUserID <= 0 || - req.BuyerUserID == req.RecipientUserID || req.Stars <= 0 || req.Amount <= 0 || - req.Currency == "" || req.Date <= 0 { - return domain.StarsGiftPurchaseResult{}, domain.ErrStarsGiftFormInvalid +func (s *Service) Purchase(ctx context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) { + if s.purchaseStore == nil || req.FormID == 0 || req.Date <= 0 || !validPurchaseCommand(req.StarsPurchaseForm) { + return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid + } + return s.purchaseStore.PurchaseStars(ctx, req) +} + +// GetGiveawayInfo resolves one launch card from the same aggregate that +// persisted it. date is supplied by the RPC clock for deterministic tests. +func (s *Service) GetGiveawayInfo(ctx context.Context, viewerUserID, channelID int64, messageID, date int) (domain.StarsGiveawayInfo, error) { + reader, ok := s.purchaseStore.(store.StarsGiveawayStore) + if !ok || viewerUserID <= 0 || channelID <= 0 || messageID <= 0 || date <= 0 { + return domain.StarsGiveawayInfo{}, domain.ErrStarsPurchaseFormInvalid + } + return reader.GetStarsGiveawayInfo(ctx, viewerUserID, channelID, messageID, date) +} + +func validPurchaseForm(form domain.StarsPurchaseForm) bool { + return validPurchaseCommand(form) && form.IssuedAt > 0 && form.ExpiresAt == form.IssuedAt+600 +} + +func validPurchaseCommand(form domain.StarsPurchaseForm) bool { + if !form.Kind.Valid() || form.BuyerUserID <= 0 || form.Stars <= 0 || form.Amount <= 0 || form.Currency == "" { + return false + } + switch form.Kind { + case domain.StarsPurchaseTopup: + return form.Giveaway == nil && form.RecipientUserID == 0 && ((form.SpendPurposePeer == domain.Peer{}) || + ((form.SpendPurposePeer.Type == domain.PeerTypeUser || form.SpendPurposePeer.Type == domain.PeerTypeChannel) && form.SpendPurposePeer.ID > 0)) + case domain.StarsPurchaseGift: + return form.Giveaway == nil && form.RecipientUserID > 0 && form.BuyerUserID != form.RecipientUserID && form.SpendPurposePeer == (domain.Peer{}) + case domain.StarsPurchaseGiveaway: + g := form.Giveaway + return form.RecipientUserID == 0 && form.SpendPurposePeer == (domain.Peer{}) && g != nil && + g.BoostPeer.Type == domain.PeerTypeChannel && g.BoostPeer.ID > 0 && g.RandomID != 0 && + g.UntilDate > 0 && g.Users > 0 && g.PerUserStars > 0 && + int64(g.Users) <= form.Stars/g.PerUserStars && int64(g.Users)*g.PerUserStars == form.Stars + default: + return false } - return s.giftStore.PurchaseStarsGift(ctx, req) } diff --git a/internal/compat/android/stars.go b/internal/compat/android/stars.go new file mode 100644 index 00000000..3f68e241 --- /dev/null +++ b/internal/compat/android/stars.go @@ -0,0 +1,24 @@ +package android + +import "encoding/json" + +// DirectInvoiceCurrencies are the Google Play billing currencies for which +// DrKLO's official client must use Telegram's invoice flow. telesrv does not +// publish or verify Google Play products, so this prevents Stars options with +// no store_product from entering the Play Billing branch once it is ready. +var directInvoiceCurrencies = []string{ + "AED", "AUD", "BRL", "CAD", "CHF", "CLP", "CNY", "COP", "CZK", "DKK", + "EGP", "EUR", "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "JPY", "KRW", + "KZT", "MXN", "MYR", "NGN", "NOK", "NZD", "PEN", "PHP", "PKR", "PLN", + "QAR", "RON", "RUB", "SAR", "SEK", "SGD", "THB", "TRY", "TWD", "UAH", + "USD", "VND", "ZAR", +} + +func DirectInvoiceCurrencies() []string { + return append([]string(nil), directInvoiceCurrencies...) +} + +func DirectInvoiceCurrenciesJSON() string { + body, _ := json.Marshal(directInvoiceCurrencies) + return string(body) +} diff --git a/internal/compat/android/stars_test.go b/internal/compat/android/stars_test.go new file mode 100644 index 00000000..4d7d6122 --- /dev/null +++ b/internal/compat/android/stars_test.go @@ -0,0 +1,30 @@ +package android + +import ( + "encoding/json" + "testing" +) + +func TestDirectInvoiceCurrenciesAreStableAndContainUSD(t *testing.T) { + values := DirectInvoiceCurrencies() + if len(values) == 0 { + t.Fatal("direct invoice currency list is empty") + } + foundUSD := false + for _, value := range values { + if value == "USD" { + foundUSD = true + } + } + if !foundUSD { + t.Fatalf("direct invoice currencies = %v, want USD", values) + } + values[0] = "MUTATED" + if DirectInvoiceCurrencies()[0] == "MUTATED" { + t.Fatal("DirectInvoiceCurrencies returned mutable package storage") + } + var decoded []string + if err := json.Unmarshal([]byte(DirectInvoiceCurrenciesJSON()), &decoded); err != nil || len(decoded) != len(values) { + t.Fatalf("currency JSON = %q decoded=%v err=%v", DirectInvoiceCurrenciesJSON(), decoded, err) + } +} diff --git a/internal/compat/tdesktop/defaults.go b/internal/compat/tdesktop/defaults.go index ac40863f..7913cae8 100644 --- a/internal/compat/tdesktop/defaults.go +++ b/internal/compat/tdesktop/defaults.go @@ -3,11 +3,12 @@ package tdesktop import ( "github.com/iamxvbaba/td/tg" + compatandroid "telesrv/internal/compat/android" "telesrv/internal/seed/catalog" ) const ( - appConfigHash = 17 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。 + appConfigHash = 18 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。 countriesListHash = 1 timezonesListHash = 1 ) @@ -38,8 +39,15 @@ func readMarkAppConfig(mapboxToken string) *tg.JSONObject { // premiumCanBuy()=!premium_purchase_blocked 耦合,置 true 会同时隐藏送礼入口 // (详见 app/help/service.go 主配置注释)。这里是无 HelpService 时的最小回退。 {Key: "premium_purchase_blocked", Value: &tg.JSONBool{Value: false}}, + {Key: "stars_purchase_blocked", Value: &tg.JSONBool{Value: false}}, // stargifts_blocked=false:DrKLO 缺省 stargiftsBlocked=true 会隐藏 star gift 送礼网格。 {Key: "stargifts_blocked", Value: &tg.JSONBool{Value: false}}, + {Key: "giveaway_gifts_purchase_available", Value: &tg.JSONBool{Value: true}}, + {Key: "giveaway_boosts_per_premium", Value: &tg.JSONNumber{Value: 4}}, + {Key: "giveaway_countries_max", Value: &tg.JSONNumber{Value: 10}}, + {Key: "giveaway_add_peers_max", Value: &tg.JSONNumber{Value: 10}}, + {Key: "giveaway_period_max", Value: &tg.JSONNumber{Value: 604800}}, + {Key: "premium_playmarket_direct_currency_list", Value: tgStringArray(compatandroid.DirectInvoiceCurrencies())}, {Key: "reactions_user_max_premium", Value: &tg.JSONNumber{Value: 3}}, // DrKLO 频道自定义 reaction 编辑页用它作为可选 reaction 数量上限。 {Key: "boosts_channel_level_max", Value: &tg.JSONNumber{Value: 100}}, @@ -88,6 +96,14 @@ func readMarkAppConfig(mapboxToken string) *tg.JSONObject { return &tg.JSONObject{Value: values} } +func tgStringArray(values []string) *tg.JSONArray { + result := &tg.JSONArray{Value: make([]tg.JSONValueClass, 0, len(values))} + for _, value := range values { + result.Value = append(result.Value, &tg.JSONString{Value: value}) + } + return result +} + // fallbackTimezones 是 catalog 未 seed 时的内置最小时区集。 var fallbackTimezones = []tg.Timezone{ {ID: "Etc/UTC", Name: "UTC", UtcOffset: 0}, diff --git a/internal/compat/tdesktop/startup_stubs_test.go b/internal/compat/tdesktop/startup_stubs_test.go index d72534d7..14e56e70 100644 --- a/internal/compat/tdesktop/startup_stubs_test.go +++ b/internal/compat/tdesktop/startup_stubs_test.go @@ -43,6 +43,8 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) { values := make(map[string]float64) strings := make(map[string]string) arrays := make(map[string]*tg.JSONArray) + bools := make(map[string]bool) + boolSeen := make(map[string]bool) if object, ok := got.Config.(*tg.JSONObject); ok && object != nil { for _, entry := range object.Value { if number, ok := entry.Value.(*tg.JSONNumber); ok { @@ -54,12 +56,20 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) { if array, ok := entry.Value.(*tg.JSONArray); ok { arrays[entry.Key] = array } + if boolean, ok := entry.Value.(*tg.JSONBool); ok { + bools[entry.Key] = boolean.Value + boolSeen[entry.Key] = true + } } } want := map[string]float64{ "stories_stealth_future_period": 1500, "stories_stealth_past_period": 300, "stories_stealth_cooldown_period": 10800, + "giveaway_boosts_per_premium": 4, + "giveaway_countries_max": 10, + "giveaway_add_peers_max": 10, + "giveaway_period_max": 604800, } for key, expected := range want { if values[key] != expected { @@ -69,6 +79,10 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) { if strings["rich_message_posting"] != "enabled" { t.Fatalf("AppConfig[rich_message_posting] = %q, want enabled", strings["rich_message_posting"]) } + if !boolSeen["stars_purchase_blocked"] || bools["stars_purchase_blocked"] || + !boolSeen["giveaway_gifts_purchase_available"] || !bools["giveaway_gifts_purchase_available"] { + t.Fatalf("AppConfig purchase flags = stars_blocked:%v giveaway_available:%v", bools["stars_purchase_blocked"], bools["giveaway_gifts_purchase_available"]) + } fragmentPrefixes := arrays["fragment_prefixes"] if fragmentPrefixes == nil || len(fragmentPrefixes.Value) != 1 { t.Fatalf("AppConfig[fragment_prefixes] = %#v, want one-element array", fragmentPrefixes) @@ -77,6 +91,10 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) { if !ok || prefix.Value != "888" { t.Fatalf("AppConfig[fragment_prefixes][0] = %#v, want \"888\"", fragmentPrefixes.Value[0]) } + directCurrencies := arrays["premium_playmarket_direct_currency_list"] + if directCurrencies == nil || !tgJSONArrayContainsString(directCurrencies, "USD") { + t.Fatalf("AppConfig[premium_playmarket_direct_currency_list] = %#v, want USD", directCurrencies) + } if _, ok := AppConfig(got.Hash).(*tg.HelpAppConfigNotModified); !ok { t.Fatalf("AppConfig(hash) = %#v, want notModified", AppConfig(got.Hash)) } @@ -85,6 +103,15 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) { } } +func tgJSONArrayContainsString(array *tg.JSONArray, want string) bool { + for _, value := range array.Value { + if text, ok := value.(*tg.JSONString); ok && text.Value == want { + return true + } + } + return false +} + func TestFallbackAppConfigOmitsMapboxToken(t *testing.T) { got, ok := AppConfig(0).(*tg.HelpAppConfig) if !ok { diff --git a/internal/domain/media.go b/internal/domain/media.go index 9af85ad0..c659b560 100644 --- a/internal/domain/media.go +++ b/internal/domain/media.go @@ -418,8 +418,23 @@ const ( MessageMediaKindTodo MessageMediaKind = "todo" MessageMediaKindStory MessageMediaKind = "story" MessageMediaKindWebPage MessageMediaKind = "web_page" + MessageMediaKindGiveaway MessageMediaKind = "giveaway" ) +// MessageGiveaway is the immutable launch-card snapshot shown in the boost +// peer. Channels contains the boost peer first followed by any additional +// channels users must join. A results card is a separate lifecycle message. +type MessageGiveaway struct { + OnlyNewSubscribers bool `json:"only_new_subscribers,omitempty"` + WinnersAreVisible bool `json:"winners_are_visible,omitempty"` + Channels []int64 `json:"channels"` + CountriesISO2 []string `json:"countries_iso2,omitempty"` + PrizeDescription string `json:"prize_description,omitempty"` + Quantity int `json:"quantity"` + Stars int64 `json:"stars"` + UntilDate int `json:"until_date"` +} + // MessageTodoItem 是清单中的一项(id 为列表内唯一正整数,客户端分配)。 type MessageTodoItem struct { ID int `json:"id"` @@ -765,6 +780,7 @@ type MessageMedia struct { Todo *MessageTodo `json:"todo,omitempty"` Story *MessageStory `json:"story,omitempty"` WebPage *MessageWebPage `json:"web_page,omitempty"` + Giveaway *MessageGiveaway `json:"giveaway,omitempty"` Spoiler bool `json:"spoiler,omitempty"` TTLSeconds int `json:"ttl_seconds,omitempty"` Nopremium bool `json:"nopremium,omitempty"` diff --git a/internal/domain/stars.go b/internal/domain/stars.go index 64b71a80..ca291dc4 100644 --- a/internal/domain/stars.go +++ b/internal/domain/stars.go @@ -17,36 +17,84 @@ type StarsBalance struct { Granted bool // 起始授予是否已应用(惰性首读授予的幂等守卫) } -// StarsGiftPurchaseForm binds one short-lived fiat Stars-gift checkout to its -// authenticated buyer, recipient and exact server-advertised package. The -// client may echo these values but cannot use a form for a different intent. -type StarsGiftPurchaseForm struct { - FormID int64 - BuyerUserID int64 - RecipientUserID int64 - Stars int64 - Currency string - Amount int64 - IssuedAt int - ExpiresAt int +// StarsPurchaseKind identifies the balance owner affected by a fiat Stars +// checkout. It is persisted with the form so a client cannot reinterpret a +// self top-up as a friend gift (or vice versa) when submitting the form. +type StarsPurchaseKind string + +const ( + StarsPurchaseTopup StarsPurchaseKind = "topup" + StarsPurchaseGift StarsPurchaseKind = "gift" + StarsPurchaseGiveaway StarsPurchaseKind = "giveaway" +) + +func (k StarsPurchaseKind) Valid() bool { + return k == StarsPurchaseTopup || k == StarsPurchaseGift || k == StarsPurchaseGiveaway } -// StarsGiftPurchaseRequest is the immutable settlement command carried by -// inputInvoiceStars(inputStorePaymentStarsGift). -type StarsGiftPurchaseRequest struct { - StarsGiftPurchaseForm +// StarsGiveawayPurchase is the complete immutable purpose behind one direct +// fiat Stars giveaway checkout. The launch purchase persists this shape; the +// eventual winner draw is a separate lifecycle transition. +type StarsGiveawayPurchase struct { + BoostPeer Peer `json:"boost_peer"` + AdditionalPeers []Peer `json:"additional_peers,omitempty"` + CountriesISO2 []string `json:"countries_iso2,omitempty"` + PrizeDescription string `json:"prize_description,omitempty"` + RandomID int64 `json:"random_id"` + UntilDate int `json:"until_date"` + Users int `json:"users"` + PerUserStars int64 `json:"per_user_stars"` + YearlyBoosts int `json:"yearly_boosts"` + OnlyNewSubscribers bool `json:"only_new_subscribers,omitempty"` + WinnersAreVisible bool `json:"winners_are_visible,omitempty"` +} + +// StarsPurchaseForm binds one short-lived fiat Stars checkout to its +// authenticated buyer, purpose and exact server-advertised package. Recipient +// is zero for a self top-up and mandatory for a friend gift. +type StarsPurchaseForm struct { + FormID int64 + Kind StarsPurchaseKind + BuyerUserID int64 + RecipientUserID int64 + SpendPurposePeer Peer + Giveaway *StarsGiveawayPurchase + Stars int64 + Currency string + Amount int64 + IssuedAt int + ExpiresAt int +} + +// StarsPurchaseRequest is the immutable settlement command carried by +// inputInvoiceStars. Android and TDesktop sendPaymentForm both resolve to this +// command after the ordinary fiat checkout has produced provider credentials. +type StarsPurchaseRequest struct { + StarsPurchaseForm Date int OriginAuthKeyID [8]byte OriginSessionID int64 } -// StarsGiftPurchaseResult is the atomically committed recipient credit and -// bilateral service-message receipt. Duplicate means an exact form replay. -type StarsGiftPurchaseResult struct { - RecipientBalance StarsBalance - Send SendPrivateTextResult - TransactionID string - Duplicate bool +// StarsPurchaseResult is the atomically committed credit and, for a friend +// gift, bilateral service-message receipt. Duplicate means an exact form replay. +type StarsPurchaseResult struct { + Balance StarsBalance + Send SendPrivateTextResult + ChannelSend SendChannelMessageResult + TransactionID string + Duplicate bool +} + +// StarsGiveawayInfo is the viewer-specific state of one durable launch card. +// Winner selection/results are intentionally outside the purchase aggregate. +type StarsGiveawayInfo struct { + StartDate int + Participating bool + PreparingResults bool + JoinedTooEarlyDate int + AdminDisallowedChatID int64 + DisallowedCountry string } // StarsTransactionReason 标记一条流水的语义(投影到 tg.StarsTransaction 的标志位/标题)。 @@ -184,10 +232,10 @@ var ( ErrStarsInvalidAmount = errors.New("stars: invalid amount") // ErrStarsTransactionQueryInvalid 表示内部构造了不可能的流水方向。 ErrStarsTransactionQueryInvalid = errors.New("stars: invalid transaction query") - // ErrStarsGiftFormInvalid covers a missing/cross-account/mutated form. - ErrStarsGiftFormInvalid = errors.New("stars: gift form invalid") - // ErrStarsGiftFormExpired is returned before any settlement write. - ErrStarsGiftFormExpired = errors.New("stars: gift form expired") + // ErrStarsPurchaseFormInvalid covers a missing/cross-account/mutated form. + ErrStarsPurchaseFormInvalid = errors.New("stars: purchase form invalid") + // ErrStarsPurchaseFormExpired is returned before any settlement write. + ErrStarsPurchaseFormExpired = errors.New("stars: purchase form expired") // ErrStarsGiftUnavailable covers a recipient that cannot receive the gift. ErrStarsGiftUnavailable = errors.New("stars: gift unavailable") ) diff --git a/internal/rpc/convert_media.go b/internal/rpc/convert_media.go index 29b36fe8..9920c2ce 100644 --- a/internal/rpc/convert_media.go +++ b/internal/rpc/convert_media.go @@ -135,6 +135,25 @@ func tgMessageMedia(m *domain.MessageMedia) tg.MessageMediaClass { return &tg.MessageMediaEmpty{} } return tgWebPageMedia(*m.WebPage) + case domain.MessageMediaKindGiveaway: + if m.Giveaway == nil { + return &tg.MessageMediaEmpty{} + } + out := &tg.MessageMediaGiveaway{ + OnlyNewSubscribers: m.Giveaway.OnlyNewSubscribers, + WinnersAreVisible: m.Giveaway.WinnersAreVisible, + Channels: append([]int64(nil), m.Giveaway.Channels...), + Quantity: m.Giveaway.Quantity, + UntilDate: m.Giveaway.UntilDate, + } + if m.Giveaway.CountriesISO2 != nil { + out.SetCountriesISO2(append([]string(nil), m.Giveaway.CountriesISO2...)) + } + if m.Giveaway.PrizeDescription != "" { + out.SetPrizeDescription(m.Giveaway.PrizeDescription) + } + out.SetStars(m.Giveaway.Stars) + return out default: return &tg.MessageMediaEmpty{} } diff --git a/internal/rpc/payments.go b/internal/rpc/payments.go index cac0acdf..0ee72840 100644 --- a/internal/rpc/payments.go +++ b/internal/rpc/payments.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tgerr" "github.com/iamxvbaba/td/tlprofile" "telesrv/internal/compat/tdesktop" @@ -15,9 +16,21 @@ import ( // registerPayments 注册 payments.* RPC:Stars 本地账本(余额/流水真实化)+ 其余 // gift/auction/revenue 第一阶段兼容桩。 func (r *Router) registerPayments(d *tlprofile.Dispatcher) { + registerRPC[*tg.PaymentsCanPurchaseStoreRequest](d, tlprofile.SemanticMethodPaymentsCanPurchaseStore, func(ctx context.Context, req *tg.PaymentsCanPurchaseStoreRequest) (any, error) { + return r.onPaymentsCanPurchaseStore(ctx, req) + }) + registerRPC[*tg.PaymentsAssignPlayMarketTransactionRequest](d, tlprofile.SemanticMethodPaymentsAssignPlayMarketTransaction, func(ctx context.Context, req *tg.PaymentsAssignPlayMarketTransactionRequest) (any, error) { + return r.onPaymentsAssignPlayMarketTransaction(ctx, req) + }) registerRPC[*tg.PaymentsGetStarsGiftOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsGiftOptions, func(ctx context.Context, req *tg.PaymentsGetStarsGiftOptionsRequest) (any, error) { return r.onPaymentsGetStarsGiftOptions(ctx, req) }) + registerRPC[*tg.PaymentsGetStarsGiveawayOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsGiveawayOptions, func(ctx context.Context, _ *tg.PaymentsGetStarsGiveawayOptionsRequest) (any, error) { + return r.onPaymentsGetStarsGiveawayOptions(ctx) + }) + registerRPC[*tg.PaymentsGetGiveawayInfoRequest](d, tlprofile.SemanticMethodPaymentsGetGiveawayInfo, func(ctx context.Context, req *tg.PaymentsGetGiveawayInfoRequest) (any, error) { + return r.onPaymentsGetGiveawayInfo(ctx, req) + }) registerRPC[*tg.PaymentsGetStarsTopupOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTopupOptions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTopupOptionsRequest) (any, // premium 订阅赠送 telesrv 不实现(无支付流),返回空选项。关键作用:TDesktop 送礼框 @@ -33,6 +46,9 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) { registerRPC[*tg.PaymentsGetStarsStatusRequest](d, tlprofile.SemanticMethodPaymentsGetStarsStatus, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsStatusRequest) (any, error) { return r.onPaymentsGetStarsStatus(ctx, layerRequest) }) + registerRPC[*tg.PaymentsGetStarsSubscriptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsSubscriptions, func(ctx context.Context, req *tg.PaymentsGetStarsSubscriptionsRequest) (any, error) { + return r.onPaymentsGetStarsSubscriptions(ctx, req) + }) registerRPC[*tg.PaymentsGetStarsTransactionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTransactions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTransactionsRequest) (any, error) { return r.onPaymentsGetStarsTransactions(ctx, layerRequest) }) @@ -66,6 +82,9 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) { registerRPC[*tg.PaymentsGetPaymentFormRequest](d, tlprofile.SemanticMethodPaymentsGetPaymentForm, func(ctx context.Context, layerRequest *tg.PaymentsGetPaymentFormRequest) (any, error) { return r.onPaymentsGetPaymentForm(ctx, layerRequest) }) + registerRPC[*tg.PaymentsValidateRequestedInfoRequest](d, tlprofile.SemanticMethodPaymentsValidateRequestedInfo, func(ctx context.Context, req *tg.PaymentsValidateRequestedInfoRequest) (any, error) { + return r.onPaymentsValidateRequestedInfo(ctx, req) + }) registerRPC[*tg.PaymentsSendStarsFormRequest](d, tlprofile.SemanticMethodPaymentsSendStarsForm, func(ctx context.Context, layerRequest *tg.PaymentsSendStarsFormRequest) (any, error) { return r.onPaymentsSendStarsForm(ctx, layerRequest) }) @@ -157,6 +176,24 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) { } +func (r *Router) onPaymentsCanPurchaseStore(ctx context.Context, _ *tg.PaymentsCanPurchaseStoreRequest) (bool, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return false, internalErr() + } + // telesrv deliberately exposes no Google Play products or receipt verifier. + // DrKLO is steered to the invoice flow by appConfig; if a stale client still + // reaches this preflight, fail closed instead of authorizing an unverifiable + // external charge. + return false, nil +} + +func (r *Router) onPaymentsAssignPlayMarketTransaction(ctx context.Context, _ *tg.PaymentsAssignPlayMarketTransactionRequest) (tg.UpdatesClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return nil, tgerr.New(400, "STORE_PAYMENT_UNAVAILABLE") +} + // 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 @@ -269,6 +306,31 @@ func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsG return emptyStarsStatus(&tg.StarsAmount{Amount: bal.Balance}), nil } +// onPaymentsGetStarsSubscriptions returns the authoritative current balance +// with an empty subscription page. telesrv does not create recurring Stars +// subscriptions yet; returning a well-shaped terminal page lets both official +// clients finish loading the Stars screen without inventing subscription state. +func (r *Router) onPaymentsGetStarsSubscriptions(ctx context.Context, req *tg.PaymentsGetStarsSubscriptionsRequest) (*tg.PaymentsStarsStatus, error) { + if req == nil || len(req.Offset) > domain.MaxStarsTransactionsOffsetBytes { + return nil, inputRequestInvalidErr() + } + userID, owner, err := r.starGiftLedgerOwnerForPeer(ctx, req.Peer) + if err != nil { + return nil, err + } + if owner.Type != domain.PeerTypeUser || owner.ID != userID { + return nil, peerIDInvalidErr() + } + if r.deps.Stars == nil { + return emptyStarsStatus(&tg.StarsAmount{}), nil + } + balance, err := r.deps.Stars.GetBalance(ctx, userID) + if err != nil { + return nil, starsErr(err) + } + return emptyStarsStatus(&tg.StarsAmount{Amount: balance.Balance}), nil +} + // onPaymentsGetStarsTransactions 返回 keyset 分页的 Stars 流水(同 starsStatus 信封)。 // 末页必须省略 next_offset(flag 不置),否则 DrKLO 会无限翻页。 func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (*tg.PaymentsStarsStatus, error) { diff --git a/internal/rpc/payments_star_gifts.go b/internal/rpc/payments_star_gifts.go index cebd3bee..38fc489f 100644 --- a/internal/rpc/payments_star_gifts.go +++ b/internal/rpc/payments_star_gifts.go @@ -4,8 +4,11 @@ import ( "context" "crypto/rand" "encoding/base64" + "encoding/json" "errors" "fmt" + "net/url" + "strconv" "strings" "github.com/iamxvbaba/td/tg" @@ -40,9 +43,114 @@ func devStarsGiftOptions() []tg.StarsGiftOption { } } -type starsGiftPurchaseService interface { - IssueGiftPurchaseForm(context.Context, domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error) - PurchaseGift(context.Context, domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error) +func devStarsGiveawayOptions() []tg.StarsGiveawayOption { + return []tg.StarsGiveawayOption{ + {Default: true, Stars: 1000, YearlyBoosts: 4, Currency: "USD", Amount: 99, Winners: []tg.StarsGiveawayWinnersOption{ + {Default: true, Users: 1, PerUserStars: 1000}, {Users: 2, PerUserStars: 500}, + {Users: 5, PerUserStars: 200}, {Users: 10, PerUserStars: 100}, + }}, + {Stars: 2500, YearlyBoosts: 10, Currency: "USD", Amount: 199, Winners: []tg.StarsGiveawayWinnersOption{ + {Default: true, Users: 1, PerUserStars: 2500}, {Users: 5, PerUserStars: 500}, {Users: 10, PerUserStars: 250}, + }}, + {Stars: 5000, YearlyBoosts: 20, Currency: "USD", Amount: 399, Winners: []tg.StarsGiveawayWinnersOption{ + {Default: true, Users: 1, PerUserStars: 5000}, {Users: 5, PerUserStars: 1000}, {Users: 10, PerUserStars: 500}, + }}, + } +} + +func (r *Router) devStarsFiatPaymentForm( + buyerUserID int64, + form domain.StarsPurchaseForm, + title, description string, + users []domain.User, +) *tg.PaymentsPaymentForm { + return &tg.PaymentsPaymentForm{ + FormID: form.FormID, + BotID: domain.OfficialSystemUserID, + Title: title, Description: description, + Invoice: tg.Invoice{ + Test: true, Currency: form.Currency, + Prices: []tg.LabeledPrice{{Label: branding.StarsName, Amount: form.Amount}}, + }, + ProviderID: domain.OfficialSystemUserID, + URL: r.publicLinkQuery("payments/dev-stars", url.Values{ + "form_id": []string{strconv.FormatInt(form.FormID, 10)}, + }), + Users: tgUsersForViewer(buyerUserID, users), + } +} + +func validDevStarsPaymentCredentials(credentials tg.InputPaymentCredentialsClass, formID int64) bool { + value, ok := credentials.(*tg.InputPaymentCredentials) + if !ok || value == nil || value.Save || formID == 0 { + return false + } + var payload map[string]string + if err := json.Unmarshal([]byte(value.Data.Data), &payload); err != nil || len(payload) != 2 { + return false + } + return payload["type"] == "telesrv_dev" && payload["form_id"] == strconv.FormatInt(formID, 10) +} + +func (r *Router) onPaymentsGetStarsGiveawayOptions(ctx context.Context) ([]tg.StarsGiveawayOption, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return devStarsGiveawayOptions(), nil +} + +func (r *Router) onPaymentsGetGiveawayInfo(ctx context.Context, req *tg.PaymentsGetGiveawayInfoRequest) (tg.PaymentsGiveawayInfoClass, error) { + if req == nil || req.MsgID <= 0 { + return nil, messageIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel || peer.ID <= 0 { + return nil, peerIDInvalidErr() + } + service, ok := r.deps.Stars.(starsGiveawayInfoService) + if !ok { + return nil, notImplementedErr() + } + info, err := service.GetGiveawayInfo(ctx, userID, peer.ID, req.MsgID, int(r.clock.Now().Unix())) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) { + return nil, messageIDInvalidErr() + } + return nil, starsPurchaseErr(err) + } + out := &tg.PaymentsGiveawayInfo{StartDate: info.StartDate} + if info.Participating { + out.SetParticipating(true) + } + if info.PreparingResults { + out.SetPreparingResults(true) + } + if info.JoinedTooEarlyDate > 0 { + out.SetJoinedTooEarlyDate(info.JoinedTooEarlyDate) + } + if info.AdminDisallowedChatID > 0 { + out.SetAdminDisallowedChatID(info.AdminDisallowedChatID) + } + if info.DisallowedCountry != "" { + out.SetDisallowedCountry(info.DisallowedCountry) + } + return out, nil +} + +type starsPurchaseService interface { + IssuePurchaseForm(context.Context, domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) + Purchase(context.Context, domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) +} + +type starsGiveawayInfoService interface { + GetGiveawayInfo(context.Context, int64, int64, int, int) (domain.StarsGiveawayInfo, error) } func userGiftUnavailableErr() error { return tgerr.New(400, "USER_GIFT_UNAVAILABLE") } @@ -106,6 +214,125 @@ func starsGiftPurpose(inv *tg.InputInvoiceStars) (*tg.InputStorePaymentStarsGift return purpose, ok && purpose != nil } +func starsGiveawayPurpose(inv *tg.InputInvoiceStars) (*tg.InputStorePaymentStarsGiveaway, bool) { + if inv == nil { + return nil, false + } + purpose, ok := inv.Purpose.(*tg.InputStorePaymentStarsGiveaway) + return purpose, ok && purpose != nil +} + +func (r *Router) validateStarsGiveawayPurpose(ctx context.Context, userID int64, purpose *tg.InputStorePaymentStarsGiveaway) (tg.StarsGiveawayOption, *domain.StarsGiveawayPurchase, error) { + if purpose == nil || purpose.Stars <= 0 || purpose.Amount <= 0 || purpose.Currency == "" || + purpose.RandomID == 0 || purpose.Users <= 0 || purpose.UntilDate <= 0 || purpose.BoostPeer == nil { + return tg.StarsGiveawayOption{}, nil, purposeInvalidErr() + } + var matched tg.StarsGiveawayOption + var perUserStars int64 + found := false + for _, option := range devStarsGiveawayOptions() { + if option.Stars != purpose.Stars || option.Currency != purpose.Currency || option.Amount != purpose.Amount { + continue + } + for _, winners := range option.Winners { + if winners.Users == purpose.Users { + matched, perUserStars, found = option, winners.PerUserStars, true + break + } + } + if found { + break + } + } + if !found { + return tg.StarsGiveawayOption{}, nil, starsFormAmountMismatchErr() + } + now := int(r.clock.Now().Unix()) + if purpose.UntilDate <= now || purpose.UntilDate > now+7*24*60*60 || len(purpose.AdditionalPeers) > 10 || + len(purpose.CountriesISO2) > 10 || len([]rune(purpose.PrizeDescription)) > 128 { + return tg.StarsGiveawayOption{}, nil, purposeInvalidErr() + } + boostPeer, err := r.starsGiveawayAdminChannel(ctx, userID, purpose.BoostPeer) + if err != nil { + return tg.StarsGiveawayOption{}, nil, err + } + additional := make([]domain.Peer, 0, len(purpose.AdditionalPeers)) + seenChannels := map[int64]struct{}{boostPeer.ID: struct{}{}} + for _, input := range purpose.AdditionalPeers { + peer, err := r.starsGiveawayAdminChannel(ctx, userID, input) + if err != nil { + return tg.StarsGiveawayOption{}, nil, err + } + if _, exists := seenChannels[peer.ID]; exists { + return tg.StarsGiveawayOption{}, nil, purposeInvalidErr() + } + seenChannels[peer.ID] = struct{}{} + additional = append(additional, peer) + } + countries := append([]string(nil), purpose.CountriesISO2...) + seenCountries := make(map[string]struct{}, len(countries)) + for _, country := range countries { + if len(country) != 2 || country != strings.ToUpper(country) || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z' { + return tg.StarsGiveawayOption{}, nil, purposeInvalidErr() + } + if _, exists := seenCountries[country]; exists { + return tg.StarsGiveawayOption{}, nil, purposeInvalidErr() + } + seenCountries[country] = struct{}{} + } + return matched, &domain.StarsGiveawayPurchase{ + BoostPeer: boostPeer, AdditionalPeers: additional, CountriesISO2: countries, + PrizeDescription: purpose.PrizeDescription, RandomID: purpose.RandomID, UntilDate: purpose.UntilDate, + Users: purpose.Users, PerUserStars: perUserStars, YearlyBoosts: matched.YearlyBoosts, + OnlyNewSubscribers: purpose.OnlyNewSubscribers, WinnersAreVisible: purpose.WinnersAreVisible, + }, nil +} + +func (r *Router) starsGiveawayAdminChannel(ctx context.Context, userID int64, input tg.InputPeerClass) (domain.Peer, error) { + if r.deps.Channels == nil { + return domain.Peer{}, notImplementedErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) + if err != nil { + return domain.Peer{}, err + } + if peer.Type != domain.PeerTypeChannel || peer.ID <= 0 { + return domain.Peer{}, peerIDInvalidErr() + } + view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) + if err != nil { + return domain.Peer{}, channelInvalidErr(err) + } + if view.Self.Status != domain.ChannelMemberActive || !channelMemberIsAdmin(view.Self) || + (view.Channel.Broadcast && !view.Self.CanPostChannelMessages()) { + return domain.Peer{}, tgerr400("CHAT_ADMIN_REQUIRED") + } + return peer, nil +} + +func (r *Router) starsGiveawayPaymentForm(ctx context.Context, buyerUserID int64, purpose *tg.InputStorePaymentStarsGiveaway) (tg.PaymentsPaymentFormClass, error) { + _, giveaway, err := r.validateStarsGiveawayPurpose(ctx, buyerUserID, purpose) + if err != nil { + return nil, err + } + service, ok := r.deps.Stars.(starsPurchaseService) + if !ok { + return nil, notImplementedErr() + } + now := int(r.clock.Now().Unix()) + form, err := service.IssuePurchaseForm(ctx, domain.StarsPurchaseForm{ + Kind: domain.StarsPurchaseGiveaway, BuyerUserID: buyerUserID, Giveaway: giveaway, + Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount, + IssuedAt: now, ExpiresAt: now + 600, + }) + if err != nil { + return nil, starsPurchaseErr(err) + } + return r.devStarsFiatPaymentForm(buyerUserID, form, + "Stars giveaway", "Launch a Stars giveaway", + []domain.User{domain.OfficialSystemUser()}), nil +} + // onPaymentsGetStarGifts 返回可购买礼物目录(hash 命中返回 NotModified)。 func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.PaymentsStarGiftsClass, error) { if r.deps.Gifts == nil { @@ -133,10 +360,12 @@ func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.Payme // onPaymentsGetPaymentForm 处理 Stars 专用 invoice: // - inputInvoiceStarGift 返回 paymentFormStarGift。 -// - inputInvoiceStars(inputStorePaymentStarsTopup) 返回 paymentFormStars。 +// - inputInvoiceStars(top-up/gift/giveaway) 返回普通 test paymentForm,由本地 dev +// provider WebView 生成 form-bound credentials 后走 sendPaymentForm。 // // 崩溃约束:star gift invoice 必须返 paymentFormStarGift#b425cfe1(TDesktop 单分支 match), -// Stars 表单 Invoice.Prices 必须非空且 Currency=XTR(DrKLO/TDesktop 读 prices.front())。 +// Stars 法币购买表单 Invoice.Prices 必须非空且保持套餐 currency/amount;不能返回 +// paymentFormStars,否则 TDesktop 会把它解释为花现有 XTR,Android 会打开空 URL WebView。 func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsGetPaymentFormRequest) (tg.PaymentsPaymentFormClass, error) { if req == nil { return nil, inputRequestInvalidErr() @@ -150,6 +379,9 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG if purpose, gift := starsGiftPurpose(inv); gift { return r.starsGiftPaymentForm(ctx, userID, purpose) } + if purpose, giveaway := starsGiveawayPurpose(inv); giveaway { + return r.starsGiveawayPaymentForm(ctx, userID, purpose) + } purpose, ok := starsTopupPurpose(inv) if !ok { return nil, notImplementedErr() @@ -160,7 +392,7 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG if _, _, err := r.validateStarsTopupPurpose(ctx, userID, purpose); err != nil { return nil, err } - return r.starsTopupPaymentForm(userID, purpose), nil + return r.starsTopupPaymentForm(ctx, userID, purpose) } if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok { return r.starGiftUpgradePaymentForm(ctx, userID, inv) @@ -228,6 +460,57 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG }, nil } +// onPaymentsValidateRequestedInfo is the read-only pre-submit gate used by +// TDesktop's ordinary payment-form checkout. Direct Stars purchase invoices +// advertise no requested information or flexible shipping, so a valid result +// deliberately carries neither an ID nor shipping options. Package, recipient +// and giveaway permissions are revalidated without issuing a form or writing +// ledger/message/update state. Other invoice families remain unsupported here +// rather than being accepted as an empty generic bot invoice. +func (r *Router) onPaymentsValidateRequestedInfo(ctx context.Context, req *tg.PaymentsValidateRequestedInfoRequest) (*tg.PaymentsValidatedRequestedInfo, error) { + if req == nil || req.Invoice == nil { + return nil, inputRequestInvalidErr() + } + // The forms returned by devStarsFiatPaymentForm request no personal data. + // Reject even present-but-empty flags so a caller cannot make data appear + // validated when this module neither stores nor forwards it. Save is safe as + // a no-op only while the information object is exactly empty. + if !req.Info.Zero() { + return nil, tgerr.New(400, "REQUESTED_INFO_INVALID") + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + inv, ok := req.Invoice.(*tg.InputInvoiceStars) + if !ok || inv == nil { + return nil, notImplementedErr() + } + if purpose, ok := starsGiftPurpose(inv); ok { + if _, err := validateStarsGiftOption(purpose); err != nil { + return nil, err + } + if _, err := r.starsGiftRecipient(ctx, userID, purpose.UserID); err != nil { + return nil, err + } + return &tg.PaymentsValidatedRequestedInfo{}, nil + } + if purpose, ok := starsGiveawayPurpose(inv); ok { + if _, _, err := r.validateStarsGiveawayPurpose(ctx, userID, purpose); err != nil { + return nil, err + } + return &tg.PaymentsValidatedRequestedInfo{}, nil + } + purpose, ok := starsTopupPurpose(inv) + if !ok { + return nil, notImplementedErr() + } + if _, _, err := r.validateStarsTopupPurpose(ctx, userID, purpose); err != nil { + return nil, err + } + return &tg.PaymentsValidatedRequestedInfo{}, nil +} + func (r *Router) starsGiftPaymentForm(ctx context.Context, buyerUserID int64, purpose *tg.InputStorePaymentStarsGift) (tg.PaymentsPaymentFormClass, error) { if _, err := validateStarsGiftOption(purpose); err != nil { return nil, err @@ -236,31 +519,27 @@ func (r *Router) starsGiftPaymentForm(ctx context.Context, buyerUserID int64, pu if err != nil { return nil, err } - service, ok := r.deps.Stars.(starsGiftPurchaseService) + service, ok := r.deps.Stars.(starsPurchaseService) if !ok { return nil, notImplementedErr() } now := int(r.clock.Now().Unix()) - form, err := service.IssueGiftPurchaseForm(ctx, domain.StarsGiftPurchaseForm{ - BuyerUserID: buyerUserID, RecipientUserID: recipient.ID, + form, err := service.IssuePurchaseForm(ctx, domain.StarsPurchaseForm{ + Kind: domain.StarsPurchaseGift, BuyerUserID: buyerUserID, RecipientUserID: recipient.ID, Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount, IssuedAt: now, ExpiresAt: now + 600, }) if err != nil { - return nil, starsGiftPurchaseErr(err) + return nil, starsPurchaseErr(err) } users := []domain.User{domain.OfficialSystemUser(), recipient} - return &tg.PaymentsPaymentFormStars{ - FormID: form.FormID, BotID: domain.OfficialSystemUserID, - Title: "Gift Stars", Description: "Gift Stars to a friend", - Invoice: tg.Invoice{Currency: "XTR", Prices: []tg.LabeledPrice{{Label: branding.StarsName, Amount: purpose.Stars}}}, - Users: tgUsersForViewer(buyerUserID, users), - }, nil + return r.devStarsFiatPaymentForm(buyerUserID, form, + "Gift Stars", "Gift Stars to a friend", users), nil } -// onPaymentsSendStarsForm 处理 star gift 与 Stars topup: -// - star gift: Debit→投递/记账,失败补偿退款。 -// - topup: 校验测试包白名单→Credit 本地账本。 +// onPaymentsSendStarsForm 只处理真正以 XTR/TON 付款的 Star Gift invoice。 +// 直接购买 Stars 是法币 test paymentForm,必须携带 dev provider credentials 走 +// sendPaymentForm;禁止把它伪装为 sendStarsForm 后无凭证铸币。 // // 返回 paymentResult{updates}(含 updateStarsBalance;用户礼物还含私聊服务消息)。 // 崩溃约束:必须返回合法 paymentResult{非空 Updates}(DrKLO 强转)。 @@ -273,11 +552,8 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe return nil, internalErr() } - if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok { - if purpose, gift := starsGiftPurpose(inv); gift { - return r.sendStarsGiftPurchase(ctx, userID, req.FormID, purpose) - } - return r.sendStarsTopupForm(ctx, userID, req.FormID, inv) + if _, ok := req.Invoice.(*tg.InputInvoiceStars); ok { + return nil, tgerr.New(400, "PAYMENT_CREDENTIALS_INVALID") } if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok { return r.sendStarGiftUpgradeForm(ctx, userID, req.FormID, inv) @@ -378,10 +654,10 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe return &tg.PaymentsPaymentResult{Updates: updates}, nil } -// onPaymentsSendPaymentForm is the Android invoice-billing completion path. -// The local development provider does not charge an external card, but it -// accepts only a persisted Stars-gift form and runs the same atomic settlement -// as TDesktop's paymentFormStars/sendStarsForm compatibility path. +// onPaymentsSendPaymentForm is the common Android/TDesktop fiat checkout path. +// The local dev provider does not charge an external card; its WebView emits a +// form-bound marker, and the persisted form/purpose remains the authoritative +// package and idempotency boundary. func (r *Router) onPaymentsSendPaymentForm(ctx context.Context, req *tg.PaymentsSendPaymentFormRequest) (tg.PaymentsPaymentResultClass, error) { if req == nil { return nil, inputRequestInvalidErr() @@ -394,17 +670,25 @@ func (r *Router) onPaymentsSendPaymentForm(ctx context.Context, req *tg.Payments if !ok { return nil, notImplementedErr() } - purpose, ok := starsGiftPurpose(inv) - if !ok { - return nil, notImplementedErr() - } - if req.Credentials == nil { + if !validDevStarsPaymentCredentials(req.Credentials, req.FormID) { return nil, tgerr.New(400, "PAYMENT_CREDENTIALS_INVALID") } - if req.TipAmount != 0 { + if _, present := req.GetRequestedInfoID(); present || req.RequestedInfoID != "" { + return nil, tgerr.New(400, "REQUESTED_INFO_ID_INVALID") + } + if _, present := req.GetShippingOptionID(); present || req.ShippingOptionID != "" { + return nil, tgerr.New(400, "SHIPPING_OPTION_INVALID") + } + if _, present := req.GetTipAmount(); present || req.TipAmount != 0 { return nil, tgerr.New(400, "TIP_AMOUNT_INVALID") } - return r.sendStarsGiftPurchase(ctx, userID, req.FormID, purpose) + if purpose, ok := starsGiftPurpose(inv); ok { + return r.sendStarsGiftPurchase(ctx, userID, req.FormID, purpose) + } + if purpose, ok := starsGiveawayPurpose(inv); ok { + return r.sendStarsGiveawayPurchase(ctx, userID, req.FormID, purpose) + } + return r.sendStarsTopupForm(ctx, userID, req.FormID, inv) } func (r *Router) sendStarsGiftPurchase(ctx context.Context, buyerUserID, formID int64, purpose *tg.InputStorePaymentStarsGift) (tg.PaymentsPaymentResultClass, error) { @@ -418,30 +702,69 @@ func (r *Router) sendStarsGiftPurchase(ctx context.Context, buyerUserID, formID if err != nil { return nil, err } - service, ok := r.deps.Stars.(starsGiftPurchaseService) + service, ok := r.deps.Stars.(starsPurchaseService) if !ok { return nil, notImplementedErr() } - result, err := service.PurchaseGift(ctx, domain.StarsGiftPurchaseRequest{ - StarsGiftPurchaseForm: domain.StarsGiftPurchaseForm{ - FormID: formID, BuyerUserID: buyerUserID, RecipientUserID: recipient.ID, + result, err := service.Purchase(ctx, domain.StarsPurchaseRequest{ + StarsPurchaseForm: domain.StarsPurchaseForm{ + FormID: formID, Kind: domain.StarsPurchaseGift, BuyerUserID: buyerUserID, RecipientUserID: recipient.ID, Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount, }, Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx), }) if err != nil { - return nil, starsGiftPurchaseErr(err) + return nil, starsPurchaseErr(err) } updates := r.starGiftSendUpdates(ctx, buyerUserID, result.Send) return &tg.PaymentsPaymentResult{Updates: updates}, nil } -func starsGiftPurchaseErr(err error) error { +func (r *Router) sendStarsGiveawayPurchase(ctx context.Context, buyerUserID, formID int64, purpose *tg.InputStorePaymentStarsGiveaway) (tg.PaymentsPaymentResultClass, error) { + if formID == 0 { + return nil, formIDEmptyErr() + } + _, giveaway, err := r.validateStarsGiveawayPurpose(ctx, buyerUserID, purpose) + if err != nil { + return nil, err + } + service, ok := r.deps.Stars.(starsPurchaseService) + if !ok { + return nil, notImplementedErr() + } + result, err := service.Purchase(ctx, domain.StarsPurchaseRequest{ + StarsPurchaseForm: domain.StarsPurchaseForm{ + FormID: formID, Kind: domain.StarsPurchaseGiveaway, BuyerUserID: buyerUserID, Giveaway: giveaway, + Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount, + }, + Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), + OriginSessionID: sessionIDOrZero(ctx), + }) + if err != nil { + if errors.Is(err, domain.ErrChannelInvalid) || errors.Is(err, domain.ErrChannelPrivate) || + errors.Is(err, domain.ErrChannelWriteForbidden) || errors.Is(err, domain.ErrChannelAdminRequired) || + errors.Is(err, domain.ErrMessageRandomIDDuplicate) { + return nil, channelInvalidErr(err) + } + return nil, starsPurchaseErr(err) + } + updates := r.channelMessageUpdatesWithPeerCache(ctx, buyerUserID, result.ChannelSend, 0, newViewerPeerCache(r)) + if updates == nil { + updates = &tg.Updates{Date: int(r.clock.Now().Unix())} + } + if !result.Duplicate { + r.enqueueChannelMessageFanout(ctx, buyerUserID, result.ChannelSend, nil) + r.pushChannelDiscussionUpdate(ctx, buyerUserID, result.ChannelSend.Discussion) + } + return &tg.PaymentsPaymentResult{Updates: updates}, nil +} + +func starsPurchaseErr(err error) error { switch { - case errors.Is(err, domain.ErrStarsGiftFormExpired): + case errors.Is(err, domain.ErrStarsPurchaseFormExpired): return tgerr.New(400, "FORM_EXPIRED") - case errors.Is(err, domain.ErrStarsGiftFormInvalid): + case errors.Is(err, domain.ErrStarsPurchaseFormInvalid): return starsFormAmountMismatchErr() case errors.Is(err, domain.ErrStarsGiftUnavailable): return userGiftUnavailableErr() @@ -512,18 +835,28 @@ func (r *Router) validateStarsTopupPurpose(ctx context.Context, userID int64, pu 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: branding.StarsName, - Description: "telesrv dev Stars top-up", - Invoice: tg.Invoice{ - Currency: "XTR", - Prices: []tg.LabeledPrice{{Label: branding.StarsName, Amount: purpose.Stars}}, - }, - Users: tgUsersForViewer(userID, []domain.User{domain.OfficialSystemUser()}), +func (r *Router) starsTopupPaymentForm(ctx context.Context, userID int64, purpose *tg.InputStorePaymentStarsTopup) (tg.PaymentsPaymentFormClass, error) { + service, ok := r.deps.Stars.(starsPurchaseService) + if !ok { + return nil, notImplementedErr() } + _, peer, err := r.validateStarsTopupPurpose(ctx, userID, purpose) + if err != nil { + return nil, err + } + now := int(r.clock.Now().Unix()) + form, err := service.IssuePurchaseForm(ctx, domain.StarsPurchaseForm{ + Kind: domain.StarsPurchaseTopup, BuyerUserID: userID, + SpendPurposePeer: peer, + Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount, + IssuedAt: now, ExpiresAt: now + 600, + }) + if err != nil { + return nil, starsPurchaseErr(err) + } + return r.devStarsFiatPaymentForm(userID, form, + branding.StarsName, "telesrv dev Stars top-up", + []domain.User{domain.OfficialSystemUser()}), nil } func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStars) (tg.PaymentsPaymentResultClass, error) { @@ -534,24 +867,27 @@ func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, i if formID == 0 { return nil, formIDEmptyErr() } - if r.deps.Stars == nil { + service, ok := r.deps.Stars.(starsPurchaseService) + if !ok { 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") + result, err := service.Purchase(ctx, domain.StarsPurchaseRequest{ + StarsPurchaseForm: domain.StarsPurchaseForm{ + FormID: formID, Kind: domain.StarsPurchaseTopup, BuyerUserID: userID, + SpendPurposePeer: peer, + Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount, + }, + Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), + OriginSessionID: sessionIDOrZero(ctx), + }) if err != nil { - return nil, starsErr(err) + return nil, starsPurchaseErr(err) } - return &tg.PaymentsPaymentResult{Updates: starsBalanceUpdates(balance.Balance, r.clock.Now().Unix())}, nil + return &tg.PaymentsPaymentResult{Updates: starsBalanceUpdates(result.Balance.Balance, r.clock.Now().Unix())}, nil } func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SavedStarGiftRef, *tg.Updates, error) { @@ -1419,20 +1755,6 @@ func savedStarGiftUserIDs(gifts []domain.SavedStarGift) []int64 { return ids } -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}}}, diff --git a/internal/rpc/payments_star_gifts_rpc_test.go b/internal/rpc/payments_star_gifts_rpc_test.go index 0c4d7692..c9202d01 100644 --- a/internal/rpc/payments_star_gifts_rpc_test.go +++ b/internal/rpc/payments_star_gifts_rpc_test.go @@ -3,7 +3,9 @@ package rpc import ( "context" "fmt" + "reflect" "testing" + "time" "github.com/iamxvbaba/td/bin" "github.com/iamxvbaba/td/clock" @@ -22,6 +24,83 @@ import ( "telesrv/internal/store/memory" ) +type starsTopupRPCStore struct { + *memory.StarsStore + nextFormID int64 + forms map[int64]domain.StarsPurchaseForm + settled map[int64]domain.StarsPurchaseResult + channel domain.Channel +} + +func devStarsCredentials(formID int64) *tg.InputPaymentCredentials { + return &tg.InputPaymentCredentials{Data: tg.DataJSON{Data: fmt.Sprintf(`{"type":"telesrv_dev","form_id":"%d"}`, formID)}} +} + +func newStarsTopupRPCStore() *starsTopupRPCStore { + return &starsTopupRPCStore{ + StarsStore: memory.NewStarsStore(), nextFormID: 91000, + forms: make(map[int64]domain.StarsPurchaseForm), settled: make(map[int64]domain.StarsPurchaseResult), + } +} + +func (s *starsTopupRPCStore) IssueStarsPurchaseForm(_ context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) { + s.nextFormID++ + form.FormID = s.nextFormID + s.forms[form.FormID] = form + return form, nil +} + +func (s *starsTopupRPCStore) PurchaseStars(ctx context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) { + form, ok := s.forms[req.FormID] + if !ok || form.Kind != req.Kind || form.BuyerUserID != req.BuyerUserID || + form.RecipientUserID != req.RecipientUserID || form.SpendPurposePeer != req.SpendPurposePeer || + form.Stars != req.Stars || form.Currency != req.Currency || form.Amount != req.Amount || + !reflect.DeepEqual(form.Giveaway, req.Giveaway) { + return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid + } + if result, ok := s.settled[req.FormID]; ok { + result.Duplicate = true + return result, nil + } + if req.Kind == domain.StarsPurchaseGiveaway { + g := req.Giveaway + result := domain.StarsPurchaseResult{TransactionID: fmt.Sprintf("stars-giveaway-test:%d", req.FormID)} + channel := s.channel + if channel.ID == 0 { + channel = domain.Channel{ID: g.BoostPeer.ID, Megagroup: true} + } + result.ChannelSend = domain.SendChannelMessageResult{ + Channel: channel, + Message: domain.ChannelMessage{ChannelID: g.BoostPeer.ID, ID: 71, RandomID: g.RandomID, SenderUserID: req.BuyerUserID, + Date: req.Date, Pts: 9, Media: &domain.MessageMedia{Kind: domain.MessageMediaKindGiveaway, Giveaway: &domain.MessageGiveaway{ + Channels: []int64{g.BoostPeer.ID}, Quantity: g.Users, Stars: req.Stars, UntilDate: g.UntilDate, + }}}, + Event: domain.ChannelUpdateEvent{ChannelID: g.BoostPeer.ID, Type: domain.ChannelUpdateNewMessage, Pts: 9, PtsCount: 1, Date: req.Date}, + } + result.ChannelSend.Event.Message = result.ChannelSend.Message + s.settled[req.FormID] = result + return result, nil + } + if req.Kind != domain.StarsPurchaseTopup { + return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid + } + balance, err := s.StarsStore.Credit(ctx, req.BuyerUserID, req.Stars, domain.StarsReasonTopup, + req.SpendPurposePeer, req.Date, "Stars top-up", "test purchase") + if err != nil { + return domain.StarsPurchaseResult{}, err + } + result := domain.StarsPurchaseResult{Balance: balance, TransactionID: fmt.Sprintf("stars-topup-test:%d", req.FormID)} + s.settled[req.FormID] = result + return result, nil +} + +func (s *starsTopupRPCStore) GetStarsGiveawayInfo(_ context.Context, viewerUserID, channelID int64, messageID, _ int) (domain.StarsGiveawayInfo, error) { + if viewerUserID <= 0 || channelID != s.channel.ID || messageID != 71 { + return domain.StarsGiveawayInfo{}, domain.ErrMessageIDInvalid + } + return domain.StarsGiveawayInfo{StartDate: 1_700_000_200, Participating: true}, nil +} + func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain.StarGift) { return starGiftTestRouterWithPremium(t, false) } @@ -48,11 +127,12 @@ func starGiftTestRouterWithPremium(t *testing.T, requirePremium bool) (*Router, giftStore := memory.NewStarGiftStore() giftStore.SeedCatalog([]domain.StarGift{gift}) gifts := appstargifts.NewService(giftStore, nil, 2) - r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + starsStore := newStarsTopupRPCStore() + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398, PublicBaseURL: "https://links.example.test"}, Deps{ Users: appusers.NewService(users), Messages: appmessages.NewService(msgStore, dialogs), Channels: appchannels.NewService(channelStore), - Stars: appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(1000)), + Stars: appstars.NewService(starsStore, appstars.WithStartingGrant(1000), appstars.WithPurchaseStore(starsStore)), Gifts: gifts, }, zaptest.NewLogger(t), clock.System) return r, sender, recipient, gift @@ -85,6 +165,115 @@ func TestStarGiftPurchaseRequiresActivePremium(t *testing.T) { } } +func TestStarsGiveawayCatalogFormSettlementReplayAndInfo(t *testing.T) { + ctx := context.Background() + now := 1_700_000_100 + users := memory.NewUserStore() + owner, err := users.Create(ctx, domain.User{AccessHash: 7201, Phone: "15550007201", FirstName: "GiveawayOwner"}) + if err != nil { + t.Fatal(err) + } + member, err := users.Create(ctx, domain.User{AccessHash: 7202, Phone: "15550007202", FirstName: "GiveawayMember"}) + if err != nil { + t.Fatal(err) + } + channelStore := memory.NewChannelStore() + created, err := channelStore.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, Title: "Giveaway Group", Megagroup: true, + MemberUserIDs: []int64{member.ID}, Date: now - 10, + }) + if err != nil { + t.Fatal(err) + } + starsStore := newStarsTopupRPCStore() + starsStore.channel = created.Channel + r := New(Config{DC: 2, PublicBaseURL: "https://links.example.test"}, Deps{ + Users: appusers.NewService(users), Channels: appchannels.NewService(channelStore), + Stars: appstars.NewService(starsStore, appstars.WithStartingGrant(0), appstars.WithPurchaseStore(starsStore)), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(int64(now), 0)}) + ownerCtx := WithUserID(ctx, owner.ID) + options, err := r.onPaymentsGetStarsGiveawayOptions(ownerCtx) + if err != nil || len(options) != 3 || len(options[0].Winners) < 2 || options[0].StoreProduct != "" { + t.Fatalf("giveaway options=%+v err=%v", options, err) + } + peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + purpose := &tg.InputStorePaymentStarsGiveaway{ + WinnersAreVisible: true, Stars: options[0].Stars, BoostPeer: peer, + RandomID: 7201001, UntilDate: now + 3600, Currency: options[0].Currency, + Amount: options[0].Amount, Users: options[0].Winners[1].Users, + } + invoice := &tg.InputInvoiceStars{Purpose: purpose} + validated, err := r.onPaymentsValidateRequestedInfo(ownerCtx, &tg.PaymentsValidateRequestedInfoRequest{Invoice: invoice}) + if err != nil || validated == nil || !validated.Zero() { + t.Fatalf("validate giveaway requested info=%+v err=%v", validated, err) + } + if len(starsStore.forms) != 0 || len(starsStore.settled) != 0 { + t.Fatalf("giveaway validation mutated store: forms=%d settled=%d", len(starsStore.forms), len(starsStore.settled)) + } + formClass, err := r.onPaymentsGetPaymentForm(ownerCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice}) + if err != nil { + t.Fatalf("get giveaway payment form: %v", err) + } + form, ok := formClass.(*tg.PaymentsPaymentForm) + if !ok || form.FormID == 0 || !form.Invoice.Test || form.Invoice.Currency != purpose.Currency || + len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != purpose.Amount { + t.Fatalf("giveaway payment form=%T %+v", formClass, formClass) + } + if _, err := r.onPaymentsSendStarsForm(ownerCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: invoice}); !tgerr.Is(err, "PAYMENT_CREDENTIALS_INVALID") { + t.Fatalf("sendStarsForm fiat giveaway err=%v", err) + } + resultClass, err := r.onPaymentsSendPaymentForm(ownerCtx, &tg.PaymentsSendPaymentFormRequest{ + FormID: form.FormID, Invoice: invoice, Credentials: devStarsCredentials(form.FormID), + }) + if err != nil { + t.Fatalf("send giveaway form: %v", err) + } + result, ok := resultClass.(*tg.PaymentsPaymentResult) + if !ok { + t.Fatalf("giveaway result=%T", resultClass) + } + updates, ok := result.Updates.(*tg.Updates) + if !ok || len(updates.Updates) == 0 { + t.Fatalf("giveaway updates=%T %+v", result.Updates, result.Updates) + } + launchID := 0 + for _, update := range updates.Updates { + newChannel, ok := update.(*tg.UpdateNewChannelMessage) + if !ok || newChannel.PtsCount != 1 { + continue + } + message, ok := newChannel.Message.(*tg.Message) + if !ok { + t.Fatalf("giveaway launch message=%T", newChannel.Message) + } + media, ok := message.Media.(*tg.MessageMediaGiveaway) + if !ok || media.Stars != purpose.Stars || media.Quantity != purpose.Users || media.UntilDate != purpose.UntilDate { + t.Fatalf("giveaway launch media=%T %+v", message.Media, message.Media) + } + launchID = message.ID + } + if launchID == 0 { + t.Fatalf("giveaway updates missing updateNewChannelMessage: %+v", updates.Updates) + } + if _, err := r.onPaymentsSendPaymentForm(ownerCtx, &tg.PaymentsSendPaymentFormRequest{ + FormID: form.FormID, Invoice: invoice, Credentials: devStarsCredentials(form.FormID), + }); err != nil { + t.Fatalf("Android sendPaymentForm replay: %v", err) + } + infoClass, err := r.onPaymentsGetGiveawayInfo(ownerCtx, &tg.PaymentsGetGiveawayInfoRequest{Peer: peer, MsgID: launchID}) + info, ok := infoClass.(*tg.PaymentsGiveawayInfo) + if err != nil || !ok || !info.Participating || info.StartDate == 0 { + t.Fatalf("get giveaway info=%T %+v err=%v", infoClass, infoClass, err) + } + memberPurpose := *purpose + memberPurpose.RandomID++ + if _, err := r.onPaymentsGetPaymentForm(WithUserID(ctx, member.ID), &tg.PaymentsGetPaymentFormRequest{ + Invoice: &tg.InputInvoiceStars{Purpose: &memberPurpose}, + }); !tgerr.Is(err, "CHAT_ADMIN_REQUIRED") { + t.Fatalf("member giveaway form err=%v, want CHAT_ADMIN_REQUIRED", err) + } +} + type uniqueGiftRPCService struct { GiftsService unique domain.UniqueStarGift @@ -1456,7 +1645,7 @@ func TestStarGiftInsufficientBalance(t *testing.T) { Sticker: domain.Document{ID: 701, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}} giftStore := memory.NewStarGiftStore() giftStore.SeedCatalog([]domain.StarGift{gift}) - r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398, PublicBaseURL: "https://links.example.test"}, Deps{ Users: appusers.NewService(users), Messages: appmessages.NewService(msgStore, dialogs), Stars: appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(1000)), // < 5000 @@ -1550,30 +1739,63 @@ func TestStarsTopupInvoiceFallbackCreditsBalance(t *testing.T) { if err != nil { t.Fatalf("getPaymentForm topup: %v", err) } - form, ok := formRes.(*tg.PaymentsPaymentFormStars) + form, ok := formRes.(*tg.PaymentsPaymentForm) if !ok { - t.Fatalf("form = %T, want *tg.PaymentsPaymentFormStars", formRes) + t.Fatalf("form = %T, want *tg.PaymentsPaymentForm", 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.FormID == 0 { + t.Fatal("form id = 0, want persisted random checkout id") } 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 !form.Invoice.Test || form.Invoice.Currency != opt.Currency || len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != opt.Amount { + t.Fatalf("form invoice = %+v, want test %s + 1 price %d", form.Invoice, opt.Currency, opt.Amount) } - 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 _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); !tgerr.Is(err, "PAYMENT_CREDENTIALS_INVALID") { + t.Fatalf("sendStarsForm fiat topup err = %v, want PAYMENT_CREDENTIALS_INVALID", err) + } + if _, err := r.onPaymentsSendPaymentForm(senderCtx, &tg.PaymentsSendPaymentFormRequest{ + FormID: form.FormID + 1, Invoice: inv, Credentials: devStarsCredentials(form.FormID + 1), + }); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") { + t.Fatalf("sendPaymentForm 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) } + for name, request := range map[string]*tg.PaymentsSendPaymentFormRequest{ + "missing": {FormID: form.FormID, Invoice: inv}, + "wrong form marker": {FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID + 1)}, + "saved credentials": {FormID: form.FormID, Invoice: inv, Credentials: &tg.InputPaymentCredentials{ + Save: true, Data: devStarsCredentials(form.FormID).Data, + }}, + } { + if _, err := r.onPaymentsSendPaymentForm(senderCtx, request); !tgerr.Is(err, "PAYMENT_CREDENTIALS_INVALID") { + t.Fatalf("%s credentials err = %v, want PAYMENT_CREDENTIALS_INVALID", name, err) + } + } + withInfo := &tg.PaymentsSendPaymentFormRequest{FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID)} + withInfo.SetRequestedInfoID("unexpected") + if _, err := r.onPaymentsSendPaymentForm(senderCtx, withInfo); !tgerr.Is(err, "REQUESTED_INFO_ID_INVALID") { + t.Fatalf("requested info err = %v", err) + } + withShipping := &tg.PaymentsSendPaymentFormRequest{FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID)} + withShipping.SetShippingOptionID("unexpected") + if _, err := r.onPaymentsSendPaymentForm(senderCtx, withShipping); !tgerr.Is(err, "SHIPPING_OPTION_INVALID") { + t.Fatalf("shipping option err = %v", err) + } + withTip := &tg.PaymentsSendPaymentFormRequest{FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID)} + withTip.SetTipAmount(1) + if _, err := r.onPaymentsSendPaymentForm(senderCtx, withTip); !tgerr.Is(err, "TIP_AMOUNT_INVALID") { + t.Fatalf("tip err = %v", err) + } - payRes, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}) + payRes, err := r.onPaymentsSendPaymentForm(senderCtx, &tg.PaymentsSendPaymentFormRequest{ + FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID), + }) if err != nil { - t.Fatalf("sendStarsForm topup: %v", err) + t.Fatalf("sendPaymentForm topup: %v", err) } pay, ok := payRes.(*tg.PaymentsPaymentResult) if !ok { @@ -1598,6 +1820,14 @@ func TestStarsTopupInvoiceFallbackCreditsBalance(t *testing.T) { if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 3500 { t.Fatalf("balance after topup = %d, want 3500", bal.Balance) } + if _, err := r.onPaymentsSendPaymentForm(senderCtx, &tg.PaymentsSendPaymentFormRequest{ + FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID), + }); err != nil { + t.Fatalf("sendPaymentForm exact replay: %v", err) + } + if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 3500 { + t.Fatalf("balance after exact replay = %d, want 3500", bal.Balance) + } page, err := r.deps.Stars.ListTransactions(ctx, sender.ID, domain.StarsTransactionQuery{Limit: 10}) if err != nil { t.Fatalf("list transactions: %v", err) diff --git a/internal/rpc/payments_stars_friend_gift_test.go b/internal/rpc/payments_stars_friend_gift_test.go index bd6c44f1..49d0b8de 100644 --- a/internal/rpc/payments_stars_friend_gift_test.go +++ b/internal/rpc/payments_stars_friend_gift_test.go @@ -17,18 +17,18 @@ import ( type starsFriendGiftRPCStore struct { *memory.StarsStore - issued domain.StarsGiftPurchaseForm - purchased domain.StarsGiftPurchaseRequest + issued domain.StarsPurchaseForm + purchased domain.StarsPurchaseRequest purchases int } -func (s *starsFriendGiftRPCStore) IssueStarsGiftPurchaseForm(_ context.Context, form domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error) { +func (s *starsFriendGiftRPCStore) IssueStarsPurchaseForm(_ context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) { form.FormID = 70001 s.issued = form return form, nil } -func (s *starsFriendGiftRPCStore) PurchaseStarsGift(_ context.Context, req domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error) { +func (s *starsFriendGiftRPCStore) PurchaseStars(_ context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) { s.purchased = req s.purchases++ action := &domain.MessageServiceAction{Kind: domain.MessageServiceActionGiftStars, GiftStars: &domain.MessageGiftStarsAction{ @@ -41,9 +41,9 @@ func (s *starsFriendGiftRPCStore) PurchaseStarsGift(_ context.Context, req domai recipient := sender recipient.ID, recipient.OwnerUserID, recipient.Peer, recipient.Out = 12, req.RecipientUserID, domain.Peer{Type: domain.PeerTypeUser, ID: req.BuyerUserID}, false - return domain.StarsGiftPurchaseResult{ - RecipientBalance: domain.StarsBalance{UserID: req.RecipientUserID, Balance: 4321}, - TransactionID: "stars-gift-test", + return domain.StarsPurchaseResult{ + Balance: domain.StarsBalance{UserID: req.RecipientUserID, Balance: 4321}, + TransactionID: "stars-gift-test", Send: domain.SendPrivateTextResult{ SenderMessage: sender, RecipientMessage: recipient, SenderEvent: domain.UpdateEvent{UserID: req.BuyerUserID, Type: domain.UpdateEventNewMessage, Pts: 5, PtsCount: 1, Date: req.Date, Message: sender}, @@ -65,14 +65,14 @@ func starsFriendGiftTestRouter(t *testing.T) (*Router, *starsFriendGiftRPCStore, t.Fatal(err) } st := &starsFriendGiftRPCStore{StarsStore: memory.NewStarsStore()} - r := New(Config{DC: 2}, Deps{ + r := New(Config{DC: 2, PublicBaseURL: "https://links.example.test"}, Deps{ Users: appusers.NewService(users), - Stars: appstars.NewService(st, appstars.WithStartingGrant(0), appstars.WithGiftPurchaseStore(st)), + Stars: appstars.NewService(st, appstars.WithStartingGrant(0), appstars.WithPurchaseStore(st)), }, zaptest.NewLogger(t), clock.System) return r, st, buyer, recipient } -func TestStarsFriendGiftOptionsFormAndBothSettlementMethods(t *testing.T) { +func TestStarsFriendGiftOptionsFormAndFiatSettlement(t *testing.T) { r, st, buyer, recipient := starsFriendGiftTestRouter(t) ctx := WithUserID(context.Background(), buyer.ID) @@ -94,17 +94,24 @@ func TestStarsFriendGiftOptionsFormAndBothSettlementMethods(t *testing.T) { if err != nil { t.Fatalf("get gift payment form: %v", err) } - form, ok := formClass.(*tg.PaymentsPaymentFormStars) - if !ok || form.FormID != 70001 || form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != 2500 { + form, ok := formClass.(*tg.PaymentsPaymentForm) + if !ok || form.FormID != 70001 || !form.Invoice.Test || form.Invoice.Currency != "USD" || + len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != 199 || + form.ProviderID != domain.OfficialSystemUserID || form.URL != "https://links.example.test/payments/dev-stars?form_id=70001" { t.Fatalf("gift payment form = %T %+v", formClass, formClass) } - if st.issued.BuyerUserID != buyer.ID || st.issued.RecipientUserID != recipient.ID || st.issued.Stars != 2500 || st.issued.ExpiresAt != st.issued.IssuedAt+600 { + if st.issued.Kind != domain.StarsPurchaseGift || st.issued.BuyerUserID != buyer.ID || st.issued.RecipientUserID != recipient.ID || st.issued.Stars != 2500 || st.issued.ExpiresAt != st.issued.IssuedAt+600 { t.Fatalf("issued form = %+v", st.issued) } - resultClass, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: invoice}) + if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: invoice}); !tgerr.Is(err, "PAYMENT_CREDENTIALS_INVALID") { + t.Fatalf("sendStarsForm fiat gift err = %v, want PAYMENT_CREDENTIALS_INVALID", err) + } + resultClass, err := r.onPaymentsSendPaymentForm(ctx, &tg.PaymentsSendPaymentFormRequest{ + FormID: form.FormID, Invoice: invoice, Credentials: devStarsCredentials(form.FormID), + }) if err != nil { - t.Fatalf("sendStarsForm gift: %v", err) + t.Fatalf("sendPaymentForm gift: %v", err) } result, ok := resultClass.(*tg.PaymentsPaymentResult) if !ok { @@ -130,14 +137,54 @@ func TestStarsFriendGiftOptionsFormAndBothSettlementMethods(t *testing.T) { t.Fatalf("purchase request = %+v count=%d", st.purchased, st.purchases) } - credentials := &tg.InputPaymentCredentials{Data: tg.DataJSON{Data: "{}"}} - if _, err := r.onPaymentsSendPaymentForm(ctx, &tg.PaymentsSendPaymentFormRequest{ - FormID: form.FormID, Invoice: invoice, Credentials: credentials, - }); err != nil { - t.Fatalf("sendPaymentForm gift: %v", err) + if st.purchases != 1 { + t.Fatalf("settlement count = %d, want one fiat submit", st.purchases) } - if st.purchases != 2 { - t.Fatalf("settlement method count = %d, want 2 fake invocations", st.purchases) +} + +func TestStarsDirectPurchaseValidateRequestedInfoIsReadOnly(t *testing.T) { + r, st, buyer, recipient := starsFriendGiftTestRouter(t) + ctx := WithUserID(context.Background(), buyer.ID) + + topup := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{ + Stars: 1000, Currency: "USD", Amount: 99, + }} + gift := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsGift{ + UserID: &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, + Stars: 2500, Currency: "USD", Amount: 199, + }} + for name, invoice := range map[string]tg.InputInvoiceClass{"topup": topup, "gift": gift} { + result, err := r.onPaymentsValidateRequestedInfo(ctx, &tg.PaymentsValidateRequestedInfoRequest{ + Save: true, Invoice: invoice, + }) + if err != nil { + t.Fatalf("%s validateRequestedInfo: %v", name, err) + } + if result == nil || !result.Zero() { + t.Fatalf("%s validated info = %+v, want flags=0", name, result) + } + } + if st.issued.FormID != 0 || st.purchases != 0 { + t.Fatalf("validation mutated purchase store: issued=%+v purchases=%d", st.issued, st.purchases) + } + + withInfo := &tg.PaymentsValidateRequestedInfoRequest{Invoice: topup} + withInfo.Info.SetName("unexpected") + if _, err := r.onPaymentsValidateRequestedInfo(ctx, withInfo); !tgerr.Is(err, "REQUESTED_INFO_INVALID") { + t.Fatalf("non-empty info err=%v, want REQUESTED_INFO_INVALID", err) + } + if _, err := r.onPaymentsValidateRequestedInfo(ctx, &tg.PaymentsValidateRequestedInfoRequest{ + Invoice: &tg.InputInvoiceSlug{Slug: "unsupported"}, + }); !tgerr.Is(err, "NOT_IMPLEMENTED") { + t.Fatalf("non-Stars invoice err=%v, want NOT_IMPLEMENTED", err) + } + if _, err := r.onPaymentsValidateRequestedInfo(ctx, &tg.PaymentsValidateRequestedInfoRequest{ + Invoice: &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{Stars: 1000, Currency: "USD", Amount: 100}}, + }); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") { + t.Fatalf("tampered package err=%v, want STARS_FORM_AMOUNT_MISMATCH", err) + } + if st.issued.FormID != 0 || st.purchases != 0 { + t.Fatalf("invalid validation mutated purchase store: issued=%+v purchases=%d", st.issued, st.purchases) } } @@ -157,7 +204,9 @@ func TestStarsFriendGiftRejectsInvalidRecipientAndPackageBeforeStore(t *testing. if _, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: bad}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") { t.Fatalf("tampered package form err = %v", err) } - if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: 70001, Invoice: bad}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") { + if _, err := r.onPaymentsSendPaymentForm(ctx, &tg.PaymentsSendPaymentFormRequest{ + FormID: 70001, Invoice: bad, Credentials: devStarsCredentials(70001), + }); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") { t.Fatalf("tampered package settle err = %v", err) } if st.issued.FormID != 0 || st.purchases != 0 { @@ -165,6 +214,27 @@ func TestStarsFriendGiftRejectsInvalidRecipientAndPackageBeforeStore(t *testing. } } +func TestAndroidStorePurchaseFailsClosedInFavorOfInvoiceCheckout(t *testing.T) { + r, _, buyer, recipient := starsFriendGiftTestRouter(t) + ctx := WithUserID(context.Background(), buyer.ID) + purpose := &tg.InputStorePaymentStarsGift{ + UserID: &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, + Stars: 1000, Currency: "USD", Amount: 99, + } + allowed, err := r.onPaymentsCanPurchaseStore(ctx, &tg.PaymentsCanPurchaseStoreRequest{Purpose: purpose}) + if err != nil { + t.Fatalf("canPurchaseStore: %v", err) + } + if allowed { + t.Fatal("canPurchaseStore = true, want false") + } + if _, err := r.onPaymentsAssignPlayMarketTransaction(ctx, &tg.PaymentsAssignPlayMarketTransactionRequest{ + Receipt: tg.DataJSON{Data: `{"orderId":"unverified"}`}, Purpose: purpose, + }); !tgerr.Is(err, "STORE_PAYMENT_UNAVAILABLE") { + t.Fatalf("assignPlayMarketTransaction err = %v, want STORE_PAYMENT_UNAVAILABLE", err) + } +} + func TestGiftStarsRecipientProjectionCarriesBalanceOnlineAndDifference(t *testing.T) { action := &domain.MessageServiceAction{Kind: domain.MessageServiceActionGiftStars, GiftStars: &domain.MessageGiftStarsAction{ Currency: "USD", Amount: 99, Stars: 1000, TransactionID: "txn-1", BalanceAfter: 3100, diff --git a/internal/rpc/payments_stars_rpc_test.go b/internal/rpc/payments_stars_rpc_test.go index 62e1c01a..7d05e4f2 100644 --- a/internal/rpc/payments_stars_rpc_test.go +++ b/internal/rpc/payments_stars_rpc_test.go @@ -42,6 +42,27 @@ func TestOnPaymentsGetStarsStatusGranted(t *testing.T) { } } +func TestOnPaymentsGetStarsSubscriptionsReturnsTerminalEmptyPage(t *testing.T) { + r := starsRouter(t, 1000) + ctx := WithUserID(context.Background(), 1000000001) + status, err := r.onPaymentsGetStarsSubscriptions(ctx, &tg.PaymentsGetStarsSubscriptionsRequest{ + Peer: &tg.InputPeerSelf{}, Offset: "", + }) + if err != nil { + t.Fatalf("getStarsSubscriptions: %v", err) + } + amount, ok := status.Balance.(*tg.StarsAmount) + if !ok || amount.Amount != 1000 { + t.Fatalf("balance = %#v, want StarsAmount 1000", status.Balance) + } + if subscriptions, ok := status.GetSubscriptions(); ok || len(subscriptions) != 0 { + t.Fatalf("subscriptions = %+v ok=%v, want absent terminal page", subscriptions, ok) + } + if _, ok := status.GetSubscriptionsNextOffset(); ok { + t.Fatal("empty subscription page unexpectedly has next offset") + } +} + // TON 余额未建模:返回 starsTonAmount 的合法响应(不崩客户端)。 func TestOnPaymentsGetStarsStatusTon(t *testing.T) { r := starsRouter(t, 1000) diff --git a/internal/rpc/router_dispatch_test.go b/internal/rpc/router_dispatch_test.go index cc95d577..eab3ba64 100644 --- a/internal/rpc/router_dispatch_test.go +++ b/internal/rpc/router_dispatch_test.go @@ -1283,8 +1283,12 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) { {name: "account.resetPassword", req: &tg.AccountResetPasswordRequest{}}, {name: "account.updateStatus", req: &tg.AccountUpdateStatusRequest{Offline: true}}, {name: "account.updateDeviceLocked", req: &tg.AccountUpdateDeviceLockedRequest{Period: 60}}, + {name: "payments.canPurchaseStore", req: &tg.PaymentsCanPurchaseStoreRequest{Purpose: &tg.InputStorePaymentStarsTopup{Stars: 1000, Currency: "USD", Amount: 99}}}, {name: "payments.getStarsTopupOptions", req: &tg.PaymentsGetStarsTopupOptionsRequest{}}, + {name: "payments.getStarsGiftOptions", req: &tg.PaymentsGetStarsGiftOptionsRequest{}}, + {name: "payments.getStarsGiveawayOptions", req: &tg.PaymentsGetStarsGiveawayOptionsRequest{}}, {name: "payments.getStarsStatus", req: &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}}}, + {name: "payments.getStarsSubscriptions", req: &tg.PaymentsGetStarsSubscriptionsRequest{Peer: &tg.InputPeerSelf{}}}, {name: "updates.getDifference", req: &tg.UpdatesGetDifferenceRequest{}}, {name: "users.getFullUser", req: &tg.UsersGetFullUserRequest{ID: &tg.InputUserSelf{}}}, {name: "users.getRequirementsToContact", req: &tg.UsersGetRequirementsToContactRequest{ID: []tg.InputUserClass{&tg.InputUserSelf{}}}}, diff --git a/internal/rpc/update_peer_refs.go b/internal/rpc/update_peer_refs.go index b5a18a7a..e40494b6 100644 --- a/internal/rpc/update_peer_refs.go +++ b/internal/rpc/update_peer_refs.go @@ -234,6 +234,13 @@ func collectMessagePeerRefs(msg domain.Message, currentChannelID int64, userIDs, if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 { userIDs[msg.Media.Contact.UserID] = struct{}{} } + if msg.Media != nil && msg.Media.Giveaway != nil { + for _, id := range msg.Media.Giveaway.Channels { + if id != 0 && id != currentChannelID { + channelIDs[id] = struct{}{} + } + } + } collectServiceActionPeerRefs(msg.Media, currentChannelID, userIDs, channelIDs) collectPollMediaUserRefs(msg.Media, userIDs) collectTodoMediaUserRefs(msg.Media, userIDs) diff --git a/internal/store/postgres/channel_message_send.go b/internal/store/postgres/channel_message_send.go index 05de824e..c785e98a 100644 --- a/internal/store/postgres/channel_message_send.go +++ b/internal/store/postgres/channel_message_send.go @@ -12,6 +12,18 @@ import ( ) func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { + return s.sendChannelMessageWithHooks(ctx, req, channelSendTxHooks{}) +} + +type channelSendTxHooks struct { + before func(context.Context, pgx.Tx, *domain.SendChannelMessageRequest) error + after func(context.Context, pgx.Tx, domain.SendChannelMessageResult) error +} + +// sendChannelMessageWithHooks lets a tightly coupled domain command join the +// channel message/event/PTS transaction. It is deliberately package-private: +// ordinary callers must use SendChannelMessage and may not inject SQL work. +func (s *ChannelStore) sendChannelMessageWithHooks(ctx context.Context, req domain.SendChannelMessageRequest, hooks channelSendTxHooks) (domain.SendChannelMessageResult, error) { if req.UserID == 0 || req.ChannelID == 0 || (strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() && req.RichMessage.IsZero()) { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } @@ -27,7 +39,7 @@ func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendCh } var lastErr error for attempt := 0; attempt < retryableChannelTxAttempts; attempt++ { - res, err := s.sendChannelMessageOnce(ctx, req, requestFingerprint) + res, err := s.sendChannelMessageOnce(ctx, req, requestFingerprint, hooks) if err == nil || !isRetryablePostgresTxError(err) || ctx.Err() != nil { return res, err } @@ -36,7 +48,7 @@ func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendCh return domain.SendChannelMessageResult{}, lastErr } -func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.SendChannelMessageRequest, requestFingerprint []byte) (domain.SendChannelMessageResult, error) { +func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.SendChannelMessageRequest, requestFingerprint []byte, hooks channelSendTxHooks) (domain.SendChannelMessageResult, error) { if req.RandomID != 0 && !req.IdempotencyPreflighted { if dup, found, err := s.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{ ChannelID: req.ChannelID, @@ -125,6 +137,11 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se p := *req.SendAs sendAs = &p } + if hooks.before != nil { + if err := hooks.before(ctx, tx, &req); err != nil { + return domain.SendChannelMessageResult{}, err + } + } msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.ChannelID) if err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("allocate channel message id: %w", err) @@ -135,7 +152,7 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se } var discussion *domain.SendChannelDiscussionResult var discussionRef *domain.ChannelDiscussionRef - if channel.Broadcast && channel.LinkedChatID != 0 { + if channel.Broadcast && channel.LinkedChatID != 0 && req.Action == nil { linked, err := getChannelByID(ctx, tx, channel.LinkedChatID) if err == nil && !linked.Deleted && linked.Megagroup { discussionMsgID, err := s.msgIDs.NextChannelMessageID(ctx, linked.ID) @@ -330,6 +347,16 @@ WHERE channel_id = $1 AND user_id = $2 AND unread_mark`, req.ChannelID, req.User return domain.SendChannelMessageResult{}, err } } + txResult := domain.SendChannelMessageResult{ + Channel: channel, Message: msg, Event: event, Discussion: discussion, + MentionUserIDs: append([]int64(nil), req.MentionUserIDs...), + SkipDeliveryUserIDs: append([]int64(nil), req.SkipDeliveryUserIDs...), + } + if hooks.after != nil { + if err := hooks.after(ctx, tx, txResult); err != nil { + return domain.SendChannelMessageResult{}, err + } + } if err := tx.Commit(ctx); err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("commit send channel: %w", err) } @@ -342,7 +369,8 @@ WHERE channel_id = $1 AND user_id = $2 AND unread_mark`, req.ChannelID, req.User discussion.Recipients, _ = s.ListActiveChannelMemberIDs(ctx, req.UserID, discussion.Channel.ID, 0) } } - return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: recipients, Discussion: discussion, MentionUserIDs: append([]int64(nil), req.MentionUserIDs...), SkipDeliveryUserIDs: append([]int64(nil), req.SkipDeliveryUserIDs...)}, nil + txResult.Recipients = recipients + return txResult, nil } func channelDeliverySkipSet(ids []int64) map[int64]struct{} { diff --git a/internal/store/postgres/stars_gift_purchase.go b/internal/store/postgres/stars_gift_purchase.go index 345abd9b..6548b085 100644 --- a/internal/store/postgres/stars_gift_purchase.go +++ b/internal/store/postgres/stars_gift_purchase.go @@ -6,69 +6,96 @@ import ( "crypto/rand" "crypto/sha256" "encoding/binary" + "encoding/json" "errors" "fmt" + "math" + "strings" + "unicode/utf8" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "telesrv/internal/domain" "telesrv/internal/store" "telesrv/internal/store/postgres/sqlcgen" ) -// StarsGiftPurchaseStore commits a fiat Stars gift as one aggregate with the -// private service message. No external provider is contacted by this local -// development checkout; form binding and settlement idempotency are still -// production-shaped so retries cannot mint twice. -type StarsGiftPurchaseStore struct { +// StarsPurchaseStore commits fiat Stars top-ups, friend gifts and giveaway +// launches. No external +// provider is contacted by this local development checkout; form binding and +// settlement idempotency are still production-shaped so retries cannot mint twice. +type StarsPurchaseStore struct { db sqlcgen.DBTX messages *MessageStore + channels *ChannelStore } -func NewStarsGiftPurchaseStore(db sqlcgen.DBTX, messages *MessageStore) *StarsGiftPurchaseStore { - return &StarsGiftPurchaseStore{db: db, messages: messages} +func NewStarsPurchaseStore(db sqlcgen.DBTX, messages *MessageStore, channels ...*ChannelStore) *StarsPurchaseStore { + var channelStore *ChannelStore + if len(channels) > 0 { + channelStore = channels[0] + } + return &StarsPurchaseStore{db: db, messages: messages, channels: channelStore} } -func (s *StarsGiftPurchaseStore) IssueStarsGiftPurchaseForm(ctx context.Context, form domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error) { - if s == nil || s.db == nil || form.BuyerUserID <= 0 || form.RecipientUserID <= 0 || - form.BuyerUserID == form.RecipientUserID || form.Stars <= 0 || form.Amount <= 0 || - len(form.Currency) != 3 || form.IssuedAt <= 0 || form.ExpiresAt != form.IssuedAt+600 { - return domain.StarsGiftPurchaseForm{}, domain.ErrStarsGiftFormInvalid +func (s *StarsPurchaseStore) IssueStarsPurchaseForm(ctx context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) { + if s == nil || s.db == nil || !validStarsPurchaseForm(form) { + return domain.StarsPurchaseForm{}, domain.ErrStarsPurchaseFormInvalid + } + purposeJSON, err := starsPurchasePurposeJSON(form) + if err != nil { + return domain.StarsPurchaseForm{}, domain.ErrStarsPurchaseFormInvalid } for attempt := 0; attempt < 8; attempt++ { - formID, err := newStarsGiftFormID() + formID, err := newStarsPurchaseFormID() if err != nil { - return domain.StarsGiftPurchaseForm{}, fmt.Errorf("generate stars gift form id: %w", err) + return domain.StarsPurchaseForm{}, fmt.Errorf("generate stars purchase form id: %w", err) } tag, err := s.db.Exec(ctx, ` -INSERT INTO stars_gift_purchase_forms - (buyer_user_id,form_id,recipient_user_id,stars,currency,amount,issued_at,expires_at) -VALUES($1,$2,$3,$4,$5,$6,$7,$8) -ON CONFLICT DO NOTHING`, form.BuyerUserID, formID, form.RecipientUserID, form.Stars, - form.Currency, form.Amount, form.IssuedAt, form.ExpiresAt) +INSERT INTO stars_purchase_forms + (buyer_user_id,form_id,kind,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,issued_at,expires_at) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) +ON CONFLICT DO NOTHING`, form.BuyerUserID, formID, string(form.Kind), starsPurchaseRecipientValue(form.RecipientUserID), + starsPurchasePeerTypeValue(form.SpendPurposePeer), starsPurchasePeerIDValue(form.SpendPurposePeer), + purposeJSON, form.Stars, form.Currency, form.Amount, form.IssuedAt, form.ExpiresAt) if err != nil { - return domain.StarsGiftPurchaseForm{}, fmt.Errorf("insert stars gift form: %w", err) + return domain.StarsPurchaseForm{}, fmt.Errorf("insert stars purchase form: %w", err) } if tag.RowsAffected() == 1 { form.FormID = formID return form, nil } } - return domain.StarsGiftPurchaseForm{}, domain.ErrStarsGiftUnavailable + return domain.StarsPurchaseForm{}, domain.ErrStarsGiftUnavailable } -var errStarsGiftPurchaseReplay = errors.New("stars gift purchase replay") +var errStarsPurchaseReplay = errors.New("stars purchase replay") -func (s *StarsGiftPurchaseStore) PurchaseStarsGift(ctx context.Context, req domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error) { - if s == nil || s.db == nil || s.messages == nil || req.FormID == 0 || - req.BuyerUserID <= 0 || req.RecipientUserID <= 0 || req.BuyerUserID == req.RecipientUserID || - req.Stars <= 0 || req.Amount <= 0 || len(req.Currency) != 3 || req.Date <= 0 { - return domain.StarsGiftPurchaseResult{}, domain.ErrStarsGiftFormInvalid +func (s *StarsPurchaseStore) PurchaseStars(ctx context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) { + if s == nil || s.db == nil || req.FormID == 0 || req.Date <= 0 || !validStarsPurchaseCommand(req.StarsPurchaseForm) { + return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid } - fingerprint := starsGiftPurchaseFingerprint(req) - if replay, found, err := s.loadStarsGiftPurchaseReplay(ctx, req, fingerprint); err != nil || found { + fingerprint := starsPurchaseFingerprint(req) + if replay, found, err := s.loadStarsPurchaseReplay(ctx, req, fingerprint); err != nil || found { return replay, err } + // A committed command is replayable after both the checkout and giveaway + // deadlines: the provider may retry a successful submission after losing the + // response, and a terminal campaign must not turn that exact retry into a + // different outcome. The deadline only gates a first settlement. + if req.Kind == domain.StarsPurchaseGiveaway && req.Giveaway.UntilDate <= req.Date { + return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormExpired + } + switch req.Kind { + case domain.StarsPurchaseTopup: + return s.purchaseStarsTopup(ctx, req, fingerprint) + case domain.StarsPurchaseGiveaway: + return s.purchaseStarsGiveaway(ctx, req, fingerprint) + } + if s.messages == nil { + return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid + } transactionID := fmt.Sprintf("stars-gift:%d:%d", req.BuyerUserID, req.FormID) randomID := lifecycleCommandRandomID("stars-fiat-gift", req.BuyerUserID, req.FormID) @@ -83,16 +110,16 @@ func (s *StarsGiftPurchaseStore) PurchaseStarsGift(ctx context.Context, req doma OriginUserID: req.BuyerUserID, OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, IdempotencyFingerprint: fingerprint[:], } - result := domain.StarsGiftPurchaseResult{TransactionID: transactionID} + result := domain.StarsPurchaseResult{TransactionID: transactionID} hooks := privateSendTxHooks{ before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { - if err := validateStarsGiftPurchaseForm(ctx, tx, req, true); err != nil { + if err := validateStarsPurchaseForm(ctx, tx, req, true); err != nil { return err } - if found, err := starsGiftPurchaseCommandExists(ctx, tx, req.BuyerUserID, req.FormID); err != nil { + if found, err := starsPurchaseCommandExists(ctx, tx, req.BuyerUserID, req.FormID); err != nil { return err } else if found { - return errStarsGiftPurchaseReplay + return errStarsPurchaseReplay } balance := domain.StarsBalance{UserID: req.RecipientUserID} if err := tx.QueryRow(ctx, ` @@ -107,21 +134,20 @@ RETURNING balance,granted`, req.RecipientUserID, req.Stars).Scan(&balance.Balanc "Stars gift", fmt.Sprintf("%d Stars", req.Stars)); err != nil { return err } - result.RecipientBalance = balance + result.Balance = balance if send.Media == nil || send.Media.ServiceAction == nil || send.Media.ServiceAction.GiftStars == nil { - return domain.ErrStarsGiftFormInvalid + return domain.ErrStarsPurchaseFormInvalid } send.Media.ServiceAction.GiftStars.BalanceAfter = balance.Balance return nil }, after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { _, err := tx.Exec(ctx, ` -INSERT INTO stars_gift_purchase_commands - (buyer_user_id,form_id,request_fingerprint,recipient_user_id,stars,currency,amount, - recipient_balance_after,transaction_id,created_at) -VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.BuyerUserID, req.FormID, fingerprint[:], - req.RecipientUserID, req.Stars, req.Currency, req.Amount, - result.RecipientBalance.Balance, transactionID, req.Date) +INSERT INTO stars_purchase_commands + (buyer_user_id,form_id,kind,request_fingerprint,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount, + balance_after,transaction_id,created_at) +VALUES($1,$2,$3,$4,$5,NULL,NULL,'{}'::jsonb,$6,$7,$8,$9,$10,$11)`, req.BuyerUserID, req.FormID, string(req.Kind), fingerprint[:], + req.RecipientUserID, req.Stars, req.Currency, req.Amount, result.Balance.Balance, transactionID, req.Date) if err != nil { return fmt.Errorf("insert stars gift purchase command: %w", err) } @@ -131,61 +157,233 @@ VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.BuyerUserID, req.FormID, fingerprin } sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks) if err != nil { - if errors.Is(err, errStarsGiftPurchaseReplay) { - if replay, found, replayErr := s.loadStarsGiftPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found { + if errors.Is(err, errStarsPurchaseReplay) { + if replay, found, replayErr := s.loadStarsPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found { return replay, replayErr } } - return domain.StarsGiftPurchaseResult{}, err + return domain.StarsPurchaseResult{}, err } result.Send = sent return result, nil } -func validateStarsGiftPurchaseForm(ctx context.Context, db sqlcgen.DBTX, req domain.StarsGiftPurchaseRequest, lock bool) error { - query := `SELECT recipient_user_id,stars,currency,amount,issued_at,expires_at -FROM stars_gift_purchase_forms WHERE buyer_user_id=$1 AND form_id=$2` +func (s *StarsPurchaseStore) purchaseStarsTopup(ctx context.Context, req domain.StarsPurchaseRequest, fingerprint [32]byte) (domain.StarsPurchaseResult, error) { + transactionID := fmt.Sprintf("stars-topup:%d:%d", req.BuyerUserID, req.FormID) + result := domain.StarsPurchaseResult{TransactionID: transactionID} + err := withTx(ctx, s.db, "settle stars topup", func(tx pgx.Tx) error { + if err := validateStarsPurchaseForm(ctx, tx, req, true); err != nil { + return err + } + if found, err := starsPurchaseCommandExists(ctx, tx, req.BuyerUserID, req.FormID); err != nil { + return err + } else if found { + return errStarsPurchaseReplay + } + result.Balance = domain.StarsBalance{UserID: req.BuyerUserID} + if err := tx.QueryRow(ctx, ` +INSERT INTO stars_balances (user_id,balance,updated_at) VALUES($1,$2,now()) +ON CONFLICT (user_id) DO UPDATE +SET balance=stars_balances.balance+EXCLUDED.balance, updated_at=now() +RETURNING balance,granted`, req.BuyerUserID, req.Stars).Scan(&result.Balance.Balance, &result.Balance.Granted); err != nil { + return fmt.Errorf("credit stars topup buyer: %w", err) + } + if err := insertStarsTxn(ctx, tx, req.BuyerUserID, req.Stars, domain.StarsReasonTopup, + req.SpendPurposePeer, req.Date, "Stars top-up", "telesrv dev purchase"); err != nil { + return err + } + _, err := tx.Exec(ctx, ` +INSERT INTO stars_purchase_commands + (buyer_user_id,form_id,kind,request_fingerprint,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount, + balance_after,transaction_id,created_at) +VALUES($1,$2,$3,$4,NULL,$5,$6,'{}'::jsonb,$7,$8,$9,$10,$11,$12)`, req.BuyerUserID, req.FormID, string(req.Kind), fingerprint[:], + starsPurchasePeerTypeValue(req.SpendPurposePeer), starsPurchasePeerIDValue(req.SpendPurposePeer), + req.Stars, req.Currency, req.Amount, result.Balance.Balance, transactionID, req.Date) + if err != nil { + return fmt.Errorf("insert stars topup command: %w", err) + } + return nil + }) + if errors.Is(err, errStarsPurchaseReplay) { + if replay, found, replayErr := s.loadStarsPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found { + return replay, replayErr + } + } + if err != nil { + return domain.StarsPurchaseResult{}, err + } + return result, nil +} + +func (s *StarsPurchaseStore) purchaseStarsGiveaway(ctx context.Context, req domain.StarsPurchaseRequest, fingerprint [32]byte) (domain.StarsPurchaseResult, error) { + if s.channels == nil || req.Giveaway == nil { + return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid + } + purposeJSON, err := starsPurchasePurposeJSON(req.StarsPurchaseForm) + if err != nil { + return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid + } + giveaway := *req.Giveaway + transactionID := fmt.Sprintf("stars-giveaway:%d:%d", req.BuyerUserID, req.FormID) + result := domain.StarsPurchaseResult{TransactionID: transactionID} + sendReq := domain.SendChannelMessageRequest{ + UserID: req.BuyerUserID, ChannelID: giveaway.BoostPeer.ID, + RandomID: giveaway.RandomID, Date: req.Date, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindGiveaway, Giveaway: &domain.MessageGiveaway{ + OnlyNewSubscribers: giveaway.OnlyNewSubscribers, WinnersAreVisible: giveaway.WinnersAreVisible, + Channels: starsGiveawayChannelIDs(giveaway), CountriesISO2: append([]string(nil), giveaway.CountriesISO2...), + PrizeDescription: giveaway.PrizeDescription, Quantity: giveaway.Users, Stars: req.Stars, UntilDate: giveaway.UntilDate, + }}, + IdempotencyFingerprint: fingerprint[:], + } + hooks := channelSendTxHooks{ + before: func(ctx context.Context, tx pgx.Tx, _ *domain.SendChannelMessageRequest) error { + if err := validateStarsPurchaseForm(ctx, tx, req, true); err != nil { + return err + } + if found, err := starsPurchaseCommandExists(ctx, tx, req.BuyerUserID, req.FormID); err != nil { + return err + } else if found { + return errStarsPurchaseReplay + } + return nil + }, + after: func(ctx context.Context, tx pgx.Tx, sent domain.SendChannelMessageResult) error { + if sent.Message.ID <= 0 || sent.Event.Pts <= 0 || sent.Event.PtsCount != 1 { + return fmt.Errorf("settle stars giveaway: invalid channel send receipt") + } + if _, err := tx.Exec(ctx, ` +INSERT INTO stars_giveaways + (buyer_user_id,form_id,channel_id,launch_message_id,random_id,stars,users,per_user_stars,yearly_boosts, + until_date,purpose_json,state,created_at) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'active',$12)`, + req.BuyerUserID, req.FormID, giveaway.BoostPeer.ID, sent.Message.ID, giveaway.RandomID, + req.Stars, giveaway.Users, giveaway.PerUserStars, giveaway.YearlyBoosts, giveaway.UntilDate, purposeJSON, req.Date); err != nil { + return fmt.Errorf("insert stars giveaway campaign: %w", err) + } + if _, err := tx.Exec(ctx, ` +INSERT INTO stars_purchase_commands + (buyer_user_id,form_id,kind,request_fingerprint,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount, + balance_after,transaction_id,created_at) +VALUES($1,$2,$3,$4,NULL,NULL,NULL,$5,$6,$7,$8,0,$9,$10)`, + req.BuyerUserID, req.FormID, string(req.Kind), fingerprint[:], purposeJSON, + req.Stars, req.Currency, req.Amount, transactionID, req.Date); err != nil { + return fmt.Errorf("insert stars giveaway purchase command: %w", err) + } + result.ChannelSend = sent + return nil + }, + } + sent, err := s.channels.sendChannelMessageWithHooks(ctx, sendReq, hooks) + if err != nil { + if errors.Is(err, errStarsPurchaseReplay) { + if replay, found, replayErr := s.loadStarsPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found { + return replay, replayErr + } + } + return domain.StarsPurchaseResult{}, err + } + if sent.Duplicate { + if replay, found, replayErr := s.loadStarsPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found { + return replay, replayErr + } + return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid + } + result.ChannelSend = sent + return result, nil +} + +func starsGiveawayChannelIDs(giveaway domain.StarsGiveawayPurchase) []int64 { + ids := make([]int64, 0, 1+len(giveaway.AdditionalPeers)) + ids = append(ids, giveaway.BoostPeer.ID) + for _, peer := range giveaway.AdditionalPeers { + ids = append(ids, peer.ID) + } + return ids +} + +func validateStarsPurchaseForm(ctx context.Context, db sqlcgen.DBTX, req domain.StarsPurchaseRequest, lock bool) error { + query := `SELECT kind,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,issued_at,expires_at +FROM stars_purchase_forms WHERE buyer_user_id=$1 AND form_id=$2` if lock { query += ` FOR UPDATE` } - var recipientID, stars, amount int64 + var kind string + var recipientID pgtype.Int8 + var spendPeerType pgtype.Text + var spendPeerID pgtype.Int8 + var purposeJSON []byte + var stars, amount int64 var currency string var issuedAt, expiresAt int err := db.QueryRow(ctx, query, req.BuyerUserID, req.FormID). - Scan(&recipientID, &stars, ¤cy, &amount, &issuedAt, &expiresAt) + Scan(&kind, &recipientID, &spendPeerType, &spendPeerID, &purposeJSON, &stars, ¤cy, &amount, &issuedAt, &expiresAt) if errors.Is(err, pgx.ErrNoRows) { - return domain.ErrStarsGiftFormInvalid + return domain.ErrStarsPurchaseFormInvalid } if err != nil { return fmt.Errorf("load stars gift form: %w", err) } if req.Date >= expiresAt { - return domain.ErrStarsGiftFormExpired + return domain.ErrStarsPurchaseFormExpired } - if issuedAt <= 0 || recipientID != req.RecipientUserID || stars != req.Stars || - currency != req.Currency || amount != req.Amount { - return domain.ErrStarsGiftFormInvalid + if issuedAt <= 0 || kind != string(req.Kind) || nullableStarsRecipient(recipientID) != req.RecipientUserID || + nullableStarsPeer(spendPeerType, spendPeerID) != req.SpendPurposePeer || stars != req.Stars || + currency != req.Currency || amount != req.Amount || !sameStarsPurchasePurpose(purposeJSON, req.StarsPurchaseForm) { + return domain.ErrStarsPurchaseFormInvalid } return nil } -func (s *StarsGiftPurchaseStore) loadStarsGiftPurchaseReplay(ctx context.Context, req domain.StarsGiftPurchaseRequest, fingerprint [32]byte) (domain.StarsGiftPurchaseResult, bool, error) { - var recipientID, stars, amount, balance int64 - var currency, transactionID string - var storedFingerprint []byte +func (s *StarsPurchaseStore) loadStarsPurchaseReplay(ctx context.Context, req domain.StarsPurchaseRequest, fingerprint [32]byte) (domain.StarsPurchaseResult, bool, error) { + var recipientID pgtype.Int8 + var spendPeerType pgtype.Text + var spendPeerID pgtype.Int8 + var stars, amount, balance int64 + var kind, currency, transactionID string + var storedFingerprint, purposeJSON []byte err := s.db.QueryRow(ctx, ` -SELECT request_fingerprint,recipient_user_id,stars,currency,amount,recipient_balance_after,transaction_id -FROM stars_gift_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2`, req.BuyerUserID, req.FormID). - Scan(&storedFingerprint, &recipientID, &stars, ¤cy, &amount, &balance, &transactionID) +SELECT kind,request_fingerprint,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,balance_after,transaction_id +FROM stars_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2`, req.BuyerUserID, req.FormID). + Scan(&kind, &storedFingerprint, &recipientID, &spendPeerType, &spendPeerID, &purposeJSON, &stars, ¤cy, &amount, &balance, &transactionID) if errors.Is(err, pgx.ErrNoRows) { - return domain.StarsGiftPurchaseResult{}, false, nil + return domain.StarsPurchaseResult{}, false, nil } if err != nil { - return domain.StarsGiftPurchaseResult{}, false, fmt.Errorf("load stars gift purchase replay: %w", err) + return domain.StarsPurchaseResult{}, false, fmt.Errorf("load stars purchase replay: %w", err) } - if !bytes.Equal(storedFingerprint, fingerprint[:]) || recipientID != req.RecipientUserID || - stars != req.Stars || currency != req.Currency || amount != req.Amount || transactionID == "" { - return domain.StarsGiftPurchaseResult{}, false, domain.ErrStarsGiftFormInvalid + if kind != string(req.Kind) || !bytes.Equal(storedFingerprint, fingerprint[:]) || nullableStarsRecipient(recipientID) != req.RecipientUserID || + nullableStarsPeer(spendPeerType, spendPeerID) != req.SpendPurposePeer || + stars != req.Stars || currency != req.Currency || amount != req.Amount || transactionID == "" || + !sameStarsPurchasePurpose(purposeJSON, req.StarsPurchaseForm) { + return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid + } + result := domain.StarsPurchaseResult{ + Balance: domain.StarsBalance{UserID: req.BuyerUserID, Balance: balance}, + TransactionID: transactionID, Duplicate: true, + } + if req.Kind == domain.StarsPurchaseTopup { + return result, true, nil + } + if req.Kind == domain.StarsPurchaseGiveaway { + if s.channels == nil || req.Giveaway == nil { + return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid + } + sent, found, err := s.channels.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{ + ChannelID: req.Giveaway.BoostPeer.ID, SenderUserID: req.BuyerUserID, + RandomID: req.Giveaway.RandomID, IdempotencyFingerprint: fingerprint[:], + }) + if err != nil { + return domain.StarsPurchaseResult{}, false, err + } + if !found { + return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid + } + result.ChannelSend = sent + return result, true, nil + } + if s.messages == nil { + return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid } sent, found, err := s.messages.LookupPrivateSendReplay(ctx, domain.PrivateSendReplayRequest{ SenderUserID: req.BuyerUserID, RecipientUserID: req.RecipientUserID, @@ -193,32 +391,33 @@ FROM stars_gift_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2`, req.Bu IdempotencyFingerprint: fingerprint[:], }) if err != nil { - return domain.StarsGiftPurchaseResult{}, false, err + return domain.StarsPurchaseResult{}, false, err } if !found { - return domain.StarsGiftPurchaseResult{}, false, domain.ErrStarsGiftFormInvalid + return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid } - return domain.StarsGiftPurchaseResult{ - RecipientBalance: domain.StarsBalance{UserID: req.RecipientUserID, Balance: balance}, - Send: sent, TransactionID: transactionID, Duplicate: true, - }, true, nil + result.Balance.UserID = req.RecipientUserID + result.Send = sent + return result, true, nil } -func starsGiftPurchaseCommandExists(ctx context.Context, db sqlcgen.DBTX, buyerUserID, formID int64) (bool, error) { +func starsPurchaseCommandExists(ctx context.Context, db sqlcgen.DBTX, buyerUserID, formID int64) (bool, error) { var exists bool if err := db.QueryRow(ctx, `SELECT EXISTS( -SELECT 1 FROM stars_gift_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2)`, buyerUserID, formID).Scan(&exists); err != nil { - return false, fmt.Errorf("check stars gift purchase command: %w", err) +SELECT 1 FROM stars_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2)`, buyerUserID, formID).Scan(&exists); err != nil { + return false, fmt.Errorf("check stars purchase command: %w", err) } return exists, nil } -func starsGiftPurchaseFingerprint(req domain.StarsGiftPurchaseRequest) [32]byte { - return sha256.Sum256([]byte(fmt.Sprintf("telesrv:stars-fiat-gift:v1:%d:%d:%d:%s:%d:%d", - req.BuyerUserID, req.RecipientUserID, req.Stars, req.Currency, req.Amount, req.FormID))) +func starsPurchaseFingerprint(req domain.StarsPurchaseRequest) [32]byte { + purposeJSON, _ := starsPurchasePurposeJSON(req.StarsPurchaseForm) + return sha256.Sum256([]byte(fmt.Sprintf("telesrv:stars-fiat-purchase:v2:%s:%d:%d:%s:%d:%d:%s:%d:%d:%s", + req.Kind, req.BuyerUserID, req.RecipientUserID, req.SpendPurposePeer.Type, req.SpendPurposePeer.ID, + req.Stars, req.Currency, req.Amount, req.FormID, purposeJSON))) } -func newStarsGiftFormID() (int64, error) { +func newStarsPurchaseFormID() (int64, error) { var raw [8]byte if _, err := rand.Read(raw[:]); err != nil { return 0, err @@ -230,4 +429,200 @@ func newStarsGiftFormID() (int64, error) { return id, nil } -var _ store.StarsGiftPurchaseStore = (*StarsGiftPurchaseStore)(nil) +func validStarsPurchaseForm(form domain.StarsPurchaseForm) bool { + if !validStarsPurchaseCommand(form) || form.IssuedAt <= 0 || form.ExpiresAt != form.IssuedAt+600 { + return false + } + return form.Kind != domain.StarsPurchaseGiveaway || + (form.Giveaway.UntilDate > form.IssuedAt && form.Giveaway.UntilDate <= form.IssuedAt+7*24*60*60) +} + +func validStarsPurchaseCommand(form domain.StarsPurchaseForm) bool { + if !form.Kind.Valid() || form.BuyerUserID <= 0 || form.Stars <= 0 || form.Amount <= 0 || len(form.Currency) != 3 { + return false + } + validSpendPeer := (form.SpendPurposePeer == domain.Peer{}) || + ((form.SpendPurposePeer.Type == domain.PeerTypeUser || form.SpendPurposePeer.Type == domain.PeerTypeChannel) && form.SpendPurposePeer.ID > 0) + switch form.Kind { + case domain.StarsPurchaseTopup: + return form.RecipientUserID == 0 && validSpendPeer && form.Giveaway == nil + case domain.StarsPurchaseGift: + return form.RecipientUserID > 0 && form.RecipientUserID != form.BuyerUserID && form.SpendPurposePeer == (domain.Peer{}) && form.Giveaway == nil + case domain.StarsPurchaseGiveaway: + return form.RecipientUserID == 0 && form.SpendPurposePeer == (domain.Peer{}) && validStarsGiveawayPurchase(form.Giveaway, form.Stars) + default: + return false + } +} + +func validStarsGiveawayPurchase(g *domain.StarsGiveawayPurchase, stars int64) bool { + if g == nil || g.BoostPeer.Type != domain.PeerTypeChannel || g.BoostPeer.ID <= 0 || g.RandomID == 0 || + g.UntilDate <= 0 || g.Users <= 0 || g.PerUserStars <= 0 || g.YearlyBoosts < 0 || + int64(g.Users) > math.MaxInt64/g.PerUserStars || int64(g.Users)*g.PerUserStars != stars || + len(g.AdditionalPeers) > 10 || len(g.CountriesISO2) > 10 || utf8.RuneCountInString(g.PrizeDescription) > 128 { + return false + } + seenPeers := map[int64]struct{}{g.BoostPeer.ID: struct{}{}} + for _, peer := range g.AdditionalPeers { + if peer.Type != domain.PeerTypeChannel || peer.ID <= 0 { + return false + } + if _, exists := seenPeers[peer.ID]; exists { + return false + } + seenPeers[peer.ID] = struct{}{} + } + seenCountries := make(map[string]struct{}, len(g.CountriesISO2)) + for _, country := range g.CountriesISO2 { + if len(country) != 2 || country != strings.ToUpper(country) || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z' { + return false + } + if _, exists := seenCountries[country]; exists { + return false + } + seenCountries[country] = struct{}{} + } + return true +} + +func starsPurchasePurposeJSON(form domain.StarsPurchaseForm) ([]byte, error) { + if form.Kind != domain.StarsPurchaseGiveaway { + return []byte(`{}`), nil + } + if form.Giveaway == nil { + return nil, domain.ErrStarsPurchaseFormInvalid + } + return json.Marshal(form.Giveaway) +} + +func sameStarsPurchasePurpose(stored []byte, form domain.StarsPurchaseForm) bool { + want, err := starsPurchasePurposeJSON(form) + if err != nil { + return false + } + if form.Kind != domain.StarsPurchaseGiveaway { + var value map[string]any + return json.Unmarshal(stored, &value) == nil && len(value) == 0 + } + var decoded domain.StarsGiveawayPurchase + if err := json.Unmarshal(stored, &decoded); err != nil { + return false + } + got, err := json.Marshal(&decoded) + return err == nil && bytes.Equal(got, want) +} + +func starsPurchaseRecipientValue(recipientUserID int64) any { + if recipientUserID == 0 { + return nil + } + return recipientUserID +} + +func nullableStarsRecipient(value pgtype.Int8) int64 { + if !value.Valid { + return 0 + } + return value.Int64 +} + +func starsPurchasePeerTypeValue(peer domain.Peer) any { + if peer == (domain.Peer{}) { + return nil + } + return string(peer.Type) +} + +func starsPurchasePeerIDValue(peer domain.Peer) any { + if peer == (domain.Peer{}) { + return nil + } + return peer.ID +} + +func nullableStarsPeer(peerType pgtype.Text, peerID pgtype.Int8) domain.Peer { + if !peerType.Valid || !peerID.Valid { + return domain.Peer{} + } + return domain.Peer{Type: domain.PeerType(peerType.String), ID: peerID.Int64} +} + +func (s *StarsPurchaseStore) GetStarsGiveawayInfo(ctx context.Context, viewerUserID, channelID int64, messageID, date int) (domain.StarsGiveawayInfo, error) { + if s == nil || s.db == nil || viewerUserID <= 0 || channelID <= 0 || messageID <= 0 || date <= 0 { + return domain.StarsGiveawayInfo{}, domain.ErrStarsPurchaseFormInvalid + } + var purposeJSON []byte + var state string + var startDate, untilDate int + err := s.db.QueryRow(ctx, ` +SELECT purpose_json,state,created_at,until_date +FROM stars_giveaways WHERE channel_id=$1 AND launch_message_id=$2`, channelID, messageID). + Scan(&purposeJSON, &state, &startDate, &untilDate) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarsGiveawayInfo{}, domain.ErrMessageIDInvalid + } + if err != nil { + return domain.StarsGiveawayInfo{}, fmt.Errorf("load stars giveaway info: %w", err) + } + var purpose domain.StarsGiveawayPurchase + if err := json.Unmarshal(purposeJSON, &purpose); err != nil || purpose.BoostPeer.ID != channelID { + return domain.StarsGiveawayInfo{}, domain.ErrStarsPurchaseFormInvalid + } + info := domain.StarsGiveawayInfo{StartDate: startDate} + if state == "cancelled" { + return info, nil + } + if state != "active" || date >= untilDate { + info.PreparingResults = true + return info, nil + } + channels := starsGiveawayChannelIDs(purpose) + for _, requiredChannelID := range channels { + var role, status string + var joinedAt int + err := s.db.QueryRow(ctx, ` +SELECT role,status,joined_at FROM channel_members WHERE channel_id=$1 AND user_id=$2`, requiredChannelID, viewerUserID). + Scan(&role, &status, &joinedAt) + if errors.Is(err, pgx.ErrNoRows) { + return info, nil + } + if err != nil { + return domain.StarsGiveawayInfo{}, fmt.Errorf("load giveaway participant membership: %w", err) + } + if status != string(domain.ChannelMemberActive) { + return info, nil + } + if role == string(domain.ChannelRoleCreator) || role == string(domain.ChannelRoleAdmin) { + info.AdminDisallowedChatID = requiredChannelID + return info, nil + } + if purpose.OnlyNewSubscribers && joinedAt > 0 && joinedAt <= startDate { + info.JoinedTooEarlyDate = joinedAt + return info, nil + } + } + if len(purpose.CountriesISO2) > 0 { + var country string + if err := s.db.QueryRow(ctx, ` +SELECT COALESCE((SELECT cc.iso2 FROM country_codes cc WHERE cc.country_code=u.country_code ORDER BY cc.id LIMIT 1),'') +FROM users u WHERE u.id=$1`, viewerUserID).Scan(&country); err != nil { + return domain.StarsGiveawayInfo{}, fmt.Errorf("load giveaway participant country: %w", err) + } + allowed := false + for _, candidate := range purpose.CountriesISO2 { + if candidate == country { + allowed = true + break + } + } + if !allowed { + info.DisallowedCountry = country + return info, nil + } + } + info.Participating = true + return info, nil +} + +var _ store.StarsPurchaseStore = (*StarsPurchaseStore)(nil) +var _ store.StarsGiveawayStore = (*StarsPurchaseStore)(nil) diff --git a/internal/store/postgres/stars_gift_purchase_integration_test.go b/internal/store/postgres/stars_gift_purchase_integration_test.go index f910e556..3e3a1c3c 100644 --- a/internal/store/postgres/stars_gift_purchase_integration_test.go +++ b/internal/store/postgres/stars_gift_purchase_integration_test.go @@ -8,6 +8,43 @@ import ( "telesrv/internal/domain" ) +type starsPurchaseAttempt struct { + result domain.StarsPurchaseResult + err error +} + +func purchaseStarsTwiceConcurrently(t *testing.T, ctx context.Context, store *StarsPurchaseStore, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, domain.StarsPurchaseResult) { + t.Helper() + start := make(chan struct{}) + attempts := make(chan starsPurchaseAttempt, 2) + for range 2 { + go func() { + <-start + result, err := store.PurchaseStars(ctx, req) + attempts <- starsPurchaseAttempt{result: result, err: err} + }() + } + close(start) + + var first, replay domain.StarsPurchaseResult + firstCount, replayCount := 0, 0 + for range 2 { + attempt := <-attempts + if attempt.err != nil { + t.Fatalf("concurrent Stars purchase: %v", attempt.err) + } + if attempt.result.Duplicate { + replay, replayCount = attempt.result, replayCount+1 + } else { + first, firstCount = attempt.result, firstCount+1 + } + } + if firstCount != 1 || replayCount != 1 { + t.Fatalf("concurrent Stars purchase first/replay counts = %d/%d, want 1/1", firstCount, replayCount) + } + return first, replay +} + func TestStarsFriendGiftPurchaseAtomicReplayAndValidationPostgres(t *testing.T) { pool := testPool(t) ctx := context.Background() @@ -22,17 +59,17 @@ func TestStarsFriendGiftPurchaseAtomicReplayAndValidationPostgres(t *testing.T) t.Fatalf("create recipient: %v", err) } t.Cleanup(func() { - _, _ = pool.Exec(ctx, "DELETE FROM stars_gift_purchase_commands WHERE buyer_user_id=$1", buyer.ID) - _, _ = pool.Exec(ctx, "DELETE FROM stars_gift_purchase_forms WHERE buyer_user_id=$1", buyer.ID) + _, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_commands WHERE buyer_user_id=$1", buyer.ID) + _, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_forms WHERE buyer_user_id=$1", buyer.ID) _, _ = pool.Exec(ctx, "DELETE FROM stars_transactions WHERE user_id=$1", recipient.ID) _, _ = pool.Exec(ctx, "DELETE FROM stars_balances WHERE user_id=$1", recipient.ID) _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=ANY($1::bigint[])", []int64{buyer.ID, recipient.ID}) }) messages := NewMessageStore(pool) - store := NewStarsGiftPurchaseStore(pool, messages) - issued, err := store.IssueStarsGiftPurchaseForm(ctx, domain.StarsGiftPurchaseForm{ - BuyerUserID: buyer.ID, RecipientUserID: recipient.ID, + store := NewStarsPurchaseStore(pool, messages) + issued, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{ + Kind: domain.StarsPurchaseGift, BuyerUserID: buyer.ID, RecipientUserID: recipient.ID, Stars: 2500, Currency: "USD", Amount: 199, IssuedAt: 1_700_000_000, ExpiresAt: 1_700_000_600, }) @@ -41,18 +78,15 @@ func TestStarsFriendGiftPurchaseAtomicReplayAndValidationPostgres(t *testing.T) } var origin [8]byte origin[0] = 9 - req := domain.StarsGiftPurchaseRequest{ - StarsGiftPurchaseForm: domain.StarsGiftPurchaseForm{ - FormID: issued.FormID, BuyerUserID: buyer.ID, RecipientUserID: recipient.ID, + req := domain.StarsPurchaseRequest{ + StarsPurchaseForm: domain.StarsPurchaseForm{ + FormID: issued.FormID, Kind: domain.StarsPurchaseGift, BuyerUserID: buyer.ID, RecipientUserID: recipient.ID, Stars: 2500, Currency: "USD", Amount: 199, }, Date: 1_700_000_100, OriginAuthKeyID: origin, OriginSessionID: 77, } - first, err := store.PurchaseStarsGift(ctx, req) - if err != nil { - t.Fatalf("purchase: %v", err) - } - if first.Duplicate || first.RecipientBalance.Balance != 2500 || first.TransactionID == "" || + first, replay := purchaseStarsTwiceConcurrently(t, ctx, store, req) + if first.Duplicate || first.Balance.Balance != 2500 || first.TransactionID == "" || first.Send.SenderEvent.PtsCount != 1 || first.Send.RecipientEvent.PtsCount != 1 { t.Fatalf("first purchase = %+v", first) } @@ -66,10 +100,6 @@ func TestStarsFriendGiftPurchaseAtomicReplayAndValidationPostgres(t *testing.T) t.Fatalf("recipient gift action = %+v", action) } - replay, err := store.PurchaseStarsGift(ctx, req) - if err != nil { - t.Fatalf("replay: %v", err) - } if !replay.Duplicate || replay.TransactionID != first.TransactionID || replay.Send.SenderMessage.ID != first.Send.SenderMessage.ID || replay.Send.SenderEvent.Pts != first.Send.SenderEvent.Pts { t.Fatalf("replay = %+v, first=%+v", replay, first) @@ -82,7 +112,7 @@ func TestStarsFriendGiftPurchaseAtomicReplayAndValidationPostgres(t *testing.T) if err := pool.QueryRow(ctx, "SELECT count(*) FROM stars_transactions WHERE user_id=$1 AND reason='gift'", recipient.ID).Scan(&txnCount); err != nil { t.Fatal(err) } - if err := pool.QueryRow(ctx, "SELECT count(*) FROM stars_gift_purchase_commands WHERE buyer_user_id=$1", buyer.ID).Scan(&commandCount); err != nil { + if err := pool.QueryRow(ctx, "SELECT count(*) FROM stars_purchase_commands WHERE buyer_user_id=$1", buyer.ID).Scan(&commandCount); err != nil { t.Fatal(err) } if balance != 2500 || txnCount != 1 || commandCount != 1 { @@ -103,11 +133,11 @@ func TestStarsFriendGiftPurchaseAtomicReplayAndValidationPostgres(t *testing.T) tampered := req tampered.Amount++ - if _, err := store.PurchaseStarsGift(ctx, tampered); !errors.Is(err, domain.ErrStarsGiftFormInvalid) { + if _, err := store.PurchaseStars(ctx, tampered); !errors.Is(err, domain.ErrStarsPurchaseFormInvalid) { t.Fatalf("tampered replay err=%v", err) } - expired, err := store.IssueStarsGiftPurchaseForm(ctx, domain.StarsGiftPurchaseForm{ - BuyerUserID: buyer.ID, RecipientUserID: recipient.ID, + expired, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{ + Kind: domain.StarsPurchaseGift, BuyerUserID: buyer.ID, RecipientUserID: recipient.ID, Stars: 1000, Currency: "USD", Amount: 99, IssuedAt: 1_699_999_000, ExpiresAt: 1_699_999_600, }) @@ -116,10 +146,221 @@ func TestStarsFriendGiftPurchaseAtomicReplayAndValidationPostgres(t *testing.T) } expiredReq := req expiredReq.FormID, expiredReq.Stars, expiredReq.Amount = expired.FormID, 1000, 99 - if _, err := store.PurchaseStarsGift(ctx, expiredReq); !errors.Is(err, domain.ErrStarsGiftFormExpired) { + if _, err := store.PurchaseStars(ctx, expiredReq); !errors.Is(err, domain.ErrStarsPurchaseFormExpired) { t.Fatalf("expired form err=%v", err) } if err := pool.QueryRow(ctx, "SELECT balance FROM stars_balances WHERE user_id=$1", recipient.ID).Scan(&balance); err != nil || balance != 2500 { t.Fatalf("balance after failures=%d err=%v", balance, err) } } + +func TestStarsTopupPurchaseAtomicReplayAndPurposeBindingPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + users := NewUserStore(pool) + suffix := randomSuffix(t) + buyer, err := users.Create(ctx, domain.User{AccessHash: 94201, Phone: "+1665942" + suffix + "01", FirstName: "TopupBuyer"}) + if err != nil { + t.Fatalf("create buyer: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_commands WHERE buyer_user_id=$1", buyer.ID) + _, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_forms WHERE buyer_user_id=$1", buyer.ID) + _, _ = pool.Exec(ctx, "DELETE FROM stars_transactions WHERE user_id=$1", buyer.ID) + _, _ = pool.Exec(ctx, "DELETE FROM stars_balances WHERE user_id=$1", buyer.ID) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=$1", buyer.ID) + }) + + store := NewStarsPurchaseStore(pool, nil) + purposePeer := domain.Peer{Type: domain.PeerTypeUser, ID: buyer.ID + 100} + issued, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{ + Kind: domain.StarsPurchaseTopup, BuyerUserID: buyer.ID, SpendPurposePeer: purposePeer, + Stars: 2500, Currency: "USD", Amount: 199, + IssuedAt: 1_700_000_000, ExpiresAt: 1_700_000_600, + }) + if err != nil || issued.FormID == 0 { + t.Fatalf("issue form = %+v err=%v", issued, err) + } + req := domain.StarsPurchaseRequest{ + StarsPurchaseForm: domain.StarsPurchaseForm{ + FormID: issued.FormID, Kind: domain.StarsPurchaseTopup, BuyerUserID: buyer.ID, + SpendPurposePeer: purposePeer, Stars: 2500, Currency: "USD", Amount: 199, + }, + Date: 1_700_000_100, + } + first, replay := purchaseStarsTwiceConcurrently(t, ctx, store, req) + if first.Duplicate || first.Balance.Balance != 2500 || first.TransactionID == "" { + t.Fatalf("first purchase = %+v", first) + } + if !replay.Duplicate || replay.Balance.Balance != 2500 || replay.TransactionID != first.TransactionID { + t.Fatalf("replay = %+v, first=%+v", replay, first) + } + + var balance, txnCount, commandCount int64 + if err := pool.QueryRow(ctx, "SELECT balance FROM stars_balances WHERE user_id=$1", buyer.ID).Scan(&balance); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, "SELECT count(*) FROM stars_transactions WHERE user_id=$1 AND reason='topup'", buyer.ID).Scan(&txnCount); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, "SELECT count(*) FROM stars_purchase_commands WHERE buyer_user_id=$1", buyer.ID).Scan(&commandCount); err != nil { + t.Fatal(err) + } + if balance != 2500 || txnCount != 1 || commandCount != 1 { + t.Fatalf("replay footprint balance=%d txns=%d commands=%d", balance, txnCount, commandCount) + } + + tampered := req + tampered.SpendPurposePeer.ID++ + if _, err := store.PurchaseStars(ctx, tampered); !errors.Is(err, domain.ErrStarsPurchaseFormInvalid) { + t.Fatalf("tampered purpose replay err=%v", err) + } + otherBuyer := req + otherBuyer.BuyerUserID++ + if _, err := store.PurchaseStars(ctx, otherBuyer); !errors.Is(err, domain.ErrStarsPurchaseFormInvalid) { + t.Fatalf("cross-account form err=%v", err) + } + if err := pool.QueryRow(ctx, "SELECT balance FROM stars_balances WHERE user_id=$1", buyer.ID).Scan(&balance); err != nil || balance != 2500 { + t.Fatalf("balance after invalid submissions=%d err=%v", balance, err) + } +} + +func TestStarsGiveawayPurchaseAtomicChannelPTSReplayAndInfoPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + users := NewUserStore(pool) + suffix := randomSuffix(t) + owner, err := users.Create(ctx, domain.User{AccessHash: 94301, Phone: "+1665943" + suffix + "01", FirstName: "GiveawayOwner", CountryCode: "1"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{AccessHash: 94302, Phone: "+1665943" + suffix + "02", FirstName: "GiveawayMember", CountryCode: "1"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, Title: "Stars Giveaway " + suffix, Megagroup: true, + MemberUserIDs: []int64{member.ID}, Date: 1_700_000_000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID := created.Channel.ID + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM stars_giveaways WHERE buyer_user_id=$1", owner.ID) + _, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_commands WHERE buyer_user_id=$1", owner.ID) + _, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_forms WHERE buyer_user_id=$1", owner.ID) + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id=$1", channelID) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + before, err := channels.GetChannelByID(ctx, channelID) + if err != nil { + t.Fatal(err) + } + purpose := &domain.StarsGiveawayPurchase{ + BoostPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + CountriesISO2: []string{"US"}, RandomID: 9430001, UntilDate: 1_700_003_700, + Users: 2, PerUserStars: 500, YearlyBoosts: 4, WinnersAreVisible: true, + } + store := NewStarsPurchaseStore(pool, nil, channels) + issued, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{ + Kind: domain.StarsPurchaseGiveaway, BuyerUserID: owner.ID, Giveaway: purpose, + Stars: 1000, Currency: "USD", Amount: 99, IssuedAt: 1_700_000_100, ExpiresAt: 1_700_000_700, + }) + if err != nil || issued.FormID == 0 { + t.Fatalf("issue giveaway form=%+v err=%v", issued, err) + } + req := domain.StarsPurchaseRequest{StarsPurchaseForm: domain.StarsPurchaseForm{ + FormID: issued.FormID, Kind: domain.StarsPurchaseGiveaway, BuyerUserID: owner.ID, Giveaway: purpose, + Stars: 1000, Currency: "USD", Amount: 99, + }, Date: 1_700_000_200} + first, replay := purchaseStarsTwiceConcurrently(t, ctx, store, req) + if first.Duplicate || first.TransactionID == "" || first.ChannelSend.Event.PtsCount != 1 || + first.ChannelSend.Event.Pts != before.Pts+1 || first.ChannelSend.Message.Media == nil || + first.ChannelSend.Message.Media.Giveaway == nil { + t.Fatalf("first giveaway result=%+v before_pts=%d", first, before.Pts) + } + media := first.ChannelSend.Message.Media.Giveaway + if media.Stars != 1000 || media.Quantity != 2 || len(media.Channels) != 1 || media.Channels[0] != channelID || + media.UntilDate != purpose.UntilDate || !media.WinnersAreVisible { + t.Fatalf("giveaway media=%+v", media) + } + difference, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: member.ID, ChannelID: channelID, Pts: before.Pts, Limit: 10, + }) + if err != nil || difference.Pts != first.ChannelSend.Event.Pts || len(difference.Events) != 1 || len(difference.NewMessages) != 1 || + difference.NewMessages[0].Media == nil || difference.NewMessages[0].Media.Giveaway == nil || + difference.NewMessages[0].Media.Giveaway.Stars != 1000 { + t.Fatalf("giveaway channel difference=%+v err=%v", difference, err) + } + if !replay.Duplicate || replay.TransactionID != first.TransactionID || + replay.ChannelSend.Message.ID != first.ChannelSend.Message.ID || replay.ChannelSend.Event.Pts != first.ChannelSend.Event.Pts { + t.Fatalf("giveaway replay=%+v first=%+v", replay, first) + } + lateReplayReq := req + lateReplayReq.Date = purpose.UntilDate + lateReplay, err := store.PurchaseStars(ctx, lateReplayReq) + if err != nil || !lateReplay.Duplicate || lateReplay.TransactionID != first.TransactionID || + lateReplay.ChannelSend.Message.ID != first.ChannelSend.Message.ID || lateReplay.ChannelSend.Event.Pts != first.ChannelSend.Event.Pts { + t.Fatalf("giveaway replay after until_date=%+v err=%v first=%+v", lateReplay, err, first) + } + + latePurpose := *purpose + latePurpose.RandomID++ + lateForm, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{ + Kind: domain.StarsPurchaseGiveaway, BuyerUserID: owner.ID, Giveaway: &latePurpose, + Stars: 1000, Currency: "USD", Amount: 99, + IssuedAt: purpose.UntilDate - 100, ExpiresAt: purpose.UntilDate + 500, + }) + if err != nil { + t.Fatalf("issue giveaway form before until_date: %v", err) + } + lateFirstReq := req + lateFirstReq.FormID = lateForm.FormID + lateFirstReq.Giveaway = &latePurpose + lateFirstReq.Date = purpose.UntilDate + if _, err := store.PurchaseStars(ctx, lateFirstReq); !errors.Is(err, domain.ErrStarsPurchaseFormExpired) { + t.Fatalf("first giveaway settlement at until_date err=%v, want form expired", err) + } + var campaigns, commands, messages, events, balanceRows, txns int64 + queries := []struct { + query string + args []any + target *int64 + }{ + {"SELECT count(*) FROM stars_giveaways WHERE buyer_user_id=$1", []any{owner.ID}, &campaigns}, + {"SELECT count(*) FROM stars_purchase_commands WHERE buyer_user_id=$1", []any{owner.ID}, &commands}, + {"SELECT count(*) FROM channel_messages WHERE channel_id=$1 AND id=$2", []any{channelID, first.ChannelSend.Message.ID}, &messages}, + {"SELECT count(*) FROM channel_update_events WHERE channel_id=$1 AND pts=$2", []any{channelID, first.ChannelSend.Event.Pts}, &events}, + {"SELECT count(*) FROM stars_balances WHERE user_id=$1", []any{owner.ID}, &balanceRows}, + {"SELECT count(*) FROM stars_transactions WHERE user_id=$1", []any{owner.ID}, &txns}, + } + for _, item := range queries { + if err := pool.QueryRow(ctx, item.query, item.args...).Scan(item.target); err != nil { + t.Fatalf("footprint query %q: %v", item.query, err) + } + } + if campaigns != 1 || commands != 1 || messages != 1 || events != 1 || balanceRows != 0 || txns != 0 { + t.Fatalf("footprint campaigns=%d commands=%d messages=%d events=%d balances=%d txns=%d", campaigns, commands, messages, events, balanceRows, txns) + } + ownerInfo, err := store.GetStarsGiveawayInfo(ctx, owner.ID, channelID, first.ChannelSend.Message.ID, 1_700_000_300) + if err != nil || ownerInfo.AdminDisallowedChatID != channelID || ownerInfo.Participating { + t.Fatalf("owner giveaway info=%+v err=%v", ownerInfo, err) + } + memberInfo, err := store.GetStarsGiveawayInfo(ctx, member.ID, channelID, first.ChannelSend.Message.ID, 1_700_000_300) + if err != nil || !memberInfo.Participating || memberInfo.StartDate != req.Date { + t.Fatalf("member giveaway info=%+v err=%v", memberInfo, err) + } + preparing, err := store.GetStarsGiveawayInfo(ctx, member.ID, channelID, first.ChannelSend.Message.ID, purpose.UntilDate) + if err != nil || !preparing.PreparingResults || preparing.Participating { + t.Fatalf("preparing giveaway info=%+v err=%v", preparing, err) + } + tampered := req + changed := *purpose + changed.Users, changed.PerUserStars = 1, 1000 + tampered.Giveaway = &changed + if _, err := store.PurchaseStars(ctx, tampered); !errors.Is(err, domain.ErrStarsPurchaseFormInvalid) { + t.Fatalf("tampered giveaway replay err=%v", err) + } +} diff --git a/internal/store/stars.go b/internal/store/stars.go index 9ad9d84d..931ad800 100644 --- a/internal/store/stars.go +++ b/internal/store/stars.go @@ -23,10 +23,16 @@ type StarsStore interface { ListTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) } -// StarsGiftPurchaseStore owns the fiat gift aggregate. A successful purchase -// must commit the recipient balance/ledger and both account message-box events -// in the same transaction; exact form retries return the original receipt. -type StarsGiftPurchaseStore interface { - IssueStarsGiftPurchaseForm(context.Context, domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error) - PurchaseStarsGift(context.Context, domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error) +// StarsPurchaseStore owns fiat self-topup, friend-gift and giveaway-launch +// aggregates. Successful settlement commits the affected ledger/message box +// atomically; exact form retries return the original receipt. +type StarsPurchaseStore interface { + IssueStarsPurchaseForm(context.Context, domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) + PurchaseStars(context.Context, domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) +} + +// StarsGiveawayStore exposes the viewer-specific state of launch cards without +// forcing lightweight purchase-store fakes to implement the read model. +type StarsGiveawayStore interface { + GetStarsGiveawayInfo(context.Context, int64, int64, int, int) (domain.StarsGiveawayInfo, error) } diff --git a/internal/web/server.go b/internal/web/server.go index f3d19d22..a2f75cb7 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -173,6 +173,7 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) { } mux := http.NewServeMux() mux.HandleFunc("GET /healthz", h.healthz) + mux.HandleFunc("GET /payments/dev-stars", h.devStarsCheckout) mux.HandleFunc("GET /_public/avatar/{username}/{photoID}", h.publicAvatar) mux.HandleFunc("GET /addstickers/{shortName}", h.addStickers) mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji) @@ -229,6 +230,43 @@ type moderationAppealPage struct { CanSubmit bool } +type devStarsCheckoutPage struct { + AppName string + FormID string +} + +var devStarsCheckoutTemplate = template.Must(template.New("dev-stars-checkout").Parse(` + + +Dev Stars checkout · {{.AppName}}

Complete dev purchase

+

This is a local telesrv test checkout. No card, Google Play, App Store, or external payment provider will be charged.

+

The package and fiat amount shown by the client are bound to form {{.FormID}}.

+

+
`)) + +func (h *handler) devStarsCheckout(w http.ResponseWriter, r *http.Request) { + raw := strings.TrimSpace(r.URL.Query().Get("form_id")) + formID, err := strconv.ParseInt(raw, 10, 64) + if err != nil || formID == 0 || raw != strconv.FormatInt(formID, 10) { + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := devStarsCheckoutTemplate.Execute(w, devStarsCheckoutPage{AppName: h.appName, FormID: raw}); err != nil { + h.logger.Warn("render dev Stars checkout failed", zap.Error(err)) + } +} + var moderationAppealTemplate = template.Must(template.New("moderation-appeal").Parse(` Moderation appeal · {{.AppName}}