From c854fc7b9478420b5c07765b5a2fdc5e84f72030 Mon Sep 17 00:00:00 2001 From: iamxvbaba <28732408+iamxvbaba@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:51:02 +0800 Subject: [PATCH] feat: sync implement Stars friend gift packages --- cmd/telesrv/main.go | 5 +- .../0165_stars_friend_gift_purchase.down.sql | 2 + .../0165_stars_friend_gift_purchase.up.sql | 45 ++++ internal/app/stars/service.go | 28 +++ internal/domain/media.go | 16 ++ internal/domain/stars.go | 38 +++ internal/rpc/convert_messages.go | 15 ++ internal/rpc/convert_updates.go | 3 + internal/rpc/messages_send.go | 15 ++ internal/rpc/payments.go | 6 + internal/rpc/payments_star_gifts.go | 184 ++++++++++++++ .../rpc/payments_stars_friend_gift_test.go | 192 +++++++++++++++ .../store/postgres/stars_gift_purchase.go | 233 ++++++++++++++++++ .../stars_gift_purchase_integration_test.go | 125 ++++++++++ internal/store/stars.go | 8 + 15 files changed, 914 insertions(+), 1 deletion(-) create mode 100644 deploy/migrations/0165_stars_friend_gift_purchase.down.sql create mode 100644 deploy/migrations/0165_stars_friend_gift_purchase.up.sql create mode 100644 internal/rpc/payments_stars_friend_gift_test.go create mode 100644 internal/store/postgres/stars_gift_purchase.go create mode 100644 internal/store/postgres/stars_gift_purchase_integration_test.go diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 16eefe26..de12c529 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -916,7 +916,10 @@ func run(logger *zap.Logger) error { encryptedQueueStore := postgres.NewEncryptedQueueStore(pool) secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator) starsStore := postgres.NewStarsStore(pool) - starsService := stars.NewService(starsStore, stars.WithStartingGrant(cfg.StarsStartingGrant)) + starsGiftPurchaseStore := postgres.NewStarsGiftPurchaseStore(pool, messageStore) + starsService := stars.NewService(starsStore, + stars.WithStartingGrant(cfg.StarsStartingGrant), + stars.WithGiftPurchaseStore(starsGiftPurchaseStore)) starGiftStore := postgres.NewStarGiftStore(pool) starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore, postgres.WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{ TransferStars: cfg.StarGiftTransferStars, DropOriginalDetailsStars: cfg.StarGiftDropOriginalDetailsStars, diff --git a/deploy/migrations/0165_stars_friend_gift_purchase.down.sql b/deploy/migrations/0165_stars_friend_gift_purchase.down.sql new file mode 100644 index 00000000..8beff8c3 --- /dev/null +++ b/deploy/migrations/0165_stars_friend_gift_purchase.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS public.stars_gift_purchase_commands; +DROP TABLE IF EXISTS public.stars_gift_purchase_forms; diff --git a/deploy/migrations/0165_stars_friend_gift_purchase.up.sql b/deploy/migrations/0165_stars_friend_gift_purchase.up.sql new file mode 100644 index 00000000..f4f3e16f --- /dev/null +++ b/deploy/migrations/0165_stars_friend_gift_purchase.up.sql @@ -0,0 +1,45 @@ +CREATE TABLE public.stars_gift_purchase_forms ( + buyer_user_id bigint NOT NULL, + form_id bigint NOT NULL, + recipient_user_id bigint NOT NULL, + stars bigint NOT NULL, + currency text NOT NULL, + amount bigint NOT NULL, + issued_at integer NOT NULL, + expires_at integer NOT NULL, + CONSTRAINT stars_gift_purchase_forms_pkey PRIMARY KEY (buyer_user_id, form_id), + 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) +); + +CREATE INDEX stars_gift_purchase_forms_expiry_idx + ON public.stars_gift_purchase_forms (expires_at, buyer_user_id, form_id); + +CREATE TABLE public.stars_gift_purchase_commands ( + buyer_user_id bigint NOT NULL, + form_id bigint NOT NULL, + request_fingerprint bytea NOT NULL, + recipient_user_id bigint NOT NULL, + stars bigint NOT NULL, + currency text NOT NULL, + amount bigint NOT NULL, + recipient_balance_after bigint NOT NULL, + transaction_id text NOT NULL, + created_at integer NOT NULL, + CONSTRAINT stars_gift_purchase_commands_pkey PRIMARY KEY (buyer_user_id, form_id), + CONSTRAINT stars_gift_purchase_commands_form_fkey + FOREIGN KEY (buyer_user_id, form_id) + REFERENCES public.stars_gift_purchase_forms (buyer_user_id, form_id) + ON DELETE RESTRICT, + 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), + CONSTRAINT stars_gift_purchase_commands_transaction_id_key UNIQUE (transaction_id) +); diff --git a/internal/app/stars/service.go b/internal/app/stars/service.go index 5aa5686c..085f15ff 100644 --- a/internal/app/stars/service.go +++ b/internal/app/stars/service.go @@ -14,6 +14,7 @@ import ( // Service 是 Stars 账本应用服务。 type Service struct { store store.StarsStore + giftStore store.StarsGiftPurchaseStore grantAmount int64 now func() time.Time } @@ -26,6 +27,11 @@ 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 } +} + // WithClock 注入时钟(测试用)。 func WithClock(now func() time.Time) Option { return func(s *Service) { @@ -89,3 +95,25 @@ 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 + } + return s.giftStore.IssueStarsGiftPurchaseForm(ctx, form) +} + +// PurchaseGift 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 + } + return s.giftStore.PurchaseStarsGift(ctx, req) +} diff --git a/internal/domain/media.go b/internal/domain/media.go index 38d5531f..9af85ad0 100644 --- a/internal/domain/media.go +++ b/internal/domain/media.go @@ -570,6 +570,9 @@ const ( // MessageServiceActionStarGift 映射 messageActionStarGift:收到一份 Star 礼物。 // 礼物快照(贴纸/星价)内嵌在 action 里,收礼人无需额外拉取即可渲染气泡。 MessageServiceActionStarGift MessageServiceActionKind = "star_gift" + // MessageServiceActionGiftStars maps messageActionGiftStars: fiat-purchased + // Stars credited directly to a friend, distinct from collectible Star Gifts. + MessageServiceActionGiftStars MessageServiceActionKind = "gift_stars" // MessageServiceActionStarGiftUnique maps messageActionStarGiftUnique. The // immutable collectible snapshot is carried by the service message so an // exact replay/difference never depends on mutable catalog state. @@ -659,11 +662,24 @@ type MessageServiceAction struct { ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"` NoForwards *MessageNoForwardsAction `json:"no_forwards,omitempty"` StarGift *MessageStarGiftAction `json:"star_gift,omitempty"` + GiftStars *MessageGiftStarsAction `json:"gift_stars,omitempty"` StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"` StarGiftOffer *MessageStarGiftOfferAction `json:"star_gift_offer,omitempty"` StarGiftOfferDeclined *MessageStarGiftOfferDeclinedAction `json:"star_gift_offer_declined,omitempty"` } +// MessageGiftStarsAction is the immutable service-message projection. The +// recipient-only BalanceAfter field is not encoded in messageActionGiftStars; +// it lets online push and offline difference attach the matching non-PTS +// updateStarsBalance without querying mutable current state. +type MessageGiftStarsAction struct { + Currency string `json:"currency"` + Amount int64 `json:"amount"` + Stars int64 `json:"stars"` + TransactionID string `json:"transaction_id,omitempty"` + BalanceAfter int64 `json:"balance_after"` +} + // MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价) // 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方;NameHidden 时下发不暴露 from。 type MessageStarGiftAction struct { diff --git a/internal/domain/stars.go b/internal/domain/stars.go index 2fbdb19e..64b71a80 100644 --- a/internal/domain/stars.go +++ b/internal/domain/stars.go @@ -17,6 +17,38 @@ 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 +} + +// StarsGiftPurchaseRequest is the immutable settlement command carried by +// inputInvoiceStars(inputStorePaymentStarsGift). +type StarsGiftPurchaseRequest struct { + StarsGiftPurchaseForm + 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 +} + // StarsTransactionReason 标记一条流水的语义(投影到 tg.StarsTransaction 的标志位/标题)。 type StarsTransactionReason string @@ -152,6 +184,12 @@ 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") + // ErrStarsGiftUnavailable covers a recipient that cannot receive the gift. + ErrStarsGiftUnavailable = errors.New("stars: gift unavailable") ) // StarsPaymentRequiredError reports the minimum paid-message authorization the diff --git a/internal/rpc/convert_messages.go b/internal/rpc/convert_messages.go index 40f9f979..9b7b85fb 100644 --- a/internal/rpc/convert_messages.go +++ b/internal/rpc/convert_messages.go @@ -244,6 +244,21 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass { } case domain.MessageServiceActionStarGift: return tgMessageActionStarGiftForViewer(m.ServiceAction.StarGift, msg.OwnerUserID) + case domain.MessageServiceActionGiftStars: + action := m.ServiceAction.GiftStars + if action == nil || action.Currency == "" || action.Amount <= 0 || action.Stars <= 0 { + return &tg.MessageActionEmpty{} + } + out := &tg.MessageActionGiftStars{ + Currency: action.Currency, + Amount: action.Amount, + Stars: action.Stars, + } + // Telegram only exposes the provider transaction id to the receiver. + if !msg.Out && action.TransactionID != "" { + out.SetTransactionID(action.TransactionID) + } + return out case domain.MessageServiceActionStarGiftUnique: return tgMessageActionStarGiftUnique(m.ServiceAction.StarGiftUnique) case domain.MessageServiceActionStarGiftOffer: diff --git a/internal/rpc/convert_updates.go b/internal/rpc/convert_updates.go index fb1915e4..b42ceb45 100644 --- a/internal/rpc/convert_updates.go +++ b/internal/rpc/convert_updates.go @@ -27,6 +27,9 @@ func tgUpdatesDifference(viewerUserID int64, diff domain.UpdateDifference) tg.Up out.NewMessages = append(out.NewMessages, msg) addMessageUsers(out, seenUsers, event.Message) } + if balance := tgGiftStarsBalanceUpdate(event.Message); balance != nil { + out.OtherUpdates = append(out.OtherUpdates, balance) + } case domain.UpdateEventReadHistoryInbox: if update := tgReadHistoryInboxUpdate(event); update != nil { out.OtherUpdates = append(out.OtherUpdates, update) diff --git a/internal/rpc/messages_send.go b/internal/rpc/messages_send.go index 2eb8f30d..bc21c4c6 100644 --- a/internal/rpc/messages_send.go +++ b/internal/rpc/messages_send.go @@ -507,6 +507,9 @@ func tgPrivateMessageUpdates(event domain.UpdateEvent, msg domain.Message, rando Pts: event.Pts, PtsCount: event.PtsCount, }) + if balance := tgGiftStarsBalanceUpdate(msg); balance != nil { + updates = append(updates, balance) + } date := event.Date if date == 0 { date = msg.Date @@ -520,6 +523,18 @@ func tgPrivateMessageUpdates(event domain.UpdateEvent, msg domain.Message, rando } } +func tgGiftStarsBalanceUpdate(msg domain.Message) tg.UpdateClass { + if msg.Out || msg.Media == nil || msg.Media.ServiceAction == nil || + msg.Media.ServiceAction.Kind != domain.MessageServiceActionGiftStars { + return nil + } + action := msg.Media.ServiceAction.GiftStars + if action == nil || action.BalanceAfter < 0 { + return nil + } + return &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: action.BalanceAfter}} +} + // tgPrivateSendResultUpdates returns a complete send acknowledgement for exact // random_id replays. DrKLO requires UpdateNewMessage in an Updates response to // transition its local pending message to SENT. Visible edited messages use the diff --git a/internal/rpc/payments.go b/internal/rpc/payments.go index f0e93de4..cac0acdf 100644 --- a/internal/rpc/payments.go +++ b/internal/rpc/payments.go @@ -15,6 +15,9 @@ import ( // registerPayments 注册 payments.* RPC:Stars 本地账本(余额/流水真实化)+ 其余 // gift/auction/revenue 第一阶段兼容桩。 func (r *Router) registerPayments(d *tlprofile.Dispatcher) { + registerRPC[*tg.PaymentsGetStarsGiftOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsGiftOptions, func(ctx context.Context, req *tg.PaymentsGetStarsGiftOptionsRequest) (any, error) { + return r.onPaymentsGetStarsGiftOptions(ctx, req) + }) registerRPC[*tg.PaymentsGetStarsTopupOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTopupOptions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTopupOptionsRequest) (any, // premium 订阅赠送 telesrv 不实现(无支付流),返回空选项。关键作用:TDesktop 送礼框 @@ -66,6 +69,9 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) { registerRPC[*tg.PaymentsSendStarsFormRequest](d, tlprofile.SemanticMethodPaymentsSendStarsForm, func(ctx context.Context, layerRequest *tg.PaymentsSendStarsFormRequest) (any, error) { return r.onPaymentsSendStarsForm(ctx, layerRequest) }) + registerRPC[*tg.PaymentsSendPaymentFormRequest](d, tlprofile.SemanticMethodPaymentsSendPaymentForm, func(ctx context.Context, req *tg.PaymentsSendPaymentFormRequest) (any, error) { + return r.onPaymentsSendPaymentForm(ctx, req) + }) registerRPC[*tg.PaymentsGetSavedStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetSavedStarGifts, func(ctx context.Context, layerRequest *tg.PaymentsGetSavedStarGiftsRequest) (any, error) { return r.onPaymentsGetSavedStarGifts(ctx, layerRequest) }) diff --git a/internal/rpc/payments_star_gifts.go b/internal/rpc/payments_star_gifts.go index 749f88c5..cebd3bee 100644 --- a/internal/rpc/payments_star_gifts.go +++ b/internal/rpc/payments_star_gifts.go @@ -30,6 +30,82 @@ func devStarsTopupOptions() []tg.StarsTopupOption { } } +func devStarsGiftOptions() []tg.StarsGiftOption { + // No store_product is advertised: telesrv has no Google/Apple product and + // sideloaded Android clients must use the invoice checkout path. + return []tg.StarsGiftOption{ + {Stars: 1000, Currency: "USD", Amount: 99}, + {Stars: 2500, Currency: "USD", Amount: 199}, + {Stars: 5000, Currency: "USD", Amount: 399}, + } +} + +type starsGiftPurchaseService interface { + IssueGiftPurchaseForm(context.Context, domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error) + PurchaseGift(context.Context, domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error) +} + +func userGiftUnavailableErr() error { return tgerr.New(400, "USER_GIFT_UNAVAILABLE") } + +func (r *Router) onPaymentsGetStarsGiftOptions(ctx context.Context, req *tg.PaymentsGetStarsGiftOptionsRequest) ([]tg.StarsGiftOption, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req != nil { + if input, ok := req.GetUserID(); ok { + if _, err := r.starsGiftRecipient(ctx, userID, input); err != nil { + return nil, err + } + } + } + return devStarsGiftOptions(), nil +} + +func (r *Router) starsGiftRecipient(ctx context.Context, buyerUserID int64, input tg.InputUserClass) (domain.User, error) { + if inputUserIsEmpty(input) || r.deps.Users == nil { + return domain.User{}, userIDInvalidErr() + } + recipient, found, err := r.userFromInput(ctx, buyerUserID, input) + if err != nil { + return domain.User{}, internalErr() + } + if !found || recipient.ID <= 0 || recipient.Deleted { + return domain.User{}, userIDInvalidErr() + } + if recipient.ID == buyerUserID { + return domain.User{}, userGiftUnavailableErr() + } + blocked, err := r.peerBlocksUser(ctx, buyerUserID, recipient.ID) + if err != nil { + return domain.User{}, err + } + if blocked { + return domain.User{}, userGiftUnavailableErr() + } + return recipient, nil +} + +func validateStarsGiftOption(purpose *tg.InputStorePaymentStarsGift) (tg.StarsGiftOption, error) { + if purpose == nil || purpose.UserID == nil || purpose.Stars <= 0 || purpose.Amount <= 0 || purpose.Currency == "" { + return tg.StarsGiftOption{}, starsAmountInvalidErr() + } + for _, option := range devStarsGiftOptions() { + if option.Stars == purpose.Stars && option.Currency == purpose.Currency && option.Amount == purpose.Amount { + return option, nil + } + } + return tg.StarsGiftOption{}, starsFormAmountMismatchErr() +} + +func starsGiftPurpose(inv *tg.InputInvoiceStars) (*tg.InputStorePaymentStarsGift, bool) { + if inv == nil { + return nil, false + } + purpose, ok := inv.Purpose.(*tg.InputStorePaymentStarsGift) + return purpose, ok && purpose != nil +} + // onPaymentsGetStarGifts 返回可购买礼物目录(hash 命中返回 NotModified)。 func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.PaymentsStarGiftsClass, error) { if r.deps.Gifts == nil { @@ -71,6 +147,9 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG } if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok { + if purpose, gift := starsGiftPurpose(inv); gift { + return r.starsGiftPaymentForm(ctx, userID, purpose) + } purpose, ok := starsTopupPurpose(inv) if !ok { return nil, notImplementedErr() @@ -149,6 +228,36 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG }, 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 + } + recipient, err := r.starsGiftRecipient(ctx, buyerUserID, purpose.UserID) + if err != nil { + return nil, err + } + service, ok := r.deps.Stars.(starsGiftPurchaseService) + if !ok { + return nil, notImplementedErr() + } + now := int(r.clock.Now().Unix()) + form, err := service.IssueGiftPurchaseForm(ctx, domain.StarsGiftPurchaseForm{ + 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) + } + 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 +} + // onPaymentsSendStarsForm 处理 star gift 与 Stars topup: // - star gift: Debit→投递/记账,失败补偿退款。 // - topup: 校验测试包白名单→Credit 本地账本。 @@ -165,6 +274,9 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe } 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 inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok { @@ -266,6 +378,78 @@ 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. +func (r *Router) onPaymentsSendPaymentForm(ctx context.Context, req *tg.PaymentsSendPaymentFormRequest) (tg.PaymentsPaymentResultClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + inv, ok := req.Invoice.(*tg.InputInvoiceStars) + if !ok { + return nil, notImplementedErr() + } + purpose, ok := starsGiftPurpose(inv) + if !ok { + return nil, notImplementedErr() + } + if req.Credentials == nil { + return nil, tgerr.New(400, "PAYMENT_CREDENTIALS_INVALID") + } + if req.TipAmount != 0 { + return nil, tgerr.New(400, "TIP_AMOUNT_INVALID") + } + return r.sendStarsGiftPurchase(ctx, userID, req.FormID, purpose) +} + +func (r *Router) sendStarsGiftPurchase(ctx context.Context, buyerUserID, formID int64, purpose *tg.InputStorePaymentStarsGift) (tg.PaymentsPaymentResultClass, error) { + if formID == 0 { + return nil, formIDEmptyErr() + } + if _, err := validateStarsGiftOption(purpose); err != nil { + return nil, err + } + recipient, err := r.starsGiftRecipient(ctx, buyerUserID, purpose.UserID) + if err != nil { + return nil, err + } + service, ok := r.deps.Stars.(starsGiftPurchaseService) + if !ok { + return nil, notImplementedErr() + } + result, err := service.PurchaseGift(ctx, domain.StarsGiftPurchaseRequest{ + StarsGiftPurchaseForm: domain.StarsGiftPurchaseForm{ + FormID: formID, 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) + } + updates := r.starGiftSendUpdates(ctx, buyerUserID, result.Send) + return &tg.PaymentsPaymentResult{Updates: updates}, nil +} + +func starsGiftPurchaseErr(err error) error { + switch { + case errors.Is(err, domain.ErrStarsGiftFormExpired): + return tgerr.New(400, "FORM_EXPIRED") + case errors.Is(err, domain.ErrStarsGiftFormInvalid): + return starsFormAmountMismatchErr() + case errors.Is(err, domain.ErrStarsGiftUnavailable): + return userGiftUnavailableErr() + default: + return internalErr() + } +} + func (r *Router) sendStarGiftMemoryPurchase(ctx context.Context, userID int64, peer domain.Peer, gift domain.StarGift, inv *tg.InputInvoiceStarGift, giftMessage string, upgradeStars int64) (tg.PaymentsPaymentResultClass, error) { purchaseStars := gift.Stars + upgradeStars diff --git a/internal/rpc/payments_stars_friend_gift_test.go b/internal/rpc/payments_stars_friend_gift_test.go new file mode 100644 index 00000000..bd6c44f1 --- /dev/null +++ b/internal/rpc/payments_stars_friend_gift_test.go @@ -0,0 +1,192 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/iamxvbaba/td/clock" + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tgerr" + "go.uber.org/zap/zaptest" + + appstars "telesrv/internal/app/stars" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +type starsFriendGiftRPCStore struct { + *memory.StarsStore + issued domain.StarsGiftPurchaseForm + purchased domain.StarsGiftPurchaseRequest + purchases int +} + +func (s *starsFriendGiftRPCStore) IssueStarsGiftPurchaseForm(_ context.Context, form domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error) { + form.FormID = 70001 + s.issued = form + return form, nil +} + +func (s *starsFriendGiftRPCStore) PurchaseStarsGift(_ context.Context, req domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error) { + s.purchased = req + s.purchases++ + action := &domain.MessageServiceAction{Kind: domain.MessageServiceActionGiftStars, GiftStars: &domain.MessageGiftStarsAction{ + Currency: req.Currency, Amount: req.Amount, Stars: req.Stars, + TransactionID: "stars-gift-test", BalanceAfter: 4321, + }} + sender := domain.Message{ID: 11, OwnerUserID: req.BuyerUserID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.BuyerUserID}, Out: true, Date: req.Date, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}} + 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", + 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}, + RecipientEvent: domain.UpdateEvent{UserID: req.RecipientUserID, Type: domain.UpdateEventNewMessage, Pts: 9, PtsCount: 1, Date: req.Date, Message: recipient}, + }, + }, nil +} + +func starsFriendGiftTestRouter(t *testing.T) (*Router, *starsFriendGiftRPCStore, domain.User, domain.User) { + t.Helper() + ctx := context.Background() + users := memory.NewUserStore() + buyer, err := users.Create(ctx, domain.User{AccessHash: 8101, Phone: "+15558101", FirstName: "Buyer"}) + if err != nil { + t.Fatal(err) + } + recipient, err := users.Create(ctx, domain.User{AccessHash: 8102, Phone: "+15558102", FirstName: "Recipient"}) + if err != nil { + t.Fatal(err) + } + st := &starsFriendGiftRPCStore{StarsStore: memory.NewStarsStore()} + r := New(Config{DC: 2}, Deps{ + Users: appusers.NewService(users), + Stars: appstars.NewService(st, appstars.WithStartingGrant(0), appstars.WithGiftPurchaseStore(st)), + }, zaptest.NewLogger(t), clock.System) + return r, st, buyer, recipient +} + +func TestStarsFriendGiftOptionsFormAndBothSettlementMethods(t *testing.T) { + r, st, buyer, recipient := starsFriendGiftTestRouter(t) + ctx := WithUserID(context.Background(), buyer.ID) + + generic, err := r.onPaymentsGetStarsGiftOptions(ctx, &tg.PaymentsGetStarsGiftOptionsRequest{}) + if err != nil || len(generic) != 3 || generic[0].Stars != 1000 || generic[0].Currency != "USD" || generic[0].Amount != 99 { + t.Fatalf("generic gift options = %+v err=%v", generic, err) + } + input := &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash} + personalReq := &tg.PaymentsGetStarsGiftOptionsRequest{} + personalReq.SetUserID(input) + personal, err := r.onPaymentsGetStarsGiftOptions(ctx, personalReq) + if err != nil || len(personal) != len(generic) { + t.Fatalf("personal gift options = %+v err=%v", personal, err) + } + + purpose := &tg.InputStorePaymentStarsGift{UserID: input, Stars: 2500, Currency: "USD", Amount: 199} + invoice := &tg.InputInvoiceStars{Purpose: purpose} + formClass, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice}) + 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 { + 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 { + t.Fatalf("issued form = %+v", st.issued) + } + + resultClass, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: invoice}) + if err != nil { + t.Fatalf("sendStarsForm gift: %v", err) + } + result, ok := resultClass.(*tg.PaymentsPaymentResult) + if !ok { + t.Fatalf("sendStarsForm result = %T", resultClass) + } + updates, ok := result.Updates.(*tg.Updates) + if !ok || len(updates.Updates) != 1 { + t.Fatalf("sender updates = %T %+v", result.Updates, result.Updates) + } + newMessage, ok := updates.Updates[0].(*tg.UpdateNewMessage) + if !ok || newMessage.Pts != 5 || newMessage.PtsCount != 1 { + t.Fatalf("sender new message = %T %+v", updates.Updates[0], updates.Updates[0]) + } + serviceMessage, ok := newMessage.Message.(*tg.MessageService) + if !ok { + t.Fatalf("gift message = %T", newMessage.Message) + } + action, ok := serviceMessage.Action.(*tg.MessageActionGiftStars) + if !ok || action.Stars != 2500 || action.Currency != "USD" || action.Amount != 199 || action.TransactionID != "" { + t.Fatalf("sender gift action = %T %+v", serviceMessage.Action, serviceMessage.Action) + } + if st.purchased.FormID != form.FormID || st.purchased.RecipientUserID != recipient.ID || st.purchases != 1 { + 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 != 2 { + t.Fatalf("settlement method count = %d, want 2 fake invocations", st.purchases) + } +} + +func TestStarsFriendGiftRejectsInvalidRecipientAndPackageBeforeStore(t *testing.T) { + r, st, buyer, recipient := starsFriendGiftTestRouter(t) + ctx := WithUserID(context.Background(), buyer.ID) + + badRecipientReq := &tg.PaymentsGetStarsGiftOptionsRequest{} + badRecipientReq.SetUserID(&tg.InputUser{UserID: recipient.ID + 99999}) + if _, err := r.onPaymentsGetStarsGiftOptions(ctx, badRecipientReq); !tgerr.Is(err, "USER_ID_INVALID") { + t.Fatalf("bad recipient err = %v", err) + } + bad := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsGift{ + UserID: &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, + Stars: 2500, Currency: "USD", Amount: 200, + }} + 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") { + t.Fatalf("tampered package settle err = %v", err) + } + if st.issued.FormID != 0 || st.purchases != 0 { + t.Fatalf("invalid request reached store: issued=%+v purchases=%d", st.issued, st.purchases) + } +} + +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, + }} + msg := domain.Message{ID: 4, OwnerUserID: 2, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, Date: 1700000000, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}} + event := domain.UpdateEvent{UserID: 2, Type: domain.UpdateEventNewMessage, Pts: 8, PtsCount: 1, Date: msg.Date, Message: msg} + online := tgPrivateMessageUpdates(event, msg, 0, false, nil, nil) + if len(online.Updates) != 2 { + t.Fatalf("online updates = %+v", online.Updates) + } + balance, ok := online.Updates[1].(*tg.UpdateStarsBalance) + if !ok || balance.Balance.(*tg.StarsAmount).Amount != 3100 { + t.Fatalf("online balance = %T %+v", online.Updates[1], online.Updates[1]) + } + diff := tgUpdatesDifference(2, domain.UpdateDifference{Events: []domain.UpdateEvent{event}, State: domain.UpdateState{Pts: 8}}) + full, ok := diff.(*tg.UpdatesDifference) + if !ok || len(full.NewMessages) != 1 || len(full.OtherUpdates) != 1 { + t.Fatalf("difference = %T %+v", diff, diff) + } + if _, ok := full.OtherUpdates[0].(*tg.UpdateStarsBalance); !ok { + t.Fatalf("difference balance = %T", full.OtherUpdates[0]) + } +} diff --git a/internal/store/postgres/stars_gift_purchase.go b/internal/store/postgres/stars_gift_purchase.go new file mode 100644 index 00000000..345abd9b --- /dev/null +++ b/internal/store/postgres/stars_gift_purchase.go @@ -0,0 +1,233 @@ +package postgres + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "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 { + db sqlcgen.DBTX + messages *MessageStore +} + +func NewStarsGiftPurchaseStore(db sqlcgen.DBTX, messages *MessageStore) *StarsGiftPurchaseStore { + return &StarsGiftPurchaseStore{db: db, messages: messages} +} + +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 + } + for attempt := 0; attempt < 8; attempt++ { + formID, err := newStarsGiftFormID() + if err != nil { + return domain.StarsGiftPurchaseForm{}, fmt.Errorf("generate stars gift 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) + if err != nil { + return domain.StarsGiftPurchaseForm{}, fmt.Errorf("insert stars gift form: %w", err) + } + if tag.RowsAffected() == 1 { + form.FormID = formID + return form, nil + } + } + return domain.StarsGiftPurchaseForm{}, domain.ErrStarsGiftUnavailable +} + +var errStarsGiftPurchaseReplay = errors.New("stars gift 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 + } + fingerprint := starsGiftPurchaseFingerprint(req) + if replay, found, err := s.loadStarsGiftPurchaseReplay(ctx, req, fingerprint); err != nil || found { + return replay, err + } + + transactionID := fmt.Sprintf("stars-gift:%d:%d", req.BuyerUserID, req.FormID) + randomID := lifecycleCommandRandomID("stars-fiat-gift", req.BuyerUserID, req.FormID) + media := &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionGiftStars, + GiftStars: &domain.MessageGiftStarsAction{Currency: req.Currency, Amount: req.Amount, + Stars: req.Stars, TransactionID: transactionID}, + }} + messageReq := domain.SendPrivateTextRequest{ + SenderUserID: req.BuyerUserID, RecipientUserID: req.RecipientUserID, + RandomID: randomID, Date: req.Date, Media: media, + OriginUserID: req.BuyerUserID, OriginAuthKeyID: req.OriginAuthKeyID, + OriginSessionID: req.OriginSessionID, IdempotencyFingerprint: fingerprint[:], + } + result := domain.StarsGiftPurchaseResult{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 { + return err + } + if found, err := starsGiftPurchaseCommandExists(ctx, tx, req.BuyerUserID, req.FormID); err != nil { + return err + } else if found { + return errStarsGiftPurchaseReplay + } + balance := domain.StarsBalance{UserID: req.RecipientUserID} + 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.RecipientUserID, req.Stars).Scan(&balance.Balance, &balance.Granted); err != nil { + return fmt.Errorf("credit stars gift recipient: %w", err) + } + if err := insertStarsTxn(ctx, tx, req.RecipientUserID, req.Stars, domain.StarsReasonGift, + domain.Peer{Type: domain.PeerTypeUser, ID: req.BuyerUserID}, req.Date, + "Stars gift", fmt.Sprintf("%d Stars", req.Stars)); err != nil { + return err + } + result.RecipientBalance = balance + if send.Media == nil || send.Media.ServiceAction == nil || send.Media.ServiceAction.GiftStars == nil { + return domain.ErrStarsGiftFormInvalid + } + 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) + if err != nil { + return fmt.Errorf("insert stars gift purchase command: %w", err) + } + result.Send = sent + return nil + }, + } + 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 { + return replay, replayErr + } + } + return domain.StarsGiftPurchaseResult{}, 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` + if lock { + query += ` FOR UPDATE` + } + var recipientID, 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) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ErrStarsGiftFormInvalid + } + if err != nil { + return fmt.Errorf("load stars gift form: %w", err) + } + if req.Date >= expiresAt { + return domain.ErrStarsGiftFormExpired + } + if issuedAt <= 0 || recipientID != req.RecipientUserID || stars != req.Stars || + currency != req.Currency || amount != req.Amount { + return domain.ErrStarsGiftFormInvalid + } + 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 + 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) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarsGiftPurchaseResult{}, false, nil + } + if err != nil { + return domain.StarsGiftPurchaseResult{}, false, fmt.Errorf("load stars gift 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 + } + sent, found, err := s.messages.LookupPrivateSendReplay(ctx, domain.PrivateSendReplayRequest{ + SenderUserID: req.BuyerUserID, RecipientUserID: req.RecipientUserID, + RandomID: lifecycleCommandRandomID("stars-fiat-gift", req.BuyerUserID, req.FormID), + IdempotencyFingerprint: fingerprint[:], + }) + if err != nil { + return domain.StarsGiftPurchaseResult{}, false, err + } + if !found { + return domain.StarsGiftPurchaseResult{}, false, domain.ErrStarsGiftFormInvalid + } + return domain.StarsGiftPurchaseResult{ + RecipientBalance: domain.StarsBalance{UserID: req.RecipientUserID, Balance: balance}, + Send: sent, TransactionID: transactionID, Duplicate: true, + }, true, nil +} + +func starsGiftPurchaseCommandExists(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) + } + 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 newStarsGiftFormID() (int64, error) { + var raw [8]byte + if _, err := rand.Read(raw[:]); err != nil { + return 0, err + } + id := int64(binary.LittleEndian.Uint64(raw[:]) & 0x7fffffffffffffff) + if id == 0 { + return 1, nil + } + return id, nil +} + +var _ store.StarsGiftPurchaseStore = (*StarsGiftPurchaseStore)(nil) diff --git a/internal/store/postgres/stars_gift_purchase_integration_test.go b/internal/store/postgres/stars_gift_purchase_integration_test.go new file mode 100644 index 00000000..f910e556 --- /dev/null +++ b/internal/store/postgres/stars_gift_purchase_integration_test.go @@ -0,0 +1,125 @@ +package postgres + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +func TestStarsFriendGiftPurchaseAtomicReplayAndValidationPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + users := NewUserStore(pool) + suffix := randomSuffix(t) + buyer, err := users.Create(ctx, domain.User{AccessHash: 94101, Phone: "+1665941" + suffix + "01", FirstName: "GiftBuyer"}) + if err != nil { + t.Fatalf("create buyer: %v", err) + } + recipient, err := users.Create(ctx, domain.User{AccessHash: 94102, Phone: "+1665941" + suffix + "02", FirstName: "GiftRecipient"}) + if err != nil { + 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_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, + 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) + } + var origin [8]byte + origin[0] = 9 + req := domain.StarsGiftPurchaseRequest{ + StarsGiftPurchaseForm: domain.StarsGiftPurchaseForm{ + FormID: issued.FormID, 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.Send.SenderEvent.PtsCount != 1 || first.Send.RecipientEvent.PtsCount != 1 { + t.Fatalf("first purchase = %+v", first) + } + if first.Send.SenderMessage.Pts <= 0 || first.Send.RecipientMessage.Pts <= 0 || + first.Send.SenderMessage.UID == 0 || first.Send.SenderMessage.UID != first.Send.RecipientMessage.UID { + t.Fatalf("bilateral send = %+v", first.Send) + } + action := first.Send.RecipientMessage.Media.ServiceAction.GiftStars + if action == nil || action.Stars != 2500 || action.Currency != "USD" || action.Amount != 199 || + action.TransactionID != first.TransactionID || action.BalanceAfter != 2500 { + 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) + } + + var balance, txnCount, commandCount int64 + if err := pool.QueryRow(ctx, "SELECT balance FROM stars_balances WHERE user_id=$1", recipient.ID).Scan(&balance); err != nil { + t.Fatalf("load recipient balance: %v", err) + } + 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 { + t.Fatal(err) + } + if balance != 2500 || txnCount != 1 || commandCount != 1 { + t.Fatalf("replay footprint balance=%d txns=%d commands=%d", balance, txnCount, commandCount) + } + for _, userID := range []int64{buyer.ID, recipient.ID} { + var eventCount, outboxCount int64 + if err := pool.QueryRow(ctx, "SELECT count(*) FROM user_update_events WHERE user_id=$1", userID).Scan(&eventCount); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, "SELECT count(*) FROM dispatch_outbox WHERE target_user_id=$1", userID).Scan(&outboxCount); err != nil { + t.Fatal(err) + } + if eventCount != 1 || outboxCount != 1 { + t.Fatalf("user %d event/outbox=%d/%d, want 1/1", userID, eventCount, outboxCount) + } + } + + tampered := req + tampered.Amount++ + if _, err := store.PurchaseStarsGift(ctx, tampered); !errors.Is(err, domain.ErrStarsGiftFormInvalid) { + t.Fatalf("tampered replay err=%v", err) + } + expired, err := store.IssueStarsGiftPurchaseForm(ctx, domain.StarsGiftPurchaseForm{ + BuyerUserID: buyer.ID, RecipientUserID: recipient.ID, + Stars: 1000, Currency: "USD", Amount: 99, + IssuedAt: 1_699_999_000, ExpiresAt: 1_699_999_600, + }) + if err != nil { + t.Fatalf("issue expired form: %v", err) + } + expiredReq := req + expiredReq.FormID, expiredReq.Stars, expiredReq.Amount = expired.FormID, 1000, 99 + if _, err := store.PurchaseStarsGift(ctx, expiredReq); !errors.Is(err, domain.ErrStarsGiftFormExpired) { + 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) + } +} diff --git a/internal/store/stars.go b/internal/store/stars.go index e92ee98d..9ad9d84d 100644 --- a/internal/store/stars.go +++ b/internal/store/stars.go @@ -22,3 +22,11 @@ type StarsStore interface { // ListTransactions 按方向与顺序做 keyset 分页,返回一页流水 + 当前余额。 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) +}