feat(stars): sync implement unified purchase flow
This commit is contained in:
parent
c13fbf8885
commit
7e0f9d1e62
26 changed files with 2087 additions and 282 deletions
|
|
@ -6,69 +6,96 @@ import (
|
|||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
// StarsGiftPurchaseStore commits a fiat Stars gift as one aggregate with the
|
||||
// private service message. No external provider is contacted by this local
|
||||
// development checkout; form binding and settlement idempotency are still
|
||||
// production-shaped so retries cannot mint twice.
|
||||
type StarsGiftPurchaseStore struct {
|
||||
// StarsPurchaseStore commits fiat Stars top-ups, friend gifts and giveaway
|
||||
// launches. No external
|
||||
// provider is contacted by this local development checkout; form binding and
|
||||
// settlement idempotency are still production-shaped so retries cannot mint twice.
|
||||
type StarsPurchaseStore struct {
|
||||
db sqlcgen.DBTX
|
||||
messages *MessageStore
|
||||
channels *ChannelStore
|
||||
}
|
||||
|
||||
func NewStarsGiftPurchaseStore(db sqlcgen.DBTX, messages *MessageStore) *StarsGiftPurchaseStore {
|
||||
return &StarsGiftPurchaseStore{db: db, messages: messages}
|
||||
func NewStarsPurchaseStore(db sqlcgen.DBTX, messages *MessageStore, channels ...*ChannelStore) *StarsPurchaseStore {
|
||||
var channelStore *ChannelStore
|
||||
if len(channels) > 0 {
|
||||
channelStore = channels[0]
|
||||
}
|
||||
return &StarsPurchaseStore{db: db, messages: messages, channels: channelStore}
|
||||
}
|
||||
|
||||
func (s *StarsGiftPurchaseStore) IssueStarsGiftPurchaseForm(ctx context.Context, form domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error) {
|
||||
if s == nil || s.db == nil || form.BuyerUserID <= 0 || form.RecipientUserID <= 0 ||
|
||||
form.BuyerUserID == form.RecipientUserID || form.Stars <= 0 || form.Amount <= 0 ||
|
||||
len(form.Currency) != 3 || form.IssuedAt <= 0 || form.ExpiresAt != form.IssuedAt+600 {
|
||||
return domain.StarsGiftPurchaseForm{}, domain.ErrStarsGiftFormInvalid
|
||||
func (s *StarsPurchaseStore) IssueStarsPurchaseForm(ctx context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
|
||||
if s == nil || s.db == nil || !validStarsPurchaseForm(form) {
|
||||
return domain.StarsPurchaseForm{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
purposeJSON, err := starsPurchasePurposeJSON(form)
|
||||
if err != nil {
|
||||
return domain.StarsPurchaseForm{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
for attempt := 0; attempt < 8; attempt++ {
|
||||
formID, err := newStarsGiftFormID()
|
||||
formID, err := newStarsPurchaseFormID()
|
||||
if err != nil {
|
||||
return domain.StarsGiftPurchaseForm{}, fmt.Errorf("generate stars gift form id: %w", err)
|
||||
return domain.StarsPurchaseForm{}, fmt.Errorf("generate stars purchase form id: %w", err)
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
INSERT INTO stars_gift_purchase_forms
|
||||
(buyer_user_id,form_id,recipient_user_id,stars,currency,amount,issued_at,expires_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
ON CONFLICT DO NOTHING`, form.BuyerUserID, formID, form.RecipientUserID, form.Stars,
|
||||
form.Currency, form.Amount, form.IssuedAt, form.ExpiresAt)
|
||||
INSERT INTO stars_purchase_forms
|
||||
(buyer_user_id,form_id,kind,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,issued_at,expires_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
ON CONFLICT DO NOTHING`, form.BuyerUserID, formID, string(form.Kind), starsPurchaseRecipientValue(form.RecipientUserID),
|
||||
starsPurchasePeerTypeValue(form.SpendPurposePeer), starsPurchasePeerIDValue(form.SpendPurposePeer),
|
||||
purposeJSON, form.Stars, form.Currency, form.Amount, form.IssuedAt, form.ExpiresAt)
|
||||
if err != nil {
|
||||
return domain.StarsGiftPurchaseForm{}, fmt.Errorf("insert stars gift form: %w", err)
|
||||
return domain.StarsPurchaseForm{}, fmt.Errorf("insert stars purchase form: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 1 {
|
||||
form.FormID = formID
|
||||
return form, nil
|
||||
}
|
||||
}
|
||||
return domain.StarsGiftPurchaseForm{}, domain.ErrStarsGiftUnavailable
|
||||
return domain.StarsPurchaseForm{}, domain.ErrStarsGiftUnavailable
|
||||
}
|
||||
|
||||
var errStarsGiftPurchaseReplay = errors.New("stars gift purchase replay")
|
||||
var errStarsPurchaseReplay = errors.New("stars purchase replay")
|
||||
|
||||
func (s *StarsGiftPurchaseStore) PurchaseStarsGift(ctx context.Context, req domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error) {
|
||||
if s == nil || s.db == nil || s.messages == nil || req.FormID == 0 ||
|
||||
req.BuyerUserID <= 0 || req.RecipientUserID <= 0 || req.BuyerUserID == req.RecipientUserID ||
|
||||
req.Stars <= 0 || req.Amount <= 0 || len(req.Currency) != 3 || req.Date <= 0 {
|
||||
return domain.StarsGiftPurchaseResult{}, domain.ErrStarsGiftFormInvalid
|
||||
func (s *StarsPurchaseStore) PurchaseStars(ctx context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) {
|
||||
if s == nil || s.db == nil || req.FormID == 0 || req.Date <= 0 || !validStarsPurchaseCommand(req.StarsPurchaseForm) {
|
||||
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
fingerprint := starsGiftPurchaseFingerprint(req)
|
||||
if replay, found, err := s.loadStarsGiftPurchaseReplay(ctx, req, fingerprint); err != nil || found {
|
||||
fingerprint := starsPurchaseFingerprint(req)
|
||||
if replay, found, err := s.loadStarsPurchaseReplay(ctx, req, fingerprint); err != nil || found {
|
||||
return replay, err
|
||||
}
|
||||
// A committed command is replayable after both the checkout and giveaway
|
||||
// deadlines: the provider may retry a successful submission after losing the
|
||||
// response, and a terminal campaign must not turn that exact retry into a
|
||||
// different outcome. The deadline only gates a first settlement.
|
||||
if req.Kind == domain.StarsPurchaseGiveaway && req.Giveaway.UntilDate <= req.Date {
|
||||
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormExpired
|
||||
}
|
||||
switch req.Kind {
|
||||
case domain.StarsPurchaseTopup:
|
||||
return s.purchaseStarsTopup(ctx, req, fingerprint)
|
||||
case domain.StarsPurchaseGiveaway:
|
||||
return s.purchaseStarsGiveaway(ctx, req, fingerprint)
|
||||
}
|
||||
if s.messages == nil {
|
||||
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
|
||||
transactionID := fmt.Sprintf("stars-gift:%d:%d", req.BuyerUserID, req.FormID)
|
||||
randomID := lifecycleCommandRandomID("stars-fiat-gift", req.BuyerUserID, req.FormID)
|
||||
|
|
@ -83,16 +110,16 @@ func (s *StarsGiftPurchaseStore) PurchaseStarsGift(ctx context.Context, req doma
|
|||
OriginUserID: req.BuyerUserID, OriginAuthKeyID: req.OriginAuthKeyID,
|
||||
OriginSessionID: req.OriginSessionID, IdempotencyFingerprint: fingerprint[:],
|
||||
}
|
||||
result := domain.StarsGiftPurchaseResult{TransactionID: transactionID}
|
||||
result := domain.StarsPurchaseResult{TransactionID: transactionID}
|
||||
hooks := privateSendTxHooks{
|
||||
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
|
||||
if err := validateStarsGiftPurchaseForm(ctx, tx, req, true); err != nil {
|
||||
if err := validateStarsPurchaseForm(ctx, tx, req, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if found, err := starsGiftPurchaseCommandExists(ctx, tx, req.BuyerUserID, req.FormID); err != nil {
|
||||
if found, err := starsPurchaseCommandExists(ctx, tx, req.BuyerUserID, req.FormID); err != nil {
|
||||
return err
|
||||
} else if found {
|
||||
return errStarsGiftPurchaseReplay
|
||||
return errStarsPurchaseReplay
|
||||
}
|
||||
balance := domain.StarsBalance{UserID: req.RecipientUserID}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
|
|
@ -107,21 +134,20 @@ RETURNING balance,granted`, req.RecipientUserID, req.Stars).Scan(&balance.Balanc
|
|||
"Stars gift", fmt.Sprintf("%d Stars", req.Stars)); err != nil {
|
||||
return err
|
||||
}
|
||||
result.RecipientBalance = balance
|
||||
result.Balance = balance
|
||||
if send.Media == nil || send.Media.ServiceAction == nil || send.Media.ServiceAction.GiftStars == nil {
|
||||
return domain.ErrStarsGiftFormInvalid
|
||||
return domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
send.Media.ServiceAction.GiftStars.BalanceAfter = balance.Balance
|
||||
return nil
|
||||
},
|
||||
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO stars_gift_purchase_commands
|
||||
(buyer_user_id,form_id,request_fingerprint,recipient_user_id,stars,currency,amount,
|
||||
recipient_balance_after,transaction_id,created_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.BuyerUserID, req.FormID, fingerprint[:],
|
||||
req.RecipientUserID, req.Stars, req.Currency, req.Amount,
|
||||
result.RecipientBalance.Balance, transactionID, req.Date)
|
||||
INSERT INTO stars_purchase_commands
|
||||
(buyer_user_id,form_id,kind,request_fingerprint,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,
|
||||
balance_after,transaction_id,created_at)
|
||||
VALUES($1,$2,$3,$4,$5,NULL,NULL,'{}'::jsonb,$6,$7,$8,$9,$10,$11)`, req.BuyerUserID, req.FormID, string(req.Kind), fingerprint[:],
|
||||
req.RecipientUserID, req.Stars, req.Currency, req.Amount, result.Balance.Balance, transactionID, req.Date)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert stars gift purchase command: %w", err)
|
||||
}
|
||||
|
|
@ -131,61 +157,233 @@ VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.BuyerUserID, req.FormID, fingerprin
|
|||
}
|
||||
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
|
||||
if err != nil {
|
||||
if errors.Is(err, errStarsGiftPurchaseReplay) {
|
||||
if replay, found, replayErr := s.loadStarsGiftPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found {
|
||||
if errors.Is(err, errStarsPurchaseReplay) {
|
||||
if replay, found, replayErr := s.loadStarsPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found {
|
||||
return replay, replayErr
|
||||
}
|
||||
}
|
||||
return domain.StarsGiftPurchaseResult{}, err
|
||||
return domain.StarsPurchaseResult{}, err
|
||||
}
|
||||
result.Send = sent
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateStarsGiftPurchaseForm(ctx context.Context, db sqlcgen.DBTX, req domain.StarsGiftPurchaseRequest, lock bool) error {
|
||||
query := `SELECT recipient_user_id,stars,currency,amount,issued_at,expires_at
|
||||
FROM stars_gift_purchase_forms WHERE buyer_user_id=$1 AND form_id=$2`
|
||||
func (s *StarsPurchaseStore) purchaseStarsTopup(ctx context.Context, req domain.StarsPurchaseRequest, fingerprint [32]byte) (domain.StarsPurchaseResult, error) {
|
||||
transactionID := fmt.Sprintf("stars-topup:%d:%d", req.BuyerUserID, req.FormID)
|
||||
result := domain.StarsPurchaseResult{TransactionID: transactionID}
|
||||
err := withTx(ctx, s.db, "settle stars topup", func(tx pgx.Tx) error {
|
||||
if err := validateStarsPurchaseForm(ctx, tx, req, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if found, err := starsPurchaseCommandExists(ctx, tx, req.BuyerUserID, req.FormID); err != nil {
|
||||
return err
|
||||
} else if found {
|
||||
return errStarsPurchaseReplay
|
||||
}
|
||||
result.Balance = domain.StarsBalance{UserID: req.BuyerUserID}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO stars_balances (user_id,balance,updated_at) VALUES($1,$2,now())
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET balance=stars_balances.balance+EXCLUDED.balance, updated_at=now()
|
||||
RETURNING balance,granted`, req.BuyerUserID, req.Stars).Scan(&result.Balance.Balance, &result.Balance.Granted); err != nil {
|
||||
return fmt.Errorf("credit stars topup buyer: %w", err)
|
||||
}
|
||||
if err := insertStarsTxn(ctx, tx, req.BuyerUserID, req.Stars, domain.StarsReasonTopup,
|
||||
req.SpendPurposePeer, req.Date, "Stars top-up", "telesrv dev purchase"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO stars_purchase_commands
|
||||
(buyer_user_id,form_id,kind,request_fingerprint,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,
|
||||
balance_after,transaction_id,created_at)
|
||||
VALUES($1,$2,$3,$4,NULL,$5,$6,'{}'::jsonb,$7,$8,$9,$10,$11,$12)`, req.BuyerUserID, req.FormID, string(req.Kind), fingerprint[:],
|
||||
starsPurchasePeerTypeValue(req.SpendPurposePeer), starsPurchasePeerIDValue(req.SpendPurposePeer),
|
||||
req.Stars, req.Currency, req.Amount, result.Balance.Balance, transactionID, req.Date)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert stars topup command: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, errStarsPurchaseReplay) {
|
||||
if replay, found, replayErr := s.loadStarsPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found {
|
||||
return replay, replayErr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return domain.StarsPurchaseResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *StarsPurchaseStore) purchaseStarsGiveaway(ctx context.Context, req domain.StarsPurchaseRequest, fingerprint [32]byte) (domain.StarsPurchaseResult, error) {
|
||||
if s.channels == nil || req.Giveaway == nil {
|
||||
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
purposeJSON, err := starsPurchasePurposeJSON(req.StarsPurchaseForm)
|
||||
if err != nil {
|
||||
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
giveaway := *req.Giveaway
|
||||
transactionID := fmt.Sprintf("stars-giveaway:%d:%d", req.BuyerUserID, req.FormID)
|
||||
result := domain.StarsPurchaseResult{TransactionID: transactionID}
|
||||
sendReq := domain.SendChannelMessageRequest{
|
||||
UserID: req.BuyerUserID, ChannelID: giveaway.BoostPeer.ID,
|
||||
RandomID: giveaway.RandomID, Date: req.Date,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindGiveaway, Giveaway: &domain.MessageGiveaway{
|
||||
OnlyNewSubscribers: giveaway.OnlyNewSubscribers, WinnersAreVisible: giveaway.WinnersAreVisible,
|
||||
Channels: starsGiveawayChannelIDs(giveaway), CountriesISO2: append([]string(nil), giveaway.CountriesISO2...),
|
||||
PrizeDescription: giveaway.PrizeDescription, Quantity: giveaway.Users, Stars: req.Stars, UntilDate: giveaway.UntilDate,
|
||||
}},
|
||||
IdempotencyFingerprint: fingerprint[:],
|
||||
}
|
||||
hooks := channelSendTxHooks{
|
||||
before: func(ctx context.Context, tx pgx.Tx, _ *domain.SendChannelMessageRequest) error {
|
||||
if err := validateStarsPurchaseForm(ctx, tx, req, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if found, err := starsPurchaseCommandExists(ctx, tx, req.BuyerUserID, req.FormID); err != nil {
|
||||
return err
|
||||
} else if found {
|
||||
return errStarsPurchaseReplay
|
||||
}
|
||||
return nil
|
||||
},
|
||||
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendChannelMessageResult) error {
|
||||
if sent.Message.ID <= 0 || sent.Event.Pts <= 0 || sent.Event.PtsCount != 1 {
|
||||
return fmt.Errorf("settle stars giveaway: invalid channel send receipt")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO stars_giveaways
|
||||
(buyer_user_id,form_id,channel_id,launch_message_id,random_id,stars,users,per_user_stars,yearly_boosts,
|
||||
until_date,purpose_json,state,created_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'active',$12)`,
|
||||
req.BuyerUserID, req.FormID, giveaway.BoostPeer.ID, sent.Message.ID, giveaway.RandomID,
|
||||
req.Stars, giveaway.Users, giveaway.PerUserStars, giveaway.YearlyBoosts, giveaway.UntilDate, purposeJSON, req.Date); err != nil {
|
||||
return fmt.Errorf("insert stars giveaway campaign: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO stars_purchase_commands
|
||||
(buyer_user_id,form_id,kind,request_fingerprint,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,
|
||||
balance_after,transaction_id,created_at)
|
||||
VALUES($1,$2,$3,$4,NULL,NULL,NULL,$5,$6,$7,$8,0,$9,$10)`,
|
||||
req.BuyerUserID, req.FormID, string(req.Kind), fingerprint[:], purposeJSON,
|
||||
req.Stars, req.Currency, req.Amount, transactionID, req.Date); err != nil {
|
||||
return fmt.Errorf("insert stars giveaway purchase command: %w", err)
|
||||
}
|
||||
result.ChannelSend = sent
|
||||
return nil
|
||||
},
|
||||
}
|
||||
sent, err := s.channels.sendChannelMessageWithHooks(ctx, sendReq, hooks)
|
||||
if err != nil {
|
||||
if errors.Is(err, errStarsPurchaseReplay) {
|
||||
if replay, found, replayErr := s.loadStarsPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found {
|
||||
return replay, replayErr
|
||||
}
|
||||
}
|
||||
return domain.StarsPurchaseResult{}, err
|
||||
}
|
||||
if sent.Duplicate {
|
||||
if replay, found, replayErr := s.loadStarsPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found {
|
||||
return replay, replayErr
|
||||
}
|
||||
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
result.ChannelSend = sent
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func starsGiveawayChannelIDs(giveaway domain.StarsGiveawayPurchase) []int64 {
|
||||
ids := make([]int64, 0, 1+len(giveaway.AdditionalPeers))
|
||||
ids = append(ids, giveaway.BoostPeer.ID)
|
||||
for _, peer := range giveaway.AdditionalPeers {
|
||||
ids = append(ids, peer.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func validateStarsPurchaseForm(ctx context.Context, db sqlcgen.DBTX, req domain.StarsPurchaseRequest, lock bool) error {
|
||||
query := `SELECT kind,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,issued_at,expires_at
|
||||
FROM stars_purchase_forms WHERE buyer_user_id=$1 AND form_id=$2`
|
||||
if lock {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
var recipientID, stars, amount int64
|
||||
var kind string
|
||||
var recipientID pgtype.Int8
|
||||
var spendPeerType pgtype.Text
|
||||
var spendPeerID pgtype.Int8
|
||||
var purposeJSON []byte
|
||||
var stars, amount int64
|
||||
var currency string
|
||||
var issuedAt, expiresAt int
|
||||
err := db.QueryRow(ctx, query, req.BuyerUserID, req.FormID).
|
||||
Scan(&recipientID, &stars, ¤cy, &amount, &issuedAt, &expiresAt)
|
||||
Scan(&kind, &recipientID, &spendPeerType, &spendPeerID, &purposeJSON, &stars, ¤cy, &amount, &issuedAt, &expiresAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ErrStarsGiftFormInvalid
|
||||
return domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("load stars gift form: %w", err)
|
||||
}
|
||||
if req.Date >= expiresAt {
|
||||
return domain.ErrStarsGiftFormExpired
|
||||
return domain.ErrStarsPurchaseFormExpired
|
||||
}
|
||||
if issuedAt <= 0 || recipientID != req.RecipientUserID || stars != req.Stars ||
|
||||
currency != req.Currency || amount != req.Amount {
|
||||
return domain.ErrStarsGiftFormInvalid
|
||||
if issuedAt <= 0 || kind != string(req.Kind) || nullableStarsRecipient(recipientID) != req.RecipientUserID ||
|
||||
nullableStarsPeer(spendPeerType, spendPeerID) != req.SpendPurposePeer || stars != req.Stars ||
|
||||
currency != req.Currency || amount != req.Amount || !sameStarsPurchasePurpose(purposeJSON, req.StarsPurchaseForm) {
|
||||
return domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StarsGiftPurchaseStore) loadStarsGiftPurchaseReplay(ctx context.Context, req domain.StarsGiftPurchaseRequest, fingerprint [32]byte) (domain.StarsGiftPurchaseResult, bool, error) {
|
||||
var recipientID, stars, amount, balance int64
|
||||
var currency, transactionID string
|
||||
var storedFingerprint []byte
|
||||
func (s *StarsPurchaseStore) loadStarsPurchaseReplay(ctx context.Context, req domain.StarsPurchaseRequest, fingerprint [32]byte) (domain.StarsPurchaseResult, bool, error) {
|
||||
var recipientID pgtype.Int8
|
||||
var spendPeerType pgtype.Text
|
||||
var spendPeerID pgtype.Int8
|
||||
var stars, amount, balance int64
|
||||
var kind, currency, transactionID string
|
||||
var storedFingerprint, purposeJSON []byte
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT request_fingerprint,recipient_user_id,stars,currency,amount,recipient_balance_after,transaction_id
|
||||
FROM stars_gift_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2`, req.BuyerUserID, req.FormID).
|
||||
Scan(&storedFingerprint, &recipientID, &stars, ¤cy, &amount, &balance, &transactionID)
|
||||
SELECT kind,request_fingerprint,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,balance_after,transaction_id
|
||||
FROM stars_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2`, req.BuyerUserID, req.FormID).
|
||||
Scan(&kind, &storedFingerprint, &recipientID, &spendPeerType, &spendPeerID, &purposeJSON, &stars, ¤cy, &amount, &balance, &transactionID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.StarsGiftPurchaseResult{}, false, nil
|
||||
return domain.StarsPurchaseResult{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.StarsGiftPurchaseResult{}, false, fmt.Errorf("load stars gift purchase replay: %w", err)
|
||||
return domain.StarsPurchaseResult{}, false, fmt.Errorf("load stars purchase replay: %w", err)
|
||||
}
|
||||
if !bytes.Equal(storedFingerprint, fingerprint[:]) || recipientID != req.RecipientUserID ||
|
||||
stars != req.Stars || currency != req.Currency || amount != req.Amount || transactionID == "" {
|
||||
return domain.StarsGiftPurchaseResult{}, false, domain.ErrStarsGiftFormInvalid
|
||||
if kind != string(req.Kind) || !bytes.Equal(storedFingerprint, fingerprint[:]) || nullableStarsRecipient(recipientID) != req.RecipientUserID ||
|
||||
nullableStarsPeer(spendPeerType, spendPeerID) != req.SpendPurposePeer ||
|
||||
stars != req.Stars || currency != req.Currency || amount != req.Amount || transactionID == "" ||
|
||||
!sameStarsPurchasePurpose(purposeJSON, req.StarsPurchaseForm) {
|
||||
return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
result := domain.StarsPurchaseResult{
|
||||
Balance: domain.StarsBalance{UserID: req.BuyerUserID, Balance: balance},
|
||||
TransactionID: transactionID, Duplicate: true,
|
||||
}
|
||||
if req.Kind == domain.StarsPurchaseTopup {
|
||||
return result, true, nil
|
||||
}
|
||||
if req.Kind == domain.StarsPurchaseGiveaway {
|
||||
if s.channels == nil || req.Giveaway == nil {
|
||||
return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
sent, found, err := s.channels.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{
|
||||
ChannelID: req.Giveaway.BoostPeer.ID, SenderUserID: req.BuyerUserID,
|
||||
RandomID: req.Giveaway.RandomID, IdempotencyFingerprint: fingerprint[:],
|
||||
})
|
||||
if err != nil {
|
||||
return domain.StarsPurchaseResult{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
result.ChannelSend = sent
|
||||
return result, true, nil
|
||||
}
|
||||
if s.messages == nil {
|
||||
return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
sent, found, err := s.messages.LookupPrivateSendReplay(ctx, domain.PrivateSendReplayRequest{
|
||||
SenderUserID: req.BuyerUserID, RecipientUserID: req.RecipientUserID,
|
||||
|
|
@ -193,32 +391,33 @@ FROM stars_gift_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2`, req.Bu
|
|||
IdempotencyFingerprint: fingerprint[:],
|
||||
})
|
||||
if err != nil {
|
||||
return domain.StarsGiftPurchaseResult{}, false, err
|
||||
return domain.StarsPurchaseResult{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.StarsGiftPurchaseResult{}, false, domain.ErrStarsGiftFormInvalid
|
||||
return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
return domain.StarsGiftPurchaseResult{
|
||||
RecipientBalance: domain.StarsBalance{UserID: req.RecipientUserID, Balance: balance},
|
||||
Send: sent, TransactionID: transactionID, Duplicate: true,
|
||||
}, true, nil
|
||||
result.Balance.UserID = req.RecipientUserID
|
||||
result.Send = sent
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func starsGiftPurchaseCommandExists(ctx context.Context, db sqlcgen.DBTX, buyerUserID, formID int64) (bool, error) {
|
||||
func starsPurchaseCommandExists(ctx context.Context, db sqlcgen.DBTX, buyerUserID, formID int64) (bool, error) {
|
||||
var exists bool
|
||||
if err := db.QueryRow(ctx, `SELECT EXISTS(
|
||||
SELECT 1 FROM stars_gift_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2)`, buyerUserID, formID).Scan(&exists); err != nil {
|
||||
return false, fmt.Errorf("check stars gift purchase command: %w", err)
|
||||
SELECT 1 FROM stars_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2)`, buyerUserID, formID).Scan(&exists); err != nil {
|
||||
return false, fmt.Errorf("check stars purchase command: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func starsGiftPurchaseFingerprint(req domain.StarsGiftPurchaseRequest) [32]byte {
|
||||
return sha256.Sum256([]byte(fmt.Sprintf("telesrv:stars-fiat-gift:v1:%d:%d:%d:%s:%d:%d",
|
||||
req.BuyerUserID, req.RecipientUserID, req.Stars, req.Currency, req.Amount, req.FormID)))
|
||||
func starsPurchaseFingerprint(req domain.StarsPurchaseRequest) [32]byte {
|
||||
purposeJSON, _ := starsPurchasePurposeJSON(req.StarsPurchaseForm)
|
||||
return sha256.Sum256([]byte(fmt.Sprintf("telesrv:stars-fiat-purchase:v2:%s:%d:%d:%s:%d:%d:%s:%d:%d:%s",
|
||||
req.Kind, req.BuyerUserID, req.RecipientUserID, req.SpendPurposePeer.Type, req.SpendPurposePeer.ID,
|
||||
req.Stars, req.Currency, req.Amount, req.FormID, purposeJSON)))
|
||||
}
|
||||
|
||||
func newStarsGiftFormID() (int64, error) {
|
||||
func newStarsPurchaseFormID() (int64, error) {
|
||||
var raw [8]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return 0, err
|
||||
|
|
@ -230,4 +429,200 @@ func newStarsGiftFormID() (int64, error) {
|
|||
return id, nil
|
||||
}
|
||||
|
||||
var _ store.StarsGiftPurchaseStore = (*StarsGiftPurchaseStore)(nil)
|
||||
func validStarsPurchaseForm(form domain.StarsPurchaseForm) bool {
|
||||
if !validStarsPurchaseCommand(form) || form.IssuedAt <= 0 || form.ExpiresAt != form.IssuedAt+600 {
|
||||
return false
|
||||
}
|
||||
return form.Kind != domain.StarsPurchaseGiveaway ||
|
||||
(form.Giveaway.UntilDate > form.IssuedAt && form.Giveaway.UntilDate <= form.IssuedAt+7*24*60*60)
|
||||
}
|
||||
|
||||
func validStarsPurchaseCommand(form domain.StarsPurchaseForm) bool {
|
||||
if !form.Kind.Valid() || form.BuyerUserID <= 0 || form.Stars <= 0 || form.Amount <= 0 || len(form.Currency) != 3 {
|
||||
return false
|
||||
}
|
||||
validSpendPeer := (form.SpendPurposePeer == domain.Peer{}) ||
|
||||
((form.SpendPurposePeer.Type == domain.PeerTypeUser || form.SpendPurposePeer.Type == domain.PeerTypeChannel) && form.SpendPurposePeer.ID > 0)
|
||||
switch form.Kind {
|
||||
case domain.StarsPurchaseTopup:
|
||||
return form.RecipientUserID == 0 && validSpendPeer && form.Giveaway == nil
|
||||
case domain.StarsPurchaseGift:
|
||||
return form.RecipientUserID > 0 && form.RecipientUserID != form.BuyerUserID && form.SpendPurposePeer == (domain.Peer{}) && form.Giveaway == nil
|
||||
case domain.StarsPurchaseGiveaway:
|
||||
return form.RecipientUserID == 0 && form.SpendPurposePeer == (domain.Peer{}) && validStarsGiveawayPurchase(form.Giveaway, form.Stars)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validStarsGiveawayPurchase(g *domain.StarsGiveawayPurchase, stars int64) bool {
|
||||
if g == nil || g.BoostPeer.Type != domain.PeerTypeChannel || g.BoostPeer.ID <= 0 || g.RandomID == 0 ||
|
||||
g.UntilDate <= 0 || g.Users <= 0 || g.PerUserStars <= 0 || g.YearlyBoosts < 0 ||
|
||||
int64(g.Users) > math.MaxInt64/g.PerUserStars || int64(g.Users)*g.PerUserStars != stars ||
|
||||
len(g.AdditionalPeers) > 10 || len(g.CountriesISO2) > 10 || utf8.RuneCountInString(g.PrizeDescription) > 128 {
|
||||
return false
|
||||
}
|
||||
seenPeers := map[int64]struct{}{g.BoostPeer.ID: struct{}{}}
|
||||
for _, peer := range g.AdditionalPeers {
|
||||
if peer.Type != domain.PeerTypeChannel || peer.ID <= 0 {
|
||||
return false
|
||||
}
|
||||
if _, exists := seenPeers[peer.ID]; exists {
|
||||
return false
|
||||
}
|
||||
seenPeers[peer.ID] = struct{}{}
|
||||
}
|
||||
seenCountries := make(map[string]struct{}, len(g.CountriesISO2))
|
||||
for _, country := range g.CountriesISO2 {
|
||||
if len(country) != 2 || country != strings.ToUpper(country) || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z' {
|
||||
return false
|
||||
}
|
||||
if _, exists := seenCountries[country]; exists {
|
||||
return false
|
||||
}
|
||||
seenCountries[country] = struct{}{}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func starsPurchasePurposeJSON(form domain.StarsPurchaseForm) ([]byte, error) {
|
||||
if form.Kind != domain.StarsPurchaseGiveaway {
|
||||
return []byte(`{}`), nil
|
||||
}
|
||||
if form.Giveaway == nil {
|
||||
return nil, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
return json.Marshal(form.Giveaway)
|
||||
}
|
||||
|
||||
func sameStarsPurchasePurpose(stored []byte, form domain.StarsPurchaseForm) bool {
|
||||
want, err := starsPurchasePurposeJSON(form)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if form.Kind != domain.StarsPurchaseGiveaway {
|
||||
var value map[string]any
|
||||
return json.Unmarshal(stored, &value) == nil && len(value) == 0
|
||||
}
|
||||
var decoded domain.StarsGiveawayPurchase
|
||||
if err := json.Unmarshal(stored, &decoded); err != nil {
|
||||
return false
|
||||
}
|
||||
got, err := json.Marshal(&decoded)
|
||||
return err == nil && bytes.Equal(got, want)
|
||||
}
|
||||
|
||||
func starsPurchaseRecipientValue(recipientUserID int64) any {
|
||||
if recipientUserID == 0 {
|
||||
return nil
|
||||
}
|
||||
return recipientUserID
|
||||
}
|
||||
|
||||
func nullableStarsRecipient(value pgtype.Int8) int64 {
|
||||
if !value.Valid {
|
||||
return 0
|
||||
}
|
||||
return value.Int64
|
||||
}
|
||||
|
||||
func starsPurchasePeerTypeValue(peer domain.Peer) any {
|
||||
if peer == (domain.Peer{}) {
|
||||
return nil
|
||||
}
|
||||
return string(peer.Type)
|
||||
}
|
||||
|
||||
func starsPurchasePeerIDValue(peer domain.Peer) any {
|
||||
if peer == (domain.Peer{}) {
|
||||
return nil
|
||||
}
|
||||
return peer.ID
|
||||
}
|
||||
|
||||
func nullableStarsPeer(peerType pgtype.Text, peerID pgtype.Int8) domain.Peer {
|
||||
if !peerType.Valid || !peerID.Valid {
|
||||
return domain.Peer{}
|
||||
}
|
||||
return domain.Peer{Type: domain.PeerType(peerType.String), ID: peerID.Int64}
|
||||
}
|
||||
|
||||
func (s *StarsPurchaseStore) GetStarsGiveawayInfo(ctx context.Context, viewerUserID, channelID int64, messageID, date int) (domain.StarsGiveawayInfo, error) {
|
||||
if s == nil || s.db == nil || viewerUserID <= 0 || channelID <= 0 || messageID <= 0 || date <= 0 {
|
||||
return domain.StarsGiveawayInfo{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
var purposeJSON []byte
|
||||
var state string
|
||||
var startDate, untilDate int
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT purpose_json,state,created_at,until_date
|
||||
FROM stars_giveaways WHERE channel_id=$1 AND launch_message_id=$2`, channelID, messageID).
|
||||
Scan(&purposeJSON, &state, &startDate, &untilDate)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.StarsGiveawayInfo{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return domain.StarsGiveawayInfo{}, fmt.Errorf("load stars giveaway info: %w", err)
|
||||
}
|
||||
var purpose domain.StarsGiveawayPurchase
|
||||
if err := json.Unmarshal(purposeJSON, &purpose); err != nil || purpose.BoostPeer.ID != channelID {
|
||||
return domain.StarsGiveawayInfo{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
info := domain.StarsGiveawayInfo{StartDate: startDate}
|
||||
if state == "cancelled" {
|
||||
return info, nil
|
||||
}
|
||||
if state != "active" || date >= untilDate {
|
||||
info.PreparingResults = true
|
||||
return info, nil
|
||||
}
|
||||
channels := starsGiveawayChannelIDs(purpose)
|
||||
for _, requiredChannelID := range channels {
|
||||
var role, status string
|
||||
var joinedAt int
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT role,status,joined_at FROM channel_members WHERE channel_id=$1 AND user_id=$2`, requiredChannelID, viewerUserID).
|
||||
Scan(&role, &status, &joinedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return info, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.StarsGiveawayInfo{}, fmt.Errorf("load giveaway participant membership: %w", err)
|
||||
}
|
||||
if status != string(domain.ChannelMemberActive) {
|
||||
return info, nil
|
||||
}
|
||||
if role == string(domain.ChannelRoleCreator) || role == string(domain.ChannelRoleAdmin) {
|
||||
info.AdminDisallowedChatID = requiredChannelID
|
||||
return info, nil
|
||||
}
|
||||
if purpose.OnlyNewSubscribers && joinedAt > 0 && joinedAt <= startDate {
|
||||
info.JoinedTooEarlyDate = joinedAt
|
||||
return info, nil
|
||||
}
|
||||
}
|
||||
if len(purpose.CountriesISO2) > 0 {
|
||||
var country string
|
||||
if err := s.db.QueryRow(ctx, `
|
||||
SELECT COALESCE((SELECT cc.iso2 FROM country_codes cc WHERE cc.country_code=u.country_code ORDER BY cc.id LIMIT 1),'')
|
||||
FROM users u WHERE u.id=$1`, viewerUserID).Scan(&country); err != nil {
|
||||
return domain.StarsGiveawayInfo{}, fmt.Errorf("load giveaway participant country: %w", err)
|
||||
}
|
||||
allowed := false
|
||||
for _, candidate := range purpose.CountriesISO2 {
|
||||
if candidate == country {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
info.DisallowedCountry = country
|
||||
return info, nil
|
||||
}
|
||||
}
|
||||
info.Participating = true
|
||||
return info, nil
|
||||
}
|
||||
|
||||
var _ store.StarsPurchaseStore = (*StarsPurchaseStore)(nil)
|
||||
var _ store.StarsGiveawayStore = (*StarsPurchaseStore)(nil)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue