removed all "paid" features - no more stars, gifts, or grams

This commit is contained in:
onysd 2026-08-07 01:50:10 +03:00
parent d4451d753c
commit 21d8e91756
165 changed files with 318 additions and 40948 deletions

View file

@ -1,557 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// AccountRatingStore is the PostgreSQL implementation of the composite account
// rating read model and its contribution ledger.
//
// account_rating is derived state: it is always rebuildable from the contributing
// tables plus the 'manual' rows of account_rating_events, which is exactly what
// AccountRatingSignals gathers. Writes use optimistic concurrency on the stored
// version so a background recompute and an admin adjustment cannot silently
// overwrite each other.
type AccountRatingStore struct {
db sqlcgen.DBTX
}
// NewAccountRatingStore builds the store on a pgx pool or transaction.
func NewAccountRatingStore(db sqlcgen.DBTX) *AccountRatingStore {
return &AccountRatingStore{db: db}
}
var _ store.AccountRatingStore = (*AccountRatingStore)(nil)
const (
defaultAccountRatingListLimit = 50
maxAccountRatingListLimit = 200
)
const accountRatingColumns = `user_id, level, stars, current_level_stars, next_level_stars,
stars_component, activity_component, penalty_component, manual_component,
pending_stars, pending_date, computed_at, updated_at, version`
// accountRatingColumnsQualified is the same projection for queries that join, so
// the shared column names stay unambiguous.
const accountRatingColumnsQualified = `r.user_id, r.level, r.stars, r.current_level_stars, r.next_level_stars,
r.stars_component, r.activity_component, r.penalty_component, r.manual_component,
r.pending_stars, r.pending_date, r.computed_at, r.updated_at, r.version`
// AccountRating returns the stored projection, distinguishing "never computed"
// from "computed as zero".
func (s *AccountRatingStore) AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
if s == nil || s.db == nil {
return domain.AccountRating{}, fmt.Errorf("account rating store is not configured")
}
if userID <= 0 {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
rating, err := scanAccountRating(s.db.QueryRow(ctx, `
SELECT `+accountRatingColumns+` FROM account_rating WHERE user_id = $1`, userID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
if err != nil {
return domain.AccountRating{}, fmt.Errorf("get account rating: %w", err)
}
return rating, nil
}
// AccountRatingBatch resolves several users in one round trip.
func (s *AccountRatingStore) AccountRatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
out := make(map[int64]domain.AccountRating, len(userIDs))
filtered := make([]int64, 0, len(userIDs))
for _, userID := range userIDs {
if userID > 0 {
filtered = append(filtered, userID)
}
}
if len(filtered) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT `+accountRatingColumns+` FROM account_rating WHERE user_id = ANY($1::bigint[])`, filtered)
if err != nil {
return nil, fmt.Errorf("list account ratings batch: %w", err)
}
defer rows.Close()
for rows.Next() {
rating, err := scanAccountRating(rows)
if err != nil {
return nil, fmt.Errorf("scan account rating batch: %w", err)
}
out[rating.UserID] = rating
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account ratings batch: %w", err)
}
return out, nil
}
// SaveAccountRating upserts the projection under optimistic concurrency: the
// caller submits the version it intends to write (prev.Version + 1, which is what
// domain.ResolveAccountRatingPending produces), and the update only lands when the
// stored row is still one version behind. A stale write reports changed=false and
// returns the row that won, so the caller can recompute instead of retrying blind.
func (s *AccountRatingStore) SaveAccountRating(ctx context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
if s == nil || s.db == nil {
return domain.AccountRating{}, false, fmt.Errorf("account rating store is not configured")
}
if rating.UserID <= 0 || rating.Level < 0 || rating.Level > domain.MaxAccountRatingLevel ||
rating.CurrentLevelStars < 0 || rating.StarsComponent < 0 ||
rating.ActivityComponent < 0 || rating.PenaltyComponent < 0 {
return domain.AccountRating{}, false, domain.ErrAccountRatingAdjustmentInvalid
}
if rating.Version <= 0 {
rating.Version = 1
}
now := time.Now().UTC()
if rating.ComputedAt.IsZero() {
rating.ComputedAt = now
}
if rating.UpdatedAt.IsZero() {
rating.UpdatedAt = now
}
// The schema pairs pending_stars with pending_date; a half-filled pending
// record is normalised away rather than rejected by the CHECK at runtime.
if rating.PendingStars == 0 || rating.PendingDate.IsZero() {
rating.PendingStars = 0
rating.PendingDate = time.Time{}
}
var nextLevelStars any
if rating.HasNextLevel && rating.NextLevelStars > rating.CurrentLevelStars {
nextLevelStars = rating.NextLevelStars
}
var pendingDate any
if rating.PendingStars != 0 {
pendingDate = rating.PendingDate.UTC()
}
stored, err := scanAccountRating(s.db.QueryRow(ctx, `
INSERT INTO account_rating (
user_id, level, stars, current_level_stars, next_level_stars,
stars_component, activity_component, penalty_component, manual_component,
pending_stars, pending_date, computed_at, updated_at, version
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
ON CONFLICT (user_id) DO UPDATE SET
level = EXCLUDED.level,
stars = EXCLUDED.stars,
current_level_stars = EXCLUDED.current_level_stars,
next_level_stars = EXCLUDED.next_level_stars,
stars_component = EXCLUDED.stars_component,
activity_component = EXCLUDED.activity_component,
penalty_component = EXCLUDED.penalty_component,
manual_component = EXCLUDED.manual_component,
pending_stars = EXCLUDED.pending_stars,
pending_date = EXCLUDED.pending_date,
computed_at = EXCLUDED.computed_at,
updated_at = EXCLUDED.updated_at,
version = EXCLUDED.version
WHERE account_rating.version = EXCLUDED.version - 1
RETURNING `+accountRatingColumns,
rating.UserID, rating.Level, rating.Stars, rating.CurrentLevelStars, nextLevelStars,
rating.StarsComponent, rating.ActivityComponent, rating.PenaltyComponent, rating.ManualComponent,
rating.PendingStars, pendingDate, rating.ComputedAt.UTC(), rating.UpdatedAt.UTC(), rating.Version,
))
if errors.Is(err, pgx.ErrNoRows) {
// The guard rejected the write; report the row that is actually stored.
current, getErr := s.AccountRating(ctx, rating.UserID)
if getErr != nil {
return domain.AccountRating{}, false, getErr
}
return current, false, nil
}
if err != nil {
return domain.AccountRating{}, false, fmt.Errorf("save account rating: %w", err)
}
return stored, true, nil
}
// AccountRatingSignals gathers the raw contribution snapshot for one user.
//
// Sources, and why each one:
//
// stars received / spent stars_transactions, split by the sign of amount: that
// ledger is the single authoritative record of Stars
// movement for a user, and stars_transactions_user_id_idx
// (user_id, id DESC) bounds the scan to the user's rows.
// gifts received peer_star_gifts for the user peer, restricted to
// lifecycle_status = 'active' -- the weight rewards gifts
// actually held, not ones converted, burned or exported
// away. peer_star_gifts_owner_profile_order_idx is the
// partial index on exactly that predicate and leads with
// (owner_peer_type, owner_peer_id).
// moderation cases moderation_cases against this user peer, restricted to
// the statuses that follow a *violation* decision:
// 'action_pending' (violation decided, actions running),
// 'action_failed' (violation decided, action delivery
// broke) and 'resolved' (violation decided and actions
// applied). 'dismissed' covers both no_violation and a
// granted appeal, and 'open'/'in_review'/'appeal_review'
// are undecided, so none of them penalise the account.
// scam / fake users.scam / users.fake, the peer flags 0136 added.
// account age users.created_at, floored to whole days.
// messages sent message_boxes, counted through
// message_boxes_private_sender_live_idx
// (message_sender_id, private_message_id) WHERE NOT
// deleted. This is the cheapest trustworthy source: the
// index leads with the sender and is coverable, so the
// count needs no heap access. Every private message
// materialises one box per participant, hence
// count(DISTINCT private_message_id) rather than
// count(*). Channel posts are deliberately excluded --
// channel_messages has no sender-leading index, so
// attributing them would cost a full table scan.
// manual sum of account_rating_events.amount where
// kind = 'manual', which is the part of the score that
// must survive a full recompute.
func (s *AccountRatingStore) AccountRatingSignals(ctx context.Context, userID int64) (domain.AccountRatingSignals, error) {
if s == nil || s.db == nil {
return domain.AccountRatingSignals{}, fmt.Errorf("account rating store is not configured")
}
if userID <= 0 {
return domain.AccountRatingSignals{}, domain.ErrUserNotFound
}
signals := domain.AccountRatingSignals{UserID: userID}
err := s.db.QueryRow(ctx, `
SELECT
COALESCE((SELECT sum(amount) FROM stars_transactions WHERE user_id = u.id AND amount > 0), 0),
COALESCE((SELECT -sum(amount) FROM stars_transactions WHERE user_id = u.id AND amount < 0), 0),
COALESCE((
SELECT count(DISTINCT private_message_id) FROM message_boxes
WHERE message_sender_id = u.id AND NOT deleted
), 0),
GREATEST(0, FLOOR(EXTRACT(EPOCH FROM ($2::timestamptz - u.created_at)) / 86400))::bigint,
COALESCE((
SELECT count(*) FROM peer_star_gifts
WHERE owner_peer_type = 'user' AND owner_peer_id = u.id AND lifecycle_status = 'active'
), 0),
COALESCE((
SELECT count(*) FROM moderation_cases
WHERE target_peer_type = 'user' AND target_peer_id = u.id
AND status IN ('action_pending', 'action_failed', 'resolved')
), 0),
u.scam,
u.fake,
COALESCE((
SELECT sum(amount) FROM account_rating_events
WHERE user_id = u.id AND kind = 'manual'
), 0)
FROM users u
WHERE u.id = $1`, userID, time.Now().UTC()).Scan(
&signals.StarsReceived, &signals.StarsSpent, &signals.MessagesSent,
&signals.AccountAgeDays, &signals.GiftsReceived, &signals.ModerationCases,
&signals.Scam, &signals.Fake, &signals.Manual,
)
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountRatingSignals{}, domain.ErrUserNotFound
}
if err != nil {
return domain.AccountRatingSignals{}, fmt.Errorf("gather account rating signals: %w", err)
}
return signals, nil
}
// AdjustAccountRating appends a manual adjustment to the ledger. It does not
// recompute the projection: the caller pairs it with SaveAccountRating so the new
// manual total is folded in through the same formula as every other signal.
// Replaying the same CommandKey returns the recorded event and applied=false.
func (s *AccountRatingStore) AdjustAccountRating(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
if s == nil || s.db == nil {
return domain.AccountRatingEvent{}, false, fmt.Errorf("account rating store is not configured")
}
req.Reason = strings.TrimSpace(req.Reason)
req.Actor = strings.TrimSpace(req.Actor)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return domain.AccountRatingEvent{}, false, err
}
var event domain.AccountRatingEvent
applied := false
err := withTx(ctx, s.db, "adjust account rating", func(tx pgx.Tx) error {
if existing, found, err := accountRatingEventByCommandKey(ctx, tx, req.CommandKey); err != nil {
return err
} else if found {
event = existing
return nil
}
event = domain.AccountRatingEvent{
UserID: req.UserID,
Kind: domain.AccountRatingEventManual,
Amount: req.Amount,
Reason: req.Reason,
Actor: req.Actor,
CommandKey: req.CommandKey,
CreatedAt: time.Now().UTC(),
}
// DO NOTHING on the partial command_key index closes the window between the
// replay lookup and the insert: a concurrent retry of the same command
// records nothing and falls back to reading the row that won.
err := tx.QueryRow(ctx, `
INSERT INTO account_rating_events (user_id, kind, amount, reason, actor, command_key, created_at)
VALUES ($1,'manual',$2,$3,$4,NULLIF($5,''),$6)
ON CONFLICT (command_key) WHERE command_key IS NOT NULL DO NOTHING
RETURNING id`, event.UserID, event.Amount, event.Reason, event.Actor, event.CommandKey, event.CreatedAt).
Scan(&event.ID)
if errors.Is(err, pgx.ErrNoRows) {
existing, found, lookupErr := accountRatingEventByCommandKey(ctx, tx, req.CommandKey)
if lookupErr != nil {
return lookupErr
}
if !found {
return fmt.Errorf("insert account rating adjustment: conflicting command %q vanished", req.CommandKey)
}
event = existing
return nil
}
if err != nil {
return fmt.Errorf("insert account rating adjustment: %w", err)
}
applied = true
return nil
})
if err != nil {
return domain.AccountRatingEvent{}, false, err
}
return event, applied, nil
}
// ListAccountRatings is the admin leaderboard query. The order matches
// account_rating_leaderboard_idx (level DESC, stars DESC, user_id) and BeforeID is
// a keyset cursor: the cursor row's own (level, stars) are read back so paging
// stays consistent across the compound order instead of only over user ids.
func (s *AccountRatingStore) ListAccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
if filter.MinLevel < 0 || filter.MinLevel > domain.MaxAccountRatingLevel {
return nil, domain.ErrAccountRatingAdjustmentInvalid
}
limit := filter.Limit
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
if limit > maxAccountRatingListLimit {
limit = maxAccountRatingListLimit
}
rows, err := s.db.Query(ctx, `
WITH cursor_row AS (
SELECT level AS c_level, stars AS c_stars, user_id AS c_user_id
FROM account_rating WHERE $3 <> 0 AND user_id = $3
)
SELECT `+accountRatingColumnsQualified+`
FROM account_rating r
LEFT JOIN cursor_row c ON true
WHERE r.level >= $1
AND ($2 = 0 OR r.user_id = $2)
AND (
c.c_user_id IS NULL
OR r.level < c.c_level
OR (r.level = c.c_level AND r.stars < c.c_stars)
OR (r.level = c.c_level AND r.stars = c.c_stars AND r.user_id > c.c_user_id)
)
ORDER BY r.level DESC, r.stars DESC, r.user_id
LIMIT $4`, filter.MinLevel, filter.UserID, filter.BeforeID, limit)
if err != nil {
return nil, fmt.Errorf("list account ratings: %w", err)
}
defer rows.Close()
out := make([]domain.AccountRating, 0, limit)
for rows.Next() {
rating, err := scanAccountRating(rows)
if err != nil {
return nil, fmt.Errorf("scan account rating: %w", err)
}
out = append(out, rating)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account ratings: %w", err)
}
return out, nil
}
// AccountRatingEvents returns the ledger for one user, newest first, over
// account_rating_events_user_idx (user_id, id DESC).
func (s *AccountRatingStore) AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
if userID <= 0 {
return nil, domain.ErrAccountRatingNotFound
}
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
if limit > maxAccountRatingListLimit {
limit = maxAccountRatingListLimit
}
rows, err := s.db.Query(ctx, `
SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at
FROM account_rating_events
WHERE user_id = $1
ORDER BY id DESC
LIMIT $2`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list account rating events: %w", err)
}
defer rows.Close()
out := make([]domain.AccountRatingEvent, 0, limit)
for rows.Next() {
event, err := scanAccountRatingEvent(rows)
if err != nil {
return nil, fmt.Errorf("scan account rating event: %w", err)
}
out = append(out, event)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account rating events: %w", err)
}
return out, nil
}
// StaleAccountRatings returns the users whose projection is older than the
// horizon, ordered so the walk follows account_rating_stale_idx
// (computed_at, user_id) exactly.
func (s *AccountRatingStore) StaleAccountRatings(ctx context.Context, olderThanUnix int64, limit int) ([]int64, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
if olderThanUnix <= 0 {
return nil, nil
}
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
if limit > maxAccountRatingListLimit {
limit = maxAccountRatingListLimit
}
rows, err := s.db.Query(ctx, `
SELECT user_id FROM account_rating
WHERE computed_at < to_timestamp($1)
ORDER BY computed_at, user_id
LIMIT $2`, olderThanUnix, limit)
if err != nil {
return nil, fmt.Errorf("list stale account ratings: %w", err)
}
defer rows.Close()
out := make([]int64, 0, limit)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, fmt.Errorf("scan stale account rating: %w", err)
}
out = append(out, userID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate stale account ratings: %w", err)
}
return out, nil
}
// UnratedAccounts returns accounts that have no projection yet, oldest account
// first so the walk is stable and every account is eventually reached.
//
// Three kinds of account are skipped, per domain.RatableAccount: bots, which do
// not transact on their own behalf; the built-in service accounts, which are
// infrastructure -- and note that the platform account is not flagged is_bot, so
// excluding bots alone would still have seeded it; and deleted accounts, which are
// tombstones whose every profile field has already been cleared.
func (s *AccountRatingStore) UnratedAccounts(ctx context.Context, limit int) ([]int64, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
if limit > maxAccountRatingListLimit {
limit = maxAccountRatingListLimit
}
rows, err := s.db.Query(ctx, `
SELECT u.id FROM users u
WHERE NOT u.is_bot
AND u.deleted_at IS NULL
AND u.id <> ALL($2::bigint[])
AND NOT EXISTS (SELECT 1 FROM account_rating r WHERE r.user_id = u.id)
ORDER BY u.created_at, u.id
LIMIT $1`, limit, domain.SystemUserIDs())
if err != nil {
return nil, fmt.Errorf("list unrated accounts: %w", err)
}
defer rows.Close()
out := make([]int64, 0, limit)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, fmt.Errorf("scan unrated account: %w", err)
}
out = append(out, userID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate unrated accounts: %w", err)
}
return out, nil
}
func accountRatingEventByCommandKey(ctx context.Context, db sqlcgen.DBTX, commandKey string) (domain.AccountRatingEvent, bool, error) {
if commandKey == "" {
return domain.AccountRatingEvent{}, false, nil
}
event, err := scanAccountRatingEvent(db.QueryRow(ctx, `
SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at
FROM account_rating_events WHERE command_key = $1`, commandKey))
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountRatingEvent{}, false, nil
}
if err != nil {
return domain.AccountRatingEvent{}, false, fmt.Errorf("lookup account rating command: %w", err)
}
return event, true, nil
}
func scanAccountRating(row pgx.Row) (domain.AccountRating, error) {
var rating domain.AccountRating
var nextLevelStars pgtype.Int8
var pendingDate pgtype.Timestamptz
if err := row.Scan(&rating.UserID, &rating.Level, &rating.Stars, &rating.CurrentLevelStars,
&nextLevelStars, &rating.StarsComponent, &rating.ActivityComponent,
&rating.PenaltyComponent, &rating.ManualComponent, &rating.PendingStars,
&pendingDate, &rating.ComputedAt, &rating.UpdatedAt, &rating.Version); err != nil {
return domain.AccountRating{}, err
}
if nextLevelStars.Valid {
rating.NextLevelStars = nextLevelStars.Int64
rating.HasNextLevel = true
}
if pendingDate.Valid {
rating.PendingDate = pendingDate.Time.UTC()
}
rating.ComputedAt = rating.ComputedAt.UTC()
rating.UpdatedAt = rating.UpdatedAt.UTC()
return rating, nil
}
func scanAccountRatingEvent(row pgx.Row) (domain.AccountRatingEvent, error) {
var event domain.AccountRatingEvent
var kind string
if err := row.Scan(&event.ID, &event.UserID, &kind, &event.Amount, &event.Reason,
&event.Actor, &event.CommandKey, &event.CreatedAt); err != nil {
return domain.AccountRatingEvent{}, err
}
event.Kind = domain.AccountRatingEventKind(kind)
event.CreatedAt = event.CreatedAt.UTC()
return event, nil
}

View file

@ -1,462 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
// ratingTestUser inserts a user row with an explicit creation date so the account
// age signal is deterministic.
func ratingTestUser(t *testing.T, pool *pgxpool.Pool, seed int64, createdAt time.Time) int64 {
t.Helper()
ctx := context.Background()
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO users (access_hash, phone, first_name, created_at, updated_at)
VALUES ($1, $2, 'rating test', $3, $3)
RETURNING id`, seed, fmt.Sprintf("%d", seed), createdAt).Scan(&id); err != nil {
t.Fatalf("insert rating test user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return id
}
// ratingTestCatalogRevision publishes a throwaway star gift so peer_star_gifts
// rows can satisfy their catalog revision foreign key.
func ratingTestCatalogRevision(t *testing.T, pool *pgxpool.Pool) (revisionID, giftID int64) {
t.Helper()
ctx := context.Background()
suffix := randomSuffix(t)
docID := time.Now().UnixNano() & 0x7fffffffffffffff
entry, err := NewStarGiftStore(pool).CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Stars: 50, ConvertStars: 50, Enabled: true,
Document: domain.Document{
ID: docID, AccessHash: docID + 1, MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
},
Blob: domain.FileBlob{
LocationKey: "doc:" + fmt.Sprint(docID), Backend: domain.MediaBackendLocalFS,
ObjectKey: "rating-star-gift", Size: 4, SHA256: make([]byte, 32),
MimeType: "application/x-tgsticker",
},
Animation: domain.StarGiftAnimation{
JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`),
SHA256: make([]byte, 32), SourceFormat: domain.StarGiftAnimationTGS, Width: 512, Height: 512,
},
Actor: "test", CommandID: "rating-star-gift-" + suffix,
})
if err != nil {
t.Fatalf("create catalog revision: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT id FROM star_gift_catalog_revisions WHERE gift_id = $1 ORDER BY revision DESC LIMIT 1`,
entry.Gift.ID).Scan(&revisionID); err != nil {
t.Fatalf("read catalog revision id: %v", err)
}
t.Cleanup(func() {
cleanupCtx := context.Background()
_, _ = pool.Exec(cleanupCtx, `DELETE FROM star_gift_catalog WHERE gift_id = $1`, entry.Gift.ID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM star_gift_catalog_revisions WHERE gift_id = $1`, entry.Gift.ID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM file_blobs WHERE location_key = $1`, "doc:"+fmt.Sprint(docID))
_, _ = pool.Exec(cleanupCtx, `DELETE FROM documents WHERE id = $1`, docID)
})
return revisionID, entry.Gift.ID
}
// TestAccountRatingSaveVersionConflict covers the optimistic write: a first save
// creates the row, a stale version is refused without an error, and the next
// version wins.
func TestAccountRatingSaveVersionConflict(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
userID := ratingTestUser(t, pool, 3_100_000_000+seed, time.Now().UTC().AddDate(0, 0, -30))
if _, err := store.AccountRating(ctx, userID); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("missing rating err = %v, want ErrAccountRatingNotFound", err)
}
now := time.Now().UTC().Truncate(time.Millisecond)
signals := domain.AccountRatingSignals{UserID: userID, StarsReceived: 900, MessagesSent: 10, AccountAgeDays: 30}
computed := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
stored, changed, err := store.SaveAccountRating(ctx, computed)
if err != nil || !changed {
t.Fatalf("first save changed=%v err=%v", changed, err)
}
if stored.Version != 1 || stored.Stars != computed.Stars || stored.Level != computed.Level ||
stored.HasNextLevel != computed.HasNextLevel || stored.NextLevelStars != computed.NextLevelStars {
t.Fatalf("stored = %+v want %+v", stored, computed)
}
read, err := store.AccountRating(ctx, userID)
if err != nil || read != stored {
t.Fatalf("read = %+v stored = %+v err=%v", read, stored, err)
}
// A second writer that still believes version 0 is stale: no error, no write.
stale := computed
stale.Stars = 999_999
conflicted, changed, err := store.SaveAccountRating(ctx, stale)
if err != nil || changed {
t.Fatalf("stale save changed=%v err=%v", changed, err)
}
if conflicted.Stars != stored.Stars || conflicted.Version != 1 {
t.Fatalf("conflicted = %+v, stored row must win", conflicted)
}
// The recompute that carries the right version applies, including a pending
// delta that must round-trip through the paired pending_stars/pending_date.
next := domain.ResolveAccountRatingPending(stored,
domain.ComputeAccountRating(domain.AccountRatingSignals{UserID: userID, StarsReceived: 40_000}, domain.DefaultAccountRatingWeights(), now),
time.Hour, now)
if next.PendingStars == 0 || next.PendingDate.IsZero() {
t.Fatalf("expected a parked pending delta, got %+v", next)
}
applied, changed, err := store.SaveAccountRating(ctx, next)
if err != nil || !changed {
t.Fatalf("versioned save changed=%v err=%v", changed, err)
}
if applied.Version != 2 || applied.PendingStars != next.PendingStars ||
!applied.PendingDate.Equal(next.PendingDate.UTC()) {
t.Fatalf("applied = %+v want pending %d at %v", applied, next.PendingStars, next.PendingDate)
}
pending, ok := applied.PendingLevel()
if !ok || pending.Stars <= applied.Stars {
t.Fatalf("pending projection = %+v ok=%v", pending, ok)
}
batch, err := store.AccountRatingBatch(ctx, []int64{userID, userID + 1})
if err != nil || len(batch) != 1 || batch[userID].Version != 2 {
t.Fatalf("batch = %+v err=%v", batch, err)
}
list, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{UserID: userID, Limit: 10})
if err != nil || len(list) != 1 || list[0].UserID != userID {
t.Fatalf("list = %+v err=%v", list, err)
}
stale2, err := store.StaleAccountRatings(ctx, now.Add(time.Minute).Unix(), 100)
if err != nil {
t.Fatalf("stale: %v", err)
}
if !containsInt64(stale2, userID) {
t.Fatalf("stale ratings %v must contain %d", stale2, userID)
}
fresh, err := store.StaleAccountRatings(ctx, now.Add(-time.Hour).Unix(), 100)
if err != nil {
t.Fatalf("stale fresh: %v", err)
}
if containsInt64(fresh, userID) {
t.Fatalf("rating computed at %v must not be stale before it", applied.ComputedAt)
}
}
// TestAccountRatingAdjustmentIdempotency covers the manual ledger: a replayed
// command key records nothing new and the manual total feeds the recompute.
func TestAccountRatingAdjustmentIdempotency(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
userID := ratingTestUser(t, pool, 3_200_000_000+seed, time.Now().UTC().AddDate(0, 0, -10))
key := fmt.Sprintf("adjust-%d", seed)
req := domain.AdjustAccountRatingRequest{
UserID: userID, Amount: 750, Reason: "community award", Actor: "ops", CommandKey: key,
}
event, applied, err := store.AdjustAccountRating(ctx, req)
if err != nil || !applied {
t.Fatalf("adjust applied=%v err=%v", applied, err)
}
if event.ID == 0 || event.Kind != domain.AccountRatingEventManual || event.Amount != 750 ||
event.CommandKey != key {
t.Fatalf("event = %+v", event)
}
replay, applied, err := store.AdjustAccountRating(ctx, req)
if err != nil || applied || replay.ID != event.ID {
t.Fatalf("replay applied=%v event=%+v err=%v", applied, replay, err)
}
// A second, distinct adjustment accumulates rather than replacing.
if _, applied, err := store.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: userID, Amount: -250, Reason: "partial revoke", Actor: "ops",
CommandKey: key + "-b",
}); err != nil || !applied {
t.Fatalf("second adjust applied=%v err=%v", applied, err)
}
events, err := store.AccountRatingEvents(ctx, userID, 10)
if err != nil || len(events) != 2 || events[0].Amount != -250 || events[1].Amount != 750 {
t.Fatalf("events = %+v err=%v", events, err)
}
if _, _, err := store.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: userID, Amount: 0, CommandKey: key + "-c",
}); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("zero adjustment err = %v, want ErrAccountRatingAdjustmentInvalid", err)
}
signals, err := store.AccountRatingSignals(ctx, userID)
if err != nil {
t.Fatalf("signals: %v", err)
}
if signals.Manual != 500 {
t.Fatalf("manual signal = %d, want 500", signals.Manual)
}
}
// TestAccountRatingSignalsSources pins where each contribution comes from: the
// Stars ledger sign split, saved gifts, upheld moderation cases, the peer flags,
// account age and the private-message count.
func TestAccountRatingSignalsSources(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
createdAt := time.Now().UTC().AddDate(0, 0, -45)
userID := ratingTestUser(t, pool, 3_300_000_000+seed, createdAt)
if _, err := pool.Exec(ctx, `
INSERT INTO stars_transactions (user_id, amount, reason, date)
VALUES ($1, 1500, 'gift', 0), ($1, 500, 'reaction', 0), ($1, -400, 'purchase', 0)`, userID); err != nil {
t.Fatalf("seed stars: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM stars_transactions WHERE user_id = $1`, userID)
})
revisionID, giftID := ratingTestCatalogRevision(t, pool)
if _, err := pool.Exec(ctx, `
INSERT INTO peer_star_gifts (owner_peer_type, owner_peer_id, msg_id, gift_id, gift_date, catalog_revision_id, lifecycle_status, converted)
VALUES ('user', $1, 1, $2, 0, $3, 'active', false),
('user', $1, 2, $2, 0, $3, 'active', false),
('user', $1, 3, $2, 0, $3, 'converted', true)`, userID, giftID, revisionID); err != nil {
t.Fatalf("seed gifts: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(),
`DELETE FROM peer_star_gifts WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, userID)
})
// Only statuses that follow a violation decision count; 'dismissed' (which
// covers no_violation and a granted appeal) and undecided states do not.
now := time.Now().UTC()
for _, status := range []string{"resolved", "action_failed", "dismissed", "open"} {
if _, err := pool.Exec(ctx, `
INSERT INTO moderation_cases (
target_peer_type, target_peer_id, status, severity, report_count,
distinct_reporter_count, first_report_at, last_report_at, created_at, updated_at
) VALUES ('user', $1, $2, 1, 1, 1, $3, $3, $3, $3)`, userID, status, now); err != nil {
t.Fatalf("seed moderation case %s: %v", status, err)
}
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(),
`DELETE FROM moderation_cases WHERE target_peer_type = 'user' AND target_peer_id = $1`, userID)
})
if _, err := pool.Exec(ctx, `UPDATE users SET scam = true WHERE id = $1`, userID); err != nil {
t.Fatalf("set scam: %v", err)
}
// Two private messages, each materialised as the sender's and the recipient's
// box: the distinct count must report two, not four.
peerID := ratingTestUser(t, pool, 3_350_000_000+seed, createdAt)
for i := 1; i <= 2; i++ {
var messageID int64
if err := pool.QueryRow(ctx, `
INSERT INTO private_messages (sender_user_id, recipient_user_id, message_date, body)
VALUES ($1, $2, 0, 'hi')
RETURNING id`, userID, peerID).Scan(&messageID); err != nil {
t.Fatalf("seed private message: %v", err)
}
for _, box := range []struct {
owner int64
peer int64
outgoing bool
}{{userID, peerID, true}, {peerID, userID, false}} {
if _, err := pool.Exec(ctx, `
INSERT INTO message_boxes (
owner_user_id, box_id, private_message_id, message_sender_id, peer_type, peer_id,
from_user_id, message_date, outgoing, body
) VALUES ($1, $2, $3, $4, 'user', $5, $4, 0, $6, 'hi')`,
box.owner, i, messageID, userID, box.peer, box.outgoing); err != nil {
t.Fatalf("seed message box: %v", err)
}
}
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM message_boxes WHERE message_sender_id = $1`, userID)
_, _ = pool.Exec(context.Background(), `DELETE FROM private_messages WHERE sender_user_id = $1`, userID)
})
signals, err := store.AccountRatingSignals(ctx, userID)
if err != nil {
t.Fatalf("signals: %v", err)
}
if signals.UserID != userID || signals.StarsReceived != 2000 || signals.StarsSpent != 400 ||
signals.GiftsReceived != 2 || signals.ModerationCases != 2 || !signals.Scam || signals.Fake ||
signals.MessagesSent != 2 || signals.AccountAgeDays != 45 || signals.Manual != 0 {
t.Fatalf("signals = %+v", signals)
}
rating := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
if rating.PenaltyComponent == 0 || rating.StarsComponent != 2000+100 {
t.Fatalf("computed rating = %+v", rating)
}
if _, err := store.AccountRatingSignals(ctx, userID+7_000_000); !errors.Is(err, domain.ErrUserNotFound) {
t.Fatalf("signals for unknown user err = %v, want ErrUserNotFound", err)
}
}
// TestAccountRatingLeaderboardPaging covers the keyset walk over
// (level DESC, stars DESC, user_id).
func TestAccountRatingLeaderboardPaging(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
now := time.Now().UTC()
scores := []int64{100, 400, 900, 1600}
ids := make([]int64, 0, len(scores))
for i, score := range scores {
userID := ratingTestUser(t, pool, 3_400_000_000+seed+int64(i), now.AddDate(0, 0, -1))
ids = append(ids, userID)
rating := domain.ComputeAccountRating(domain.AccountRatingSignals{UserID: userID, Manual: score},
domain.DefaultAccountRatingWeights(), now)
if _, changed, err := store.SaveAccountRating(ctx, rating); err != nil || !changed {
t.Fatalf("save rating %d: changed=%v err=%v", userID, changed, err)
}
}
page, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: 2, Limit: 2})
if err != nil || len(page) != 2 {
t.Fatalf("first page = %+v err=%v", page, err)
}
if page[0].Level < page[1].Level {
t.Fatalf("leaderboard must be level-descending: %+v", page)
}
next, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{
MinLevel: 2, BeforeID: page[len(page)-1].UserID, Limit: 10,
})
if err != nil {
t.Fatalf("second page: %v", err)
}
for _, item := range next {
if item.UserID == page[0].UserID || item.UserID == page[1].UserID {
t.Fatalf("keyset page repeated user %d", item.UserID)
}
if item.Level > page[len(page)-1].Level {
t.Fatalf("keyset page went backwards: %+v after %+v", item, page)
}
}
if _, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: -1}); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("negative level filter must be rejected")
}
_ = ids
}
// TestAccountRatingUnratedAccountsSeedsTheReadModel proves the SQL behind the
// bootstrap pass: only accounts with no projection are returned, bots and deleted
// tombstones are excluded, the walk is oldest-account-first, and a user drops out of
// the candidate set the moment a projection exists.
//
// Without this query the read model can never populate itself -- StaleAccountRatings
// walks account_rating and so cannot return a user who is not in it.
func TestAccountRatingUnratedAccountsSeedsTheReadModel(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
base := time.Now().UTC().Add(-72 * time.Hour).Truncate(time.Second)
oldest := ratingTestUser(t, pool, 4_100_000_000+seed, base)
middle := ratingTestUser(t, pool, 4_200_000_000+seed, base.Add(time.Hour))
newest := ratingTestUser(t, pool, 4_300_000_000+seed, base.Add(2*time.Hour))
bot := ratingTestUser(t, pool, 4_400_000_000+seed, base.Add(3*time.Hour))
deleted := ratingTestUser(t, pool, 4_500_000_000+seed, base.Add(4*time.Hour))
if _, err := pool.Exec(ctx, `UPDATE users SET is_bot = true WHERE id = $1`, bot); err != nil {
t.Fatalf("mark bot: %v", err)
}
// A deleted account is a tombstone: every profile field is already cleared, so
// there is no rating to show and no reason to compute one.
if _, err := pool.Exec(ctx, `
UPDATE users SET deleted_at = now(), deletion_source = 'manual', deletion_reason = 'test',
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
verified = false, support = false, premium_expires_at = NULL,
emoji_status_document_id = 0, emoji_status_until = 0,
emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb,
color_set = false, color = 0, color_background_emoji_id = 0,
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
birthday_day = 0, birthday_month = 0, birthday_year = 0,
personal_channel_id = 0, last_seen_at = 0, account_delete_at = NULL
WHERE id = $1`, deleted); err != nil {
t.Fatalf("mark deleted: %v", err)
}
// The table is shared with every other account in the test database, so assert
// on relative order and membership rather than on an exact page.
positions := func(t *testing.T) (map[int64]int, []int64) {
t.Helper()
ids, err := store.UnratedAccounts(ctx, maxAccountRatingListLimit)
if err != nil {
t.Fatalf("UnratedAccounts: %v", err)
}
index := make(map[int64]int, len(ids))
for i, id := range ids {
index[id] = i
}
return index, ids
}
index, ids := positions(t)
for _, id := range []int64{oldest, middle, newest} {
if _, ok := index[id]; !ok {
t.Fatalf("account %d with no projection is absent from %d candidates", id, len(ids))
}
}
if _, ok := index[bot]; ok {
t.Fatalf("bot %d was offered as a rating candidate", bot)
}
if _, ok := index[deleted]; ok {
t.Fatalf("deleted account %d was offered as a rating candidate", deleted)
}
// The service accounts are infrastructure. The platform account in particular is
// NOT flagged is_bot, so excluding bots alone would still have seeded it -- which
// is exactly how it got a rating.
for _, serviceID := range domain.SystemUserIDs() {
if _, ok := index[serviceID]; ok {
t.Fatalf("service account %d was offered as a rating candidate", serviceID)
}
}
if !(index[oldest] < index[middle] && index[middle] < index[newest]) {
t.Fatalf("candidate order = oldest %d, middle %d, newest %d; want oldest first",
index[oldest], index[middle], index[newest])
}
// Seeding one account removes it from the candidate set, so the pass converges
// instead of offering the same user every cycle.
if _, changed, err := store.SaveAccountRating(ctx, domain.AccountRating{
UserID: middle, Level: 1, Stars: 150,
CurrentLevelStars: domain.AccountRatingLevelThreshold(1),
ComputedAt: time.Now().UTC(), Version: 1,
}); err != nil || !changed {
t.Fatalf("seed projection = %v changed=%v", err, changed)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM account_rating WHERE user_id = $1`, middle)
})
index, _ = positions(t)
if _, ok := index[middle]; ok {
t.Fatalf("account %d is still a candidate after being seeded", middle)
}
if _, ok := index[oldest]; !ok {
t.Fatalf("seeding %d removed unrelated candidate %d", middle, oldest)
}
// The limit is honoured, so one cycle can never walk the whole users table.
capped, err := store.UnratedAccounts(ctx, 2)
if err != nil {
t.Fatalf("UnratedAccounts with a limit: %v", err)
}
if len(capped) != 2 {
t.Fatalf("limited candidates = %d, want 2", len(capped))
}
}

View file

@ -4,8 +4,6 @@ import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
@ -36,61 +34,6 @@ func (s *ChannelStore) AppendCallServiceMessage(ctx context.Context, channelID,
return s.appendServiceMessage(ctx, "call", channelID, senderUserID, date, action)
}
// AppendStarGiftAdminLog 记录频道 Star gift 到 Recent Actions不插入 channel_messages。
func (s *ChannelStore) AppendStarGiftAdminLog(ctx context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error {
if channelID == 0 || senderUserID == 0 || savedID <= 0 {
return domain.ErrChannelInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return fmt.Errorf("append star gift admin log: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return fmt.Errorf("begin star gift admin log: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if err := s.appendStarGiftAdminLogTx(ctx, tx, channelID, senderUserID, savedID, date, action); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit star gift admin log: %w", err)
}
committed = true
return nil
}
// appendStarGiftAdminLogTx is the aggregate-local form used when the saved gift,
// inventory/balance mutation and Recent Actions entry must commit together.
func (s *ChannelStore) appendStarGiftAdminLogTx(ctx context.Context, tx pgx.Tx, channelID, senderUserID, savedID int64, date int, action domain.ChannelMessageAction) error {
if channelID == 0 || senderUserID == 0 || savedID <= 0 {
return domain.ErrChannelInvalid
}
channel, err := getChannelByID(ctx, tx, channelID)
if err != nil {
return err
}
messageID := int(savedID)
if savedID > int64(domain.MaxMessageBoxID) {
messageID = domain.MaxMessageBoxID
}
action = channelServiceActionForMessage(channelID, messageID, action)
msg := domain.ChannelMessage{
ChannelID: channelID, ID: messageID, SenderUserID: senderUserID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, Date: date,
Post: channel.Broadcast, Action: &action, Pts: channel.Pts,
}
return s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
ChannelID: channelID, UserID: senderUserID, Date: date,
Type: domain.ChannelAdminLogSendMessage, Message: &msg,
})
}
func (s *ChannelStore) appendServiceMessage(ctx context.Context, label string, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) {
if channelID == 0 || senderUserID == 0 {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid

View file

@ -499,14 +499,6 @@ WHERE channel_id = $1 AND id = $2`, msg.ChannelID, msg.ID).Scan(
SenderUserID: first.SenderUserID,
}
result := domain.SendChannelMessageResult{Channel: channel, Message: replay, Event: event, Duplicate: true, ReplayDeleteEvent: replayDelete}
if first.PaidMessageStars > 0 {
balance := domain.StarsBalance{UserID: first.SenderUserID}
if err := s.db.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1`, first.SenderUserID).
Scan(&balance.Balance, &balance.Granted); err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("load paid-message replay balance: %w", err)
}
result.SenderStarsBalance = &balance
}
return result, nil
}
@ -515,7 +507,6 @@ func (s *ChannelStore) insertServiceMessage(ctx context.Context, tx pgx.Tx, chan
if err != nil {
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("allocate channel service message id: %w", err)
}
action = channelServiceActionForMessage(channel.ID, msgID, action)
pts, err := s.reserveChannelPts(ctx, tx, channel.ID)
if err != nil {
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("allocate channel service pts: %w", err)
@ -552,20 +543,6 @@ func (s *ChannelStore) insertServiceMessage(ctx context.Context, tx pgx.Tx, chan
return msg, event, nil
}
func channelServiceActionForMessage(channelID int64, msgID int, action domain.ChannelMessageAction) domain.ChannelMessageAction {
if action.Type == domain.ChannelActionStarGift && action.StarGift != nil {
g := *action.StarGift
if g.PeerChannelID == 0 {
g.PeerChannelID = channelID
}
if g.SavedID == 0 {
g.SavedID = int64(msgID)
}
action.StarGift = &g
}
return action
}
func insertChannelMessageTx(ctx context.Context, tx pgx.Tx, msg domain.ChannelMessage) error {
return insertChannelMessageWithFingerprintTx(ctx, tx, msg, nil)
}

View file

@ -22,9 +22,6 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" && req.Media == nil {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
if req.AllowPaidStars < 0 {
return domain.SendChannelMessageResult{}, domain.ErrStarsInvalidAmount
}
requestFingerprint, err := store.MonoforumSendFingerprint(req)
if err != nil {
return domain.SendChannelMessageResult{}, err
@ -103,47 +100,10 @@ FOR SHARE OF m, p`, channel.ID).Scan(
if req.SenderUserID != req.SavedPeer.ID && !isAdmin {
return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired
}
var senderBalance *domain.StarsBalance
// Direct Messages are always free: telesrv has no Stars economy, so the
// per-channel paid-messages price (if a stale admin setting still has one)
// is never charged.
paidMessageStars := int64(0)
if !isAdmin && channel.SendPaidMessagesStars > 0 {
if req.AllowPaidStars < channel.SendPaidMessagesStars {
return domain.SendChannelMessageResult{}, &domain.StarsPaymentRequiredError{Stars: channel.SendPaidMessagesStars}
}
balance := domain.StarsBalance{UserID: req.SenderUserID}
if err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1 FOR UPDATE`, req.SenderUserID).
Scan(&balance.Balance, &balance.Granted); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient
}
return domain.SendChannelMessageResult{}, fmt.Errorf("lock paid-message sender balance: %w", err)
}
if balance.Balance < channel.SendPaidMessagesStars {
return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient
}
paidMessageStars = channel.SendPaidMessagesStars
if err := tx.QueryRow(ctx, `
UPDATE stars_balances
SET balance = balance - $2, updated_at = now()
WHERE user_id = $1
RETURNING balance`, req.SenderUserID, paidMessageStars).Scan(&balance.Balance); err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("debit paid-message sender balance: %w", err)
}
if err := insertStarsTxn(ctx, tx, req.SenderUserID, -paidMessageStars, domain.StarsReasonPaidMessage,
domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}, req.Date, "Paid message", ""); err != nil {
return domain.SendChannelMessageResult{}, err
}
channelCredit := paidMessageStars * paidMessageChannelCommissionPermille / 1000
if channelCredit > 0 {
if _, err := tx.Exec(ctx, `
INSERT INTO channel_stars_balances(channel_id, balance)
VALUES($1, $2)
ON CONFLICT(channel_id) DO UPDATE
SET balance = channel_stars_balances.balance + EXCLUDED.balance, updated_at = now()`, parent.ID, channelCredit); err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("credit paid-message channel balance: %w", err)
}
}
senderBalance = &balance
}
if req.ReplyTo != nil {
if req.ReplyTo.MessageID <= 0 || req.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}) {
return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid
@ -266,7 +226,7 @@ ORDER BY user_id`, parent.ID)
committed = true
channel.TopMessageID = msgID
channel.Pts = pts
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: uniqueChannelUserIDs(recipients, 0), SenderStarsBalance: senderBalance}, nil
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: uniqueChannelUserIDs(recipients, 0)}, nil
}
// ListMonoforumHistory 拉取某订阅者(saved_peer)在 monoforum 内的私信历史,id 倒序分页。

View file

@ -432,117 +432,3 @@ WHERE m.channel_id = $1 AND m.id = $2`, monoID, otherMessage.Message.ID, sub.ID)
t.Fatalf("deleted monoforum replay mutated pts/events = %d/%d, want %d/%d", ptsAfterReplay, eventsAfterReplay, ptsBeforeReplay, eventsBeforeReplay)
}
}
func TestSendPaidMonoforumMessageLedgerPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 191, Phone: "+1789" + suffix + "41", FirstName: "PaidMonoOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
sub, err := users.Create(ctx, domain.User{AccessHash: 192, Phone: "+1789" + suffix + "42", FirstName: "PaidMonoSub"})
if err != nil {
t.Fatalf("create sub: %v", err)
}
other, err := users.Create(ctx, domain.User{AccessHash: 193, Phone: "+1789" + suffix + "43", FirstName: "PaidMonoOther"})
if err != nil {
t.Fatalf("create other: %v", err)
}
channels := NewChannelStore(pool)
broadcast, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Paid Mono " + suffix, Broadcast: true, Date: 1700002000})
if err != nil {
t.Fatalf("create channel: %v", err)
}
enabled, err := channels.SetPaidMessagesPrice(ctx, owner.ID, broadcast.Channel.ID, 10, true)
if err != nil {
t.Fatalf("enable paid DM: %v", err)
}
monoID := enabled.Channel.LinkedMonoforumID
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", []int64{broadcast.Channel.ID, monoID})
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, sub.ID, other.ID})
})
stars := NewStarsStore(pool)
if _, _, err := stars.EnsureGrant(ctx, sub.ID, 25, 1700002000); err != nil {
t.Fatalf("grant subscriber stars: %v", err)
}
if _, _, err := stars.EnsureGrant(ctx, other.ID, 5, 1700002000); err != nil {
t.Fatalf("grant other stars: %v", err)
}
subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID}
var beforeMessages int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id=$1`, monoID).Scan(&beforeMessages); err != nil {
t.Fatalf("count messages before paid send: %v", err)
}
lowReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 4001, Message: "too low", AllowPaidStars: 9, Date: 1700002001}
var required *domain.StarsPaymentRequiredError
if _, err := channels.SendMonoforumMessage(ctx, lowReq); !errors.As(err, &required) || required.Stars != 10 {
t.Fatalf("low authorization err = %v, want 10-Star payment required", err)
}
var afterLowMessages int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id=$1`, monoID).Scan(&afterLowMessages); err != nil || afterLowMessages != beforeMessages {
t.Fatalf("low authorization message count = %d/%v, want %d", afterLowMessages, err, beforeMessages)
}
paidReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 4002, Message: "paid", AllowPaidStars: 99, Date: 1700002002}
paid, err := channels.SendMonoforumMessage(ctx, paidReq)
if err != nil {
t.Fatalf("paid send: %v", err)
}
if paid.Message.PaidMessageStars != 10 || paid.SenderStarsBalance == nil || paid.SenderStarsBalance.Balance != 15 {
t.Fatalf("paid result = %+v balance=%+v, want actual 10 and balance 15", paid.Message, paid.SenderStarsBalance)
}
var senderBalance, channelBalance, persistedPaid int64
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, sub.ID).Scan(&senderBalance); err != nil {
t.Fatalf("load sender balance: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil {
t.Fatalf("load channel balance: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT paid_message_stars FROM channel_messages WHERE channel_id=$1 AND id=$2`, monoID, paid.Message.ID).Scan(&persistedPaid); err != nil {
t.Fatalf("load persisted paid stars: %v", err)
}
if senderBalance != 15 || channelBalance != 8 || persistedPaid != 10 {
t.Fatalf("persisted sender/channel/message = %d/%d/%d, want 15/8/10", senderBalance, channelBalance, persistedPaid)
}
replay, err := channels.SendMonoforumMessage(ctx, paidReq)
if err != nil {
t.Fatalf("paid replay: %v", err)
}
if !replay.Duplicate || replay.Message.ID != paid.Message.ID || replay.SenderStarsBalance == nil || replay.SenderStarsBalance.Balance != 15 {
t.Fatalf("paid replay = %+v, want exact original and balance 15", replay)
}
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, sub.ID).Scan(&senderBalance); err != nil || senderBalance != 15 {
t.Fatalf("paid replay sender balance = %d/%v, want 15", senderBalance, err)
}
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 8 {
t.Fatalf("paid replay channel balance = %d/%v, want 8", channelBalance, err)
}
admin, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 4003, Message: "free admin reply", AllowPaidStars: 100, Date: 1700002003,
})
if err != nil {
t.Fatalf("admin reply: %v", err)
}
if admin.Message.PaidMessageStars != 0 || admin.SenderStarsBalance != nil {
t.Fatalf("admin reply charged: message=%+v balance=%+v", admin.Message, admin.SenderStarsBalance)
}
otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: other.ID, SavedPeer: otherPeer, RandomID: 4004, Message: "insufficient", AllowPaidStars: 10, Date: 1700002004,
}); !errors.Is(err, domain.ErrStarsInsufficient) {
t.Fatalf("insufficient err = %v, want ErrStarsInsufficient", err)
}
var otherBalance int64
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, other.ID).Scan(&otherBalance); err != nil || otherBalance != 5 {
t.Fatalf("insufficient sender balance = %d/%v, want 5", otherBalance, err)
}
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 8 {
t.Fatalf("insufficient channel balance = %d/%v, want 8", channelBalance, err)
}
}

View file

@ -199,70 +199,6 @@ ORDER BY channel_id ASC, message_id ASC, reaction_date DESC, reacted_user_id DES
recentRows.Close()
}
// 3) 付费 reactionStars跨频道一次取所有 reactor 行Go 内按 (channel,message) 聚合
// 总星数 + viewer 自身 + top reactors挂到 message.Reactions.Paidtg 转换注入 ReactionPaid
// 绝大多数消息无付费 reaction索引扫描即返回空总星数须含全部 reactor 故取全行。
if err := populateChannelMessagesPaidReactions(ctx, db, viewerUserID, channelsByID, indexes, messages, pairChannels, pairMessages); err != nil {
return err
}
return nil
}
func populateChannelMessagesPaidReactions(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, channelsByID map[int64]domain.Channel, indexes map[channelReactionMessageKey][]int, messages []domain.ChannelMessage, pairChannels []int64, pairMessages []int32) error {
rows, err := db.Query(ctx, `
SELECT channel_id, message_id, reactor_user_id, stars, anonymous
FROM channel_message_paid_reactions
WHERE (channel_id, message_id) IN (SELECT * FROM unnest($1::bigint[], $2::int[]))
ORDER BY channel_id ASC, message_id ASC, stars DESC, reactor_user_id ASC`, pairChannels, pairMessages)
if err != nil {
return fmt.Errorf("load channel message paid reactions: %w", err)
}
defer rows.Close()
aggByKey := make(map[channelReactionMessageKey]*domain.ChannelMessagePaidReactions)
for rows.Next() {
var channelID int64
var msgID int
var r domain.PaidReactor
if err := rows.Scan(&channelID, &msgID, &r.UserID, &r.Stars, &r.Anonymous); err != nil {
return err
}
key := channelReactionMessageKey{channelID: channelID, messageID: msgID}
agg := aggByKey[key]
if agg == nil {
agg = &domain.ChannelMessagePaidReactions{}
aggByKey[key] = agg
}
agg.TotalStars += r.Stars
r.My = r.UserID == viewerUserID
if r.My {
agg.MyStars = r.Stars
agg.MyAnonymous = r.Anonymous
}
// top reactors 取前 N已按 stars DESCviewer 自身若不在前 N 也补一条(始终在列)。
if len(agg.TopReactors) < domain.MaxPaidReactionTopReactors {
agg.TopReactors = append(agg.TopReactors, r)
} else if r.My {
agg.TopReactors = append(agg.TopReactors, r)
}
}
if err := rows.Err(); err != nil {
return err
}
for key, agg := range aggByKey {
if agg.TotalStars <= 0 {
continue
}
ch := channelsByID[key.channelID]
for _, idx := range indexes[key] {
if messages[idx].Reactions == nil {
reactions := emptyChannelMessageReactions(ch)
messages[idx].Reactions = &reactions
}
paidCopy := *agg
paidCopy.TopReactors = append([]domain.PaidReactor(nil), agg.TopReactors...)
messages[idx].Reactions.Paid = &paidCopy
}
}
return nil
}

View file

@ -182,125 +182,6 @@ DO UPDATE SET reaction_count = user_top_reactions.reaction_count + 1, reaction_d
}, nil
}
// AddChannelMessagePaidReaction 为一条广播频道消息增投付费 reaction 星数(累计),返回聚合
// 状态供 rpc 投影与扇出。扣费在 rpc 层经 Stars 账本 Debit 完成,本方法只负责累计与聚合。
func (s *ChannelStore) AddChannelMessagePaidReaction(ctx context.Context, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error) {
if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID {
return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
}
if req.Stars <= 0 || req.Stars > domain.MaxPaidReactionStarsPerRequest {
return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
}
if req.Date <= 0 {
req.Date = nowUnix()
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.ChannelMessagePaidReactionResult{}, fmt.Errorf("add channel paid reaction: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.ChannelMessagePaidReactionResult{}, fmt.Errorf("begin add channel paid reaction: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessagePaidReactionResult{}, err
}
// 付费 reaction 仅用于广播频道帖子(官方语义)。
if !channel.Broadcast || channel.Megagroup {
return domain.ChannelMessagePaidReactionResult{}, domain.ErrReactionInvalid
}
msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID)
if err != nil {
return domain.ChannelMessagePaidReactionResult{}, err
}
if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID {
return domain.ChannelMessagePaidReactionResult{}, domain.ErrMessageIDInvalid
}
if _, err := tx.Exec(ctx, `
INSERT INTO channel_message_paid_reactions (channel_id, message_id, reactor_user_id, stars, anonymous, reaction_date)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (channel_id, message_id, reactor_user_id)
DO UPDATE SET stars = channel_message_paid_reactions.stars + EXCLUDED.stars,
anonymous = EXCLUDED.anonymous,
reaction_date = EXCLUDED.reaction_date`,
req.ChannelID, req.MessageID, req.UserID, req.Stars, req.Anonymous, req.Date); err != nil {
return domain.ChannelMessagePaidReactionResult{}, fmt.Errorf("upsert channel paid reaction: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.ChannelMessagePaidReactionResult{}, fmt.Errorf("commit add channel paid reaction: %w", err)
}
committed = true
messages := []domain.ChannelMessage{msg}
if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil {
return domain.ChannelMessagePaidReactionResult{}, err
}
msg = messages[0]
paid, err := s.aggregateChannelPaidReactions(ctx, req.ChannelID, req.MessageID, req.UserID)
if err != nil {
return domain.ChannelMessagePaidReactionResult{}, err
}
recipients, err := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxChannelRealtimeFanout)
if err != nil || len(recipients) == 0 {
recipients = []int64{req.UserID}
}
return domain.ChannelMessagePaidReactionResult{
Channel: channel,
Message: msg,
Paid: paid,
Recipients: recipients,
}, nil
}
// aggregateChannelPaidReactions 汇总一条消息的付费 reaction总星数 + viewer 自身 + top reactors。
func (s *ChannelStore) aggregateChannelPaidReactions(ctx context.Context, channelID int64, messageID int, viewerUserID int64) (domain.ChannelMessagePaidReactions, error) {
rows, err := s.db.Query(ctx, `
SELECT reactor_user_id, stars, anonymous
FROM channel_message_paid_reactions
WHERE channel_id = $1 AND message_id = $2
ORDER BY stars DESC, reactor_user_id ASC`, channelID, messageID)
if err != nil {
return domain.ChannelMessagePaidReactions{}, fmt.Errorf("aggregate channel paid reactions: %w", err)
}
defer rows.Close()
var out domain.ChannelMessagePaidReactions
var myReactor domain.PaidReactor
myInTop := false
for rows.Next() {
var r domain.PaidReactor
if err := rows.Scan(&r.UserID, &r.Stars, &r.Anonymous); err != nil {
return domain.ChannelMessagePaidReactions{}, err
}
out.TotalStars += r.Stars
r.My = r.UserID == viewerUserID
if r.My {
out.MyStars = r.Stars
out.MyAnonymous = r.Anonymous
myReactor = r
}
if len(out.TopReactors) < domain.MaxPaidReactionTopReactors {
out.TopReactors = append(out.TopReactors, r)
if r.My {
myInTop = true
}
}
}
if err := rows.Err(); err != nil {
return domain.ChannelMessagePaidReactions{}, err
}
// viewer 自身始终出现在 top reactors官方你的条目总在列表里带 My 标志)。
if out.MyStars > 0 && !myInTop {
out.TopReactors = append(out.TopReactors, myReactor)
}
return out, nil
}
func (s *ChannelStore) DeleteChannelParticipantReaction(ctx context.Context, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) {
if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID || req.ParticipantUserID == 0 {
return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid

View file

@ -150,32 +150,10 @@ func (s *ChannelStore) ToggleSuggestedPostApproval(ctx context.Context, req doma
return domain.ToggleSuggestedPostApprovalResult{}, err
}
} else {
stars, ton, enough, err := reserveSuggestedPostPaymentTx(ctx, tx, original.SavedPeer.ID, parent.ID, price, req.Date)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
result.PayerStarsBalance, result.PayerTONBalance = stars, ton
if !enough {
if found {
result, err = s.loadSuggestedPostResultTx(ctx, tx, existing, true)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
result.PayerStarsBalance, result.PayerTONBalance = stars, ton
} else {
result.ServiceMessage, result.ServiceEvent, err = s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, req.UserID, fromSubscriber && manager.CanManageDirectMessages(), original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{
Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostBalanceTooLow: true,
SuggestedPostScheduleDate: scheduleDate, SuggestedPostPrice: price,
})
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
result.State = domain.SuggestedPostStateBalanceLow
if err := upsertSuggestedPostApprovalTx(ctx, tx, persistedSuggestedPostApproval{monoforumID: mono.ID, messageID: original.ID, parentID: parent.ID, actorID: req.UserID, payerID: original.SavedPeer.ID, state: result.State, price: price, scheduleDate: scheduleDate, approvalServiceID: result.ServiceMessage.ID}, req.Date); err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
}
} else {
// telesrv has no Stars economy: a suggested post is approved for free
// regardless of any price attached to it, so the balance-check/collect
// step and its "balance too low" retry state are skipped entirely.
{
effectivePublishDate := scheduleDate
original.SuggestedPost.Accepted, original.SuggestedPost.Rejected, original.SuggestedPost.ScheduleDate = true, false, effectivePublishDate
original, result.OriginalEvent, err = s.persistSuggestedPostEditTx(ctx, tx, original, req.UserID, req.Date)
@ -195,12 +173,10 @@ func (s *ChannelStore) ToggleSuggestedPostApproval(ctx context.Context, req doma
}
result.Published = &published
record.publishedMessageID = published.Message.ID
if price == nil {
record.state = domain.SuggestedPostStateCompleted
} else {
record.state = domain.SuggestedPostStatePublished
record.settlementDue = req.Date + suggestedPostSettlementAge
}
// telesrv has no Stars economy: there is nothing to settle after
// publish (no charge was ever collected), so a published post
// goes straight to Completed regardless of any attached price.
record.state = domain.SuggestedPostStateCompleted
}
result.State = record.state
if err := upsertSuggestedPostApprovalTx(ctx, tx, record, req.Date); err != nil {
@ -301,56 +277,6 @@ func (s *ChannelStore) publishSuggestedPostTx(ctx context.Context, tx pgx.Tx, pa
return domain.SendChannelMessageResult{Channel: parent, Message: msg, Event: event, Recipients: recipients}, nil
}
func reserveSuggestedPostPaymentTx(ctx context.Context, tx pgx.Tx, payerID, parentID int64, price *domain.SuggestedPostPrice, date int) (*domain.StarsBalance, *int64, bool, error) {
if price == nil {
return nil, nil, true, nil
}
switch price.Kind {
case domain.SuggestedPostPriceStars:
if price.Nanos != 0 || price.Amount <= 0 {
return nil, nil, false, domain.ErrSuggestedPostInvalid
}
balance := domain.StarsBalance{UserID: payerID}
err := tx.QueryRow(ctx, `SELECT balance,granted FROM stars_balances WHERE user_id=$1 FOR UPDATE`, payerID).Scan(&balance.Balance, &balance.Granted)
if errors.Is(err, pgx.ErrNoRows) {
return &balance, nil, false, nil
}
if err != nil {
return nil, nil, false, err
}
if balance.Balance < price.Amount {
return &balance, nil, false, nil
}
if err := tx.QueryRow(ctx, `UPDATE stars_balances SET balance=balance-$2,updated_at=now() WHERE user_id=$1 RETURNING balance`, payerID, price.Amount).Scan(&balance.Balance); err != nil {
return nil, nil, false, err
}
if err := insertStarsTxn(ctx, tx, payerID, -price.Amount, domain.StarsReasonSuggestedPost, domain.Peer{Type: domain.PeerTypeChannel, ID: parentID}, date, "Suggested post escrow", ""); err != nil {
return nil, nil, false, err
}
return &balance, nil, true, nil
case domain.SuggestedPostPriceTON:
var balance int64
err := tx.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id=$1 FOR UPDATE`, payerID).Scan(&balance)
if errors.Is(err, pgx.ErrNoRows) {
return nil, &balance, false, nil
}
if err != nil {
return nil, nil, false, err
}
if balance < price.Amount {
return nil, &balance, false, nil
}
if err := tx.QueryRow(ctx, `UPDATE ton_balances SET balance_nanoton=balance_nanoton-$2,updated_at=now() WHERE user_id=$1 RETURNING balance_nanoton`, payerID, price.Amount).Scan(&balance); err != nil {
return nil, nil, false, err
}
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,peer_type,peer_id,date) VALUES($1,$2,$3,'channel',$4,$5)`, payerID, -price.Amount, string(domain.StarsReasonSuggestedPost), parentID, date); err != nil {
return nil, nil, false, err
}
return nil, &balance, true, nil
default:
return nil, nil, false, domain.ErrSuggestedPostInvalid
}
}
func monoforumManagerRecipientsTx(ctx context.Context, tx pgx.Tx, parentID, subscriberID int64) ([]int64, error) {
rows, err := tx.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id=$1 AND status='active' AND (role='creator' OR (role='admin' AND COALESCE((admin_rights->>'ManageDirectMessages')::boolean,false))) ORDER BY user_id`, parentID)
@ -560,19 +486,12 @@ func (s *ChannelStore) processSuggestedPostLifecycleOne(ctx context.Context, mon
}
result := domain.ToggleSuggestedPostApprovalResult{Monoforum: mono, Parent: parent, SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: row.payerID}, State: row.state, Recipients: recipients}
changed := false
// telesrv has no Stars economy: nothing was ever charged, so a deleted
// scheduled post is simply dropped (Refunded is the closest existing
// terminal state, reused here so downstream event handling stays uniform)
// and a published post is Completed immediately -- there is no settlement
// window and no refund path to run.
if row.state == domain.SuggestedPostStateScheduled && original.Deleted {
if row.price != nil {
stars, ton, err := refundSuggestedPostPaymentTx(ctx, tx, row.payerID, row.parentID, row.price, now)
if err != nil {
return result, false, err
}
result.PayerStarsBalance, result.PayerTONBalance = stars, ton
service, event, err := s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, row.actorID, true, row.savedPeer(), row.messageID, now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostRefund})
if err != nil {
return result, false, err
}
result.ServiceMessage, result.ServiceEvent, row.finalServiceID = service, event, service.ID
}
row.state, result.State, changed = domain.SuggestedPostStateRefunded, domain.SuggestedPostStateRefunded, true
}
if row.state == domain.SuggestedPostStateScheduled && row.scheduleDate <= now {
@ -582,47 +501,10 @@ func (s *ChannelStore) processSuggestedPostLifecycleOne(ctx context.Context, mon
}
result.Published = &published
row.publishedMessageID = published.Message.ID
if row.price == nil {
row.state = domain.SuggestedPostStateCompleted
} else {
row.state = domain.SuggestedPostStatePublished
row.settlementDue = now + suggestedPostSettlementAge
}
row.state = domain.SuggestedPostStateCompleted
result.State = row.state
changed = true
}
if row.state == domain.SuggestedPostStatePublished {
var deleted bool
var deleteDate int
if err := tx.QueryRow(ctx, `SELECT deleted,delete_date FROM channel_messages WHERE channel_id=$1 AND id=$2 FOR SHARE`, row.parentID, row.publishedMessageID).Scan(&deleted, &deleteDate); err != nil {
return result, false, err
}
if deleted && (deleteDate == 0 || deleteDate < row.settlementDue) {
stars, ton, err := refundSuggestedPostPaymentTx(ctx, tx, row.payerID, row.parentID, row.price, now)
if err != nil {
return result, false, err
}
result.PayerStarsBalance, result.PayerTONBalance = stars, ton
service, event, err := s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, row.actorID, true, row.savedPeer(), row.messageID, now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostRefund})
if err != nil {
return result, false, err
}
result.ServiceMessage, result.ServiceEvent = service, event
row.state, row.finalServiceID, result.State = domain.SuggestedPostStateRefunded, service.ID, domain.SuggestedPostStateRefunded
changed = true
} else if row.settlementDue <= now {
if err := settleSuggestedPostPaymentTx(ctx, tx, row.actorID, row.payerID, row.parentID, row.price, now); err != nil {
return result, false, err
}
service, event, err := s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, row.actorID, true, row.savedPeer(), row.messageID, now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostSuccess, SuggestedPostPrice: cloneSuggestedPricePG(row.price)})
if err != nil {
return result, false, err
}
result.ServiceMessage, result.ServiceEvent = service, event
row.state, row.finalServiceID, result.State = domain.SuggestedPostStateCompleted, service.ID, domain.SuggestedPostStateCompleted
changed = true
}
}
if !changed {
if err := tx.Commit(ctx); err != nil {
return result, false, err
@ -652,56 +534,4 @@ func (r persistedSuggestedPostApproval) savedPeer() domain.Peer {
return domain.Peer{Type: domain.PeerTypeUser, ID: r.payerID}
}
func refundSuggestedPostPaymentTx(ctx context.Context, tx pgx.Tx, payerID, parentID int64, price *domain.SuggestedPostPrice, date int) (*domain.StarsBalance, *int64, error) {
if price == nil {
return nil, nil, nil
}
switch price.Kind {
case domain.SuggestedPostPriceStars:
balance := domain.StarsBalance{UserID: payerID, Granted: true}
if err := tx.QueryRow(ctx, `INSERT INTO stars_balances(user_id,balance,granted) VALUES($1,$2,true) ON CONFLICT(user_id) DO UPDATE SET balance=stars_balances.balance+EXCLUDED.balance,updated_at=now() RETURNING balance,granted`, payerID, price.Amount).Scan(&balance.Balance, &balance.Granted); err != nil {
return nil, nil, err
}
if err := insertStarsTxn(ctx, tx, payerID, price.Amount, domain.StarsReasonSuggestedPost, domain.Peer{Type: domain.PeerTypeChannel, ID: parentID}, date, "Suggested post refund", ""); err != nil {
return nil, nil, err
}
return &balance, nil, nil
case domain.SuggestedPostPriceTON:
var balance int64
if err := tx.QueryRow(ctx, `INSERT INTO ton_balances(user_id,balance_nanoton,granted) VALUES($1,$2,true) ON CONFLICT(user_id) DO UPDATE SET balance_nanoton=ton_balances.balance_nanoton+EXCLUDED.balance_nanoton,updated_at=now() RETURNING balance_nanoton`, payerID, price.Amount).Scan(&balance); err != nil {
return nil, nil, err
}
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,peer_type,peer_id,date) VALUES($1,$2,$3,'channel',$4,$5)`, payerID, price.Amount, string(domain.StarsReasonSuggestedPost), parentID, date); err != nil {
return nil, nil, err
}
return nil, &balance, nil
default:
return nil, nil, domain.ErrSuggestedPostInvalid
}
}
func settleSuggestedPostPaymentTx(ctx context.Context, tx pgx.Tx, actorID, payerID, parentID int64, price *domain.SuggestedPostPrice, date int) error {
if price == nil {
return nil
}
credit := price.Amount * paidMessageChannelCommissionPermille / 1000
if credit <= 0 {
return nil
}
switch price.Kind {
case domain.SuggestedPostPriceStars:
if _, err := tx.Exec(ctx, `INSERT INTO channel_stars_balances(channel_id,balance) VALUES($1,$2) ON CONFLICT(channel_id) DO UPDATE SET balance=channel_stars_balances.balance+EXCLUDED.balance,updated_at=now()`, parentID, credit); err != nil {
return err
}
_, err := tx.Exec(ctx, `INSERT INTO channel_stars_transactions(channel_id,actor_user_id,amount,reason,peer_type,peer_id,date) VALUES($1,$2,$3,$4,'user',$5,$6)`, parentID, actorID, credit, string(domain.StarsReasonSuggestedPost), payerID, date)
return err
case domain.SuggestedPostPriceTON:
if _, err := tx.Exec(ctx, `INSERT INTO channel_ton_balances(channel_id,balance_nanoton) VALUES($1,$2) ON CONFLICT(channel_id) DO UPDATE SET balance_nanoton=channel_ton_balances.balance_nanoton+EXCLUDED.balance_nanoton,updated_at=now()`, parentID, credit); err != nil {
return err
}
_, err := tx.Exec(ctx, `INSERT INTO channel_ton_transactions(channel_id,actor_user_id,amount_nanoton,reason,peer_type,peer_id,date) VALUES($1,$2,$3,$4,'user',$5,$6)`, parentID, actorID, credit, string(domain.StarsReasonSuggestedPost), payerID, date)
return err
default:
return domain.ErrSuggestedPostInvalid
}
}

View file

@ -39,9 +39,6 @@ func TestSuggestedPostLifecyclePostgres(t *testing.T) {
_, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id=ANY($1::bigint[])`, []int64{monoID, created.Channel.ID})
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=ANY($1::bigint[])`, []int64{owner.ID, subscriber.ID})
})
if _, err := pool.Exec(ctx, `INSERT INTO stars_balances(user_id,balance,granted) VALUES($1,100,true) ON CONFLICT(user_id) DO UPDATE SET balance=100,granted=true`, subscriber.ID); err != nil {
t.Fatal(err)
}
saved := domain.Peer{Type: domain.PeerTypeUser, ID: subscriber.ID}
suggestion, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved, RandomID: 71, Message: "postgres suggestion", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_000_100})
if err != nil {
@ -51,7 +48,7 @@ func TestSuggestedPostLifecyclePostgres(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if approved.State != domain.SuggestedPostStatePublished || approved.Published == nil || approved.PayerStarsBalance == nil || approved.PayerStarsBalance.Balance != 90 {
if approved.State != domain.SuggestedPostStateCompleted || approved.Published == nil {
t.Fatalf("approved=%+v", approved)
}
if approved.OriginalMessage.SuggestedPost.ScheduleDate != 1_700_000_200 || approved.ServiceMessage.Action.SuggestedPostScheduleDate != 1_700_000_200 {
@ -77,40 +74,39 @@ func TestSuggestedPostLifecyclePostgres(t *testing.T) {
}
var state string
var scheduleDate int
var debit, channelBalance int64
if err := pool.QueryRow(ctx, `SELECT state,schedule_date FROM suggested_post_approvals WHERE monoforum_id=$1 AND suggestion_message_id=$2`, monoID, suggestion.Message.ID).Scan(&state, &scheduleDate); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, subscriber.ID).Scan(&debit); err != nil {
t.Fatal(err)
}
_ = pool.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM channel_stars_balances WHERE channel_id=$1),0)`, created.Channel.ID).Scan(&channelBalance)
if state != string(domain.SuggestedPostStatePublished) || scheduleDate != 1_700_000_200 || debit != 90 || channelBalance != 0 {
t.Fatalf("state/schedule/debit/channel=%s/%d/%d/%d", state, scheduleDate, debit, channelBalance)
}
if _, err := pool.Exec(ctx, `UPDATE channel_messages SET deleted=true WHERE channel_id=$1 AND id=$2`, created.Channel.ID, approved.Published.Message.ID); err != nil {
t.Fatal(err)
}
resolved, err := channels.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_000_300, Limit: 10})
if err != nil || len(resolved) != 1 || resolved[0].State != domain.SuggestedPostStateRefunded || resolved[0].ServiceMessage.Action == nil || resolved[0].ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostRefund {
t.Fatalf("refund=%+v err=%v", resolved, err)
}
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, subscriber.ID).Scan(&debit); err != nil {
t.Fatal(err)
}
var txnNet int64
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount),0) FROM stars_transactions WHERE user_id=$1 AND reason=$2`, subscriber.ID, string(domain.StarsReasonSuggestedPost)).Scan(&txnNet); err != nil {
t.Fatal(err)
}
if debit != 100 || txnNet != 0 {
t.Fatalf("refund balance/net=%d/%d, want 100/0", debit, txnNet)
if state != string(domain.SuggestedPostStateCompleted) || scheduleDate != 1_700_000_200 {
t.Fatalf("state/schedule=%s/%d", state, scheduleDate)
}
late, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved, RandomID: 72, Message: "late deletion", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_000_400})
// Scheduled (not-yet-due) posts still refund via the lifecycle worker if
// deleted before publication; immediate approvals above are already
// terminal (Completed) since there is nothing left to settle.
scheduled, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved, RandomID: 72, Message: "cancel before due", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_000_400})
if err != nil {
t.Fatal(err)
}
approvedAt := 1_700_000_500
scheduledApproval, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
UserID: owner.ID, MonoforumID: monoID, MessageID: scheduled.Message.ID, ScheduleDate: 1_700_000_900, Date: 1_700_000_401,
})
if err != nil || scheduledApproval.State != domain.SuggestedPostStateScheduled {
t.Fatalf("scheduled approval=%+v err=%v", scheduledApproval, err)
}
if _, err := pool.Exec(ctx, `UPDATE channel_messages SET deleted=true WHERE channel_id=$1 AND id=$2`, monoID, scheduled.Message.ID); err != nil {
t.Fatal(err)
}
resolved, err := channels.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_000_500, Limit: 10})
if err != nil || len(resolved) != 1 || resolved[0].State != domain.SuggestedPostStateRefunded {
t.Fatalf("refund=%+v err=%v", resolved, err)
}
late, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved, RandomID: 73, Message: "late deletion", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_000_600})
if err != nil {
t.Fatal(err)
}
approvedAt := 1_700_000_700
lateApproved, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: owner.ID, MonoforumID: monoID, MessageID: late.Message.ID, Date: approvedAt})
if err != nil || lateApproved.Published == nil {
t.Fatalf("late approval=%+v err=%v", lateApproved, err)
@ -120,17 +116,8 @@ func TestSuggestedPostLifecyclePostgres(t *testing.T) {
t.Fatal(err)
}
resolved, err = channels.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: due + 2, Limit: 10})
if err != nil || len(resolved) != 1 || resolved[0].State != domain.SuggestedPostStateCompleted || resolved[0].ServiceMessage.Action == nil || resolved[0].ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostSuccess {
t.Fatalf("late settlement=%+v err=%v", resolved, err)
}
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, subscriber.ID).Scan(&debit); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM channel_stars_balances WHERE channel_id=$1),0)`, created.Channel.ID).Scan(&channelBalance); err != nil {
t.Fatal(err)
}
if debit != 90 || channelBalance != 8 {
t.Fatalf("late settlement balance/channel=%d/%d, want 90/8", debit, channelBalance)
if err != nil || len(resolved) != 0 {
t.Fatalf("already-completed post must not be revisited by the lifecycle worker: resolved=%+v err=%v", resolved, err)
}
}

View file

@ -37,14 +37,6 @@ func cloneChannelMessageAction(action *domain.ChannelMessageAction) *domain.Chan
v := *action.Hidden
clone.Hidden = &v
}
if action.StarGift != nil {
g := *action.StarGift
if action.StarGift.Sticker != nil {
sticker := *action.StarGift.Sticker
g.Sticker = &sticker
}
clone.StarGift = &g
}
clone.Wallpaper = domain.CloneWallpaperPtr(action.Wallpaper)
clone.Photo = domain.ClonePhotoPtr(action.Photo)
return &clone

View file

@ -1,129 +0,0 @@
package postgres
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
// TestChannelPaidReactionPostgres 回归迁移 0010广播频道付费 reaction 对真实 PG 的累计 +
// 聚合(总星数 / viewer 自身 / top reactors 降序 / 同 reactor 多次累加)。
func TestChannelPaidReactionPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 521, Phone: "+1778" + suffix + "41", FirstName: "PaidRxOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
member, err := users.Create(ctx, domain.User{AccessHash: 522, Phone: "+1778" + suffix + "42", FirstName: "PaidRxMember"})
if err != nil {
t.Fatalf("create member: %v", err)
}
var channelID int64
t.Cleanup(func() {
if channelID != 0 {
_, _ = pool.Exec(ctx, "DELETE FROM channel_message_paid_reactions WHERE channel_id = $1", channelID)
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
}
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID})
})
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Paid Reaction " + suffix,
Broadcast: true,
MemberUserIDs: []int64{member.ID},
Date: 1700000400,
})
if err != nil {
t.Fatalf("create broadcast channel: %v", err)
}
channelID = created.Channel.ID
sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID,
ChannelID: channelID,
RandomID: 9401,
Message: "paid reaction target",
Date: 1700000401,
})
if err != nil {
t.Fatalf("send message: %v", err)
}
msgID := sent.Message.ID
// owner 投 100。
res, err := channels.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
UserID: owner.ID, ChannelID: channelID, MessageID: msgID, Stars: 100, Date: 1700000402,
})
if err != nil {
t.Fatalf("owner paid reaction: %v", err)
}
if res.Paid.TotalStars != 100 || res.Paid.MyStars != 100 {
t.Fatalf("after owner 100 = total %d my %d, want 100/100", res.Paid.TotalStars, res.Paid.MyStars)
}
// member 投 250 → 总 350member 视角 my=250top reactors 降序 member(250)/owner(100)。
res, err = channels.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
UserID: member.ID, ChannelID: channelID, MessageID: msgID, Stars: 250, Date: 1700000403,
})
if err != nil {
t.Fatalf("member paid reaction: %v", err)
}
if res.Paid.TotalStars != 350 || res.Paid.MyStars != 250 {
t.Fatalf("after member 250 = total %d my %d, want 350/250", res.Paid.TotalStars, res.Paid.MyStars)
}
if len(res.Paid.TopReactors) != 2 || res.Paid.TopReactors[0].Stars != 250 || !res.Paid.TopReactors[0].My || res.Paid.TopReactors[1].Stars != 100 {
t.Fatalf("top reactors = %+v, want member(250,My)/owner(100)", res.Paid.TopReactors)
}
// owner 再投 50 → 累加到 150总 400。
res, err = channels.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
UserID: owner.ID, ChannelID: channelID, MessageID: msgID, Stars: 50, Date: 1700000404,
})
if err != nil {
t.Fatalf("owner re-invest: %v", err)
}
if res.Paid.TotalStars != 400 || res.Paid.MyStars != 150 {
t.Fatalf("after owner +50 = total %d my %d, want 400/150 (accumulated)", res.Paid.TotalStars, res.Paid.MyStars)
}
// 关键回归读路径修复fresh 读回该消息必须携带付费 reaction不止实时推送
// owner 视角Paid.TotalStars=400, MyStars=150。
read, err := channels.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{
UserID: owner.ID, ChannelID: channelID, IDs: []int{msgID},
})
if err != nil {
t.Fatalf("get message reactions: %v", err)
}
if len(read.Messages) != 1 || read.Messages[0].Reactions == nil || read.Messages[0].Reactions.Paid == nil {
t.Fatalf("fresh read messages=%d reactions/paid missing: %+v", len(read.Messages), read.Messages)
}
paid := read.Messages[0].Reactions.Paid
if paid.TotalStars != 400 || paid.MyStars != 150 {
t.Fatalf("fresh read paid = total %d my %d, want 400/150 (读路径须回显付费 reaction)", paid.TotalStars, paid.MyStars)
}
// member 视角读同一条MyStars=250、TopReactors 含 member 自己带 My。
readMember, err := channels.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{
UserID: member.ID, ChannelID: channelID, IDs: []int{msgID},
})
if err != nil {
t.Fatalf("get message reactions (member): %v", err)
}
mp := readMember.Messages[0].Reactions.Paid
if mp == nil || mp.TotalStars != 400 || mp.MyStars != 250 {
t.Fatalf("member fresh read paid = %+v, want total 400 my 250", mp)
}
// 非法星数被拒。
if _, err := channels.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
UserID: owner.ID, ChannelID: channelID, MessageID: msgID, Stars: 0, Date: 1700000405,
}); !errors.Is(err, domain.ErrChannelInvalid) {
t.Fatalf("zero-stars err = %v, want ErrChannelInvalid", err)
}
}

View file

@ -36,15 +36,9 @@ type ReadModelCacheSet struct {
RPCProjections RPCProjectionReadModelCache
BaseUsers BaseUserCache
BotProfiles BotProfileReadModelCache
StarGifts StarGiftCatalogCache
AccountSettings AccountSettingsReadModelCache
}
type StarGiftCatalogCache interface {
InvalidateStarGiftCatalog()
FlushStarGiftCatalog()
}
type AccountSettingsReadModelCache interface {
InvalidateAccountSettingsReadModel(userID int64)
FlushAccountSettingsReadModel()
@ -251,7 +245,6 @@ func (l *ReadModelChangeListener) empty() bool {
l.caches.RPCProjections == nil &&
l.caches.BaseUsers == nil &&
l.caches.BotProfiles == nil &&
l.caches.StarGifts == nil &&
l.caches.AccountSettings == nil
}
@ -325,10 +318,6 @@ func (l *ReadModelChangeListener) flush(reasons ...string) {
l.caches.BotProfiles.FlushBotProfileReadModel()
flushed = append(flushed, "bot_profiles")
}
if l.caches.StarGifts != nil {
l.caches.StarGifts.FlushStarGiftCatalog()
flushed = append(flushed, "star_gifts")
}
if l.caches.AccountSettings != nil {
l.caches.AccountSettings.FlushAccountSettingsReadModel()
flushed = append(flushed, "account_settings")
@ -374,10 +363,6 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
}
}
}
case "star_gift_catalog":
if l.caches.StarGifts != nil {
l.caches.StarGifts.InvalidateStarGiftCatalog()
}
case "user_base":
if evt.PeerType == "user" && evt.PeerID != 0 {
if l.caches.RPCProjections != nil {

View file

@ -1,951 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// StarGiftStore 用 PostgreSQL 实现 store.StarGiftStorepeer 收到的 Star 礼物实例)。
type StarGiftStore struct {
db sqlcgen.DBTX
}
// NewStarGiftStore 基于 pgx 连接池(或事务)创建 StarGiftStore。
func NewStarGiftStore(db sqlcgen.DBTX) *StarGiftStore {
return &StarGiftStore{db: db}
}
const starGiftCatalogSelect = `
SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title,
r.limited, r.sold_out, r.birthday, r.require_premium,
r.limited_per_user, r.peer_color_available, r.auction,
c.availability_remains, r.availability_total, c.availability_resale,
c.first_sale_date, c.last_sale_date, c.resell_min_stars,
COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0),
r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round,
r.auction_start_date, r.upgrade_variants,
r.background_center_color IS NOT NULL,
COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0),
COALESCE(r.background_text_color, 0),
COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0),
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text
FROM star_gift_catalog c
JOIN star_gift_catalog_revisions r ON r.id = c.active_revision_id
LEFT JOIN star_gift_collectible_revisions cr ON cr.id = c.collectible_revision_id AND cr.status = 'published'
JOIN documents d ON d.id = r.document_id`
func (s *StarGiftStore) Catalog(ctx context.Context) ([]domain.StarGift, error) {
rows, err := s.db.Query(ctx, starGiftCatalogSelect+`
WHERE c.enabled
ORDER BY c.sort_order, c.gift_id`)
if err != nil {
return nil, fmt.Errorf("list star gift catalog: %w", err)
}
defer rows.Close()
out := make([]domain.StarGift, 0)
for rows.Next() {
gift, err := scanCatalogGift(rows)
if err != nil {
return nil, err
}
out = append(out, gift)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate star gift catalog: %w", err)
}
return out, nil
}
func (s *StarGiftStore) CatalogGift(ctx context.Context, giftID int64) (domain.StarGift, bool, error) {
if giftID <= 0 {
return domain.StarGift{}, false, nil
}
gift, err := scanCatalogGift(s.db.QueryRow(ctx, starGiftCatalogSelect+`
WHERE c.enabled AND c.gift_id = $1`, giftID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGift{}, false, nil
}
if err != nil {
return domain.StarGift{}, false, err
}
return gift, true, nil
}
func (s *StarGiftStore) CatalogRevision(ctx context.Context, revisionID int64) (domain.StarGift, bool, error) {
if revisionID <= 0 {
return domain.StarGift{}, false, nil
}
gift, err := scanCatalogGift(s.db.QueryRow(ctx, `
SELECT r.gift_id, r.id, r.stars, r.convert_stars, r.title,
r.limited, r.sold_out, r.birthday, r.require_premium,
r.limited_per_user, r.peer_color_available, r.auction,
c.availability_remains, r.availability_total, c.availability_resale,
c.first_sale_date, c.last_sale_date, c.resell_min_stars,
COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0),
r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round,
r.auction_start_date, r.upgrade_variants,
r.background_center_color IS NOT NULL,
COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0),
COALESCE(r.background_text_color, 0),
COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0),
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text
FROM star_gift_catalog_revisions r
JOIN star_gift_catalog c ON c.gift_id = r.gift_id
LEFT JOIN star_gift_collectible_revisions cr ON cr.id = c.collectible_revision_id AND cr.status = 'published'
JOIN documents d ON d.id = r.document_id
WHERE r.id = $1`, revisionID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGift{}, false, nil
}
if err != nil {
return domain.StarGift{}, false, err
}
return gift, true, nil
}
func scanCatalogGift(row rowScanner) (domain.StarGift, error) {
var gift domain.StarGift
var attrsJSON, thumbsJSON string
var releasedByType string
var releasedByID int64
var hasBackground bool
var background domain.StarGiftBackground
if err := row.Scan(
&gift.ID, &gift.RevisionID, &gift.Stars, &gift.ConvertStars, &gift.Title,
&gift.Limited, &gift.SoldOut, &gift.Birthday, &gift.RequirePremium,
&gift.LimitedPerUser, &gift.PeerColorAvailable, &gift.Auction,
&gift.AvailabilityRemains, &gift.AvailabilityTotal, &gift.AvailabilityResale,
&gift.FirstSaleDate, &gift.LastSaleDate, &gift.ResellMinStars,
&releasedByType, &releasedByID, &gift.PerUserTotal, &gift.LockedUntilDate,
&gift.AuctionSlug, &gift.GiftsPerRound, &gift.AuctionStartDate, &gift.UpgradeVariants,
&hasBackground, &background.CenterColor, &background.EdgeColor, &background.TextColor,
&gift.UpgradeStars, &gift.UpgradeTotal, &gift.UpgradeIssued,
&gift.Sticker.ID, &gift.Sticker.AccessHash, &gift.Sticker.FileReference, &gift.Sticker.Date,
&gift.Sticker.MimeType, &gift.Sticker.Size, &gift.Sticker.DCID, &attrsJSON, &thumbsJSON,
); err != nil {
return domain.StarGift{}, err
}
if releasedByType != "" && releasedByID > 0 {
gift.ReleasedBy = domain.Peer{Type: domain.PeerType(releasedByType), ID: releasedByID}
}
if hasBackground {
gift.Background = &background
}
if gift.LimitedPerUser {
gift.PerUserRemains = gift.PerUserTotal
}
attrs, err := decodeDocumentAttributes(attrsJSON)
if err != nil {
return domain.StarGift{}, fmt.Errorf("decode star gift document attributes: %w", err)
}
thumbs, err := decodePhotoSizes(thumbsJSON)
if err != nil {
return domain.StarGift{}, fmt.Errorf("decode star gift document thumbs: %w", err)
}
gift.Sticker.Attributes = attrs
gift.Sticker.Thumbs = thumbs
if !gift.Sticker.IsSticker() || gift.Sticker.MimeType != "application/x-tgsticker" {
return domain.StarGift{}, fmt.Errorf("invalid star gift revision %d document %d", gift.RevisionID, gift.Sticker.ID)
}
return gift, nil
}
func (s *StarGiftStore) CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
if write.Stars <= 0 || write.ConvertStars < 0 || write.ConvertStars > write.Stars ||
write.Document.ID <= 0 || !write.Document.IsSticker() || write.Document.MimeType != "application/x-tgsticker" ||
len(write.Animation.JSON) == 0 || len(write.Animation.SHA256) != 32 {
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftInvalid
}
var entry domain.StarGiftCatalogEntry
err := withTx(ctx, s.db, "create star gift catalog revision", func(tx pgx.Tx) error {
giftID := write.GiftID
var revisionID int64
if err := tx.QueryRow(ctx, `SELECT nextval('star_gift_catalog_revision_id_seq')`).Scan(&revisionID); err != nil {
return fmt.Errorf("allocate star gift revision id: %w", err)
}
revision := 1
if giftID == 0 {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended('star_gift_catalog', 0))`); err != nil {
return fmt.Errorf("lock star gift catalog capacity: %w", err)
}
var catalogCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_catalog`).Scan(&catalogCount); err != nil {
return fmt.Errorf("count star gift catalog: %w", err)
}
if catalogCount >= domain.MaxStarGiftCatalogSize {
return domain.ErrStarGiftCatalogFull
}
if err := tx.QueryRow(ctx, `SELECT nextval('star_gift_catalog_gift_id_seq')`).Scan(&giftID); err != nil {
return fmt.Errorf("allocate star gift id: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_catalog (
gift_id, active_revision_id, enabled, sort_order, availability_remains,
availability_resale, resell_min_stars, first_sale_date, last_sale_date
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, giftID, revisionID, write.Enabled, write.SortOrder,
write.AvailabilityRemains, write.AvailabilityResale, write.ResellMinStars,
write.FirstSaleDate, write.LastSaleDate); err != nil {
return fmt.Errorf("insert star gift catalog: %w", err)
}
} else {
var ignored int64
if err := tx.QueryRow(ctx, `
SELECT active_revision_id FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, giftID).Scan(&ignored); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrStarGiftNotFound
}
return fmt.Errorf("lock star gift catalog: %w", err)
}
if err := tx.QueryRow(ctx, `
SELECT COALESCE(MAX(revision), 0) + 1
FROM star_gift_catalog_revisions
WHERE gift_id = $1`, giftID).Scan(&revision); err != nil {
return fmt.Errorf("lock star gift catalog: %w", err)
}
}
media := NewMediaStore(tx)
if err := media.PutDocument(ctx, write.Document); err != nil {
return fmt.Errorf("put star gift document: %w", err)
}
if err := media.PutFileBlob(ctx, write.Blob); err != nil {
return fmt.Errorf("put star gift blob: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_catalog_revisions (
id, gift_id, revision, title, stars, convert_stars, document_id,
animation_json, animation_sha256, source_name, source_format,
width, height, frame_rate, in_point, out_point, created_by, command_id,
official_gift_id, source_manifest_sha256, official_source,
limited, sold_out, birthday, require_premium, limited_per_user,
peer_color_available, auction, availability_total,
released_by_peer_type, released_by_peer_id, per_user_total, locked_until_date,
auction_slug, gifts_per_round, auction_start_date, upgrade_variants,
background_center_color, background_edge_color, background_text_color
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,
NULLIF($19::bigint,0),$20,$21::jsonb,$22,$23,$24,$25,$26,$27,$28,$29,
$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40
)`,
revisionID, giftID, revision, write.Title, write.Stars, write.ConvertStars, write.Document.ID,
string(write.Animation.JSON), write.Animation.SHA256, write.Animation.SourceName, string(write.Animation.SourceFormat),
write.Animation.Width, write.Animation.Height, write.Animation.FrameRate, write.Animation.InPoint, write.Animation.OutPoint,
write.Actor, write.CommandID, write.OfficialGiftID, nullableSHA256(write.SourceManifestSHA256), nullableOfficialGiftJSON(write.OfficialSourceJSON),
write.Limited, write.SoldOut, write.Birthday, write.RequirePremium, write.LimitedPerUser,
write.PeerColorAvailable, write.Auction, write.AvailabilityTotal,
nullableStarGiftPeerType(write.ReleasedBy), nullableStarGiftPeerID(write.ReleasedBy), write.PerUserTotal,
write.LockedUntilDate, write.AuctionSlug, write.GiftsPerRound, write.AuctionStartDate,
write.UpgradeVariants, nullableBackgroundColor(write.Background, "center"),
nullableBackgroundColor(write.Background, "edge"), nullableBackgroundColor(write.Background, "text"),
); err != nil {
return fmt.Errorf("insert star gift revision: %w", err)
}
if write.GiftID != 0 {
if _, err := tx.Exec(ctx, `
UPDATE star_gift_catalog
SET active_revision_id=$2, enabled=$3, sort_order=$4, availability_remains=$5,
availability_resale=$6, resell_min_stars=$7, first_sale_date=$8, last_sale_date=$9, updated_at=now()
WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder, write.AvailabilityRemains,
write.AvailabilityResale, write.ResellMinStars, write.FirstSaleDate, write.LastSaleDate); err != nil {
return fmt.Errorf("activate star gift revision: %w", err)
}
}
write.GiftID = giftID
var err error
entry, err = catalogEntryByID(ctx, tx, giftID)
return err
})
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
return entry, nil
}
func nullableStarGiftPeerType(peer domain.Peer) any {
if peer.ID <= 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
return nil
}
return string(peer.Type)
}
func nullableStarGiftPeerID(peer domain.Peer) any {
if nullableStarGiftPeerType(peer) == nil {
return nil
}
return peer.ID
}
func nullableBackgroundColor(background *domain.StarGiftBackground, component string) any {
if background == nil {
return nil
}
switch component {
case "center":
return background.CenterColor
case "edge":
return background.EdgeColor
default:
return background.TextColor
}
}
func (s *StarGiftStore) CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) {
var result domain.StarGiftCatalogBundleResult
err := withTx(ctx, s.db, "create star gift catalog bundle", func(tx pgx.Tx) error {
nested := NewStarGiftStore(tx)
entry, err := nested.CreateCatalogRevision(ctx, write.Catalog)
if err != nil {
return err
}
result.Catalog = entry
if write.Collectible != nil {
collectibleWrite := *write.Collectible
collectibleWrite.GiftID = entry.Gift.ID
revision, err := nested.PublishCollectibleRevision(ctx, collectibleWrite)
if err != nil {
return err
}
result.Collectible = &revision
entry, err = catalogEntryByID(ctx, tx, entry.Gift.ID)
if err != nil {
return err
}
result.Catalog = entry
}
return nil
})
return result, err
}
func (s *StarGiftStore) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
tag, err := s.db.Exec(ctx, `
UPDATE star_gift_catalog SET enabled=$2, updated_at=now()
WHERE gift_id=$1 AND enabled IS DISTINCT FROM $2`, giftID, enabled)
if err != nil {
return false, fmt.Errorf("set star gift enabled: %w", err)
}
if tag.RowsAffected() > 0 {
return true, nil
}
var exists bool
if err := s.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM star_gift_catalog WHERE gift_id=$1)`, giftID).Scan(&exists); err != nil {
return false, fmt.Errorf("check star gift enabled target: %w", err)
}
if !exists {
return false, domain.ErrStarGiftNotFound
}
return false, nil
}
func (s *StarGiftStore) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) {
tag, err := s.db.Exec(ctx, `
UPDATE star_gift_catalog SET sort_order=$2, updated_at=now()
WHERE gift_id=$1 AND sort_order IS DISTINCT FROM $2`, giftID, sortOrder)
if err != nil {
return false, fmt.Errorf("set star gift sort order: %w", err)
}
if tag.RowsAffected() > 0 {
return true, nil
}
var exists bool
if err := s.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM star_gift_catalog WHERE gift_id=$1)`, giftID).Scan(&exists); err != nil {
return false, fmt.Errorf("check star gift sort target: %w", err)
}
if !exists {
return false, domain.ErrStarGiftNotFound
}
return false, nil
}
func (s *StarGiftStore) AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error) {
var raw []byte
err := s.db.QueryRow(ctx, `
SELECT r.animation_json::text
FROM star_gift_catalog c
JOIN star_gift_catalog_revisions r ON r.id = c.active_revision_id
WHERE c.gift_id=$1`, giftID).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("get star gift animation: %w", err)
}
return raw, true, nil
}
func catalogEntryByID(ctx context.Context, db sqlcgen.DBTX, giftID int64) (domain.StarGiftCatalogEntry, error) {
row := db.QueryRow(ctx, `
SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title,
r.limited, r.sold_out, r.birthday, r.require_premium,
r.limited_per_user, r.peer_color_available, r.auction,
c.availability_remains, r.availability_total, c.availability_resale,
c.first_sale_date, c.last_sale_date, c.resell_min_stars,
COALESCE(r.released_by_peer_type, ''), COALESCE(r.released_by_peer_id, 0),
r.per_user_total, r.locked_until_date, r.auction_slug, r.gifts_per_round,
r.auction_start_date, r.upgrade_variants,
r.background_center_color IS NOT NULL,
COALESCE(r.background_center_color, 0), COALESCE(r.background_edge_color, 0),
COALESCE(r.background_text_color, 0),
COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0),
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text,
c.enabled, c.sort_order, r.revision, r.source_name, r.source_format,
r.animation_sha256, r.width, r.height, r.frame_rate, r.created_by, c.updated_at,
(SELECT COUNT(*) FROM peer_star_gifts p WHERE p.gift_id=c.gift_id)
FROM star_gift_catalog c
JOIN star_gift_catalog_revisions r ON r.id=c.active_revision_id
LEFT JOIN star_gift_collectible_revisions cr ON cr.id=c.collectible_revision_id AND cr.status='published'
JOIN documents d ON d.id=r.document_id
WHERE c.gift_id=$1`, giftID)
var entry domain.StarGiftCatalogEntry
var attrsJSON, thumbsJSON, sourceFormat string
var releasedByType string
var releasedByID int64
var hasBackground bool
var background domain.StarGiftBackground
if err := row.Scan(
&entry.Gift.ID, &entry.Gift.RevisionID, &entry.Gift.Stars, &entry.Gift.ConvertStars, &entry.Gift.Title,
&entry.Gift.Limited, &entry.Gift.SoldOut, &entry.Gift.Birthday, &entry.Gift.RequirePremium,
&entry.Gift.LimitedPerUser, &entry.Gift.PeerColorAvailable, &entry.Gift.Auction,
&entry.Gift.AvailabilityRemains, &entry.Gift.AvailabilityTotal, &entry.Gift.AvailabilityResale,
&entry.Gift.FirstSaleDate, &entry.Gift.LastSaleDate, &entry.Gift.ResellMinStars,
&releasedByType, &releasedByID, &entry.Gift.PerUserTotal, &entry.Gift.LockedUntilDate,
&entry.Gift.AuctionSlug, &entry.Gift.GiftsPerRound, &entry.Gift.AuctionStartDate,
&entry.Gift.UpgradeVariants, &hasBackground, &background.CenterColor, &background.EdgeColor,
&background.TextColor,
&entry.Gift.UpgradeStars, &entry.Gift.UpgradeTotal, &entry.Gift.UpgradeIssued,
&entry.Gift.Sticker.ID, &entry.Gift.Sticker.AccessHash, &entry.Gift.Sticker.FileReference, &entry.Gift.Sticker.Date,
&entry.Gift.Sticker.MimeType, &entry.Gift.Sticker.Size, &entry.Gift.Sticker.DCID, &attrsJSON, &thumbsJSON,
&entry.Enabled, &entry.SortOrder, &entry.Revision, &entry.SourceName, &sourceFormat,
&entry.AnimationSHA, &entry.Width, &entry.Height, &entry.FrameRate, &entry.CreatedBy, &entry.UpdatedAt,
&entry.ReceivedCount,
); err != nil {
return domain.StarGiftCatalogEntry{}, err
}
if releasedByType != "" && releasedByID > 0 {
entry.Gift.ReleasedBy = domain.Peer{Type: domain.PeerType(releasedByType), ID: releasedByID}
}
if hasBackground {
entry.Gift.Background = &background
}
if entry.Gift.LimitedPerUser {
entry.Gift.PerUserRemains = entry.Gift.PerUserTotal
}
attrs, err := decodeDocumentAttributes(attrsJSON)
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
thumbs, err := decodePhotoSizes(thumbsJSON)
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
entry.Gift.Sticker.Attributes = attrs
entry.Gift.Sticker.Thumbs = thumbs
entry.SourceFormat = domain.StarGiftAnimationFormat(sourceFormat)
entry.AnimationSize = entry.Gift.Sticker.Size
return entry, nil
}
func (s *StarGiftStore) Create(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
if !validSavedStarGift(gift) {
return 0, domain.ErrStarGiftInvalid
}
var id int64
err := s.db.QueryRow(ctx, `
WITH next_id AS (
SELECT nextval(pg_get_serial_sequence('public.peer_star_gifts', 'id'))::bigint AS id
)
INSERT INTO peer_star_gifts (id, owner_peer_type, owner_peer_id, from_user_id, gift_id, catalog_revision_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, prepaid_upgrade_stars, prepaid_upgrade_hash, gift_num, message)
SELECT next_id.id, $1,$2,$3,$4,$5,$6,
CASE WHEN $1 = 'channel' AND $7::bigint = 0 THEN next_id.id ELSE $7::bigint END,
$8,$9,$10,false,$11,$12,$13,$14,$15
FROM next_id
RETURNING id`,
string(gift.Owner.Type), gift.Owner.ID, gift.FromUserID, gift.GiftID, gift.RevisionID, gift.MsgID, gift.SavedID, gift.Date,
gift.NameHidden, gift.Unsaved, gift.ConvertStars, gift.PrepaidUpgradeStars, gift.PrepaidUpgradeHash, gift.GiftNum, gift.Message).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create star gift: %w", err)
}
return id, nil
}
func (s *StarGiftStore) ListByOwner(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
return s.ListByOwnerFiltered(ctx, domain.SavedStarGiftFilter{
Owner: owner, ExcludeUnsaved: excludeUnsaved, Offset: offset, Limit: limit,
})
}
func (s *StarGiftStore) ListByOwnerFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) {
owner, offset, limit := filter.Owner, filter.Offset, filter.Limit
if !validStarGiftOwner(owner) {
return domain.SavedStarGiftPage{}, nil
}
if limit <= 0 || limit > domain.MaxSavedStarGiftsLimit {
limit = domain.MaxSavedStarGiftsLimit
}
joins := `
JOIN star_gift_catalog c ON c.gift_id = p.gift_id
LEFT JOIN star_gift_collectible_revisions acr
ON acr.id = c.collectible_revision_id AND acr.status = 'published'`
conditions := []string{"p.owner_peer_type = $1", "p.owner_peer_id = $2", "p.lifecycle_status = 'active'"}
args := []any{string(owner.Type), owner.ID}
if filter.ExcludeUnsaved {
conditions = append(conditions, "NOT p.unsaved")
}
if filter.ExcludeSaved {
conditions = append(conditions, "p.unsaved")
}
if filter.ExcludeUnique {
conditions = append(conditions, "p.unique_gift_id IS NULL")
}
// telesrv ordinary catalog gifts are currently unlimited. Unique gifts are
// collectibles and therefore survive exclude_unlimited.
if filter.ExcludeUnlimited {
conditions = append(conditions, "p.unique_gift_id IS NOT NULL")
}
upgradable := `(p.unique_gift_id IS NULL AND acr.id IS NOT NULL AND acr.upgrade_stars > 0 AND acr.issued < acr.supply_total)`
if filter.ExcludeUpgradable {
conditions = append(conditions, "NOT "+upgradable)
}
if filter.ExcludeUnupgradable {
conditions = append(conditions, upgradable)
}
if filter.CollectionID > 0 {
args = append(args, filter.CollectionID)
conditions = append(conditions, fmt.Sprintf(`EXISTS (
SELECT 1 FROM star_gift_collection_items ci
JOIN star_gift_collections cc ON cc.collection_id = ci.collection_id
WHERE ci.saved_gift_id = p.id AND ci.collection_id = $%d
AND cc.owner_peer_type = p.owner_peer_type AND cc.owner_peer_id = p.owner_peer_id)`, len(args)))
}
where := strings.Join(conditions, " AND ")
countQuery := `SELECT COUNT(*) FROM peer_star_gifts p ` + joins + ` WHERE ` + where
var total int
if err := s.db.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
return domain.SavedStarGiftPage{}, fmt.Errorf("count star gifts: %w", err)
}
page := domain.SavedStarGiftPage{Count: total}
profileOrder := filter.CollectionID == 0
if cursor, ok := domain.DecodeSavedStarGiftListCursor(offset); ok {
if profileOrder && cursor.PinnedOrder > 0 {
args = append(args, cursor.PinnedOrder, cursor.ID)
where += fmt.Sprintf(` AND (
p.pinned_order = 0
OR p.pinned_order > $%d
OR (p.pinned_order = $%d AND p.id < $%d)
)`, len(args)-1, len(args)-1, len(args))
} else {
args = append(args, cursor.ID)
if profileOrder {
where += fmt.Sprintf(" AND p.pinned_order = 0 AND p.id < $%d", len(args))
} else {
where += fmt.Sprintf(" AND p.id < $%d", len(args))
}
}
}
orderBy := "ORDER BY p.id DESC"
if profileOrder {
orderBy = "ORDER BY (p.pinned_order = 0), p.pinned_order, p.id DESC"
}
args = append(args, limit+1)
limitPlaceholder := len(args)
rows, err := s.db.Query(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.prepaid_upgrade_hash, p.gift_num,
p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at,
p.drop_original_details_stars, p.can_craft_at,
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 `+joins+`
WHERE `+where+`
`+orderBy+`
LIMIT $`+fmt.Sprint(limitPlaceholder), args...)
if err != nil {
return domain.SavedStarGiftPage{}, fmt.Errorf("list star gifts: %w", err)
}
defer rows.Close()
gifts := make([]domain.SavedStarGift, 0, limit)
for rows.Next() {
g, err := scanSavedStarGift(rows)
if err != nil {
return domain.SavedStarGiftPage{}, err
}
gifts = append(gifts, g)
}
if err := rows.Err(); err != nil {
return domain.SavedStarGiftPage{}, fmt.Errorf("iterate star gifts: %w", err)
}
if len(gifts) > limit {
gifts = gifts[:limit]
last := gifts[len(gifts)-1]
pinnedOrder := 0
if profileOrder {
pinnedOrder = last.PinnedOrder
}
page.NextOffset = domain.EncodeSavedStarGiftListCursor(pinnedOrder, last.ID)
}
page.Gifts = gifts
return page, nil
}
func (s *StarGiftStore) ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) {
if !validStarGiftOwner(owner) || len(refs) > domain.MaxStarGiftCollectionItems {
return nil, domain.ErrStarGiftCollectibleInvalid
}
if len(refs) == 0 {
return []int64{}, nil
}
type resolveKey struct {
value int64
slug string
}
keys := make([]resolveKey, 0, len(refs))
values := make([]int64, 0, len(refs))
slugs := make([]string, 0, len(refs))
seenKeys := make(map[string]struct{}, len(refs))
for _, ref := range refs {
if ref.Owner != owner || !ref.Valid() {
return nil, domain.ErrStarGiftNotFound
}
if ref.Slug != "" {
slug := strings.ToLower(strings.TrimSpace(ref.Slug))
key := "slug:" + slug
if _, duplicate := seenKeys[key]; duplicate {
return nil, domain.ErrStarGiftCollectibleInvalid
}
seenKeys[key] = struct{}{}
keys = append(keys, resolveKey{slug: slug})
slugs = append(slugs, slug)
continue
}
value := int64(ref.MsgID)
if owner.Type == domain.PeerTypeChannel {
value = ref.SavedID
}
key := fmt.Sprintf("id:%d", value)
if _, duplicate := seenKeys[key]; duplicate {
return nil, domain.ErrStarGiftCollectibleInvalid
}
seenKeys[key] = struct{}{}
keys = append(keys, resolveKey{value: value})
values = append(values, value)
}
query := `SELECT p.saved_id::bigint, COALESCE(u.slug, ''), p.id
FROM peer_star_gifts p
LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id
WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active'
AND (p.saved_id::bigint=ANY($3::bigint[]) OR u.slug=ANY($4::text[]))`
if owner.Type == domain.PeerTypeUser {
query = `SELECT ref.msg_id, COALESCE(u.slug, ''), p.id
FROM peer_star_gifts p
LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id
CROSS JOIN LATERAL (
SELECT p.msg_id::bigint AS msg_id
UNION ALL
SELECT r.msg_id::bigint FROM star_gift_user_message_refs r
WHERE r.saved_gift_id=p.id AND r.owner_user_id=p.owner_peer_id
) ref
WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active'
AND (ref.msg_id=ANY($3::bigint[])
OR u.slug=ANY($4::text[]))`
}
rows, err := s.db.Query(ctx, query, string(owner.Type), owner.ID, values, slugs)
if err != nil {
return nil, fmt.Errorf("resolve saved star gifts: %w", err)
}
defer rows.Close()
resolvedValues := make(map[int64]int64, len(values))
resolvedSlugs := make(map[string]int64, len(slugs))
for rows.Next() {
var primaryValue, id int64
var slug string
if err := rows.Scan(&primaryValue, &slug, &id); err != nil {
return nil, fmt.Errorf("scan resolved saved star gift: %w", err)
}
if existing := resolvedValues[primaryValue]; existing != 0 && existing != id {
return nil, domain.ErrStarGiftCollectibleInvalid
}
resolvedValues[primaryValue] = id
if slug != "" {
if existing := resolvedSlugs[slug]; existing != 0 && existing != id {
return nil, domain.ErrStarGiftCollectibleInvalid
}
resolvedSlugs[slug] = id
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate resolved saved star gifts: %w", err)
}
out := make([]int64, 0, len(keys))
seenIDs := make(map[int64]struct{}, len(keys))
for _, key := range keys {
id := resolvedValues[key.value]
if key.slug != "" {
id = resolvedSlugs[key.slug]
}
if id == 0 {
return nil, domain.ErrStarGiftNotFound
}
if _, duplicate := seenIDs[id]; duplicate {
return nil, domain.ErrStarGiftCollectibleInvalid
}
seenIDs[id] = struct{}{}
out = append(out, id)
}
return out, nil
}
func (s *StarGiftStore) GetByRef(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
if !ref.Valid() {
return domain.SavedStarGift{}, false, nil
}
where, args := savedStarGiftRefWhere(ref)
row := s.db.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.prepaid_upgrade_hash, p.gift_num,
p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at,
p.drop_original_details_stars, p.can_craft_at,
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, args...)
g, err := scanSavedStarGift(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.SavedStarGift{}, false, nil
}
return domain.SavedStarGift{}, false, err
}
return g, true, nil
}
func (s *StarGiftStore) ResolveUserMessageRef(ctx context.Context, viewerUserID int64, msgID int) (domain.SavedStarGiftRef, bool, error) {
if s == nil || s.db == nil || viewerUserID <= 0 || msgID <= 0 {
return domain.SavedStarGiftRef{}, false, nil
}
var ownerType string
var ownerID, savedID int64
err := s.db.QueryRow(ctx, `
SELECT gift.owner_peer_type,gift.owner_peer_id,gift.saved_id
FROM star_gift_user_message_refs ref
JOIN peer_star_gifts gift ON gift.id=ref.saved_gift_id
JOIN message_boxes box
ON box.owner_user_id=ref.owner_user_id AND box.box_id=ref.msg_id
WHERE ref.owner_user_id=$1 AND ref.msg_id=$2
AND NOT box.deleted
AND gift.lifecycle_status='active'
AND (
(gift.owner_peer_type='user' AND gift.owner_peer_id=$1)
OR (
gift.owner_peer_type='channel'
AND (
(
box.media #>> '{service_action,kind}'='star_gift'
AND box.media #>> '{service_action,star_gift,peer_channel_id}'=gift.owner_peer_id::text
AND box.media #>> '{service_action,star_gift,saved_id}'=gift.saved_id::text
)
OR (
box.media #>> '{service_action,kind}'='star_gift_unique'
AND box.media #>> '{service_action,star_gift_unique,peer,Type}'='channel'
AND box.media #>> '{service_action,star_gift_unique,peer,ID}'=gift.owner_peer_id::text
AND box.media #>> '{service_action,star_gift_unique,saved_id}'=gift.saved_id::text
)
)
)
)`,
viewerUserID, msgID).Scan(&ownerType, &ownerID, &savedID)
if errors.Is(err, pgx.ErrNoRows) {
return domain.SavedStarGiftRef{}, false, nil
}
if err != nil {
return domain.SavedStarGiftRef{}, false, fmt.Errorf("resolve star gift user message ref: %w", err)
}
owner := domain.Peer{Type: domain.PeerType(ownerType), ID: ownerID}
switch owner.Type {
case domain.PeerTypeUser:
return domain.SavedStarGiftRef{Owner: owner, MsgID: msgID}, true, nil
case domain.PeerTypeChannel:
if savedID <= 0 {
return domain.SavedStarGiftRef{}, false, domain.ErrStarGiftOwnerInvalid
}
return domain.SavedStarGiftRef{Owner: owner, SavedID: savedID}, true, nil
default:
return domain.SavedStarGiftRef{}, false, domain.ErrStarGiftOwnerInvalid
}
}
func (s *StarGiftStore) CountByOwner(ctx context.Context, owner domain.Peer) (int, error) {
if !validStarGiftOwner(owner) {
return 0, nil
}
var n int
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND lifecycle_status='active' AND NOT unsaved`, string(owner.Type), owner.ID).Scan(&n); err != nil {
return 0, fmt.Errorf("count star gifts: %w", err)
}
return n, nil
}
func (s *StarGiftStore) SetUnsaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) {
if !ref.Valid() {
return false, domain.ErrStarGiftNotFound
}
changed := false
err := withTx(ctx, s.db, "set star gift unsaved", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(ref.Owner)); err != nil {
return err
}
where, args := savedStarGiftRefWhere(ref)
var savedID int64
var pinnedOrder int
err := tx.QueryRow(ctx, `SELECT id,pinned_order FROM peer_star_gifts WHERE `+where+` AND lifecycle_status='active' FOR UPDATE`, args...).Scan(&savedID, &pinnedOrder)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET unsaved=$2,pinned_order=CASE WHEN $2 THEN 0 ELSE pinned_order END WHERE id=$1`, savedID, unsaved); err != nil {
return err
}
if unsaved && pinnedOrder > 0 {
// The positive-order unique index is immediate. Move the bounded
// vector one vacant slot at a time so no transient duplicate order
// can be observed by PostgreSQL.
for order := pinnedOrder + 1; order <= domain.MaxPinnedStarGifts; order++ {
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$4
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND pinned_order=$3`, string(ref.Owner.Type), ref.Owner.ID, order, order-1); err != nil {
return err
}
}
}
changed = true
return nil
})
if err != nil {
return false, fmt.Errorf("set star gift unsaved: %w", err)
}
return changed, nil
}
func (s *StarGiftStore) MarkConverted(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
if !ref.Valid() {
return domain.SavedStarGift{}, domain.ErrStarGiftNotFound
}
out := domain.SavedStarGift{}
err := withTx(ctx, s.db, "convert star gift", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(ref.Owner)); err != nil {
return fmt.Errorf("lock star gift owner collections: %w", err)
}
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.prepaid_upgrade_hash, p.gift_num,
p.lifecycle_status, p.transfer_stars, p.can_export_at, p.can_transfer_at, p.can_resell_at,
p.drop_original_details_stars, p.can_craft_at,
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...)
g, err := scanSavedStarGift(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrStarGiftNotFound
}
return err
}
if g.Converted {
return domain.ErrStarGiftAlreadyConverted
}
if g.UniqueGiftID != 0 {
return domain.ErrStarGiftAlreadyUpgraded
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET converted = true, lifecycle_status='converted', unsaved = true, pinned_order = 0 WHERE id = $1`, g.ID); err != nil {
return fmt.Errorf("mark star gift converted: %w", err)
}
if err := removeSavedGiftFromCollections(ctx, tx, g.Owner, g.ID); err != nil {
return err
}
g.Converted = true
g.LifecycleStatus = domain.StarGiftLifecycleConverted
g.Unsaved = true
g.PinnedOrder = 0
g.CollectionIDs = nil
out = g
return nil
})
if err != nil {
return domain.SavedStarGift{}, err
}
return out, nil
}
func scanSavedStarGift(row rowScanner) (domain.SavedStarGift, error) {
var g domain.SavedStarGift
var ownerType string
if err := row.Scan(&g.ID, &ownerType, &g.Owner.ID, &g.FromUserID, &g.GiftID, &g.RevisionID, &g.MsgID, &g.SavedID, &g.Date,
&g.NameHidden, &g.Unsaved, &g.Converted, &g.ConvertStars, &g.PrepaidUpgradeStars, &g.PrepaidUpgradeHash, &g.GiftNum,
&g.LifecycleStatus, &g.TransferStars, &g.CanExportAt, &g.CanTransferAt, &g.CanResellAt,
&g.DropOriginalDetailsStars, &g.CanCraftAt, &g.Message, &g.UniqueGiftID,
&g.UpgradeMsgID, &g.PinnedOrder, &g.CollectionIDs); err != nil {
return domain.SavedStarGift{}, err
}
g.Owner.Type = domain.PeerType(ownerType)
return g, nil
}
func savedStarGiftRefWhere(ref domain.SavedStarGiftRef) (string, []any) {
args := []any{string(ref.Owner.Type), ref.Owner.ID}
if ref.Slug != "" {
args = append(args, strings.ToLower(strings.TrimSpace(ref.Slug)))
return "owner_peer_type = $1 AND owner_peer_id = $2 AND unique_gift_id = (SELECT id FROM unique_star_gifts WHERE slug = $3)", args
}
switch ref.Owner.Type {
case domain.PeerTypeChannel:
args = append(args, ref.SavedID)
return "owner_peer_type = $1 AND owner_peer_id = $2 AND saved_id = $3", args
default:
args = append(args, ref.MsgID)
return `owner_peer_type = $1 AND owner_peer_id = $2 AND (
msg_id = $3 OR EXISTS (
SELECT 1 FROM star_gift_user_message_refs r
WHERE r.saved_gift_id = id AND r.owner_user_id = $2 AND r.msg_id = $3
))`, args
}
}
func validSavedStarGift(g domain.SavedStarGift) bool {
if g.GiftID == 0 || g.RevisionID == 0 || !validStarGiftOwner(g.Owner) {
return false
}
switch g.Owner.Type {
case domain.PeerTypeUser:
return g.MsgID > 0 && g.SavedID == 0
case domain.PeerTypeChannel:
return g.MsgID == 0 && g.SavedID >= 0
default:
return false
}
}
func validStarGiftOwner(owner domain.Peer) bool {
return owner.ID != 0 && (owner.Type == domain.PeerTypeUser || owner.Type == domain.PeerTypeChannel)
}

View file

@ -1,234 +0,0 @@
package postgres
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
const (
maxChannelStarGiftNotificationRecipients = 256
channelStarGiftNotificationLeaseSeconds = 60
)
type channelStarGiftNotificationJob struct {
SavedGiftID int64
TargetUserID int64
GiftDate int
Action domain.MessageStarGiftAction
Attempts int
}
func enqueueChannelStarGiftNotifications(
ctx context.Context,
tx pgx.Tx,
savedGiftID int64,
channelID int64,
giftDate int,
action *domain.MessageStarGiftAction,
) error {
if savedGiftID <= 0 || channelID <= 0 || giftDate <= 0 || action == nil ||
action.PeerChannelID != channelID || action.SavedID <= 0 {
return fmt.Errorf("enqueue channel star gift notifications: invalid intent")
}
actionJSON, err := json.Marshal(action)
if err != nil {
return fmt.Errorf("encode channel star gift notification: %w", err)
}
_, err = tx.Exec(ctx, `
WITH candidates AS (
SELECT creator_user_id AS user_id
FROM channels
WHERE id=$2 AND NOT deleted
UNION
SELECT user_id
FROM channel_members
WHERE channel_id=$2 AND status='active'
AND (role='creator' OR (
role='admin'
AND COALESCE((admin_rights->>'PostMessages')::boolean,false)
))
), bounded AS (
SELECT user_id FROM candidates
WHERE user_id>0
ORDER BY user_id
LIMIT $5
)
INSERT INTO star_gift_channel_notification_jobs
(saved_gift_id,target_user_id,gift_date,action,next_attempt_at)
SELECT $1,bounded.user_id,$3,$4::jsonb,$3
FROM bounded
LEFT JOIN star_gift_notification_settings settings
ON settings.user_id=bounded.user_id AND settings.channel_id=$2
WHERE COALESCE(settings.enabled,TRUE)
ON CONFLICT(saved_gift_id,target_user_id) DO NOTHING`,
savedGiftID, channelID, giftDate, string(actionJSON), maxChannelStarGiftNotificationRecipients)
if err != nil {
return fmt.Errorf("enqueue channel star gift notifications: %w", err)
}
return nil
}
func (s *StarGiftLifecycleStore) dispatchChannelStarGiftNotifications(
ctx context.Context,
now int,
limit int,
savedGiftID int64,
) (int, error) {
if s == nil || s.db == nil || s.messages == nil || now <= 0 || limit <= 0 {
return 0, domain.ErrStarGiftUnavailable
}
if limit > maxChannelStarGiftNotificationRecipients {
limit = maxChannelStarGiftNotificationRecipients
}
jobs, err := s.claimChannelStarGiftNotificationJobs(ctx, now, limit, savedGiftID)
if err != nil {
return 0, err
}
var firstErr error
delivered := 0
for _, job := range jobs {
messageID, sendErr := s.deliverChannelStarGiftNotification(ctx, job)
if sendErr == nil {
tag, markErr := s.db.Exec(ctx, `UPDATE star_gift_channel_notification_jobs
SET delivered_at=$3,message_id=$4,lease_until=0,last_error='',updated_at=now()
WHERE saved_gift_id=$1 AND target_user_id=$2 AND delivered_at=0`,
job.SavedGiftID, job.TargetUserID, now, messageID)
if markErr == nil && tag.RowsAffected() == 1 {
delivered++
continue
}
if markErr == nil {
markErr = fmt.Errorf("channel star gift notification job disappeared")
}
sendErr = markErr
}
if firstErr == nil {
firstErr = sendErr
}
retryAt := now + channelStarGiftNotificationRetrySeconds(job.Attempts)
_, _ = s.db.Exec(ctx, `UPDATE star_gift_channel_notification_jobs
SET next_attempt_at=$3,lease_until=0,last_error=$4,updated_at=now()
WHERE saved_gift_id=$1 AND target_user_id=$2 AND delivered_at=0`,
job.SavedGiftID, job.TargetUserID, retryAt, truncateStarGiftNotificationError(sendErr))
}
return delivered, firstErr
}
func (s *StarGiftLifecycleStore) claimChannelStarGiftNotificationJobs(
ctx context.Context,
now int,
limit int,
savedGiftID int64,
) ([]channelStarGiftNotificationJob, error) {
jobs := make([]channelStarGiftNotificationJob, 0, limit)
err := withTx(ctx, s.db, "claim channel star gift notifications", func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
WITH picked AS (
SELECT saved_gift_id,target_user_id
FROM star_gift_channel_notification_jobs
WHERE delivered_at=0 AND next_attempt_at<=$1 AND lease_until<$1
AND ($3::bigint=0 OR saved_gift_id=$3)
ORDER BY next_attempt_at,saved_gift_id,target_user_id
FOR UPDATE SKIP LOCKED
LIMIT $2
)
UPDATE star_gift_channel_notification_jobs job
SET attempts=job.attempts+1,
lease_until=$1+$4,
updated_at=now()
FROM picked
WHERE job.saved_gift_id=picked.saved_gift_id
AND job.target_user_id=picked.target_user_id
RETURNING job.saved_gift_id,job.target_user_id,job.gift_date,job.action,job.attempts`,
now, limit, savedGiftID, channelStarGiftNotificationLeaseSeconds)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var job channelStarGiftNotificationJob
var actionJSON []byte
if err := rows.Scan(&job.SavedGiftID, &job.TargetUserID, &job.GiftDate, &actionJSON, &job.Attempts); err != nil {
return err
}
if err := json.Unmarshal(actionJSON, &job.Action); err != nil {
return fmt.Errorf("decode channel star gift notification: %w", err)
}
if job.SavedGiftID <= 0 || job.TargetUserID <= 0 || job.GiftDate <= 0 ||
job.Action.PeerChannelID <= 0 || job.Action.SavedID <= 0 {
return fmt.Errorf("decode channel star gift notification: invalid intent")
}
jobs = append(jobs, job)
}
return rows.Err()
})
return jobs, err
}
func (s *StarGiftLifecycleStore) deliverChannelStarGiftNotification(
ctx context.Context,
job channelStarGiftNotificationJob,
) (int, error) {
fingerprint := sha256.Sum256([]byte(fmt.Sprintf(
"telesrv:channel-star-gift-notification:v1:%d:%d",
job.SavedGiftID, job.TargetUserID,
)))
action := job.Action
request := domain.SendPrivateTextRequest{
SenderUserID: domain.OfficialSystemUserID,
RecipientUserID: job.TargetUserID,
RandomID: lifecycleCommandRandomID("channel-star-gift-notification", job.SavedGiftID, job.TargetUserID),
Date: job.GiftDate,
IdempotencyFingerprint: fingerprint[:],
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &action,
}},
}
sent, err := s.messages.sendPrivateTextWithHooks(ctx, request, privateSendTxHooks{
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
if sent.RecipientMessage.ID <= 0 {
return fmt.Errorf("channel star gift notification missing recipient box")
}
return registerChannelNotificationMessageRef(ctx, tx, job.TargetUserID,
sent.RecipientMessage.ID, job.SavedGiftID)
},
})
if err != nil {
return 0, err
}
if sent.RecipientMessage.ID <= 0 {
return 0, fmt.Errorf("channel star gift notification replay missing recipient box")
}
return sent.RecipientMessage.ID, nil
}
func channelStarGiftNotificationRetrySeconds(attempt int) int {
if attempt < 1 {
attempt = 1
}
delay := attempt * attempt * 5
if delay > 3600 {
return 3600
}
return delay
}
func truncateStarGiftNotificationError(err error) string {
if err == nil {
return ""
}
value := strings.TrimSpace(err.Error())
runes := []rune(value)
if len(runes) > 1000 {
value = string(runes[:1000])
}
return value
}

View file

@ -1,982 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
func nullablePermille(attribute domain.StarGiftCollectibleAttribute) any {
if attribute.RarityKind != domain.StarGiftRarityPermille {
return nil
}
return attribute.RarityPermille
}
func nullableSHA256(value []byte) any {
if len(value) == 0 {
return nil
}
return value
}
func nullablePositiveInt64(value int64) any {
if value <= 0 {
return nil
}
return value
}
func nullableOfficialGiftJSON(value []byte) any {
if len(value) == 0 {
return nil
}
return string(value)
}
func (s *StarGiftStore) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
write.Actor = strings.TrimSpace(write.Actor)
write.CommandID = strings.TrimSpace(write.CommandID)
if err := domain.ValidateStarGiftCollectibleWrite(write); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
var result domain.StarGiftCollectibleRevision
err := withTx(ctx, s.db, "publish collectible star gift revision", func(tx pgx.Tx) error {
var ignored int64
if err := tx.QueryRow(ctx, `SELECT gift_id FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, write.GiftID).Scan(&ignored); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrStarGiftNotFound
}
return fmt.Errorf("lock collectible catalog gift: %w", err)
}
var revision int
if err := tx.QueryRow(ctx, `
SELECT COALESCE(MAX(revision), 0) + 1 FROM star_gift_collectible_revisions WHERE gift_id=$1`, write.GiftID).Scan(&revision); err != nil {
return fmt.Errorf("allocate collectible revision: %w", err)
}
var revisionID int64
if err := tx.QueryRow(ctx, `
INSERT INTO star_gift_collectible_revisions
(gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id,
official_gift_id, source_manifest_sha256)
VALUES ($1,$2,$3,$4,$5,'draft',$6,$7,NULLIF($8::bigint,0),$9)
RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, write.SlugPrefix, write.Actor, write.CommandID,
write.OfficialGiftID, nullableSHA256(write.SourceManifestSHA256)).Scan(&revisionID); err != nil {
return fmt.Errorf("insert collectible revision: %w", err)
}
media := NewMediaStore(tx)
insertAnimated := func(table string, attributes []domain.StarGiftCollectibleAttribute, models bool) error {
for _, attribute := range attributes {
if err := media.PutDocument(ctx, *attribute.Document); err != nil {
return fmt.Errorf("put collectible %s document: %w", attribute.Kind, err)
}
if err := media.PutFileBlob(ctx, *attribute.Blob); err != nil {
return fmt.Errorf("put collectible %s blob: %w", attribute.Kind, err)
}
animation := attribute.Animation
var query string
if models {
query = fmt.Sprintf(`
INSERT INTO %s
(collectible_revision_id, name, document_id, animation_json, animation_sha256,
source_name, source_format, width, height, frame_rate, in_point, out_point,
rarity_kind, rarity_permille, crafted, official_document_id, sort_order)
VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`, table)
if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID,
string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat),
animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint,
string(attribute.RarityKind), nullablePermille(attribute), attribute.Crafted,
nullablePositiveInt64(attribute.OfficialDocumentID), attribute.SortOrder); err != nil {
return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err)
}
} else {
query = fmt.Sprintf(`
INSERT INTO %s
(collectible_revision_id, name, document_id, animation_json, animation_sha256,
source_name, source_format, width, height, frame_rate, in_point, out_point,
rarity_kind, rarity_permille, official_document_id, sort_order)
VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, table)
if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID,
string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat),
animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint,
string(attribute.RarityKind), nullablePermille(attribute), nullablePositiveInt64(attribute.OfficialDocumentID),
attribute.SortOrder); err != nil {
return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err)
}
}
}
return nil
}
if err := insertAnimated("star_gift_collectible_models", write.Models, true); err != nil {
return err
}
if err := insertAnimated("star_gift_collectible_patterns", write.Patterns, false); err != nil {
return err
}
for _, attribute := range write.Backdrops {
if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_collectible_backdrops
(collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
text_color, rarity_kind, rarity_permille, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, revisionID, strings.TrimSpace(attribute.Name), attribute.BackdropID,
attribute.CenterColor, attribute.EdgeColor, attribute.PatternColor, attribute.TextColor,
string(attribute.RarityKind), nullablePermille(attribute), attribute.SortOrder); err != nil {
return fmt.Errorf("insert collectible backdrop: %w", err)
}
}
if _, err := tx.Exec(ctx, `
UPDATE star_gift_collectible_revisions SET status='published', published_at=now() WHERE id=$1`, revisionID); err != nil {
return fmt.Errorf("publish collectible revision: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE star_gift_catalog SET collectible_revision_id=$2, updated_at=now() WHERE gift_id=$1`, write.GiftID, revisionID); err != nil {
return fmt.Errorf("activate collectible revision: %w", err)
}
var err error
result, err = collectibleRevisionByID(ctx, tx, revisionID)
return err
})
return result, err
}
func (s *StarGiftStore) ActiveCollectibleRevision(ctx context.Context, giftID int64) (domain.StarGiftCollectibleRevision, bool, error) {
revisionID, ok, err := activeCollectibleRevisionID(ctx, s.db, giftID)
if err != nil || !ok {
return domain.StarGiftCollectibleRevision{}, ok, err
}
revision, err := collectibleRevisionByID(ctx, s.db, revisionID)
if err != nil {
return domain.StarGiftCollectibleRevision{}, false, err
}
return revision, true, nil
}
func (s *StarGiftStore) ActiveCollectibleProjection(ctx context.Context, giftID int64, samplePerKind int) (domain.StarGiftCollectibleRevision, bool, error) {
revisionID, ok, err := activeCollectibleRevisionID(ctx, s.db, giftID)
if err != nil || !ok {
return domain.StarGiftCollectibleRevision{}, ok, err
}
revision, err := collectibleRevisionProjectionByID(ctx, s.db, revisionID, samplePerKind)
if err != nil {
return domain.StarGiftCollectibleRevision{}, false, err
}
return revision, true, nil
}
func activeCollectibleRevisionID(ctx context.Context, db sqlcgen.DBTX, giftID int64) (int64, bool, error) {
var revisionID int64
err := db.QueryRow(ctx, `
SELECT collectible_revision_id FROM star_gift_catalog
WHERE gift_id=$1 AND collectible_revision_id IS NOT NULL`, giftID).Scan(&revisionID)
if errors.Is(err, pgx.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, fmt.Errorf("get active collectible revision: %w", err)
}
return revisionID, true, nil
}
func (s *StarGiftStore) CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
out := make(map[int64]domain.StarGiftCollectibleAvailability, len(giftIDs))
if len(giftIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT c.gift_id, r.upgrade_stars, r.supply_total, r.issued
FROM star_gift_catalog c
JOIN star_gift_collectible_revisions r ON r.id=c.collectible_revision_id
WHERE c.gift_id=ANY($1) AND r.status='published'`, giftIDs)
if err != nil {
return nil, fmt.Errorf("list collectible availability: %w", err)
}
defer rows.Close()
for rows.Next() {
var giftID int64
var availability domain.StarGiftCollectibleAvailability
if err := rows.Scan(&giftID, &availability.UpgradeStars, &availability.SupplyTotal, &availability.Issued); err != nil {
return nil, fmt.Errorf("scan collectible availability: %w", err)
}
out[giftID] = availability
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list collectible availability rows: %w", err)
}
return out, nil
}
func collectibleRevisionByID(ctx context.Context, db sqlcgen.DBTX, revisionID int64) (domain.StarGiftCollectibleRevision, error) {
return readCollectibleRevisionByID(ctx, db, revisionID, collectibleRevisionReadOptions{includeAnimationJSON: true})
}
func collectibleRevisionProjectionByID(ctx context.Context, db sqlcgen.DBTX, revisionID int64, samplePerKind int) (domain.StarGiftCollectibleRevision, error) {
return readCollectibleRevisionByID(ctx, db, revisionID, collectibleRevisionReadOptions{samplePerKind: samplePerKind})
}
type collectibleRevisionReadOptions struct {
includeAnimationJSON bool
samplePerKind int
}
func readCollectibleRevisionByID(ctx context.Context, db sqlcgen.DBTX, revisionID int64, options collectibleRevisionReadOptions) (domain.StarGiftCollectibleRevision, error) {
var revision domain.StarGiftCollectibleRevision
var status string
var publishedAt pgtype.Timestamptz
if err := db.QueryRow(ctx, `
SELECT id, gift_id, revision, upgrade_stars, supply_total, issued, slug_prefix, status,
created_by, created_at, published_at, COALESCE(official_gift_id,0), source_manifest_sha256
FROM star_gift_collectible_revisions WHERE id=$1`, revisionID).Scan(
&revision.ID, &revision.GiftID, &revision.Revision, &revision.UpgradeStars, &revision.SupplyTotal,
&revision.Issued, &revision.SlugPrefix, &status, &revision.CreatedBy, &revision.CreatedAt, &publishedAt,
&revision.OfficialGiftID, &revision.SourceManifestSHA256,
); err != nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("get collectible revision: %w", err)
}
revision.Published = status == "published"
if publishedAt.Valid {
revision.PublishedAt = publishedAt.Time
}
var err error
if revision.Models, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectibleModel, options); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if revision.Patterns, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectiblePattern, options); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if revision.Backdrops, err = listCollectibleBackdrops(ctx, db, revisionID, options); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
return revision, nil
}
func listAnimatedCollectibleAttributes(ctx context.Context, db sqlcgen.DBTX, revisionID int64, kind domain.StarGiftCollectibleAttributeKind, options collectibleRevisionReadOptions) ([]domain.StarGiftCollectibleAttribute, error) {
table := "star_gift_collectible_models"
if kind == domain.StarGiftCollectiblePattern {
table = "star_gift_collectible_patterns"
} else if kind != domain.StarGiftCollectibleModel {
return nil, domain.ErrStarGiftCollectibleInvalid
}
craftedExpression := "false"
if kind == domain.StarGiftCollectibleModel {
craftedExpression = "a.crafted"
}
animationJSONExpression := "''::text"
if options.includeAnimationJSON {
animationJSONExpression = "a.animation_json::text"
}
prefix := ""
from := fmt.Sprintf("%s a JOIN documents d ON d.id=a.document_id", table)
args := []any{revisionID}
if options.samplePerKind > 0 {
extra := ""
if kind == domain.StarGiftCollectibleModel {
extra = " AND NOT crafted"
}
prefix = fmt.Sprintf(`WITH picked AS MATERIALIZED (
SELECT id FROM %s
WHERE collectible_revision_id=$1 AND rarity_kind='permille' AND rarity_permille > 0%s
ORDER BY random()
LIMIT $2
)`, table, extra)
from = fmt.Sprintf("picked p JOIN %s a ON a.id=p.id JOIN documents d ON d.id=a.document_id", table)
args = append(args, options.samplePerKind)
}
rows, err := db.Query(ctx, fmt.Sprintf(`
%s
SELECT a.id, a.collectible_revision_id, a.name, a.rarity_kind, COALESCE(a.rarity_permille,0),
%s, COALESCE(a.official_document_id,0), a.sort_order,
%s, a.animation_sha256, a.source_name, a.source_format,
a.width, a.height, a.frame_rate, a.in_point, a.out_point,
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text
FROM %s
%s
ORDER BY a.sort_order, a.id`, prefix, craftedExpression, animationJSONExpression, from,
func() string {
if options.samplePerKind > 0 {
return ""
}
return "WHERE a.collectible_revision_id=$1"
}()), args...)
if err != nil {
return nil, fmt.Errorf("list collectible %s attributes: %w", kind, err)
}
defer rows.Close()
out := make([]domain.StarGiftCollectibleAttribute, 0)
for rows.Next() {
attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Document: &domain.Document{}, Animation: &domain.StarGiftAnimation{}}
var attrsJSON, thumbsJSON, sourceFormat string
if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.RarityKind,
&attribute.RarityPermille, &attribute.Crafted, &attribute.OfficialDocumentID, &attribute.SortOrder,
&attribute.Animation.JSON, &attribute.Animation.SHA256, &attribute.Animation.SourceName, &sourceFormat,
&attribute.Animation.Width, &attribute.Animation.Height, &attribute.Animation.FrameRate, &attribute.Animation.InPoint, &attribute.Animation.OutPoint,
&attribute.Document.ID, &attribute.Document.AccessHash, &attribute.Document.FileReference, &attribute.Document.Date,
&attribute.Document.MimeType, &attribute.Document.Size, &attribute.Document.DCID, &attrsJSON, &thumbsJSON); err != nil {
return nil, err
}
attribute.Animation.SourceFormat = domain.StarGiftAnimationFormat(sourceFormat)
if attribute.Document.Attributes, err = decodeDocumentAttributes(attrsJSON); err != nil {
return nil, err
}
if attribute.Document.Thumbs, err = decodePhotoSizes(thumbsJSON); err != nil {
return nil, err
}
out = append(out, attribute)
}
return out, rows.Err()
}
func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64, options collectibleRevisionReadOptions) ([]domain.StarGiftCollectibleAttribute, error) {
prefix := ""
from := "star_gift_collectible_backdrops a"
where := "WHERE a.collectible_revision_id=$1"
args := []any{revisionID}
if options.samplePerKind > 0 {
prefix = `WITH picked AS MATERIALIZED (
SELECT id FROM star_gift_collectible_backdrops
WHERE collectible_revision_id=$1 AND rarity_kind='permille' AND rarity_permille > 0
ORDER BY random()
LIMIT $2
)`
from = "picked p JOIN star_gift_collectible_backdrops a ON a.id=p.id"
where = ""
args = append(args, options.samplePerKind)
}
rows, err := db.Query(ctx, fmt.Sprintf(`
%s
SELECT a.id, a.collectible_revision_id, a.name, a.backdrop_id, a.center_color, a.edge_color, a.pattern_color,
a.text_color, a.rarity_kind, COALESCE(a.rarity_permille,0), a.sort_order
FROM %s
%s
ORDER BY a.sort_order, a.id`, prefix, from, where), args...)
if err != nil {
return nil, fmt.Errorf("list collectible backdrops: %w", err)
}
defer rows.Close()
out := make([]domain.StarGiftCollectibleAttribute, 0)
for rows.Next() {
attribute := domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleBackdrop}
if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.BackdropID,
&attribute.CenterColor, &attribute.EdgeColor, &attribute.PatternColor, &attribute.TextColor,
&attribute.RarityKind, &attribute.RarityPermille, &attribute.SortOrder); err != nil {
return nil, err
}
out = append(out, attribute)
}
return out, rows.Err()
}
func (s *StarGiftStore) CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
table := "star_gift_collectible_models"
if kind == domain.StarGiftCollectiblePattern {
table = "star_gift_collectible_patterns"
} else if kind != domain.StarGiftCollectibleModel {
return nil, false, nil
}
var raw []byte
err := s.db.QueryRow(ctx, fmt.Sprintf(`
SELECT a.animation_json::text FROM %s a
JOIN star_gift_catalog c ON c.collectible_revision_id=a.collectible_revision_id
WHERE c.gift_id=$1 AND a.id=$2`, table), giftID, attributeID).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("get collectible animation: %w", err)
}
return raw, true, nil
}
func (s *StarGiftStore) UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) {
return s.uniqueByPredicate(ctx, "u.slug=$1", strings.ToLower(strings.TrimSpace(slug)))
}
func (s *StarGiftStore) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) {
return s.uniqueByPredicate(ctx, "u.id=$1", uniqueGiftID)
}
func (s *StarGiftStore) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) {
out := make(map[int64]domain.UniqueStarGift, len(uniqueGiftIDs))
if len(uniqueGiftIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, uniqueStarGiftQuery("u.id=ANY($1::bigint[])"), uniqueGiftIDs)
if err != nil {
return nil, fmt.Errorf("list unique star gifts: %w", err)
}
defer rows.Close()
for rows.Next() {
unique, err := scanUniqueStarGift(rows)
if err != nil {
return nil, err
}
out[unique.ID] = unique
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate unique star gifts: %w", err)
}
return out, nil
}
func (s *StarGiftStore) ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
if owner.ID <= 0 || limit <= 0 {
return []domain.UniqueStarGift{}, nil
}
if limit > domain.MaxSavedStarGiftsLimit {
limit = domain.MaxSavedStarGiftsLimit
}
rows, err := s.db.Query(ctx, uniqueStarGiftQuery(`
u.owner_peer_type=$1 AND u.owner_peer_id=$2
AND NOT u.burned AND u.owner_address=''
AND sg.lifecycle_status='active'`)+`
ORDER BY u.id DESC
LIMIT $3`, string(owner.Type), owner.ID, limit)
if err != nil {
return nil, fmt.Errorf("list unique star gifts by owner: %w", err)
}
defer rows.Close()
out := make([]domain.UniqueStarGift, 0, limit)
for rows.Next() {
gift, err := scanUniqueStarGift(rows)
if err != nil {
return nil, err
}
out = append(out, gift)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate unique star gifts by owner: %w", err)
}
return out, nil
}
func (s *StarGiftStore) uniqueByPredicate(ctx context.Context, predicate string, value any) (domain.UniqueStarGift, bool, error) {
row := s.db.QueryRow(ctx, uniqueStarGiftQuery(predicate), value)
unique, err := scanUniqueStarGift(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.UniqueStarGift{}, false, nil
}
return domain.UniqueStarGift{}, false, err
}
return unique, true, nil
}
func uniqueStarGiftQuery(predicate string) string {
return fmt.Sprintf(`
SELECT u.id, u.gift_id, u.collectible_revision_id, u.source_saved_gift_id, u.title, u.slug, u.num,
COALESCE(u.owner_peer_type,''), COALESCE(u.owner_peer_id,0), u.keep_original_details, u.created_at,
u.require_premium, u.resale_ton_only, u.theme_available, u.burned, u.crafted,
u.owner_name, u.owner_address, u.gift_address,
COALESCE(l.currency,''), COALESCE(l.amount,0), COALESCE(l.version,0),
COALESCE(u.released_by_peer_type,''), COALESCE(u.released_by_peer_id,0),
u.value_amount, u.value_currency, u.value_usd_amount,
COALESCE(u.theme_peer_type,''), COALESCE(u.theme_peer_id,0),
COALESCE(u.host_peer_type,''), COALESCE(u.host_peer_id,0),
u.offer_min_stars, u.craft_chance_permille, u.last_sale_date,
u.last_sale_currency, u.last_sale_amount,
r.issued, r.supply_total, sg.from_user_id, u.original_owner_peer_type, u.original_owner_peer_id,
sg.gift_date, sg.message, sg.name_hidden,
m.id, m.name, m.rarity_kind, COALESCE(m.rarity_permille,0), m.crafted, md.id, md.access_hash, md.file_reference, md.date,
md.mime_type, md.size, md.dc_id, md.attributes::text, md.thumbs::text,
p.id, p.name, p.rarity_kind, COALESCE(p.rarity_permille,0), pd.id, pd.access_hash, pd.file_reference, pd.date,
pd.mime_type, pd.size, pd.dc_id, pd.attributes::text, pd.thumbs::text,
b.id, b.name, b.backdrop_id, b.center_color, b.edge_color, b.pattern_color, b.text_color,
b.rarity_kind, COALESCE(b.rarity_permille,0)
FROM unique_star_gifts u
JOIN star_gift_collectible_revisions r ON r.id=u.collectible_revision_id
JOIN star_gift_collectible_models m ON m.id=u.model_attribute_id
JOIN documents md ON md.id=m.document_id
JOIN star_gift_collectible_patterns p ON p.id=u.pattern_attribute_id
JOIN documents pd ON pd.id=p.document_id
JOIN star_gift_collectible_backdrops b ON b.id=u.backdrop_attribute_id
JOIN peer_star_gifts sg ON sg.id=u.source_saved_gift_id
LEFT JOIN star_gift_listings l ON l.unique_gift_id=u.id
WHERE %s`, predicate)
}
func scanUniqueStarGift(row rowScanner) (domain.UniqueStarGift, error) {
var unique domain.UniqueStarGift
var ownerType, originalOwnerType, listingCurrency, releasedByType, themePeerType, hostPeerType, lastSaleCurrency string
var listingAmount, lastSaleAmount int64
unique.Model.Kind = domain.StarGiftCollectibleModel
unique.Pattern.Kind = domain.StarGiftCollectiblePattern
unique.Backdrop.Kind = domain.StarGiftCollectibleBackdrop
unique.Model.Document = &domain.Document{}
unique.Pattern.Document = &domain.Document{}
var modelAttrs, modelThumbs, patternAttrs, patternThumbs string
if err := row.Scan(&unique.ID, &unique.GiftID, &unique.CollectibleRevisionID, &unique.SourceSavedGiftID,
&unique.Title, &unique.Slug, &unique.Num, &ownerType, &unique.Owner.ID, &unique.KeepOriginalDetails,
&unique.CreatedAt, &unique.RequirePremium, &unique.ResaleTonOnly, &unique.ThemeAvailable,
&unique.Burned, &unique.Crafted, &unique.OwnerName, &unique.OwnerAddress, &unique.GiftAddress,
&listingCurrency, &listingAmount, &unique.ResellVersion, &releasedByType, &unique.ReleasedBy.ID,
&unique.ValueAmount, &unique.ValueCurrency, &unique.ValueUSD,
&themePeerType, &unique.ThemePeer.ID, &hostPeerType, &unique.Host.ID,
&unique.OfferMinStars, &unique.CraftChancePermille, &unique.LastSaleDate,
&lastSaleCurrency, &lastSaleAmount,
&unique.AvailabilityIssued, &unique.AvailabilityTotal,
&unique.OriginalFromUserID, &originalOwnerType, &unique.OriginalOwner.ID, &unique.OriginalDate,
&unique.OriginalMessage, &unique.OriginalNameHidden,
&unique.Model.ID, &unique.Model.Name, &unique.Model.RarityKind, &unique.Model.RarityPermille, &unique.Model.Crafted,
&unique.Model.Document.ID, &unique.Model.Document.AccessHash, &unique.Model.Document.FileReference,
&unique.Model.Document.Date, &unique.Model.Document.MimeType, &unique.Model.Document.Size,
&unique.Model.Document.DCID, &modelAttrs, &modelThumbs,
&unique.Pattern.ID, &unique.Pattern.Name, &unique.Pattern.RarityKind, &unique.Pattern.RarityPermille,
&unique.Pattern.Document.ID, &unique.Pattern.Document.AccessHash, &unique.Pattern.Document.FileReference,
&unique.Pattern.Document.Date, &unique.Pattern.Document.MimeType, &unique.Pattern.Document.Size,
&unique.Pattern.Document.DCID, &patternAttrs, &patternThumbs,
&unique.Backdrop.ID, &unique.Backdrop.Name, &unique.Backdrop.BackdropID, &unique.Backdrop.CenterColor,
&unique.Backdrop.EdgeColor, &unique.Backdrop.PatternColor, &unique.Backdrop.TextColor,
&unique.Backdrop.RarityKind, &unique.Backdrop.RarityPermille); err != nil {
return domain.UniqueStarGift{}, fmt.Errorf("get unique star gift: %w", err)
}
unique.Owner.Type = domain.PeerType(ownerType)
unique.OriginalOwner.Type = domain.PeerType(originalOwnerType)
unique.ReleasedBy.Type = domain.PeerType(releasedByType)
unique.ThemePeer.Type = domain.PeerType(themePeerType)
unique.Host.Type = domain.PeerType(hostPeerType)
if listingCurrency != "" && listingAmount > 0 {
unique.ResellAmount = &domain.StarGiftAmount{Currency: domain.StarGiftCurrency(listingCurrency), Amount: listingAmount}
}
if lastSaleCurrency != "" && unique.LastSaleDate > 0 {
unique.LastSaleAmount = &domain.StarGiftAmount{Currency: domain.StarGiftCurrency(lastSaleCurrency), Amount: lastSaleAmount}
}
unique.Model.CollectibleRevisionID = unique.CollectibleRevisionID
unique.Pattern.CollectibleRevisionID = unique.CollectibleRevisionID
unique.Backdrop.CollectibleRevisionID = unique.CollectibleRevisionID
var err error
if unique.Model.Document.Attributes, err = decodeDocumentAttributes(modelAttrs); err != nil {
return domain.UniqueStarGift{}, err
}
if unique.Model.Document.Thumbs, err = decodePhotoSizes(modelThumbs); err != nil {
return domain.UniqueStarGift{}, err
}
if unique.Pattern.Document.Attributes, err = decodeDocumentAttributes(patternAttrs); err != nil {
return domain.UniqueStarGift{}, err
}
if unique.Pattern.Document.Thumbs, err = decodePhotoSizes(patternThumbs); err != nil {
return domain.UniqueStarGift{}, err
}
return unique, nil
}
func (s *StarGiftStore) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
rows, err := s.db.Query(ctx, `
SELECT c.collection_id, c.title, c.hash, c.sort_order, c.created_at, c.updated_at, i.saved_gift_id
FROM star_gift_collections c
LEFT JOIN star_gift_collection_items i ON i.collection_id=c.collection_id
WHERE c.owner_peer_type=$1 AND c.owner_peer_id=$2
ORDER BY c.sort_order, c.collection_id, i.sort_order, i.saved_gift_id`, string(owner.Type), owner.ID)
if err != nil {
return nil, fmt.Errorf("list star gift collections: %w", err)
}
defer rows.Close()
out := make([]domain.StarGiftCollection, 0)
index := make(map[int]int)
for rows.Next() {
var collection domain.StarGiftCollection
var giftID pgtype.Int8
if err := rows.Scan(&collection.CollectionID, &collection.Title, &collection.Hash, &collection.SortOrder,
&collection.CreatedAt, &collection.UpdatedAt, &giftID); err != nil {
return nil, err
}
position, ok := index[collection.CollectionID]
if !ok {
collection.Owner = owner
position = len(out)
index[collection.CollectionID] = position
out = append(out, collection)
}
if giftID.Valid {
out[position].GiftIDs = append(out[position].GiftIDs, giftID.Int64)
}
}
return out, rows.Err()
}
func (s *StarGiftStore) CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) {
title = strings.TrimSpace(title)
if !validPostgresStarGiftOwner(owner) || title == "" || len([]rune(title)) > domain.MaxStarGiftCollectionTitleRunes {
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectibleInvalid
}
var result domain.StarGiftCollection
err := withTx(ctx, s.db, "create star gift collection", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
}
var count int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_collections WHERE owner_peer_type=$1 AND owner_peer_id=$2`, string(owner.Type), owner.ID).Scan(&count); err != nil {
return err
}
if count >= domain.MaxStarGiftCollectionsPerPeer {
return domain.ErrStarGiftCollectionsFull
}
ids, err := validatePostgresCollectionGiftIDs(ctx, tx, owner, savedGiftIDs)
if err != nil {
return err
}
result = domain.StarGiftCollection{Owner: owner, Title: title, GiftIDs: ids, SortOrder: count}
result.Hash = domain.StarGiftCollectionHash(title, ids)
if err := tx.QueryRow(ctx, `
INSERT INTO star_gift_collections(owner_peer_type, owner_peer_id, title, sort_order, hash)
VALUES ($1,$2,$3,$4,$5) RETURNING collection_id, created_at, updated_at`, string(owner.Type), owner.ID,
title, count, result.Hash).Scan(&result.CollectionID, &result.CreatedAt, &result.UpdatedAt); err != nil {
return err
}
return replaceCollectionItems(ctx, tx, result.CollectionID, ids)
})
return result, err
}
func (s *StarGiftStore) UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) {
var result domain.StarGiftCollection
err := withTx(ctx, s.db, "update star gift collection", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
}
if err := tx.QueryRow(ctx, `
SELECT title, hash, sort_order, created_at, updated_at FROM star_gift_collections
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND collection_id=$3 FOR UPDATE`, string(owner.Type), owner.ID, collectionID).Scan(
&result.Title, &result.Hash, &result.SortOrder, &result.CreatedAt, &result.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrStarGiftCollectionNotFound
}
return err
}
result.Owner = owner
result.CollectionID = collectionID
rows, err := tx.Query(ctx, `SELECT saved_gift_id FROM star_gift_collection_items WHERE collection_id=$1 ORDER BY sort_order, saved_gift_id`, collectionID)
if err != nil {
return err
}
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
result.GiftIDs = append(result.GiftIDs, id)
}
rows.Close()
if patch.Title != nil {
title := strings.TrimSpace(*patch.Title)
if title == "" || len([]rune(title)) > domain.MaxStarGiftCollectionTitleRunes {
return domain.ErrStarGiftCollectibleInvalid
}
result.Title = title
}
deleted := make(map[int64]struct{}, len(patch.DeleteIDs))
for _, id := range patch.DeleteIDs {
deleted[id] = struct{}{}
}
next := make([]int64, 0, len(result.GiftIDs)+len(patch.AddIDs))
for _, id := range result.GiftIDs {
if _, ok := deleted[id]; !ok {
next = append(next, id)
}
}
add, err := validatePostgresCollectionGiftIDs(ctx, tx, owner, patch.AddIDs)
if err != nil {
return err
}
next = appendUniquePostgresIDs(next, add...)
if patch.Order != nil {
order, err := validatePostgresCollectionGiftIDs(ctx, tx, owner, patch.Order)
if err != nil || !samePostgresIDSet(order, next) {
return domain.ErrStarGiftCollectibleInvalid
}
next = order
}
if len(next) > domain.MaxStarGiftCollectionItems {
return domain.ErrStarGiftCollectibleInvalid
}
result.GiftIDs = next
result.Hash = domain.StarGiftCollectionHash(result.Title, result.GiftIDs)
if err := tx.QueryRow(ctx, `
UPDATE star_gift_collections SET title=$4, hash=$5, updated_at=now()
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND collection_id=$3 RETURNING updated_at`,
string(owner.Type), owner.ID, collectionID, result.Title, result.Hash).Scan(&result.UpdatedAt); err != nil {
return err
}
return replaceCollectionItems(ctx, tx, collectionID, result.GiftIDs)
})
return result, err
}
func (s *StarGiftStore) DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error) {
var changed bool
err := withTx(ctx, s.db, "delete star gift collection", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
}
tag, err := tx.Exec(ctx, `DELETE FROM star_gift_collections WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND collection_id=$3`, string(owner.Type), owner.ID, collectionID)
if err != nil {
return err
}
changed = tag.RowsAffected() > 0
if changed {
_, err = tx.Exec(ctx, `
WITH ordered AS (
SELECT collection_id, row_number() OVER (ORDER BY sort_order, collection_id) - 1 AS next_order
FROM star_gift_collections WHERE owner_peer_type=$1 AND owner_peer_id=$2
)
UPDATE star_gift_collections c SET sort_order=o.next_order, updated_at=now()
FROM ordered o WHERE c.collection_id=o.collection_id`, string(owner.Type), owner.ID)
}
return err
})
return changed, err
}
func (s *StarGiftStore) ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error {
return withTx(ctx, s.db, "reorder star gift collections", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
}
rows, err := tx.Query(ctx, `SELECT collection_id FROM star_gift_collections WHERE owner_peer_type=$1 AND owner_peer_id=$2 FOR UPDATE`, string(owner.Type), owner.ID)
if err != nil {
return err
}
existing := make([]int, 0)
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
existing = append(existing, id)
}
rows.Close()
if !samePostgresIntSet(existing, collectionIDs) {
return domain.ErrStarGiftCollectibleInvalid
}
for order, id := range collectionIDs {
if _, err := tx.Exec(ctx, `UPDATE star_gift_collections SET sort_order=$4, updated_at=now() WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND collection_id=$3`, string(owner.Type), owner.ID, id, order); err != nil {
return err
}
}
return nil
})
}
func (s *StarGiftStore) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error {
if len(savedGiftIDs) > domain.MaxPinnedStarGifts {
return domain.ErrStarGiftCollectibleInvalid
}
return withTx(ctx, s.db, "set pinned star gifts", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
}
ids, err := validatePostgresCollectionGiftIDs(ctx, tx, owner, savedGiftIDs)
if err != nil {
return err
}
if len(ids) != len(savedGiftIDs) {
return domain.ErrStarGiftCollectibleInvalid
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=0 WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND pinned_order<>0`, string(owner.Type), owner.ID); err != nil {
return err
}
for order, id := range ids {
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$2,unsaved=false WHERE id=$1`, id, order+1); err != nil {
return err
}
}
return nil
})
}
func validatePostgresCollectionGiftIDs(ctx context.Context, db sqlcgen.DBTX, owner domain.Peer, ids []int64) ([]int64, error) {
ids = dedupePostgresIDs(ids)
if len(ids) > domain.MaxStarGiftCollectionItems {
return nil, domain.ErrStarGiftCollectibleInvalid
}
if len(ids) == 0 {
return []int64{}, nil
}
rows, err := db.Query(ctx, `
SELECT id FROM peer_star_gifts
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND lifecycle_status='active' AND id=ANY($3::bigint[])
FOR UPDATE`, string(owner.Type), owner.ID, ids)
if err != nil {
return nil, err
}
defer rows.Close()
found := make(map[int64]struct{}, len(ids))
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
found[id] = struct{}{}
}
if len(found) != len(ids) {
return nil, domain.ErrStarGiftNotFound
}
return ids, rows.Err()
}
// removeSavedGiftFromCollections runs under the owner advisory lock. It removes
// terminal gifts and updates every affected collection hash in bounded batches,
// so getStarGiftCollections cannot return NotModified for changed membership.
func removeSavedGiftFromCollections(ctx context.Context, tx pgx.Tx, owner domain.Peer, savedGiftID int64) error {
rows, err := tx.Query(ctx, `
SELECT c.collection_id, c.title
FROM star_gift_collections c
JOIN star_gift_collection_items i ON i.collection_id=c.collection_id
WHERE c.owner_peer_type=$1 AND c.owner_peer_id=$2 AND i.saved_gift_id=$3
ORDER BY c.collection_id
FOR UPDATE OF c`, string(owner.Type), owner.ID, savedGiftID)
if err != nil {
return fmt.Errorf("lock converted gift collections: %w", err)
}
titles := make(map[int]string)
ids := make([]int, 0)
for rows.Next() {
var id int
var title string
if err := rows.Scan(&id, &title); err != nil {
rows.Close()
return err
}
ids = append(ids, id)
titles[id] = title
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
if len(ids) == 0 {
return nil
}
if _, err := tx.Exec(ctx, `DELETE FROM star_gift_collection_items WHERE saved_gift_id=$1`, savedGiftID); err != nil {
return fmt.Errorf("remove converted gift collection memberships: %w", err)
}
memberships := make(map[int][]int64, len(ids))
itemRows, err := tx.Query(ctx, `
SELECT collection_id, saved_gift_id
FROM star_gift_collection_items
WHERE collection_id=ANY($1::integer[])
ORDER BY collection_id, sort_order, saved_gift_id`, ids)
if err != nil {
return fmt.Errorf("list remaining collection memberships: %w", err)
}
for itemRows.Next() {
var collectionID int
var giftID int64
if err := itemRows.Scan(&collectionID, &giftID); err != nil {
itemRows.Close()
return err
}
memberships[collectionID] = append(memberships[collectionID], giftID)
}
if err := itemRows.Err(); err != nil {
itemRows.Close()
return err
}
itemRows.Close()
hashes := make([]int64, len(ids))
for i, collectionID := range ids {
hashes[i] = domain.StarGiftCollectionHash(titles[collectionID], memberships[collectionID])
}
if _, err := tx.Exec(ctx, `
UPDATE star_gift_collections c SET hash=x.hash, updated_at=now()
FROM unnest($1::integer[], $2::bigint[]) AS x(collection_id, hash)
WHERE c.collection_id=x.collection_id`, ids, hashes); err != nil {
return fmt.Errorf("refresh converted gift collection hashes: %w", err)
}
return nil
}
func replaceCollectionItems(ctx context.Context, tx pgx.Tx, collectionID int, ids []int64) error {
if _, err := tx.Exec(ctx, `DELETE FROM star_gift_collection_items WHERE collection_id=$1`, collectionID); err != nil {
return err
}
for order, id := range ids {
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_collection_items(collection_id, saved_gift_id, sort_order) VALUES ($1,$2,$3)`, collectionID, id, order); err != nil {
return err
}
}
return nil
}
func validPostgresStarGiftOwner(owner domain.Peer) bool {
return owner.ID > 0 && (owner.Type == domain.PeerTypeUser || owner.Type == domain.PeerTypeChannel)
}
func starGiftCollectionLockKey(owner domain.Peer) string {
return fmt.Sprintf("star_gift_collection:%s:%d", owner.Type, owner.ID)
}
func dedupePostgresIDs(ids []int64) []int64 {
out := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; !ok {
seen[id] = struct{}{}
out = append(out, id)
}
}
return out
}
func appendUniquePostgresIDs(dst []int64, values ...int64) []int64 {
seen := make(map[int64]struct{}, len(dst)+len(values))
for _, id := range dst {
seen[id] = struct{}{}
}
for _, id := range values {
if _, ok := seen[id]; !ok {
seen[id] = struct{}{}
dst = append(dst, id)
}
}
return dst
}
func samePostgresIDSet(a, b []int64) bool {
if len(a) != len(b) {
return false
}
a = append([]int64(nil), a...)
b = append([]int64(nil), b...)
sort.Slice(a, func(i, j int) bool { return a[i] < a[j] })
sort.Slice(b, func(i, j int) bool { return b[i] < b[j] })
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func samePostgresIntSet(a, b []int) bool {
if len(a) != len(b) {
return false
}
seen := make(map[int]struct{}, len(a))
for _, id := range a {
seen[id] = struct{}{}
}
for _, id := range b {
if _, ok := seen[id]; !ok {
return false
}
delete(seen, id)
}
return len(seen) == 0
}

View file

@ -1,838 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"testing"
"time"
"telesrv/internal/domain"
)
func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender := createTestUser(t, ctx, users, "+1778"+suffix+"41", "CollectibleSender", "")
owner := createTestUser(t, ctx, users, "+1778"+suffix+"42", "CollectibleOwner", "")
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
gifts := NewStarGiftStore(pool)
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Comet", Stars: 50, ConvertStars: 25, Enabled: true,
Document: collectibleTestDocument(baseDocumentID, "gift.tgs"),
Blob: collectibleTestBlob(baseDocumentID, "gift"),
Animation: collectibleTestAnimation("gift.tgs"),
Actor: "integration", CommandID: "catalog-" + suffix,
})
if err != nil {
t.Fatalf("create collectible catalog gift: %v", err)
}
poolRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "comet-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectibleModel, Name: "Aurora", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 922,
Document: collectibleTestDocumentPtr(baseDocumentID+1, "model.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+1, "model"), Animation: collectibleTestAnimationPtr("model.tgs"),
OfficialDocumentID: 5100000000000000001,
}, {
Kind: domain.StarGiftCollectibleModel, Name: "Crafted Aurora", RarityKind: domain.StarGiftRarityLegendary, Crafted: true,
Document: collectibleTestDocumentPtr(baseDocumentID+3, "crafted-model.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+3, "crafted-model"), Animation: collectibleTestAnimationPtr("crafted-model.tgs"),
OfficialDocumentID: 5100000000000000003,
}, {
Kind: domain.StarGiftCollectibleModel, Name: "Solar", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 78,
Document: collectibleTestDocumentPtr(baseDocumentID+4, "model-two.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+4, "model-two"), Animation: collectibleTestAnimationPtr("model-two.tgs"),
OfficialDocumentID: 5100000000000000004,
}},
Patterns: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 989,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")},
{Kind: domain.StarGiftCollectiblePattern, Name: "Rings", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 11,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+5, "pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+5, "pattern-two"), Animation: collectibleTestAnimationPtr("pattern-two.tgs")},
},
Backdrops: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Midnight", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 999},
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Daylight", BackdropID: 2, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1},
},
Actor: "integration", CommandID: "collectibles-" + suffix,
OfficialGiftID: 5170145012310081615, SourceManifestSHA256: make([]byte, 32),
})
if err != nil {
t.Fatalf("publish collectible pool: %v", err)
}
if !poolRevision.Published || poolRevision.Issued != 0 || len(poolRevision.Models) != 3 || len(poolRevision.Patterns) != 2 || len(poolRevision.Backdrops) != 2 ||
!poolRevision.Models[1].Crafted || poolRevision.Models[1].RarityKind != domain.StarGiftRarityLegendary ||
poolRevision.Models[1].RarityPermille != 0 || poolRevision.Models[0].OfficialDocumentID != 5100000000000000001 {
t.Fatalf("published pool = %+v", poolRevision)
}
storedRevision, found, err := gifts.ActiveCollectibleRevision(ctx, entry.Gift.ID)
if err != nil || !found || storedRevision.Models[0].Animation == nil || len(storedRevision.Models[0].Animation.JSON) == 0 {
t.Fatalf("full active collectible revision = found:%v err:%v value:%+v", found, err, storedRevision)
}
fullProjection, found, err := gifts.ActiveCollectibleProjection(ctx, entry.Gift.ID, 0)
if err != nil || !found || len(fullProjection.Models) != 3 || len(fullProjection.Patterns) != 2 || len(fullProjection.Backdrops) != 2 ||
fullProjection.Models[0].Animation == nil || len(fullProjection.Models[0].Animation.JSON) != 0 ||
fullProjection.Patterns[0].Animation == nil || len(fullProjection.Patterns[0].Animation.JSON) != 0 {
t.Fatalf("complete collectible projection = found:%v err:%v value:%+v", found, err, fullProjection)
}
sampleProjection, found, err := gifts.ActiveCollectibleProjection(ctx, entry.Gift.ID, 1)
if err != nil || !found || len(sampleProjection.Models) != 1 || len(sampleProjection.Patterns) != 1 || len(sampleProjection.Backdrops) != 1 ||
sampleProjection.Models[0].Crafted || sampleProjection.Models[0].RarityKind != domain.StarGiftRarityPermille ||
sampleProjection.Models[0].Animation == nil || len(sampleProjection.Models[0].Animation.JSON) != 0 {
t.Fatalf("sampled collectible projection = found:%v err:%v value:%+v", found, err, sampleProjection)
}
availability, err := gifts.CollectibleAvailability(ctx, []int64{entry.Gift.ID, entry.Gift.ID + 1})
if err != nil {
t.Fatalf("collectible availability: %v", err)
}
if got, ok := availability[entry.Gift.ID]; !ok || got.UpgradeStars != 100 || got.SupplyTotal != 10 || got.Issued != 0 {
t.Fatalf("collectible availability = %+v, want active published pool", availability)
}
if _, ok := availability[entry.Gift.ID+1]; ok {
t.Fatalf("unknown gift must not have collectible availability: %+v", availability)
}
if _, err := pool.Exec(ctx, `UPDATE star_gift_collectible_revisions SET issued=issued WHERE id=$1`, poolRevision.ID); err == nil {
t.Fatal("published collectible revision accepted a non-advancing issuance update")
}
var guardedIssued int
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, poolRevision.ID).Scan(&guardedIssued); err != nil || guardedIssued != 0 {
t.Fatalf("issued after rejected manual update = %d err %v, want 0", guardedIssued, err)
}
messages := NewMessageStore(pool)
saved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
Date: 1700001000, ConvertStars: 25, Message: "original",
})
savedID := saved.ID
stars := NewStarsStore(pool)
if _, _, err := stars.EnsureGrant(ctx, owner.ID, 1000, 1700001001); err != nil {
t.Fatalf("grant upgrade stars: %v", err)
}
upgrades := NewStarGiftUpgradeStore(pool, messages)
req := domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: saved.MsgID},
KeepOriginalDetails: true, ChargeStars: 100, FormID: 991,
CommandKey: "paid-" + suffix, Date: 1700001002,
}
upgraded, err := upgrades.UpgradeStarGift(ctx, req)
if err != nil {
t.Fatalf("upgrade star gift: %v", err)
}
if upgraded.Duplicate || upgraded.Unique.Num != 1 || upgraded.Unique.Slug != "comet-"+suffix+"-1" ||
upgraded.Unique.Model.Name != "Aurora" || upgraded.Unique.Pattern.Name != "Orbit" ||
upgraded.Unique.Backdrop.Name != "Midnight" || upgraded.Balance.Balance != 900 ||
upgraded.Saved.ID != savedID || upgraded.Saved.UniqueGiftID != upgraded.Unique.ID || upgraded.Saved.UpgradeMsgID <= 0 {
t.Fatalf("upgrade result = %+v", upgraded)
}
ownerMessage := upgraded.Send.RecipientMessage
if ownerMessage.OwnerUserID != owner.ID || ownerMessage.Pts <= 0 || ownerMessage.Media == nil ||
ownerMessage.Media.ServiceAction == nil || ownerMessage.Media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique ||
ownerMessage.Media.ServiceAction.StarGiftUnique == nil || ownerMessage.Media.ServiceAction.StarGiftUnique.Gift.ID != upgraded.Unique.ID {
t.Fatalf("owner upgrade service message = %+v", ownerMessage)
}
uniqueAction := ownerMessage.Media.ServiceAction.StarGiftUnique
if uniqueAction.SavedID != 0 || uniqueAction.Peer.Type != "" || uniqueAction.Peer.ID != 0 {
t.Fatalf("user unique action leaked channel peer/saved_id: %+v", uniqueAction)
}
senderUniqueAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
if senderUniqueAction == nil || senderUniqueAction.SavedID != 0 {
t.Fatalf("sender unique action leaked owner-only saved_id: %+v", senderUniqueAction)
}
if byOutput, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: ownerMessage.ID}); err != nil || !found || byOutput.ID != savedID {
t.Fatalf("owner upgrade output ref = %+v found %v err %v", byOutput, found, err)
}
var ownerAliasCount, senderAliasCount int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3`, owner.ID, ownerMessage.ID, savedID).Scan(&ownerAliasCount); err != nil {
t.Fatalf("load owner upgrade output alias: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND msg_id=$2`, sender.ID, ownerMessage.ID).Scan(&senderAliasCount); err != nil {
t.Fatalf("load sender upgrade output alias: %v", err)
}
if ownerAliasCount != 1 || senderAliasCount != 0 {
t.Fatalf("upgrade output aliases owner=%d sender=%d, want owner-only", ownerAliasCount, senderAliasCount)
}
ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID)
if ownerSourceEdit.Event.Pts <= ownerMessage.Pts || ownerSourceEdit.Message.Media == nil ||
ownerSourceEdit.Message.Media.ServiceAction == nil || ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil ||
ownerSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != ownerMessage.ID ||
ownerSourceEdit.Message.Media.ServiceAction.StarGift.CanUpgrade {
t.Fatalf("owner source gift was not durably marked upgraded: %+v", ownerSourceEdit)
}
senderSourceEdit := upgradedSourceEditForUser(upgraded, sender.ID)
if senderSourceEdit.Message.Media == nil || senderSourceEdit.Message.Media.ServiceAction == nil ||
senderSourceEdit.Message.Media.ServiceAction.StarGift == nil ||
senderSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Send.SenderMessage.ID {
t.Fatalf("sender source gift has wrong box-local upgrade link: %+v", senderSourceEdit)
}
difference, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, ownerMessage.Pts-1, 4)
if err != nil || len(difference) < 2 || difference[0].Type != domain.UpdateEventNewMessage ||
difference[0].Message.ID != ownerMessage.ID || difference[1].Type != domain.UpdateEventEditMessage ||
difference[1].Message.ID != saved.MsgID || difference[1].Message.Media == nil ||
difference[1].Message.Media.ServiceAction == nil || difference[1].Message.Media.ServiceAction.StarGift == nil ||
difference[1].Message.Media.ServiceAction.StarGift.UpgradeMsgID != ownerMessage.ID {
t.Fatalf("owner upgrade difference = %+v err %v", difference, err)
}
var (
issued, uniqueCount, commandCount int
reason string
)
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, poolRevision.ID).Scan(&issued); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM unique_star_gifts WHERE source_saved_gift_id=$1`, savedID).Scan(&uniqueCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_upgrade_commands WHERE source_saved_gift_id=$1`, savedID).Scan(&commandCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT reason FROM stars_transactions WHERE user_id=$1 ORDER BY id DESC LIMIT 1`, owner.ID).Scan(&reason); err != nil {
t.Fatal(err)
}
if issued != 1 || uniqueCount != 1 || commandCount != 1 || reason != string(domain.StarsReasonGiftUpgrade) {
t.Fatalf("durable aggregate issued=%d unique=%d command=%d reason=%q", issued, uniqueCount, commandCount, reason)
}
receipt, found, err := upgrades.StarGiftUpgradeReceipt(ctx, owner.ID, req.CommandKey)
if err != nil || !found || receipt.SourceSavedGiftID != savedID || receipt.UniqueGiftID != upgraded.Unique.ID ||
receipt.FormID != req.FormID || receipt.ChargeStars != req.ChargeStars || receipt.RequirePrepaid ||
!receipt.KeepOriginalDetails || receipt.BalanceAfter != 900 || receipt.SourceEditPts != ownerSourceEdit.Event.Pts {
t.Fatalf("upgrade receipt = %+v found=%v err=%v", receipt, found, err)
}
replayed, err := upgrades.UpgradeStarGift(ctx, req)
if err != nil {
t.Fatalf("replay upgrade: %v", err)
}
if !replayed.Duplicate || replayed.Unique.ID != upgraded.Unique.ID || replayed.Balance.Balance != 900 ||
upgradedSourceEditForUser(replayed, owner.ID).Event.Pts != ownerSourceEdit.Event.Pts {
t.Fatalf("replayed upgrade = %+v", replayed)
}
conflictingReplay := req
conflictingReplay.KeepOriginalDetails = false
if _, err := upgrades.UpgradeStarGift(ctx, conflictingReplay); err == nil {
t.Fatal("same command key with a changed semantic payload must not replay")
}
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: req.Ref, ChargeStars: 100, FormID: 992,
CommandKey: "different-" + suffix, Date: 1700001003,
}); !errors.Is(err, domain.ErrStarGiftAlreadyUpgraded) {
t.Fatalf("second logical upgrade err = %v", err)
}
bal, err := stars.GetBalance(ctx, owner.ID)
if err != nil || bal.Balance != 900 {
t.Fatalf("balance after retries = %+v err %v", bal, err)
}
prepaidSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
// A later pool revision may raise the current price; the historical paid
// amount remains an entitlement instead of being compared to that price.
Date: 1700001004, ConvertStars: 25, PrepaidUpgradeStars: 50,
})
prepaidSavedID := prepaidSaved.ID
prepaid, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: prepaidSaved.MsgID},
RequirePrepaid: true, CommandKey: "prepaid-" + suffix, Date: 1700001005,
})
if err != nil {
t.Fatalf("free prepaid upgrade: %v", err)
}
if prepaid.Saved.ID != prepaidSavedID || prepaid.Unique.Num != 2 || prepaid.Balance.Balance != 900 ||
prepaid.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique == nil ||
!prepaid.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique.PrepaidUpgrade {
t.Fatalf("prepaid upgrade = %+v", prepaid)
}
insufficientSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
Date: 1700001006, ConvertStars: 25,
})
insufficientSavedID := insufficientSaved.ID
if _, err := stars.Debit(ctx, owner.ID, 850, domain.StarsReasonReaction,
domain.Peer{Type: domain.PeerTypeChannel, ID: 777001}, 1700001007, "paid reaction", ""); err != nil {
t.Fatalf("seed isolated paid reaction debit: %v", err)
}
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID},
ChargeStars: 100, FormID: 994, CommandKey: "insufficient-" + suffix, Date: 1700001008,
}); !errors.Is(err, domain.ErrStarsInsufficient) {
t.Fatalf("insufficient upgrade err = %v", err)
}
insufficientAfter, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID})
if err != nil || !found || insufficientAfter.ID != insufficientSavedID || insufficientAfter.UniqueGiftID != 0 {
t.Fatalf("saved gift after rejected upgrade = %+v found %v err %v", insufficientAfter, found, err)
}
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, poolRevision.ID).Scan(&issued); err != nil || issued != 2 {
t.Fatalf("issued after rejected upgrade = %d err %v, want 2", issued, err)
}
if err := pool.QueryRow(ctx, `SELECT reason FROM stars_transactions WHERE user_id=$1 ORDER BY id DESC LIMIT 1`, owner.ID).Scan(&reason); err != nil || reason != string(domain.StarsReasonReaction) {
t.Fatalf("paid reaction ledger reason after rejected upgrade = %q err %v", reason, err)
}
collection, err := gifts.CreateCollection(ctx, ownerPeer, "Favorites", []int64{savedID})
if err != nil {
t.Fatalf("create unique collection: %v", err)
}
filtered, err := gifts.ListByOwnerFiltered(ctx, domain.SavedStarGiftFilter{Owner: ownerPeer, CollectionID: collection.CollectionID, Limit: 10})
if err != nil || filtered.Count != 1 || len(filtered.Gifts) != 1 || filtered.Gifts[0].UniqueGiftID != upgraded.Unique.ID {
t.Fatalf("collection filter = %+v err %v", filtered, err)
}
if err := gifts.SetPinned(ctx, ownerPeer, []int64{savedID}); err != nil {
t.Fatalf("pin unique gift: %v", err)
}
pinned, found, err := gifts.GetByRef(ctx, req.Ref)
if err != nil || !found || pinned.PinnedOrder != 1 || len(pinned.CollectionIDs) != 1 || pinned.CollectionIDs[0] != collection.CollectionID {
t.Fatalf("pinned saved gift = %+v found %v err %v", pinned, found, err)
}
concurrentOwner := createTestUser(t, ctx, users, "+1778"+suffix+"43", "ConcurrentOwner", "")
concurrentPeer := domain.Peer{Type: domain.PeerTypeUser, ID: concurrentOwner.ID}
concurrentSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
Owner: concurrentPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
Date: 1700001010, ConvertStars: 25,
})
if _, _, err := stars.EnsureGrant(ctx, concurrentOwner.ID, 150, 1700001011); err != nil {
t.Fatalf("grant concurrent balance: %v", err)
}
type concurrentDebitResult struct {
kind string
err error
}
start := make(chan struct{})
results := make(chan concurrentDebitResult, 2)
go func() {
<-start
_, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: concurrentOwner.ID, Ref: domain.SavedStarGiftRef{Owner: concurrentPeer, MsgID: concurrentSaved.MsgID},
ChargeStars: 100, FormID: 993, CommandKey: "concurrent-upgrade-" + suffix, Date: 1700001012,
})
results <- concurrentDebitResult{kind: "gift_upgrade", err: err}
}()
go func() {
<-start
_, err := stars.Debit(ctx, concurrentOwner.ID, 100, domain.StarsReasonReaction,
domain.Peer{Type: domain.PeerTypeChannel, ID: 777002}, 1700001012, "paid reaction", "")
results <- concurrentDebitResult{kind: "paid_reaction", err: err}
}()
close(start)
firstResult, secondResult := <-results, <-results
successes := 0
for _, result := range []concurrentDebitResult{firstResult, secondResult} {
if result.err == nil {
successes++
continue
}
if !errors.Is(result.err, domain.ErrStarsInsufficient) {
t.Fatalf("concurrent %s err = %v, want Stars insufficient for loser", result.kind, result.err)
}
}
if successes != 1 {
t.Fatalf("concurrent debit results = %+v / %+v, want exactly one success", firstResult, secondResult)
}
concurrentBalance, err := stars.GetBalance(ctx, concurrentOwner.ID)
if err != nil || concurrentBalance.Balance != 50 {
t.Fatalf("concurrent balance = %+v err %v, want 50", concurrentBalance, err)
}
reasonRows, err := pool.Query(ctx, `SELECT reason FROM stars_transactions WHERE user_id=$1 AND amount<0 ORDER BY id`, concurrentOwner.ID)
if err != nil {
t.Fatalf("list concurrent debit reasons: %v", err)
}
var debitReasons []string
for reasonRows.Next() {
var got string
if err := reasonRows.Scan(&got); err != nil {
reasonRows.Close()
t.Fatal(err)
}
debitReasons = append(debitReasons, got)
}
if err := reasonRows.Err(); err != nil {
reasonRows.Close()
t.Fatal(err)
}
reasonRows.Close()
if len(debitReasons) != 1 || (debitReasons[0] != string(domain.StarsReasonGiftUpgrade) && debitReasons[0] != string(domain.StarsReasonReaction)) {
t.Fatalf("concurrent debit reasons = %+v, want exactly one isolated business reason", debitReasons)
}
soldOutEntry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Nova", Stars: 25, ConvertStars: 10, Enabled: true,
Document: collectibleTestDocument(baseDocumentID+100, "nova.tgs"),
Blob: collectibleTestBlob(baseDocumentID+100, "nova"), Animation: collectibleTestAnimation("nova.tgs"),
Actor: "integration", CommandID: "soldout-catalog-" + suffix,
})
if err != nil {
t.Fatalf("create sold-out catalog: %v", err)
}
soldOutRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: soldOutEntry.Gift.ID, UpgradeStars: 10, SupplyTotal: 1, SlugPrefix: "nova-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleModel, Name: "Nova", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestDocumentPtr(baseDocumentID+101, "nova-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+101, "nova-model"), Animation: collectibleTestAnimationPtr("nova-model.tgs")},
{Kind: domain.StarGiftCollectibleModel, Name: "Nova Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestDocumentPtr(baseDocumentID+103, "nova-model-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+103, "nova-model-two"), Animation: collectibleTestAnimationPtr("nova-model-two.tgs")},
},
Patterns: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectiblePattern, Name: "Ray", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+102, "nova-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+102, "nova-pattern"), Animation: collectibleTestAnimationPtr("nova-pattern.tgs")},
{Kind: domain.StarGiftCollectiblePattern, Name: "Ray Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+104, "nova-pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+104, "nova-pattern-two"), Animation: collectibleTestAnimationPtr("nova-pattern-two.tgs")},
},
Backdrops: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Void", BackdropID: 2, CenterColor: 0x101010, EdgeColor: 0x202020, PatternColor: 0x303030, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Light", BackdropID: 3, CenterColor: 0xeeeeee, EdgeColor: 0xcccccc, PatternColor: 0xaaaaaa, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
},
Actor: "integration", CommandID: "soldout-pool-" + suffix,
})
if err != nil {
t.Fatalf("publish sold-out pool: %v", err)
}
soldOutOwner := createTestUser(t, ctx, users, "+1778"+suffix+"44", "SoldOutOwner", "")
soldOutPeer := domain.Peer{Type: domain.PeerTypeUser, ID: soldOutOwner.ID}
soldOutSaved := make([]domain.SavedStarGift, 0, 2)
for index := range 2 {
soldOutSaved = append(soldOutSaved, createCollectibleSavedGift(t, ctx, messages, gifts, soldOutEntry.Gift, domain.SavedStarGift{
Owner: soldOutPeer, FromUserID: sender.ID, GiftID: soldOutEntry.Gift.ID, RevisionID: soldOutEntry.Gift.RevisionID,
Date: 1700001020 + index, ConvertStars: 10,
}))
}
if _, _, err := stars.EnsureGrant(ctx, soldOutOwner.ID, 100, 1700001022); err != nil {
t.Fatalf("grant sold-out owner balance: %v", err)
}
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: soldOutSaved[0].MsgID},
ChargeStars: 10, FormID: 995, CommandKey: "soldout-first-" + suffix, Date: 1700001023,
}); err != nil {
t.Fatalf("fill collectible supply: %v", err)
}
balanceBeforeSoldOut, _ := stars.GetBalance(ctx, soldOutOwner.ID)
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: soldOutSaved[1].MsgID},
ChargeStars: 10, FormID: 996, CommandKey: "soldout-second-" + suffix, Date: 1700001024,
}); !errors.Is(err, domain.ErrStarGiftCollectibleSoldOut) {
t.Fatalf("sold-out upgrade err = %v", err)
}
balanceAfterSoldOut, _ := stars.GetBalance(ctx, soldOutOwner.ID)
var soldOutIssued int
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, soldOutRevision.ID).Scan(&soldOutIssued); err != nil || soldOutIssued != 1 || balanceAfterSoldOut.Balance != balanceBeforeSoldOut.Balance {
t.Fatalf("sold-out state issued=%d balance=%d->%d err=%v", soldOutIssued, balanceBeforeSoldOut.Balance, balanceAfterSoldOut.Balance, err)
}
ordinaryCollection, err := gifts.CreateCollection(ctx, ownerPeer, "Ordinary", []int64{insufficientSavedID})
if err != nil {
t.Fatalf("create ordinary collection: %v", err)
}
converted, err := gifts.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: insufficientSaved.MsgID})
if err != nil || !converted.Converted || converted.PinnedOrder != 0 || len(converted.CollectionIDs) != 0 {
t.Fatalf("convert collection member = %+v err %v", converted, err)
}
collections, err := gifts.ListCollections(ctx, ownerPeer)
if err != nil {
t.Fatalf("list collections after conversion: %v", err)
}
foundOrdinary := false
for _, got := range collections {
if got.CollectionID != ordinaryCollection.CollectionID {
continue
}
foundOrdinary = true
if len(got.GiftIDs) != 0 || got.Hash != domain.StarGiftCollectionHash(got.Title, nil) || got.Hash == ordinaryCollection.Hash {
t.Fatalf("ordinary collection after conversion = %+v", got)
}
}
if !foundOrdinary {
t.Fatal("ordinary collection disappeared after member conversion")
}
filteredAfterConvert, err := gifts.ListByOwnerFiltered(ctx, domain.SavedStarGiftFilter{
Owner: ownerPeer, CollectionID: ordinaryCollection.CollectionID, Limit: 10,
})
if err != nil || filteredAfterConvert.Count != 0 || len(filteredAfterConvert.Gifts) != 0 {
t.Fatalf("converted collection filter = %+v err %v, want empty", filteredAfterConvert, err)
}
}
func TestStarGiftCollectiblePreviewActivationGuardPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
gifts := NewStarGiftStore(pool)
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Unsafe Preview " + suffix, Stars: 10, ConvertStars: 5, Enabled: true,
Document: collectibleTestDocument(baseDocumentID, "unsafe-preview.tgs"), Blob: collectibleTestBlob(baseDocumentID, "unsafe-preview"),
Animation: collectibleTestAnimation("unsafe-preview.tgs"), Actor: "integration", CommandID: "unsafe-preview-catalog-" + suffix,
})
if err != nil {
t.Fatalf("create unsafe-preview catalog: %v", err)
}
var revisionID int64
if err := pool.QueryRow(ctx, `
INSERT INTO star_gift_collectible_revisions
(gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id)
VALUES ($1, 1, 10, 10, $2, 'draft', 'integration', $3)
RETURNING id`, entry.Gift.ID, "unsafe-"+suffix, "unsafe-preview-pool-"+suffix).Scan(&revisionID); err != nil {
t.Fatalf("insert unsafe-preview revision: %v", err)
}
animation := collectibleTestAnimation("unsafe-preview-attribute.tgs")
if _, err := pool.Exec(ctx, `
INSERT INTO star_gift_collectible_models
(collectible_revision_id, name, document_id, animation_json, animation_sha256, source_name, source_format,
width, height, frame_rate, in_point, out_point, rarity_kind, rarity_permille, crafted, sort_order)
VALUES ($1, 'Only Model', $2, $3::jsonb, $4, 'model.tgs', 'tgs', 512, 512, 30, 0, 60, 'permille', 1000, false, 0)`,
revisionID, baseDocumentID, string(animation.JSON), animation.SHA256); err != nil {
t.Fatalf("insert unsafe-preview model: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO star_gift_collectible_patterns
(collectible_revision_id, name, document_id, animation_json, animation_sha256, source_name, source_format,
width, height, frame_rate, in_point, out_point, rarity_kind, rarity_permille, sort_order)
VALUES ($1, 'Only Pattern', $2, $3::jsonb, $4, 'pattern.tgs', 'tgs', 512, 512, 30, 0, 60, 'permille', 1000, 0)`,
revisionID, baseDocumentID, string(animation.JSON), animation.SHA256); err != nil {
t.Fatalf("insert unsafe-preview pattern: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO star_gift_collectible_backdrops
(collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color, text_color,
rarity_kind, rarity_permille, sort_order)
VALUES ($1, 'Only Backdrop', 1, 1, 2, 3, 4, 'permille', 1000, 0)`, revisionID); err != nil {
t.Fatalf("insert unsafe-preview backdrop: %v", err)
}
if _, err := pool.Exec(ctx, `
UPDATE star_gift_collectible_revisions SET status='published', published_at=now() WHERE id=$1`, revisionID); err != nil {
t.Fatalf("publish unsafe-preview revision directly: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE star_gift_catalog SET collectible_revision_id=$2 WHERE gift_id=$1`, entry.Gift.ID, revisionID); err == nil {
t.Fatal("database activated a collectible preview pool with one client-distinct item per category")
}
var activeRevisionID *int64
if err := pool.QueryRow(ctx, `SELECT collectible_revision_id FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&activeRevisionID); err != nil || activeRevisionID != nil {
t.Fatalf("unsafe preview activation pointer=%v err=%v, want null", activeRevisionID, err)
}
}
func TestStarGiftUpgradeWithoutCraftedModelDoesNotAdvertiseCraft(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
now := int(time.Now().Unix())
users := NewUserStore(pool)
sender := createTestUser(t, ctx, users, "+1779"+suffix+"51", "NoCraftSender", "")
owner := createTestUser(t, ctx, users, "+1779"+suffix+"52", "NoCraftOwner", "")
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
gifts := NewStarGiftStore(pool)
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "No Craft " + suffix, Stars: 50, ConvertStars: 25, Enabled: true,
Document: collectibleTestDocument(baseDocumentID, "no-craft-gift.tgs"),
Blob: collectibleTestBlob(baseDocumentID, "no-craft-gift"), Animation: collectibleTestAnimation("no-craft-gift.tgs"),
Actor: "integration", CommandID: "no-craft-catalog-" + suffix,
})
if err != nil {
t.Fatalf("create no-craft catalog gift: %v", err)
}
revision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "no-craft-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleModel, Name: "Ordinary", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestDocumentPtr(baseDocumentID+1, "no-craft-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "no-craft-model"), Animation: collectibleTestAnimationPtr("no-craft-model.tgs")},
{Kind: domain.StarGiftCollectibleModel, Name: "Ordinary Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestDocumentPtr(baseDocumentID+3, "no-craft-model-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "no-craft-model-two"), Animation: collectibleTestAnimationPtr("no-craft-model-two.tgs")},
},
Patterns: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "no-craft-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "no-craft-pattern"), Animation: collectibleTestAnimationPtr("no-craft-pattern.tgs")},
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+4, "no-craft-pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+4, "no-craft-pattern-two"), Animation: collectibleTestAnimationPtr("no-craft-pattern-two.tgs")},
},
Backdrops: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 2, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
},
Actor: "integration", CommandID: "no-craft-pool-" + suffix,
})
if err != nil {
t.Fatalf("publish no-craft pool: %v", err)
}
if len(revision.Models) != 2 || revision.Models[0].Crafted || revision.Models[1].Crafted {
t.Fatalf("no-craft pool models = %+v", revision.Models)
}
messages := NewMessageStore(pool)
saved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
Date: now, ConvertStars: 25,
})
stars := NewStarsStore(pool)
if _, _, err := stars.EnsureGrant(ctx, owner.ID, 1000, now); err != nil {
t.Fatalf("grant no-craft upgrade stars: %v", err)
}
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 750,
}))
upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: saved.MsgID},
ChargeStars: 100, FormID: 551, CommandKey: "no-craft-upgrade-" + suffix, Date: now + 1,
})
if err != nil {
t.Fatalf("upgrade no-craft gift: %v", err)
}
uniqueAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
if upgraded.Unique.CraftChancePermille != 0 || upgraded.Saved.CanCraftAt != 0 ||
uniqueAction == nil || uniqueAction.Gift.CraftChancePermille != 0 || uniqueAction.CanCraftAt != 0 {
t.Fatalf("no-craft capability leaked: saved=%+v unique=%+v action=%+v", upgraded.Saved, upgraded.Unique, uniqueAction)
}
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000)
page, err := lifecycle.ListCraftStarGifts(ctx, owner.ID, entry.Gift.ID, "", 10)
if err != nil || page.Count != 0 || len(page.Gifts) != 0 {
t.Fatalf("no-craft candidate page = %+v err %v", page, err)
}
if _, err := lifecycle.CraftStarGift(ctx, domain.StarGiftCraftRequest{
UserID: owner.ID, Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: saved.MsgID}},
CommandKey: "no-craft-attempt-" + suffix, Date: now + 2,
}); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) {
t.Fatalf("no-craft attempt err = %v", err)
}
var lifecycleStatus string
var burned bool
var commandCount int
if err := pool.QueryRow(ctx, `SELECT p.lifecycle_status,u.burned
FROM peer_star_gifts p JOIN unique_star_gifts u ON u.id=p.unique_gift_id WHERE p.id=$1`, upgraded.Saved.ID).
Scan(&lifecycleStatus, &burned); err != nil {
t.Fatalf("load no-craft aggregate: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`,
owner.ID, "no-craft-attempt-"+suffix).Scan(&commandCount); err != nil {
t.Fatalf("count no-craft commands: %v", err)
}
if lifecycleStatus != "active" || burned || commandCount != 0 {
t.Fatalf("no-craft attempt mutated aggregate: status=%q burned=%t commands=%d", lifecycleStatus, burned, commandCount)
}
}
func TestAdminUniqueStarGiftGrantIsAtomicAndReplayable(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
now := int(time.Now().Unix())
recipient := createTestUser(t, ctx, NewUserStore(pool), "+1780"+suffix+"61", "AdminGiftRecipient", "")
gifts := NewStarGiftStore(pool)
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Admin Grant " + suffix, Stars: 50, ConvertStars: 25, Enabled: true,
Document: collectibleTestDocument(baseDocumentID, "admin-grant-gift.tgs"),
Blob: collectibleTestBlob(baseDocumentID, "admin-grant-gift"), Animation: collectibleTestAnimation("admin-grant-gift.tgs"),
Actor: "integration", CommandID: "admin-grant-catalog-" + suffix,
})
if err != nil {
t.Fatalf("create admin grant catalog gift: %v", err)
}
revision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 2, SlugPrefix: "admin-grant-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleModel, Name: "Model One", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestDocumentPtr(baseDocumentID+1, "admin-model-one.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "admin-model-one"), Animation: collectibleTestAnimationPtr("admin-model-one.tgs")},
{Kind: domain.StarGiftCollectibleModel, Name: "Model Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestDocumentPtr(baseDocumentID+2, "admin-model-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "admin-model-two"), Animation: collectibleTestAnimationPtr("admin-model-two.tgs")},
},
Patterns: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern One", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "admin-pattern-one.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "admin-pattern-one"), Animation: collectibleTestAnimationPtr("admin-pattern-one.tgs")},
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+4, "admin-pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+4, "admin-pattern-two"), Animation: collectibleTestAnimationPtr("admin-pattern-two.tgs")},
},
Backdrops: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop One", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 2, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
},
Actor: "integration", CommandID: "admin-grant-pool-" + suffix,
})
if err != nil {
t.Fatalf("publish admin grant collectible pool: %v", err)
}
upgrades := NewStarGiftUpgradeStore(pool, NewMessageStore(pool))
invalid := domain.AdminStarGiftGrant{
SenderID: domain.OfficialSystemUserID, Recipient: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID},
GiftID: entry.Gift.ID, Upgrade: true, CommandKey: "admin-invalid-" + suffix, Date: now,
ModelAttributeID: revision.Models[0].ID + 9_999_999,
}
if _, err := upgrades.GrantUniqueStarGift(ctx, invalid); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
t.Fatalf("invalid admin grant error=%v", err)
}
var issued, messageCount, savedCount, uniqueCount, commandCount int
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, revision.ID).Scan(&issued); err != nil {
t.Fatal(err)
}
invalidRandomID := lifecycleCommandRandomID("admin-collectible-grant", recipient.ID, invalid.CommandKey)
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM private_messages WHERE sender_user_id=$1 AND random_id=$2`,
domain.OfficialSystemUserID, invalidRandomID).Scan(&messageCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type='user' AND owner_peer_id=$1 AND gift_id=$2`,
recipient.ID, entry.Gift.ID).Scan(&savedCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM unique_star_gifts WHERE gift_id=$1`, entry.Gift.ID).Scan(&uniqueCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_admin_grant_commands WHERE recipient_user_id=$1`,
recipient.ID).Scan(&commandCount); err != nil {
t.Fatal(err)
}
if issued != 0 || messageCount != 0 || savedCount != 0 || uniqueCount != 0 || commandCount != 0 {
t.Fatalf("failed grant leaked state: issued=%d messages=%d saved=%d unique=%d commands=%d",
issued, messageCount, savedCount, uniqueCount, commandCount)
}
req := invalid
req.CommandKey = "admin-success-" + suffix
req.Message = "atomic collectible"
req.ModelAttributeID = revision.Models[0].ID
req.PatternAttributeID = revision.Patterns[0].ID
req.BackdropAttributeID = revision.Backdrops[0].ID
granted, err := upgrades.GrantUniqueStarGift(ctx, req)
if err != nil {
t.Fatalf("grant admin unique gift: %v", err)
}
action := granted.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
if granted.Duplicate || granted.Saved.MsgID <= 0 || granted.Saved.MsgID != granted.Saved.UpgradeMsgID ||
granted.Saved.UniqueGiftID != granted.Unique.ID || granted.Unique.Num != 1 ||
action == nil || !action.Assigned || !action.Saved || action.Gift.ID != granted.Unique.ID {
t.Fatalf("admin grant result=%+v action=%+v", granted, action)
}
events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, granted.Send.RecipientMessage.Pts-1, 1)
if err != nil || len(events) != 1 || events[0].Message.Media == nil ||
events[0].Message.Media.ServiceAction == nil ||
events[0].Message.Media.ServiceAction.StarGiftUnique == nil ||
events[0].Message.Media.ServiceAction.StarGiftUnique.Gift.ID != granted.Unique.ID {
t.Fatalf("admin grant durable update=%+v err=%v", events, err)
}
replay, err := upgrades.GrantUniqueStarGift(ctx, req)
if err != nil {
t.Fatalf("replay admin unique gift: %v", err)
}
if !replay.Duplicate || replay.Saved.ID != granted.Saved.ID || replay.Unique.ID != granted.Unique.ID ||
replay.Send.RecipientMessage.ID != granted.Send.RecipientMessage.ID {
t.Fatalf("admin grant replay=%+v want saved=%d unique=%d msg=%d",
replay, granted.Saved.ID, granted.Unique.ID, granted.Send.RecipientMessage.ID)
}
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, revision.ID).Scan(&issued); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_admin_grant_commands WHERE recipient_user_id=$1`,
recipient.ID).Scan(&commandCount); err != nil {
t.Fatal(err)
}
if issued != 1 || commandCount != 1 {
t.Fatalf("replay duplicated aggregate: issued=%d commands=%d", issued, commandCount)
}
}
func collectibleTestAnimation(name string) domain.StarGiftAnimation {
return domain.StarGiftAnimation{
SourceName: name, SourceFormat: domain.StarGiftAnimationTGS,
JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`),
TGS: []byte("test"), SHA256: make([]byte, 32), Width: 512, Height: 512, FrameRate: 30, OutPoint: 30,
}
}
func collectibleTestAnimationPtr(name string) *domain.StarGiftAnimation {
animation := collectibleTestAnimation(name)
return &animation
}
func collectibleTestDocument(id int64, name string) domain.Document {
return domain.Document{
ID: id, AccessHash: id + 100, FileReference: []byte("collectible-test"), Date: 1700001000,
MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
{Kind: domain.DocAttrSticker, Alt: "🎁"},
{Kind: domain.DocAttrFilename, FileName: name},
},
}
}
func collectibleTestDocumentPtr(id int64, name string) *domain.Document {
document := collectibleTestDocument(id, name)
return &document
}
func collectibleTestPatternDocumentPtr(id int64, name string) *domain.Document {
document := collectibleTestDocument(id, name)
document.Attributes[1] = domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: "🎁", TextColor: true}
document.Thumbs = []domain.PhotoSize{{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte{1}}}
return &document
}
func collectibleTestBlob(id int64, suffix string) domain.FileBlob {
return domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", id), Backend: domain.MediaBackendLocalFS,
ObjectKey: "collectible-integration-" + suffix, Size: 4, SHA256: make([]byte, 32), MimeType: "application/x-tgsticker",
}
}
func collectibleTestBlobPtr(id int64, suffix string) *domain.FileBlob {
blob := collectibleTestBlob(id, suffix)
return &blob
}
// createCollectibleSavedGift seeds the same valid source-message + saved-gift
// invariant as the purchase aggregate. Tests must not invent a peer_star_gifts
// msg_id that has no durable message box behind it.
func createCollectibleSavedGift(
t *testing.T,
ctx context.Context,
messages *MessageStore,
gifts *StarGiftStore,
gift domain.StarGift,
saved domain.SavedStarGift,
) domain.SavedStarGift {
t.Helper()
sticker := gift.Sticker
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: saved.FromUserID,
RecipientUserID: saved.Owner.ID,
RandomID: (time.Now().UnixNano() & 0x7fffffffffffffff) ^ saved.Owner.ID ^ int64(saved.Date),
Date: saved.Date,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{
GiftID: gift.ID, Stars: gift.Stars, ConvertStars: saved.ConvertStars,
Title: gift.Title, Sticker: &sticker, Message: saved.Message,
FromUserID: saved.FromUserID, PeerUserID: saved.Owner.ID, Saved: true,
CanUpgrade: gift.UpgradeStars > 0, PrepaidUpgrade: saved.PrepaidUpgradeStars > 0,
UpgradePriceStars: gift.UpgradeStars, UpgradeStars: saved.PrepaidUpgradeStars,
},
}},
})
if err != nil {
t.Fatalf("create collectible source message: %v", err)
}
saved.MsgID = sent.RecipientMessage.ID
id, err := gifts.Create(ctx, saved)
if err != nil {
t.Fatalf("create saved gift: %v", err)
}
saved.ID = id
return saved
}
func upgradedSourceEditForUser(result domain.StarGiftUpgradeResult, userID int64) domain.EditedMessageForUser {
for _, edit := range result.SourceEdits {
if edit.UserID == userID {
return edit
}
}
return domain.EditedMessageForUser{UserID: userID}
}

File diff suppressed because it is too large Load diff

View file

@ -1,230 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// markCraftInputMessagesTx makes the chat projection part of the same commit
// as the craft outcome. TDesktop derives the Craft entry directly from the
// messageActionStarGiftUnique snapshot, so changing only peer_star_gifts and
// unique_star_gifts would leave an already-burned input actionable.
func (s *StarGiftLifecycleStore) markCraftInputMessagesTx(
ctx context.Context,
tx pgx.Tx,
req domain.StarGiftCraftRequest,
savedIDs []int64,
) ([]domain.EditedMessageForUser, []int32, error) {
edits := make([]domain.EditedMessageForUser, 0, len(savedIDs)*2)
ownerPTS := make([]int32, 0, len(savedIDs))
for _, savedID := range savedIDs {
saved, found, err := savedStarGiftByID(ctx, tx, savedID)
if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) ||
saved.UniqueGiftID <= 0 || saved.UpgradeMsgID <= 0 {
if err != nil {
return nil, nil, err
}
return nil, nil, domain.ErrStarGiftCraftUnavailable
}
unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID)
if err != nil || !found {
if err != nil {
return nil, nil, err
}
return nil, nil, domain.ErrStarGiftCraftUnavailable
}
inputEdits, ownerPT, err := s.markCraftInputMessageTx(ctx, tx, req, saved, unique)
if err != nil {
return nil, nil, err
}
edits = append(edits, inputEdits...)
ownerPTS = append(ownerPTS, int32(ownerPT))
}
return edits, ownerPTS, nil
}
func (s *StarGiftLifecycleStore) markCraftInputMessageTx(
ctx context.Context,
tx pgx.Tx,
req domain.StarGiftCraftRequest,
saved domain.SavedStarGift,
unique domain.UniqueStarGift,
) ([]domain.EditedMessageForUser, int, error) {
q := sqlcgen.New(tx)
target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{
OwnerUserID: req.UserID,
BoxID: int32(saved.UpgradeMsgID),
PeerType: string(domain.PeerTypeUser),
PeerID: saved.FromUserID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, 0, domain.ErrStarGiftCraftUnavailable
}
return nil, 0, fmt.Errorf("lock craft input message: %w", err)
}
boxes, err := q.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
OwnerUserIds: privateMessageOwnerIDs(req.UserID, saved.FromUserID),
MessageSenderID: target.MessageSenderID,
PrivateMessageID: target.PrivateMessageID,
})
if err != nil {
return nil, 0, fmt.Errorf("list craft input message boxes: %w", err)
}
if len(boxes) == 0 {
return nil, 0, domain.ErrStarGiftCraftUnavailable
}
edits := make([]domain.EditedMessageForUser, 0, len(boxes))
ownerPTS := 0
var privateMediaJSON []byte
for _, box := range boxes {
media, err := decodeMessageMedia(box.MediaJson)
if err != nil {
return nil, 0, fmt.Errorf("decode craft input message media: %w", err)
}
if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique || media.ServiceAction.StarGiftUnique == nil ||
media.ServiceAction.StarGiftUnique.Gift.ID != unique.ID {
return nil, 0, fmt.Errorf("craft input message %d has invalid unique gift projection", box.BoxID)
}
action := media.ServiceAction.StarGiftUnique
action.Gift = unique
action.Saved = saved.LifecycleStatus.Live() && !saved.Unsaved
action.CanExportAt = saved.CanExportAt
action.TransferStars = saved.TransferStars
action.CanTransferAt = saved.CanTransferAt
action.CanResellAt = saved.CanResellAt
action.DropOriginalDetailsStars = saved.DropOriginalDetailsStars
action.CanCraftAt = saved.CanCraftAt
mediaJSON, err := encodeMessageMedia(media)
if err != nil {
return nil, 0, fmt.Errorf("encode craft input message media: %w", err)
}
pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID)
if err != nil {
return nil, 0, fmt.Errorf("allocate craft input edit pts: %w", err)
}
tag, err := tx.Exec(ctx, `
UPDATE message_boxes SET media=$3,pts=$4
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxID, mediaJSON, int32(pts))
if err != nil {
return nil, 0, fmt.Errorf("update craft input message box: %w", err)
}
if tag.RowsAffected() != 1 {
return nil, 0, fmt.Errorf("update craft input message box lost row")
}
msg, err := messageFromVisibleBoxRow(box)
if err != nil {
return nil, 0, err
}
msg.Media = media
msg.Pts = pts
if err := replaceMessageBoxMediaIndexTx(ctx, tx, msg.OwnerUserID, msg.Peer.ID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil {
return nil, 0, err
}
event := domain.UpdateEvent{UserID: msg.OwnerUserID, Type: domain.UpdateEventEditMessage,
Pts: pts, PtsCount: 1, Date: req.Date, Message: msg}
if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil {
return nil, 0, fmt.Errorf("append craft input edit event: %w", err)
}
dispatchAuthKeyID := [8]byte{}
dispatchSessionID := int64(0)
if msg.OwnerUserID == req.UserID {
dispatchAuthKeyID = req.OriginAuthKeyID
dispatchSessionID = req.OriginSessionID
ownerPTS = pts
}
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: msg.OwnerUserID, Pts: int32(pts), EventType: string(domain.UpdateEventEditMessage),
ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), ExcludeSessionID: dispatchSessionID,
}); err != nil {
return nil, 0, fmt.Errorf("enqueue craft input edit: %w", err)
}
if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 {
privateMediaJSON, err = encodeSharedPrivateStarGiftMedia(media)
if err != nil {
return nil, 0, err
}
}
edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event})
}
if ownerPTS <= 0 || len(privateMediaJSON) == 0 {
return nil, 0, fmt.Errorf("craft input message missing owner projection")
}
if _, err := tx.Exec(ctx, `
UPDATE private_messages SET media=$3
WHERE sender_user_id=$1 AND id=$2`, target.MessageSenderID, target.PrivateMessageID, privateMediaJSON); err != nil {
return nil, 0, fmt.Errorf("update craft input private message: %w", err)
}
return edits, ownerPTS, nil
}
func (s *StarGiftLifecycleStore) loadCraftInputMessageReplays(
ctx context.Context,
req domain.StarGiftCraftRequest,
savedIDs []int64,
ptsValues []int32,
) ([]domain.EditedMessageForUser, error) {
if len(savedIDs) != len(ptsValues) {
return nil, domain.ErrStarGiftCraftUnavailable
}
edits := make([]domain.EditedMessageForUser, 0, len(savedIDs))
for i, savedID := range savedIDs {
saved, found, err := savedStarGiftByID(ctx, s.db, savedID)
if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) ||
saved.UpgradeMsgID <= 0 || ptsValues[i] <= 0 {
if err != nil {
return nil, err
}
return nil, domain.ErrStarGiftCraftUnavailable
}
var privateMessageID, messageSenderID int64
err = s.db.QueryRow(ctx, `
SELECT private_message_id,message_sender_id FROM message_boxes
WHERE owner_user_id=$1 AND box_id=$2 AND peer_type='user' AND peer_id=$3 AND NOT deleted`,
req.UserID, saved.UpgradeMsgID, saved.FromUserID).Scan(&privateMessageID, &messageSenderID)
if errors.Is(err, pgx.ErrNoRows) {
continue
}
if err != nil {
return nil, fmt.Errorf("load craft input replay message: %w", err)
}
boxes, err := sqlcgen.New(s.db).ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
OwnerUserIds: []int64{req.UserID}, MessageSenderID: messageSenderID, PrivateMessageID: privateMessageID,
})
if err != nil {
return nil, fmt.Errorf("load craft input replay box: %w", err)
}
if len(boxes) != 1 || int(boxes[0].BoxID) != saved.UpgradeMsgID {
return nil, domain.ErrStarGiftCraftUnavailable
}
var eventDate int
err = s.db.QueryRow(ctx, `
SELECT date FROM user_update_events
WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`,
req.UserID, ptsValues[i], saved.UpgradeMsgID).Scan(&eventDate)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrStarGiftCraftUnavailable
}
return nil, fmt.Errorf("load craft input replay event: %w", err)
}
msg, err := messageFromVisibleBoxRow(boxes[0])
if err != nil {
return nil, err
}
msg.Pts = int(ptsValues[i])
event := domain.UpdateEvent{UserID: req.UserID, Type: domain.UpdateEventEditMessage,
Pts: int(ptsValues[i]), PtsCount: 1, Date: eventDate, Message: msg}
edits = append(edits, domain.EditedMessageForUser{UserID: req.UserID, Message: msg, Event: event})
}
return edits, nil
}

View file

@ -1,299 +0,0 @@
package postgres
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
func (s *StarGiftLifecycleStore) PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error) {
hash = strings.TrimSpace(hash)
if s == nil || s.db == nil || !validLifecyclePeer(owner) || len(hash) < 32 || len(hash) > 256 {
return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable
}
row := s.db.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.prepaid_upgrade_hash,p.gift_num,
p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at,
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 p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.prepaid_upgrade_hash=$3`,
string(owner.Type), owner.ID, hash)
saved, err := scanSavedStarGift(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable
}
if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID != 0 || saved.PrepaidUpgradeStars != 0 {
if err != nil {
return domain.SavedStarGift{}, 0, err
}
return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable
}
revision, err := locklessActiveCollectibleRevision(ctx, s.db, saved.GiftID)
if err != nil || revision.UpgradeStars <= 0 || revision.Issued >= revision.SupplyTotal {
return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable
}
return saved, revision.UpgradeStars, nil
}
func locklessActiveCollectibleRevision(ctx context.Context, db interface {
QueryRow(context.Context, string, ...any) pgx.Row
}, giftID int64) (domain.StarGiftCollectibleRevision, error) {
var revision domain.StarGiftCollectibleRevision
var status string
err := db.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`, giftID).Scan(&revision.ID, &revision.GiftID, &revision.UpgradeStars,
&revision.SupplyTotal, &revision.Issued, &revision.SlugPrefix, &status)
if err != nil || status != "published" {
return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftCollectibleUnavailable
}
return revision, nil
}
func (s *StarGiftLifecycleStore) PrepayStarGiftUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error) {
req.Hash, req.CommandKey = strings.TrimSpace(req.Hash), strings.TrimSpace(req.CommandKey)
if s == nil || s.messages == nil || req.PayerUserID <= 0 || !validLifecyclePeer(req.Owner) ||
len(req.Hash) < 32 || len(req.Hash) > 256 || req.FormID == 0 || req.Date <= 0 || req.CommandKey == "" || len(req.CommandKey) > 256 || req.ChargeStars < 0 {
return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable
}
if replay, found, err := s.loadPrepaidUpgradeReplay(ctx, req, domain.SendPrivateTextResult{}); err != nil || found {
return replay, err
}
if req.ChargeStars <= 0 {
return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable
}
target, price, err := s.PrepaidUpgradeTarget(ctx, req.Owner, req.Hash)
if err != nil || price != req.ChargeStars {
return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable
}
fingerprint := sha256.Sum256([]byte(fmt.Sprintf("telesrv:star-gift-prepay:v2:%d:%s:%d:%s:%d:%d", req.PayerUserID,
req.Owner.Type, req.Owner.ID, req.Hash, req.FormID, req.ChargeStars)))
placeholder := &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{Saved: true, CanUpgrade: true, UpgradeSeparate: true}}}
messageSenderID, recipientUserID := req.PayerUserID, req.Owner.ID
if req.Owner.Type == domain.PeerTypeChannel {
messageSenderID, recipientUserID = domain.OfficialSystemUserID, req.PayerUserID
}
messageReq := domain.SendPrivateTextRequest{SenderUserID: messageSenderID, RecipientUserID: recipientUserID,
RandomID: lifecycleCommandRandomID("prepay", req.PayerUserID, req.Owner.ID, req.Hash), Media: placeholder, Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.PayerUserID,
IdempotencyFingerprint: fingerprint[:]}
var result domain.StarGiftPrepaidUpgradeResult
hooks := privateSendTxHooks{before: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) error {
locked, err := lockSavedStarGiftByPrepayHash(ctx, tx, req.Owner, req.Hash)
if err != nil || locked.ID != target.ID || !locked.LifecycleStatus.Live() || locked.UniqueGiftID != 0 || locked.PrepaidUpgradeStars != 0 {
return domain.ErrStarGiftCollectibleUnavailable
}
revision, err := lockActiveCollectibleRevision(ctx, tx, locked.GiftID)
if err != nil || revision.UpgradeStars != req.ChargeStars || revision.Issued >= revision.SupplyTotal {
return domain.ErrStarGiftCollectibleUnavailable
}
balance, err := s.debitLifecycleAmount(ctx, tx, req.PayerUserID,
domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: req.ChargeStars},
domain.StarsReasonGiftPrepaid, req.Owner, req.Date, "Prepaid star gift upgrade")
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET prepaid_upgrade_stars=$2,prepaid_upgrade_hash='' WHERE id=$1`, locked.ID, req.ChargeStars); err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_prepaid_upgrade_commands(payer_user_id,command_key,saved_gift_id,form_id,charge_stars,balance_after,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7)`, req.PayerUserID, req.CommandKey, locked.ID, req.FormID, req.ChargeStars, balance.Balance, req.Date); err != nil {
return err
}
gift, found, err := NewStarGiftStore(tx).CatalogRevision(ctx, locked.RevisionID)
if err != nil || !found {
return domain.ErrStarGiftCollectibleUnavailable
}
sticker := gift.Sticker
action := &domain.MessageStarGiftAction{
GiftID: gift.ID, Stars: gift.Stars, ConvertStars: locked.ConvertStars, Title: gift.Title, Sticker: &sticker,
FromUserID: req.PayerUserID, To: req.Owner, SavedID: locked.SavedID, Saved: true, CanUpgrade: true,
PrepaidUpgrade: true, UpgradeSeparate: true, UpgradePriceStars: req.ChargeStars,
UpgradeStars: req.ChargeStars, GiftMsgID: locked.MsgID,
}
if req.Owner.Type == domain.PeerTypeChannel {
action.PeerChannelID = req.Owner.ID
} else {
action.PeerUserID = req.Owner.ID
}
messageReq.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{
GiftID: action.GiftID, Stars: action.Stars, ConvertStars: action.ConvertStars, Title: action.Title,
Sticker: action.Sticker, FromUserID: action.FromUserID, PeerUserID: action.PeerUserID,
PeerChannelID: action.PeerChannelID, To: action.To, SavedID: action.SavedID, Saved: action.Saved,
CanUpgrade: action.CanUpgrade, PrepaidUpgrade: action.PrepaidUpgrade, UpgradeSeparate: action.UpgradeSeparate,
UpgradePriceStars: action.UpgradePriceStars, UpgradeStars: action.UpgradeStars, GiftMsgID: action.GiftMsgID}}}
locked.PrepaidUpgradeStars, locked.PrepaidUpgradeHash = req.ChargeStars, ""
result.Saved, result.Balance = locked, balance
return nil
}, projectMedia: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) (privateSendMediaProjection, error) {
if result.Saved.Owner.Type != domain.PeerTypeUser {
return privateSendMediaProjection{Shared: messageReq.Media, Sender: messageReq.Media, Recipient: messageReq.Media}, nil
}
return projectPrivateStarGiftSourceRef(ctx, tx, messageReq, result.Saved.Owner.ID, result.Saved.MsgID)
}, after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
if req.Owner.Type == domain.PeerTypeUser {
ownerMessageID := sent.RecipientMessage.ID
if sent.SenderMessage.OwnerUserID == req.Owner.ID {
ownerMessageID = sent.SenderMessage.ID
}
return registerUserStarGiftMessageRef(ctx, tx, req.Owner.ID, ownerMessageID, result.Saved.ID, 0)
}
notificationMessageID := sent.RecipientMessage.ID
if notificationMessageID <= 0 {
return fmt.Errorf("prepaid channel gift notification missing recipient box")
}
if err := registerViewerStarGiftMessageRef(ctx, tx, req.PayerUserID, notificationMessageID,
result.Saved.ID, req.Owner, 0); err != nil {
return err
}
action := messageReq.Media.ServiceAction.StarGift
return NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.Owner.ID, req.PayerUserID,
result.Saved.SavedID, req.Date, domain.ChannelMessageAction{Type: domain.ChannelActionStarGift, StarGift: action})
}}
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
if err != nil {
if isUniqueViolation(err) {
if replay, found, replayErr := s.loadPrepaidUpgradeReplay(ctx, req, sent); replayErr != nil || found {
return replay, replayErr
}
}
return domain.StarGiftPrepaidUpgradeResult{}, err
}
result.Send, result.Duplicate = sent, sent.Duplicate
if sent.Duplicate {
replay, _, replayErr := s.loadPrepaidUpgradeReplay(ctx, req, sent)
return replay, replayErr
}
return result, nil
}
func lockSavedStarGiftByPrepayHash(ctx context.Context, tx pgx.Tx, owner domain.Peer, hash string) (domain.SavedStarGift, error) {
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.prepaid_upgrade_hash,p.gift_num,
p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at,
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 p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.prepaid_upgrade_hash=$3 FOR UPDATE`,
string(owner.Type), owner.ID, hash)
saved, err := scanSavedStarGift(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.SavedStarGift{}, domain.ErrStarGiftCollectibleUnavailable
}
return saved, err
}
func (s *StarGiftLifecycleStore) loadPrepaidUpgradeReplay(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest, sent domain.SendPrivateTextResult) (domain.StarGiftPrepaidUpgradeResult, bool, error) {
var savedID, balance int64
err := s.db.QueryRow(ctx, `SELECT saved_gift_id,balance_after FROM star_gift_prepaid_upgrade_commands WHERE payer_user_id=$1 AND command_key=$2`,
req.PayerUserID, req.CommandKey).Scan(&savedID, &balance)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftPrepaidUpgradeResult{}, false, nil
}
if err != nil {
return domain.StarGiftPrepaidUpgradeResult{}, false, err
}
saved, found, err := savedStarGiftByID(ctx, s.db, savedID)
if err != nil || !found {
return domain.StarGiftPrepaidUpgradeResult{}, false, domain.ErrStarGiftCollectibleUnavailable
}
return domain.StarGiftPrepaidUpgradeResult{Saved: saved, Balance: domain.StarsBalance{UserID: req.PayerUserID, Balance: balance}, Send: sent, Duplicate: true}, true, nil
}
func (s *StarGiftLifecycleStore) DropStarGiftOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error) {
req.CommandKey = strings.TrimSpace(req.CommandKey)
if s == nil || s.db == nil || req.UserID <= 0 || !req.Ref.Valid() ||
(req.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.ID != req.UserID) || !validLifecyclePeer(req.Ref.Owner) ||
req.FormID == 0 || req.Date <= 0 || req.CommandKey == "" || len(req.CommandKey) > 256 || req.ChargeStars < 0 {
return domain.StarGiftDropOriginalDetailsResult{}, domain.ErrStarGiftCollectibleUnavailable
}
if replay, found, err := s.loadDropDetailsReplay(ctx, req); err != nil || found {
return replay, err
}
if req.ChargeStars <= 0 {
return domain.StarGiftDropOriginalDetailsResult{}, domain.ErrStarGiftCollectibleUnavailable
}
var result domain.StarGiftDropOriginalDetailsResult
err := withTx(ctx, s.db, "drop star gift original details", func(tx pgx.Tx) error {
saved, unique, err := lockOwnedUniqueStarGift(ctx, tx, req.UserID, req.Ref)
if err != nil || saved.DropOriginalDetailsStars != req.ChargeStars || !unique.KeepOriginalDetails {
return domain.ErrStarGiftCollectibleUnavailable
}
balance, err := s.debitLifecycleAmount(ctx, tx, req.UserID,
domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: req.ChargeStars},
domain.StarsReasonGiftDrop, saved.Owner, req.Date, "Drop star gift original details")
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET keep_original_details=false,updated_at=now() WHERE id=$1`, unique.ID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET drop_original_details_stars=0 WHERE id=$1`, saved.ID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_drop_details_commands(user_id,command_key,saved_gift_id,unique_gift_id,form_id,charge_stars,balance_after,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, req.UserID, req.CommandKey, saved.ID, unique.ID, req.FormID, req.ChargeStars, balance.Balance, req.Date); err != nil {
return err
}
saved.DropOriginalDetailsStars, unique.KeepOriginalDetails = 0, false
result = domain.StarGiftDropOriginalDetailsResult{Saved: saved, Unique: unique, Balance: balance}
return nil
})
if err != nil {
if isUniqueViolation(err) {
if replay, found, replayErr := s.loadDropDetailsReplay(ctx, req); replayErr != nil || found {
return replay, replayErr
}
}
return domain.StarGiftDropOriginalDetailsResult{}, err
}
return result, nil
}
func (s *StarGiftLifecycleStore) loadDropDetailsReplay(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, bool, error) {
var savedID, uniqueID, balance int64
err := s.db.QueryRow(ctx, `SELECT saved_gift_id,unique_gift_id,balance_after FROM star_gift_drop_details_commands WHERE user_id=$1 AND command_key=$2`,
req.UserID, req.CommandKey).Scan(&savedID, &uniqueID, &balance)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftDropOriginalDetailsResult{}, false, nil
}
if err != nil {
return domain.StarGiftDropOriginalDetailsResult{}, false, err
}
saved, found, err := savedStarGiftByID(ctx, s.db, savedID)
if err != nil || !found {
return domain.StarGiftDropOriginalDetailsResult{}, false, domain.ErrStarGiftCollectibleUnavailable
}
unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID)
if err != nil || !found {
return domain.StarGiftDropOriginalDetailsResult{}, false, domain.ErrStarGiftCollectibleUnavailable
}
return domain.StarGiftDropOriginalDetailsResult{Saved: saved, Unique: unique,
Balance: domain.StarsBalance{UserID: req.UserID, Balance: balance}, Duplicate: true}, true, nil
}
func savedStarGiftByID(ctx context.Context, db interface {
QueryRow(context.Context, string, ...any) pgx.Row
}, savedID int64) (domain.SavedStarGift, bool, error) {
row := db.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.prepaid_upgrade_hash,p.gift_num,
p.lifecycle_status,p.transfer_stars,p.can_export_at,p.can_transfer_at,p.can_resell_at,p.drop_original_details_stars,p.can_craft_at,
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 p.id=$1`, savedID)
saved, err := scanSavedStarGift(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.SavedStarGift{}, false, nil
}
return saved, err == nil, err
}

View file

@ -1,230 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"slices"
"testing"
"time"
"telesrv/internal/domain"
)
// TestStarGiftStorePostgres 回归迁移 0089目录不可变版本与用户收到礼物实例对真实 PG 的 CRUD
// (版本固定 / 创建 / keyset 分页 / excludeUnsaved / 隐藏切换 / 转换幂等)。
func TestStarGiftStorePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
st := NewStarGiftStore(pool)
users := NewUserStore(pool)
suffix := randomSuffix(t)
owner, err := users.Create(ctx, domain.User{AccessHash: 81, Phone: "+1779" + suffix + "51", FirstName: "GiftOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
from, err := users.Create(ctx, domain.User{AccessHash: 82, Phone: "+1779" + suffix + "52", FirstName: "GiftSender"})
if err != nil {
t.Fatalf("create sender: %v", err)
}
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
docID := time.Now().UnixNano() & 0x7fffffffffffffff
documentIDs := []int64{docID}
locationKeys := []string{"doc:" + fmt.Sprint(docID)}
entry, err := st.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Stars: 50, ConvertStars: 50, Enabled: true, Document: domain.Document{
ID: docID, AccessHash: docID + 1, MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
},
Blob: domain.FileBlob{LocationKey: "doc:" + fmt.Sprint(docID), Backend: domain.MediaBackendLocalFS, ObjectKey: "test-star-gift", Size: 4, SHA256: make([]byte, 32), MimeType: "application/x-tgsticker"},
Animation: domain.StarGiftAnimation{JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`), SHA256: make([]byte, 32), SourceFormat: domain.StarGiftAnimationTGS, Width: 512, Height: 512},
Actor: "test", CommandID: "test-star-gift-" + suffix,
})
if err != nil {
t.Fatalf("create catalog gift: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM peer_star_gifts WHERE owner_peer_id IN ($1, $2)", owner.ID, int64(987654321))
tx, _ := pool.Begin(ctx)
if tx != nil {
_, _ = tx.Exec(ctx, "DELETE FROM star_gift_catalog WHERE gift_id=$1", entry.Gift.ID)
_, _ = tx.Exec(ctx, "DELETE FROM star_gift_catalog_revisions WHERE gift_id=$1", entry.Gift.ID)
_, _ = tx.Exec(ctx, "DELETE FROM file_blobs WHERE location_key = ANY($1::text[])", locationKeys)
_, _ = tx.Exec(ctx, "DELETE FROM documents WHERE id = ANY($1::bigint[])", documentIDs)
_ = tx.Commit(ctx)
}
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, from.ID})
})
// 创建三份礼物msg_id 递增)。
savedIDs := make([]int64, 3)
for i := 0; i < 3; i++ {
savedID, err := st.Create(ctx, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: from.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, MsgID: 100 + i,
Date: 1700000000 + i, ConvertStars: 50,
})
if err != nil {
t.Fatalf("create gift #%d: %v", i, err)
}
savedIDs[i] = savedID
}
// active revision 更新后,已收到的礼物必须继续固定到购买瞬间的 immutable revision。
docID2 := docID + 1
documentIDs = append(documentIDs, docID2)
locationKeys = append(locationKeys, "doc:"+fmt.Sprint(docID2))
updated, err := st.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
GiftID: entry.Gift.ID, Title: "Revision 2", Stars: 75, ConvertStars: 25, Enabled: true,
Document: domain.Document{
ID: docID2, AccessHash: docID2 + 1, MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
},
Blob: domain.FileBlob{LocationKey: "doc:" + fmt.Sprint(docID2), Backend: domain.MediaBackendLocalFS, ObjectKey: "test-star-gift-v2", Size: 4, SHA256: make([]byte, 32), MimeType: "application/x-tgsticker"},
Animation: domain.StarGiftAnimation{JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`), SHA256: make([]byte, 32), SourceFormat: domain.StarGiftAnimationTGS, Width: 512, Height: 512},
Actor: "test", CommandID: "test-star-gift-v2-" + suffix,
})
if err != nil {
t.Fatalf("create catalog revision 2: %v", err)
}
if updated.Revision != 2 || updated.Gift.RevisionID == entry.Gift.RevisionID {
t.Fatalf("revision 2 = %+v, want a new immutable revision", updated)
}
if updated.ReceivedCount != 3 {
t.Fatalf("revision 2 received count = %d, want all 3 historical instances", updated.ReceivedCount)
}
historical, found, err := st.CatalogRevision(ctx, entry.Gift.RevisionID)
if err != nil || !found || historical.Stars != 50 || historical.Sticker.ID != docID {
t.Fatalf("historical revision = %+v found %v err %v", historical, found, err)
}
// keyset 分页:每页 2末页省略游标。
page1, err := st.ListByOwner(ctx, ownerPeer, false, "", 2)
if err != nil {
t.Fatalf("list page1: %v", err)
}
if len(page1.Gifts) != 2 || page1.Count != 3 || page1.NextOffset == "" {
t.Fatalf("page1 = %d count %d next %q, want 2/3/nonempty", len(page1.Gifts), page1.Count, page1.NextOffset)
}
if page1.Gifts[0].MsgID != 102 {
t.Fatalf("page1[0] msg_id = %d, want 102 (newest first)", page1.Gifts[0].MsgID)
}
page2, err := st.ListByOwner(ctx, ownerPeer, false, page1.NextOffset, 2)
if err != nil {
t.Fatalf("list page2: %v", err)
}
if len(page2.Gifts) != 1 || page2.NextOffset != "" {
t.Fatalf("page2 = %d next %q, want 1 + empty (terminal)", len(page2.Gifts), page2.NextOffset)
}
// 资料页顺序:完整 pin vector 的顺序必须成为列表前缀;游标即使切在
// pinned block 内或 pinned/unpinned 边界,也不能重复或漏项。
if err := st.SetPinned(ctx, ownerPeer, []int64{savedIDs[0], savedIDs[2]}); err != nil {
t.Fatalf("set pinned profile order: %v", err)
}
wantMsgIDs := []int{100, 102, 101}
gotMsgIDs := make([]int, 0, len(wantMsgIDs))
offset := ""
for pageNumber := 0; ; pageNumber++ {
page, err := st.ListByOwner(ctx, ownerPeer, false, offset, 1)
if err != nil {
t.Fatalf("list pinned page %d: %v", pageNumber, err)
}
if page.Count != 3 || len(page.Gifts) != 1 {
t.Fatalf("pinned page %d = %+v, want count=3 and one gift", pageNumber, page)
}
gotMsgIDs = append(gotMsgIDs, page.Gifts[0].MsgID)
if page.NextOffset == "" {
break
}
offset = page.NextOffset
}
if !slices.Equal(gotMsgIDs, wantMsgIDs) {
t.Fatalf("pinned paged msg ids = %v, want %v", gotMsgIDs, wantMsgIDs)
}
// Hiding a pinned gift atomically unpins it and compacts the remaining
// vector. Pinning it again makes it visible in the same owner transaction.
if ok, err := st.SetUnsaved(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100}, true); err != nil || !ok {
t.Fatalf("hide pinned gift = %v err %v", ok, err)
}
hiddenPinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100})
if err != nil || !found || !hiddenPinned.Unsaved || hiddenPinned.PinnedOrder != 0 {
t.Fatalf("hidden pinned gift = %+v found %v err %v", hiddenPinned, found, err)
}
remainingPinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 102})
if err != nil || !found || remainingPinned.PinnedOrder != 1 {
t.Fatalf("remaining pin after compaction = %+v found %v err %v", remainingPinned, found, err)
}
if err := st.SetPinned(ctx, ownerPeer, []int64{savedIDs[0], savedIDs[2]}); err != nil {
t.Fatalf("repin hidden gift: %v", err)
}
repinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100})
if err != nil || !found || repinned.Unsaved || repinned.PinnedOrder != 1 {
t.Fatalf("repinned hidden gift = %+v found %v err %v", repinned, found, err)
}
if err := st.SetPinned(ctx, ownerPeer, nil); err != nil {
t.Fatalf("clear pinned profile order: %v", err)
}
// 隐藏 msg_id=101 → excludeUnsaved 列表少一份。
if ok, err := st.SetUnsaved(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 101}, true); err != nil || !ok {
t.Fatalf("set unsaved = %v err %v", ok, err)
}
shown, err := st.ListByOwner(ctx, ownerPeer, true, "", 100)
if err != nil || len(shown.Gifts) != 2 || shown.Count != 2 {
t.Fatalf("excludeUnsaved = %d count %d err %v, want 2/2", len(shown.Gifts), shown.Count, err)
}
// GetByRef(user msg_id)。
g, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100})
if err != nil || !found || g.GiftID != entry.Gift.ID || g.RevisionID != entry.Gift.RevisionID || g.ConvertStars != 50 {
t.Fatalf("get = %+v found %v err %v", g, found, err)
}
// 转换 msg_id=100 → converted从列表消失重复转换被拒。
conv, err := st.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100})
if err != nil || conv.ConvertStars != 50 || !conv.Converted {
t.Fatalf("convert = %+v err %v, want ConvertStars 50 converted", conv, err)
}
if _, err := st.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100}); !errors.Is(err, domain.ErrStarGiftAlreadyConverted) {
t.Fatalf("double convert err = %v, want ErrStarGiftAlreadyConverted", err)
}
full, _ := st.ListByOwner(ctx, ownerPeer, false, "", 100)
if full.Count != 2 {
t.Fatalf("count after convert = %d, want 2", full.Count)
}
// 转换不存在的礼物。
if _, err := st.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 999}); !errors.Is(err, domain.ErrStarGiftNotFound) {
t.Fatalf("convert missing err = %v, want ErrStarGiftNotFound", err)
}
// CountByOwner展示在资料 = 非转换、非隐藏100 已转换、101 已隐藏、102 仍展示 → 1。
n, err := st.CountByOwner(ctx, ownerPeer)
if err != nil || n != 1 {
t.Fatalf("CountByOwner = %d err %v, want 1 (100 converted, 101 hidden, 102 shown)", n, err)
}
// 频道礼物用 saved_id 定位,和用户 msg_id 身份键隔离。
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 987654321}
if _, err := st.Create(ctx, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: from.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, MsgID: 700,
Date: 1700000100, ConvertStars: 50,
}); err != nil {
t.Fatalf("create user gift with same msg_id namespace: %v", err)
}
channelSavedID, err := st.Create(ctx, domain.SavedStarGift{
Owner: channelPeer, FromUserID: from.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, MsgID: 0, SavedID: 0,
Date: 1700000101, ConvertStars: 50,
})
if err != nil {
t.Fatalf("create channel gift: %v", err)
}
cg, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: channelPeer, SavedID: channelSavedID})
if err != nil || !found || cg.Owner != channelPeer || cg.MsgID != 0 || cg.SavedID != channelSavedID {
t.Fatalf("get channel gift = %+v found %v err %v", cg, found, err)
}
cn, err := st.CountByOwner(ctx, channelPeer)
if err != nil || cn != 1 {
t.Fatalf("channel CountByOwner = %d err %v, want 1", cn, err)
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,20 +0,0 @@
package postgres
import (
"os"
"testing"
)
func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
}
status, err := MigrateAndStatus(dsn)
if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err)
}
if status.Dirty || status.Empty || status.Version != 20260714003129 {
t.Fatalf("migration status = %+v, want clean version 20260714003129", status)
}
}

View file

@ -1,196 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"sort"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// retireUserStarGiftMessagesTx closes every user-scoped unique-gift action
// emitted for the source ownership epoch. Ownership moves and terminal export
// must not leave an older chat card with Craft/transfer/resale capabilities.
// The aggregate mutation and all message edits share one transaction and each
// visible box receives its own durable pts/event/outbox entry.
func (s *StarGiftLifecycleStore) retireUserStarGiftMessagesTx(
ctx context.Context,
tx pgx.Tx,
source domain.SavedStarGift,
current domain.UniqueStarGift,
date int,
) ([]domain.EditedMessageForUser, error) {
if s == nil || s.messages == nil || source.Owner.Type != domain.PeerTypeUser || source.Owner.ID <= 0 ||
source.ID <= 0 || source.UniqueGiftID <= 0 || current.ID != source.UniqueGiftID || date <= 0 {
return nil, domain.ErrStarGiftTransferUnavailable
}
messageIDs := map[int]struct{}{}
if source.MsgID > 0 {
messageIDs[source.MsgID] = struct{}{}
}
if source.UpgradeMsgID > 0 {
messageIDs[source.UpgradeMsgID] = struct{}{}
}
rows, err := tx.Query(ctx, `
SELECT msg_id FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND saved_gift_id=$2
ORDER BY msg_id`, source.Owner.ID, source.ID)
if err != nil {
return nil, fmt.Errorf("list star gift message projections: %w", err)
}
for rows.Next() {
var msgID int
if err := rows.Scan(&msgID); err != nil {
rows.Close()
return nil, fmt.Errorf("scan star gift message projection: %w", err)
}
if msgID > 0 {
messageIDs[msgID] = struct{}{}
}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("iterate star gift message projections: %w", err)
}
rows.Close()
ids := make([]int, 0, len(messageIDs))
for msgID := range messageIDs {
ids = append(ids, msgID)
}
sort.Ints(ids)
q := sqlcgen.New(tx)
edits := make([]domain.EditedMessageForUser, 0, len(ids)*2)
seenPrivateMessages := make(map[string]struct{}, len(ids))
for _, msgID := range ids {
var peerType string
var peerID int64
err := tx.QueryRow(ctx, `
SELECT peer_type,peer_id FROM message_boxes
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted
FOR UPDATE`, source.Owner.ID, msgID).Scan(&peerType, &peerID)
if errors.Is(err, pgx.ErrNoRows) {
continue
}
if err != nil {
return nil, fmt.Errorf("lock star gift message projection: %w", err)
}
if peerType != string(domain.PeerTypeUser) || peerID <= 0 {
return nil, fmt.Errorf("star gift message projection %d is not private", msgID)
}
target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{
OwnerUserID: source.Owner.ID, BoxID: int32(msgID), PeerType: peerType, PeerID: peerID,
})
if err != nil {
return nil, fmt.Errorf("load star gift message projection: %w", err)
}
logicalKey := fmt.Sprintf("%d:%d", target.MessageSenderID, target.PrivateMessageID)
if _, duplicate := seenPrivateMessages[logicalKey]; duplicate {
continue
}
seenPrivateMessages[logicalKey] = struct{}{}
boxes, err := q.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
OwnerUserIds: privateMessageOwnerIDs(source.Owner.ID, peerID),
MessageSenderID: target.MessageSenderID, PrivateMessageID: target.PrivateMessageID,
})
if err != nil {
return nil, fmt.Errorf("list visible star gift message projections: %w", err)
}
var privateMediaJSON []byte
matched := false
for _, box := range boxes {
media, err := decodeMessageMedia(box.MediaJson)
if err != nil {
return nil, fmt.Errorf("decode star gift message projection: %w", err)
}
if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique || media.ServiceAction.StarGiftUnique == nil ||
media.ServiceAction.StarGiftUnique.Gift.ID != current.ID {
continue
}
matched = true
action := media.ServiceAction.StarGiftUnique
retiredGift := current
retiredGift.CraftChancePermille = 0
retiredGift.ResellAmount = nil
action.Gift = retiredGift
action.Peer = domain.Peer{}
action.SavedID = 0
action.Saved = false
if validLifecyclePeer(current.Owner) && current.Owner != source.Owner {
action.Transferred = true
}
action.CanExportAt = 0
action.TransferStars = 0
action.ResaleAmount = nil
action.CanTransferAt = 0
action.CanResellAt = 0
action.DropOriginalDetailsStars = 0
action.CanCraftAt = 0
mediaJSON, err := encodeMessageMedia(media)
if err != nil {
return nil, fmt.Errorf("encode retired star gift projection: %w", err)
}
pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID)
if err != nil {
return nil, fmt.Errorf("allocate retired star gift pts: %w", err)
}
tag, err := tx.Exec(ctx, `
UPDATE message_boxes SET media=$3,pts=$4
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxID, mediaJSON, int32(pts))
if err != nil {
return nil, fmt.Errorf("update retired star gift projection: %w", err)
}
if tag.RowsAffected() != 1 {
return nil, fmt.Errorf("update retired star gift projection lost row")
}
msg, err := messageFromVisibleBoxRow(box)
if err != nil {
return nil, err
}
msg.Media = media
msg.Pts = pts
if err := replaceMessageBoxMediaIndexTx(ctx, tx, msg.OwnerUserID, msg.Peer.ID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil {
return nil, err
}
event := domain.UpdateEvent{UserID: msg.OwnerUserID, Type: domain.UpdateEventEditMessage,
Pts: pts, PtsCount: 1, Date: date, Message: msg}
if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil {
return nil, fmt.Errorf("append retired star gift edit event: %w", err)
}
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: msg.OwnerUserID, Pts: int32(pts), EventType: string(domain.UpdateEventEditMessage),
ExcludeAuthKeyID: 0, ExcludeSessionID: 0,
}); err != nil {
return nil, fmt.Errorf("enqueue retired star gift edit: %w", err)
}
if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 {
privateMediaJSON, err = encodeSharedPrivateStarGiftMedia(media)
if err != nil {
return nil, err
}
}
edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event})
}
if !matched {
continue
}
if len(privateMediaJSON) == 0 {
return nil, fmt.Errorf("retired star gift projection missing shared media")
}
if _, err := tx.Exec(ctx, `
UPDATE private_messages SET media=$3
WHERE sender_user_id=$1 AND id=$2`, target.MessageSenderID, target.PrivateMessageID, privateMediaJSON); err != nil {
return nil, fmt.Errorf("update retired star gift private media: %w", err)
}
}
return edits, nil
}

View file

@ -1,165 +0,0 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestProjectPrivateStarGiftPurchaseScopesViewerCapabilities(t *testing.T) {
media := &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{
PeerUserID: 200, To: domain.Peer{Type: domain.PeerTypeUser, ID: 200},
CanUpgrade: true, PrepaidUpgradeHash: "prepaid-upgrade-hash-0123456789",
},
},
}
req := &domain.SendPrivateTextRequest{SenderUserID: 100, RecipientUserID: 200, Media: media}
projection, err := projectPrivateStarGiftPurchase(context.Background(), nil, req)
if err != nil {
t.Fatalf("project purchase: %v", err)
}
shared := privateStarGiftAction(projection.Shared)
sender := privateStarGiftAction(projection.Sender)
recipient := privateStarGiftAction(projection.Recipient)
if shared == nil || shared.CanUpgrade || shared.PrepaidUpgradeHash != "" {
t.Fatalf("shared projection retained viewer capability: %+v", shared)
}
if sender == nil || sender.CanUpgrade || sender.PrepaidUpgradeHash == "" {
t.Fatalf("sender projection = %+v, want hash without can_upgrade", sender)
}
if recipient == nil || !recipient.CanUpgrade || recipient.PrepaidUpgradeHash != "" {
t.Fatalf("recipient projection = %+v, want can_upgrade without hash", recipient)
}
if original := privateStarGiftAction(media); original == nil || !original.CanUpgrade || original.PrepaidUpgradeHash == "" {
t.Fatalf("source projection was mutated: %+v", original)
}
selfReq := &domain.SendPrivateTextRequest{SenderUserID: 200, RecipientUserID: 200, Media: media}
selfProjection, err := projectPrivateStarGiftPurchase(context.Background(), nil, selfReq)
if err != nil {
t.Fatalf("project self purchase: %v", err)
}
self := privateStarGiftAction(selfProjection.Sender)
if self == nil || !self.CanUpgrade || self.PrepaidUpgradeHash != "" {
t.Fatalf("self-owner projection = %+v, want can_upgrade without hash", self)
}
bad := *req
bad.RecipientUserID = 300
if _, err := projectPrivateStarGiftPurchase(context.Background(), nil, &bad); err == nil {
t.Fatal("mismatched gift owner and recipient was accepted")
}
}
func TestTransferUniqueActionSavedIDNamespace(t *testing.T) {
saved := domain.SavedStarGift{SavedID: 42, CanCraftAt: 1_780_000_123}
unique := domain.UniqueStarGift{ID: 7}
user := domain.Peer{Type: domain.PeerTypeUser, ID: 100}
channel := domain.Peer{Type: domain.PeerTypeChannel, ID: 200}
if action := transferUniqueAction(unique, 1, user, saved); action.SavedID != 0 || action.CanCraftAt != saved.CanCraftAt {
t.Fatalf("user transfer action leaked channel saved_id: %+v", action)
}
if action := transferUniqueAction(unique, 1, channel, saved); action.SavedID != saved.SavedID || action.CanCraftAt != 0 {
t.Fatalf("channel transfer action lost channel saved_id: %+v", action)
}
}
func TestStarGiftCraftReadyAt(t *testing.T) {
const date = 1_780_000_000
if got := starGiftCraftReadyAt(date, 0); got != date {
t.Fatalf("zero-delay craft ready_at = %d, want %d", got, date)
}
if got := starGiftCraftReadyAt(date, 60); got != date+60 {
t.Fatalf("delayed craft ready_at = %d, want %d", got, date+60)
}
if got := starGiftCraftReadyAt(0, 0); got != 0 {
t.Fatalf("invalid-date craft ready_at = %d, want 0", got)
}
if got := starGiftCraftReadyAt(1<<31-10, 60); got != 1<<31-1 {
t.Fatalf("overflow craft ready_at = %d, want max int32", got)
}
if got := starGiftCraftReadyAt(1<<31+10, 0); got != 1<<31-1 {
t.Fatalf("oversized-date craft ready_at = %d, want max int32", got)
}
}
func TestEncodeSharedPrivateStarGiftMediaOmitsUserBoxLocalRefs(t *testing.T) {
ordinary := &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{
PeerUserID: 9,
SavedID: 10, GiftMsgID: 11, UpgradeMsgID: 12,
},
},
}
encoded, err := encodeSharedPrivateStarGiftMedia(ordinary)
if err != nil {
t.Fatalf("encode ordinary shared projection: %v", err)
}
sharedOrdinary, err := decodeMessageMedia(string(encoded))
if err != nil {
t.Fatalf("decode ordinary shared projection: %v", err)
}
ordinaryAction := sharedOrdinary.ServiceAction.StarGift
if ordinaryAction.SavedID != 0 || ordinaryAction.GiftMsgID != 0 || ordinaryAction.UpgradeMsgID != 0 {
t.Fatalf("ordinary shared projection retained box-local refs: %+v", ordinaryAction)
}
if original := ordinary.ServiceAction.StarGift; original.SavedID != 10 || original.GiftMsgID != 11 || original.UpgradeMsgID != 12 {
t.Fatalf("ordinary source projection was mutated: %+v", original)
}
unique := &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 9}, SavedID: 13,
},
},
}
encoded, err = encodeSharedPrivateStarGiftMedia(unique)
if err != nil {
t.Fatalf("encode unique shared projection: %v", err)
}
sharedUnique, err := decodeMessageMedia(string(encoded))
if err != nil {
t.Fatalf("decode unique shared projection: %v", err)
}
if action := sharedUnique.ServiceAction.StarGiftUnique; action.SavedID != 0 {
t.Fatalf("unique shared projection retained user saved_id: %+v", action)
}
if unique.ServiceAction.StarGiftUnique.SavedID != 13 {
t.Fatalf("unique source projection was mutated: %+v", unique.ServiceAction.StarGiftUnique)
}
}
func TestEncodeSharedPrivateStarGiftMediaPreservesChannelSavedID(t *testing.T) {
media := &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 9}, SavedID: 14,
},
},
}
encoded, err := encodeSharedPrivateStarGiftMedia(media)
if err != nil {
t.Fatalf("encode channel shared projection: %v", err)
}
shared, err := decodeMessageMedia(string(encoded))
if err != nil {
t.Fatalf("decode channel shared projection: %v", err)
}
if action := shared.ServiceAction.StarGiftUnique; action.SavedID != 14 {
t.Fatalf("channel shared projection lost saved_id: %+v", action)
}
}

View file

@ -1,113 +0,0 @@
package postgres
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
)
func TestOfficialStarGiftBundleIsAtomicPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
store := NewStarGiftStore(pool)
baseID := time.Now().UnixNano() & 0x7ffffffffffff000
manifestSHA := make([]byte, 32)
for i := range manifestSHA {
manifestSHA[i] = 0x5a
}
attribute := func(kind domain.StarGiftCollectibleAttributeKind, id int64, name string) domain.StarGiftCollectibleAttribute {
value := domain.StarGiftCollectibleAttribute{Kind: kind, Name: name, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 918}
if kind == domain.StarGiftCollectibleBackdrop {
value.BackdropID = int(id)
value.CenterColor, value.EdgeColor, value.PatternColor, value.TextColor = 1, 2, 3, 4
return value
}
if kind == domain.StarGiftCollectiblePattern {
value.Document = collectibleTestPatternDocumentPtr(id, name+".tgs")
} else {
value.Document = collectibleTestDocumentPtr(id, name+".tgs")
}
value.Blob = collectibleTestBlobPtr(id, name)
value.Animation = collectibleTestAnimationPtr(name + ".tgs")
value.OfficialDocumentID = 5200000000000000000 + id%1000
return value
}
bundle := domain.StarGiftCatalogBundleWrite{
Catalog: domain.StarGiftCatalogWrite{
Title: "Official", Stars: 50, ConvertStars: 25, Enabled: true,
Document: collectibleTestDocument(baseID, "official.tgs"), Blob: collectibleTestBlob(baseID, "official"),
Animation: collectibleTestAnimation("official.tgs"), Actor: "integration", CommandID: "official-catalog-" + suffix,
OfficialGiftID: 5170145012310081615, SourceManifestSHA256: manifestSHA,
OfficialSourceJSON: []byte(`{"id":5170145012310081615,"sold_out":true,"birthday":false}`),
},
Collectible: &domain.StarGiftCollectibleWrite{
UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "official-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{
attribute(domain.StarGiftCollectibleModel, baseID+1, "model"),
attribute(domain.StarGiftCollectibleModel, baseID+3, "model-two"),
},
Patterns: []domain.StarGiftCollectibleAttribute{
attribute(domain.StarGiftCollectiblePattern, baseID+2, "pattern"),
attribute(domain.StarGiftCollectiblePattern, baseID+4, "pattern-two"),
},
Backdrops: []domain.StarGiftCollectibleAttribute{
attribute(domain.StarGiftCollectibleBackdrop, 0, "backdrop"),
attribute(domain.StarGiftCollectibleBackdrop, 1, "backdrop-two"),
},
Actor: "integration", CommandID: "official-pool-" + suffix,
OfficialGiftID: 5170145012310081615, SourceManifestSHA256: manifestSHA,
},
}
result, err := store.CreateCatalogBundle(ctx, bundle)
if err != nil {
t.Fatalf("create official bundle: %v", err)
}
if result.Catalog.Gift.ID == 0 || result.Collectible == nil || result.Catalog.Gift.UpgradeStars != 100 {
t.Fatalf("bundle result = %+v", result)
}
var sourceID int64
var soldOut bool
if err := pool.QueryRow(ctx, `
SELECT official_gift_id, (official_source->>'sold_out')::boolean
FROM star_gift_catalog_revisions WHERE id=$1`, result.Catalog.Gift.RevisionID).Scan(&sourceID, &soldOut); err != nil {
t.Fatal(err)
}
if sourceID != 5170145012310081615 || !soldOut {
t.Fatalf("source id=%d sold_out=%v", sourceID, soldOut)
}
failing := bundle
failing.Catalog.CommandID = "official-rollback-" + suffix
failing.Catalog.Document = collectibleTestDocument(baseID+100, "rollback.tgs")
failing.Catalog.Blob = collectibleTestBlob(baseID+100, "rollback")
failing.Collectible = &domain.StarGiftCollectibleWrite{
UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "rollback-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{
attribute(domain.StarGiftCollectibleModel, baseID+101, "duplicate"),
attribute(domain.StarGiftCollectibleModel, baseID+102, "duplicate"),
},
Patterns: []domain.StarGiftCollectibleAttribute{
attribute(domain.StarGiftCollectiblePattern, baseID+103, "pattern"),
attribute(domain.StarGiftCollectiblePattern, baseID+104, "pattern-two"),
},
Backdrops: []domain.StarGiftCollectibleAttribute{
attribute(domain.StarGiftCollectibleBackdrop, 0, "backdrop"),
attribute(domain.StarGiftCollectibleBackdrop, 1, "backdrop-two"),
},
Actor: "integration", CommandID: "rollback-pool-" + suffix,
}
if _, err := store.CreateCatalogBundle(ctx, failing); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
t.Fatalf("failing bundle err=%v", err)
}
var rows int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM star_gift_catalog_revisions WHERE command_id=$1`, failing.Catalog.CommandID).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 0 {
t.Fatalf("failed bundle left %d catalog revisions", rows)
}
}

View file

@ -1,199 +0,0 @@
package postgres
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
// projectPrivateStarGiftSourceRef exposes a user-owned gift's stable source
// message identity only in the gift owner's message-box projection. Telegram
// defines gift_msg_id as receiver-only. A non-owner counterpart box id is not
// a valid substitute: it could resolve to an unrelated gift owned by that
// viewer. User unique actions do not use channel-only peer/saved_id fields;
// their owner-scoped message ids are registered separately at write time.
func projectPrivateStarGiftSourceRef(
_ context.Context,
_ pgx.Tx,
req *domain.SendPrivateTextRequest,
sourceOwnerUserID int64,
sourceOwnerBoxID int,
) (privateSendMediaProjection, error) {
if req == nil || req.Media == nil || sourceOwnerUserID <= 0 || sourceOwnerBoxID <= 0 ||
(sourceOwnerUserID != req.SenderUserID && sourceOwnerUserID != req.RecipientUserID) {
return privateSendMediaProjection{}, fmt.Errorf("project private star gift source: invalid scope")
}
shared, err := cloneMessageMedia(req.Media)
if err != nil {
return privateSendMediaProjection{}, err
}
sender, err := cloneMessageMedia(req.Media)
if err != nil {
return privateSendMediaProjection{}, err
}
recipient, err := cloneMessageMedia(req.Media)
if err != nil {
return privateSendMediaProjection{}, err
}
switch {
case privateStarGiftAction(shared) != nil:
sharedAction := privateStarGiftAction(shared)
senderAction := privateStarGiftAction(sender)
recipientAction := privateStarGiftAction(recipient)
if sharedAction.GiftMsgID != sourceOwnerBoxID {
return privateSendMediaProjection{}, fmt.Errorf(
"project private star gift source: gift_msg_id %d does not match owner box %d",
sharedAction.GiftMsgID, sourceOwnerBoxID,
)
}
sharedAction.GiftMsgID = 0
senderAction.GiftMsgID = 0
recipientAction.GiftMsgID = 0
// messageActionStarGift.can_upgrade is receiver-only. A separate
// prepayment notification shares one logical private message, but its
// payer box must not advertise owner actions.
sharedAction.CanUpgrade = false
senderAction.CanUpgrade = req.SenderUserID == sourceOwnerUserID && senderAction.CanUpgrade
recipientAction.CanUpgrade = req.RecipientUserID == sourceOwnerUserID && recipientAction.CanUpgrade
if req.SenderUserID == sourceOwnerUserID {
senderAction.GiftMsgID = sourceOwnerBoxID
} else {
recipientAction.GiftMsgID = sourceOwnerBoxID
}
default:
return privateSendMediaProjection{}, fmt.Errorf("project private star gift source: unsupported media")
}
return privateSendMediaProjection{Shared: shared, Sender: sender, Recipient: recipient}, nil
}
// projectPrivateStarGiftPurchase scopes the two mutually exclusive actions of
// an ordinary user gift to the correct account-local message box:
// - the gift owner/receiver may upgrade it and must not receive the separate
// prepayment hash;
// - the non-owner sender may use the hash to prepay for the owner's upgrade,
// but must not receive the receiver-only can_upgrade capability.
//
// The shared logical envelope carries neither viewer-only field. This keeps a
// future read/replay path from accidentally treating it as either participant's
// projection while the two message_boxes remain the durable wire truth.
func projectPrivateStarGiftPurchase(
_ context.Context,
_ pgx.Tx,
req *domain.SendPrivateTextRequest,
) (privateSendMediaProjection, error) {
if req == nil || req.Media == nil || req.SenderUserID <= 0 || req.RecipientUserID <= 0 {
return privateSendMediaProjection{}, fmt.Errorf("project private star gift purchase: invalid scope")
}
action := privateStarGiftAction(req.Media)
if action == nil {
return privateSendMediaProjection{}, fmt.Errorf("project private star gift purchase: unsupported media")
}
ownerUserID := action.PeerUserID
if ownerUserID == 0 && action.To.Type == domain.PeerTypeUser {
ownerUserID = action.To.ID
}
if ownerUserID <= 0 || ownerUserID != req.RecipientUserID {
return privateSendMediaProjection{}, fmt.Errorf(
"project private star gift purchase: owner %d does not match recipient %d",
ownerUserID, req.RecipientUserID,
)
}
shared, err := cloneMessageMedia(req.Media)
if err != nil {
return privateSendMediaProjection{}, err
}
sender, err := cloneMessageMedia(req.Media)
if err != nil {
return privateSendMediaProjection{}, err
}
recipient, err := cloneMessageMedia(req.Media)
if err != nil {
return privateSendMediaProjection{}, err
}
sharedAction := privateStarGiftAction(shared)
senderAction := privateStarGiftAction(sender)
recipientAction := privateStarGiftAction(recipient)
sharedAction.PrepaidUpgradeHash = ""
sharedAction.CanUpgrade = false
if req.SenderUserID == ownerUserID {
senderAction.PrepaidUpgradeHash = ""
} else {
senderAction.CanUpgrade = false
}
recipientAction.PrepaidUpgradeHash = ""
return privateSendMediaProjection{Shared: shared, Sender: sender, Recipient: recipient}, nil
}
func cloneMessageMedia(media *domain.MessageMedia) (*domain.MessageMedia, error) {
encoded, err := encodeMessageMedia(media)
if err != nil {
return nil, fmt.Errorf("clone private message media: %w", err)
}
cloned, err := decodeMessageMedia(string(encoded))
if err != nil {
return nil, fmt.Errorf("clone private message media: %w", err)
}
return cloned, nil
}
// encodeSharedPrivateStarGiftMedia returns the logical private-message
// envelope for an already viewpoint-projected Star Gift service message.
// Conversation message ids belong to a single owner's message_boxes
// namespace, so the shared row must never retain them. saved_id is likewise
// box-local for user gifts, while channel saved ids remain globally meaningful
// inside the channel gift namespace.
func encodeSharedPrivateStarGiftMedia(media *domain.MessageMedia) ([]byte, error) {
shared, err := cloneMessageMedia(media)
if err != nil {
return nil, err
}
switch {
case privateStarGiftAction(shared) != nil:
action := privateStarGiftAction(shared)
action.GiftMsgID = 0
action.UpgradeMsgID = 0
if action.PeerUserID > 0 || action.To.Type == domain.PeerTypeUser {
action.SavedID = 0
action.PrepaidUpgradeHash = ""
action.CanUpgrade = false
}
case privateStarGiftUniqueAction(shared) != nil:
action := privateStarGiftUniqueAction(shared)
if action.Peer.Type == domain.PeerTypeUser {
action.SavedID = 0
}
default:
return nil, fmt.Errorf("encode shared private star gift media: unsupported media")
}
encoded, err := encodeMessageMedia(shared)
if err != nil {
return nil, fmt.Errorf("encode shared private star gift media: %w", err)
}
return encoded, nil
}
func privateStarGiftAction(media *domain.MessageMedia) *domain.MessageStarGiftAction {
if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
media.ServiceAction.Kind != domain.MessageServiceActionStarGift {
return nil
}
return media.ServiceAction.StarGift
}
func privateStarGiftUniqueAction(media *domain.MessageMedia) *domain.MessageStarGiftUniqueAction {
if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique {
return nil
}
return media.ServiceAction.StarGiftUnique
}

View file

@ -1,351 +0,0 @@
package postgres
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
func (s *StarGiftLifecycleStore) IssueStarGiftPurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error) {
if s == nil || s.db == nil || form.FormID != 0 || form.BuyerUserID <= 0 || !validLifecyclePeer(form.To) ||
form.GiftID <= 0 || form.RevisionID <= 0 || form.ChargeStars <= 0 || form.IssuedAt <= 0 ||
form.ExpiresAt != form.IssuedAt+600 || len([]rune(form.Message)) > 128 {
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftFormPurposeInvalid
}
for attempt := 0; attempt < 8; attempt++ {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {
return domain.StarGiftPurchaseForm{}, fmt.Errorf("generate star gift form id: %w", err)
}
form.FormID = int64(binary.LittleEndian.Uint64(raw[:]) & 0x7fffffffffffffff)
if form.FormID == 0 {
form.FormID = 1
}
_, err := s.db.Exec(ctx, `INSERT INTO star_gift_purchase_forms(buyer_user_id,form_id,gift_id,revision_id,
recipient_peer_type,recipient_peer_id,include_upgrade,hide_name,message,charge_stars,issued_at,expires_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, form.BuyerUserID, form.FormID, form.GiftID, form.RevisionID,
string(form.To.Type), form.To.ID, form.IncludeUpgrade, form.HideName, form.Message, form.ChargeStars, form.IssuedAt, form.ExpiresAt)
if err == nil {
return form, nil
}
if !isUniqueViolation(err) {
return domain.StarGiftPurchaseForm{}, err
}
}
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable
}
func (s *StarGiftLifecycleStore) ValidateStarGiftPurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error {
if s == nil || s.db == nil {
return domain.ErrStarGiftUnavailable
}
return validateStarGiftPurchaseForm(ctx, s.db, req, false)
}
func validateStarGiftPurchaseForm(ctx context.Context, db sqlcgen.DBTX, req domain.StarGiftPurchaseRequest, lock bool) error {
if req.BuyerUserID <= 0 || req.FormID == 0 || req.Date <= 0 {
return domain.ErrStarGiftFormExpired
}
query := `SELECT gift_id,revision_id,recipient_peer_type,recipient_peer_id,include_upgrade,hide_name,message,
charge_stars,issued_at,expires_at FROM star_gift_purchase_forms WHERE buyer_user_id=$1 AND form_id=$2`
if lock {
query += ` FOR UPDATE`
}
var form domain.StarGiftPurchaseForm
var peerType string
err := db.QueryRow(ctx, query, req.BuyerUserID, req.FormID).Scan(&form.GiftID, &form.RevisionID, &peerType, &form.To.ID,
&form.IncludeUpgrade, &form.HideName, &form.Message, &form.ChargeStars, &form.IssuedAt, &form.ExpiresAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrStarGiftFormExpired
}
if err != nil {
return err
}
form.FormID, form.BuyerUserID, form.To.Type = req.FormID, req.BuyerUserID, domain.PeerType(peerType)
if form.ExpiresAt < req.Date {
return domain.ErrStarGiftFormExpired
}
if form.To != req.To || form.GiftID != req.GiftID || form.IncludeUpgrade != req.IncludeUpgrade ||
form.HideName != req.HideName || form.Message != req.Message {
return domain.ErrStarGiftFormPurposeInvalid
}
if form.RevisionID != req.RevisionID || form.ChargeStars != req.ChargeStars {
return domain.ErrStarGiftFormAmountMismatch
}
return nil
}
func (s *StarGiftLifecycleStore) PurchaseStarGift(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) {
req.CommandKey = strings.TrimSpace(req.CommandKey)
if s == nil || s.db == nil || req.BuyerUserID <= 0 || !validLifecyclePeer(req.To) || req.GiftID <= 0 ||
req.FormID == 0 || req.CommandKey == "" || len(req.CommandKey) > 256 || req.Date <= 0 || len([]rune(req.Message)) > 128 {
return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftInvalid
}
if replay, found, err := s.loadStarGiftPurchaseReplay(ctx, req, domain.SendPrivateTextResult{}); err != nil || found {
return replay, err
}
if err := s.ValidateStarGiftPurchaseForm(ctx, req); err != nil {
return domain.StarGiftPurchaseResult{}, err
}
if req.To.Type == domain.PeerTypeChannel {
return s.purchaseStarGiftToChannel(ctx, req)
}
if s.messages == nil {
return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable
}
fingerprint := starGiftPurchaseFingerprint(req)
messageReq := domain.SendPrivateTextRequest{SenderUserID: req.BuyerUserID, RecipientUserID: req.To.ID,
RandomID: lifecycleCommandRandomID("purchase", req.BuyerUserID, req.CommandKey), Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.BuyerUserID,
RecipientBlocked: req.RecipientBlocked, IdempotencyFingerprint: fingerprint[:],
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{Saved: true}}}}
var result domain.StarGiftPurchaseResult
hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
if err := validateStarGiftPurchaseForm(ctx, tx, req, true); err != nil {
return err
}
gift, saved, balance, err := s.prepareStarGiftPurchase(ctx, tx, req)
if err != nil {
return err
}
sticker := gift.Sticker
send.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift, StarGift: &domain.MessageStarGiftAction{GiftID: gift.ID,
Stars: gift.Stars, ConvertStars: saved.ConvertStars, Title: gift.Title, Sticker: &sticker, Message: req.Message,
FromUserID: req.BuyerUserID, PeerUserID: req.To.ID, To: req.To, NameHidden: req.HideName, Saved: true,
CanUpgrade: gift.UpgradeStars > 0, PrepaidUpgrade: saved.PrepaidUpgradeStars > 0,
PrepaidUpgradeHash: saved.PrepaidUpgradeHash, UpgradePriceStars: gift.UpgradeStars,
UpgradeStars: saved.PrepaidUpgradeStars}}}
result.Gift, result.Saved, result.Balance = gift, saved, balance
return nil
},
projectMedia: projectPrivateStarGiftPurchase,
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
msgID := sent.RecipientMessage.ID
if msgID <= 0 {
msgID = sent.SenderMessage.ID
}
result.Saved.MsgID = msgID
id, err := NewStarGiftStore(tx).Create(ctx, result.Saved)
if err != nil {
return err
}
result.Saved.ID = id
return s.insertStarGiftPurchaseCommand(ctx, tx, req, result.Saved.ID, result.Gift.Stars+result.Saved.PrepaidUpgradeStars, result.Balance.Balance)
},
}
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
if err != nil {
if isUniqueViolation(err) {
if replay, found, replayErr := s.loadStarGiftPurchaseReplay(ctx, req, sent); replayErr != nil || found {
return replay, replayErr
}
}
return domain.StarGiftPurchaseResult{}, err
}
result.Send, result.Duplicate = sent, sent.Duplicate
if sent.Duplicate {
replay, _, replayErr := s.loadStarGiftPurchaseReplay(ctx, req, sent)
return replay, replayErr
}
return result, nil
}
func (s *StarGiftLifecycleStore) purchaseStarGiftToChannel(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) {
var result domain.StarGiftPurchaseResult
err := withTx(ctx, s.db, "purchase star gift for channel", func(tx pgx.Tx) error {
if err := validateStarGiftPurchaseForm(ctx, tx, req, true); err != nil {
return err
}
gift, saved, balance, err := s.prepareStarGiftPurchase(ctx, tx, req)
if err != nil {
return err
}
id, err := NewStarGiftStore(tx).Create(ctx, saved)
if err != nil {
return err
}
saved.ID, saved.SavedID = id, id
sticker := gift.Sticker
action := domain.ChannelMessageAction{Type: domain.ChannelActionStarGift, StarGift: &domain.MessageStarGiftAction{
GiftID: gift.ID, Stars: gift.Stars, ConvertStars: saved.ConvertStars, Title: gift.Title,
Sticker: &sticker, Message: saved.Message, FromUserID: req.BuyerUserID, PeerChannelID: req.To.ID,
SavedID: id, NameHidden: saved.NameHidden, Saved: true, CanUpgrade: gift.UpgradeStars > 0,
PrepaidUpgrade: saved.PrepaidUpgradeStars > 0, PrepaidUpgradeHash: saved.PrepaidUpgradeHash,
UpgradePriceStars: gift.UpgradeStars, UpgradeStars: saved.PrepaidUpgradeStars,
}}
if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, req.To.ID, req.BuyerUserID, id, req.Date, action); err != nil {
return err
}
if err := enqueueChannelStarGiftNotifications(ctx, tx, id, req.To.ID, req.Date, action.StarGift); err != nil {
return err
}
if err := s.insertStarGiftPurchaseCommand(ctx, tx, req, id, gift.Stars+saved.PrepaidUpgradeStars, balance.Balance); err != nil {
return err
}
result = domain.StarGiftPurchaseResult{Gift: gift, Saved: saved, Balance: balance}
return nil
})
if err != nil {
if isUniqueViolation(err) {
if replay, found, replayErr := s.loadStarGiftPurchaseReplay(ctx, req, domain.SendPrivateTextResult{}); replayErr != nil || found {
return replay, replayErr
}
}
return domain.StarGiftPurchaseResult{}, err
}
// The purchase remains successful once its transaction has committed. Any
// immediate delivery failure leaves a durable job for the lifecycle sweeper.
_, _ = s.dispatchChannelStarGiftNotifications(ctx, req.Date, maxChannelStarGiftNotificationRecipients, result.Saved.ID)
return result, nil
}
func (s *StarGiftLifecycleStore) prepareStarGiftPurchase(ctx context.Context, tx pgx.Tx, req domain.StarGiftPurchaseRequest) (domain.StarGift, domain.SavedStarGift, domain.StarsBalance, error) {
var revisionID int64
var enabled bool
var remains int
if err := tx.QueryRow(ctx, `SELECT active_revision_id,enabled,availability_remains FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, req.GiftID).
Scan(&revisionID, &enabled, &remains); err != nil {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftInvalid
}
gift, found, err := NewStarGiftStore(tx).CatalogRevision(ctx, revisionID)
if err != nil || !found || !enabled || gift.ID != req.GiftID || gift.SoldOut || gift.Auction || gift.LockedUntilDate > req.Date ||
gift.Limited && remains <= 0 {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftInvalid
}
if gift.RevisionID != req.RevisionID {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftFormAmountMismatch
}
if gift.RequirePremium && !req.BuyerPremium {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrPremiumRequired
}
gift.AvailabilityRemains = remains
upgradePrice := int64(0)
prepayHash := ""
if gift.UpgradeStars > 0 || req.IncludeUpgrade {
revision, err := lockActiveCollectibleRevision(ctx, tx, gift.ID)
if err != nil || revision.Issued >= revision.SupplyTotal {
if req.IncludeUpgrade {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftCollectibleUnavailable
}
} else if req.IncludeUpgrade {
upgradePrice = revision.UpgradeStars
} else {
var token [32]byte
if _, err := rand.Read(token[:]); err != nil {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err
}
prepayHash = base64.RawURLEncoding.EncodeToString(token[:])
}
}
if req.IncludeUpgrade && upgradePrice <= 0 {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftCollectibleUnavailable
}
if gift.Stars+upgradePrice != req.ChargeStars {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftFormAmountMismatch
}
var purchased int
if err := tx.QueryRow(ctx, `INSERT INTO star_gift_user_purchases(user_id,gift_id,purchased_count) VALUES($1,$2,1)
ON CONFLICT(user_id,gift_id) DO UPDATE SET purchased_count=star_gift_user_purchases.purchased_count+1,updated_at=now()
WHERE NOT $3 OR star_gift_user_purchases.purchased_count<$4 RETURNING purchased_count`, req.BuyerUserID, gift.ID,
gift.LimitedPerUser, gift.PerUserTotal).Scan(&purchased); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftUnavailable
}
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err
}
if gift.Limited {
if tag, err := tx.Exec(ctx, `UPDATE star_gift_catalog SET availability_remains=availability_remains-1,
first_sale_date=CASE WHEN first_sale_date=0 THEN $2 ELSE first_sale_date END,last_sale_date=$2,updated_at=now()
WHERE gift_id=$1 AND availability_remains>0`, gift.ID, req.Date); err != nil || tag.RowsAffected() != 1 {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, domain.ErrStarGiftUnavailable
}
} else if _, err := tx.Exec(ctx, `UPDATE star_gift_catalog SET first_sale_date=CASE WHEN first_sale_date=0 THEN $2 ELSE first_sale_date END,
last_sale_date=$2,updated_at=now() WHERE gift_id=$1`, gift.ID, req.Date); err != nil {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err
}
charge := gift.Stars + upgradePrice
balance, err := s.debitLifecycleAmount(ctx, tx, req.BuyerUserID,
domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: charge}, domain.StarsReasonGift,
req.To, req.Date, "Star gift")
if err != nil {
return domain.StarGift{}, domain.SavedStarGift{}, domain.StarsBalance{}, err
}
saved := domain.SavedStarGift{Owner: req.To, FromUserID: req.BuyerUserID, GiftID: gift.ID, RevisionID: gift.RevisionID,
Date: req.Date, NameHidden: req.HideName, ConvertStars: gift.ConvertStars, PrepaidUpgradeStars: upgradePrice,
PrepaidUpgradeHash: prepayHash, Message: req.Message, Unsaved: req.RecipientUnsaved}
return gift, saved, balance, nil
}
func (s *StarGiftLifecycleStore) insertStarGiftPurchaseCommand(ctx context.Context, tx pgx.Tx, req domain.StarGiftPurchaseRequest, savedID, charge, balance int64) error {
_, err := tx.Exec(ctx, `INSERT INTO star_gift_purchase_commands(buyer_user_id,command_key,gift_id,recipient_peer_type,
recipient_peer_id,saved_gift_id,form_id,charge_stars,balance_after,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.BuyerUserID, req.CommandKey, req.GiftID, string(req.To.Type), req.To.ID,
savedID, req.FormID, charge, balance, req.Date)
return err
}
func (s *StarGiftLifecycleStore) loadStarGiftPurchaseReplay(ctx context.Context, req domain.StarGiftPurchaseRequest, sent domain.SendPrivateTextResult) (domain.StarGiftPurchaseResult, bool, error) {
var giftID, recipientID, savedID, formID, charge, balance int64
var recipientType string
err := s.db.QueryRow(ctx, `SELECT gift_id,recipient_peer_type,recipient_peer_id,saved_gift_id,form_id,charge_stars,balance_after
FROM star_gift_purchase_commands WHERE buyer_user_id=$1 AND command_key=$2`, req.BuyerUserID, req.CommandKey).
Scan(&giftID, &recipientType, &recipientID, &savedID, &formID, &charge, &balance)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftPurchaseResult{}, false, nil
}
if err != nil {
return domain.StarGiftPurchaseResult{}, false, err
}
if giftID != req.GiftID || recipientType != string(req.To.Type) || recipientID != req.To.ID || formID != req.FormID || charge <= 0 {
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid
}
saved, found, err := savedStarGiftByID(ctx, s.db, savedID)
if err != nil || !found {
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid
}
if saved.Owner != req.To || saved.GiftID != req.GiftID || saved.NameHidden != req.HideName || saved.Message != req.Message ||
(saved.PrepaidUpgradeStars > 0) != req.IncludeUpgrade {
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid
}
gift, found, err := NewStarGiftStore(s.db).CatalogRevision(ctx, saved.RevisionID)
if err != nil || !found {
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid
}
if req.To.Type == domain.PeerTypeUser && sent.SenderMessage.ID == 0 {
if s.messages == nil {
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftUnavailable
}
fingerprint := starGiftPurchaseFingerprint(req)
replay, replayFound, replayErr := s.messages.LookupPrivateSendReplay(ctx, domain.PrivateSendReplayRequest{
SenderUserID: req.BuyerUserID, RecipientUserID: req.To.ID,
RandomID: lifecycleCommandRandomID("purchase", req.BuyerUserID, req.CommandKey), IdempotencyFingerprint: fingerprint[:],
})
if replayErr != nil || !replayFound {
if replayErr != nil {
return domain.StarGiftPurchaseResult{}, false, replayErr
}
return domain.StarGiftPurchaseResult{}, false, domain.ErrStarGiftInvalid
}
sent = replay
}
return domain.StarGiftPurchaseResult{Gift: gift, Saved: saved, Balance: domain.StarsBalance{UserID: req.BuyerUserID, Balance: balance},
Send: sent, Duplicate: true}, true, nil
}
func starGiftPurchaseFingerprint(req domain.StarGiftPurchaseRequest) [32]byte {
return sha256.Sum256([]byte(fmt.Sprintf("telesrv:star-gift-purchase:v1:%d:%s:%d:%d:%t:%t:%s",
req.BuyerUserID, req.To.Type, req.To.ID, req.GiftID, req.IncludeUpgrade, req.HideName, req.Message)))
}

View file

@ -1,266 +0,0 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
// TestStarGiftResaleClearsSellerProfileStatePostgres pins the full seller-side
// teardown of a resale: a collectible that was worn as an emoji status and
// pinned to the profile must, the moment somebody buys it, stop being worn, stop
// being pinned, leave the seller's saved-gift list, and produce the three
// durable seller-visible updates the clients need to converge on that
// (user_emoji_status for the cleared status, new_message for the sale card,
// edit_message for the retired older card).
//
// This is the "my sold gift is still on my profile" report: everything below is
// server state and server push, so a client still showing the gift after this
// test passes is showing its own cache, not our state.
func TestStarGiftResaleClearsSellerProfileStatePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
now := int(time.Now().Unix())
users := NewUserStore(pool)
seller := createTestUser(t, ctx, users, "+1882"+suffix+"01", "ResaleSeller", "")
buyer := createTestUser(t, ctx, users, "+1882"+suffix+"02", "ResaleBuyer2", "")
sellerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: seller.ID}
stars := NewStarsStore(pool)
for _, u := range []domain.User{seller, buyer} {
if _, _, err := stars.EnsureGrant(ctx, u.ID, 10000, now); err != nil {
t.Fatalf("grant: %v", err)
}
}
gifts := NewStarGiftStore(pool)
base := time.Now().UnixNano() & 0x7ffffffffffff000
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "ResaleSync " + suffix, Stars: 50, ConvertStars: 20, Enabled: true,
Document: collectibleTestDocument(base, "resale-sync.tgs"),
Blob: collectibleTestBlob(base, "resale-sync"), Animation: collectibleTestAnimation("resale-sync.tgs"),
Actor: "integration", CommandID: "resale-sync-catalog-" + suffix,
})
if err != nil {
t.Fatalf("catalog: %v", err)
}
if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 20, SlugPrefix: "rsy-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleModel, Name: "Base", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(base+1, "model.tgs"), Blob: collectibleTestBlobPtr(base+1, "model"), Animation: collectibleTestAnimationPtr("model.tgs")},
{Kind: domain.StarGiftCollectibleModel, Name: "Base Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(base+4, "model-two.tgs"), Blob: collectibleTestBlobPtr(base+4, "model-two"), Animation: collectibleTestAnimationPtr("model-two.tgs")},
},
Patterns: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestPatternDocumentPtr(base+3, "pattern.tgs"), Blob: collectibleTestBlobPtr(base+3, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")},
{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestPatternDocumentPtr(base+5, "pattern-two.tgs"), Blob: collectibleTestBlobPtr(base+5, "pattern-two"), Animation: collectibleTestAnimationPtr("pattern-two.tgs")},
},
Backdrops: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Night", BackdropID: 77, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Day", BackdropID: 78, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
},
Actor: "integration", CommandID: "resale-sync-pool-" + suffix,
}); err != nil {
t.Fatalf("pool: %v", err)
}
messages := NewMessageStore(pool)
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
StarsProceedsPermille: 900, TONProceedsPermille: 900,
}))
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 500,
}))
purchase := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{
BuyerUserID: seller.ID, To: sellerPeer, GiftID: entry.Gift.ID, IncludeUpgrade: true,
CommandKey: "resale-sync-purchase-" + suffix, Date: now,
})
bought, err := lifecycle.PurchaseStarGift(ctx, purchase)
if err != nil {
t.Fatalf("purchase: %v", err)
}
upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: seller.ID, Ref: domain.SavedStarGiftRef{Owner: sellerPeer, MsgID: bought.Saved.MsgID},
RequirePrepaid: true, KeepOriginalDetails: true, CommandKey: "resale-sync-upgrade-" + suffix, Date: now + 1,
})
if err != nil {
t.Fatalf("upgrade: %v", err)
}
// Wear it.
selected, valid := domain.CollectibleEmojiStatus(upgraded.Unique)
if !valid {
t.Fatalf("cannot wear: %+v", upgraded.Unique)
}
if _, err := users.UpdateEmojiStatus(ctx, seller.ID, domain.UserEmojiStatus{
DocumentID: selected.DocumentID, Collectible: selected,
}); err != nil {
t.Fatalf("wear: %v", err)
}
// Pin it to the profile.
if err := gifts.SetPinned(ctx, sellerPeer, []int64{upgraded.Saved.ID}); err != nil {
t.Fatalf("pin: %v", err)
}
var pinnedOrder int
if err := pool.QueryRow(ctx, `SELECT pinned_order FROM peer_star_gifts WHERE id=$1`, upgraded.Saved.ID).Scan(&pinnedOrder); err != nil {
t.Fatalf("read pin: %v", err)
}
if pinnedOrder == 0 {
t.Fatalf("gift was not pinned before the sale")
}
listed, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{
ActorUserID: seller.ID, Ref: domain.SavedStarGiftRef{Owner: sellerPeer, MsgID: upgraded.Saved.MsgID},
Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 500}, Date: now + 2,
})
if err != nil {
t.Fatalf("list: %v", err)
}
// The seller's profile-visible state right before the sale.
beforePage, err := gifts.ListByOwner(ctx, sellerPeer, true, "", 20)
if err != nil {
t.Fatalf("list before: %v", err)
}
if len(beforePage.Gifts) != 1 {
t.Fatalf("seller saved gifts before the sale = %d, want 1", len(beforePage.Gifts))
}
sellerPtsBefore, err := NewUpdateEventStore(pool).MaxContiguousPts(ctx, seller.ID)
if err != nil {
t.Fatalf("pts before: %v", err)
}
resold, err := lifecycle.PurchaseResaleStarGift(ctx, domain.StarGiftResalePurchaseRequest{
BuyerUserID: buyer.ID, Slug: listed.Slug, To: domain.Peer{Type: domain.PeerTypeUser, ID: buyer.ID},
Amount: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 500}, FormID: 31001,
CommandKey: "resale-sync-resale-" + suffix, Date: now + 3,
})
if err != nil {
t.Fatalf("resale: %v", err)
}
buyerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: buyer.ID}
if resold.Unique.Owner != buyerPeer || resold.Saved.Owner != buyerPeer {
t.Fatalf("resale ownership = unique %+v saved %+v, want %+v", resold.Unique.Owner, resold.Saved.Owner, buyerPeer)
}
// The gift is no longer pinned to the seller's profile.
if err := pool.QueryRow(ctx, `SELECT pinned_order FROM peer_star_gifts WHERE id=$1`, upgraded.Saved.ID).Scan(&pinnedOrder); err != nil {
t.Fatalf("read pin after: %v", err)
}
if pinnedOrder != 0 {
t.Fatalf("sold gift is still pinned to the seller's profile: pinned_order = %d", pinnedOrder)
}
// The seller no longer wears it: the lifecycle trigger cleared the status.
var docID int64
var collectibleID *int64
if err := pool.QueryRow(ctx, `SELECT emoji_status_document_id,emoji_status_collectible_id FROM users WHERE id=$1`,
seller.ID).Scan(&docID, &collectibleID); err != nil {
t.Fatalf("read status: %v", err)
}
if docID != 0 || collectibleID != nil {
t.Fatalf("seller still wears the sold collectible: document_id=%d collectible_id=%v", docID, collectibleID)
}
// And it has left the seller's saved-gift list entirely.
afterPage, err := gifts.ListByOwner(ctx, sellerPeer, true, "", 20)
if err != nil {
t.Fatalf("list after: %v", err)
}
if len(afterPage.Gifts) != 0 {
t.Fatalf("seller still owns %d saved gifts after the sale", len(afterPage.Gifts))
}
buyerPage, err := gifts.ListByOwner(ctx, buyerPeer, true, "", 20)
if err != nil {
t.Fatalf("buyer list after: %v", err)
}
if len(buyerPage.Gifts) != 1 || buyerPage.Gifts[0].ID != upgraded.Saved.ID || buyerPage.Gifts[0].PinnedOrder != 0 {
t.Fatalf("buyer saved gifts = %+v, want exactly the unpinned sold gift", buyerPage.Gifts)
}
// Every seller-visible consequence is also a durable update the clients can
// converge on, in contiguous pts order and with no gap.
sellerPtsAfter, err := NewUpdateEventStore(pool).MaxContiguousPts(ctx, seller.ID)
if err != nil {
t.Fatalf("pts after: %v", err)
}
rows, err := pool.Query(ctx, `SELECT pts,event_type FROM user_update_events
WHERE user_id=$1 AND pts>$2 ORDER BY pts`, seller.ID, sellerPtsBefore)
if err != nil {
t.Fatalf("events: %v", err)
}
kinds := make([]string, 0, 3)
pointers := make([]int, 0, 3)
for rows.Next() {
var pts int
var kind string
if err := rows.Scan(&pts, &kind); err != nil {
rows.Close()
t.Fatal(err)
}
pointers = append(pointers, pts)
kinds = append(kinds, kind)
}
rows.Close()
if err := rows.Err(); err != nil {
t.Fatal(err)
}
want := []string{
string(domain.UpdateEventUserEmojiStatus),
string(domain.UpdateEventNewMessage),
string(domain.UpdateEventEditMessage),
}
if len(kinds) != len(want) {
t.Fatalf("seller update events = %v (pts %v), want %v", kinds, pointers, want)
}
for i, kind := range want {
if kinds[i] != kind {
t.Fatalf("seller update events = %v, want %v", kinds, want)
}
if pointers[i] != sellerPtsBefore+1+i {
t.Fatalf("seller update pts = %v, want contiguous from %d", pointers, sellerPtsBefore+1)
}
}
if sellerPtsAfter != sellerPtsBefore+len(want) {
t.Fatalf("seller contiguous pts = %d, want %d", sellerPtsAfter, sellerPtsBefore+len(want))
}
// Each of them is also queued for online dispatch, and none of them is
// suppressed by the buyer's origin session.
var dispatched int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
WHERE target_user_id=$1 AND pts>$2 AND exclude_auth_key_id=0 AND exclude_session_id=0`,
seller.ID, sellerPtsBefore).Scan(&dispatched); err != nil {
t.Fatalf("dispatch rows: %v", err)
}
if dispatched != len(want) {
t.Fatalf("seller dispatch rows = %d, want %d", dispatched, len(want))
}
// The cleared status must serialise as an explicit empty status, not as a
// dropped update: a client that never sees it keeps rendering the sold gift.
events, err := NewUpdateEventStore(pool).ListAfter(ctx, seller.ID, sellerPtsBefore, 10)
if err != nil {
t.Fatalf("read events: %v", err)
}
found := false
for _, event := range events {
if event.Type != domain.UpdateEventUserEmojiStatus {
continue
}
found = true
if event.UserID != seller.ID || !event.EmojiStatus.Empty() || !event.EmojiStatus.Valid() {
t.Fatalf("cleared emoji status event = %+v, want a valid empty status for %d", event, seller.ID)
}
}
if !found {
t.Fatalf("no user_emoji_status event in %+v", events)
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,111 +0,0 @@
package postgres
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
// registerUserStarGiftMessageRef records an owner-scoped service-message alias
// for a user-owned gift. Official clients may continue from a freshly emitted
// messageActionStarGiftUnique or a separate prepaid-upgrade notification and
// pass that message id to a lifecycle RPC, while payments.getSavedStarGifts may
// still expose the original received gift message as the aggregate's primary
// msg_id. expectedUniqueGiftID is zero for an ordinary gift and positive for a
// unique gift; the write boundary never aliases across lifecycle states.
func registerUserStarGiftMessageRef(
ctx context.Context,
tx pgx.Tx,
ownerUserID int64,
msgID int,
savedGiftID int64,
uniqueGiftID int64,
) error {
return registerViewerStarGiftMessageRef(ctx, tx, ownerUserID, msgID, savedGiftID,
domain.Peer{Type: domain.PeerTypeUser, ID: ownerUserID}, uniqueGiftID)
}
// registerViewerStarGiftMessageRef binds one viewer-local private message to
// the aggregate owner explicitly named by the action. The alias does not grant
// ownership: RPC callers resolve the real owner and authorize it again.
func registerViewerStarGiftMessageRef(
ctx context.Context,
tx pgx.Tx,
viewerUserID int64,
msgID int,
savedGiftID int64,
expectedOwner domain.Peer,
uniqueGiftID int64,
) error {
if viewerUserID <= 0 || msgID <= 0 || savedGiftID <= 0 || !validLifecyclePeer(expectedOwner) || uniqueGiftID < 0 {
return fmt.Errorf("register star gift message ref: invalid identity")
}
tag, err := tx.Exec(ctx, `
INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
SELECT $1,$2,p.id
FROM peer_star_gifts p
WHERE p.id=$3 AND p.owner_peer_type=$4 AND p.owner_peer_id=$5
AND (($6::bigint=0 AND p.unique_gift_id IS NULL) OR ($6::bigint>0 AND p.unique_gift_id=$6::bigint))
AND p.lifecycle_status='active'
ON CONFLICT(owner_user_id,msg_id) DO UPDATE
SET saved_gift_id=EXCLUDED.saved_gift_id
WHERE star_gift_user_message_refs.saved_gift_id=EXCLUDED.saved_gift_id`,
viewerUserID, msgID, savedGiftID, string(expectedOwner.Type), expectedOwner.ID, uniqueGiftID)
if err != nil {
return fmt.Errorf("register star gift message ref: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf("register star gift message ref: identity collision")
}
return nil
}
func registerChannelNotificationMessageRef(
ctx context.Context,
tx pgx.Tx,
viewerUserID int64,
msgID int,
savedGiftID int64,
) error {
if viewerUserID <= 0 || msgID <= 0 || savedGiftID <= 0 {
return fmt.Errorf("register channel notification star gift message ref: invalid identity")
}
tag, err := tx.Exec(ctx, `
INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
SELECT $1,$2,gift.id
FROM star_gift_channel_notification_jobs job
JOIN peer_star_gifts gift ON gift.id=job.saved_gift_id
WHERE job.saved_gift_id=$3 AND job.target_user_id=$1
AND gift.owner_peer_type='channel' AND gift.lifecycle_status='active'
ON CONFLICT(owner_user_id,msg_id) DO UPDATE
SET saved_gift_id=EXCLUDED.saved_gift_id
WHERE star_gift_user_message_refs.saved_gift_id=EXCLUDED.saved_gift_id`,
viewerUserID, msgID, savedGiftID)
if err != nil {
return fmt.Errorf("register channel notification star gift message ref: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf("register channel notification star gift message ref: identity collision")
}
return nil
}
func userStarGiftMessageRefMatches(
ctx context.Context,
db interface {
QueryRow(context.Context, string, ...any) pgx.Row
},
ownerUserID int64,
msgID int,
savedGiftID int64,
) (bool, error) {
var matches bool
err := db.QueryRow(ctx, `SELECT EXISTS (
SELECT 1 FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3
)`, ownerUserID, msgID, savedGiftID).Scan(&matches)
return matches, err
}

View file

@ -1,117 +0,0 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestStarGiftLedgerTransactionDirectionsPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
ownerID := (time.Now().UnixNano() & 0x1fffffffffffffff) + 3_000_000_000
channelID := ownerID + 1
lifecycle := NewStarGiftLifecycleStore(pool, nil, 0)
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM ton_transactions WHERE user_id=$1`, ownerID)
_, _ = pool.Exec(ctx, `DELETE FROM ton_balances WHERE user_id=$1`, ownerID)
_, _ = pool.Exec(ctx, `DELETE FROM channel_stars_transactions WHERE channel_id=$1`, channelID)
_, _ = pool.Exec(ctx, `DELETE FROM channel_stars_balances WHERE channel_id=$1`, channelID)
_, _ = pool.Exec(ctx, `DELETE FROM channel_ton_transactions WHERE channel_id=$1`, channelID)
_, _ = pool.Exec(ctx, `DELETE FROM channel_ton_balances WHERE channel_id=$1`, channelID)
})
if _, err := pool.Exec(ctx, `INSERT INTO ton_balances(user_id,balance_nanoton,granted) VALUES($1,70,true)`, ownerID); err != nil {
t.Fatalf("insert ton balance: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO channel_stars_balances(channel_id,balance) VALUES($1,70)`, channelID); err != nil {
t.Fatalf("insert channel stars balance: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO channel_ton_balances(channel_id,balance_nanoton) VALUES($1,70)`, channelID); err != nil {
t.Fatalf("insert channel ton balance: %v", err)
}
for i, amount := range []int64{100, -40, 20, -10} {
date := 1_800_000_000 + i
if _, err := pool.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,date) VALUES($1,$2,'adjust',$3)`, ownerID, amount, date); err != nil {
t.Fatalf("insert ton transaction %d: %v", i, err)
}
if _, err := pool.Exec(ctx, `INSERT INTO channel_stars_transactions(channel_id,actor_user_id,amount,reason,date) VALUES($1,$2,$3,'adjust',$4)`, channelID, ownerID, amount, date); err != nil {
t.Fatalf("insert channel stars transaction %d: %v", i, err)
}
if _, err := pool.Exec(ctx, `INSERT INTO channel_ton_transactions(channel_id,actor_user_id,amount_nanoton,reason,date) VALUES($1,$2,$3,'adjust',$4)`, channelID, ownerID, amount, date); err != nil {
t.Fatalf("insert channel ton transaction %d: %v", i, err)
}
}
tonIncoming, err := lifecycle.TonTransactions(ctx, ownerID, domain.StarsTransactionQuery{
Limit: 10, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("personal ton incoming: %v", err)
}
assertTonTransactionAmounts(t, tonIncoming.Transactions, []int64{20, 100})
tonOutgoing, err := lifecycle.TonTransactions(ctx, ownerID, domain.StarsTransactionQuery{
Limit: 10, Direction: domain.StarsTransactionDirectionOutgoing, Ascending: true,
})
if err != nil {
t.Fatalf("personal ton outgoing: %v", err)
}
assertTonTransactionAmounts(t, tonOutgoing.Transactions, []int64{-40, -10})
channelIncoming1, err := lifecycle.ChannelStarsTransactions(ctx, channelID, domain.StarsTransactionQuery{
Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("channel stars incoming page1: %v", err)
}
assertPostgresStarsAmounts(t, channelIncoming1.Transactions, []int64{20})
if channelIncoming1.NextOffset == "" {
t.Fatal("channel stars incoming page1 missing next offset")
}
channelIncoming2, err := lifecycle.ChannelStarsTransactions(ctx, channelID, domain.StarsTransactionQuery{
Offset: channelIncoming1.NextOffset, Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("channel stars incoming page2: %v", err)
}
assertPostgresStarsAmounts(t, channelIncoming2.Transactions, []int64{100})
if channelIncoming2.NextOffset != "" {
t.Fatalf("channel stars terminal next offset = %q", channelIncoming2.NextOffset)
}
channelTonOutgoing, err := lifecycle.ChannelTonTransactions(ctx, channelID, domain.StarsTransactionQuery{
Limit: 10, Direction: domain.StarsTransactionDirectionOutgoing,
})
if err != nil {
t.Fatalf("channel ton outgoing: %v", err)
}
assertTonTransactionAmounts(t, channelTonOutgoing.Transactions, []int64{-10, -40})
}
func assertPostgresStarsAmounts(t *testing.T, transactions []domain.StarsTransaction, want []int64) {
t.Helper()
if len(transactions) != len(want) {
t.Fatalf("stars transaction count = %d, want %d: %+v", len(transactions), len(want), transactions)
}
for i, amount := range want {
if transactions[i].Amount != amount {
t.Fatalf("stars transaction[%d].amount = %d, want %d", i, transactions[i].Amount, amount)
}
}
}
func assertTonTransactionAmounts(t *testing.T, transactions []domain.TonTransaction, want []int64) {
t.Helper()
if len(transactions) != len(want) {
t.Fatalf("ton transaction count = %d, want %d: %+v", len(transactions), len(want), transactions)
}
for i, amount := range want {
if transactions[i].Amount != amount {
t.Fatalf("ton transaction[%d].amount = %d, want %d", i, transactions[i].Amount, amount)
}
}
}

View file

@ -1,220 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// StarsStore 用 PostgreSQL 实现 store.StarsStoreStars 本地账本)。
// 借记/贷记/授予各自在单事务内完成:余额与流水永不漂移。
type StarsStore struct {
db sqlcgen.DBTX
}
// NewStarsStore 基于 pgx 连接池(或事务)创建 StarsStore。
func NewStarsStore(db sqlcgen.DBTX) *StarsStore {
return &StarsStore{db: db}
}
func (s *StarsStore) GetBalance(ctx context.Context, userID int64) (domain.StarsBalance, error) {
if userID == 0 {
return domain.StarsBalance{}, nil
}
bal := domain.StarsBalance{UserID: userID}
err := s.db.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1`, userID).
Scan(&bal.Balance, &bal.Granted)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarsBalance{UserID: userID}, nil
}
return domain.StarsBalance{}, fmt.Errorf("get stars balance: %w", err)
}
return bal, nil
}
func (s *StarsStore) EnsureGrant(ctx context.Context, userID, amount int64, date int) (domain.StarsBalance, bool, error) {
if userID == 0 {
return domain.StarsBalance{}, false, nil
}
if amount <= 0 {
// 授予额为 0 时只确保有一行且 granted=true幂等关闭授予
bal, err := s.GetBalance(ctx, userID)
return bal, false, err
}
out := domain.StarsBalance{UserID: userID}
applied := false
err := withTx(ctx, s.db, "ensure stars grant", func(tx pgx.Tx) error {
var balance int64
var granted bool
err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1 FOR UPDATE`, userID).
Scan(&balance, &granted)
switch {
case errors.Is(err, pgx.ErrNoRows):
if _, err := tx.Exec(ctx, `INSERT INTO stars_balances (user_id, balance, granted, updated_at) VALUES ($1, $2, true, now())`,
userID, amount); err != nil {
return fmt.Errorf("insert stars balance grant: %w", err)
}
out.Balance, out.Granted, applied = amount, true, true
case err != nil:
return fmt.Errorf("select stars balance for grant: %w", err)
case granted:
out.Balance, out.Granted = balance, true
return nil
default:
if err := tx.QueryRow(ctx, `UPDATE stars_balances SET balance = balance + $2, granted = true, updated_at = now() WHERE user_id = $1 RETURNING balance`,
userID, amount).Scan(&out.Balance); err != nil {
return fmt.Errorf("update stars balance grant: %w", err)
}
out.Granted, applied = true, true
}
return insertStarsTxn(ctx, tx, userID, amount, domain.StarsReasonGrant, domain.Peer{}, date, "", "")
})
if err != nil {
return domain.StarsBalance{}, false, err
}
return out, applied, nil
}
func (s *StarsStore) Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) (domain.StarsBalance, error) {
if userID == 0 || amount <= 0 {
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
}
out := domain.StarsBalance{UserID: userID}
err := withTx(ctx, s.db, "credit stars", func(tx pgx.Tx) error {
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`, userID, amount).Scan(&out.Balance, &out.Granted); err != nil {
return fmt.Errorf("credit stars balance: %w", err)
}
return insertStarsTxn(ctx, tx, userID, amount, reason, peer, date, title, desc)
})
if err != nil {
return domain.StarsBalance{}, err
}
return out, nil
}
func (s *StarsStore) Debit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) (domain.StarsBalance, error) {
if userID == 0 || amount <= 0 {
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
}
out := domain.StarsBalance{UserID: userID, Granted: true}
err := withTx(ctx, s.db, "debit stars", func(tx pgx.Tx) error {
var balance int64
var granted bool
err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1 FOR UPDATE`, userID).
Scan(&balance, &granted)
if errors.Is(err, pgx.ErrNoRows) || (err == nil && balance < amount) {
return domain.ErrStarsInsufficient
}
if err != nil {
return fmt.Errorf("select stars balance for debit: %w", err)
}
if err := tx.QueryRow(ctx, `UPDATE stars_balances SET balance = balance - $2, updated_at = now() WHERE user_id = $1 RETURNING balance`,
userID, amount).Scan(&out.Balance); err != nil {
return fmt.Errorf("update stars balance debit: %w", err)
}
out.Granted = granted
return insertStarsTxn(ctx, tx, userID, -amount, reason, peer, date, title, desc)
})
if err != nil {
return domain.StarsBalance{}, err
}
return out, nil
}
func (s *StarsStore) ListTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
if userID == 0 {
return domain.StarsTransactionPage{}, nil
}
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.StarsTransactionPage{}, err
}
bal, err := s.GetBalance(ctx, userID)
if err != nil {
return domain.StarsTransactionPage{}, err
}
page := domain.StarsTransactionPage{Balance: bal.Balance}
// keyset方向过滤先于 LIMIT多取一条以探测同一视图是否还有下一页。
where, order, args := starsTransactionQueryParts("user_id", "amount", userID, query)
rows, err := s.db.Query(ctx, `
SELECT id, peer_type, peer_id, amount, reason, title, description, date
FROM stars_transactions
WHERE `+where+`
ORDER BY id `+order+`
LIMIT $2`, args...)
if err != nil {
return domain.StarsTransactionPage{}, fmt.Errorf("list stars transactions: %w", err)
}
defer rows.Close()
txns := make([]domain.StarsTransaction, 0, query.Limit+1)
for rows.Next() {
var (
t domain.StarsTransaction
peerType string
peerID int64
reason string
)
if err := rows.Scan(&t.ID, &peerType, &peerID, &t.Amount, &reason, &t.Title, &t.Description, &t.Date); err != nil {
return domain.StarsTransactionPage{}, fmt.Errorf("scan stars transaction: %w", err)
}
t.UserID = userID
t.Reason = domain.StarsTransactionReason(reason)
if peerType != "" {
t.Peer = domain.Peer{Type: domain.PeerType(peerType), ID: peerID}
}
txns = append(txns, t)
}
if err := rows.Err(); err != nil {
return domain.StarsTransactionPage{}, fmt.Errorf("iterate stars transactions: %w", err)
}
if len(txns) > query.Limit {
txns = txns[:query.Limit]
page.NextOffset = domain.EncodeStarsCursor(txns[len(txns)-1].ID)
}
page.Transactions = txns
return page, nil
}
// starsTransactionQueryParts centralizes the sign predicate and keyset
// direction for personal/channel Stars and TON ledgers. Column names are only
// package-owned constants; client values remain bind parameters.
func starsTransactionQueryParts(ownerColumn, amountColumn string, ownerID int64, query domain.StarsTransactionQuery) (string, string, []any) {
where := ownerColumn + "=$1"
switch query.Direction {
case domain.StarsTransactionDirectionIncoming:
where += " AND " + amountColumn + ">0"
case domain.StarsTransactionDirectionOutgoing:
where += " AND " + amountColumn + "<0"
}
order, comparator := "DESC", "<"
if query.Ascending {
order, comparator = "ASC", ">"
}
args := []any{ownerID, query.Limit + 1}
if cursor, ok := domain.DecodeStarsCursor(query.Offset); ok {
where += " AND id" + comparator + "$3"
args = append(args, cursor)
}
return where, order, args
}
// insertStarsTxn 在事务内写一条流水amount 带符号)。
func insertStarsTxn(ctx context.Context, tx pgx.Tx, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) error {
if _, err := tx.Exec(ctx, `
INSERT INTO stars_transactions (user_id, peer_type, peer_id, amount, reason, title, description, date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
userID, string(peer.Type), peer.ID, amount, string(reason), title, desc, date); err != nil {
return fmt.Errorf("insert stars transaction: %w", err)
}
return nil
}

View file

@ -1,628 +0,0 @@
package postgres
import (
"bytes"
"context"
"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"
)
// 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 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 *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 := newStarsPurchaseFormID()
if err != nil {
return domain.StarsPurchaseForm{}, fmt.Errorf("generate stars purchase form id: %w", err)
}
tag, err := s.db.Exec(ctx, `
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.StarsPurchaseForm{}, fmt.Errorf("insert stars purchase form: %w", err)
}
if tag.RowsAffected() == 1 {
form.FormID = formID
return form, nil
}
}
return domain.StarsPurchaseForm{}, domain.ErrStarsGiftUnavailable
}
var errStarsPurchaseReplay = errors.New("stars purchase replay")
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 := 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)
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.StarsPurchaseResult{TransactionID: transactionID}
hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) 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
}
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.Balance = balance
if send.Media == nil || send.Media.ServiceAction == nil || send.Media.ServiceAction.GiftStars == nil {
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_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)
}
result.Send = sent
return nil
},
}
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, 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
}
result.Send = sent
return result, nil
}
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 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(&kind, &recipientID, &spendPeerType, &spendPeerID, &purposeJSON, &stars, &currency, &amount, &issuedAt, &expiresAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrStarsPurchaseFormInvalid
}
if err != nil {
return fmt.Errorf("load stars gift form: %w", err)
}
if req.Date >= expiresAt {
return domain.ErrStarsPurchaseFormExpired
}
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 *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 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, &currency, &amount, &balance, &transactionID)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarsPurchaseResult{}, false, nil
}
if err != nil {
return domain.StarsPurchaseResult{}, false, fmt.Errorf("load stars purchase replay: %w", err)
}
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,
RandomID: lifecycleCommandRandomID("stars-fiat-gift", req.BuyerUserID, req.FormID),
IdempotencyFingerprint: fingerprint[:],
})
if err != nil {
return domain.StarsPurchaseResult{}, false, err
}
if !found {
return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid
}
result.Balance.UserID = req.RecipientUserID
result.Send = sent
return result, true, nil
}
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_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 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 newStarsPurchaseFormID() (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
}
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)

View file

@ -1,366 +0,0 @@
package postgres
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
type starsPurchaseAttempt struct {
result domain.StarsPurchaseResult
err error
}
func purchaseStarsTwiceConcurrently(t *testing.T, ctx context.Context, store *StarsPurchaseStore, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, domain.StarsPurchaseResult) {
t.Helper()
start := make(chan struct{})
attempts := make(chan starsPurchaseAttempt, 2)
for range 2 {
go func() {
<-start
result, err := store.PurchaseStars(ctx, req)
attempts <- starsPurchaseAttempt{result: result, err: err}
}()
}
close(start)
var first, replay domain.StarsPurchaseResult
firstCount, replayCount := 0, 0
for range 2 {
attempt := <-attempts
if attempt.err != nil {
t.Fatalf("concurrent Stars purchase: %v", attempt.err)
}
if attempt.result.Duplicate {
replay, replayCount = attempt.result, replayCount+1
} else {
first, firstCount = attempt.result, firstCount+1
}
}
if firstCount != 1 || replayCount != 1 {
t.Fatalf("concurrent Stars purchase first/replay counts = %d/%d, want 1/1", firstCount, replayCount)
}
return first, replay
}
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_purchase_commands WHERE buyer_user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_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 := NewStarsPurchaseStore(pool, messages)
issued, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseGift, 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.StarsPurchaseRequest{
StarsPurchaseForm: domain.StarsPurchaseForm{
FormID: issued.FormID, Kind: domain.StarsPurchaseGift, BuyerUserID: buyer.ID, RecipientUserID: recipient.ID,
Stars: 2500, Currency: "USD", Amount: 199,
},
Date: 1_700_000_100, OriginAuthKeyID: origin, OriginSessionID: 77,
}
first, replay := purchaseStarsTwiceConcurrently(t, ctx, store, req)
if first.Duplicate || first.Balance.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)
}
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_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.PurchaseStars(ctx, tampered); !errors.Is(err, domain.ErrStarsPurchaseFormInvalid) {
t.Fatalf("tampered replay err=%v", err)
}
expired, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseGift, 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.PurchaseStars(ctx, expiredReq); !errors.Is(err, domain.ErrStarsPurchaseFormExpired) {
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)
}
}
func TestStarsTopupPurchaseAtomicReplayAndPurposeBindingPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
users := NewUserStore(pool)
suffix := randomSuffix(t)
buyer, err := users.Create(ctx, domain.User{AccessHash: 94201, Phone: "+1665942" + suffix + "01", FirstName: "TopupBuyer"})
if err != nil {
t.Fatalf("create buyer: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_commands WHERE buyer_user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_forms WHERE buyer_user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_transactions WHERE user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_balances WHERE user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=$1", buyer.ID)
})
store := NewStarsPurchaseStore(pool, nil)
purposePeer := domain.Peer{Type: domain.PeerTypeUser, ID: buyer.ID + 100}
issued, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseTopup, BuyerUserID: buyer.ID, SpendPurposePeer: purposePeer,
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)
}
req := domain.StarsPurchaseRequest{
StarsPurchaseForm: domain.StarsPurchaseForm{
FormID: issued.FormID, Kind: domain.StarsPurchaseTopup, BuyerUserID: buyer.ID,
SpendPurposePeer: purposePeer, Stars: 2500, Currency: "USD", Amount: 199,
},
Date: 1_700_000_100,
}
first, replay := purchaseStarsTwiceConcurrently(t, ctx, store, req)
if first.Duplicate || first.Balance.Balance != 2500 || first.TransactionID == "" {
t.Fatalf("first purchase = %+v", first)
}
if !replay.Duplicate || replay.Balance.Balance != 2500 || replay.TransactionID != first.TransactionID {
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", buyer.ID).Scan(&balance); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, "SELECT count(*) FROM stars_transactions WHERE user_id=$1 AND reason='topup'", buyer.ID).Scan(&txnCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, "SELECT count(*) FROM stars_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)
}
tampered := req
tampered.SpendPurposePeer.ID++
if _, err := store.PurchaseStars(ctx, tampered); !errors.Is(err, domain.ErrStarsPurchaseFormInvalid) {
t.Fatalf("tampered purpose replay err=%v", err)
}
otherBuyer := req
otherBuyer.BuyerUserID++
if _, err := store.PurchaseStars(ctx, otherBuyer); !errors.Is(err, domain.ErrStarsPurchaseFormInvalid) {
t.Fatalf("cross-account form err=%v", err)
}
if err := pool.QueryRow(ctx, "SELECT balance FROM stars_balances WHERE user_id=$1", buyer.ID).Scan(&balance); err != nil || balance != 2500 {
t.Fatalf("balance after invalid submissions=%d err=%v", balance, err)
}
}
func TestStarsGiveawayPurchaseAtomicChannelPTSReplayAndInfoPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
users := NewUserStore(pool)
suffix := randomSuffix(t)
owner, err := users.Create(ctx, domain.User{AccessHash: 94301, Phone: "+1665943" + suffix + "01", FirstName: "GiveawayOwner", CountryCode: "1"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
member, err := users.Create(ctx, domain.User{AccessHash: 94302, Phone: "+1665943" + suffix + "02", FirstName: "GiveawayMember", CountryCode: "1"})
if err != nil {
t.Fatalf("create member: %v", err)
}
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "Stars Giveaway " + suffix, Megagroup: true,
MemberUserIDs: []int64{member.ID}, Date: 1_700_000_000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID := created.Channel.ID
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM stars_giveaways WHERE buyer_user_id=$1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_commands WHERE buyer_user_id=$1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_forms WHERE buyer_user_id=$1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id=$1", channelID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=ANY($1::bigint[])", []int64{owner.ID, member.ID})
})
before, err := channels.GetChannelByID(ctx, channelID)
if err != nil {
t.Fatal(err)
}
purpose := &domain.StarsGiveawayPurchase{
BoostPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
CountriesISO2: []string{"US"}, RandomID: 9430001, UntilDate: 1_700_003_700,
Users: 2, PerUserStars: 500, YearlyBoosts: 4, WinnersAreVisible: true,
}
store := NewStarsPurchaseStore(pool, nil, channels)
issued, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseGiveaway, BuyerUserID: owner.ID, Giveaway: purpose,
Stars: 1000, Currency: "USD", Amount: 99, IssuedAt: 1_700_000_100, ExpiresAt: 1_700_000_700,
})
if err != nil || issued.FormID == 0 {
t.Fatalf("issue giveaway form=%+v err=%v", issued, err)
}
req := domain.StarsPurchaseRequest{StarsPurchaseForm: domain.StarsPurchaseForm{
FormID: issued.FormID, Kind: domain.StarsPurchaseGiveaway, BuyerUserID: owner.ID, Giveaway: purpose,
Stars: 1000, Currency: "USD", Amount: 99,
}, Date: 1_700_000_200}
first, replay := purchaseStarsTwiceConcurrently(t, ctx, store, req)
if first.Duplicate || first.TransactionID == "" || first.ChannelSend.Event.PtsCount != 1 ||
first.ChannelSend.Event.Pts != before.Pts+1 || first.ChannelSend.Message.Media == nil ||
first.ChannelSend.Message.Media.Giveaway == nil {
t.Fatalf("first giveaway result=%+v before_pts=%d", first, before.Pts)
}
media := first.ChannelSend.Message.Media.Giveaway
if media.Stars != 1000 || media.Quantity != 2 || len(media.Channels) != 1 || media.Channels[0] != channelID ||
media.UntilDate != purpose.UntilDate || !media.WinnersAreVisible {
t.Fatalf("giveaway media=%+v", media)
}
difference, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: member.ID, ChannelID: channelID, Pts: before.Pts, Limit: 10,
})
if err != nil || difference.Pts != first.ChannelSend.Event.Pts || len(difference.Events) != 1 || len(difference.NewMessages) != 1 ||
difference.NewMessages[0].Media == nil || difference.NewMessages[0].Media.Giveaway == nil ||
difference.NewMessages[0].Media.Giveaway.Stars != 1000 {
t.Fatalf("giveaway channel difference=%+v err=%v", difference, err)
}
if !replay.Duplicate || replay.TransactionID != first.TransactionID ||
replay.ChannelSend.Message.ID != first.ChannelSend.Message.ID || replay.ChannelSend.Event.Pts != first.ChannelSend.Event.Pts {
t.Fatalf("giveaway replay=%+v first=%+v", replay, first)
}
lateReplayReq := req
lateReplayReq.Date = purpose.UntilDate
lateReplay, err := store.PurchaseStars(ctx, lateReplayReq)
if err != nil || !lateReplay.Duplicate || lateReplay.TransactionID != first.TransactionID ||
lateReplay.ChannelSend.Message.ID != first.ChannelSend.Message.ID || lateReplay.ChannelSend.Event.Pts != first.ChannelSend.Event.Pts {
t.Fatalf("giveaway replay after until_date=%+v err=%v first=%+v", lateReplay, err, first)
}
latePurpose := *purpose
latePurpose.RandomID++
lateForm, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseGiveaway, BuyerUserID: owner.ID, Giveaway: &latePurpose,
Stars: 1000, Currency: "USD", Amount: 99,
IssuedAt: purpose.UntilDate - 100, ExpiresAt: purpose.UntilDate + 500,
})
if err != nil {
t.Fatalf("issue giveaway form before until_date: %v", err)
}
lateFirstReq := req
lateFirstReq.FormID = lateForm.FormID
lateFirstReq.Giveaway = &latePurpose
lateFirstReq.Date = purpose.UntilDate
if _, err := store.PurchaseStars(ctx, lateFirstReq); !errors.Is(err, domain.ErrStarsPurchaseFormExpired) {
t.Fatalf("first giveaway settlement at until_date err=%v, want form expired", err)
}
var campaigns, commands, messages, events, balanceRows, txns int64
queries := []struct {
query string
args []any
target *int64
}{
{"SELECT count(*) FROM stars_giveaways WHERE buyer_user_id=$1", []any{owner.ID}, &campaigns},
{"SELECT count(*) FROM stars_purchase_commands WHERE buyer_user_id=$1", []any{owner.ID}, &commands},
{"SELECT count(*) FROM channel_messages WHERE channel_id=$1 AND id=$2", []any{channelID, first.ChannelSend.Message.ID}, &messages},
{"SELECT count(*) FROM channel_update_events WHERE channel_id=$1 AND pts=$2", []any{channelID, first.ChannelSend.Event.Pts}, &events},
{"SELECT count(*) FROM stars_balances WHERE user_id=$1", []any{owner.ID}, &balanceRows},
{"SELECT count(*) FROM stars_transactions WHERE user_id=$1", []any{owner.ID}, &txns},
}
for _, item := range queries {
if err := pool.QueryRow(ctx, item.query, item.args...).Scan(item.target); err != nil {
t.Fatalf("footprint query %q: %v", item.query, err)
}
}
if campaigns != 1 || commands != 1 || messages != 1 || events != 1 || balanceRows != 0 || txns != 0 {
t.Fatalf("footprint campaigns=%d commands=%d messages=%d events=%d balances=%d txns=%d", campaigns, commands, messages, events, balanceRows, txns)
}
ownerInfo, err := store.GetStarsGiveawayInfo(ctx, owner.ID, channelID, first.ChannelSend.Message.ID, 1_700_000_300)
if err != nil || ownerInfo.AdminDisallowedChatID != channelID || ownerInfo.Participating {
t.Fatalf("owner giveaway info=%+v err=%v", ownerInfo, err)
}
memberInfo, err := store.GetStarsGiveawayInfo(ctx, member.ID, channelID, first.ChannelSend.Message.ID, 1_700_000_300)
if err != nil || !memberInfo.Participating || memberInfo.StartDate != req.Date {
t.Fatalf("member giveaway info=%+v err=%v", memberInfo, err)
}
preparing, err := store.GetStarsGiveawayInfo(ctx, member.ID, channelID, first.ChannelSend.Message.ID, purpose.UntilDate)
if err != nil || !preparing.PreparingResults || preparing.Participating {
t.Fatalf("preparing giveaway info=%+v err=%v", preparing, err)
}
tampered := req
changed := *purpose
changed.Users, changed.PerUserStars = 1, 1000
tampered.Giveaway = &changed
if _, err := store.PurchaseStars(ctx, tampered); !errors.Is(err, domain.ErrStarsPurchaseFormInvalid) {
t.Fatalf("tampered giveaway replay err=%v", err)
}
}

View file

@ -1,128 +0,0 @@
package postgres
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
// TestStarsLedgerPostgres 回归迁移 0009Stars 本地账本对真实 PG 的原子语义
// (首读授予幂等 / 贷记 / 借记原子 / 余额不足拦截且不动账 / keyset 分页末页无游标)。
func TestStarsLedgerPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
st := NewStarsStore(pool)
users := NewUserStore(pool)
suffix := randomSuffix(t)
u, err := users.Create(ctx, domain.User{AccessHash: 92, Phone: "+1665" + suffix + "01", FirstName: "StarsLedger"})
if err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM stars_transactions WHERE user_id = $1", u.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_balances WHERE user_id = $1", u.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", u.ID)
})
// 空账号:余额 0、未授予。
if bal, err := st.GetBalance(ctx, u.ID); err != nil || bal.Balance != 0 || bal.Granted {
t.Fatalf("empty balance = %+v err %v, want 0 not granted", bal, err)
}
// 首读授予一次。
bal, applied, err := st.EnsureGrant(ctx, u.ID, 1000, 1700000000)
if err != nil || !applied || bal.Balance != 1000 || !bal.Granted {
t.Fatalf("first grant = %+v applied %v err %v, want 1000 granted applied", bal, applied, err)
}
// 再次授予幂等:不重复。
bal, applied, err = st.EnsureGrant(ctx, u.ID, 1000, 1700000001)
if err != nil || applied || bal.Balance != 1000 {
t.Fatalf("second grant = %+v applied %v err %v, want 1000 not applied", bal, applied, err)
}
// 借记原子扣减 + 写负流水。
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 4242}
bal, err = st.Debit(ctx, u.ID, 300, domain.StarsReasonReaction, peer, 1700000002, "paid reaction", "")
if err != nil || bal.Balance != 700 {
t.Fatalf("debit = %+v err %v, want 700", bal, err)
}
// 余额不足拦截且不动账CHECK + FOR UPDATE 双保险)。
if _, err := st.Debit(ctx, u.ID, 100000, domain.StarsReasonReaction, peer, 1700000003, "", ""); !errors.Is(err, domain.ErrStarsInsufficient) {
t.Fatalf("over-debit err = %v, want ErrStarsInsufficient", err)
}
if bal, err := st.GetBalance(ctx, u.ID); err != nil || bal.Balance != 700 {
t.Fatalf("balance after failed debit = %+v err %v, want 700 unchanged", bal, err)
}
// 贷记。
bal, err = st.Credit(ctx, u.ID, 50, domain.StarsReasonGift, domain.Peer{Type: domain.PeerTypeUser, ID: 9}, 1700000004, "gift", "")
if err != nil || bal.Balance != 750 {
t.Fatalf("credit = %+v err %v, want 750", bal, err)
}
// 流水grant(+1000) / debit(-300) / credit(+50) 共 3 条,倒序最新在前。
page, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{Limit: 2})
if err != nil {
t.Fatalf("list page1: %v", err)
}
if len(page.Transactions) != 2 || page.NextOffset == "" {
t.Fatalf("page1 = %d txns next=%q, want 2 + next", len(page.Transactions), page.NextOffset)
}
if page.Transactions[0].Amount != 50 || page.Transactions[0].Reason != domain.StarsReasonGift {
t.Fatalf("page1[0] = %+v, want +50 gift (newest)", page.Transactions[0])
}
if page.Balance != 750 {
t.Fatalf("page balance = %d, want 750", page.Balance)
}
page2, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{Offset: page.NextOffset, Limit: 2})
if err != nil {
t.Fatalf("list page2: %v", err)
}
if len(page2.Transactions) != 1 || page2.NextOffset != "" {
t.Fatalf("page2 = %d txns next=%q, want 1 + empty (terminal)", len(page2.Transactions), page2.NextOffset)
}
if page2.Transactions[0].Reason != domain.StarsReasonGrant || page2.Transactions[0].Amount != 1000 {
t.Fatalf("page2[0] = %+v, want +1000 grant (oldest)", page2.Transactions[0])
}
incoming1, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{
Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("incoming page1: %v", err)
}
if len(incoming1.Transactions) != 1 || incoming1.Transactions[0].Amount != 50 || incoming1.NextOffset == "" {
t.Fatalf("incoming page1 = %+v next=%q, want +50 and next", incoming1.Transactions, incoming1.NextOffset)
}
incoming2, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{
Offset: incoming1.NextOffset, Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("incoming page2: %v", err)
}
if len(incoming2.Transactions) != 1 || incoming2.Transactions[0].Amount != 1000 || incoming2.NextOffset != "" {
t.Fatalf("incoming page2 = %+v next=%q, want +1000 terminal", incoming2.Transactions, incoming2.NextOffset)
}
outgoing, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{
Limit: 10, Direction: domain.StarsTransactionDirectionOutgoing,
})
if err != nil || len(outgoing.Transactions) != 1 || outgoing.Transactions[0].Amount != -300 {
t.Fatalf("outgoing = %+v err=%v, want only -300", outgoing.Transactions, err)
}
ascending, err := st.ListTransactions(ctx, u.ID, domain.StarsTransactionQuery{Limit: 10, Ascending: true})
if err != nil || len(ascending.Transactions) != 3 {
t.Fatalf("ascending = %+v err=%v", ascending.Transactions, err)
}
wantAscending := []int64{1000, -300, 50}
for i, amount := range wantAscending {
if ascending.Transactions[i].Amount != amount {
t.Fatalf("ascending[%d].amount = %d, want %d", i, ascending.Transactions[i].Amount, amount)
}
}
}

View file

@ -996,7 +996,7 @@ func decodeEventReaction(raw string) (*domain.MessageReaction, error) {
func encodeEventEmojiStatus(status domain.UserEmojiStatus) ([]byte, error) {
if !status.Valid() {
return nil, domain.ErrStarGiftCollectibleInvalid
return nil, domain.ErrEmojiStatusCollectibleInvalid
}
raw, err := json.Marshal(status)
if err != nil {
@ -1014,7 +1014,7 @@ func decodeEventEmojiStatus(raw string) (domain.UserEmojiStatus, error) {
return domain.UserEmojiStatus{}, err
}
if !status.Valid() {
return domain.UserEmojiStatus{}, domain.ErrStarGiftCollectibleInvalid
return domain.UserEmojiStatus{}, domain.ErrEmojiStatusCollectibleInvalid
}
return status, nil
}

View file

@ -561,7 +561,7 @@ func (s *UserStore) UpdateEmojiStatus(ctx context.Context, userID int64, status
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUserNotFound
}
if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
if errors.Is(err, domain.ErrEmojiStatusCollectibleInvalid) {
return domain.User{}, err
}
return domain.User{}, fmt.Errorf("update user emoji status: %w", err)
@ -580,7 +580,7 @@ func (s *UserStore) UpdateEmojiStatusWithEvent(ctx context.Context, userID int64
}
if event.Type != domain.UpdateEventUserEmojiStatus || event.EmojiStatus != status ||
event.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) {
return domain.User{}, domain.UpdateEvent{}, domain.ErrStarGiftCollectibleInvalid
return domain.User{}, domain.UpdateEvent{}, domain.ErrEmojiStatusCollectibleInvalid
}
params := sqlcgen.UpdateUserEmojiStatusParams{
ID: userID,
@ -604,7 +604,7 @@ func (s *UserStore) UpdateEmojiStatusWithEvent(ctx context.Context, userID int64
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.UpdateEvent{}, domain.ErrUserNotFound
}
if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
if errors.Is(err, domain.ErrEmojiStatusCollectibleInvalid) {
return domain.User{}, domain.UpdateEvent{}, err
}
return domain.User{}, domain.UpdateEvent{}, fmt.Errorf("update user emoji status with event: %w", err)
@ -613,24 +613,10 @@ func (s *UserStore) UpdateEmojiStatusWithEvent(ctx context.Context, userID int64
}
func updateEmojiStatusRow(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, userID int64, status domain.UserEmojiStatus, params sqlcgen.UpdateUserEmojiStatusParams) (sqlcgen.User, error) {
// telesrv has no collectible-gift ownership left to verify against, so a
// collectible emoji status can never be legitimately set.
if !status.Collectible.Empty() {
var lockedID int64
if err := db.QueryRow(ctx, `
SELECT id FROM unique_star_gifts WHERE id=$1 FOR UPDATE`, status.Collectible.CollectibleID).Scan(&lockedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return sqlcgen.User{}, domain.ErrStarGiftCollectibleInvalid
}
return sqlcgen.User{}, err
}
gift, found, err := NewStarGiftStore(db).UniqueByID(ctx, lockedID)
if err != nil {
return sqlcgen.User{}, err
}
expected, valid := domain.CollectibleEmojiStatus(gift)
if !found || !valid || gift.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) ||
gift.Burned || gift.OwnerAddress != "" || expected != status.Collectible {
return sqlcgen.User{}, domain.ErrStarGiftCollectibleInvalid
}
return sqlcgen.User{}, domain.ErrEmojiStatusCollectibleInvalid
}
return q.UpdateUserEmojiStatus(ctx, params)
}
@ -769,7 +755,7 @@ func userFromModel(r sqlcgen.User) domain.User {
func encodeEmojiStatusCollectible(status domain.UserEmojiStatus) ([]byte, *int64, error) {
if !status.Valid() {
return nil, nil, domain.ErrStarGiftCollectibleInvalid
return nil, nil, domain.ErrEmojiStatusCollectibleInvalid
}
if status.Collectible.Empty() {
return []byte(`{}`), nil, nil