feat: sync collectible star gifts
This commit is contained in:
parent
47fcf0ea41
commit
5ecf4e912d
64 changed files with 7559 additions and 403 deletions
365
internal/store/postgres/star_gift_upgrade.go
Normal file
365
internal/store/postgres/star_gift_upgrade.go
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue