feat: sync implement Stars friend gift packages
This commit is contained in:
parent
3a123f38db
commit
c854fc7b94
15 changed files with 914 additions and 1 deletions
|
|
@ -916,7 +916,10 @@ func run(logger *zap.Logger) error {
|
||||||
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
|
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
|
||||||
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator)
|
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator)
|
||||||
starsStore := postgres.NewStarsStore(pool)
|
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)
|
starGiftStore := postgres.NewStarGiftStore(pool)
|
||||||
starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore, postgres.WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
|
starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore, postgres.WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
|
||||||
TransferStars: cfg.StarGiftTransferStars, DropOriginalDetailsStars: cfg.StarGiftDropOriginalDetailsStars,
|
TransferStars: cfg.StarGiftTransferStars, DropOriginalDetailsStars: cfg.StarGiftDropOriginalDetailsStars,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP TABLE IF EXISTS public.stars_gift_purchase_commands;
|
||||||
|
DROP TABLE IF EXISTS public.stars_gift_purchase_forms;
|
||||||
45
deploy/migrations/0165_stars_friend_gift_purchase.up.sql
Normal file
45
deploy/migrations/0165_stars_friend_gift_purchase.up.sql
Normal file
|
|
@ -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)
|
||||||
|
);
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
// Service 是 Stars 账本应用服务。
|
// Service 是 Stars 账本应用服务。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store store.StarsStore
|
store store.StarsStore
|
||||||
|
giftStore store.StarsGiftPurchaseStore
|
||||||
grantAmount int64
|
grantAmount int64
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
@ -26,6 +27,11 @@ func WithStartingGrant(amount int64) Option {
|
||||||
return func(s *Service) { s.grantAmount = amount }
|
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 注入时钟(测试用)。
|
// WithClock 注入时钟(测试用)。
|
||||||
func WithClock(now func() time.Time) Option {
|
func WithClock(now func() time.Time) Option {
|
||||||
return func(s *Service) {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -570,6 +570,9 @@ const (
|
||||||
// MessageServiceActionStarGift 映射 messageActionStarGift:收到一份 Star 礼物。
|
// MessageServiceActionStarGift 映射 messageActionStarGift:收到一份 Star 礼物。
|
||||||
// 礼物快照(贴纸/星价)内嵌在 action 里,收礼人无需额外拉取即可渲染气泡。
|
// 礼物快照(贴纸/星价)内嵌在 action 里,收礼人无需额外拉取即可渲染气泡。
|
||||||
MessageServiceActionStarGift MessageServiceActionKind = "star_gift"
|
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
|
// MessageServiceActionStarGiftUnique maps messageActionStarGiftUnique. The
|
||||||
// immutable collectible snapshot is carried by the service message so an
|
// immutable collectible snapshot is carried by the service message so an
|
||||||
// exact replay/difference never depends on mutable catalog state.
|
// exact replay/difference never depends on mutable catalog state.
|
||||||
|
|
@ -659,11 +662,24 @@ type MessageServiceAction struct {
|
||||||
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
|
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
|
||||||
NoForwards *MessageNoForwardsAction `json:"no_forwards,omitempty"`
|
NoForwards *MessageNoForwardsAction `json:"no_forwards,omitempty"`
|
||||||
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
|
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
|
||||||
|
GiftStars *MessageGiftStarsAction `json:"gift_stars,omitempty"`
|
||||||
StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"`
|
StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"`
|
||||||
StarGiftOffer *MessageStarGiftOfferAction `json:"star_gift_offer,omitempty"`
|
StarGiftOffer *MessageStarGiftOfferAction `json:"star_gift_offer,omitempty"`
|
||||||
StarGiftOfferDeclined *MessageStarGiftOfferDeclinedAction `json:"star_gift_offer_declined,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 的协议中立载荷:内嵌礼物快照(贴纸/星价)
|
// MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价)
|
||||||
// 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方;NameHidden 时下发不暴露 from。
|
// 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方;NameHidden 时下发不暴露 from。
|
||||||
type MessageStarGiftAction struct {
|
type MessageStarGiftAction struct {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,38 @@ type StarsBalance struct {
|
||||||
Granted bool // 起始授予是否已应用(惰性首读授予的幂等守卫)
|
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 的标志位/标题)。
|
// StarsTransactionReason 标记一条流水的语义(投影到 tg.StarsTransaction 的标志位/标题)。
|
||||||
type StarsTransactionReason string
|
type StarsTransactionReason string
|
||||||
|
|
||||||
|
|
@ -152,6 +184,12 @@ var (
|
||||||
ErrStarsInvalidAmount = errors.New("stars: invalid amount")
|
ErrStarsInvalidAmount = errors.New("stars: invalid amount")
|
||||||
// ErrStarsTransactionQueryInvalid 表示内部构造了不可能的流水方向。
|
// ErrStarsTransactionQueryInvalid 表示内部构造了不可能的流水方向。
|
||||||
ErrStarsTransactionQueryInvalid = errors.New("stars: invalid transaction query")
|
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
|
// StarsPaymentRequiredError reports the minimum paid-message authorization the
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,21 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
|
||||||
}
|
}
|
||||||
case domain.MessageServiceActionStarGift:
|
case domain.MessageServiceActionStarGift:
|
||||||
return tgMessageActionStarGiftForViewer(m.ServiceAction.StarGift, msg.OwnerUserID)
|
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:
|
case domain.MessageServiceActionStarGiftUnique:
|
||||||
return tgMessageActionStarGiftUnique(m.ServiceAction.StarGiftUnique)
|
return tgMessageActionStarGiftUnique(m.ServiceAction.StarGiftUnique)
|
||||||
case domain.MessageServiceActionStarGiftOffer:
|
case domain.MessageServiceActionStarGiftOffer:
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ func tgUpdatesDifference(viewerUserID int64, diff domain.UpdateDifference) tg.Up
|
||||||
out.NewMessages = append(out.NewMessages, msg)
|
out.NewMessages = append(out.NewMessages, msg)
|
||||||
addMessageUsers(out, seenUsers, event.Message)
|
addMessageUsers(out, seenUsers, event.Message)
|
||||||
}
|
}
|
||||||
|
if balance := tgGiftStarsBalanceUpdate(event.Message); balance != nil {
|
||||||
|
out.OtherUpdates = append(out.OtherUpdates, balance)
|
||||||
|
}
|
||||||
case domain.UpdateEventReadHistoryInbox:
|
case domain.UpdateEventReadHistoryInbox:
|
||||||
if update := tgReadHistoryInboxUpdate(event); update != nil {
|
if update := tgReadHistoryInboxUpdate(event); update != nil {
|
||||||
out.OtherUpdates = append(out.OtherUpdates, update)
|
out.OtherUpdates = append(out.OtherUpdates, update)
|
||||||
|
|
|
||||||
|
|
@ -507,6 +507,9 @@ func tgPrivateMessageUpdates(event domain.UpdateEvent, msg domain.Message, rando
|
||||||
Pts: event.Pts,
|
Pts: event.Pts,
|
||||||
PtsCount: event.PtsCount,
|
PtsCount: event.PtsCount,
|
||||||
})
|
})
|
||||||
|
if balance := tgGiftStarsBalanceUpdate(msg); balance != nil {
|
||||||
|
updates = append(updates, balance)
|
||||||
|
}
|
||||||
date := event.Date
|
date := event.Date
|
||||||
if date == 0 {
|
if date == 0 {
|
||||||
date = msg.Date
|
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
|
// tgPrivateSendResultUpdates returns a complete send acknowledgement for exact
|
||||||
// random_id replays. DrKLO requires UpdateNewMessage in an Updates response to
|
// random_id replays. DrKLO requires UpdateNewMessage in an Updates response to
|
||||||
// transition its local pending message to SENT. Visible edited messages use the
|
// transition its local pending message to SENT. Visible edited messages use the
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import (
|
||||||
// registerPayments 注册 payments.* RPC:Stars 本地账本(余额/流水真实化)+ 其余
|
// registerPayments 注册 payments.* RPC:Stars 本地账本(余额/流水真实化)+ 其余
|
||||||
// gift/auction/revenue 第一阶段兼容桩。
|
// gift/auction/revenue 第一阶段兼容桩。
|
||||||
func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
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,
|
registerRPC[*tg.PaymentsGetStarsTopupOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTopupOptions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTopupOptionsRequest) (any,
|
||||||
|
|
||||||
// premium 订阅赠送 telesrv 不实现(无支付流),返回空选项。关键作用:TDesktop 送礼框
|
// 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) {
|
registerRPC[*tg.PaymentsSendStarsFormRequest](d, tlprofile.SemanticMethodPaymentsSendStarsForm, func(ctx context.Context, layerRequest *tg.PaymentsSendStarsFormRequest) (any, error) {
|
||||||
return r.onPaymentsSendStarsForm(ctx, layerRequest)
|
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) {
|
registerRPC[*tg.PaymentsGetSavedStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetSavedStarGifts, func(ctx context.Context, layerRequest *tg.PaymentsGetSavedStarGiftsRequest) (any, error) {
|
||||||
return r.onPaymentsGetSavedStarGifts(ctx, layerRequest)
|
return r.onPaymentsGetSavedStarGifts(ctx, layerRequest)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)。
|
// onPaymentsGetStarGifts 返回可购买礼物目录(hash 命中返回 NotModified)。
|
||||||
func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.PaymentsStarGiftsClass, error) {
|
func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.PaymentsStarGiftsClass, error) {
|
||||||
if r.deps.Gifts == nil {
|
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 inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
|
||||||
|
if purpose, gift := starsGiftPurpose(inv); gift {
|
||||||
|
return r.starsGiftPaymentForm(ctx, userID, purpose)
|
||||||
|
}
|
||||||
purpose, ok := starsTopupPurpose(inv)
|
purpose, ok := starsTopupPurpose(inv)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, notImplementedErr()
|
return nil, notImplementedErr()
|
||||||
|
|
@ -149,6 +228,36 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
|
||||||
}, nil
|
}, 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:
|
// onPaymentsSendStarsForm 处理 star gift 与 Stars topup:
|
||||||
// - star gift: Debit→投递/记账,失败补偿退款。
|
// - star gift: Debit→投递/记账,失败补偿退款。
|
||||||
// - topup: 校验测试包白名单→Credit 本地账本。
|
// - 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 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)
|
return r.sendStarsTopupForm(ctx, userID, req.FormID, inv)
|
||||||
}
|
}
|
||||||
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
|
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
|
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,
|
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) {
|
inv *tg.InputInvoiceStarGift, giftMessage string, upgradeStars int64) (tg.PaymentsPaymentResultClass, error) {
|
||||||
purchaseStars := gift.Stars + upgradeStars
|
purchaseStars := gift.Stars + upgradeStars
|
||||||
|
|
|
||||||
192
internal/rpc/payments_stars_friend_gift_test.go
Normal file
192
internal/rpc/payments_stars_friend_gift_test.go
Normal file
|
|
@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
233
internal/store/postgres/stars_gift_purchase.go
Normal file
233
internal/store/postgres/stars_gift_purchase.go
Normal file
|
|
@ -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)
|
||||||
125
internal/store/postgres/stars_gift_purchase_integration_test.go
Normal file
125
internal/store/postgres/stars_gift_purchase_integration_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,3 +22,11 @@ type StarsStore interface {
|
||||||
// ListTransactions 按方向与顺序做 keyset 分页,返回一页流水 + 当前余额。
|
// ListTransactions 按方向与顺序做 keyset 分页,返回一页流水 + 当前余额。
|
||||||
ListTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue