removed all "paid" features - no more stars, gifts, or grams
This commit is contained in:
parent
d4451d753c
commit
21d8e91756
165 changed files with 318 additions and 40948 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 倒序分页。
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,70 +199,6 @@ ORDER BY channel_id ASC, message_id ASC, reaction_date DESC, reacted_user_id DES
|
|||
recentRows.Close()
|
||||
}
|
||||
|
||||
// 3) 付费 reaction(Stars):跨频道一次取所有 reactor 行,Go 内按 (channel,message) 聚合
|
||||
// 总星数 + viewer 自身 + top reactors,挂到 message.Reactions.Paid(tg 转换注入 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 DESC);viewer 自身若不在前 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 → 总 350,member 视角 my=250,top 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.StarGiftStore(peer 收到的 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.StarsStore(Stars 本地账本)。
|
||||
// 借记/贷记/授予各自在单事务内完成:余额与流水永不漂移。
|
||||
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
|
||||
}
|
||||
|
|
@ -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, ¤cy, &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, ¤cy, &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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// TestStarsLedgerPostgres 回归迁移 0009:Stars 本地账本对真实 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue