365 lines
14 KiB
Go
365 lines
14 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
"telesrv/internal/store/postgres/sqlcgen"
|
|
)
|
|
|
|
// StarGiftUpgradeStore is the PostgreSQL aggregate coordinator for collectible
|
|
// upgrades. It intentionally shares MessageStore's allocator and transaction
|
|
// machinery so Stars, issuance, the saved gift and durable updates commit once.
|
|
type StarGiftUpgradeStore struct {
|
|
db sqlcgen.DBTX
|
|
messages *MessageStore
|
|
}
|
|
|
|
func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore) *StarGiftUpgradeStore {
|
|
return &StarGiftUpgradeStore{db: db, messages: messages}
|
|
}
|
|
|
|
func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
|
|
if s == nil || s.db == nil || s.messages == nil || req.UserID <= 0 || !req.Ref.Valid() ||
|
|
req.Ref.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) ||
|
|
req.ChargeStars < 0 || req.Date <= 0 || strings.TrimSpace(req.CommandKey) == "" || len(req.CommandKey) > 256 {
|
|
return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid
|
|
}
|
|
saved, found, err := NewStarGiftStore(s.db).GetByRef(ctx, req.Ref)
|
|
if err != nil {
|
|
return domain.StarGiftUpgradeResult{}, err
|
|
}
|
|
if !found || saved.FromUserID <= 0 {
|
|
return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftNotFound
|
|
}
|
|
|
|
commandKey := strings.TrimSpace(req.CommandKey)
|
|
fingerprint := sha256.Sum256([]byte(fmt.Sprintf(
|
|
"telesrv:star-gift-upgrade:v1:%s:%d:%d:%t:%d:%t",
|
|
commandKey, saved.ID, req.ChargeStars, req.RequirePrepaid, req.FormID, req.KeepOriginalDetails,
|
|
)))
|
|
randomID := starGiftUpgradeRandomID(saved.FromUserID, req.UserID, commandKey)
|
|
placeholder := &domain.MessageMedia{
|
|
Kind: domain.MessageMediaKindService,
|
|
ServiceAction: &domain.MessageServiceAction{
|
|
Kind: domain.MessageServiceActionStarGiftUnique,
|
|
StarGiftUnique: &domain.MessageStarGiftUniqueAction{Upgrade: true, Saved: true},
|
|
},
|
|
}
|
|
messageReq := domain.SendPrivateTextRequest{
|
|
SenderUserID: saved.FromUserID,
|
|
RecipientUserID: req.UserID,
|
|
RandomID: randomID,
|
|
Media: placeholder,
|
|
Date: req.Date,
|
|
OriginAuthKeyID: req.OriginAuthKeyID,
|
|
OriginSessionID: req.OriginSessionID,
|
|
OriginUserID: req.UserID,
|
|
IdempotencyFingerprint: fingerprint[:],
|
|
}
|
|
|
|
var result domain.StarGiftUpgradeResult
|
|
hooks := privateSendTxHooks{
|
|
before: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) error {
|
|
locked, err := lockSavedStarGiftForUpgrade(ctx, tx, req.Ref)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if locked.ID != saved.ID || locked.FromUserID != saved.FromUserID {
|
|
return domain.ErrStarGiftCollectibleInvalid
|
|
}
|
|
if locked.Converted {
|
|
return domain.ErrStarGiftAlreadyConverted
|
|
}
|
|
if locked.UniqueGiftID != 0 {
|
|
return domain.ErrStarGiftAlreadyUpgraded
|
|
}
|
|
|
|
revision, err := lockActiveCollectibleRevision(ctx, tx, locked.GiftID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if revision.Issued >= revision.SupplyTotal {
|
|
return domain.ErrStarGiftCollectibleSoldOut
|
|
}
|
|
if req.RequirePrepaid {
|
|
// Prepayment is an entitlement captured at gift purchase time. A
|
|
// later published revision may change the current price, but must not
|
|
// retroactively invalidate that already-paid entitlement.
|
|
if req.ChargeStars != 0 || locked.PrepaidUpgradeStars <= 0 {
|
|
return domain.ErrStarGiftCollectibleUnavailable
|
|
}
|
|
} else if req.ChargeStars != revision.UpgradeStars {
|
|
return domain.ErrStarGiftCollectibleUnavailable
|
|
}
|
|
|
|
balance, err := debitStarGiftUpgrade(ctx, tx, req.UserID, req.ChargeStars, locked.Owner, req.Date)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
modelID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_models", revision.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
patternID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_patterns", revision.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
backdropID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_backdrops", revision.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
num := revision.Issued + 1
|
|
var uniqueID int64
|
|
if err := tx.QueryRow(ctx, `SELECT nextval('unique_star_gift_id_seq')`).Scan(&uniqueID); err != nil {
|
|
return fmt.Errorf("allocate unique star gift id: %w", err)
|
|
}
|
|
var title string
|
|
if err := tx.QueryRow(ctx, `SELECT title FROM star_gift_catalog_revisions WHERE id=$1`, locked.RevisionID).Scan(&title); err != nil {
|
|
return fmt.Errorf("load upgrade gift title: %w", err)
|
|
}
|
|
slug := fmt.Sprintf("%s-%d", revision.SlugPrefix, num)
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO unique_star_gifts
|
|
(id, gift_id, collectible_revision_id, source_saved_gift_id, title, slug, num,
|
|
owner_peer_type, owner_peer_id, model_attribute_id, pattern_attribute_id,
|
|
backdrop_attribute_id, keep_original_details)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
|
|
uniqueID, locked.GiftID, revision.ID, locked.ID, title, slug, num,
|
|
string(locked.Owner.Type), locked.Owner.ID, modelID, patternID, backdropID, req.KeepOriginalDetails); err != nil {
|
|
return fmt.Errorf("insert unique star gift: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE star_gift_collectible_revisions SET issued=issued+1 WHERE id=$1`, revision.ID); err != nil {
|
|
return fmt.Errorf("increment collectible issuance: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE peer_star_gifts
|
|
SET unique_gift_id=$2, prepaid_upgrade_stars=0, convert_stars=0
|
|
WHERE id=$1 AND unique_gift_id IS NULL AND NOT converted`, locked.ID, uniqueID); err != nil {
|
|
return fmt.Errorf("upgrade saved star gift: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO star_gift_upgrade_commands
|
|
(user_id, command_key, source_saved_gift_id, form_id, unique_gift_id, balance_after)
|
|
VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniqueID, balance.Balance); err != nil {
|
|
return fmt.Errorf("insert star gift upgrade command: %w", err)
|
|
}
|
|
|
|
unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, uniqueID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("new unique star gift %d disappeared", uniqueID)
|
|
}
|
|
locked.UniqueGiftID = uniqueID
|
|
locked.PrepaidUpgradeStars = 0
|
|
locked.ConvertStars = 0
|
|
locked.Unique = &unique
|
|
result.Saved, result.Unique, result.Balance = locked, unique, balance
|
|
messageReq.Media = &domain.MessageMedia{
|
|
Kind: domain.MessageMediaKindService,
|
|
ServiceAction: &domain.MessageServiceAction{
|
|
Kind: domain.MessageServiceActionStarGiftUnique,
|
|
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
|
Gift: unique, FromUserID: func() int64 {
|
|
if locked.NameHidden {
|
|
return 0
|
|
}
|
|
return locked.FromUserID
|
|
}(), Peer: locked.Owner, Upgrade: true, Saved: !locked.Unsaved,
|
|
PrepaidUpgrade: req.RequirePrepaid,
|
|
},
|
|
},
|
|
}
|
|
return nil
|
|
},
|
|
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
|
ownerMessageID := sent.RecipientMessage.ID
|
|
if saved.FromUserID == req.UserID {
|
|
ownerMessageID = sent.SenderMessage.ID
|
|
}
|
|
if ownerMessageID <= 0 {
|
|
return fmt.Errorf("upgrade service message missing owner box")
|
|
}
|
|
tag, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET upgrade_msg_id=$2 WHERE id=$1 AND unique_gift_id=$3`, result.Saved.ID, ownerMessageID, result.Unique.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("save star gift upgrade message id: %w", err)
|
|
}
|
|
if tag.RowsAffected() != 1 {
|
|
return fmt.Errorf("save star gift upgrade message id lost aggregate row")
|
|
}
|
|
result.Saved.UpgradeMsgID = ownerMessageID
|
|
return nil
|
|
},
|
|
}
|
|
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
|
|
if err != nil {
|
|
return domain.StarGiftUpgradeResult{}, err
|
|
}
|
|
result.Send = sent
|
|
result.Duplicate = sent.Duplicate
|
|
if sent.Duplicate {
|
|
return s.loadUpgradeReplay(ctx, req, saved, sent)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func lockSavedStarGiftForUpgrade(ctx context.Context, tx pgx.Tx, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
|
|
where, args := savedStarGiftRefWhere(ref)
|
|
row := tx.QueryRow(ctx, `
|
|
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
|
|
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
|
|
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
|
|
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
|
|
FROM star_gift_collection_items i
|
|
JOIN star_gift_collections c ON c.collection_id=i.collection_id
|
|
WHERE i.saved_gift_id=p.id), ARRAY[]::integer[])
|
|
FROM peer_star_gifts p WHERE `+where+` FOR UPDATE`, args...)
|
|
saved, err := scanSavedStarGift(row)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.SavedStarGift{}, domain.ErrStarGiftNotFound
|
|
}
|
|
return saved, err
|
|
}
|
|
|
|
func lockActiveCollectibleRevision(ctx context.Context, tx pgx.Tx, giftID int64) (domain.StarGiftCollectibleRevision, error) {
|
|
var revision domain.StarGiftCollectibleRevision
|
|
var status string
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT r.id, r.gift_id, r.upgrade_stars, r.supply_total, r.issued, r.slug_prefix, r.status
|
|
FROM star_gift_catalog c
|
|
JOIN star_gift_collectible_revisions r ON r.id=c.collectible_revision_id
|
|
WHERE c.gift_id=$1 FOR UPDATE OF r`, giftID).Scan(
|
|
&revision.ID, &revision.GiftID, &revision.UpgradeStars, &revision.SupplyTotal,
|
|
&revision.Issued, &revision.SlugPrefix, &status)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftCollectibleUnavailable
|
|
}
|
|
if err != nil {
|
|
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("lock active collectible revision: %w", err)
|
|
}
|
|
if status != "published" {
|
|
return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftCollectibleUnavailable
|
|
}
|
|
return revision, nil
|
|
}
|
|
|
|
func debitStarGiftUpgrade(ctx context.Context, tx pgx.Tx, userID, amount int64, peer domain.Peer, date int) (domain.StarsBalance, error) {
|
|
result := domain.StarsBalance{UserID: userID}
|
|
var balance int64
|
|
err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id=$1 FOR UPDATE`, userID).Scan(&balance, &result.Granted)
|
|
if amount == 0 && errors.Is(err, pgx.ErrNoRows) {
|
|
return result, nil
|
|
}
|
|
if errors.Is(err, pgx.ErrNoRows) || (err == nil && balance < amount) {
|
|
return domain.StarsBalance{}, domain.ErrStarsInsufficient
|
|
}
|
|
if err != nil {
|
|
return domain.StarsBalance{}, fmt.Errorf("lock stars balance for gift upgrade: %w", err)
|
|
}
|
|
if amount == 0 {
|
|
result.Balance = balance
|
|
return result, nil
|
|
}
|
|
if err := tx.QueryRow(ctx, `UPDATE stars_balances SET balance=balance-$2, updated_at=now() WHERE user_id=$1 RETURNING balance`, userID, amount).Scan(&result.Balance); err != nil {
|
|
return domain.StarsBalance{}, fmt.Errorf("debit star gift upgrade: %w", err)
|
|
}
|
|
if err := insertStarsTxn(ctx, tx, userID, -amount, domain.StarsReasonGiftUpgrade, peer, date, "Star gift upgrade", ""); err != nil {
|
|
return domain.StarsBalance{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func chooseCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, revisionID int64) (int64, error) {
|
|
rows, err := tx.Query(ctx, fmt.Sprintf(`SELECT id, rarity_permille FROM %s WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, table), revisionID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("list collectible attributes for issuance: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
type weightedID struct {
|
|
id int64
|
|
weight int
|
|
}
|
|
items := make([]weightedID, 0)
|
|
total := 0
|
|
for rows.Next() {
|
|
var item weightedID
|
|
if err := rows.Scan(&item.id, &item.weight); err != nil {
|
|
return 0, err
|
|
}
|
|
items = append(items, item)
|
|
total += item.weight
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
if len(items) == 0 || total != 1000 {
|
|
return 0, domain.ErrStarGiftCollectibleInvalid
|
|
}
|
|
draw, err := rand.Int(rand.Reader, big.NewInt(int64(total)))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("draw collectible attribute: %w", err)
|
|
}
|
|
value := int(draw.Int64())
|
|
for _, item := range items {
|
|
if value < item.weight {
|
|
return item.id, nil
|
|
}
|
|
value -= item.weight
|
|
}
|
|
return 0, domain.ErrStarGiftCollectibleInvalid
|
|
}
|
|
|
|
func starGiftUpgradeRandomID(senderID, ownerID int64, commandKey string) int64 {
|
|
sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%d:%s", senderID, ownerID, commandKey)))
|
|
id := int64(binary.LittleEndian.Uint64(sum[:8]) & 0x7fffffffffffffff)
|
|
if id == 0 {
|
|
id = 1
|
|
}
|
|
return id
|
|
}
|
|
|
|
func (s *StarGiftUpgradeStore) loadUpgradeReplay(ctx context.Context, req domain.StarGiftUpgradeRequest, original domain.SavedStarGift, sent domain.SendPrivateTextResult) (domain.StarGiftUpgradeResult, error) {
|
|
saved, found, err := NewStarGiftStore(s.db).GetByRef(ctx, req.Ref)
|
|
if err != nil || !found || saved.UniqueGiftID == 0 {
|
|
if err == nil {
|
|
err = domain.ErrStarGiftCollectibleInvalid
|
|
}
|
|
return domain.StarGiftUpgradeResult{}, err
|
|
}
|
|
unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, saved.UniqueGiftID)
|
|
if err != nil || !found {
|
|
if err == nil {
|
|
err = domain.ErrStarGiftCollectibleInvalid
|
|
}
|
|
return domain.StarGiftUpgradeResult{}, err
|
|
}
|
|
var commandUniqueID int64
|
|
var balanceAfter int64
|
|
if err := s.db.QueryRow(ctx, `SELECT unique_gift_id, balance_after FROM star_gift_upgrade_commands WHERE user_id=$1 AND command_key=$2`, req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&commandUniqueID, &balanceAfter); err != nil {
|
|
return domain.StarGiftUpgradeResult{}, fmt.Errorf("load star gift upgrade replay: %w", err)
|
|
}
|
|
if commandUniqueID != unique.ID || saved.ID != original.ID {
|
|
return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid
|
|
}
|
|
uniqueCopy := unique
|
|
saved.Unique = &uniqueCopy
|
|
return domain.StarGiftUpgradeResult{
|
|
Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.UserID, Balance: balanceAfter},
|
|
Send: sent, Duplicate: true,
|
|
}, nil
|
|
}
|
|
|
|
var _ store.StarGiftUpgradeStore = (*StarGiftUpgradeStore)(nil)
|