feat: sync implement Stars friend gift packages

This commit is contained in:
iamxvbaba 2026-08-02 01:51:02 +08:00
parent 3a123f38db
commit c854fc7b94
15 changed files with 914 additions and 1 deletions

View file

@ -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)
}

View file

@ -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 {

View file

@ -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

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -15,6 +15,9 @@ import (
// registerPayments 注册 payments.* RPCStars 本地账本(余额/流水真实化)+ 其余
// 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)
})

View file

@ -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

View 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])
}
}

View 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, &currency, &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, &currency, &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)

View 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)
}
}

View file

@ -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)
}