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
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue