feat(stars): sync implement unified purchase flow
This commit is contained in:
parent
c13fbf8885
commit
7e0f9d1e62
26 changed files with 2087 additions and 282 deletions
|
|
@ -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,
|
||||
|
|
|
|||
41
deploy/migrations/0166_unified_stars_purchase.down.sql
Normal file
41
deploy/migrations/0166_unified_stars_purchase.down.sql
Normal file
|
|
@ -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;
|
||||
81
deploy/migrations/0166_unified_stars_purchase.up.sql
Normal file
81
deploy/migrations/0166_unified_stars_purchase.up.sql
Normal file
|
|
@ -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);
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
24
internal/compat/android/stars.go
Normal file
24
internal/compat/android/stars.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
30
internal/compat/android/stars_test.go
Normal file
30
internal/compat/android/stars_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}}},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{}}}},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{} {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(`<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer"><meta name="robots" content="noindex,nofollow">
|
||||
<title>Dev Stars checkout · {{.AppName}}</title><style>
|
||||
body{font:16px/1.5 system-ui,sans-serif;background:#f4f6f8;color:#17212b;margin:0;padding:24px}.card{max-width:520px;margin:9vh auto;background:#fff;border-radius:16px;padding:28px;box-shadow:0 8px 32px #0002}h1{margin-top:0}.note{color:#53606d}.status{min-height:24px;color:#b42318}button{width:100%;border:0;border-radius:10px;padding:13px 18px;background:#2481cc;color:#fff;font:inherit;font-weight:650;cursor:pointer}button:disabled{opacity:.55;cursor:default}
|
||||
</style></head><body><main class="card"><h1>Complete dev purchase</h1>
|
||||
<p>This is a local telesrv test checkout. No card, Google Play, App Store, or external payment provider will be charged.</p>
|
||||
<p class="note">The package and fiat amount shown by the client are bound to form {{.FormID}}.</p>
|
||||
<button id="complete" type="button">Complete test purchase</button><p id="status" class="status" role="status"></p>
|
||||
</main><script>
|
||||
(() => { const button=document.getElementById('complete'), status=document.getElementById('status');
|
||||
button.addEventListener('click', () => { const proxy=window.TelegramWebviewProxy;
|
||||
if(!proxy || typeof proxy.postEvent !== 'function'){status.textContent='Open this checkout inside Telegram.';return;}
|
||||
button.disabled=true;status.textContent='Submitting…';
|
||||
proxy.postEvent('payment_form_submit', JSON.stringify({title:'telesrv dev payment',credentials:{type:'telesrv_dev',form_id:'{{.FormID}}'}}));
|
||||
}); })();
|
||||
</script></body></html>`))
|
||||
|
||||
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(`<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer"><title>Moderation appeal · {{.AppName}}</title><style>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,32 @@ func newTestHandlerWithPublicPeers(
|
|||
return h
|
||||
}
|
||||
|
||||
func TestDevStarsCheckoutEmitsFormBoundTelegramCredentials(t *testing.T) {
|
||||
h := newTestHandler(t, fakeResolver{}, "https://links.example.test")
|
||||
for _, target := range []string{
|
||||
"/payments/dev-stars",
|
||||
"/payments/dev-stars?form_id=0",
|
||||
"/payments/dev-stars?form_id=01",
|
||||
"/payments/dev-stars?form_id=not-a-number",
|
||||
} {
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET %s status = %d, want 404", target, rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/payments/dev-stars?form_id=-70001", nil))
|
||||
body := rr.Body.String()
|
||||
if rr.Code != http.StatusOK || rr.Header().Get("Cache-Control") != "no-store" ||
|
||||
!strings.Contains(body, "payment_form_submit") ||
|
||||
!strings.Contains(body, "type:'telesrv_dev',form_id:'-70001'") ||
|
||||
!strings.Contains(body, "No card, Google Play, App Store, or external payment provider will be charged") {
|
||||
t.Fatalf("dev checkout status=%d headers=%v body=%q", rr.Code, rr.Header(), body)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeGiftWithdrawals struct {
|
||||
value domain.StarGiftWithdrawal
|
||||
found bool
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue