Merge remote-tracking branch 'upstream/main' into merge-gramsrv-2965f5d
This commit is contained in:
commit
ebb0be38d9
355 changed files with 44640 additions and 2320 deletions
738
internal/store/postgres/account_lifecycle.go
Normal file
738
internal/store/postgres/account_lifecycle.go
Normal file
|
|
@ -0,0 +1,738 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// AccountLifecycleStore is the PostgreSQL implementation of the unified
|
||||
// account tombstone, delayed deletion and deletion notification boundary.
|
||||
type AccountLifecycleStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewAccountLifecycleStore(pool *pgxpool.Pool) *AccountLifecycleStore {
|
||||
return &AccountLifecycleStore{pool: pool}
|
||||
}
|
||||
|
||||
func (s *AccountLifecycleStore) AccountDeletionSnapshot(ctx context.Context, userID int64) (domain.AccountDeletionSnapshot, bool, error) {
|
||||
if s == nil || s.pool == nil || userID == 0 {
|
||||
return domain.AccountDeletionSnapshot{}, false, nil
|
||||
}
|
||||
u, found, err := NewUserStore(s.pool).ByID(ctx, userID)
|
||||
if err != nil || !found {
|
||||
return domain.AccountDeletionSnapshot{}, found, err
|
||||
}
|
||||
var snapshot = domain.AccountDeletionSnapshot{User: u}
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (SELECT 1 FROM account_passwords WHERE user_id = $1 AND has_password),
|
||||
COALESCE((SELECT password_changed_at FROM account_passwords WHERE user_id = $1), u.created_at)
|
||||
FROM users u WHERE u.id = $1`, userID).Scan(&snapshot.HasPassword, &snapshot.PasswordUpdatedAt); err != nil {
|
||||
return domain.AccountDeletionSnapshot{}, false, fmt.Errorf("load account deletion password facts: %w", err)
|
||||
}
|
||||
pending, ok, err := pendingAccountDeletion(ctx, s.pool, userID, nil)
|
||||
if err != nil {
|
||||
return domain.AccountDeletionSnapshot{}, false, err
|
||||
}
|
||||
if ok {
|
||||
snapshot.Pending = &pending
|
||||
}
|
||||
return snapshot, true, nil
|
||||
}
|
||||
|
||||
func (s *AccountLifecycleStore) ScheduleAccountDeletion(ctx context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error) {
|
||||
if s == nil || s.pool == nil || req.UserID == 0 || req.RequestedAt.IsZero() || !req.ExecuteAt.After(req.RequestedAt) {
|
||||
return domain.AccountDeletionRequest{}, false, domain.ErrAccountDeletionForbidden
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.AccountDeletionRequest{}, false, fmt.Errorf("begin schedule account deletion: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if err := lockUsersForUpdate(ctx, tx, req.UserID, domain.OfficialSystemUserID); err != nil {
|
||||
return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock schedule account deletion users: %w", err)
|
||||
}
|
||||
var deletedAt *time.Time
|
||||
if err := tx.QueryRow(ctx, `SELECT deleted_at FROM users WHERE id = $1 FOR UPDATE`, req.UserID).Scan(&deletedAt); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AccountDeletionRequest{}, false, domain.ErrUserNotFound
|
||||
}
|
||||
return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock account deletion user: %w", err)
|
||||
}
|
||||
if deletedAt != nil {
|
||||
return domain.AccountDeletionRequest{}, false, domain.ErrAccountDeleted
|
||||
}
|
||||
if existing, ok, err := pendingAccountDeletion(ctx, tx, req.UserID, nil); err != nil {
|
||||
return domain.AccountDeletionRequest{}, false, err
|
||||
} else if ok {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.AccountDeletionRequest{}, false, fmt.Errorf("commit existing account deletion: %w", err)
|
||||
}
|
||||
return existing, false, nil
|
||||
}
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO account_deletion_requests (
|
||||
user_id, requester_auth_key_id, reason, confirm_hash_digest, requested_at, execute_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest,
|
||||
requested_at, execute_at, completed_at`,
|
||||
req.UserID, authKeyIDToInt64(req.RequesterAuthKeyID), req.Reason,
|
||||
req.ConfirmHashDigest[:], req.RequestedAt, req.ExecuteAt)
|
||||
pending, err := scanAccountDeletionRequest(row)
|
||||
if err != nil {
|
||||
return domain.AccountDeletionRequest{}, false, fmt.Errorf("insert account deletion request: %w", err)
|
||||
}
|
||||
randomID := int64(binary.LittleEndian.Uint64(req.ConfirmHashDigest[:8]))
|
||||
if randomID == 0 {
|
||||
randomID = pending.ID
|
||||
}
|
||||
if _, err := NewMessageStore(tx).SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: domain.OfficialSystemUserID,
|
||||
RecipientUserID: req.UserID,
|
||||
RandomID: randomID,
|
||||
Message: req.ServiceMessage,
|
||||
Date: int(req.RequestedAt.Unix()),
|
||||
}); err != nil {
|
||||
return domain.AccountDeletionRequest{}, false, fmt.Errorf("send account deletion confirmation message: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.AccountDeletionRequest{}, false, fmt.Errorf("commit schedule account deletion: %w", err)
|
||||
}
|
||||
return pending, true, nil
|
||||
}
|
||||
|
||||
func (s *AccountLifecycleStore) PendingAccountDeletionByHash(ctx context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error) {
|
||||
if s == nil || s.pool == nil || userID == 0 {
|
||||
return domain.AccountDeletionRequest{}, false, nil
|
||||
}
|
||||
return pendingAccountDeletion(ctx, s.pool, userID, digest[:])
|
||||
}
|
||||
|
||||
func (s *AccountLifecycleStore) ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error) {
|
||||
if s == nil || s.pool == nil || userID == 0 || now.IsZero() || !validAccountDeletionSource(source) {
|
||||
return domain.AccountDeletionResult{}, domain.ErrAccountDeletionForbidden
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.AccountDeletionResult{}, fmt.Errorf("begin execute account deletion: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if err := lockUsersForUpdate(ctx, tx, userID); err != nil {
|
||||
return domain.AccountDeletionResult{}, fmt.Errorf("lock account deletion user: %w", err)
|
||||
}
|
||||
var lockedID int64
|
||||
if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&lockedID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AccountDeletionResult{}, domain.ErrUserNotFound
|
||||
}
|
||||
return domain.AccountDeletionResult{}, fmt.Errorf("lock account deletion row: %w", err)
|
||||
}
|
||||
u, found, err := NewUserStore(tx).ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.AccountDeletionResult{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.AccountDeletionResult{}, domain.ErrUserNotFound
|
||||
}
|
||||
if u.Deleted {
|
||||
return domain.AccountDeletionResult{User: u, Changed: false}, nil
|
||||
}
|
||||
if u.Bot || domain.IsSystemUserID(u.ID) {
|
||||
return domain.AccountDeletionResult{}, domain.ErrAccountDeletionForbidden
|
||||
}
|
||||
due, err := accountDeletionStillDue(ctx, tx, u, source, now)
|
||||
if err != nil {
|
||||
return domain.AccountDeletionResult{}, err
|
||||
}
|
||||
if !due {
|
||||
return domain.AccountDeletionResult{User: u, Changed: false}, nil
|
||||
}
|
||||
if err := enqueueAccountDeletionNotifications(ctx, tx, userID); err != nil {
|
||||
return domain.AccountDeletionResult{}, err
|
||||
}
|
||||
if err := settleDeletedAccountFinancialState(ctx, tx, userID, now); err != nil {
|
||||
return domain.AccountDeletionResult{}, err
|
||||
}
|
||||
revoked, err := revokeByUserExceptTx(ctx, tx, userID, 0)
|
||||
if err != nil {
|
||||
return domain.AccountDeletionResult{}, fmt.Errorf("revoke deleted account authorizations: %w", err)
|
||||
}
|
||||
if err := purgeDeletedAccountPrivateState(ctx, tx, userID, now); err != nil {
|
||||
return domain.AccountDeletionResult{}, err
|
||||
}
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, ""); err != nil {
|
||||
return domain.AccountDeletionResult{}, fmt.Errorf("release deleted account username: %w", err)
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
reason = truncateUTF8Bytes(reason, 1024)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE users SET
|
||||
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
|
||||
verified = false, support = false, last_seen_at = 0,
|
||||
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,
|
||||
deleted_at = $2, deletion_source = $3, deletion_reason = $4,
|
||||
account_delete_at = NULL, updated_at = $2
|
||||
WHERE id = $1 AND deleted_at IS NULL`, userID, now, string(source), reason); err != nil {
|
||||
return domain.AccountDeletionResult{}, fmt.Errorf("write deleted account tombstone: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE account_deletion_requests
|
||||
SET state = 'executed', completed_at = $2, updated_at = $2
|
||||
WHERE user_id = $1 AND state = 'pending'`, userID, now); err != nil {
|
||||
return domain.AccountDeletionResult{}, fmt.Errorf("complete account deletion request: %w", err)
|
||||
}
|
||||
u, found, err = NewUserStore(tx).ByID(ctx, userID)
|
||||
if err != nil || !found {
|
||||
if err == nil {
|
||||
err = domain.ErrUserNotFound
|
||||
}
|
||||
return domain.AccountDeletionResult{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.AccountDeletionResult{}, fmt.Errorf("commit execute account deletion: %w", err)
|
||||
}
|
||||
return domain.AccountDeletionResult{User: u, Changed: true, RevokedAuthorizations: revoked}, nil
|
||||
}
|
||||
|
||||
func (s *AccountLifecycleStore) CancelAccountDeletion(ctx context.Context, userID int64, digest [32]byte, now time.Time) ([]domain.Authorization, error) {
|
||||
if s == nil || s.pool == nil || userID == 0 || now.IsZero() {
|
||||
return nil, domain.ErrAccountDeletionHashInvalid
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin cancel account deletion: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if err := lockUsersForUpdate(ctx, tx, userID); err != nil {
|
||||
return nil, fmt.Errorf("lock cancel account deletion: %w", err)
|
||||
}
|
||||
pending, ok, err := pendingAccountDeletionForUpdate(ctx, tx, userID, digest[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, domain.ErrAccountDeletionHashInvalid
|
||||
}
|
||||
revoked, err := revokeOneAuthorizationTx(ctx, tx, userID, pending.RequesterAuthKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE account_deletion_requests
|
||||
SET state = 'cancelled', completed_at = $2, updated_at = $2
|
||||
WHERE id = $1 AND state = 'pending'`, pending.ID, now); err != nil {
|
||||
return nil, fmt.Errorf("cancel account deletion request: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit cancel account deletion: %w", err)
|
||||
}
|
||||
return revoked, nil
|
||||
}
|
||||
|
||||
func (s *AccountLifecycleStore) DueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionCandidate, error) {
|
||||
if s == nil || s.pool == nil || limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH candidates AS (
|
||||
SELECT user_id, 'password_reset_expiry'::text AS source, execute_at AS due_at, 1 AS priority
|
||||
FROM account_deletion_requests WHERE state = 'pending' AND execute_at <= $1
|
||||
UNION ALL
|
||||
SELECT id, 'account_ttl', account_delete_at, 2
|
||||
FROM users WHERE deleted_at IS NULL AND is_bot = false AND account_delete_at <= $1
|
||||
UNION ALL
|
||||
SELECT r.user_id, 'freeze_expiry', r.frozen_until, 3
|
||||
FROM account_restrictions r JOIN users u ON u.id = r.user_id
|
||||
WHERE r.frozen = true AND r.frozen_until IS NOT NULL AND r.frozen_until <= $1
|
||||
AND u.deleted_at IS NULL AND u.is_bot = false
|
||||
), dedup AS (
|
||||
SELECT DISTINCT ON (user_id) user_id, source, due_at
|
||||
FROM candidates ORDER BY user_id, priority, due_at
|
||||
)
|
||||
SELECT user_id, source, due_at FROM dedup ORDER BY due_at, user_id LIMIT $2`, now, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list due account deletions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.AccountDeletionCandidate, 0)
|
||||
for rows.Next() {
|
||||
var c domain.AccountDeletionCandidate
|
||||
var source string
|
||||
if err := rows.Scan(&c.UserID, &source, &c.DueAt); err != nil {
|
||||
return nil, fmt.Errorf("scan due account deletion: %w", err)
|
||||
}
|
||||
c.Source = domain.AccountDeletionSource(source)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *AccountLifecycleStore) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||
if s == nil || s.pool == nil || limit <= 0 || lease <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH claim AS (
|
||||
SELECT id FROM account_deletion_notifications
|
||||
WHERE (status = 'pending' AND next_attempt_at <= $1)
|
||||
OR (status = 'dispatching' AND lease_until <= $1)
|
||||
ORDER BY next_attempt_at, id FOR UPDATE SKIP LOCKED LIMIT $2
|
||||
)
|
||||
UPDATE account_deletion_notifications n
|
||||
SET status = 'dispatching', attempts = attempts + 1, lease_until = $3, updated_at = $1
|
||||
FROM claim WHERE n.id = claim.id
|
||||
RETURNING n.id, n.target_user_id, n.deleted_user_id, n.attempts`, now, limit, now.Add(lease))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claim account deletion notifications: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.AccountDeletionNotification, 0)
|
||||
for rows.Next() {
|
||||
var n domain.AccountDeletionNotification
|
||||
if err := rows.Scan(&n.ID, &n.TargetUserID, &n.DeletedUserID, &n.Attempts); err != nil {
|
||||
return nil, fmt.Errorf("scan account deletion notification: %w", err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *AccountLifecycleStore) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE account_deletion_notifications
|
||||
SET status = 'delivered', lease_until = NULL, last_error = '', updated_at = $2 WHERE id = $1`, id, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete account deletion notification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type accountDeletionRowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanAccountDeletionRequest(row accountDeletionRowScanner) (domain.AccountDeletionRequest, error) {
|
||||
var (
|
||||
r domain.AccountDeletionRequest
|
||||
authKey int64
|
||||
state string
|
||||
digest []byte
|
||||
completedAt *time.Time
|
||||
)
|
||||
if err := row.Scan(&r.ID, &r.UserID, &authKey, &state, &r.Reason, &digest,
|
||||
&r.RequestedAt, &r.ExecuteAt, &completedAt); err != nil {
|
||||
return domain.AccountDeletionRequest{}, err
|
||||
}
|
||||
if len(digest) != len(r.ConfirmHashDigest) {
|
||||
return domain.AccountDeletionRequest{}, fmt.Errorf("invalid account deletion digest length %d", len(digest))
|
||||
}
|
||||
copy(r.ConfirmHashDigest[:], digest)
|
||||
r.RequesterAuthKeyID = authKeyIDFromInt64(authKey)
|
||||
r.State = domain.AccountDeletionRequestState(state)
|
||||
if completedAt != nil {
|
||||
r.CompletedAt = *completedAt
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func pendingAccountDeletion(ctx context.Context, db interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}, userID int64, digest []byte) (domain.AccountDeletionRequest, bool, error) {
|
||||
query := `SELECT id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest,
|
||||
requested_at, execute_at, completed_at FROM account_deletion_requests
|
||||
WHERE user_id = $1 AND state = 'pending'`
|
||||
args := []any{userID}
|
||||
if digest != nil {
|
||||
query += ` AND confirm_hash_digest = $2`
|
||||
args = append(args, digest)
|
||||
}
|
||||
r, err := scanAccountDeletionRequest(db.QueryRow(ctx, query, args...))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AccountDeletionRequest{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AccountDeletionRequest{}, false, fmt.Errorf("load pending account deletion: %w", err)
|
||||
}
|
||||
return r, true, nil
|
||||
}
|
||||
|
||||
func pendingAccountDeletionForUpdate(ctx context.Context, tx pgx.Tx, userID int64, digest []byte) (domain.AccountDeletionRequest, bool, error) {
|
||||
row := tx.QueryRow(ctx, `SELECT id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest,
|
||||
requested_at, execute_at, completed_at FROM account_deletion_requests
|
||||
WHERE user_id = $1 AND confirm_hash_digest = $2 AND state = 'pending' FOR UPDATE`, userID, digest)
|
||||
r, err := scanAccountDeletionRequest(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AccountDeletionRequest{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock pending account deletion: %w", err)
|
||||
}
|
||||
return r, true, nil
|
||||
}
|
||||
|
||||
func validAccountDeletionSource(source domain.AccountDeletionSource) bool {
|
||||
switch source {
|
||||
case domain.AccountDeletionManual, domain.AccountDeletionForgotPassword, domain.AccountDeletionTOSDecline,
|
||||
domain.AccountDeletionPasswordResetExpiry, domain.AccountDeletionAccountTTL, domain.AccountDeletionFreezeExpiry:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// accountDeletionStillDue closes the list-then-execute race for every scheduled
|
||||
// source. The user row is already locked; source-specific facts are read and,
|
||||
// where applicable, locked again immediately before destructive work begins.
|
||||
// Manual sources are admission decisions made by the caller and have no
|
||||
// independently mutable deadline to revalidate.
|
||||
func accountDeletionStillDue(ctx context.Context, tx pgx.Tx, user domain.User, source domain.AccountDeletionSource, now time.Time) (bool, error) {
|
||||
switch source {
|
||||
case domain.AccountDeletionManual, domain.AccountDeletionForgotPassword, domain.AccountDeletionTOSDecline:
|
||||
return true, nil
|
||||
case domain.AccountDeletionPasswordResetExpiry:
|
||||
var requestID int64
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT id FROM account_deletion_requests
|
||||
WHERE user_id = $1 AND state = 'pending' AND execute_at <= $2
|
||||
ORDER BY execute_at, id LIMIT 1 FOR UPDATE`, user.ID, now).Scan(&requestID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("revalidate pending account deletion: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
case domain.AccountDeletionAccountTTL:
|
||||
return !user.AccountDeleteAt.IsZero() && !user.AccountDeleteAt.After(now), nil
|
||||
case domain.AccountDeletionFreezeExpiry:
|
||||
var frozen bool
|
||||
var frozenUntil *time.Time
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT frozen, frozen_until FROM account_restrictions WHERE user_id = $1 FOR UPDATE`, user.ID).Scan(&frozen, &frozenUntil)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("revalidate frozen account deletion: %w", err)
|
||||
}
|
||||
return frozen && frozenUntil != nil && !frozenUntil.After(now), nil
|
||||
default:
|
||||
return false, domain.ErrAccountDeletionForbidden
|
||||
}
|
||||
}
|
||||
|
||||
func truncateUTF8Bytes(value string, maxBytes int) string {
|
||||
if maxBytes < 1 {
|
||||
return ""
|
||||
}
|
||||
if len(value) <= maxBytes {
|
||||
return value
|
||||
}
|
||||
cut := maxBytes
|
||||
for cut > 0 && !utf8.ValidString(value[:cut]) {
|
||||
cut--
|
||||
}
|
||||
return value[:cut]
|
||||
}
|
||||
|
||||
func enqueueAccountDeletionNotifications(ctx context.Context, tx pgx.Tx, userID int64) error {
|
||||
const maxAccountDeletionNotificationAudience = 4096
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO account_deletion_notifications (target_user_id, deleted_user_id)
|
||||
SELECT audience.user_id, $1
|
||||
FROM (
|
||||
SELECT user_id
|
||||
FROM (
|
||||
SELECT contact_user_id AS user_id, 0 AS priority, 0 AS activity
|
||||
FROM contacts WHERE user_id = $1
|
||||
UNION ALL
|
||||
SELECT user_id, 0, 0 FROM contacts WHERE contact_user_id = $1
|
||||
UNION ALL
|
||||
SELECT peer_id, 1, top_message_date
|
||||
FROM dialogs WHERE user_id = $1 AND peer_type = 'user'
|
||||
UNION ALL
|
||||
SELECT user_id, 1, top_message_date
|
||||
FROM dialogs WHERE peer_type = 'user' AND peer_id = $1
|
||||
) candidates
|
||||
GROUP BY user_id
|
||||
ORDER BY min(priority), max(activity) DESC, user_id
|
||||
LIMIT $2
|
||||
) audience
|
||||
JOIN users u ON u.id = audience.user_id
|
||||
WHERE audience.user_id <> $1 AND u.deleted_at IS NULL
|
||||
ON CONFLICT (target_user_id, deleted_user_id) DO NOTHING`, userID, maxAccountDeletionNotificationAudience)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enqueue account deletion notifications: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func revokeOneAuthorizationTx(ctx context.Context, tx pgx.Tx, userID int64, authKeyID [8]byte) ([]domain.Authorization, error) {
|
||||
id := authKeyIDToInt64(authKeyID)
|
||||
if id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := lockPermanentAuthIdentities(ctx, tx, []int64{id}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var locked int64
|
||||
if err := tx.QueryRow(ctx, `SELECT auth_key_id FROM auth_keys WHERE auth_key_id = $1 FOR UPDATE`, id).Scan(&locked); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("lock password reset auth key: %w", err)
|
||||
}
|
||||
a, found, err := scanRevokedAuthorization(tx.QueryRow(ctx, `
|
||||
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version,
|
||||
api_id, app_version, ip, password_pending, created_at, active_at
|
||||
FROM authorizations WHERE auth_key_id = $1 AND user_id = $2 FOR UPDATE`, id, userID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load password reset authorization: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return nil, nil
|
||||
}
|
||||
if err := deleteRevocationTargetsTx(ctx, tx, []int64{id}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []domain.Authorization{a}, nil
|
||||
}
|
||||
|
||||
func purgeDeletedAccountPrivateState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
|
||||
// Leave shared private_messages/channel_messages and immutable transaction
|
||||
// ledgers intact. Only the deleted user's private projections and settings are
|
||||
// removed; other users continue to reference the tombstone sender.
|
||||
statements := []string{
|
||||
`DELETE FROM account_privacy_rules WHERE owner_user_id = $1`,
|
||||
`DELETE FROM account_reaction_settings WHERE user_id = $1`,
|
||||
`DELETE FROM account_restrictions WHERE user_id = $1`,
|
||||
`DELETE FROM account_settings WHERE user_id = $1`,
|
||||
`DELETE FROM account_passwords WHERE user_id = $1`,
|
||||
`DELETE FROM notify_settings WHERE owner_user_id = $1`,
|
||||
`DELETE FROM passkey_credentials WHERE user_id = $1`,
|
||||
`DELETE FROM contacts WHERE user_id = $1 OR contact_user_id = $1`,
|
||||
`DELETE FROM contact_blocks WHERE owner_user_id = $1 OR blocked_user_id = $1`,
|
||||
`DELETE FROM dialog_drafts WHERE user_id = $1`,
|
||||
`DELETE FROM dialog_filter_settings WHERE user_id = $1`,
|
||||
`DELETE FROM dialog_filters WHERE user_id = $1`,
|
||||
`DELETE FROM chatlist_memberships WHERE user_id = $1 OR owner_user_id = $1`,
|
||||
`DELETE FROM chatlist_invites WHERE owner_user_id = $1`,
|
||||
`DELETE FROM saved_dialog_pins WHERE user_id = $1`,
|
||||
`DELETE FROM message_box_media WHERE owner_user_id = $1`,
|
||||
`DELETE FROM private_media_category_counts WHERE owner_user_id = $1`,
|
||||
`DELETE FROM message_boxes WHERE owner_user_id = $1`,
|
||||
`DELETE FROM dialogs WHERE user_id = $1`,
|
||||
`DELETE FROM dispatch_outbox WHERE target_user_id = $1`,
|
||||
`DELETE FROM dispatch_outbox_user_heads WHERE target_user_id = $1`,
|
||||
`DELETE FROM user_update_events WHERE user_id = $1`,
|
||||
`DELETE FROM user_update_retention WHERE user_id = $1`,
|
||||
`DELETE FROM user_update_watermarks WHERE user_id = $1`,
|
||||
`DELETE FROM update_states WHERE user_id = $1`,
|
||||
`DELETE FROM bootstrap_update_jobs WHERE user_id = $1`,
|
||||
`DELETE FROM scheduled_messages WHERE owner_user_id = $1`,
|
||||
`DELETE FROM quick_reply_messages WHERE owner_user_id = $1`,
|
||||
`DELETE FROM quick_replies WHERE owner_user_id = $1`,
|
||||
`DELETE FROM saved_music WHERE user_id = $1`,
|
||||
`DELETE FROM user_sticker_collections WHERE owner_user_id = $1`,
|
||||
`DELETE FROM user_sticker_sets WHERE owner_user_id = $1`,
|
||||
`DELETE FROM user_recent_reactions WHERE user_id = $1`,
|
||||
`DELETE FROM user_saved_reaction_tags WHERE user_id = $1`,
|
||||
`DELETE FROM user_top_reactions WHERE user_id = $1`,
|
||||
`DELETE FROM theme_user_installs WHERE user_id = $1`,
|
||||
`DELETE FROM peer_translation_settings WHERE user_id = $1`,
|
||||
`DELETE FROM ai_compose_tone_saves WHERE user_id = $1`,
|
||||
`DELETE FROM ai_compose_tones WHERE owner_user_id = $1`,
|
||||
`DELETE FROM business_automation_deliveries WHERE owner_user_id = $1 OR peer_user_id = $1`,
|
||||
`DELETE FROM business_connected_bot_peer_states WHERE owner_user_id = $1 OR peer_user_id = $1`,
|
||||
`DELETE FROM business_connected_bots WHERE owner_user_id = $1`,
|
||||
`DELETE FROM business_chat_links WHERE owner_user_id = $1`,
|
||||
`DELETE FROM user_business_profiles WHERE user_id = $1`,
|
||||
`DELETE FROM attach_menu_user_states WHERE user_id = $1`,
|
||||
`DELETE FROM bot_emoji_status_permissions WHERE user_id = $1`,
|
||||
`DELETE FROM bot_user_permissions WHERE user_id = $1`,
|
||||
`DELETE FROM login_code_message_deliveries WHERE user_id = $1`,
|
||||
`DELETE FROM webview_custom_method_queries WHERE user_id = $1`,
|
||||
`DELETE FROM webview_requested_buttons WHERE user_id = $1`,
|
||||
`DELETE FROM profile_photos WHERE owner_peer_type = 'user' AND owner_peer_id = $1`,
|
||||
`DELETE FROM story_views WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
|
||||
`DELETE FROM story_exposures WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
|
||||
`DELETE FROM story_read_states WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
|
||||
`DELETE FROM story_hidden_peers WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
|
||||
`DELETE FROM stories WHERE owner_peer_type = 'user' AND owner_peer_id = $1`,
|
||||
`DELETE FROM group_call_schedule_subscribers WHERE user_id = $1`,
|
||||
`DELETE FROM group_call_participants WHERE user_id = $1`,
|
||||
`DELETE FROM group_call_invites WHERE inviter_user_id = $1 OR invitee_user_id = $1`,
|
||||
`DELETE FROM channel_boost_slots WHERE user_id = $1`,
|
||||
`DELETE FROM channel_invite_importers WHERE user_id = $1`,
|
||||
`DELETE FROM channel_topic_read WHERE user_id = $1`,
|
||||
`DELETE FROM channel_unread_mentions WHERE user_id = $1`,
|
||||
`DELETE FROM channel_unread_mention_index WHERE user_id = $1`,
|
||||
`DELETE FROM channel_dialogs WHERE user_id = $1`,
|
||||
`DELETE FROM user_channel_member_index WHERE user_id = $1`,
|
||||
`DELETE FROM account_deletion_notifications WHERE target_user_id = $1`,
|
||||
`DELETE FROM uploaded_media_receipts WHERE owner_user_id = $1`,
|
||||
`DELETE FROM upload_parts WHERE owner_user_id = $1`,
|
||||
`DELETE FROM encrypted_files WHERE owner_user_id = $1`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := tx.Exec(ctx, statement, userID); err != nil {
|
||||
return fmt.Errorf("purge deleted account private state (%s): %w", statement, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
WITH changed AS (
|
||||
UPDATE channel_members
|
||||
SET status = 'left', left_at = $2, unread_mark = false, updated_at = $3
|
||||
WHERE user_id = $1 AND status = 'active'
|
||||
RETURNING channel_id, role
|
||||
), counts AS (
|
||||
SELECT channel_id, count(*) AS participants,
|
||||
count(*) FILTER (WHERE role IN ('creator', 'admin')) AS admins
|
||||
FROM changed GROUP BY channel_id
|
||||
)
|
||||
UPDATE channels c
|
||||
SET participants_count = GREATEST(0, c.participants_count - counts.participants::int),
|
||||
admins_count = GREATEST(0, c.admins_count - counts.admins::int),
|
||||
updated_at = $3
|
||||
FROM counts WHERE c.id = counts.channel_id`, userID, int(now.Unix()), now); err != nil {
|
||||
return fmt.Errorf("leave deleted account channels: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE secret_chats SET state = 'discarded', history_deleted = true,
|
||||
g_a = ''::bytea, g_b = ''::bytea, key_fingerprint = 0
|
||||
WHERE admin_user_id = $1 OR participant_user_id = $1`, userID); err != nil {
|
||||
return fmt.Errorf("discard deleted account secret chats: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func settleDeletedAccountFinancialState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
|
||||
nowUnix := int(now.Unix())
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id, buyer_user_id, currency, amount
|
||||
FROM star_gift_offers
|
||||
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND status = 'pending'
|
||||
ORDER BY id FOR UPDATE`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock deleted account gift offers: %w", err)
|
||||
}
|
||||
type offer struct {
|
||||
id, buyer, amount int64
|
||||
currency string
|
||||
}
|
||||
offers := make([]offer, 0)
|
||||
for rows.Next() {
|
||||
var o offer
|
||||
if err := rows.Scan(&o.id, &o.buyer, &o.currency, &o.amount); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("scan deleted account gift offer: %w", err)
|
||||
}
|
||||
offers = append(offers, o)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
for _, o := range offers {
|
||||
var balance int64
|
||||
if o.currency == "XTR" {
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO stars_balances (user_id, balance) VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO UPDATE SET balance = stars_balances.balance + EXCLUDED.balance, updated_at = now()
|
||||
RETURNING balance`, o.buyer, o.amount).Scan(&balance); err != nil {
|
||||
return fmt.Errorf("refund deleted account stars offer: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO stars_transactions
|
||||
(user_id, peer_type, peer_id, amount, reason, title, description, date)
|
||||
VALUES ($1, 'user', $2, $3, 'gift_offer_refund_account_deleted', 'Gift offer refunded', '', $4)`, o.buyer, userID, o.amount, nowUnix); err != nil {
|
||||
return fmt.Errorf("record deleted account stars refund: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO UPDATE SET balance_nanoton = ton_balances.balance_nanoton + EXCLUDED.balance_nanoton, updated_at = now()
|
||||
RETURNING balance_nanoton`, o.buyer, o.amount).Scan(&balance); err != nil {
|
||||
return fmt.Errorf("refund deleted account TON offer: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions
|
||||
(user_id, amount_nanoton, reason, peer_type, peer_id, date)
|
||||
VALUES ($1, $2, 'gift_offer_refund_account_deleted', 'user', $3, $4)`, o.buyer, o.amount, userID, nowUnix); err != nil {
|
||||
return fmt.Errorf("record deleted account TON refund: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE star_gift_offers
|
||||
SET status = 'cancelled', resolved_at = $2, balance_after = $3
|
||||
WHERE id = $1 AND status = 'pending'`, o.id, nowUnix, balance); err != nil {
|
||||
return fmt.Errorf("cancel deleted account gift offer: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE star_gift_offers
|
||||
SET status = 'cancelled', resolved_at = $2, balance_after = 0
|
||||
WHERE buyer_user_id = $1 AND status = 'pending'`, userID, nowUnix); err != nil {
|
||||
return fmt.Errorf("cancel deleted buyer gift offers: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests
|
||||
SET status = 'failed', completed_at = $2 WHERE owner_user_id = $1 AND status = 'pending'`, userID, nowUnix); err != nil {
|
||||
return fmt.Errorf("fail deleted account withdrawals: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE star_gift_auction_bids SET active = false, version = version + 1
|
||||
WHERE bidder_user_id = $1 AND active = true`, userID); err != nil {
|
||||
return fmt.Errorf("deactivate deleted account auction bids: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts
|
||||
SET burned = true, owner_name = '', updated_at = $2
|
||||
WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, userID, now); err != nil {
|
||||
return fmt.Errorf("burn deleted account unique gifts: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts
|
||||
SET lifecycle_status = 'burned', unsaved = true, pinned_order = 0
|
||||
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND unique_gift_id IS NOT NULL`, userID); err != nil {
|
||||
return fmt.Errorf("burn deleted account saved gifts: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM peer_star_gifts
|
||||
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND unique_gift_id IS NULL`, userID); err != nil {
|
||||
return fmt.Errorf("delete deleted account regular gifts: %w", err)
|
||||
}
|
||||
var stars int64
|
||||
if err := tx.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1 FOR UPDATE`, userID).Scan(&stars); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("lock deleted account stars balance: %w", err)
|
||||
}
|
||||
if stars != 0 {
|
||||
if _, err := tx.Exec(ctx, `UPDATE stars_balances SET balance = 0, updated_at = $2 WHERE user_id = $1`, userID, now); err != nil {
|
||||
return fmt.Errorf("zero deleted account stars: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO stars_transactions
|
||||
(user_id, peer_type, peer_id, amount, reason, title, description, date)
|
||||
VALUES ($1, 'user', $1, $2, 'account_deleted', 'Account deleted', '', $3)`, userID, -stars, nowUnix); err != nil {
|
||||
return fmt.Errorf("record deleted account stars clearing: %w", err)
|
||||
}
|
||||
}
|
||||
var ton int64
|
||||
if err := tx.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id = $1 FOR UPDATE`, userID).Scan(&ton); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("lock deleted account TON balance: %w", err)
|
||||
}
|
||||
if ton != 0 {
|
||||
if _, err := tx.Exec(ctx, `UPDATE ton_balances SET balance_nanoton = 0, updated_at = $2 WHERE user_id = $1`, userID, now); err != nil {
|
||||
return fmt.Errorf("zero deleted account TON: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions
|
||||
(user_id, amount_nanoton, reason, date) VALUES ($1, $2, 'account_deleted', $3)`, userID, -ton, nowUnix); err != nil {
|
||||
return fmt.Errorf("record deleted account TON clearing: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
289
internal/store/postgres/account_lifecycle_integration_test.go
Normal file
289
internal/store/postgres/account_lifecycle_integration_test.go
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestAccountLifecycleScheduleCancelAndTombstonePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
nonce := time.Now().UnixNano()
|
||||
users := NewUserStore(pool)
|
||||
deleted := createTestUser(t, ctx, users, fmt.Sprintf("15571%d", nonce), "Delete", "Me")
|
||||
peer := createTestUser(t, ctx, users, fmt.Sprintf("15572%d", nonce), "Keep", "Peer")
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM stars_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM ton_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM stars_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM ton_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_notifications WHERE target_user_id = ANY($1) OR deleted_user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_requests WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM private_messages WHERE sender_user_id = ANY($1) OR recipient_user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||
})
|
||||
|
||||
authOne := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 1)
|
||||
authTwo := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 2)
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO contacts
|
||||
(user_id, contact_user_id, contact_phone, contact_first_name, contact_last_name)
|
||||
VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err != nil {
|
||||
t.Fatalf("insert reverse contact: %v", err)
|
||||
}
|
||||
if _, err := NewMessageStore(pool).SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: deleted.ID, RecipientUserID: peer.ID, RandomID: nonce, Message: "keep shared history",
|
||||
}); err != nil {
|
||||
t.Fatalf("send shared message: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO account_settings (user_id, account_ttl_days) VALUES ($1, 30)`, deleted.ID); err != nil {
|
||||
t.Fatalf("insert account settings: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO stars_balances (user_id, balance) VALUES ($1, 50)`, deleted.ID); err != nil {
|
||||
t.Fatalf("insert stars balance: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, 100)`, deleted.ID); err != nil {
|
||||
t.Fatalf("insert TON balance: %v", err)
|
||||
}
|
||||
|
||||
lifecycle := NewAccountLifecycleStore(pool)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
digestOne := sha256.Sum256([]byte("confirm-one"))
|
||||
pending, created, err := lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{
|
||||
UserID: deleted.ID, RequesterAuthKeyID: authOne, Reason: "Forgot password",
|
||||
ConfirmHashDigest: digestOne, ServiceMessage: "tg://confirmphone?phone=hidden&hash=confirm-one",
|
||||
RequestedAt: now, ExecuteAt: now.Add(7 * 24 * time.Hour),
|
||||
})
|
||||
if err != nil || !created || pending.UserID != deleted.ID {
|
||||
t.Fatalf("schedule deletion = %+v created=%v err=%v", pending, created, err)
|
||||
}
|
||||
if got, found, err := lifecycle.PendingAccountDeletionByHash(ctx, deleted.ID, digestOne); err != nil || !found || got.ID != pending.ID {
|
||||
t.Fatalf("pending deletion by hash = %+v found=%v err=%v", got, found, err)
|
||||
}
|
||||
revoked, err := lifecycle.CancelAccountDeletion(ctx, deleted.ID, digestOne, now.Add(time.Minute))
|
||||
if err != nil || len(revoked) != 1 || revoked[0].AuthKeyID != authOne {
|
||||
t.Fatalf("cancel deletion revoked=%+v err=%v", revoked, err)
|
||||
}
|
||||
if _, found, err := NewAuthKeyStore(pool).Get(ctx, authOne); err != nil || found {
|
||||
t.Fatalf("requester auth key after cancel found=%v err=%v, want revoked", found, err)
|
||||
}
|
||||
if _, found, err := NewAuthKeyStore(pool).Get(ctx, authTwo); err != nil || !found {
|
||||
t.Fatalf("other auth key after cancel found=%v err=%v, want retained", found, err)
|
||||
}
|
||||
|
||||
result, err := lifecycle.ExecuteAccountDeletion(ctx, deleted.ID, domain.AccountDeletionManual, "manual", now.Add(2*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("execute account deletion: %v", err)
|
||||
}
|
||||
if !result.Changed || !result.User.Deleted || result.User.Phone != "" || result.User.FirstName != "" || len(result.RevokedAuthorizations) != 1 {
|
||||
t.Fatalf("deletion result = %+v", result)
|
||||
}
|
||||
if _, found, err := users.ByPhone(ctx, deleted.Phone); err != nil || found {
|
||||
t.Fatalf("released phone found=%v err=%v", found, err)
|
||||
}
|
||||
if tombstone, found, err := users.ByID(ctx, deleted.ID); err != nil || !found || !tombstone.Deleted || tombstone.FirstName != "" {
|
||||
t.Fatalf("tombstone = %+v found=%v err=%v", tombstone, found, err)
|
||||
}
|
||||
if _, err := users.UpdateProfile(ctx, deleted.ID, "Resurrected", "", ""); err == nil {
|
||||
t.Fatal("deleted account profile mutation unexpectedly succeeded")
|
||||
}
|
||||
history, err := NewMessageStore(pool).ListByUser(ctx, peer.ID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil || len(history.Messages) != 1 || history.Messages[0].Body != "keep shared history" || history.Messages[0].From.ID != deleted.ID {
|
||||
t.Fatalf("peer history after deletion = %+v err=%v", history, err)
|
||||
}
|
||||
var peerBoxes, settings, contacts, notifications int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1 AND from_user_id = $2`, peer.ID, deleted.ID).Scan(&peerBoxes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_settings WHERE user_id = $1`, deleted.ID).Scan(&settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM contacts WHERE user_id = $1 OR contact_user_id = $1`, deleted.ID).Scan(&contacts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_deletion_notifications WHERE target_user_id = $1 AND deleted_user_id = $2`, peer.ID, deleted.ID).Scan(¬ifications); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if peerBoxes != 1 || settings != 0 || contacts != 0 || notifications != 1 {
|
||||
t.Fatalf("post-delete state peerBoxes=%d settings=%d contacts=%d notifications=%d", peerBoxes, settings, contacts, notifications)
|
||||
}
|
||||
var stars, ton, starClear, tonClear int64
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1`, deleted.ID).Scan(&stars); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id = $1`, deleted.ID).Scan(&ton); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount), 0) FROM stars_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&starClear); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount_nanoton), 0) FROM ton_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&tonClear); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stars != 0 || ton != 0 || starClear != -50 || tonClear != -100 {
|
||||
t.Fatalf("financial clearing stars=%d ton=%d star_tx=%d ton_tx=%d", stars, ton, starClear, tonClear)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountLifecycleDueSourcesAndTTLWatermarkPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
nonce := time.Now().UnixNano()
|
||||
users := NewUserStore(pool)
|
||||
ttlUser := createTestUser(t, ctx, users, fmt.Sprintf("15671%d", nonce), "TTL", "User")
|
||||
freezeUser := createTestUser(t, ctx, users, fmt.Sprintf("15672%d", nonce), "Frozen", "User")
|
||||
pendingUser := createTestUser(t, ctx, users, fmt.Sprintf("15673%d", nonce), "Pending", "User")
|
||||
ids := []int64{ttlUser.ID, freezeUser.ID, pendingUser.ID}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_notifications WHERE target_user_id = ANY($1) OR deleted_user_id = ANY($1)`, ids)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_requests WHERE user_id = ANY($1)`, ids)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, ids)
|
||||
})
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
if _, err := pool.Exec(ctx, `UPDATE users SET account_delete_at = $2 WHERE id = $1`, ttlUser.ID, now.Add(-time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO account_restrictions
|
||||
(user_id, frozen, reason, actor, command_id, frozen_since, frozen_until, appeal_url)
|
||||
VALUES ($1, true, 'abuse', 'test', 'freeze-test', $2, $3, 'https://example.test/appeal')`, freezeUser.ID, now.Add(-time.Hour), now.Add(-time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256([]byte("due-pending"))
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO account_deletion_requests
|
||||
(user_id, requester_auth_key_id, reason, confirm_hash_digest, requested_at, execute_at)
|
||||
VALUES ($1, 123, 'forgot', $2, $3, $4)`, pendingUser.ID, digest[:], now.Add(-8*24*time.Hour), now.Add(-time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
candidates, err := NewAccountLifecycleStore(pool).DueAccountDeletions(ctx, now, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sources := make(map[int64]domain.AccountDeletionSource, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
sources[candidate.UserID] = candidate.Source
|
||||
}
|
||||
if sources[ttlUser.ID] != domain.AccountDeletionAccountTTL || sources[freezeUser.ID] != domain.AccountDeletionFreezeExpiry || sources[pendingUser.ID] != domain.AccountDeletionPasswordResetExpiry {
|
||||
t.Fatalf("due sources = %+v", sources)
|
||||
}
|
||||
seen := now.Add(time.Hour)
|
||||
if err := users.UpdateLastSeen(ctx, ttlUser.ID, int(seen.Unix())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lifecycle := NewAccountLifecycleStore(pool)
|
||||
if stale, err := lifecycle.ExecuteAccountDeletion(ctx, ttlUser.ID, domain.AccountDeletionAccountTTL, "", now); err != nil || stale.Changed {
|
||||
t.Fatalf("stale TTL candidate changed=%v err=%v", stale.Changed, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE account_restrictions SET frozen_until = $2, updated_at = $3 WHERE user_id = $1`, freezeUser.ID, now.Add(24*time.Hour), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stale, err := lifecycle.ExecuteAccountDeletion(ctx, freezeUser.ID, domain.AccountDeletionFreezeExpiry, "", now); err != nil || stale.Changed {
|
||||
t.Fatalf("extended freeze candidate changed=%v err=%v", stale.Changed, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE account_deletion_requests SET state = 'cancelled', completed_at = $2, updated_at = $2 WHERE user_id = $1`, pendingUser.ID, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stale, err := lifecycle.ExecuteAccountDeletion(ctx, pendingUser.ID, domain.AccountDeletionPasswordResetExpiry, "", now); err != nil || stale.Changed {
|
||||
t.Fatalf("cancelled pending candidate changed=%v err=%v", stale.Changed, err)
|
||||
}
|
||||
var deadline time.Time
|
||||
if err := pool.QueryRow(ctx, `SELECT account_delete_at FROM users WHERE id = $1`, ttlUser.ID).Scan(&deadline); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := seen.Add(365 * 24 * time.Hour); deadline.Sub(want) > time.Second || want.Sub(deadline) > time.Second {
|
||||
t.Fatalf("TTL watermark deadline=%v want=%v", deadline, want)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO account_settings (user_id, account_ttl_days) VALUES ($1, 30)
|
||||
ON CONFLICT (user_id) DO UPDATE SET account_ttl_days = EXCLUDED.account_ttl_days`, ttlUser.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT account_delete_at FROM users WHERE id = $1`, ttlUser.ID).Scan(&deadline); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := seen.Add(30 * 24 * time.Hour); deadline.Sub(want) > time.Second || want.Sub(deadline) > time.Second {
|
||||
t.Fatalf("custom TTL deadline=%v want=%v", deadline, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountPasswordChangedAtIgnoresSRPChallengeRotationPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
user := createTestUser(t, ctx, NewUserStore(pool), fmt.Sprintf("15771%d", time.Now().UnixNano()), "Password", "Clock")
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, user.ID) })
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO account_passwords
|
||||
(user_id, has_password, current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p, srp_verifier, srp_id, srp_b)
|
||||
VALUES ($1, true, '\x01', '\x02', 3, '\x03', '\x04', 10, '\x05')`, user.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var initial, afterChallenge, afterPassword time.Time
|
||||
if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&initial); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `SELECT pg_sleep(0.02)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_id = 11, srp_b = '\x06', updated_at = now() WHERE user_id = $1`, user.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&afterChallenge); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !afterChallenge.Equal(initial) {
|
||||
t.Fatalf("SRP challenge rotation changed password clock: initial=%v after=%v", initial, afterChallenge)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `SELECT pg_sleep(0.02)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_verifier = '\x07', updated_at = now() WHERE user_id = $1`, user.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&afterPassword); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !afterPassword.After(afterChallenge) {
|
||||
t.Fatalf("password verifier change did not advance clock: before=%v after=%v", afterChallenge, afterPassword)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateAccountDeletionReasonUTF8(t *testing.T) {
|
||||
got := truncateUTF8Bytes(strings.Repeat("界", 400), 1024)
|
||||
if !utf8.ValidString(got) || len(got) > 1024 {
|
||||
t.Fatalf("truncateUTF8Bytes returned invalid result: valid=%v bytes=%d", utf8.ValidString(got), len(got))
|
||||
}
|
||||
if got == "" {
|
||||
t.Fatal("truncateUTF8Bytes unexpectedly removed the whole reason")
|
||||
}
|
||||
}
|
||||
|
||||
func saveLifecycleTestAuthorization(t *testing.T, ctx context.Context, db *pgxpool.Pool, userID int64, marker byte) [8]byte {
|
||||
t.Helper()
|
||||
var id [8]byte
|
||||
var value [256]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id[0] = marker
|
||||
if _, err := rand.Read(value[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewAuthKeyStore(db).Save(ctx, store.AuthKeyData{ID: id, Value: value}); err != nil {
|
||||
t.Fatalf("save lifecycle auth key: %v", err)
|
||||
}
|
||||
if err := NewAuthorizationStore(db).Bind(ctx, domain.Authorization{AuthKeyID: id, UserID: userID, Hash: int64(marker)}); err != nil {
|
||||
t.Fatalf("bind lifecycle authorization: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
|
@ -183,6 +183,22 @@ func TestBotStoreRoundTripPostgres(t *testing.T) {
|
|||
if flagBot, _, _ := bots.GetBot(ctx, bot1.ID); !flagBot.Nochats || !flagBot.ChatHistory {
|
||||
t.Fatalf("flags = nochats=%v chat_history=%v, want both true", flagBot.Nochats, flagBot.ChatHistory)
|
||||
}
|
||||
requestedButton := domain.BotRequestedWebViewButton{
|
||||
WebAppReqID: fmt.Sprintf("pg-requested-%d", suffix), BotUserID: bot1.ID, UserID: owner.ID,
|
||||
ButtonID: 45, Text: "Share", PeerType: "user", MaxQuantity: 2,
|
||||
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
|
||||
CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = bots.DeleteRequestedWebViewButton(ctx, bot1.ID, owner.ID, requestedButton.WebAppReqID)
|
||||
})
|
||||
if err := bots.SaveRequestedWebViewButton(ctx, requestedButton); err != nil {
|
||||
t.Fatalf("save requested button: %v", err)
|
||||
}
|
||||
storedButton, found, err := bots.GetRequestedWebViewButton(ctx, bot1.ID, owner.ID, requestedButton.WebAppReqID)
|
||||
if err != nil || !found || !storedButton.NameRequested || !storedButton.UsernameRequested || !storedButton.PhotoRequested {
|
||||
t.Fatalf("requested button=%#v found=%v err=%v", storedButton, found, err)
|
||||
}
|
||||
if can, err := bots.CanBotSendMessage(ctx, bot1.ID, owner.ID); err != nil || can {
|
||||
t.Fatalf("CanBotSendMessage before allow = %v,%v, want false,nil", can, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -416,16 +416,29 @@ func (s *BotStore) SaveRequestedWebViewButton(ctx context.Context, button domain
|
|||
if button.BotUserID == 0 || button.UserID == 0 || button.WebAppReqID == "" || button.ExpiresAt.IsZero() {
|
||||
return domain.ErrBotRequestedButtonInvalid
|
||||
}
|
||||
_, err := s.db.Exec(ctx, `
|
||||
INSERT INTO webview_requested_buttons (webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
peerFilter, err := json.Marshal(button.PeerFilter)
|
||||
if err != nil {
|
||||
return domain.ErrBotRequestedButtonInvalid
|
||||
}
|
||||
_, err = s.db.Exec(ctx, `
|
||||
INSERT INTO webview_requested_buttons (
|
||||
webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity,
|
||||
peer_filter, name_requested, username_requested, photo_requested, created_at, expires_at
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
ON CONFLICT (webapp_req_id) DO UPDATE SET
|
||||
button_id=EXCLUDED.button_id,
|
||||
text=EXCLUDED.text,
|
||||
peer_type=EXCLUDED.peer_type,
|
||||
max_quantity=EXCLUDED.max_quantity,
|
||||
peer_filter=EXCLUDED.peer_filter,
|
||||
name_requested=EXCLUDED.name_requested,
|
||||
username_requested=EXCLUDED.username_requested,
|
||||
photo_requested=EXCLUDED.photo_requested,
|
||||
expires_at=EXCLUDED.expires_at`,
|
||||
button.WebAppReqID, button.BotUserID, button.UserID, button.ButtonID, button.Text, button.PeerType, button.MaxQuantity, button.CreatedAt, button.ExpiresAt)
|
||||
button.WebAppReqID, button.BotUserID, button.UserID, button.ButtonID, button.Text,
|
||||
button.PeerType, button.MaxQuantity, peerFilter, button.NameRequested,
|
||||
button.UsernameRequested, button.PhotoRequested, button.CreatedAt, button.ExpiresAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save requested webview button: %w", err)
|
||||
}
|
||||
|
|
@ -435,18 +448,28 @@ ON CONFLICT (webapp_req_id) DO UPDATE SET
|
|||
func (s *BotStore) GetRequestedWebViewButton(ctx context.Context, botUserID, userID int64, webAppReqID string) (domain.BotRequestedWebViewButton, bool, error) {
|
||||
_, _ = s.db.Exec(ctx, `DELETE FROM webview_requested_buttons WHERE expires_at <= now()`)
|
||||
var button domain.BotRequestedWebViewButton
|
||||
var peerFilter []byte
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at
|
||||
SELECT webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity,
|
||||
peer_filter, name_requested, username_requested, photo_requested, created_at, expires_at
|
||||
FROM webview_requested_buttons
|
||||
WHERE bot_user_id=$1 AND user_id=$2 AND webapp_req_id=$3 AND expires_at > now()`,
|
||||
botUserID, userID, webAppReqID).
|
||||
Scan(&button.WebAppReqID, &button.BotUserID, &button.UserID, &button.ButtonID, &button.Text, &button.PeerType, &button.MaxQuantity, &button.CreatedAt, &button.ExpiresAt)
|
||||
Scan(&button.WebAppReqID, &button.BotUserID, &button.UserID, &button.ButtonID,
|
||||
&button.Text, &button.PeerType, &button.MaxQuantity, &peerFilter,
|
||||
&button.NameRequested, &button.UsernameRequested, &button.PhotoRequested,
|
||||
&button.CreatedAt, &button.ExpiresAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.BotRequestedWebViewButton{}, false, nil
|
||||
}
|
||||
return domain.BotRequestedWebViewButton{}, false, fmt.Errorf("get requested webview button: %w", err)
|
||||
}
|
||||
if string(peerFilter) != "{}" && string(peerFilter) != "null" {
|
||||
if err := json.Unmarshal(peerFilter, &button.PeerFilter); err != nil {
|
||||
return domain.BotRequestedWebViewButton{}, false, fmt.Errorf("decode requested webview button filter: %w", err)
|
||||
}
|
||||
}
|
||||
return button, true, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
|
@ -20,17 +23,331 @@ func NewBotAPIUpdateStore(db sqlcgen.DBTX) *BotAPIUpdateStore {
|
|||
return &BotAPIUpdateStore{db: db}
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) SetBotAPIWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error {
|
||||
if config.BotUserID <= 0 || config.URL == "" || config.MaxConnections < 1 || config.MaxConnections > 100 {
|
||||
return fmt.Errorf("invalid bot api webhook")
|
||||
}
|
||||
var allowed []string
|
||||
if len(config.AllowedUpdates) > 0 {
|
||||
allowed = make([]string, 0, len(config.AllowedUpdates))
|
||||
for _, kind := range config.AllowedUpdates {
|
||||
if kind != "" {
|
||||
allowed = append(allowed, string(kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
WITH policy AS (
|
||||
SELECT CASE WHEN $6::boolean THEN $5::text[]
|
||||
ELSE (SELECT allowed_updates FROM bot_api_update_states WHERE bot_user_id = $1)
|
||||
END AS allowed_updates
|
||||
), configured AS (
|
||||
INSERT INTO bot_api_webhooks (
|
||||
bot_user_id, url, secret_token, max_connections, allowed_updates,
|
||||
failure_count, last_error_date, last_error_message, next_attempt_at,
|
||||
delivery_owner, delivery_expires_at, updated_at
|
||||
)
|
||||
SELECT $1, $2, $3, $4, allowed_updates, 0, 0, '', now(), '', NULL, now()
|
||||
FROM policy
|
||||
ON CONFLICT (bot_user_id) DO UPDATE
|
||||
SET url = EXCLUDED.url,
|
||||
secret_token = EXCLUDED.secret_token,
|
||||
max_connections = EXCLUDED.max_connections,
|
||||
allowed_updates = EXCLUDED.allowed_updates,
|
||||
failure_count = 0,
|
||||
last_error_date = 0,
|
||||
last_error_message = '',
|
||||
next_attempt_at = now(),
|
||||
delivery_owner = '',
|
||||
delivery_expires_at = NULL,
|
||||
updated_at = now()
|
||||
RETURNING bot_user_id
|
||||
), boundary AS (
|
||||
SELECT CASE WHEN $7::boolean THEN COALESCE(MAX(id), 0) ELSE 0 END AS confirmed_update_id
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1
|
||||
)
|
||||
INSERT INTO bot_api_update_states (
|
||||
bot_user_id, confirmed_update_id, allowed_updates, cursor_initialized
|
||||
)
|
||||
SELECT $1, confirmed_update_id, policy.allowed_updates, $7::boolean
|
||||
FROM boundary, configured, policy
|
||||
ON CONFLICT (bot_user_id) DO UPDATE
|
||||
SET confirmed_update_id = CASE WHEN $7::boolean
|
||||
THEN GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id)
|
||||
ELSE bot_api_update_states.confirmed_update_id
|
||||
END,
|
||||
allowed_updates = EXCLUDED.allowed_updates,
|
||||
cursor_initialized = bot_api_update_states.cursor_initialized OR EXCLUDED.cursor_initialized,
|
||||
updated_at = now()
|
||||
`, config.BotUserID, config.URL, config.SecretToken, config.MaxConnections, allowed,
|
||||
config.AllowedUpdatesSet, dropPending); err != nil {
|
||||
return fmt.Errorf("set bot api webhook: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) DeleteBotAPIWebhook(ctx context.Context, botUserID int64, dropPending bool) error {
|
||||
if botUserID <= 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
WITH deleted AS (
|
||||
DELETE FROM bot_api_webhooks WHERE bot_user_id = $1 RETURNING bot_user_id
|
||||
), boundary AS (
|
||||
SELECT CASE WHEN $2::boolean THEN COALESCE(MAX(id), 0) ELSE 0 END AS confirmed_update_id
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1
|
||||
)
|
||||
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized)
|
||||
SELECT $1, confirmed_update_id, $2::boolean
|
||||
FROM boundary
|
||||
ON CONFLICT (bot_user_id) DO UPDATE
|
||||
SET confirmed_update_id = CASE WHEN $2::boolean
|
||||
THEN GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id)
|
||||
ELSE bot_api_update_states.confirmed_update_id
|
||||
END,
|
||||
cursor_initialized = bot_api_update_states.cursor_initialized OR EXCLUDED.cursor_initialized,
|
||||
updated_at = now()
|
||||
`, botUserID, dropPending); err != nil {
|
||||
return fmt.Errorf("delete bot api webhook: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) BotAPIWebhook(ctx context.Context, botUserID int64) (domain.BotAPIWebhook, bool, error) {
|
||||
config, err := scanBotAPIWebhook(s.db.QueryRow(ctx, `
|
||||
SELECT bot_user_id, url, secret_token, max_connections, allowed_updates,
|
||||
failure_count, last_error_date, last_error_message, next_attempt_at
|
||||
FROM bot_api_webhooks
|
||||
WHERE bot_user_id = $1
|
||||
`, botUserID))
|
||||
if err == pgx.ErrNoRows {
|
||||
return domain.BotAPIWebhook{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.BotAPIWebhook{}, false, fmt.Errorf("get bot api webhook: %w", err)
|
||||
}
|
||||
return config, true, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT bot_user_id, url, secret_token, max_connections, allowed_updates,
|
||||
failure_count, last_error_date, last_error_message, next_attempt_at
|
||||
FROM bot_api_webhooks
|
||||
WHERE next_attempt_at <= now()
|
||||
AND (delivery_owner = '' OR delivery_expires_at <= now())
|
||||
ORDER BY next_attempt_at, bot_user_id
|
||||
LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list due bot api webhooks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.BotAPIWebhook, 0, limit)
|
||||
for rows.Next() {
|
||||
config, err := scanBotAPIWebhook(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, config)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list due bot api webhook rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) AcquireBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) {
|
||||
if botUserID <= 0 || owner == "" || ttl <= 0 {
|
||||
return false, fmt.Errorf("invalid bot api webhook lease")
|
||||
}
|
||||
var acquiredOwner string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
UPDATE bot_api_webhooks
|
||||
SET delivery_owner = $2,
|
||||
delivery_expires_at = now() + make_interval(secs => $3),
|
||||
updated_at = now()
|
||||
WHERE bot_user_id = $1
|
||||
AND (delivery_owner = $2 OR delivery_owner = '' OR delivery_expires_at <= now())
|
||||
RETURNING delivery_owner
|
||||
`, botUserID, owner, int64((ttl+time.Second-1)/time.Second)).Scan(&acquiredOwner)
|
||||
if err == pgx.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("acquire bot api webhook lease: %w", err)
|
||||
}
|
||||
return acquiredOwner == owner, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ReleaseBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string) error {
|
||||
if botUserID <= 0 || owner == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
UPDATE bot_api_webhooks
|
||||
SET delivery_owner = '', delivery_expires_at = NULL, updated_at = now()
|
||||
WHERE bot_user_id = $1 AND delivery_owner = $2
|
||||
`, botUserID, owner); err != nil {
|
||||
return fmt.Errorf("release bot api webhook lease: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) RecordBotAPIWebhookFailure(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time, message string) error {
|
||||
if len(message) > 512 {
|
||||
message = message[:512]
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
UPDATE bot_api_webhooks
|
||||
SET failure_count = failure_count + 1,
|
||||
last_error_date = EXTRACT(EPOCH FROM now())::integer,
|
||||
last_error_message = $3,
|
||||
next_attempt_at = $4,
|
||||
delivery_owner = '', delivery_expires_at = NULL, updated_at = now()
|
||||
WHERE bot_user_id = $1 AND delivery_owner = $2
|
||||
`, botUserID, owner, message, nextAttempt); err != nil {
|
||||
return fmt.Errorf("record bot api webhook failure: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) RecordBotAPIWebhookSuccess(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time) error {
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
UPDATE bot_api_webhooks
|
||||
SET failure_count = 0, last_error_date = 0, last_error_message = '',
|
||||
next_attempt_at = $3, delivery_owner = '', delivery_expires_at = NULL, updated_at = now()
|
||||
WHERE bot_user_id = $1 AND delivery_owner = $2
|
||||
`, botUserID, owner, nextAttempt); err != nil {
|
||||
return fmt.Errorf("record bot api webhook success: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanBotAPIWebhook(row botAPIUpdateScanner) (domain.BotAPIWebhook, error) {
|
||||
var config domain.BotAPIWebhook
|
||||
var allowed []string
|
||||
if err := row.Scan(&config.BotUserID, &config.URL, &config.SecretToken, &config.MaxConnections, &allowed,
|
||||
&config.FailureCount, &config.LastErrorDate, &config.LastErrorMessage, &config.NextAttemptAt); err != nil {
|
||||
return domain.BotAPIWebhook{}, err
|
||||
}
|
||||
if allowed != nil {
|
||||
config.AllowedUpdates = make([]domain.BotAPIUpdateKind, 0, len(allowed))
|
||||
for _, kind := range allowed {
|
||||
config.AllowedUpdates = append(config.AllowedUpdates, domain.BotAPIUpdateKind(kind))
|
||||
}
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) AcquireBotAPIPollLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) {
|
||||
if botUserID <= 0 || owner == "" || ttl <= 0 {
|
||||
return false, fmt.Errorf("invalid bot api poll lease")
|
||||
}
|
||||
var acquiredOwner string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO bot_api_update_states (
|
||||
bot_user_id, confirmed_update_id, poll_owner, poll_expires_at
|
||||
) VALUES ($1, 0, $2, now() + make_interval(secs => $3))
|
||||
ON CONFLICT (bot_user_id) DO UPDATE
|
||||
SET poll_owner = EXCLUDED.poll_owner,
|
||||
poll_expires_at = EXCLUDED.poll_expires_at,
|
||||
updated_at = now()
|
||||
WHERE bot_api_update_states.poll_owner = EXCLUDED.poll_owner
|
||||
OR bot_api_update_states.poll_expires_at IS NULL
|
||||
OR bot_api_update_states.poll_expires_at <= now()
|
||||
RETURNING poll_owner
|
||||
`, botUserID, owner, int64((ttl+time.Second-1)/time.Second)).Scan(&acquiredOwner)
|
||||
if err == pgx.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("acquire bot api poll lease: %w", err)
|
||||
}
|
||||
return acquiredOwner == owner, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ReleaseBotAPIPollLease(ctx context.Context, botUserID int64, owner string) error {
|
||||
if botUserID <= 0 || owner == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
UPDATE bot_api_update_states
|
||||
SET poll_owner = '', poll_expires_at = NULL, updated_at = now()
|
||||
WHERE bot_user_id = $1 AND poll_owner = $2
|
||||
`, botUserID, owner); err != nil {
|
||||
return fmt.Errorf("release bot api poll lease: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(ctx context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) {
|
||||
if err := validateBotAPIUpdateRequest(req); err != nil {
|
||||
return domain.BotAPIUpdate{}, false, err
|
||||
}
|
||||
var callbackQueryID, callbackUserID, callbackChatInstance int64
|
||||
var callbackInlineDCID, callbackInlineMessageID int
|
||||
var callbackInlineOwnerID, callbackInlineAccessHash int64
|
||||
var callbackData []byte
|
||||
var ephemeralPayload []byte
|
||||
if req.Callback != nil {
|
||||
callbackQueryID = req.Callback.ID
|
||||
callbackUserID = req.Callback.UserID
|
||||
callbackChatInstance = req.Callback.ChatInstance
|
||||
callbackData = req.Callback.Data
|
||||
if req.Callback.InlineMessage != nil {
|
||||
callbackInlineDCID = req.Callback.InlineMessage.DCID
|
||||
callbackInlineOwnerID = req.Callback.InlineMessage.OwnerID
|
||||
callbackInlineMessageID = req.Callback.InlineMessage.ID
|
||||
callbackInlineAccessHash = req.Callback.InlineMessage.AccessHash
|
||||
}
|
||||
}
|
||||
if req.Ephemeral != nil {
|
||||
var err error
|
||||
ephemeralPayload, err = json.Marshal(req.Ephemeral)
|
||||
if err != nil {
|
||||
return domain.BotAPIUpdate{}, false, fmt.Errorf("marshal bot api ephemeral payload: %w", err)
|
||||
}
|
||||
}
|
||||
row, err := s.scanBotAPIUpdate(s.db.QueryRow(ctx, `
|
||||
INSERT INTO bot_api_updates (
|
||||
bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts) DO NOTHING
|
||||
RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date
|
||||
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date))
|
||||
WITH inserted AS (
|
||||
INSERT INTO bot_api_updates (
|
||||
bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
|
||||
ephemeral_payload
|
||||
) SELECT $1, $2::varchar(32), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM bot_api_update_states
|
||||
WHERE bot_user_id = $1
|
||||
AND allowed_updates IS NOT NULL
|
||||
AND NOT ($2::text = ANY(allowed_updates))
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
|
||||
ephemeral_payload
|
||||
), wake_webhook AS (
|
||||
UPDATE bot_api_webhooks
|
||||
SET next_attempt_at = now(), updated_at = now()
|
||||
WHERE bot_user_id = $1 AND EXISTS (SELECT 1 FROM inserted)
|
||||
RETURNING bot_user_id
|
||||
)
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
|
||||
ephemeral_payload
|
||||
FROM inserted
|
||||
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date,
|
||||
callbackQueryID, callbackUserID, callbackChatInstance, callbackData,
|
||||
callbackInlineDCID, callbackInlineOwnerID, callbackInlineMessageID, callbackInlineAccessHash, ephemeralPayload))
|
||||
if err == nil {
|
||||
return row, true, nil
|
||||
}
|
||||
|
|
@ -38,21 +355,75 @@ RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_p
|
|||
return domain.BotAPIUpdate{}, false, fmt.Errorf("insert bot api update: %w", err)
|
||||
}
|
||||
row, err = s.scanBotAPIUpdate(s.db.QueryRow(ctx, `
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
|
||||
ephemeral_payload
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1
|
||||
AND update_kind = $2
|
||||
AND peer_type = $3
|
||||
AND peer_id = $4
|
||||
AND message_id = $5
|
||||
AND source_pts = $6
|
||||
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts))
|
||||
AND (
|
||||
(update_kind = 'callback_query' AND callback_query_id = $7)
|
||||
OR
|
||||
(update_kind <> 'callback_query' AND peer_type = $3 AND peer_id = $4 AND message_id = $5 AND (
|
||||
(ephemeral_payload IS NULL AND $8::jsonb IS NULL AND source_pts = $6)
|
||||
OR (ephemeral_payload = $8::jsonb)
|
||||
))
|
||||
)
|
||||
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, callbackQueryID, ephemeralPayload))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return domain.BotAPIUpdate{}, false, nil
|
||||
}
|
||||
return domain.BotAPIUpdate{}, false, fmt.Errorf("select existing bot api update: %w", err)
|
||||
}
|
||||
return row, false, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ListTailBotAPIUpdates(ctx context.Context, botUserID int64, tail, limit int) ([]domain.BotAPIUpdate, error) {
|
||||
if botUserID == 0 || tail <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
|
||||
ephemeral_payload
|
||||
FROM (
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
|
||||
ephemeral_payload
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1
|
||||
AND id > COALESCE((SELECT confirmed_update_id FROM bot_api_update_states WHERE bot_user_id = $1), 0)
|
||||
ORDER BY id DESC
|
||||
LIMIT $2
|
||||
) AS tail_updates
|
||||
ORDER BY id
|
||||
LIMIT $3
|
||||
`, botUserID, tail, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list bot api tail updates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.BotAPIUpdate, 0, limit)
|
||||
for rows.Next() {
|
||||
item, err := scanBotAPIUpdateRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list bot api tail update rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error) {
|
||||
if botUserID == 0 {
|
||||
return nil, nil
|
||||
|
|
@ -64,7 +435,10 @@ func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fr
|
|||
limit = 100
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
|
||||
ephemeral_payload
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1 AND id >= $2
|
||||
ORDER BY id
|
||||
|
|
@ -93,23 +467,98 @@ func (s *BotAPIUpdateStore) ConfirmBotAPIUpdates(ctx context.Context, botUserID,
|
|||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id)
|
||||
VALUES ($1, $2)
|
||||
WITH bounded AS (
|
||||
SELECT COALESCE(MAX(id), 0) AS max_update_id,
|
||||
LEAST($2::bigint, COALESCE(MAX(id), 0)) AS confirmed_update_id
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1
|
||||
)
|
||||
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized)
|
||||
SELECT $1, confirmed_update_id, true
|
||||
FROM bounded
|
||||
ON CONFLICT (bot_user_id) DO UPDATE
|
||||
SET confirmed_update_id = GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id),
|
||||
SET confirmed_update_id = GREATEST(
|
||||
bot_api_update_states.confirmed_update_id,
|
||||
CASE
|
||||
WHEN $2::bigint > (SELECT max_update_id FROM bounded)
|
||||
AND bot_api_update_states.cursor_initialized
|
||||
THEN bot_api_update_states.confirmed_update_id
|
||||
ELSE EXCLUDED.confirmed_update_id
|
||||
END
|
||||
),
|
||||
cursor_initialized = true,
|
||||
updated_at = now()
|
||||
WHERE bot_api_update_states.confirmed_update_id < EXCLUDED.confirmed_update_id
|
||||
`, botUserID, confirmedUpdateID); err != nil {
|
||||
return fmt.Errorf("confirm bot api updates: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) SetBotAPIAllowedUpdates(ctx context.Context, botUserID int64, allowed []domain.BotAPIUpdateKind) error {
|
||||
if botUserID == 0 {
|
||||
return nil
|
||||
}
|
||||
var values []string
|
||||
if len(allowed) > 0 {
|
||||
values = make([]string, 0, len(allowed))
|
||||
for _, kind := range allowed {
|
||||
if kind != "" {
|
||||
values = append(values, string(kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, allowed_updates)
|
||||
VALUES ($1, 0, $2::text[])
|
||||
ON CONFLICT (bot_user_id) DO UPDATE
|
||||
SET allowed_updates = EXCLUDED.allowed_updates,
|
||||
updated_at = now()
|
||||
`, botUserID, values); err != nil {
|
||||
return fmt.Errorf("set bot api allowed updates: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) DropPendingBotAPIUpdates(ctx context.Context, botUserID int64) error {
|
||||
if botUserID == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized)
|
||||
SELECT $1, COALESCE(MAX(id), 0), true
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1
|
||||
ON CONFLICT (bot_user_id) DO UPDATE
|
||||
SET confirmed_update_id = GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id),
|
||||
cursor_initialized = true,
|
||||
updated_at = now()
|
||||
`, botUserID); err != nil {
|
||||
return fmt.Errorf("drop pending bot api updates: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) PendingBotAPIUpdateCount(ctx context.Context, botUserID int64) (int, error) {
|
||||
if botUserID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int
|
||||
if err := s.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1
|
||||
AND id > COALESCE((SELECT confirmed_update_id FROM bot_api_update_states WHERE bot_user_id = $1), 0)
|
||||
`, botUserID).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("count pending bot api updates: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// DeleteDeliveredOrExpired 回收 Bot API 投递队列的死行(性能审计 H1):
|
||||
// 1. 已确认(id <= bot_api_update_states.confirmed_update_id)且入队超过 confirmedGrace 的行——
|
||||
// 官方 Bot API 语义下确认即弃,getUpdates 的 fromID 恒 > confirmed,删除不影响任何读路径;
|
||||
// 宽限仅防御 offset 回拨调试场景。
|
||||
// 2. 按消息 date 超过 maxAge 的行(无论确认与否)——对齐官方「updates 服务器最多保留 24 小时」
|
||||
// 2. 按队列 created_at 超过 maxAge 的行(无论确认与否)——对齐官方「updates 服务器最多保留 24 小时」
|
||||
// 语义,同时封顶 MTProto-only bot(从不调 getUpdates、无 state 行)成员身份带来的无界增长。
|
||||
//
|
||||
// 与 user_update_events 的「永久保留」约束无关:那是 TDesktop 账号级 differenceTooLong 缺陷所迫,
|
||||
|
|
@ -139,15 +588,15 @@ WHERE id IN (
|
|||
total += int(tag.RowsAffected())
|
||||
}
|
||||
if maxAge > 0 {
|
||||
cutoff := time.Now().Add(-maxAge).Unix()
|
||||
// 走 bot_api_updates_retention_idx(date, id)。
|
||||
cutoff := time.Now().Add(-maxAge)
|
||||
// 走 bot_api_updates_created_retention_idx(created_at, id)。
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
DELETE FROM bot_api_updates
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM bot_api_updates
|
||||
WHERE date < $1
|
||||
ORDER BY date, id
|
||||
WHERE created_at < $1
|
||||
ORDER BY created_at, id
|
||||
LIMIT $2
|
||||
)`, cutoff, limit)
|
||||
if err != nil {
|
||||
|
|
@ -187,28 +636,113 @@ type botAPIUpdateScanner interface {
|
|||
func scanBotAPIUpdateRows(row botAPIUpdateScanner) (domain.BotAPIUpdate, error) {
|
||||
var item domain.BotAPIUpdate
|
||||
var kind, peerType string
|
||||
if err := row.Scan(&item.ID, &item.BotUserID, &kind, &peerType, &item.Peer.ID, &item.MessageID, &item.SourcePts, &item.Date); err != nil {
|
||||
var callbackQueryID, callbackUserID, callbackChatInstance int64
|
||||
var callbackInlineDCID, callbackInlineMessageID int
|
||||
var callbackInlineOwnerID, callbackInlineAccessHash int64
|
||||
var callbackData []byte
|
||||
var ephemeralPayload []byte
|
||||
if err := row.Scan(&item.ID, &item.BotUserID, &kind, &peerType, &item.Peer.ID, &item.MessageID, &item.SourcePts, &item.Date,
|
||||
&callbackQueryID, &callbackUserID, &callbackChatInstance, &callbackData,
|
||||
&callbackInlineDCID, &callbackInlineOwnerID, &callbackInlineMessageID, &callbackInlineAccessHash,
|
||||
&ephemeralPayload); err != nil {
|
||||
return domain.BotAPIUpdate{}, err
|
||||
}
|
||||
item.Kind = domain.BotAPIUpdateKind(kind)
|
||||
item.Peer.Type = domain.PeerType(peerType)
|
||||
if item.Kind == domain.BotAPIUpdateCallbackQuery {
|
||||
item.Callback = &domain.BotCallbackQuery{
|
||||
ID: callbackQueryID,
|
||||
BotUserID: item.BotUserID,
|
||||
UserID: callbackUserID,
|
||||
Peer: item.Peer,
|
||||
MessageID: item.MessageID,
|
||||
ChatInstance: callbackChatInstance,
|
||||
Data: append([]byte(nil), callbackData...),
|
||||
}
|
||||
if callbackInlineMessageID > 0 {
|
||||
item.Callback.InlineMessage = &domain.BotInlineMessageID{DCID: callbackInlineDCID, OwnerID: callbackInlineOwnerID, ID: callbackInlineMessageID, AccessHash: callbackInlineAccessHash}
|
||||
}
|
||||
}
|
||||
if len(ephemeralPayload) != 0 {
|
||||
var payload domain.BotAPIEphemeralPayload
|
||||
decoder := json.NewDecoder(bytes.NewReader(ephemeralPayload))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return domain.BotAPIUpdate{}, fmt.Errorf("decode bot api ephemeral payload: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return domain.BotAPIUpdate{}, fmt.Errorf("decode bot api ephemeral payload: trailing JSON")
|
||||
}
|
||||
if err := validateBotAPIEphemeralPayload(item.BotUserID, item.Kind, item.Peer, item.MessageID, item.SourcePts, item.Date, &payload); err != nil {
|
||||
return domain.BotAPIUpdate{}, err
|
||||
}
|
||||
item.Ephemeral = &payload
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error {
|
||||
if req.BotUserID == 0 || req.MessageID <= 0 {
|
||||
if req.BotUserID == 0 {
|
||||
return fmt.Errorf("invalid bot api update")
|
||||
}
|
||||
if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage {
|
||||
if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage && req.Kind != domain.BotAPIUpdateCallbackQuery {
|
||||
return fmt.Errorf("invalid bot api update kind %q", req.Kind)
|
||||
}
|
||||
switch req.Peer.Type {
|
||||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||||
if req.Peer.ID <= 0 {
|
||||
if req.Peer.ID <= 0 || req.MessageID <= 0 {
|
||||
return fmt.Errorf("invalid bot api update peer")
|
||||
}
|
||||
case "":
|
||||
if req.Kind != domain.BotAPIUpdateCallbackQuery || req.Peer.ID != 0 || req.MessageID != 0 {
|
||||
return fmt.Errorf("invalid bot api update peer")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type)
|
||||
}
|
||||
if err := validateBotAPIEphemeralPayload(req.BotUserID, req.Kind, req.Peer, req.MessageID, req.SourcePts, req.Date, req.Ephemeral); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Kind == domain.BotAPIUpdateCallbackQuery {
|
||||
cb := req.Callback
|
||||
if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 ||
|
||||
cb.Peer != req.Peer || cb.MessageID != req.MessageID || cb.ChatInstance == 0 ||
|
||||
len(cb.Data) > domain.MaxCallbackDataLen || req.SourcePts != 0 {
|
||||
return fmt.Errorf("invalid bot api callback query")
|
||||
}
|
||||
inline := cb.InlineMessage
|
||||
if req.MessageID == 0 && (inline == nil || inline.DCID <= 0 || inline.OwnerID <= 0 || inline.ID <= 0 || inline.AccessHash == 0) {
|
||||
return fmt.Errorf("invalid bot api inline callback query")
|
||||
}
|
||||
if req.MessageID > 0 && inline != nil {
|
||||
return fmt.Errorf("ambiguous bot api callback query")
|
||||
}
|
||||
} else if req.Callback != nil {
|
||||
return fmt.Errorf("unexpected bot api callback query")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBotAPIEphemeralPayload(botUserID int64, kind domain.BotAPIUpdateKind, peer domain.Peer, messageID, sourcePts, date int, payload *domain.BotAPIEphemeralPayload) error {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
message := payload.Message
|
||||
if payload.Validate() != nil || peer.Type != domain.PeerTypeChannel || message.ID != messageID || message.Peer != peer ||
|
||||
message.Expired(time.Unix(int64(date), 0)) || sourcePts != 0 {
|
||||
return fmt.Errorf("invalid bot api ephemeral update")
|
||||
}
|
||||
if kind == domain.BotAPIUpdateCallbackQuery {
|
||||
if message.SenderUserID != botUserID {
|
||||
return fmt.Errorf("invalid bot api ephemeral callback target")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if kind != domain.BotAPIUpdateMessage && kind != domain.BotAPIUpdateEditedMessage {
|
||||
return fmt.Errorf("invalid bot api ephemeral update kind")
|
||||
}
|
||||
if message.ReceiverUserID != botUserID {
|
||||
return fmt.Errorf("invalid bot api ephemeral receiver")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -8,10 +9,292 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestBotAPICallbackQueryQueueRoundTrip(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
bot, err := users.Create(ctx, domain.User{
|
||||
AccessHash: 921, Phone: "+1921" + suffix + "01", FirstName: "CallbackQueueBot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create bot user: %v", err)
|
||||
}
|
||||
clicker, err := users.Create(ctx, domain.User{
|
||||
AccessHash: 922, Phone: "+1922" + suffix + "02", FirstName: "CallbackClicker",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create callback user: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO bots (bot_user_id, owner_user_id, token_secret)
|
||||
VALUES ($1, $1, 'callback-queue-secret')`, bot.ID); err != nil {
|
||||
t.Fatalf("seed bot: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
|
||||
})
|
||||
|
||||
callback := &domain.BotCallbackQuery{
|
||||
ID: 880011, BotUserID: bot.ID, UserID: clicker.ID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: clicker.ID}, MessageID: 17,
|
||||
ChatInstance: 990022, Data: []byte{0, 1, 0xff, 'x'},
|
||||
}
|
||||
req := domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery,
|
||||
Peer: callback.Peer, MessageID: callback.MessageID, Date: int(time.Now().Unix()), Callback: callback,
|
||||
}
|
||||
store := NewBotAPIUpdateStore(pool)
|
||||
first, created, err := store.EnqueueBotAPIUpdate(ctx, req)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("enqueue callback: row=%+v created=%v err=%v", first, created, err)
|
||||
}
|
||||
again, created, err := store.EnqueueBotAPIUpdate(ctx, req)
|
||||
if err != nil || created || again.ID != first.ID {
|
||||
t.Fatalf("dedupe callback: row=%+v created=%v err=%v", again, created, err)
|
||||
}
|
||||
items, err := store.ListBotAPIUpdates(ctx, bot.ID, first.ID, 100)
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("list callback = %+v, %v", items, err)
|
||||
}
|
||||
got := items[0].Callback
|
||||
if got == nil || got.ID != callback.ID || got.BotUserID != bot.ID || got.UserID != clicker.ID ||
|
||||
got.Peer != callback.Peer || got.MessageID != callback.MessageID || got.ChatInstance != callback.ChatInstance ||
|
||||
!bytes.Equal(got.Data, callback.Data) {
|
||||
t.Fatalf("callback round trip = %+v, want %+v", got, callback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIInlineCallbackAndWebhookStateRoundTrip(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
bot, err := users.Create(ctx, domain.User{AccessHash: 931, Phone: "+1931" + suffix + "01", FirstName: "WebhookBot"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clicker, err := users.Create(ctx, domain.User{AccessHash: 932, Phone: "+1932" + suffix + "02", FirstName: "InlineClicker"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'webhook-secret')`, bot.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_webhooks WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
|
||||
})
|
||||
|
||||
s := NewBotAPIUpdateStore(pool)
|
||||
inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: clicker.ID, ID: 17, AccessHash: 445566}
|
||||
callback := &domain.BotCallbackQuery{
|
||||
ID: 9911, BotUserID: bot.ID, UserID: clicker.ID, ChatInstance: 8811,
|
||||
Data: []byte{0, 1, 0xff}, InlineMessage: inline,
|
||||
}
|
||||
row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: callback,
|
||||
})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("enqueue inline callback row=%#v created=%v err=%v", row, created, err)
|
||||
}
|
||||
items, err := s.ListBotAPIUpdates(ctx, bot.ID, row.ID, 100)
|
||||
if err != nil || len(items) != 1 || items[0].Peer != (domain.Peer{}) || items[0].MessageID != 0 ||
|
||||
items[0].Callback == nil || items[0].Callback.InlineMessage == nil || *items[0].Callback.InlineMessage != *inline ||
|
||||
!bytes.Equal(items[0].Callback.Data, callback.Data) {
|
||||
t.Fatalf("inline callback items=%#v err=%v", items, err)
|
||||
}
|
||||
|
||||
config := domain.BotAPIWebhook{
|
||||
BotUserID: bot.ID, URL: "https://example.test/hook", SecretToken: "safe_secret",
|
||||
MaxConnections: 8, AllowedUpdates: []domain.BotAPIUpdateKind{domain.BotAPIUpdateCallbackQuery}, AllowedUpdatesSet: true,
|
||||
}
|
||||
if err := s.SetBotAPIWebhook(ctx, config, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, found, err := s.BotAPIWebhook(ctx, bot.ID)
|
||||
if err != nil || !found || stored.URL != config.URL || stored.SecretToken != config.SecretToken ||
|
||||
stored.MaxConnections != 8 || len(stored.AllowedUpdates) != 1 {
|
||||
t.Fatalf("webhook=%#v found=%v err=%v", stored, found, err)
|
||||
}
|
||||
config.URL = "https://example.test/reconfigured"
|
||||
config.AllowedUpdates = nil
|
||||
config.AllowedUpdatesSet = false
|
||||
if err := s.SetBotAPIWebhook(ctx, config, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, found, err = s.BotAPIWebhook(ctx, bot.ID)
|
||||
if err != nil || !found || stored.URL != config.URL || len(stored.AllowedUpdates) != 1 || stored.AllowedUpdates[0] != domain.BotAPIUpdateCallbackQuery {
|
||||
t.Fatalf("preserved webhook=%#v found=%v err=%v", stored, found, err)
|
||||
}
|
||||
if acquired, err := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "one", time.Minute); err != nil || !acquired {
|
||||
t.Fatalf("first lease=%v err=%v", acquired, err)
|
||||
}
|
||||
if acquired, err := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "two", time.Minute); err != nil || acquired {
|
||||
t.Fatalf("second lease=%v err=%v", acquired, err)
|
||||
}
|
||||
if err := s.ReleaseBotAPIWebhookLease(ctx, bot.ID, "stale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acquired, _ := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "two", time.Minute); acquired {
|
||||
t.Fatal("stale webhook release removed active lease")
|
||||
}
|
||||
next := time.Now().Add(time.Hour)
|
||||
if err := s.RecordBotAPIWebhookSuccess(ctx, bot.ID, "one", next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if due, err := s.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 0 {
|
||||
t.Fatalf("idle due=%#v err=%v", due, err)
|
||||
}
|
||||
// A newly inserted allowed callback wakes the idle webhook in the same SQL statement.
|
||||
callback2 := *callback
|
||||
callback2.ID++
|
||||
callback2.InlineMessage = &domain.BotInlineMessageID{DCID: 2, OwnerID: clicker.ID, ID: 18, AccessHash: 556677}
|
||||
if _, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: &callback2,
|
||||
}); err != nil || !created {
|
||||
t.Fatalf("enqueue wake created=%v err=%v", created, err)
|
||||
}
|
||||
if due, err := s.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 1 || due[0].BotUserID != bot.ID {
|
||||
t.Fatalf("woken due=%#v err=%v", due, err)
|
||||
}
|
||||
if err := s.DeleteBotAPIWebhook(ctx, bot.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, err := s.BotAPIWebhook(ctx, bot.ID); err != nil || found {
|
||||
t.Fatalf("webhook after delete found=%v err=%v", found, err)
|
||||
}
|
||||
if pending, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || pending != 0 {
|
||||
t.Fatalf("pending after delete/drop=%d err=%v", pending, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIPollLeaseCrossStoreInstance(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
bot, err := users.Create(ctx, domain.User{AccessHash: 933, Phone: "+1933" + suffix + "01", FirstName: "PollLeaseBot"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'poll-lease-secret')`, bot.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
|
||||
})
|
||||
a, b := NewBotAPIUpdateStore(pool), NewBotAPIUpdateStore(pool)
|
||||
if acquired, err := a.AcquireBotAPIPollLease(ctx, bot.ID, "one", time.Minute); err != nil || !acquired {
|
||||
t.Fatalf("first acquire=%v err=%v", acquired, err)
|
||||
}
|
||||
if acquired, err := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); err != nil || acquired {
|
||||
t.Fatalf("cross-instance acquire=%v err=%v", acquired, err)
|
||||
}
|
||||
if err := b.ReleaseBotAPIPollLease(ctx, bot.ID, "stale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acquired, _ := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); acquired {
|
||||
t.Fatal("stale release removed active poll lease")
|
||||
}
|
||||
if err := a.ReleaseBotAPIPollLease(ctx, bot.ID, "one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acquired, err := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); err != nil || !acquired {
|
||||
t.Fatalf("successor acquire=%v err=%v", acquired, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIPollingStateClampFilterTailAndDrop(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
users := NewUserStore(pool)
|
||||
suffix := randomSuffix(t)
|
||||
bot, err := users.Create(ctx, domain.User{
|
||||
AccessHash: 923, Phone: "+1923" + suffix + "01", FirstName: "PollingStateBot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create bot user: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'poll-state-secret')`, bot.ID); err != nil {
|
||||
t.Fatalf("seed bot: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
|
||||
})
|
||||
|
||||
s := NewBotAPIUpdateStore(pool)
|
||||
enqueue := func(kind domain.BotAPIUpdateKind, messageID int) (domain.BotAPIUpdate, bool) {
|
||||
t.Helper()
|
||||
row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: bot.ID, Kind: kind,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bot.ID + 1},
|
||||
MessageID: messageID, SourcePts: messageID, Date: int(time.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue %s/%d: %v", kind, messageID, err)
|
||||
}
|
||||
return row, created
|
||||
}
|
||||
for id := 1; id <= 3; id++ {
|
||||
if _, created := enqueue(domain.BotAPIUpdateMessage, id); !created {
|
||||
t.Fatalf("initial message %d was not created", id)
|
||||
}
|
||||
}
|
||||
if err := s.SetBotAPIAllowedUpdates(ctx, bot.ID, []domain.BotAPIUpdateKind{domain.BotAPIUpdateEditedMessage}); err != nil {
|
||||
t.Fatalf("set allowed updates: %v", err)
|
||||
}
|
||||
if row, created := enqueue(domain.BotAPIUpdateMessage, 4); created || row.ID != 0 {
|
||||
t.Fatalf("filtered row=%+v created=%v", row, created)
|
||||
}
|
||||
lastBeforeBaseline, created := enqueue(domain.BotAPIUpdateEditedMessage, 5)
|
||||
if !created {
|
||||
t.Fatal("allowed edit was filtered")
|
||||
}
|
||||
if err := s.ConfirmBotAPIUpdates(ctx, bot.ID, 1<<60); err != nil {
|
||||
t.Fatalf("initialize external cursor: %v", err)
|
||||
}
|
||||
confirmed, found, err := s.ConfirmedBotAPIUpdateID(ctx, bot.ID)
|
||||
if err != nil || !found || confirmed != lastBeforeBaseline.ID {
|
||||
t.Fatalf("baseline confirmed=%d found=%v err=%v want=%d", confirmed, found, err, lastBeforeBaseline.ID)
|
||||
}
|
||||
pendingRow, created := enqueue(domain.BotAPIUpdateEditedMessage, 6)
|
||||
if !created {
|
||||
t.Fatal("post-baseline edit was filtered")
|
||||
}
|
||||
if err := s.ConfirmBotAPIUpdates(ctx, bot.ID, 1<<60); err != nil {
|
||||
t.Fatalf("repeat external cursor: %v", err)
|
||||
}
|
||||
confirmed, _, _ = s.ConfirmedBotAPIUpdateID(ctx, bot.ID)
|
||||
if confirmed != lastBeforeBaseline.ID {
|
||||
t.Fatalf("repeat external cursor advanced to %d, want %d", confirmed, lastBeforeBaseline.ID)
|
||||
}
|
||||
tail, err := s.ListTailBotAPIUpdates(ctx, bot.ID, 1, 100)
|
||||
if err != nil || len(tail) != 1 || tail[0].ID != pendingRow.ID {
|
||||
t.Fatalf("tail=%+v err=%v want=%d", tail, err, pendingRow.ID)
|
||||
}
|
||||
if count, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || count != 1 {
|
||||
t.Fatalf("pending count=%d err=%v", count, err)
|
||||
}
|
||||
if err := s.DropPendingBotAPIUpdates(ctx, bot.ID); err != nil {
|
||||
t.Fatalf("drop pending: %v", err)
|
||||
}
|
||||
if count, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || count != 0 {
|
||||
t.Fatalf("pending after drop=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotAPIUpdateRetention 锁定 H1 场景矩阵:
|
||||
// - 已确认 + 超宽限 → 删;已确认 + 宽限内 → 留;
|
||||
// - 未确认 + date 超保留期 → 删(含无 state 行的 MTProto-only bot);
|
||||
// - 未确认 + date 在保留期内 → 留;
|
||||
// - 未确认 + created_at 超保留期 → 删(含无 state 行的 MTProto-only bot);
|
||||
// - 未确认 + created_at 在保留期内 → 留;
|
||||
// - 删除后 getUpdates 读路径(fromID > confirmed)不受影响。
|
||||
func TestBotAPIUpdateRetention(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
|
|
@ -47,7 +330,6 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
|
|||
|
||||
s := NewBotAPIUpdateStore(pool)
|
||||
now := time.Now().Unix()
|
||||
stale := now - int64((48 * time.Hour).Seconds())
|
||||
enqueue := func(botID int64, messageID int, date int64) domain.BotAPIUpdate {
|
||||
t.Helper()
|
||||
row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
|
|
@ -67,7 +349,7 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
|
|||
confirmedOld := enqueue(confirmedBot, 1, now) // 已确认 + created_at 回拨超宽限 → 删
|
||||
confirmedFresh := enqueue(confirmedBot, 2, now) // 已确认 + 宽限内 → 留
|
||||
unconfirmedFresh := enqueue(confirmedBot, 3, now)
|
||||
expiredNoState := enqueue(mtprotoOnlyBot, 4, stale) // 无 state 行 + date 超保留期 → 删
|
||||
expiredNoState := enqueue(mtprotoOnlyBot, 4, now) // 无 state 行 + created_at 超保留期 → 删
|
||||
freshNoState := enqueue(mtprotoOnlyBot, 5, now)
|
||||
|
||||
if err := s.ConfirmBotAPIUpdates(ctx, confirmedBot, confirmedFresh.ID); err != nil {
|
||||
|
|
@ -77,6 +359,10 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
|
|||
"UPDATE bot_api_updates SET created_at = now() - interval '1 hour' WHERE id = $1", confirmedOld.ID); err != nil {
|
||||
t.Fatalf("backdate confirmed row: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx,
|
||||
"UPDATE bot_api_updates SET created_at = now() - interval '48 hours' WHERE id = $1", expiredNoState.ID); err != nil {
|
||||
t.Fatalf("backdate expired row: %v", err)
|
||||
}
|
||||
|
||||
deleted, err := s.DeleteDeliveredOrExpired(ctx, 15*time.Minute, 24*time.Hour, 1000)
|
||||
if err != nil {
|
||||
|
|
@ -85,7 +371,7 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
|
|||
// 共享测试库可能有其它历史行同被回收,只要求至少删掉本测试的 2 行;
|
||||
// 精确归属由下方 remaining 断言保证。
|
||||
if deleted < 2 {
|
||||
t.Fatalf("deleted = %d, want >= 2 (confirmed+grace expired, date expired)", deleted)
|
||||
t.Fatalf("deleted = %d, want >= 2 (confirmed+grace expired, created_at expired)", deleted)
|
||||
}
|
||||
|
||||
remaining := map[int64]bool{}
|
||||
|
|
@ -120,3 +406,70 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
|
|||
t.Fatalf("post-retention list = %+v, want only unconfirmed fresh row %d", items, unconfirmedFresh.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIEphemeralEnvelopeRoundTrip(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
bot, err := users.Create(ctx, domain.User{AccessHash: 941, Phone: "+1941" + suffix + "01", FirstName: "EphemeralQueueBot"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
human, err := users.Create(ctx, domain.User{AccessHash: 942, Phone: "+1942" + suffix + "02", FirstName: "EphemeralHuman"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'ephemeral-secret')`, bot.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 3901}
|
||||
message := domain.EphemeralMessage{
|
||||
ID: 81, Peer: peer, SenderUserID: human.ID, ReceiverUserID: bot.ID,
|
||||
Date: int(now.Unix()), RandomID: 11, Content: domain.EphemeralContent{Message: "/private"},
|
||||
Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
|
||||
}
|
||||
store := NewBotAPIUpdateStore(pool)
|
||||
request := domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: bot.ID, Kind: domain.BotAPIUpdateMessage, Peer: peer,
|
||||
MessageID: message.ID, Date: message.Date,
|
||||
Ephemeral: domain.NewBotAPIEphemeralPayload(message),
|
||||
}
|
||||
first, created, err := store.EnqueueBotAPIUpdate(ctx, request)
|
||||
if err != nil || !created || first.Ephemeral == nil {
|
||||
t.Fatalf("first=%+v created=%v err=%v", first, created, err)
|
||||
}
|
||||
var leakedPrivateRoutingState bool
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT (ephemeral_payload -> 'Message') ?| ARRAY[
|
||||
'RandomID', 'OriginDevice', 'PayloadHash', 'CreatedAt', 'Deleted'
|
||||
]
|
||||
FROM bot_api_updates WHERE id = $1`, first.ID).Scan(&leakedPrivateRoutingState); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if leakedPrivateRoutingState {
|
||||
t.Fatal("durable Bot API envelope contains private ephemeral routing fields")
|
||||
}
|
||||
if replay, created, err := store.EnqueueBotAPIUpdate(ctx, request); err != nil || created || replay.ID != first.ID {
|
||||
t.Fatalf("replay=%+v created=%v err=%v", replay, created, err)
|
||||
}
|
||||
message.Version, message.EditDate, message.Content.Message = 2, message.Date+1, "edited"
|
||||
request.Kind = domain.BotAPIUpdateEditedMessage
|
||||
request.Ephemeral = domain.NewBotAPIEphemeralPayload(message)
|
||||
second, created, err := store.EnqueueBotAPIUpdate(ctx, request)
|
||||
if err != nil || !created || second.ID <= first.ID {
|
||||
t.Fatalf("second=%+v created=%v err=%v", second, created, err)
|
||||
}
|
||||
rows, err := store.ListBotAPIUpdates(ctx, bot.ID, first.ID, 100)
|
||||
if err != nil || len(rows) != 2 || rows[0].SourcePts != 0 || rows[0].Ephemeral == nil ||
|
||||
rows[0].Ephemeral.Message.Content.Message != "/private" || rows[1].Ephemeral.Message.Content.Message != "edited" {
|
||||
t.Fatalf("rows=%+v err=%v", rows, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -377,6 +377,20 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentIDs := make([]int64, 0, len(channels))
|
||||
for _, channel := range channels {
|
||||
if channel.Monoforum && channel.LinkedMonoforumID != 0 {
|
||||
parentIDs = append(parentIDs, channel.LinkedMonoforumID)
|
||||
}
|
||||
}
|
||||
parents, err := listChannelsByIDs(ctx, s.db, parentIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentsByID := make(map[int64]domain.Channel, len(parents))
|
||||
for _, parent := range parents {
|
||||
parentsByID[parent.ID] = parent
|
||||
}
|
||||
linkedGuests, err := s.listLinkedDiscussionGuests(ctx, s.db, viewerUserID, remaining)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -402,6 +416,17 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
|
|||
}
|
||||
continue
|
||||
}
|
||||
if channel.Monoforum && channel.LinkedMonoforumID != 0 {
|
||||
if parent, ok := parentsByID[channel.LinkedMonoforumID]; ok && parent.BroadcastMessagesAllowed && parent.LinkedMonoforumID == channel.ID {
|
||||
member := syntheticMonoforumUserMember(channel, viewerUserID)
|
||||
views[channel.ID] = domain.ChannelView{
|
||||
Channel: channel,
|
||||
Self: member,
|
||||
Dialog: previewChannelDialog(viewerUserID, channel, member),
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !publicPreviewableChannel(channel) {
|
||||
continue
|
||||
}
|
||||
|
|
@ -472,7 +497,7 @@ func scanChannel(row rowScanner) (domain.Channel, error) {
|
|||
func channelScanDest(ch *domain.Channel, rights, reactionPolicy *string, wallpaper **string) []any {
|
||||
return []any{
|
||||
&ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, &ch.Verified,
|
||||
&ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.HasLink, &ch.LinkedChatID, &ch.Monoforum, &ch.LinkedMonoforumID, &ch.SlowmodeSeconds, &ch.BoostsUnrestrict, rights,
|
||||
&ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.HasLink, &ch.LinkedChatID, &ch.LinkedCommunityID, &ch.Monoforum, &ch.LinkedMonoforumID, &ch.SlowmodeSeconds, &ch.BoostsUnrestrict, rights,
|
||||
reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until,
|
||||
wallpaper, &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID,
|
||||
&ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
|
|
@ -53,6 +55,22 @@ func (s *ChannelStore) AppendStarGiftAdminLog(ctx context.Context, channelID, se
|
|||
_ = 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
|
||||
|
|
@ -63,29 +81,14 @@ func (s *ChannelStore) AppendStarGiftAdminLog(ctx context.Context, channelID, se
|
|||
}
|
||||
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,
|
||||
ChannelID: channelID, ID: messageID, SenderUserID: senderUserID,
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, Date: date,
|
||||
Post: channel.Broadcast, Action: &action, Pts: channel.Pts,
|
||||
}
|
||||
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||||
ChannelID: channelID,
|
||||
UserID: senderUserID,
|
||||
Date: date,
|
||||
Type: domain.ChannelAdminLogSendMessage,
|
||||
Message: &msg,
|
||||
}); 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
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -298,12 +298,26 @@ func (s *ChannelStore) ListActiveChannelIDsForUser(ctx context.Context, userID,
|
|||
limit = domain.MaxSynchronousChannelDialogFanout
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT channel_id
|
||||
FROM user_channel_member_index
|
||||
WHERE user_id = $1
|
||||
AND status = 'active'
|
||||
AND NOT deleted
|
||||
AND channel_id > $2
|
||||
WITH visible_channels AS (
|
||||
SELECT channel_id
|
||||
FROM user_channel_member_index
|
||||
WHERE user_id = $1 AND status = 'active' AND NOT deleted
|
||||
UNION
|
||||
SELECT mono.id
|
||||
FROM channels mono
|
||||
JOIN channels parent ON parent.id = mono.linked_monoforum_id
|
||||
AND NOT parent.deleted AND parent.broadcast_messages_allowed AND parent.linked_monoforum_id = mono.id
|
||||
WHERE mono.monoforum AND NOT mono.deleted
|
||||
AND (EXISTS (
|
||||
SELECT 1 FROM channel_members admin
|
||||
WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active' AND admin.role IN ('creator', 'admin')
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM channel_messages message
|
||||
WHERE message.channel_id = mono.id AND message.saved_peer_type = 'user' AND message.saved_peer_id = $1 AND NOT message.deleted
|
||||
))
|
||||
)
|
||||
SELECT channel_id FROM visible_channels
|
||||
WHERE channel_id > $2
|
||||
ORDER BY channel_id
|
||||
LIMIT $3`, userID, afterChannelID, limit)
|
||||
if err != nil {
|
||||
|
|
@ -329,16 +343,31 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(ctx context.Context, userI
|
|||
limit = domain.MaxChannelDifferenceLimit
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT i.channel_id, c.pts
|
||||
FROM user_channel_member_index i
|
||||
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted
|
||||
JOIN channel_update_checkpoints cp ON cp.channel_id = i.channel_id
|
||||
WHERE i.user_id = $1
|
||||
AND i.status = 'active'
|
||||
AND NOT i.deleted
|
||||
AND i.channel_id > $3
|
||||
WITH visible_channels AS (
|
||||
SELECT channel_id
|
||||
FROM user_channel_member_index
|
||||
WHERE user_id = $1 AND status = 'active' AND NOT deleted
|
||||
UNION
|
||||
SELECT mono.id
|
||||
FROM channels mono
|
||||
JOIN channels parent ON parent.id = mono.linked_monoforum_id
|
||||
AND NOT parent.deleted AND parent.broadcast_messages_allowed AND parent.linked_monoforum_id = mono.id
|
||||
WHERE mono.monoforum AND NOT mono.deleted
|
||||
AND (EXISTS (
|
||||
SELECT 1 FROM channel_members admin
|
||||
WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active' AND admin.role IN ('creator', 'admin')
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM channel_messages message
|
||||
WHERE message.channel_id = mono.id AND message.saved_peer_type = 'user' AND message.saved_peer_id = $1 AND NOT message.deleted
|
||||
))
|
||||
)
|
||||
SELECT visible.channel_id, c.pts
|
||||
FROM visible_channels visible
|
||||
JOIN channels c ON c.id = visible.channel_id AND NOT c.deleted
|
||||
JOIN channel_update_checkpoints cp ON cp.channel_id = visible.channel_id
|
||||
WHERE visible.channel_id > $3
|
||||
AND cp.latest_event_date > $2
|
||||
ORDER BY i.channel_id ASC
|
||||
ORDER BY visible.channel_id ASC
|
||||
LIMIT $4`, userID, sinceDate, afterChannelID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list dirty active channels for user: %w", err)
|
||||
|
|
@ -381,6 +410,15 @@ func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX,
|
|||
} else if ok {
|
||||
return ch, member, true, nil
|
||||
}
|
||||
if ch.Monoforum && ch.LinkedMonoforumID != 0 {
|
||||
parent, parentErr := s.channelByID(ctx, db, ch.LinkedMonoforumID)
|
||||
if parentErr != nil {
|
||||
return domain.Channel{}, domain.ChannelMember{}, false, parentErr
|
||||
}
|
||||
if parent.BroadcastMessagesAllowed && parent.LinkedMonoforumID == ch.ID {
|
||||
return ch, syntheticMonoforumUserMember(ch, viewerUserID), true, nil
|
||||
}
|
||||
}
|
||||
if !publicPreviewableChannel(ch) {
|
||||
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs
|
|||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if channel.Monoforum {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported
|
||||
}
|
||||
if !canInviteToChannel(channel, inviter) {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
|
|
|
|||
|
|
@ -544,6 +544,40 @@ LIMIT $2`, userID, domain.MaxAdminedPublicChannels)
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListCommunityLinkableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
|
||||
if userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT i.channel_id
|
||||
FROM user_channel_member_index i
|
||||
JOIN channels c ON c.id=i.channel_id
|
||||
WHERE i.user_id=$1
|
||||
AND i.status='active'
|
||||
AND i.role IN ('creator','admin')
|
||||
AND NOT i.deleted
|
||||
AND NOT c.monoforum
|
||||
AND c.linked_community_id=0
|
||||
ORDER BY i.channel_id DESC
|
||||
LIMIT $2`, userID, domain.MaxCommunityPeers)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list community-linkable channels: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := make([]int64, 0, domain.MaxCommunityPeers)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return listChannelsByIDsInOrder(ctx, s.db, ids)
|
||||
}
|
||||
|
||||
// ListSendAsChannels lists the broadcast channels a user may post messages AS in groups
|
||||
// (channels.getSendAs candidates): channels where the user is the creator, or an admin holding
|
||||
// PostMessages rights. Mirrors ListStoryPostableChannels but is restricted to broadcast channels
|
||||
|
|
|
|||
|
|
@ -478,6 +478,15 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan
|
|||
return member
|
||||
}
|
||||
|
||||
func syntheticMonoforumUserMember(mono domain.Channel, userID int64) domain.ChannelMember {
|
||||
return domain.ChannelMember{
|
||||
ChannelID: mono.ID,
|
||||
UserID: userID,
|
||||
Role: domain.ChannelRoleMember,
|
||||
Status: domain.ChannelMemberActive,
|
||||
}
|
||||
}
|
||||
|
||||
func zeroChannelAdminRights(rights domain.ChannelAdminRights) bool {
|
||||
return rights == domain.ChannelAdminRights{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ func (s *ChannelStore) JoinChannel(ctx context.Context, channelID, userID int64,
|
|||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if channel.Monoforum {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported
|
||||
}
|
||||
existing, existingErr := s.getChannelMember(ctx, tx, channelID, userID)
|
||||
if existingErr == nil {
|
||||
switch {
|
||||
|
|
|
|||
|
|
@ -33,17 +33,19 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) {
|
|||
var richMessageJSON string
|
||||
var savedPeerType string
|
||||
var savedPeerID int64
|
||||
var suggestedPostJSON string
|
||||
if err := row.Scan(
|
||||
&msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID,
|
||||
&sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards,
|
||||
&msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID,
|
||||
&forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON,
|
||||
&replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID,
|
||||
&replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &msg.PaidMessageStars, &suggestedPostJSON,
|
||||
); err != nil {
|
||||
return domain.ChannelMessage{}, err
|
||||
}
|
||||
msg.From.Type = domain.PeerType(fromType)
|
||||
msg.SavedPeer = domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID}
|
||||
msg.SuggestedPost = decodeJSONPtr[domain.SuggestedPost](suggestedPostJSON)
|
||||
if sendAsType.Valid && sendAsID.Valid {
|
||||
msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64}
|
||||
}
|
||||
|
|
@ -90,17 +92,19 @@ func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, er
|
|||
var richMessageJSON string
|
||||
var savedPeerType string
|
||||
var savedPeerID int64
|
||||
var suggestedPostJSON string
|
||||
if err := row.Scan(
|
||||
&msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID,
|
||||
&sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards,
|
||||
&msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID,
|
||||
&forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON,
|
||||
&replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &count,
|
||||
&replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &msg.PaidMessageStars, &suggestedPostJSON, &count,
|
||||
); err != nil {
|
||||
return domain.ChannelMessage{}, 0, err
|
||||
}
|
||||
msg.From.Type = domain.PeerType(fromType)
|
||||
msg.SavedPeer = domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID}
|
||||
msg.SuggestedPost = decodeJSONPtr[domain.SuggestedPost](suggestedPostJSON)
|
||||
if sendAsType.Valid && sendAsID.Valid {
|
||||
msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,12 @@ func (s *ChannelStore) ListChannelHistory(ctx context.Context, viewerUserID int6
|
|||
base := "channel_id = $1 AND NOT deleted"
|
||||
extraChannels := []domain.Channel(nil)
|
||||
if channel.Monoforum {
|
||||
base += " AND saved_peer_id = 0"
|
||||
if isChannelAdmin(member) {
|
||||
base += " AND saved_peer_id = 0"
|
||||
} else {
|
||||
baseArgs = append(baseArgs, viewerUserID)
|
||||
base += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(baseArgs))
|
||||
}
|
||||
if channel.LinkedMonoforumID != 0 {
|
||||
if parent, parentErr := s.channelByID(ctx, s.db, channel.LinkedMonoforumID); parentErr == nil {
|
||||
extraChannels = append(extraChannels, parent)
|
||||
|
|
@ -216,8 +221,12 @@ func (s *ChannelStore) SearchJoinedMessages(ctx context.Context, viewerUserID in
|
|||
if limit <= 0 || limit > domain.MaxChannelGlobalSearchLimit {
|
||||
limit = domain.MaxChannelGlobalSearchLimit
|
||||
}
|
||||
args := []any{viewerUserID}
|
||||
args := []any{viewerUserID, req.AllowPublicPreview}
|
||||
where := `NOT deleted`
|
||||
if req.RestrictChannelIDs {
|
||||
args = append(args, req.ChannelIDs)
|
||||
where += fmt.Sprintf("\nAND channel_id = ANY($%d::bigint[])", len(args))
|
||||
}
|
||||
if query != "" {
|
||||
args = append(args, "%"+escapeLike(query)+"%")
|
||||
where += fmt.Sprintf(`
|
||||
|
|
@ -237,15 +246,18 @@ AND EXISTS (
|
|||
where += `
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM channels c
|
||||
JOIN channel_members cm ON cm.channel_id = c.id
|
||||
AND cm.user_id = $1
|
||||
AND cm.status = 'active'
|
||||
AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false)
|
||||
FROM channels c
|
||||
LEFT JOIN channel_members cm ON cm.channel_id = c.id AND cm.user_id = $1
|
||||
LEFT JOIN channel_dialogs d ON d.channel_id = c.id AND d.user_id = $1
|
||||
WHERE c.id = channel_messages.channel_id
|
||||
AND NOT c.deleted
|
||||
AND (cm.available_min_id <= 0 OR channel_messages.id > cm.available_min_id)`
|
||||
WHERE c.id = channel_messages.channel_id
|
||||
AND NOT c.deleted
|
||||
AND (
|
||||
(cm.status = 'active' AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false))
|
||||
OR ($2::boolean AND COALESCE(c.username,'') <> ''
|
||||
AND COALESCE(cm.status,'') <> 'kicked'
|
||||
AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false))
|
||||
)
|
||||
AND (COALESCE(cm.status,'') <> 'active' OR cm.available_min_id <= 0 OR channel_messages.id > cm.available_min_id)`
|
||||
if req.BroadcastsOnly {
|
||||
where += `
|
||||
AND c.broadcast AND NOT c.megagroup`
|
||||
|
|
|
|||
|
|
@ -470,7 +470,16 @@ WHERE channel_id = $1 AND id = $2`, msg.ChannelID, msg.ID).Scan(
|
|||
Message: replay,
|
||||
SenderUserID: first.SenderUserID,
|
||||
}
|
||||
return domain.SendChannelMessageResult{Channel: channel, Message: replay, Event: event, Duplicate: true, ReplayDeleteEvent: replayDelete}, nil
|
||||
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
|
||||
}
|
||||
|
||||
func (s *ChannelStore) insertServiceMessage(ctx context.Context, tx pgx.Tx, channel domain.Channel, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent, error) {
|
||||
|
|
@ -578,6 +587,10 @@ func insertChannelMessageWithFingerprintTx(ctx context.Context, tx pgx.Tx, msg d
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
suggestedPost, err := marshalJSON(msg.SuggestedPost, "{}")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sendSnapshot := []byte("{}")
|
||||
if msg.RandomID != 0 {
|
||||
sendSnapshot, err = store.EncodeChannelSendSnapshot(msg)
|
||||
|
|
@ -613,12 +626,12 @@ INSERT INTO channel_messages (
|
|||
channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id,
|
||||
send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards,
|
||||
body, entities, reply_to, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
|
||||
fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, rich_message, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id, send_snapshot, request_fingerprint
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38::jsonb,$39::bytea)`,
|
||||
fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, rich_message, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id, paid_message_stars, suggested_post, send_snapshot, request_fingerprint
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39::jsonb,$40::jsonb,$41::bytea)`,
|
||||
msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, string(msg.From.Type), msg.From.ID,
|
||||
sendAsType, sendAsID, msg.Date, msg.EditDate, msg.Post, msg.Silent, msg.NoForwards,
|
||||
msg.Body, entities, reply, replyMsgID, replyPeerType, replyPeerID, replyTopID,
|
||||
forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, richMessage, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID, sendSnapshot, requestFingerprint); err != nil {
|
||||
forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, richMessage, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID, msg.PaidMessageStars, suggestedPost, sendSnapshot, requestFingerprint); err != nil {
|
||||
return fmt.Errorf("insert channel message: %w", err)
|
||||
}
|
||||
// 共享媒体索引(迁移 0118):创建即按媒体类别建索引行,供 messages.search 媒体标签页。
|
||||
|
|
|
|||
|
|
@ -12,14 +12,19 @@ import (
|
|||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const paidMessageChannelCommissionPermille int64 = 850
|
||||
|
||||
// SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。
|
||||
// 私信消息存进 channel_messages(复用 channel pts/事件/difference);发件权限(订阅者身份/管理员)
|
||||
// 由 RPC 层校验,store 只校验 monoforum 频道存在,不要求发件人是成员(订阅者不是 monoforum 成员)。
|
||||
// 私信消息存进 channel_messages(复用 channel pts/事件/difference);store 在写边界再次强制:订阅者
|
||||
// 无需成员记录但只能写自己的 saved_peer,母频道管理员可以回复任意订阅者。
|
||||
func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) {
|
||||
if req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 ||
|
||||
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" {
|
||||
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
|
||||
|
|
@ -65,6 +70,101 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
|
|||
if channel.Deleted || !channel.Monoforum {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
parent, err := getChannelByID(ctx, tx, channel.LinkedMonoforumID)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
var monoDeleted, parentDeleted, directEnabled bool
|
||||
var linkedMonoforumID, monoPrice, parentPrice int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT m.deleted, p.deleted, p.broadcast_messages_allowed, p.linked_monoforum_id,
|
||||
m.send_paid_messages_stars, p.send_paid_messages_stars
|
||||
FROM channels m
|
||||
JOIN channels p ON p.id = m.linked_monoforum_id
|
||||
WHERE m.id = $1
|
||||
FOR SHARE OF m, p`, channel.ID).Scan(
|
||||
&monoDeleted, &parentDeleted, &directEnabled, &linkedMonoforumID, &monoPrice, &parentPrice,
|
||||
); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
if monoDeleted || parentDeleted || !directEnabled || linkedMonoforumID != channel.ID {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelPrivate
|
||||
}
|
||||
if monoPrice != parentPrice || monoPrice < 0 {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("monoforum %d paid-message price disagrees with parent %d", channel.ID, parent.ID)
|
||||
}
|
||||
channel.SendPaidMessagesStars = monoPrice
|
||||
parent.SendPaidMessagesStars = parentPrice
|
||||
parentMember, parentMemberErr := s.getChannelMember(ctx, tx, parent.ID, req.SenderUserID)
|
||||
if parentMemberErr != nil && !errors.Is(parentMemberErr, domain.ErrChannelPrivate) {
|
||||
return domain.SendChannelMessageResult{}, parentMemberErr
|
||||
}
|
||||
isAdmin := parentMemberErr == nil && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember)
|
||||
if req.SenderUserID != req.SavedPeer.ID && !isAdmin {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
var senderBalance *domain.StarsBalance
|
||||
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
|
||||
}
|
||||
var exists bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM channel_messages
|
||||
WHERE channel_id = $1 AND id = $2 AND NOT deleted
|
||||
AND saved_peer_type = $3 AND saved_peer_id = $4
|
||||
)`, channel.ID, req.ReplyTo.MessageID, string(req.SavedPeer.Type), req.SavedPeer.ID).Scan(&exists); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
if !exists {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
}
|
||||
from := domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
|
||||
if isAdmin {
|
||||
from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}
|
||||
}
|
||||
msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.MonoforumID)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum message id: %w", err)
|
||||
|
|
@ -74,16 +174,22 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
|
|||
return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum pts: %w", err)
|
||||
}
|
||||
msg := domain.ChannelMessage{
|
||||
ChannelID: req.MonoforumID,
|
||||
ID: msgID,
|
||||
RandomID: req.RandomID,
|
||||
SenderUserID: req.SenderUserID,
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID},
|
||||
SavedPeer: req.SavedPeer,
|
||||
Date: req.Date,
|
||||
Body: req.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Pts: pts,
|
||||
ChannelID: req.MonoforumID,
|
||||
ID: msgID,
|
||||
RandomID: req.RandomID,
|
||||
SenderUserID: req.SenderUserID,
|
||||
From: from,
|
||||
SavedPeer: req.SavedPeer,
|
||||
SuggestedPost: req.SuggestedPost,
|
||||
PaidMessageStars: paidMessageStars,
|
||||
Date: req.Date,
|
||||
Silent: req.Silent,
|
||||
NoForwards: req.NoForwards,
|
||||
Body: req.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Media: req.Media,
|
||||
ReplyTo: req.ReplyTo,
|
||||
Pts: pts,
|
||||
}
|
||||
event := domain.ChannelUpdateEvent{
|
||||
ChannelID: req.MonoforumID,
|
||||
|
|
@ -130,13 +236,31 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
|
|||
if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, req.MonoforumID, msgID, pts); err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("update monoforum top: %w", err)
|
||||
}
|
||||
recipients := []int64{req.SavedPeer.ID}
|
||||
rows, err := tx.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id = $1 AND status = 'active' AND role IN ('creator', 'admin') ORDER BY user_id`, parent.ID)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("list monoforum recipients: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var recipient int64
|
||||
if err := rows.Scan(&recipient); err != nil {
|
||||
rows.Close()
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
recipients = append(recipients, recipient)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
rows.Close()
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("commit send monoforum: %w", err)
|
||||
}
|
||||
committed = true
|
||||
channel.TopMessageID = msgID
|
||||
channel.Pts = pts
|
||||
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event}, nil
|
||||
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: uniqueChannelUserIDs(recipients, 0), SenderStarsBalance: senderBalance}, nil
|
||||
}
|
||||
|
||||
// ListMonoforumHistory 拉取某订阅者(saved_peer)在 monoforum 内的私信历史,id 倒序分页。
|
||||
|
|
@ -205,9 +329,11 @@ func (s *ChannelStore) ResolveMonoforumSend(ctx context.Context, viewerUserID, m
|
|||
return domain.Channel{}, false, domain.ErrChannelInvalid
|
||||
}
|
||||
isAdmin := false
|
||||
if _, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, mono.LinkedMonoforumID); err == nil {
|
||||
if _, member, memberErr := s.getChannelForMember(ctx, s.db, viewerUserID, mono.LinkedMonoforumID); memberErr == nil {
|
||||
isAdmin = member.Status == domain.ChannelMemberActive &&
|
||||
(member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin)
|
||||
} else if !errors.Is(memberErr, domain.ErrChannelPrivate) {
|
||||
return domain.Channel{}, false, memberErr
|
||||
}
|
||||
return mono, isAdmin, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package postgres
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -55,18 +56,46 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
channelIDs = append(channelIDs, monoID)
|
||||
|
||||
subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID}
|
||||
if _, err := channels.GetChannel(ctx, sub.ID, monoID); err != nil {
|
||||
t.Fatalf("subscriber get enabled monoforum without membership: %v", err)
|
||||
}
|
||||
if _, err := channels.JoinChannel(ctx, monoID, sub.ID, 1700001001); !errors.Is(err, domain.ErrChannelMonoforumUnsupported) {
|
||||
t.Fatalf("subscriber join monoforum err = %v, want ErrChannelMonoforumUnsupported", err)
|
||||
}
|
||||
suggestedDraft := domain.DialogDraft{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}, Message: "pending suggested post", Date: 1700001001,
|
||||
SuggestedPost: &domain.SuggestedPost{
|
||||
Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10},
|
||||
ScheduleDate: 1700100000,
|
||||
},
|
||||
}
|
||||
dialogStore := NewDialogStore(pool)
|
||||
if err := dialogStore.SaveDraft(ctx, sub.ID, suggestedDraft); err != nil {
|
||||
t.Fatalf("save subscriber monoforum draft: %v", err)
|
||||
}
|
||||
loadedDraft, found, err := dialogStore.GetDraft(ctx, sub.ID, suggestedDraft.Peer, 0)
|
||||
if err != nil || !found || loadedDraft.SuggestedPost == nil || loadedDraft.SuggestedPost.Price == nil || loadedDraft.SuggestedPost.Price.Amount != 10 || loadedDraft.SuggestedPost.ScheduleDate != 1700100000 {
|
||||
t.Fatalf("loaded subscriber monoforum draft = %+v, %v, %v; want suggested post", loadedDraft, found, err)
|
||||
}
|
||||
|
||||
m1, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001001})
|
||||
suggestedPost := &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}, ScheduleDate: 1700100000}
|
||||
m1, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001001,
|
||||
SuggestedPost: suggestedPost,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber send 1: %v", err)
|
||||
}
|
||||
if m1.Message.SavedPeer != subPeer || m1.Message.ChannelID != monoID || m1.Message.Pts == 0 {
|
||||
t.Fatalf("m1 = %+v, want saved_peer sub + channel mono + pts>0", m1.Message)
|
||||
}
|
||||
if len(m1.Recipients) != 2 || !slices.Contains(m1.Recipients, owner.ID) || !slices.Contains(m1.Recipients, sub.ID) {
|
||||
t.Fatalf("m1 recipients = %v, want subscriber %d + parent admin %d", m1.Recipients, sub.ID, owner.ID)
|
||||
}
|
||||
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 112, Message: "again", Date: 1700001002}); err != nil {
|
||||
t.Fatalf("subscriber send 2: %v", err)
|
||||
}
|
||||
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 113, Message: "reply", Date: 1700001003}); err != nil {
|
||||
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 113, Message: "reply", ReplyTo: &domain.MessageReply{MessageID: m1.Message.ID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1700001003}); err != nil {
|
||||
t.Fatalf("admin reply: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -85,12 +114,21 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
if len(mainHist.Channels) != 1 || mainHist.Channels[0].ID != broadcast.Channel.ID {
|
||||
t.Fatalf("main monoforum extra channels = %+v, want parent %d", mainHist.Channels, broadcast.Channel.ID)
|
||||
}
|
||||
if _, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}); err == nil {
|
||||
t.Fatalf("subscriber main monoforum history = nil err, want denied")
|
||||
subscriberHist, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber monoforum history: %v", err)
|
||||
}
|
||||
if subscriberHist.Count != 3 || len(subscriberHist.Messages) != 3 {
|
||||
t.Fatalf("subscriber monoforum history count=%d len=%d, want own 3", subscriberHist.Count, len(subscriberHist.Messages))
|
||||
}
|
||||
for _, message := range subscriberHist.Messages {
|
||||
if message.SavedPeer != subPeer {
|
||||
t.Fatalf("subscriber history leaked message %+v", message)
|
||||
}
|
||||
}
|
||||
|
||||
// 幂等。
|
||||
dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001004})
|
||||
dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", SuggestedPost: suggestedPost, Date: 1700001004})
|
||||
if err != nil {
|
||||
t.Fatalf("dup send: %v", err)
|
||||
}
|
||||
|
|
@ -124,6 +162,16 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
t.Fatalf("history msg saved_peer = %+v, want sub", m.SavedPeer)
|
||||
}
|
||||
}
|
||||
oldest := hist.Messages[len(hist.Messages)-1]
|
||||
if oldest.SuggestedPost == nil || oldest.SuggestedPost.Price == nil || oldest.SuggestedPost.Price.Kind != domain.SuggestedPostPriceStars || oldest.SuggestedPost.Price.Amount != 10 || oldest.SuggestedPost.ScheduleDate != 1700100000 {
|
||||
t.Fatalf("persisted suggested post = %+v, want 10 Stars + schedule", oldest.SuggestedPost)
|
||||
}
|
||||
if newest := hist.Messages[0]; newest.ReplyTo == nil || newest.ReplyTo.MessageID != m1.Message.ID {
|
||||
t.Fatalf("persisted admin reply = %+v, want message %d", newest.ReplyTo, m1.Message.ID)
|
||||
}
|
||||
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 114, Message: "bad reply", ReplyTo: &domain.MessageReply{MessageID: 999999, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1700001004}); !errors.Is(err, domain.ErrReplyMessageIDInvalid) {
|
||||
t.Fatalf("invalid monoforum reply err = %v, want ErrReplyMessageIDInvalid", err)
|
||||
}
|
||||
|
||||
// 另一个订阅者不串会话。
|
||||
otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
|
||||
|
|
@ -134,6 +182,31 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
if subHist.Count != 3 {
|
||||
t.Fatalf("sub history after other subscriber = %d, want still 3 (no cross-talk)", subHist.Count)
|
||||
}
|
||||
subscriberChannelHistory, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10})
|
||||
if err != nil || subscriberChannelHistory.Count != 3 || len(subscriberChannelHistory.Messages) != 3 {
|
||||
t.Fatalf("subscriber channel history after other = %d/%d, %v; want own 3", subscriberChannelHistory.Count, len(subscriberChannelHistory.Messages), err)
|
||||
}
|
||||
for _, message := range subscriberChannelHistory.Messages {
|
||||
if message.SavedPeer != subPeer {
|
||||
t.Fatalf("subscriber channel history leaked message %+v", message)
|
||||
}
|
||||
}
|
||||
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: sub.ID, ChannelID: monoID, Pts: 0, Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber channel difference: %v", err)
|
||||
}
|
||||
if len(diff.NewMessages) != 3 {
|
||||
t.Fatalf("subscriber channel difference messages = %d, want own 3", len(diff.NewMessages))
|
||||
}
|
||||
for _, message := range diff.NewMessages {
|
||||
if message.SavedPeer != subPeer {
|
||||
t.Fatalf("subscriber difference leaked message %+v", message)
|
||||
}
|
||||
}
|
||||
activeChannelIDs, err := channels.ListActiveChannelIDsForUser(ctx, sub.ID, 0, 10)
|
||||
if err != nil || !slices.Contains(activeChannelIDs, monoID) {
|
||||
t.Fatalf("subscriber active channels = %v, %v; want monoforum %d", activeChannelIDs, err, monoID)
|
||||
}
|
||||
|
||||
// 去重按订阅者子会话维度(迁移 0022 唯一索引含 saved_peer_id):管理员用相同 random_id 向两个不同
|
||||
// 订阅者发,不得互相去重(与 memory 行为一致)。
|
||||
|
|
@ -202,6 +275,13 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
if err := tx.Commit(ctx); err != nil {
|
||||
t.Fatalf("commit monoforum delete: %v", err)
|
||||
}
|
||||
deleteDiff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: sub.ID, ChannelID: monoID, Pts: deleteEvent.Pts - deleteEvent.PtsCount, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber difference after own delete: %v", err)
|
||||
}
|
||||
if deleteDiff.Pts != deleteEvent.Pts || len(deleteDiff.OtherUpdates) != 1 || len(deleteDiff.OtherUpdates[0].MessageIDs) != 1 || deleteDiff.OtherUpdates[0].MessageIDs[0] != a.Message.ID {
|
||||
t.Fatalf("subscriber delete difference = %+v, want own deleted id %d at pts %d", deleteDiff, a.Message.ID, deleteEvent.Pts)
|
||||
}
|
||||
var ptsBeforeReplay, eventsBeforeReplay int
|
||||
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, monoID).Scan(&ptsBeforeReplay); err != nil {
|
||||
t.Fatalf("load monoforum pts: %v", err)
|
||||
|
|
@ -227,3 +307,117 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ func NewChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore
|
|||
const channelColumns = `c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(c.username, ''), c.verified,
|
||||
c.broadcast, c.megagroup, c.forum, c.forum_tabs, c.autotranslation, c.restricted_sponsored, c.broadcast_messages_allowed, c.send_paid_messages_stars, c.noforwards, c.join_to_send, c.join_request, c.signatures, c.pre_history_hidden, c.participants_hidden, c.antispam,
|
||||
EXISTS (SELECT 1 FROM channel_invites ci WHERE ci.channel_id = c.id AND NOT ci.revoked) AS has_link,
|
||||
c.linked_chat_id, c.monoforum, c.linked_monoforum_id, c.slowmode_seconds, c.boosts_unrestrict, c.default_banned_rights::text,
|
||||
c.linked_chat_id, c.linked_community_id, c.monoforum, c.linked_monoforum_id, c.slowmode_seconds, c.boosts_unrestrict, c.default_banned_rights::text,
|
||||
c.available_reactions::text, c.color_set, c.color, c.color_background_emoji_id, c.profile_color_set, c.profile_color, c.profile_color_background_emoji_id, c.emoji_status_document_id, c.emoji_status_until,
|
||||
c.wallpaper::text, c.participants_count, c.admins_count, c.kicked_count, c.banned_count, c.top_message_id, c.pinned_message_id, c.pts,
|
||||
c.ttl_period, c.date, c.deleted, c.photo_id, c.photo_dc_id, c.photo_stripped,
|
||||
|
|
@ -126,7 +126,7 @@ const channelMessageColumns = `channel_id, id, random_id, sender_user_id, from_p
|
|||
send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body,
|
||||
entities::text, reply_to::text, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
|
||||
fwd_from::text, discussion_channel_id, discussion_message_id, action::text, pts, deleted, media::text,
|
||||
reply_markup::text, rich_message::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id`
|
||||
reply_markup::text, rich_message::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id, paid_message_stars, suggested_post::text`
|
||||
|
||||
const channelForumTopicColumns = `channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id,
|
||||
title_missing, closed, hidden, pinned, pinned_order, date, top_message_id, read_inbox_max_id,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.Cha
|
|||
args = append(args, member.AvailableMinID)
|
||||
where += fmt.Sprintf(" AND id > $%d", len(args))
|
||||
}
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
args = append(args, req.UserID)
|
||||
where += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(args))
|
||||
}
|
||||
args = append(args, domain.MaxChannelDifferenceTooLongMessages)
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT `+channelMessageColumns+`
|
||||
|
|
@ -100,11 +104,15 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
|
|||
if err != nil {
|
||||
return domain.ChannelDifference{}, fmt.Errorf("list channel difference: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
diff := domain.ChannelDifference{Channel: channel, Self: member, Pts: channel.Pts, Final: true, Timeout: 30}
|
||||
userRefs := make(map[int64]struct{})
|
||||
channelRefs := make(map[int64]struct{})
|
||||
lastPts := req.Pts
|
||||
type differenceEventRow struct {
|
||||
event domain.ChannelUpdateEvent
|
||||
messageID int
|
||||
}
|
||||
eventRows := make([]differenceEventRow, 0, limit)
|
||||
for rows.Next() {
|
||||
event, messageID, err := scanChannelEvent(rows)
|
||||
if err != nil {
|
||||
|
|
@ -131,6 +139,27 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
|
|||
break
|
||||
}
|
||||
lastPts = event.Pts
|
||||
eventRows = append(eventRows, differenceEventRow{event: event, messageID: messageID})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return domain.ChannelDifference{}, err
|
||||
}
|
||||
rows.Close()
|
||||
var visibleMonoforumMessageIDs map[int]struct{}
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
messageIDs := make([]int, 0)
|
||||
for _, row := range eventRows {
|
||||
messageIDs = append(messageIDs, row.event.MessageIDs...)
|
||||
}
|
||||
visibleMonoforumMessageIDs, err = s.monoforumVisibleMessageIDs(ctx, req.ChannelID, req.UserID, messageIDs)
|
||||
if err != nil {
|
||||
return domain.ChannelDifference{}, err
|
||||
}
|
||||
}
|
||||
for _, row := range eventRows {
|
||||
event := row.event
|
||||
messageID := row.messageID
|
||||
if messageID != 0 && event.Message.ID == 0 {
|
||||
msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, messageID)
|
||||
if err != nil {
|
||||
|
|
@ -143,6 +172,12 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
|
|||
continue
|
||||
}
|
||||
event = visibleEvent
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
event, ok = filterMonoforumEventForUser(event, req.UserID, visibleMonoforumMessageIDs)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if preview && event.Type == domain.ChannelUpdateParticipant {
|
||||
continue
|
||||
}
|
||||
|
|
@ -156,9 +191,6 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
|
|||
diff.OtherUpdates = append(diff.OtherUpdates, event)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.ChannelDifference{}, err
|
||||
}
|
||||
if len(diff.Events) == 0 {
|
||||
diff.Pts = lastPts
|
||||
} else if lastPts > diff.Pts {
|
||||
|
|
@ -208,6 +240,55 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
|
|||
return diff, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) monoforumVisibleMessageIDs(ctx context.Context, channelID, userID int64, ids []int) (map[int]struct{}, error) {
|
||||
visible := make(map[int]struct{})
|
||||
if len(ids) == 0 {
|
||||
return visible, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id
|
||||
FROM channel_messages
|
||||
WHERE channel_id = $1
|
||||
AND id = ANY($2::int[])
|
||||
AND saved_peer_type = 'user'
|
||||
AND saved_peer_id = $3`, channelID, int32s(ids), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list visible monoforum message ids: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible[id] = struct{}{}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
func filterMonoforumEventForUser(event domain.ChannelUpdateEvent, userID int64, visibleMessageIDs map[int]struct{}) (domain.ChannelUpdateEvent, bool) {
|
||||
if event.Message.ID != 0 {
|
||||
return event, event.Message.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: userID})
|
||||
}
|
||||
if len(event.MessageIDs) == 0 {
|
||||
return event, false
|
||||
}
|
||||
ids := make([]int, 0, len(event.MessageIDs))
|
||||
for _, id := range event.MessageIDs {
|
||||
if _, ok := visibleMessageIDs[id]; ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return event, false
|
||||
}
|
||||
event.MessageIDs = ids
|
||||
return event, true
|
||||
}
|
||||
|
||||
func (s *ChannelStore) MaxChannelPts(ctx context.Context, channelID int64) (int, error) {
|
||||
var pts int
|
||||
err := s.db.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, channelID).Scan(&pts)
|
||||
|
|
|
|||
1374
internal/store/postgres/community.go
Normal file
1374
internal/store/postgres/community.go
Normal file
File diff suppressed because it is too large
Load diff
138
internal/store/postgres/community_integration_test.go
Normal file
138
internal/store/postgres/community_integration_test.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestCommunityStoreLifecycleIsAtomicInPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
owner, err := users.Create(ctx, domain.User{AccessHash: 801, Phone: "+1888" + suffix + "01", FirstName: "CommunityOwner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
member, err := users.Create(ctx, domain.User{AccessHash: 802, Phone: "+1888" + suffix + "02", FirstName: "SearchableMember"})
|
||||
if err != nil {
|
||||
t.Fatalf("create member: %v", err)
|
||||
}
|
||||
channels := NewChannelStore(pool)
|
||||
initial, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "Community Initial " + suffix,
|
||||
Megagroup: true,
|
||||
MemberUserIDs: []int64{member.ID},
|
||||
Date: 1_800_200_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create initial channel: %v", err)
|
||||
}
|
||||
owned, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: member.ID,
|
||||
Title: "Community Owned " + suffix,
|
||||
Megagroup: true,
|
||||
Date: 1_800_200_001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create owned channel: %v", err)
|
||||
}
|
||||
owned.Channel, err = channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{
|
||||
UserID: member.ID, ChannelID: owned.Channel.ID, Username: "communitypreview" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("make owned channel public: %v", err)
|
||||
}
|
||||
if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: member.ID, ChannelID: owned.Channel.ID, RandomID: 9_900_000_001,
|
||||
Message: "community public preview search", Date: 1_800_200_001,
|
||||
}); err != nil {
|
||||
t.Fatalf("send public preview message: %v", err)
|
||||
}
|
||||
var communityID int64
|
||||
t.Cleanup(func() {
|
||||
if communityID != 0 {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM communities WHERE id=$1", communityID)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id=ANY($1::bigint[])", []int64{initial.Channel.ID, owned.Channel.ID})
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=ANY($1::bigint[])", []int64{owner.ID, member.ID})
|
||||
})
|
||||
|
||||
store := NewCommunityStore(pool, nil, nil)
|
||||
created, err := store.CreateCommunity(ctx, domain.CreateCommunityRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "Postgres Community " + suffix,
|
||||
InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: initial.Channel.ID},
|
||||
Visibility: domain.CommunityPeerHidden,
|
||||
Date: 1_800_200_002,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create community: %v", err)
|
||||
}
|
||||
communityID = created.Community.ID
|
||||
if len(created.ServiceMessages) != 1 || created.ServiceMessages[0].Event.Pts == 0 {
|
||||
t.Fatalf("create service messages = %+v", created.ServiceMessages)
|
||||
}
|
||||
var linkedID int64
|
||||
if err := pool.QueryRow(ctx, "SELECT linked_community_id FROM channels WHERE id=$1", initial.Channel.ID).Scan(&linkedID); err != nil || linkedID != communityID {
|
||||
t.Fatalf("initial linked_community_id = %d err=%v, want %d", linkedID, err, communityID)
|
||||
}
|
||||
|
||||
requested, err := store.ToggleCommunityPeerLink(ctx, domain.CommunityTogglePeerLinkRequest{
|
||||
ActorUserID: member.ID,
|
||||
CommunityID: communityID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: owned.Channel.ID},
|
||||
Visibility: domain.CommunityPeerVisible,
|
||||
Date: 1_800_200_003,
|
||||
})
|
||||
if err != nil || !requested.RequestCreated {
|
||||
t.Fatalf("create peer link request = %+v err=%v", requested, err)
|
||||
}
|
||||
approved, err := store.DecideCommunityPeerLinkRequest(ctx, owner.ID, communityID, requested.Peer, false, 1_800_200_004)
|
||||
if err != nil || approved.Link == nil || approved.RequestedBy != member.ID || approved.ServiceMessage == nil {
|
||||
t.Fatalf("approve link request = %+v err=%v", approved, err)
|
||||
}
|
||||
search, err := channels.SearchJoinedMessages(ctx, owner.ID, domain.ChannelGlobalSearchRequest{
|
||||
Query: "public preview", ChannelIDs: []int64{owned.Channel.ID}, RestrictChannelIDs: true,
|
||||
AllowPublicPreview: true, Limit: 20,
|
||||
})
|
||||
if err != nil || len(search.Messages) != 1 || search.Messages[0].ChannelID != owned.Channel.ID {
|
||||
t.Fatalf("community public-preview search = %+v err=%v", search.Messages, err)
|
||||
}
|
||||
|
||||
participants, err := store.ListCommunityParticipants(ctx, owner.ID, communityID, domain.ChannelParticipantsFilter{
|
||||
Kind: domain.ChannelParticipantsSearch, Query: "SEARCHABLE",
|
||||
}, 0, 20)
|
||||
if err != nil || participants.Count != 1 || len(participants.Participants) != 1 || participants.Participants[0].UserID != member.ID {
|
||||
t.Fatalf("participant search = %+v err=%v", participants, err)
|
||||
}
|
||||
admins, err := store.ListCommunityParticipants(ctx, member.ID, communityID, domain.ChannelParticipantsFilter{
|
||||
Kind: domain.ChannelParticipantsAdmins,
|
||||
}, 0, 100)
|
||||
if err != nil || admins.Count != 1 || len(admins.Participants) != 1 || admins.Participants[0].UserID != owner.ID {
|
||||
t.Fatalf("member-visible Community admins = %+v err=%v", admins, err)
|
||||
}
|
||||
if _, err := store.ListCommunityParticipants(ctx, member.ID, communityID, domain.ChannelParticipantsFilter{
|
||||
Kind: domain.ChannelParticipantsBanned,
|
||||
}, 0, 100); !errors.Is(err, domain.ErrCommunityAdminRequired) {
|
||||
t.Fatalf("member Community banned list error = %v, want admin required", err)
|
||||
}
|
||||
|
||||
ban, err := store.ToggleCommunityParticipantBanned(ctx, owner.ID, communityID, member.ID, false, 1_800_200_005)
|
||||
if err != nil {
|
||||
t.Fatalf("ban participant: %v", err)
|
||||
}
|
||||
if !ban.Changed || len(ban.ChannelBans) != 1 || len(ban.RemovedLinks) != 1 {
|
||||
t.Fatalf("ban result = %+v", ban)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, "SELECT linked_community_id FROM channels WHERE id=$1", owned.Channel.ID).Scan(&linkedID); err != nil || linkedID != 0 {
|
||||
t.Fatalf("owned linked_community_id after ban = %d err=%v, want 0", linkedID, err)
|
||||
}
|
||||
if _, err := store.GetCommunity(ctx, member.ID, communityID); !errors.Is(err, domain.ErrCommunityPrivate) {
|
||||
t.Fatalf("banned member get community error = %v, want private", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,8 @@ SELECT
|
|||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at
|
||||
FROM contacts c
|
||||
JOIN users u ON u.id = c.contact_user_id
|
||||
|
|
@ -137,6 +139,8 @@ SELECT
|
|||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at
|
||||
FROM contacts c
|
||||
JOIN users u ON u.id = c.contact_user_id
|
||||
|
|
@ -278,6 +282,8 @@ SELECT
|
|||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at,
|
||||
EXISTS (SELECT 1 FROM reverse_updated ru WHERE ru.user_id = c.contact_user_id)::boolean AS reverse_mutual_changed
|
||||
FROM upserted c
|
||||
|
|
@ -342,6 +348,8 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do
|
|||
premiumUntil int64
|
||||
emojiStatusDocID int64
|
||||
emojiStatusUntil int64
|
||||
emojiCollectibleID *int64
|
||||
emojiCollectibleJSON []byte
|
||||
lastSeenAt int64
|
||||
reverseMutualChanged bool
|
||||
)
|
||||
|
|
@ -366,6 +374,8 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do
|
|||
&premiumUntil,
|
||||
&emojiStatusDocID,
|
||||
&emojiStatusUntil,
|
||||
&emojiCollectibleID,
|
||||
&emojiCollectibleJSON,
|
||||
&lastSeenAt,
|
||||
&reverseMutualChanged,
|
||||
); err != nil {
|
||||
|
|
@ -376,7 +386,7 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("decode contact note entities: %w", err)
|
||||
}
|
||||
out = append(out, contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend))
|
||||
out = append(out, contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), emojiCollectibleID, emojiCollectibleJSON, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate upsert contacts many: %w", err)
|
||||
|
|
@ -556,7 +566,7 @@ func contactFromListRow(row sqlcgen.ListContactsByUserRow) (domain.Contact, erro
|
|||
if err != nil {
|
||||
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err)
|
||||
}
|
||||
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
|
||||
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
|
||||
}
|
||||
|
||||
func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) {
|
||||
|
|
@ -564,7 +574,7 @@ func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) {
|
|||
if err != nil {
|
||||
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err)
|
||||
}
|
||||
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
|
||||
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
|
||||
}
|
||||
|
||||
func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error) {
|
||||
|
|
@ -572,7 +582,7 @@ func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error)
|
|||
if err != nil {
|
||||
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err)
|
||||
}
|
||||
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
|
||||
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
|
||||
}
|
||||
|
||||
func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact, error) {
|
||||
|
|
@ -580,7 +590,7 @@ func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact,
|
|||
if err != nil {
|
||||
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err)
|
||||
}
|
||||
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
|
||||
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
|
||||
}
|
||||
|
||||
// contactFromFields 组装 domain.Contact。getContacts 主路径(List/Get/Upsert/UpdateNote
|
||||
|
|
@ -588,27 +598,28 @@ func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact,
|
|||
// raw-scan 调用传 false/0——bot 无 phone 不经手机号导入,bot 加联系人走 username
|
||||
// 的单条 UpsertContact 路径(已带真实 bot 列)。premium/emoji status 列所有路径必须
|
||||
// 传真实值:TDesktop 对任何缺 emoji_status 字段的 user TL 一律清空本地状态。
|
||||
func contactFromFields(id, accessHash int64, phone, firstName, lastName, username, countryCode string, verified, support, isBot bool, botInfoVersion, premiumUntil int, emojiStatusDocumentID int64, emojiStatusUntil, lastSeenAt int, contactFirstName, contactLastName, contactPhone, note string, noteEntities []domain.MessageEntity, mutual, closeFriend bool) domain.Contact {
|
||||
func contactFromFields(id, accessHash int64, phone, firstName, lastName, username, countryCode string, verified, support, isBot bool, botInfoVersion, premiumUntil int, emojiStatusDocumentID int64, emojiStatusUntil int, emojiCollectibleID *int64, emojiCollectibleJSON []byte, lastSeenAt int, contactFirstName, contactLastName, contactPhone, note string, noteEntities []domain.MessageEntity, mutual, closeFriend bool) domain.Contact {
|
||||
return domain.Contact{
|
||||
User: domain.User{
|
||||
ID: id,
|
||||
AccessHash: accessHash,
|
||||
Phone: phone,
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
Username: username,
|
||||
CountryCode: countryCode,
|
||||
Verified: verified,
|
||||
Support: support,
|
||||
Bot: isBot,
|
||||
BotInfoVersion: botInfoVersion,
|
||||
PremiumUntil: premiumUntil,
|
||||
EmojiStatusDocumentID: emojiStatusDocumentID,
|
||||
EmojiStatusUntil: emojiStatusUntil,
|
||||
LastSeenAt: lastSeenAt,
|
||||
Contact: true,
|
||||
Mutual: mutual,
|
||||
CloseFriend: closeFriend,
|
||||
ID: id,
|
||||
AccessHash: accessHash,
|
||||
Phone: phone,
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
Username: username,
|
||||
CountryCode: countryCode,
|
||||
Verified: verified,
|
||||
Support: support,
|
||||
Bot: isBot,
|
||||
BotInfoVersion: botInfoVersion,
|
||||
PremiumUntil: premiumUntil,
|
||||
EmojiStatusDocumentID: emojiStatusDocumentID,
|
||||
EmojiStatusUntil: emojiStatusUntil,
|
||||
EmojiStatusCollectible: mustDecodeEmojiStatusCollectible(emojiCollectibleID, emojiCollectibleJSON),
|
||||
LastSeenAt: lastSeenAt,
|
||||
Contact: true,
|
||||
Mutual: mutual,
|
||||
CloseFriend: closeFriend,
|
||||
},
|
||||
FirstName: contactFirstName,
|
||||
LastName: contactLastName,
|
||||
|
|
@ -626,27 +637,29 @@ type contactScanner interface {
|
|||
|
||||
func scanContactRows(row contactScanner) (domain.Contact, error) {
|
||||
var (
|
||||
contactUserID int64
|
||||
mutual bool
|
||||
closeFriend bool
|
||||
contactPhone string
|
||||
contactFirstName string
|
||||
contactLastName string
|
||||
note string
|
||||
noteEntitiesJSON string
|
||||
id int64
|
||||
accessHash int64
|
||||
phone string
|
||||
firstName string
|
||||
lastName string
|
||||
username string
|
||||
countryCode string
|
||||
verified bool
|
||||
support bool
|
||||
premiumUntil int64
|
||||
emojiStatusDocID int64
|
||||
emojiStatusUntil int64
|
||||
lastSeenAt int32
|
||||
contactUserID int64
|
||||
mutual bool
|
||||
closeFriend bool
|
||||
contactPhone string
|
||||
contactFirstName string
|
||||
contactLastName string
|
||||
note string
|
||||
noteEntitiesJSON string
|
||||
id int64
|
||||
accessHash int64
|
||||
phone string
|
||||
firstName string
|
||||
lastName string
|
||||
username string
|
||||
countryCode string
|
||||
verified bool
|
||||
support bool
|
||||
premiumUntil int64
|
||||
emojiStatusDocID int64
|
||||
emojiStatusUntil int64
|
||||
emojiCollectibleID *int64
|
||||
emojiCollectibleJSON []byte
|
||||
lastSeenAt int32
|
||||
)
|
||||
if err := row.Scan(
|
||||
&contactUserID,
|
||||
|
|
@ -669,6 +682,8 @@ func scanContactRows(row contactScanner) (domain.Contact, error) {
|
|||
&premiumUntil,
|
||||
&emojiStatusDocID,
|
||||
&emojiStatusUntil,
|
||||
&emojiCollectibleID,
|
||||
&emojiCollectibleJSON,
|
||||
&lastSeenAt,
|
||||
); err != nil {
|
||||
return domain.Contact{}, err
|
||||
|
|
@ -677,32 +692,34 @@ func scanContactRows(row contactScanner) (domain.Contact, error) {
|
|||
if err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
return contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend), nil
|
||||
return contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), emojiCollectibleID, emojiCollectibleJSON, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend), nil
|
||||
}
|
||||
|
||||
func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) {
|
||||
var (
|
||||
ownerUserID int64
|
||||
mutual bool
|
||||
closeFriend bool
|
||||
contactPhone string
|
||||
contactFirstName string
|
||||
contactLastName string
|
||||
note string
|
||||
noteEntitiesJSON string
|
||||
id int64
|
||||
accessHash int64
|
||||
phone string
|
||||
firstName string
|
||||
lastName string
|
||||
username string
|
||||
countryCode string
|
||||
verified bool
|
||||
support bool
|
||||
premiumUntil int64
|
||||
emojiStatusDocID int64
|
||||
emojiStatusUntil int64
|
||||
lastSeenAt int32
|
||||
ownerUserID int64
|
||||
mutual bool
|
||||
closeFriend bool
|
||||
contactPhone string
|
||||
contactFirstName string
|
||||
contactLastName string
|
||||
note string
|
||||
noteEntitiesJSON string
|
||||
id int64
|
||||
accessHash int64
|
||||
phone string
|
||||
firstName string
|
||||
lastName string
|
||||
username string
|
||||
countryCode string
|
||||
verified bool
|
||||
support bool
|
||||
premiumUntil int64
|
||||
emojiStatusDocID int64
|
||||
emojiStatusUntil int64
|
||||
emojiCollectibleID *int64
|
||||
emojiCollectibleJSON []byte
|
||||
lastSeenAt int32
|
||||
)
|
||||
if err := row.Scan(
|
||||
&ownerUserID,
|
||||
|
|
@ -725,6 +742,8 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) {
|
|||
&premiumUntil,
|
||||
&emojiStatusDocID,
|
||||
&emojiStatusUntil,
|
||||
&emojiCollectibleID,
|
||||
&emojiCollectibleJSON,
|
||||
&lastSeenAt,
|
||||
); err != nil {
|
||||
return 0, domain.Contact{}, err
|
||||
|
|
@ -733,7 +752,7 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) {
|
|||
if err != nil {
|
||||
return 0, domain.Contact{}, err
|
||||
}
|
||||
contact := contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend)
|
||||
contact := contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), emojiCollectibleID, emojiCollectibleJSON, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend)
|
||||
return ownerUserID, contact, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
54
internal/store/postgres/emoji_status_codec_test.go
Normal file
54
internal/store/postgres/emoji_status_codec_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func testCollectibleEmojiStatusValue() domain.UserEmojiStatus {
|
||||
return domain.UserEmojiStatus{
|
||||
DocumentID: 101,
|
||||
Until: 2_000_000_000,
|
||||
Collectible: domain.EmojiStatusCollectible{
|
||||
CollectibleID: 1001, DocumentID: 101, Title: "Gift", Slug: "Gift-1",
|
||||
PatternDocumentID: 102, CenterColor: 1, EdgeColor: 2, PatternColor: 3, TextColor: 4,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleEmojiStatusUserAndEventCodecsRoundTrip(t *testing.T) {
|
||||
value := testCollectibleEmojiStatusValue()
|
||||
raw, id, err := encodeEmojiStatusCollectible(value)
|
||||
if err != nil {
|
||||
t.Fatalf("encode user collectible: %v", err)
|
||||
}
|
||||
if id == nil || *id != value.Collectible.CollectibleID {
|
||||
t.Fatalf("collectible id = %v", id)
|
||||
}
|
||||
if got := mustDecodeEmojiStatusCollectible(id, raw); got != value.Collectible {
|
||||
t.Fatalf("decoded user collectible = %+v, want %+v", got, value.Collectible)
|
||||
}
|
||||
|
||||
eventRaw, err := encodeEventEmojiStatus(value)
|
||||
if err != nil {
|
||||
t.Fatalf("encode event collectible: %v", err)
|
||||
}
|
||||
got, err := decodeEventEmojiStatus(string(eventRaw))
|
||||
if err != nil || got != value {
|
||||
t.Fatalf("decoded event collectible = %+v err=%v, want %+v", got, err, value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleEmojiStatusCodecRejectsPartialSnapshot(t *testing.T) {
|
||||
value := domain.UserEmojiStatus{
|
||||
DocumentID: 101,
|
||||
Collectible: domain.EmojiStatusCollectible{CollectibleID: 1001, DocumentID: 101},
|
||||
}
|
||||
if _, _, err := encodeEmojiStatusCollectible(value); err == nil {
|
||||
t.Fatal("partial user snapshot encoded successfully")
|
||||
}
|
||||
if _, err := encodeEventEmojiStatus(value); err == nil {
|
||||
t.Fatal("partial event snapshot encoded successfully")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestUpdateEmojiStatusWithEventIsAtomic(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
users := NewUserStore(pool)
|
||||
u, err := users.Create(ctx, domain.User{
|
||||
AccessHash: time.Now().UnixNano(),
|
||||
Phone: fmt.Sprintf("1666%d", time.Now().UnixNano()),
|
||||
FirstName: "Emoji status event",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, u.ID) })
|
||||
|
||||
status := domain.UserEmojiStatus{DocumentID: 42}
|
||||
event := domain.UpdateEvent{
|
||||
Type: domain.UpdateEventUserEmojiStatus,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: u.ID},
|
||||
EmojiStatus: status,
|
||||
Date: int(time.Now().Unix()),
|
||||
PtsCount: 1,
|
||||
}
|
||||
// A nonzero session without its auth-key half violates the outbox
|
||||
// exclusion-pair invariant. The event failure must roll back the users row.
|
||||
if _, _, err := users.UpdateEmojiStatusWithEvent(ctx, u.ID, status, event, [8]byte{}, 77); err == nil {
|
||||
t.Fatal("UpdateEmojiStatusWithEvent unexpectedly accepted a partial exclusion pair")
|
||||
}
|
||||
got, found, err := users.ByID(ctx, u.ID)
|
||||
if err != nil || !found || !got.EmojiStatus().Empty() {
|
||||
t.Fatalf("failed aggregate write leaked user state: user=%+v found=%v err=%v", got, found, err)
|
||||
}
|
||||
|
||||
authKeyID := [8]byte{1, 2, 3, 4, 5, 6, 7, 8}
|
||||
got, storedEvent, err := users.UpdateEmojiStatusWithEvent(ctx, u.ID, status, event, authKeyID, 77)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateEmojiStatusWithEvent: %v", err)
|
||||
}
|
||||
if got.EmojiStatus() != status || storedEvent.Pts <= 0 || storedEvent.EmojiStatus != status {
|
||||
t.Fatalf("aggregate result: user=%+v event=%+v", got.EmojiStatus(), storedEvent)
|
||||
}
|
||||
loaded, err := NewUpdateEventStore(pool).ListAfter(ctx, u.ID, storedEvent.Pts-1, 1)
|
||||
if err != nil || len(loaded) != 1 || loaded[0].EmojiStatus != status {
|
||||
t.Fatalf("durable event: events=%+v err=%v", loaded, err)
|
||||
}
|
||||
var outboxCount int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM dispatch_outbox
|
||||
WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, u.ID, storedEvent.Pts).Scan(&outboxCount); err != nil || outboxCount != 1 {
|
||||
t.Fatalf("dispatch outbox count=%d err=%v", outboxCount, err)
|
||||
}
|
||||
}
|
||||
49
internal/store/postgres/ephemeral_report.go
Normal file
49
internal/store/postgres/ephemeral_report.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
// EphemeralReportStore persists the low-volume abuse-review evidence path.
|
||||
// The hot ephemeral send/edit/delete path remains entirely in Redis.
|
||||
type EphemeralReportStore struct {
|
||||
db sqlcgen.DBTX
|
||||
}
|
||||
|
||||
func NewEphemeralReportStore(db sqlcgen.DBTX) *EphemeralReportStore {
|
||||
return &EphemeralReportStore{db: db}
|
||||
}
|
||||
|
||||
func (s *EphemeralReportStore) CreateEphemeralReport(ctx context.Context, report domain.EphemeralAbuseReport) (bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return false, fmt.Errorf("ephemeral report store is not configured")
|
||||
}
|
||||
if err := report.Validate(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
evidence, err := json.Marshal(report.Evidence)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("marshal ephemeral report evidence: %w", err)
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
INSERT INTO ephemeral_abuse_reports (
|
||||
reporter_user_id, channel_id, ephemeral_message_id, sender_user_id,
|
||||
receiver_user_id, report_option, report_comment, comment_hash,
|
||||
payload_hash, evidence, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11)
|
||||
ON CONFLICT (
|
||||
reporter_user_id, channel_id, ephemeral_message_id, report_option, comment_hash
|
||||
) DO NOTHING
|
||||
`, report.ReporterUserID, report.Evidence.Peer.ID, report.Evidence.MessageID,
|
||||
report.Evidence.SenderUserID, report.Evidence.ReceiverUserID,
|
||||
report.Option, report.Comment, report.CommentHash[:], report.Evidence.PayloadHash[:], evidence, report.CreatedAt)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("insert ephemeral abuse report: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
60
internal/store/postgres/ephemeral_report_integration_test.go
Normal file
60
internal/store/postgres/ephemeral_report_integration_test.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestEphemeralReportStoreDurableEvidenceAndIdempotency(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
reporter := now.UnixNano()&0x3fffffff + 1000
|
||||
sender := reporter + 1
|
||||
messageID := int(now.UnixNano()&0x3fffffff) + 1
|
||||
message := domain.EphemeralMessage{
|
||||
ID: messageID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: reporter + 2},
|
||||
SenderUserID: sender, ReceiverUserID: reporter, Date: int(now.Unix()), RandomID: 99,
|
||||
Content: domain.EphemeralContent{Message: "abuse evidence"},
|
||||
OriginDevice: domain.EphemeralDevice{UserID: reporter, BusinessAuthKeyID: [8]byte{1, 2, 3}, SessionID: 44},
|
||||
Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
|
||||
}
|
||||
report := domain.NewEphemeralAbuseReport(reporter, "spam", "review this", message, now)
|
||||
store := NewEphemeralReportStore(pool)
|
||||
created, err := store.CreateEphemeralReport(ctx, report)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("create=%v err=%v", created, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM ephemeral_abuse_reports WHERE reporter_user_id = $1", reporter)
|
||||
})
|
||||
if created, err := store.CreateEphemeralReport(ctx, report); err != nil || created {
|
||||
t.Fatalf("retry create=%v err=%v", created, err)
|
||||
}
|
||||
var evidenceRaw []byte
|
||||
var count int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT evidence, count(*) OVER ()
|
||||
FROM ephemeral_abuse_reports
|
||||
WHERE reporter_user_id = $1 AND channel_id = $2 AND ephemeral_message_id = $3
|
||||
`, reporter, message.Peer.ID, message.ID).Scan(&evidenceRaw, &count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("rows=%d", count)
|
||||
}
|
||||
var evidence map[string]any
|
||||
if err := json.Unmarshal(evidenceRaw, &evidence); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if evidence["MessageID"] != float64(message.ID) || evidence["Content"] == nil {
|
||||
t.Fatalf("evidence=%s", evidenceRaw)
|
||||
}
|
||||
if _, leaked := evidence["OriginDevice"]; leaked {
|
||||
t.Fatalf("device identity leaked into report evidence: %s", evidenceRaw)
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ func (s *MessageStore) DefaultHistoryTTL(ctx context.Context, userID int64) (int
|
|||
return 0, nil
|
||||
}
|
||||
var period int
|
||||
err := s.db.QueryRow(ctx, `SELECT COALESCE(default_history_ttl_period, 0)::int FROM users WHERE id = $1`, userID).Scan(&period)
|
||||
err := s.db.QueryRow(ctx, `SELECT COALESCE(default_history_ttl_period, 0)::int FROM users WHERE id = $1 AND deleted_at IS NULL`, userID).Scan(&period)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
|
|
@ -70,7 +70,7 @@ func (s *MessageStore) SetDefaultHistoryTTL(ctx context.Context, userID int64, p
|
|||
UPDATE users
|
||||
SET default_history_ttl_period = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
`, userID, period)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set default history ttl: %w", err)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
|
@ -208,7 +209,7 @@ func TestLoginCodeDeliveryPostgresCommitAckLossRecoversFromReceipt(t *testing.T)
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoginCodeDeliveryPostgresDifferentUsersDoNotRewriteOfficialUser(t *testing.T) {
|
||||
func TestLoginCodeDeliveryPostgresDifferentUsersDoNotRewriteOfficialIdentity(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
firstUser := createLoginCodeDeliveryTestUser(t, ctx, pool, "official-row-first")
|
||||
|
|
@ -222,6 +223,16 @@ func TestLoginCodeDeliveryPostgresDifferentUsersDoNotRewriteOfficialUser(t *test
|
|||
if err := pool.QueryRow(ctx, `SELECT xmin::text FROM users WHERE id = $1`, domain.OfficialSystemUserID).Scan(&xminBefore); err != nil {
|
||||
t.Fatalf("load official user xmin: %v", err)
|
||||
}
|
||||
var usernameBefore, usernameXminBefore string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT username_lower, xmin::text
|
||||
FROM peer_usernames
|
||||
WHERE peer_type = 'user' AND peer_id = $1`, domain.OfficialSystemUserID).Scan(&usernameBefore, &usernameXminBefore); err != nil {
|
||||
t.Fatalf("load official username identity: %v", err)
|
||||
}
|
||||
if want := strings.ToLower(domain.OfficialSystemUser().Username); usernameBefore != want {
|
||||
t.Fatalf("official username = %q, want %q", usernameBefore, want)
|
||||
}
|
||||
|
||||
const workers = 12
|
||||
users := make([]domain.User, workers)
|
||||
|
|
@ -256,6 +267,16 @@ func TestLoginCodeDeliveryPostgresDifferentUsersDoNotRewriteOfficialUser(t *test
|
|||
if xminAfter != xminBefore {
|
||||
t.Fatalf("official system user row was rewritten: xmin %s -> %s", xminBefore, xminAfter)
|
||||
}
|
||||
var usernameAfter, usernameXminAfter string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT username_lower, xmin::text
|
||||
FROM peer_usernames
|
||||
WHERE peer_type = 'user' AND peer_id = $1`, domain.OfficialSystemUserID).Scan(&usernameAfter, &usernameXminAfter); err != nil {
|
||||
t.Fatalf("reload official username identity: %v", err)
|
||||
}
|
||||
if usernameAfter != usernameBefore || usernameXminAfter != usernameXminBefore {
|
||||
t.Fatalf("official username identity was rewritten: %q/%s -> %q/%s", usernameBefore, usernameXminBefore, usernameAfter, usernameXminAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCodeDeliveryPostgresReceiptRetentionIsBoundedAndSeekOrdered(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -550,6 +550,56 @@ func (s *MediaStore) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool
|
|||
return photo, true, nil
|
||||
}
|
||||
|
||||
// GetPhotos resolves a bounded set of immutable photo metadata with one indexed
|
||||
// ANY query. Missing ids are omitted and the result follows first-seen caller order.
|
||||
func (s *MediaStore) GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
unique := 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 {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
unique = append(unique, id)
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, access_hash, file_reference, date, dc_id, has_stickers, sizes::text
|
||||
FROM photos
|
||||
WHERE id = ANY($1::bigint[])
|
||||
`, unique)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
byID := make(map[int64]domain.Photo, len(unique))
|
||||
for rows.Next() {
|
||||
photo, err := scanPhotoRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID[photo.ID] = photo
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]domain.Photo, 0, len(byID))
|
||||
for _, id := range unique {
|
||||
if photo, ok := byID[id]; ok {
|
||||
out = append(out, photo)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type photoScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package postgres
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -44,9 +45,12 @@ func decodeMessageMedia(s string) (*domain.MessageMedia, error) {
|
|||
return &m, nil
|
||||
}
|
||||
|
||||
// encodeReplyMarkup 把 inline keyboard 快照序列化为 JSONB;空 markup 序列化为 "{}"。
|
||||
// encodeReplyMarkup 把 reply/inline keyboard 快照序列化为 JSONB;空 markup 序列化为 "{}"。
|
||||
// callback data 是 []byte,json.Marshal 自动 base64(保证经 JSONB 字节级 round-trip)。
|
||||
func encodeReplyMarkup(m *domain.MessageReplyMarkup) ([]byte, error) {
|
||||
if err := domain.ValidateReplyMarkup(m); err != nil {
|
||||
return nil, fmt.Errorf("encode reply markup: %w", err)
|
||||
}
|
||||
if m.IsZero() {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
|
|
@ -63,6 +67,9 @@ func decodeReplyMarkup(s string) (*domain.MessageReplyMarkup, error) {
|
|||
if err := json.Unmarshal([]byte(s), &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(&m); err != nil {
|
||||
return nil, fmt.Errorf("decode reply markup: %w", err)
|
||||
}
|
||||
if m.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@ func TestMediaStoreRoundTrip(t *testing.T) {
|
|||
if err != nil || !ok || len(gotPhoto.Sizes) != 1 || gotPhoto.Sizes[0].Type != "x" {
|
||||
t.Fatalf("get photo mismatch: ok=%v err=%v photo=%+v", ok, err, gotPhoto)
|
||||
}
|
||||
photos, err := s.GetPhotos(ctx, []int64{photoID, 0, photoID, photoID + 99})
|
||||
if err != nil || len(photos) != 1 || photos[0].ID != photoID || len(photos[0].Sizes) != 1 {
|
||||
t.Fatalf("get photos mismatch: photos=%+v err=%v", photos, err)
|
||||
}
|
||||
|
||||
// ---- sticker set ----
|
||||
set := domain.StickerSet{
|
||||
|
|
|
|||
|
|
@ -331,19 +331,20 @@ func appendDeleteMessagesEvent(ctx context.Context, q *sqlcgen.Queries, event do
|
|||
event.PtsCount = 1
|
||||
}
|
||||
if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{
|
||||
UserID: event.UserID,
|
||||
Pts: int32(event.Pts),
|
||||
PtsCount: int32(event.PtsCount),
|
||||
Date: int32(event.Date),
|
||||
EventType: string(domain.UpdateEventDeleteMessages),
|
||||
EventPeers: []byte("[]"),
|
||||
PeerSettings: []byte("{}"),
|
||||
MessageIds: messageIDs,
|
||||
DialogFilter: []byte("{}"),
|
||||
FilterOrder: []byte("[]"),
|
||||
FolderPeers: []byte("[]"),
|
||||
StoryPayload: []byte("{}"),
|
||||
ReactionPayload: []byte("{}"),
|
||||
UserID: event.UserID,
|
||||
Pts: int32(event.Pts),
|
||||
PtsCount: int32(event.PtsCount),
|
||||
Date: int32(event.Date),
|
||||
EventType: string(domain.UpdateEventDeleteMessages),
|
||||
EventPeers: []byte("[]"),
|
||||
PeerSettings: []byte("{}"),
|
||||
MessageIds: messageIDs,
|
||||
DialogFilter: []byte("{}"),
|
||||
FilterOrder: []byte("[]"),
|
||||
FolderPeers: []byte("[]"),
|
||||
StoryPayload: []byte("{}"),
|
||||
ReactionPayload: []byte("{}"),
|
||||
EmojiStatusPayload: []byte("{}"),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("append delete messages event: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,27 @@ func (s *MessageStore) GetByIDs(ctx context.Context, userID int64, ids []int) (d
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// GetByUID resolves one owner's box row by the indexed shared private_message_id.
|
||||
func (s *MessageStore) GetByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error) {
|
||||
if userID == 0 || uid == 0 {
|
||||
return domain.Message{}, false, nil
|
||||
}
|
||||
row, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{
|
||||
OwnerUserID: userID,
|
||||
PrivateMessageID: uid,
|
||||
})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Message{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.Message{}, false, fmt.Errorf("get message by uid: %w", err)
|
||||
}
|
||||
if _, err := decodeReplyMarkup(row.ReplyMarkupJson); err != nil {
|
||||
return domain.Message{}, false, fmt.Errorf("get message by uid reply markup: %w", err)
|
||||
}
|
||||
return messageFromGetBoxRow(row), true, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
|
||||
limit := filter.Limit
|
||||
if limit <= 0 {
|
||||
|
|
@ -79,21 +100,23 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
|
|||
var rows []sqlcgen.ListMessagesByUserRow
|
||||
if addOffset >= 0 {
|
||||
bw, err := s.q.ListMessagesBackward(ctx, sqlcgen.ListMessagesBackwardParams{
|
||||
OwnerUserID: userID,
|
||||
HasPeer: filter.HasPeer,
|
||||
PeerType: string(filter.Peer.Type),
|
||||
PeerID: filter.Peer.ID,
|
||||
Query: filter.Query,
|
||||
MaxID: pgInt32NonNegative(filter.MaxID),
|
||||
MinID: pgInt32NonNegative(filter.MinID),
|
||||
PinnedOnly: filter.PinnedOnly,
|
||||
MusicOnly: filter.MusicOnly,
|
||||
SavedPeerType: savedPeerType,
|
||||
SavedPeerID: savedPeerID,
|
||||
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
|
||||
OffsetID: pgInt32NonNegative(filter.OffsetID),
|
||||
RowOffset: pgInt32Bounded(addOffset),
|
||||
LimitCount: int32(queryLimit),
|
||||
OwnerUserID: userID,
|
||||
HasPeer: filter.HasPeer,
|
||||
PeerType: string(filter.Peer.Type),
|
||||
PeerID: filter.Peer.ID,
|
||||
RestrictPeerIds: filter.RestrictPeerIDs,
|
||||
PeerIds: filter.PeerIDs,
|
||||
Query: filter.Query,
|
||||
MaxID: pgInt32NonNegative(filter.MaxID),
|
||||
MinID: pgInt32NonNegative(filter.MinID),
|
||||
PinnedOnly: filter.PinnedOnly,
|
||||
MusicOnly: filter.MusicOnly,
|
||||
SavedPeerType: savedPeerType,
|
||||
SavedPeerID: savedPeerID,
|
||||
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
|
||||
OffsetID: pgInt32NonNegative(filter.OffsetID),
|
||||
RowOffset: pgInt32Bounded(addOffset),
|
||||
LimitCount: int32(queryLimit),
|
||||
})
|
||||
if err != nil {
|
||||
return domain.MessageList{}, fmt.Errorf("list messages (backward): %w", err)
|
||||
|
|
@ -104,17 +127,19 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
|
|||
}
|
||||
if filter.NeedTotalCount {
|
||||
total, err := s.q.CountMessagesByUser(ctx, sqlcgen.CountMessagesByUserParams{
|
||||
OwnerUserID: userID,
|
||||
HasPeer: filter.HasPeer,
|
||||
PeerType: string(filter.Peer.Type),
|
||||
PeerID: filter.Peer.ID,
|
||||
Query: filter.Query,
|
||||
MaxID: pgInt32NonNegative(filter.MaxID),
|
||||
MinID: pgInt32NonNegative(filter.MinID),
|
||||
PinnedOnly: filter.PinnedOnly,
|
||||
MusicOnly: filter.MusicOnly,
|
||||
SavedPeerType: savedPeerType,
|
||||
SavedPeerID: savedPeerID,
|
||||
OwnerUserID: userID,
|
||||
HasPeer: filter.HasPeer,
|
||||
PeerType: string(filter.Peer.Type),
|
||||
PeerID: filter.Peer.ID,
|
||||
RestrictPeerIds: filter.RestrictPeerIDs,
|
||||
PeerIds: filter.PeerIDs,
|
||||
Query: filter.Query,
|
||||
MaxID: pgInt32NonNegative(filter.MaxID),
|
||||
MinID: pgInt32NonNegative(filter.MinID),
|
||||
PinnedOnly: filter.PinnedOnly,
|
||||
MusicOnly: filter.MusicOnly,
|
||||
SavedPeerType: savedPeerType,
|
||||
SavedPeerID: savedPeerID,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.MessageList{}, fmt.Errorf("count messages: %w", err)
|
||||
|
|
@ -128,22 +153,24 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
|
|||
} else {
|
||||
var err error
|
||||
rows, err = s.q.ListMessagesByUser(ctx, sqlcgen.ListMessagesByUserParams{
|
||||
OwnerUserID: userID,
|
||||
HasPeer: filter.HasPeer,
|
||||
PeerType: string(filter.Peer.Type),
|
||||
PeerID: filter.Peer.ID,
|
||||
Query: filter.Query,
|
||||
OffsetID: pgInt32NonNegative(filter.OffsetID),
|
||||
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
|
||||
MaxID: pgInt32NonNegative(filter.MaxID),
|
||||
MinID: pgInt32NonNegative(filter.MinID),
|
||||
AddOffset: pgInt32Bounded(addOffset),
|
||||
LimitCount: int32(queryLimit),
|
||||
PinnedOnly: filter.PinnedOnly,
|
||||
MusicOnly: filter.MusicOnly,
|
||||
NeedTotalCount: filter.NeedTotalCount,
|
||||
SavedPeerType: savedPeerType,
|
||||
SavedPeerID: savedPeerID,
|
||||
OwnerUserID: userID,
|
||||
HasPeer: filter.HasPeer,
|
||||
PeerType: string(filter.Peer.Type),
|
||||
PeerID: filter.Peer.ID,
|
||||
RestrictPeerIds: filter.RestrictPeerIDs,
|
||||
PeerIds: filter.PeerIDs,
|
||||
Query: filter.Query,
|
||||
OffsetID: pgInt32NonNegative(filter.OffsetID),
|
||||
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
|
||||
MaxID: pgInt32NonNegative(filter.MaxID),
|
||||
MinID: pgInt32NonNegative(filter.MinID),
|
||||
AddOffset: pgInt32Bounded(addOffset),
|
||||
LimitCount: int32(queryLimit),
|
||||
PinnedOnly: filter.PinnedOnly,
|
||||
MusicOnly: filter.MusicOnly,
|
||||
NeedTotalCount: filter.NeedTotalCount,
|
||||
SavedPeerType: savedPeerType,
|
||||
SavedPeerID: savedPeerID,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.MessageList{}, fmt.Errorf("list messages: %w", err)
|
||||
|
|
|
|||
42
internal/store/postgres/message_markup_codec_test.go
Normal file
42
internal/store/postgres/message_markup_codec_test.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestReplyMarkupCodecValidatesTaggedUnion(t *testing.T) {
|
||||
keyboard := &domain.MessageReplyMarkup{
|
||||
Type: domain.MessageReplyMarkupKeyboard,
|
||||
Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}}},
|
||||
Resize: true,
|
||||
Placeholder: "Choose",
|
||||
}
|
||||
raw, err := encodeReplyMarkup(keyboard)
|
||||
if err != nil {
|
||||
t.Fatalf("encode reply keyboard: %v", err)
|
||||
}
|
||||
got, err := decodeReplyMarkup(string(raw))
|
||||
if err != nil || got == nil || got.Kind() != domain.MessageReplyMarkupKeyboard ||
|
||||
len(got.Keyboard) != 1 || got.Keyboard[0][0].Text != "Help" || !got.Resize || got.Placeholder != "Choose" {
|
||||
t.Fatalf("decoded reply keyboard = %#v, err=%v", got, err)
|
||||
}
|
||||
|
||||
// Pre-union inline snapshots intentionally remain readable.
|
||||
legacy, err := decodeReplyMarkup(`{"inline":[[{"type":"callback","text":"OK","data":"b2s="}]]}`)
|
||||
if err != nil || legacy == nil || legacy.Kind() != domain.MessageReplyMarkupInline {
|
||||
t.Fatalf("legacy inline markup = %#v, err=%v", legacy, err)
|
||||
}
|
||||
|
||||
malformed := &domain.MessageReplyMarkup{
|
||||
Type: domain.MessageReplyMarkupInline,
|
||||
Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "wrong"}}},
|
||||
}
|
||||
if _, err := encodeReplyMarkup(malformed); err == nil {
|
||||
t.Fatal("malformed union must fail at the write boundary")
|
||||
}
|
||||
if _, err := decodeReplyMarkup(`{"type":"inline","keyboard":[[{"type":"text","text":"wrong"}]]}`); err == nil {
|
||||
t.Fatal("malformed stored union must fail at the read boundary")
|
||||
}
|
||||
}
|
||||
|
|
@ -66,9 +66,46 @@ func ensureOfficialSystemUserWithDB(ctx context.Context, db sqlcgen.DBTX, msg do
|
|||
return nil
|
||||
}
|
||||
if _, err := db.Exec(ctx, `
|
||||
INSERT INTO users (id, access_hash, phone, first_name, last_name, username, country_code, verified, support, about, is_bot, bot_info_version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
WITH desired (
|
||||
id, access_hash, phone, first_name, last_name, username,
|
||||
country_code, verified, support, about, is_bot, bot_info_version
|
||||
) AS (
|
||||
VALUES ($1::bigint, $2::bigint, $3::text, $4::text, $5::text, $6::text,
|
||||
$7::text, $8::boolean, $9::boolean, $10::text, $11::boolean, $12::integer)
|
||||
), upserted AS (
|
||||
INSERT INTO users (id, access_hash, phone, first_name, last_name, username, country_code, verified, support, about, is_bot, bot_info_version)
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, verified, support, about, is_bot, bot_info_version
|
||||
FROM desired
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
access_hash = EXCLUDED.access_hash,
|
||||
phone = EXCLUDED.phone,
|
||||
first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name,
|
||||
username = EXCLUDED.username,
|
||||
country_code = EXCLUDED.country_code,
|
||||
verified = EXCLUDED.verified,
|
||||
support = EXCLUDED.support,
|
||||
about = EXCLUDED.about,
|
||||
is_bot = EXCLUDED.is_bot,
|
||||
bot_info_version = EXCLUDED.bot_info_version,
|
||||
updated_at = now()
|
||||
WHERE (
|
||||
users.access_hash, users.phone, users.first_name, users.last_name,
|
||||
users.username, users.country_code, users.verified, users.support,
|
||||
users.about, users.is_bot, users.bot_info_version
|
||||
) IS DISTINCT FROM (
|
||||
EXCLUDED.access_hash, EXCLUDED.phone, EXCLUDED.first_name, EXCLUDED.last_name,
|
||||
EXCLUDED.username, EXCLUDED.country_code, EXCLUDED.verified, EXCLUDED.support,
|
||||
EXCLUDED.about, EXCLUDED.is_bot, EXCLUDED.bot_info_version
|
||||
)
|
||||
)
|
||||
INSERT INTO peer_usernames (username_lower, peer_type, peer_id)
|
||||
SELECT lower(username), 'user', id
|
||||
FROM desired
|
||||
ON CONFLICT (peer_type, peer_id) DO UPDATE SET
|
||||
username_lower = EXCLUDED.username_lower,
|
||||
updated_at = now()
|
||||
WHERE peer_usernames.username_lower IS DISTINCT FROM EXCLUDED.username_lower
|
||||
`, u.ID, u.AccessHash, u.Phone, u.FirstName, u.LastName, u.Username, u.CountryCode, u.Verified, u.Support, u.About, u.Bot, u.BotInfoVersion); err != nil {
|
||||
return fmt.Errorf("ensure official system user: %w", err)
|
||||
}
|
||||
|
|
@ -117,7 +154,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
// reply_markup(bot inline keyboard)随消息一并入双盒;普通用户发送恒 nil → "{}"。
|
||||
// reply_markup(bot reply/inline keyboard)随消息一并入双盒;普通用户发送恒 nil → "{}"。
|
||||
replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
|
|
@ -656,22 +693,23 @@ func appendNewMessageEvent(ctx context.Context, q *sqlcgen.Queries, msg domain.M
|
|||
peerType := string(msg.Peer.Type)
|
||||
peerID := msg.Peer.ID
|
||||
if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{
|
||||
UserID: msg.OwnerUserID,
|
||||
Pts: int32(msg.Pts),
|
||||
PtsCount: 1,
|
||||
Date: int32(msg.Date),
|
||||
EventType: string(domain.UpdateEventNewMessage),
|
||||
EventPeers: []byte("[]"),
|
||||
PeerSettings: []byte("{}"),
|
||||
MessageIds: []byte("[]"),
|
||||
DialogFilter: []byte("{}"),
|
||||
FilterOrder: []byte("[]"),
|
||||
FolderPeers: []byte("[]"),
|
||||
StoryPayload: []byte("{}"),
|
||||
ReactionPayload: []byte("{}"),
|
||||
MessageBoxID: &boxID,
|
||||
PeerType: &peerType,
|
||||
PeerID: &peerID,
|
||||
UserID: msg.OwnerUserID,
|
||||
Pts: int32(msg.Pts),
|
||||
PtsCount: 1,
|
||||
Date: int32(msg.Date),
|
||||
EventType: string(domain.UpdateEventNewMessage),
|
||||
EventPeers: []byte("[]"),
|
||||
PeerSettings: []byte("{}"),
|
||||
MessageIds: []byte("[]"),
|
||||
DialogFilter: []byte("{}"),
|
||||
FilterOrder: []byte("[]"),
|
||||
FolderPeers: []byte("[]"),
|
||||
StoryPayload: []byte("{}"),
|
||||
ReactionPayload: []byte("{}"),
|
||||
EmojiStatusPayload: []byte("{}"),
|
||||
MessageBoxID: &boxID,
|
||||
PeerType: &peerType,
|
||||
PeerID: &peerID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("append new message event: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,6 +205,70 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
|
|||
assertWebViewData("recipient event", events[0].Message)
|
||||
}
|
||||
|
||||
func TestMessageStoreRequestedPeerDisclosureSnapshotRoundTrip(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
|
||||
users := NewUserStore(pool)
|
||||
sender := createTestUser(t, ctx, users, "+1666"+suffix+"33", "RequestedSender", "")
|
||||
recipient := createTestUser(t, ctx, users, "+1666"+suffix+"34", "RequestedRecipient", "")
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
|
||||
})
|
||||
|
||||
requestedPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 5501}
|
||||
photo := domain.Photo{ID: 8201, Sizes: []domain.PhotoSize{{
|
||||
Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096,
|
||||
}}}
|
||||
messages := NewMessageStore(pool)
|
||||
got, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: sender.ID, RecipientUserID: recipient.ID, RandomID: 9002, Date: 1700000212,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer,
|
||||
RequestedPeer: &domain.MessageRequestedPeerAction{
|
||||
ButtonID: 88, Peers: []domain.Peer{requestedPeer},
|
||||
Details: []domain.MessageRequestedPeerDetails{{
|
||||
Peer: requestedPeer, Title: "Shared Chat", Username: "shared_chat", Photo: &photo,
|
||||
}},
|
||||
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
assertSnapshot := func(name string, msg domain.Message) {
|
||||
t.Helper()
|
||||
if msg.Media == nil || msg.Media.ServiceAction == nil || msg.Media.ServiceAction.RequestedPeer == nil {
|
||||
t.Fatalf("%s media=%+v, want requested-peer action", name, msg.Media)
|
||||
}
|
||||
action := msg.Media.ServiceAction.RequestedPeer
|
||||
if action.ButtonID != 88 || len(action.Peers) != 1 || action.Peers[0] != requestedPeer ||
|
||||
len(action.Details) != 1 || action.Details[0].Title != "Shared Chat" ||
|
||||
action.Details[0].Username != "shared_chat" || action.Details[0].Photo == nil ||
|
||||
len(action.Details[0].Photo.Sizes) != 1 || action.Details[0].Photo.Sizes[0].W != 320 ||
|
||||
!action.NameRequested || !action.UsernameRequested || !action.PhotoRequested {
|
||||
t.Fatalf("%s requested-peer=%+v", name, action)
|
||||
}
|
||||
}
|
||||
assertSnapshot("sender", got.SenderMessage)
|
||||
assertSnapshot("recipient", got.RecipientMessage)
|
||||
|
||||
history, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{
|
||||
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10,
|
||||
})
|
||||
if err != nil || len(history.Messages) != 1 {
|
||||
t.Fatalf("recipient history=%+v err=%v", history, err)
|
||||
}
|
||||
assertSnapshot("recipient history", history.Messages[0])
|
||||
events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10)
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("recipient events=%+v err=%v", events, err)
|
||||
}
|
||||
assertSnapshot("recipient event", events[0].Message)
|
||||
}
|
||||
|
||||
func TestMessageStorePhoneCallServiceFirstMessageFeedsDialogsAndUpdates(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ SELECT
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at
|
||||
FROM contacts c
|
||||
JOIN users u ON u.id = c.contact_user_id
|
||||
|
|
@ -52,6 +54,8 @@ SELECT
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at
|
||||
FROM contacts c
|
||||
JOIN users u ON u.id = c.contact_user_id
|
||||
|
|
@ -130,6 +134,8 @@ SELECT
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at,
|
||||
EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed
|
||||
FROM upserted c
|
||||
|
|
@ -168,6 +174,8 @@ SELECT
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at
|
||||
FROM updated c
|
||||
JOIN users u ON u.id = c.contact_user_id;
|
||||
|
|
|
|||
|
|
@ -537,6 +537,10 @@ base AS NOT MATERIALIZED (
|
|||
NOT sqlc.arg(has_peer)::boolean
|
||||
OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint)
|
||||
)
|
||||
AND (
|
||||
NOT sqlc.arg(restrict_peer_ids)::boolean
|
||||
OR (m.peer_type = 'user' AND m.peer_id = ANY(sqlc.arg(peer_ids)::bigint[]))
|
||||
)
|
||||
AND (
|
||||
sqlc.arg(query)::text = ''
|
||||
OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%')
|
||||
|
|
@ -798,6 +802,10 @@ WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
|
|||
NOT sqlc.arg(has_peer)::boolean
|
||||
OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint)
|
||||
)
|
||||
AND (
|
||||
NOT sqlc.arg(restrict_peer_ids)::boolean
|
||||
OR (m.peer_type = 'user' AND m.peer_id = ANY(sqlc.arg(peer_ids)::bigint[]))
|
||||
)
|
||||
AND (
|
||||
sqlc.arg(query)::text = ''
|
||||
OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%')
|
||||
|
|
@ -840,6 +848,10 @@ WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
|
|||
NOT sqlc.arg(has_peer)::boolean
|
||||
OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint)
|
||||
)
|
||||
AND (
|
||||
NOT sqlc.arg(restrict_peer_ids)::boolean
|
||||
OR (m.peer_type = 'user' AND m.peer_id = ANY(sqlc.arg(peer_ids)::bigint[]))
|
||||
)
|
||||
AND (
|
||||
sqlc.arg(query)::text = ''
|
||||
OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%')
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ WHERE id = ANY(sqlc.arg(ids)::bigint[])
|
|||
ORDER BY id;
|
||||
|
||||
-- name: GetUserByPhone :one
|
||||
SELECT * FROM users WHERE phone = $1;
|
||||
SELECT * FROM users WHERE phone = $1 AND deleted_at IS NULL;
|
||||
|
||||
-- name: GetUserBySignupEmail :one
|
||||
SELECT * FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> '';
|
||||
|
|
@ -16,11 +16,11 @@ SELECT * FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> ''
|
|||
-- name: GetUsersByPhones :many
|
||||
SELECT *
|
||||
FROM users
|
||||
WHERE phone = ANY(sqlc.arg(phones)::text[])
|
||||
WHERE phone = ANY(sqlc.arg(phones)::text[]) AND deleted_at IS NULL
|
||||
ORDER BY id;
|
||||
|
||||
-- name: GetUserByUsername :one
|
||||
SELECT * FROM users WHERE lower(username) = lower($1) AND username <> '';
|
||||
SELECT * FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL;
|
||||
|
||||
-- name: SearchUsers :many
|
||||
WITH matched AS (
|
||||
|
|
@ -40,12 +40,15 @@ WITH matched AS (
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.color_set,
|
||||
u.color,
|
||||
u.color_background_emoji_id,
|
||||
u.profile_color_set,
|
||||
u.profile_color,
|
||||
u.profile_color_background_emoji_id,
|
||||
u.linked_community_id,
|
||||
u.last_seen_at,
|
||||
(c.contact_user_id IS NOT NULL)::boolean AS contact,
|
||||
COALESCE(c.mutual, false)::boolean AS mutual,
|
||||
|
|
@ -60,6 +63,7 @@ WITH matched AS (
|
|||
FROM users u
|
||||
LEFT JOIN contacts c ON c.user_id = sqlc.arg(current_user_id)::bigint AND c.contact_user_id = u.id
|
||||
WHERE u.id <> sqlc.arg(current_user_id)::bigint
|
||||
AND u.deleted_at IS NULL
|
||||
AND sqlc.arg(query_lower)::text <> ''
|
||||
AND (
|
||||
(sqlc.arg(phone_query)::text <> '' AND u.phone LIKE sqlc.arg(phone_query)::text || '%')
|
||||
|
|
@ -88,12 +92,15 @@ SELECT
|
|||
premium_expires_at,
|
||||
emoji_status_document_id,
|
||||
emoji_status_until,
|
||||
emoji_status_collectible_id,
|
||||
emoji_status_collectible,
|
||||
color_set,
|
||||
color,
|
||||
color_background_emoji_id,
|
||||
profile_color_set,
|
||||
profile_color,
|
||||
profile_color_background_emoji_id,
|
||||
linked_community_id,
|
||||
last_seen_at,
|
||||
contact,
|
||||
mutual
|
||||
|
|
@ -110,14 +117,14 @@ RETURNING *;
|
|||
UPDATE users
|
||||
SET username = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateUserLastSeen :exec
|
||||
UPDATE users
|
||||
SET last_seen_at = GREATEST(last_seen_at, sqlc.arg(last_seen_at)::bigint),
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg(id)::bigint;
|
||||
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL;
|
||||
|
||||
-- name: UpdateUserProfile :one
|
||||
UPDATE users
|
||||
|
|
@ -125,14 +132,14 @@ SET first_name = $2,
|
|||
last_name = $3,
|
||||
about = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateUserPhone :one
|
||||
UPDATE users
|
||||
SET phone = sqlc.arg(phone)::text,
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg(id)::bigint
|
||||
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateUserPhoneAndSignupEmail :one
|
||||
|
|
@ -147,14 +154,14 @@ RETURNING *;
|
|||
UPDATE users
|
||||
SET premium_expires_at = sqlc.narg(premium_expires_at)::timestamptz,
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg(id)::bigint
|
||||
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: SetUserVerified :one
|
||||
UPDATE users
|
||||
SET verified = sqlc.arg(verified)::boolean,
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg(id)::bigint
|
||||
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: SweepExpiredPremium :many
|
||||
|
|
@ -164,6 +171,7 @@ SET premium_expires_at = NULL,
|
|||
WHERE id IN (
|
||||
SELECT id FROM users
|
||||
WHERE premium_expires_at IS NOT NULL
|
||||
AND deleted_at IS NULL
|
||||
AND premium_expires_at <= sqlc.arg(now)::timestamptz
|
||||
ORDER BY premium_expires_at
|
||||
LIMIT sqlc.arg(limit_count)::int
|
||||
|
|
@ -174,8 +182,10 @@ RETURNING *;
|
|||
UPDATE users
|
||||
SET emoji_status_document_id = sqlc.arg(emoji_status_document_id)::bigint,
|
||||
emoji_status_until = sqlc.arg(emoji_status_until)::bigint,
|
||||
emoji_status_collectible_id = sqlc.narg(emoji_status_collectible_id)::bigint,
|
||||
emoji_status_collectible = sqlc.arg(emoji_status_collectible)::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg(id)::bigint
|
||||
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateUserBirthday :one
|
||||
|
|
@ -184,14 +194,14 @@ SET birthday_day = sqlc.arg(birthday_day)::int,
|
|||
birthday_month = sqlc.arg(birthday_month)::int,
|
||||
birthday_year = sqlc.arg(birthday_year)::int,
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg(id)::bigint
|
||||
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateUserPersonalChannel :one
|
||||
UPDATE users
|
||||
SET personal_channel_id = sqlc.arg(personal_channel_id)::bigint,
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg(id)::bigint
|
||||
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateUserColor :one
|
||||
|
|
@ -200,7 +210,7 @@ SET color_set = sqlc.arg(color_set)::boolean,
|
|||
color = sqlc.arg(color)::int,
|
||||
color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint,
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg(id)::bigint
|
||||
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateUserProfileColor :one
|
||||
|
|
@ -209,5 +219,5 @@ SET profile_color_set = sqlc.arg(color_set)::boolean,
|
|||
profile_color = sqlc.arg(color)::int,
|
||||
profile_color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint,
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg(id)::bigint
|
||||
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ INSERT INTO user_update_events (
|
|||
folder_peers,
|
||||
story_payload,
|
||||
reaction_payload,
|
||||
emoji_status_payload,
|
||||
message_box_id,
|
||||
peer_type,
|
||||
peer_id,
|
||||
|
|
@ -40,6 +41,7 @@ INSERT INTO user_update_events (
|
|||
sqlc.arg(folder_peers)::jsonb,
|
||||
sqlc.arg(story_payload)::jsonb,
|
||||
sqlc.arg(reaction_payload)::jsonb,
|
||||
sqlc.arg(emoji_status_payload)::jsonb,
|
||||
sqlc.narg(message_box_id),
|
||||
sqlc.narg(peer_type)::text,
|
||||
sqlc.narg(peer_id)::bigint,
|
||||
|
|
@ -68,6 +70,7 @@ SELECT
|
|||
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
|
||||
COALESCE(e.story_payload::text, '{}')::text AS story_payload_json,
|
||||
COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json,
|
||||
COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json,
|
||||
COALESCE(e.peer_type, '')::text AS event_peer_type,
|
||||
COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
|
||||
e.filter_id,
|
||||
|
|
@ -341,6 +344,7 @@ SELECT
|
|||
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
|
||||
COALESCE(e.story_payload::text, '{}')::text AS story_payload_json,
|
||||
COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json,
|
||||
COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json,
|
||||
COALESCE(e.peer_type, '')::text AS event_peer_type,
|
||||
COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
|
||||
e.filter_id,
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ func (q *Queries) InsertBot(ctx context.Context, arg InsertBotParams) error {
|
|||
const insertBotUser = `-- name: InsertBotUser :one
|
||||
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version)
|
||||
VALUES ($1, '', $2, '', $3, '', TRUE, 1)
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
`
|
||||
|
||||
type InsertBotUserParams struct {
|
||||
|
|
@ -209,6 +209,13 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ SELECT
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at
|
||||
FROM contacts c
|
||||
JOIN users u ON u.id = c.contact_user_id
|
||||
|
|
@ -80,29 +82,31 @@ type GetContactParams struct {
|
|||
}
|
||||
|
||||
type GetContactRow struct {
|
||||
ContactUserID int64
|
||||
Mutual bool
|
||||
CloseFriend bool
|
||||
ContactPhone string
|
||||
ContactFirstName string
|
||||
ContactLastName string
|
||||
Note string
|
||||
NoteEntitiesJson string
|
||||
ID int64
|
||||
AccessHash int64
|
||||
Phone string
|
||||
FirstName string
|
||||
LastName string
|
||||
Username string
|
||||
CountryCode string
|
||||
Verified bool
|
||||
Support bool
|
||||
IsBot bool
|
||||
BotInfoVersion int32
|
||||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
LastSeenAt int64
|
||||
ContactUserID int64
|
||||
Mutual bool
|
||||
CloseFriend bool
|
||||
ContactPhone string
|
||||
ContactFirstName string
|
||||
ContactLastName string
|
||||
Note string
|
||||
NoteEntitiesJson string
|
||||
ID int64
|
||||
AccessHash int64
|
||||
Phone string
|
||||
FirstName string
|
||||
LastName string
|
||||
Username string
|
||||
CountryCode string
|
||||
Verified bool
|
||||
Support bool
|
||||
IsBot bool
|
||||
BotInfoVersion int32
|
||||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
LastSeenAt int64
|
||||
}
|
||||
|
||||
func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetContactRow, error) {
|
||||
|
|
@ -131,6 +135,8 @@ func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetCont
|
|||
&i.PremiumExpiresAt,
|
||||
&i.EmojiStatusDocumentID,
|
||||
&i.EmojiStatusUntil,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LastSeenAt,
|
||||
)
|
||||
return i, err
|
||||
|
|
@ -160,6 +166,8 @@ SELECT
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at
|
||||
FROM contacts c
|
||||
JOIN users u ON u.id = c.contact_user_id
|
||||
|
|
@ -168,29 +176,31 @@ ORDER BY c.contact_first_name, c.contact_last_name, u.first_name, u.last_name, u
|
|||
`
|
||||
|
||||
type ListContactsByUserRow struct {
|
||||
ContactUserID int64
|
||||
Mutual bool
|
||||
CloseFriend bool
|
||||
ContactPhone string
|
||||
ContactFirstName string
|
||||
ContactLastName string
|
||||
Note string
|
||||
NoteEntitiesJson string
|
||||
ID int64
|
||||
AccessHash int64
|
||||
Phone string
|
||||
FirstName string
|
||||
LastName string
|
||||
Username string
|
||||
CountryCode string
|
||||
Verified bool
|
||||
Support bool
|
||||
IsBot bool
|
||||
BotInfoVersion int32
|
||||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
LastSeenAt int64
|
||||
ContactUserID int64
|
||||
Mutual bool
|
||||
CloseFriend bool
|
||||
ContactPhone string
|
||||
ContactFirstName string
|
||||
ContactLastName string
|
||||
Note string
|
||||
NoteEntitiesJson string
|
||||
ID int64
|
||||
AccessHash int64
|
||||
Phone string
|
||||
FirstName string
|
||||
LastName string
|
||||
Username string
|
||||
CountryCode string
|
||||
Verified bool
|
||||
Support bool
|
||||
IsBot bool
|
||||
BotInfoVersion int32
|
||||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
LastSeenAt int64
|
||||
}
|
||||
|
||||
func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListContactsByUserRow, error) {
|
||||
|
|
@ -225,6 +235,8 @@ func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListC
|
|||
&i.PremiumExpiresAt,
|
||||
&i.EmojiStatusDocumentID,
|
||||
&i.EmojiStatusUntil,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LastSeenAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -270,6 +282,8 @@ SELECT
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at
|
||||
FROM updated c
|
||||
JOIN users u ON u.id = c.contact_user_id
|
||||
|
|
@ -283,29 +297,31 @@ type UpdateContactNoteParams struct {
|
|||
}
|
||||
|
||||
type UpdateContactNoteRow struct {
|
||||
ContactUserID int64
|
||||
Mutual bool
|
||||
CloseFriend bool
|
||||
ContactPhone string
|
||||
ContactFirstName string
|
||||
ContactLastName string
|
||||
Note string
|
||||
NoteEntitiesJson string
|
||||
ID int64
|
||||
AccessHash int64
|
||||
Phone string
|
||||
FirstName string
|
||||
LastName string
|
||||
Username string
|
||||
CountryCode string
|
||||
Verified bool
|
||||
Support bool
|
||||
IsBot bool
|
||||
BotInfoVersion int32
|
||||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
LastSeenAt int64
|
||||
ContactUserID int64
|
||||
Mutual bool
|
||||
CloseFriend bool
|
||||
ContactPhone string
|
||||
ContactFirstName string
|
||||
ContactLastName string
|
||||
Note string
|
||||
NoteEntitiesJson string
|
||||
ID int64
|
||||
AccessHash int64
|
||||
Phone string
|
||||
FirstName string
|
||||
LastName string
|
||||
Username string
|
||||
CountryCode string
|
||||
Verified bool
|
||||
Support bool
|
||||
IsBot bool
|
||||
BotInfoVersion int32
|
||||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
LastSeenAt int64
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNoteParams) (UpdateContactNoteRow, error) {
|
||||
|
|
@ -339,6 +355,8 @@ func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNotePa
|
|||
&i.PremiumExpiresAt,
|
||||
&i.EmojiStatusDocumentID,
|
||||
&i.EmojiStatusUntil,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LastSeenAt,
|
||||
)
|
||||
return i, err
|
||||
|
|
@ -416,6 +434,8 @@ SELECT
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at,
|
||||
EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed
|
||||
FROM upserted c
|
||||
|
|
@ -433,30 +453,32 @@ type UpsertContactParams struct {
|
|||
}
|
||||
|
||||
type UpsertContactRow struct {
|
||||
ContactUserID int64
|
||||
Mutual bool
|
||||
CloseFriend bool
|
||||
ContactPhone string
|
||||
ContactFirstName string
|
||||
ContactLastName string
|
||||
Note string
|
||||
NoteEntitiesJson string
|
||||
ID int64
|
||||
AccessHash int64
|
||||
Phone string
|
||||
FirstName string
|
||||
LastName string
|
||||
Username string
|
||||
CountryCode string
|
||||
Verified bool
|
||||
Support bool
|
||||
IsBot bool
|
||||
BotInfoVersion int32
|
||||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
LastSeenAt int64
|
||||
ReverseMutualChanged bool
|
||||
ContactUserID int64
|
||||
Mutual bool
|
||||
CloseFriend bool
|
||||
ContactPhone string
|
||||
ContactFirstName string
|
||||
ContactLastName string
|
||||
Note string
|
||||
NoteEntitiesJson string
|
||||
ID int64
|
||||
AccessHash int64
|
||||
Phone string
|
||||
FirstName string
|
||||
LastName string
|
||||
Username string
|
||||
CountryCode string
|
||||
Verified bool
|
||||
Support bool
|
||||
IsBot bool
|
||||
BotInfoVersion int32
|
||||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
LastSeenAt int64
|
||||
ReverseMutualChanged bool
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (UpsertContactRow, error) {
|
||||
|
|
@ -493,6 +515,8 @@ func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (U
|
|||
&i.PremiumExpiresAt,
|
||||
&i.EmojiStatusDocumentID,
|
||||
&i.EmojiStatusUntil,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LastSeenAt,
|
||||
&i.ReverseMutualChanged,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,14 +19,18 @@ WHERE m.owner_user_id = $1::bigint
|
|||
OR (m.peer_type = $3::text AND m.peer_id = $4::bigint)
|
||||
)
|
||||
AND (
|
||||
$5::text = ''
|
||||
OR m.body ILIKE ('%' || $5::text || '%')
|
||||
NOT $5::boolean
|
||||
OR (m.peer_type = 'user' AND m.peer_id = ANY($6::bigint[]))
|
||||
)
|
||||
AND ($6::int <= 0 OR m.box_id < $6::int)
|
||||
AND ($7::int <= 0 OR m.box_id > $7::int)
|
||||
AND (NOT $8::boolean OR m.pinned)
|
||||
AND (
|
||||
NOT $9::boolean
|
||||
$7::text = ''
|
||||
OR m.body ILIKE ('%' || $7::text || '%')
|
||||
)
|
||||
AND ($8::int <= 0 OR m.box_id < $8::int)
|
||||
AND ($9::int <= 0 OR m.box_id > $9::int)
|
||||
AND (NOT $10::boolean OR m.pinned)
|
||||
AND (
|
||||
NOT $11::boolean
|
||||
OR (
|
||||
m.media->>'kind' = 'document'
|
||||
AND EXISTS (
|
||||
|
|
@ -38,23 +42,25 @@ WHERE m.owner_user_id = $1::bigint
|
|||
)
|
||||
)
|
||||
AND (
|
||||
$10::text = ''
|
||||
OR (m.saved_peer_type = $10::text AND m.saved_peer_id = $11::bigint)
|
||||
$12::text = ''
|
||||
OR (m.saved_peer_type = $12::text AND m.saved_peer_id = $13::bigint)
|
||||
)
|
||||
`
|
||||
|
||||
type CountMessagesByUserParams struct {
|
||||
OwnerUserID int64
|
||||
HasPeer bool
|
||||
PeerType string
|
||||
PeerID int64
|
||||
Query string
|
||||
MaxID int32
|
||||
MinID int32
|
||||
PinnedOnly bool
|
||||
MusicOnly bool
|
||||
SavedPeerType string
|
||||
SavedPeerID int64
|
||||
OwnerUserID int64
|
||||
HasPeer bool
|
||||
PeerType string
|
||||
PeerID int64
|
||||
RestrictPeerIds bool
|
||||
PeerIds []int64
|
||||
Query string
|
||||
MaxID int32
|
||||
MinID int32
|
||||
PinnedOnly bool
|
||||
MusicOnly bool
|
||||
SavedPeerType string
|
||||
SavedPeerID int64
|
||||
}
|
||||
|
||||
// ListMessagesByUser total CTE 的独立化:相同 base 过滤(不含分页 anchor),
|
||||
|
|
@ -65,6 +71,8 @@ func (q *Queries) CountMessagesByUser(ctx context.Context, arg CountMessagesByUs
|
|||
arg.HasPeer,
|
||||
arg.PeerType,
|
||||
arg.PeerID,
|
||||
arg.RestrictPeerIds,
|
||||
arg.PeerIds,
|
||||
arg.Query,
|
||||
arg.MaxID,
|
||||
arg.MinID,
|
||||
|
|
@ -2280,14 +2288,18 @@ WHERE m.owner_user_id = $1::bigint
|
|||
OR (m.peer_type = $3::text AND m.peer_id = $4::bigint)
|
||||
)
|
||||
AND (
|
||||
$5::text = ''
|
||||
OR m.body ILIKE ('%' || $5::text || '%')
|
||||
NOT $5::boolean
|
||||
OR (m.peer_type = 'user' AND m.peer_id = ANY($6::bigint[]))
|
||||
)
|
||||
AND ($6::int <= 0 OR m.box_id < $6::int)
|
||||
AND ($7::int <= 0 OR m.box_id > $7::int)
|
||||
AND (NOT $8::boolean OR m.pinned)
|
||||
AND (
|
||||
NOT $9::boolean
|
||||
$7::text = ''
|
||||
OR m.body ILIKE ('%' || $7::text || '%')
|
||||
)
|
||||
AND ($8::int <= 0 OR m.box_id < $8::int)
|
||||
AND ($9::int <= 0 OR m.box_id > $9::int)
|
||||
AND (NOT $10::boolean OR m.pinned)
|
||||
AND (
|
||||
NOT $11::boolean
|
||||
OR (
|
||||
m.media->>'kind' = 'document'
|
||||
AND EXISTS (
|
||||
|
|
@ -2299,34 +2311,36 @@ WHERE m.owner_user_id = $1::bigint
|
|||
)
|
||||
)
|
||||
AND (
|
||||
$10::text = ''
|
||||
OR (m.saved_peer_type = $10::text AND m.saved_peer_id = $11::bigint)
|
||||
$12::text = ''
|
||||
OR (m.saved_peer_type = $12::text AND m.saved_peer_id = $13::bigint)
|
||||
)
|
||||
AND (
|
||||
($12::int > 0 AND m.message_date < $12::int)
|
||||
OR ($12::int <= 0 AND ($13::int <= 0 OR m.box_id < $13::int))
|
||||
($14::int > 0 AND m.message_date < $14::int)
|
||||
OR ($14::int <= 0 AND ($15::int <= 0 OR m.box_id < $15::int))
|
||||
)
|
||||
ORDER BY m.box_id DESC
|
||||
OFFSET GREATEST($14::int, 0)
|
||||
LIMIT $15::int
|
||||
OFFSET GREATEST($16::int, 0)
|
||||
LIMIT $17::int
|
||||
`
|
||||
|
||||
type ListMessagesBackwardParams struct {
|
||||
OwnerUserID int64
|
||||
HasPeer bool
|
||||
PeerType string
|
||||
PeerID int64
|
||||
Query string
|
||||
MaxID int32
|
||||
MinID int32
|
||||
PinnedOnly bool
|
||||
MusicOnly bool
|
||||
SavedPeerType string
|
||||
SavedPeerID int64
|
||||
OffsetDate int32
|
||||
OffsetID int32
|
||||
RowOffset int32
|
||||
LimitCount int32
|
||||
OwnerUserID int64
|
||||
HasPeer bool
|
||||
PeerType string
|
||||
PeerID int64
|
||||
RestrictPeerIds bool
|
||||
PeerIds []int64
|
||||
Query string
|
||||
MaxID int32
|
||||
MinID int32
|
||||
PinnedOnly bool
|
||||
MusicOnly bool
|
||||
SavedPeerType string
|
||||
SavedPeerID int64
|
||||
OffsetDate int32
|
||||
OffsetID int32
|
||||
RowOffset int32
|
||||
LimitCount int32
|
||||
}
|
||||
|
||||
type ListMessagesBackwardRow struct {
|
||||
|
|
@ -2416,6 +2430,8 @@ func (q *Queries) ListMessagesBackward(ctx context.Context, arg ListMessagesBack
|
|||
arg.HasPeer,
|
||||
arg.PeerType,
|
||||
arg.PeerID,
|
||||
arg.RestrictPeerIds,
|
||||
arg.PeerIds,
|
||||
arg.Query,
|
||||
arg.MaxID,
|
||||
arg.MinID,
|
||||
|
|
@ -2618,14 +2634,18 @@ base AS NOT MATERIALIZED (
|
|||
OR (m.peer_type = $7::text AND m.peer_id = $8::bigint)
|
||||
)
|
||||
AND (
|
||||
$9::text = ''
|
||||
OR m.body ILIKE ('%' || $9::text || '%')
|
||||
NOT $9::boolean
|
||||
OR (m.peer_type = 'user' AND m.peer_id = ANY($10::bigint[]))
|
||||
)
|
||||
AND ($10::int <= 0 OR m.box_id < $10::int)
|
||||
AND ($11::int <= 0 OR m.box_id > $11::int)
|
||||
AND (NOT $12::boolean OR m.pinned)
|
||||
AND (
|
||||
NOT $13::boolean
|
||||
$11::text = ''
|
||||
OR m.body ILIKE ('%' || $11::text || '%')
|
||||
)
|
||||
AND ($12::int <= 0 OR m.box_id < $12::int)
|
||||
AND ($13::int <= 0 OR m.box_id > $13::int)
|
||||
AND (NOT $14::boolean OR m.pinned)
|
||||
AND (
|
||||
NOT $15::boolean
|
||||
OR (
|
||||
m.media->>'kind' = 'document'
|
||||
AND EXISTS (
|
||||
|
|
@ -2637,14 +2657,14 @@ base AS NOT MATERIALIZED (
|
|||
)
|
||||
)
|
||||
AND (
|
||||
$14::text = ''
|
||||
OR (m.saved_peer_type = $14::text AND m.saved_peer_id = $15::bigint)
|
||||
$16::text = ''
|
||||
OR (m.saved_peer_type = $16::text AND m.saved_peer_id = $17::bigint)
|
||||
)
|
||||
),
|
||||
total AS (
|
||||
SELECT count(*)::int AS total_count
|
||||
FROM base
|
||||
WHERE $16::boolean
|
||||
WHERE $18::boolean
|
||||
),
|
||||
backward AS (
|
||||
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at
|
||||
|
|
@ -2791,22 +2811,24 @@ ORDER BY box_id DESC
|
|||
`
|
||||
|
||||
type ListMessagesByUserParams struct {
|
||||
OwnerUserID int64
|
||||
OffsetID int32
|
||||
OffsetDate int32
|
||||
AddOffset int32
|
||||
LimitCount int32
|
||||
HasPeer bool
|
||||
PeerType string
|
||||
PeerID int64
|
||||
Query string
|
||||
MaxID int32
|
||||
MinID int32
|
||||
PinnedOnly bool
|
||||
MusicOnly bool
|
||||
SavedPeerType string
|
||||
SavedPeerID int64
|
||||
NeedTotalCount bool
|
||||
OwnerUserID int64
|
||||
OffsetID int32
|
||||
OffsetDate int32
|
||||
AddOffset int32
|
||||
LimitCount int32
|
||||
HasPeer bool
|
||||
PeerType string
|
||||
PeerID int64
|
||||
RestrictPeerIds bool
|
||||
PeerIds []int64
|
||||
Query string
|
||||
MaxID int32
|
||||
MinID int32
|
||||
PinnedOnly bool
|
||||
MusicOnly bool
|
||||
SavedPeerType string
|
||||
SavedPeerID int64
|
||||
NeedTotalCount bool
|
||||
}
|
||||
|
||||
type ListMessagesByUserRow struct {
|
||||
|
|
@ -2896,6 +2918,8 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser
|
|||
arg.HasPeer,
|
||||
arg.PeerType,
|
||||
arg.PeerID,
|
||||
arg.RestrictPeerIds,
|
||||
arg.PeerIds,
|
||||
arg.Query,
|
||||
arg.MaxID,
|
||||
arg.MinID,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,32 @@ import (
|
|||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type AccountDeletionNotification struct {
|
||||
ID int64
|
||||
TargetUserID int64
|
||||
DeletedUserID int64
|
||||
Status string
|
||||
Attempts int32
|
||||
NextAttemptAt pgtype.Timestamptz
|
||||
LeaseUntil pgtype.Timestamptz
|
||||
LastError string
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type AccountDeletionRequest struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
RequesterAuthKeyID int64
|
||||
State string
|
||||
Reason string
|
||||
ConfirmHashDigest []byte
|
||||
RequestedAt pgtype.Timestamptz
|
||||
ExecuteAt pgtype.Timestamptz
|
||||
CompletedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type AccountPassword struct {
|
||||
UserID int64
|
||||
HasRecovery bool
|
||||
|
|
@ -30,6 +56,7 @@ type AccountPassword struct {
|
|||
RecoveryCode string
|
||||
RecoveryCodeExpiresAt pgtype.Timestamptz
|
||||
LoginEmail string
|
||||
PasswordChangedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type AccountPrivacyRule struct {
|
||||
|
|
@ -267,21 +294,49 @@ type Bot struct {
|
|||
}
|
||||
|
||||
type BotApiUpdate struct {
|
||||
ID int64
|
||||
BotUserID int64
|
||||
UpdateKind string
|
||||
PeerType string
|
||||
PeerID int64
|
||||
MessageID int32
|
||||
SourcePts int32
|
||||
Date int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
ID int64
|
||||
BotUserID int64
|
||||
UpdateKind string
|
||||
PeerType string
|
||||
PeerID int64
|
||||
MessageID int32
|
||||
SourcePts int32
|
||||
Date int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
CallbackQueryID int64
|
||||
CallbackUserID int64
|
||||
CallbackChatInstance int64
|
||||
CallbackData []byte
|
||||
CallbackInlineDcID int32
|
||||
CallbackInlineOwnerID int64
|
||||
CallbackInlineMessageID int32
|
||||
CallbackInlineAccessHash int64
|
||||
EphemeralPayload []byte
|
||||
}
|
||||
|
||||
type BotApiUpdateState struct {
|
||||
BotUserID int64
|
||||
ConfirmedUpdateID int64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
AllowedUpdates []string
|
||||
CursorInitialized bool
|
||||
PollOwner string
|
||||
PollExpiresAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type BotApiWebhook struct {
|
||||
BotUserID int64
|
||||
Url string
|
||||
SecretToken string
|
||||
MaxConnections int32
|
||||
AllowedUpdates []string
|
||||
FailureCount int32
|
||||
LastErrorDate int32
|
||||
LastErrorMessage string
|
||||
NextAttemptAt pgtype.Timestamptz
|
||||
DeliveryOwner string
|
||||
DeliveryExpiresAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type BotApp struct {
|
||||
|
|
@ -448,6 +503,7 @@ type Channel struct {
|
|||
LinkedMonoforumID int64
|
||||
Wallpaper []byte
|
||||
Verified bool
|
||||
LinkedCommunityID int64
|
||||
}
|
||||
|
||||
type ChannelAdminLogEvent struct {
|
||||
|
|
@ -653,6 +709,8 @@ type ChannelMessage struct {
|
|||
DeleteDate int32
|
||||
DeleteMessageIds []byte
|
||||
RequestFingerprint []byte
|
||||
SuggestedPost []byte
|
||||
PaidMessageStars int64
|
||||
}
|
||||
|
||||
type ChannelMessageMedium struct {
|
||||
|
|
@ -694,6 +752,42 @@ type ChannelMessageViewer struct {
|
|||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type ChannelStarsBalance struct {
|
||||
ChannelID int64
|
||||
Balance int64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type ChannelStarsTransaction struct {
|
||||
ID int64
|
||||
ChannelID int64
|
||||
ActorUserID int64
|
||||
Amount int64
|
||||
Reason string
|
||||
PeerType string
|
||||
PeerID int64
|
||||
GiftID *int64
|
||||
Date int32
|
||||
}
|
||||
|
||||
type ChannelTonBalance struct {
|
||||
ChannelID int64
|
||||
BalanceNanoton int64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type ChannelTonTransaction struct {
|
||||
ID int64
|
||||
ChannelID int64
|
||||
ActorUserID int64
|
||||
AmountNanoton int64
|
||||
Reason string
|
||||
PeerType string
|
||||
PeerID int64
|
||||
GiftID *int64
|
||||
Date int32
|
||||
}
|
||||
|
||||
type ChannelTopicRead struct {
|
||||
ChannelID int64
|
||||
UserID int64
|
||||
|
|
@ -767,6 +861,62 @@ type ChatlistMembership struct {
|
|||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type Community struct {
|
||||
ID int64
|
||||
AccessHash int64
|
||||
CreatorUserID int64
|
||||
Title string
|
||||
About string
|
||||
DefaultBannedRights []byte
|
||||
PhotoID int64
|
||||
PhotoDcID int32
|
||||
PhotoStripped []byte
|
||||
Date int32
|
||||
Deleted bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type CommunityMember struct {
|
||||
CommunityID int64
|
||||
UserID int64
|
||||
Role string
|
||||
Status string
|
||||
AdminRights []byte
|
||||
Rank string
|
||||
Date int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type CommunityPeerLink struct {
|
||||
CommunityID int64
|
||||
PeerType string
|
||||
PeerID int64
|
||||
Visibility string
|
||||
CreatedBy int64
|
||||
Date int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type CommunityPeerLinkRequest struct {
|
||||
CommunityID int64
|
||||
PeerType string
|
||||
PeerID int64
|
||||
RequestedBy int64
|
||||
Visibility string
|
||||
Date int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type CommunityUserState struct {
|
||||
CommunityID int64
|
||||
UserID int64
|
||||
Collapsed bool
|
||||
Pinned bool
|
||||
PinnedOrder int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type Contact struct {
|
||||
UserID int64
|
||||
ContactUserID int64
|
||||
|
|
@ -942,6 +1092,21 @@ type EncryptedStateEventDelivery struct {
|
|||
AuthKeyID int64
|
||||
}
|
||||
|
||||
type EphemeralAbuseReport struct {
|
||||
ID int64
|
||||
ReporterUserID int64
|
||||
ChannelID int64
|
||||
EphemeralMessageID int32
|
||||
SenderUserID int64
|
||||
ReceiverUserID int64
|
||||
ReportOption string
|
||||
ReportComment string
|
||||
CommentHash []byte
|
||||
PayloadHash []byte
|
||||
Evidence []byte
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type FileBlob struct {
|
||||
LocationKey string
|
||||
Backend string
|
||||
|
|
@ -1156,19 +1321,33 @@ type PasskeyCredential struct {
|
|||
}
|
||||
|
||||
type PeerStarGift struct {
|
||||
ID int64
|
||||
OwnerPeerID int64
|
||||
FromUserID int64
|
||||
GiftID int64
|
||||
MsgID int32
|
||||
GiftDate int32
|
||||
NameHidden bool
|
||||
Unsaved bool
|
||||
Converted bool
|
||||
ConvertStars int64
|
||||
Message string
|
||||
OwnerPeerType string
|
||||
SavedID int64
|
||||
ID int64
|
||||
OwnerPeerID int64
|
||||
FromUserID int64
|
||||
GiftID int64
|
||||
MsgID int32
|
||||
GiftDate int32
|
||||
NameHidden bool
|
||||
Unsaved bool
|
||||
Converted bool
|
||||
ConvertStars int64
|
||||
Message string
|
||||
OwnerPeerType string
|
||||
SavedID int64
|
||||
CatalogRevisionID int64
|
||||
UniqueGiftID *int64
|
||||
UpgradeMsgID int32
|
||||
PinnedOrder int32
|
||||
PrepaidUpgradeStars int64
|
||||
LifecycleStatus string
|
||||
TransferStars int64
|
||||
PrepaidUpgradeHash string
|
||||
GiftNum int32
|
||||
CanExportAt int32
|
||||
CanTransferAt int32
|
||||
CanResellAt int32
|
||||
DropOriginalDetailsStars int64
|
||||
CanCraftAt int32
|
||||
}
|
||||
|
||||
type PeerTranslationSetting struct {
|
||||
|
|
@ -1417,6 +1596,394 @@ type SeedState struct {
|
|||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type StarGiftAuction struct {
|
||||
GiftID int64
|
||||
Slug string
|
||||
Version int32
|
||||
StartDate int32
|
||||
EndDate int32
|
||||
RoundDuration int32
|
||||
GiftsPerRound int32
|
||||
TotalRounds int32
|
||||
CurrentRound int32
|
||||
NextRoundAt int32
|
||||
LastGiftNum int32
|
||||
GiftsLeft int32
|
||||
MinBidAmount int64
|
||||
Status string
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type StarGiftAuctionAcquired struct {
|
||||
ID int64
|
||||
GiftID int64
|
||||
BidderUserID int64
|
||||
RecipientPeerType string
|
||||
RecipientPeerID int64
|
||||
SavedGiftID *int64
|
||||
BidAmount int64
|
||||
Round int32
|
||||
Pos int32
|
||||
GiftNum *int32
|
||||
AcquiredAt int32
|
||||
HideName bool
|
||||
Message string
|
||||
}
|
||||
|
||||
type StarGiftAuctionBid struct {
|
||||
GiftID int64
|
||||
BidderUserID int64
|
||||
RecipientPeerType string
|
||||
RecipientPeerID int64
|
||||
Amount int64
|
||||
BidDate int32
|
||||
HideName bool
|
||||
Message string
|
||||
Returned bool
|
||||
AcquiredCount int32
|
||||
Active bool
|
||||
Version int64
|
||||
}
|
||||
|
||||
type StarGiftAuctionBidPayment struct {
|
||||
UserID int64
|
||||
FormID int64
|
||||
GiftID int64
|
||||
BidAmount int64
|
||||
BalanceAfter int64
|
||||
CreatedAt int32
|
||||
}
|
||||
|
||||
type StarGiftCatalog struct {
|
||||
GiftID int64
|
||||
ActiveRevisionID int64
|
||||
Enabled bool
|
||||
SortOrder int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
CollectibleRevisionID *int64
|
||||
AvailabilityRemains int32
|
||||
AvailabilityResale int64
|
||||
ResellMinStars int64
|
||||
FirstSaleDate int32
|
||||
LastSaleDate int32
|
||||
}
|
||||
|
||||
type StarGiftCatalogRevision struct {
|
||||
ID int64
|
||||
GiftID int64
|
||||
Revision int32
|
||||
Title string
|
||||
Stars int64
|
||||
ConvertStars int64
|
||||
DocumentID int64
|
||||
AnimationJson []byte
|
||||
AnimationSha256 []byte
|
||||
SourceName string
|
||||
SourceFormat string
|
||||
Width int32
|
||||
Height int32
|
||||
FrameRate float64
|
||||
InPoint float64
|
||||
OutPoint float64
|
||||
CreatedBy string
|
||||
CommandID string
|
||||
CreatedAt pgtype.Timestamptz
|
||||
OfficialGiftID *int64
|
||||
SourceManifestSha256 []byte
|
||||
OfficialSource []byte
|
||||
Limited bool
|
||||
SoldOut bool
|
||||
Birthday bool
|
||||
RequirePremium bool
|
||||
LimitedPerUser bool
|
||||
PeerColorAvailable bool
|
||||
Auction bool
|
||||
AvailabilityTotal int32
|
||||
ReleasedByPeerType *string
|
||||
ReleasedByPeerID *int64
|
||||
PerUserTotal int32
|
||||
LockedUntilDate int32
|
||||
AuctionSlug string
|
||||
GiftsPerRound int32
|
||||
AuctionStartDate int32
|
||||
UpgradeVariants int32
|
||||
BackgroundCenterColor *int32
|
||||
BackgroundEdgeColor *int32
|
||||
BackgroundTextColor *int32
|
||||
}
|
||||
|
||||
type StarGiftCollectibleBackdrop struct {
|
||||
ID int64
|
||||
CollectibleRevisionID int64
|
||||
Name string
|
||||
BackdropID int32
|
||||
CenterColor int32
|
||||
EdgeColor int32
|
||||
PatternColor int32
|
||||
TextColor int32
|
||||
RarityPermille *int32
|
||||
SortOrder int32
|
||||
RarityKind string
|
||||
}
|
||||
|
||||
type StarGiftCollectibleModel struct {
|
||||
ID int64
|
||||
CollectibleRevisionID int64
|
||||
Name string
|
||||
DocumentID int64
|
||||
AnimationJson []byte
|
||||
AnimationSha256 []byte
|
||||
SourceName string
|
||||
SourceFormat string
|
||||
Width int32
|
||||
Height int32
|
||||
FrameRate float64
|
||||
InPoint float64
|
||||
OutPoint float64
|
||||
RarityPermille *int32
|
||||
SortOrder int32
|
||||
RarityKind string
|
||||
Crafted bool
|
||||
OfficialDocumentID *int64
|
||||
}
|
||||
|
||||
type StarGiftCollectiblePattern struct {
|
||||
ID int64
|
||||
CollectibleRevisionID int64
|
||||
Name string
|
||||
DocumentID int64
|
||||
AnimationJson []byte
|
||||
AnimationSha256 []byte
|
||||
SourceName string
|
||||
SourceFormat string
|
||||
Width int32
|
||||
Height int32
|
||||
FrameRate float64
|
||||
InPoint float64
|
||||
OutPoint float64
|
||||
RarityPermille *int32
|
||||
SortOrder int32
|
||||
RarityKind string
|
||||
OfficialDocumentID *int64
|
||||
}
|
||||
|
||||
type StarGiftCollectibleRevision struct {
|
||||
ID int64
|
||||
GiftID int64
|
||||
Revision int32
|
||||
UpgradeStars int64
|
||||
SupplyTotal int32
|
||||
Issued int32
|
||||
SlugPrefix string
|
||||
Status string
|
||||
CreatedBy string
|
||||
CommandID string
|
||||
CreatedAt pgtype.Timestamptz
|
||||
PublishedAt pgtype.Timestamptz
|
||||
OfficialGiftID *int64
|
||||
SourceManifestSha256 []byte
|
||||
}
|
||||
|
||||
type StarGiftCollection struct {
|
||||
CollectionID int32
|
||||
OwnerPeerType string
|
||||
OwnerPeerID int64
|
||||
Title string
|
||||
SortOrder int32
|
||||
Hash int64
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type StarGiftCollectionItem struct {
|
||||
CollectionID int32
|
||||
SavedGiftID int64
|
||||
SortOrder int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type StarGiftConversion struct {
|
||||
SavedGiftID int64
|
||||
ActorUserID int64
|
||||
OwnerPeerType string
|
||||
OwnerPeerID int64
|
||||
Amount int64
|
||||
BalanceAfter int64
|
||||
ConvertedAt int32
|
||||
}
|
||||
|
||||
type StarGiftCraftCommand struct {
|
||||
UserID int64
|
||||
CommandKey string
|
||||
InputUniqueGiftIds []int64
|
||||
GiftID int64
|
||||
Success bool
|
||||
ResultUniqueGiftID *int64
|
||||
ChancePermille int32
|
||||
CreatedAt int32
|
||||
SourceEditPts []int32
|
||||
}
|
||||
|
||||
type StarGiftDropDetailsCommand struct {
|
||||
UserID int64
|
||||
CommandKey string
|
||||
SavedGiftID int64
|
||||
UniqueGiftID int64
|
||||
FormID int64
|
||||
ChargeStars int64
|
||||
BalanceAfter int64
|
||||
CreatedAt int32
|
||||
}
|
||||
|
||||
type StarGiftListing struct {
|
||||
UniqueGiftID int64
|
||||
SellerPeerType string
|
||||
SellerPeerID int64
|
||||
Currency string
|
||||
Amount int64
|
||||
ListedAt int32
|
||||
UpdatedAt int32
|
||||
Version int64
|
||||
}
|
||||
|
||||
type StarGiftNotificationSetting struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
Enabled bool
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type StarGiftOffer struct {
|
||||
ID int64
|
||||
BuyerUserID int64
|
||||
OwnerPeerType string
|
||||
OwnerPeerID int64
|
||||
UniqueGiftID int64
|
||||
Currency string
|
||||
Amount int64
|
||||
RandomID int64
|
||||
OfferMsgID int32
|
||||
BuyerMsgID int32
|
||||
Status string
|
||||
CreatedAt int32
|
||||
ExpiresAt int32
|
||||
ResolvedAt int32
|
||||
BalanceAfter int64
|
||||
ResolutionNotified bool
|
||||
}
|
||||
|
||||
type StarGiftPatternDocumentRepair struct {
|
||||
OldDocumentID int64
|
||||
NewDocumentID int64
|
||||
RepairedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type StarGiftPatternPreviewDocumentRepair struct {
|
||||
OldDocumentID int64
|
||||
NewDocumentID int64
|
||||
RepairedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type StarGiftPrepaidUpgradeCommand struct {
|
||||
PayerUserID int64
|
||||
CommandKey string
|
||||
SavedGiftID int64
|
||||
FormID int64
|
||||
ChargeStars int64
|
||||
BalanceAfter int64
|
||||
CreatedAt int32
|
||||
}
|
||||
|
||||
type StarGiftPurchaseCommand struct {
|
||||
BuyerUserID int64
|
||||
CommandKey string
|
||||
GiftID int64
|
||||
RecipientPeerType string
|
||||
RecipientPeerID int64
|
||||
SavedGiftID int64
|
||||
FormID int64
|
||||
ChargeStars int64
|
||||
BalanceAfter int64
|
||||
CreatedAt int32
|
||||
}
|
||||
|
||||
type StarGiftPurchaseForm struct {
|
||||
BuyerUserID int64
|
||||
FormID int64
|
||||
GiftID int64
|
||||
RevisionID int64
|
||||
RecipientPeerType string
|
||||
RecipientPeerID int64
|
||||
IncludeUpgrade bool
|
||||
HideName bool
|
||||
Message string
|
||||
ChargeStars int64
|
||||
IssuedAt int32
|
||||
ExpiresAt int32
|
||||
}
|
||||
|
||||
type StarGiftSale struct {
|
||||
ID int64
|
||||
UniqueGiftID int64
|
||||
SellerPeerType string
|
||||
SellerPeerID int64
|
||||
BuyerPeerType string
|
||||
BuyerPeerID int64
|
||||
Currency string
|
||||
Amount int64
|
||||
CommissionAmount int64
|
||||
SoldAt int32
|
||||
CommandKey string
|
||||
}
|
||||
|
||||
type StarGiftTransferCommand struct {
|
||||
ActorUserID int64
|
||||
CommandKey string
|
||||
UniqueGiftID int64
|
||||
FromPeerType string
|
||||
FromPeerID int64
|
||||
ToPeerType string
|
||||
ToPeerID int64
|
||||
ChargeStars int64
|
||||
BalanceAfter int64
|
||||
CreatedAt int32
|
||||
}
|
||||
|
||||
type StarGiftUpgradeCommand struct {
|
||||
UserID int64
|
||||
CommandKey string
|
||||
SourceSavedGiftID int64
|
||||
FormID int64
|
||||
UniqueGiftID int64
|
||||
BalanceAfter int64
|
||||
CreatedAt pgtype.Timestamptz
|
||||
ChargeStars int64
|
||||
RequirePrepaid bool
|
||||
KeepOriginalDetails bool
|
||||
SourceEditPts int32
|
||||
}
|
||||
|
||||
type StarGiftUserPurchase struct {
|
||||
UserID int64
|
||||
GiftID int64
|
||||
PurchasedCount int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type StarGiftWithdrawalRequest struct {
|
||||
ID int64
|
||||
UniqueGiftID int64
|
||||
OwnerUserID int64
|
||||
Provider string
|
||||
ProviderRequestID string
|
||||
Url string
|
||||
Status string
|
||||
CreatedAt int32
|
||||
ExpiresAt int32
|
||||
CompletedAt int32
|
||||
}
|
||||
|
||||
type StarsBalance struct {
|
||||
UserID int64
|
||||
Balance int64
|
||||
|
|
@ -1533,6 +2100,28 @@ type StoryView struct {
|
|||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TelesrvCollectiblePatternCorrectionEvent struct {
|
||||
UserID int64
|
||||
Pts int32
|
||||
}
|
||||
|
||||
type TelesrvPatternPreviewCorrectionEvent struct {
|
||||
UserID int64
|
||||
Pts int32
|
||||
}
|
||||
|
||||
type TelesrvPatternPreviewRepairedWearer struct {
|
||||
UserID int64
|
||||
OldDocumentID int64
|
||||
NewDocumentID int64
|
||||
}
|
||||
|
||||
type TelesrvRepairedCollectibleWearer struct {
|
||||
UserID int64
|
||||
OldDocumentID int64
|
||||
NewDocumentID int64
|
||||
}
|
||||
|
||||
type TempAuthKeyBinding struct {
|
||||
TempAuthKeyID int64
|
||||
PermAuthKeyID int64
|
||||
|
|
@ -1564,6 +2153,66 @@ type ThemeUserInstall struct {
|
|||
InstalledAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TonBalance struct {
|
||||
UserID int64
|
||||
BalanceNanoton int64
|
||||
Granted bool
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TonTransaction struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
AmountNanoton int64
|
||||
Reason string
|
||||
PeerType *string
|
||||
PeerID *int64
|
||||
GiftID *int64
|
||||
Date int32
|
||||
}
|
||||
|
||||
type UniqueStarGift struct {
|
||||
ID int64
|
||||
GiftID int64
|
||||
CollectibleRevisionID int64
|
||||
SourceSavedGiftID int64
|
||||
Title string
|
||||
Slug string
|
||||
Num int32
|
||||
OwnerPeerType *string
|
||||
OwnerPeerID *int64
|
||||
ModelAttributeID int64
|
||||
PatternAttributeID int64
|
||||
BackdropAttributeID int64
|
||||
KeepOriginalDetails bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
RequirePremium bool
|
||||
ResaleTonOnly bool
|
||||
ThemeAvailable bool
|
||||
Burned bool
|
||||
Crafted bool
|
||||
OriginalOwnerPeerType string
|
||||
OriginalOwnerPeerID int64
|
||||
OwnerName string
|
||||
OwnerAddress string
|
||||
GiftAddress string
|
||||
ReleasedByPeerType *string
|
||||
ReleasedByPeerID *int64
|
||||
ValueAmount int64
|
||||
ValueCurrency string
|
||||
ValueUsdAmount int64
|
||||
ThemePeerType *string
|
||||
ThemePeerID *int64
|
||||
HostPeerType *string
|
||||
HostPeerID *int64
|
||||
OfferMinStars int32
|
||||
CraftChancePermille int32
|
||||
LastSaleDate int32
|
||||
LastSaleCurrency string
|
||||
LastSaleAmount int64
|
||||
}
|
||||
|
||||
type UpdateState struct {
|
||||
AuthKeyID int64
|
||||
Pts int32
|
||||
|
|
@ -1627,6 +2276,13 @@ type User struct {
|
|||
BirthdayMonth int32
|
||||
BirthdayYear int32
|
||||
PersonalChannelID int64
|
||||
DeletedAt pgtype.Timestamptz
|
||||
DeletionSource string
|
||||
DeletionReason string
|
||||
AccountDeleteAt pgtype.Timestamptz
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
LinkedCommunityID int64
|
||||
SignupEmail string
|
||||
}
|
||||
|
||||
|
|
@ -1703,33 +2359,34 @@ type UserTopReaction struct {
|
|||
}
|
||||
|
||||
type UserUpdateEvent struct {
|
||||
UserID int64
|
||||
Pts int32
|
||||
PtsCount int32
|
||||
Date int32
|
||||
EventType string
|
||||
MessageBoxID *int32
|
||||
PeerType *string
|
||||
PeerID *int64
|
||||
MaxID int32
|
||||
StillUnreadCount int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
EventBool bool
|
||||
EventPeers []byte
|
||||
PeerSettings []byte
|
||||
MessageIds []byte
|
||||
DialogFilter []byte
|
||||
FilterOrder []byte
|
||||
FolderPeers []byte
|
||||
FilterID int32
|
||||
TagsEnabled bool
|
||||
ChannelPts int32
|
||||
FolderID int32
|
||||
QuickReplies []byte
|
||||
QuickReplyMessage []byte
|
||||
StoryPayload []byte
|
||||
ReactionPayload []byte
|
||||
EventPhone string
|
||||
UserID int64
|
||||
Pts int32
|
||||
PtsCount int32
|
||||
Date int32
|
||||
EventType string
|
||||
MessageBoxID *int32
|
||||
PeerType *string
|
||||
PeerID *int64
|
||||
MaxID int32
|
||||
StillUnreadCount int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
EventBool bool
|
||||
EventPeers []byte
|
||||
PeerSettings []byte
|
||||
MessageIds []byte
|
||||
DialogFilter []byte
|
||||
FilterOrder []byte
|
||||
FolderPeers []byte
|
||||
FilterID int32
|
||||
TagsEnabled bool
|
||||
ChannelPts int32
|
||||
FolderID int32
|
||||
QuickReplies []byte
|
||||
QuickReplyMessage []byte
|
||||
StoryPayload []byte
|
||||
ReactionPayload []byte
|
||||
EventPhone string
|
||||
EmojiStatusPayload []byte
|
||||
}
|
||||
|
||||
type UserUpdateRetention struct {
|
||||
|
|
@ -1765,13 +2422,17 @@ type WebviewCustomMethodQuery struct {
|
|||
}
|
||||
|
||||
type WebviewRequestedButton struct {
|
||||
WebappReqID string
|
||||
BotUserID int64
|
||||
UserID int64
|
||||
ButtonID int32
|
||||
Text string
|
||||
PeerType string
|
||||
MaxQuantity int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
ExpiresAt pgtype.Timestamptz
|
||||
WebappReqID string
|
||||
BotUserID int64
|
||||
UserID int64
|
||||
ButtonID int32
|
||||
Text string
|
||||
PeerType string
|
||||
MaxQuantity int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
ExpiresAt pgtype.Timestamptz
|
||||
PeerFilter []byte
|
||||
NameRequested bool
|
||||
UsernameRequested bool
|
||||
PhotoRequested bool
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (access_hash, phone, signup_email, first_name, last_name, username, country_code, premium_expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
|
|
@ -70,13 +70,20 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email FROM users WHERE id = $1
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||
|
|
@ -112,13 +119,20 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByPhone = `-- name: GetUserByPhone :one
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email FROM users WHERE phone = $1
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE phone = $1 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) {
|
||||
|
|
@ -154,13 +168,20 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserBySignupEmail = `-- name: GetUserBySignupEmail :one
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> ''
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> ''
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserBySignupEmail(ctx context.Context, lower string) (User, error) {
|
||||
|
|
@ -196,13 +217,20 @@ func (q *Queries) GetUserBySignupEmail(ctx context.Context, lower string) (User,
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email FROM users WHERE lower(username) = lower($1) AND username <> ''
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) {
|
||||
|
|
@ -238,13 +266,20 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUsersByIDs = `-- name: GetUsersByIDs :many
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
FROM users
|
||||
WHERE id = ANY($1::bigint[])
|
||||
ORDER BY id
|
||||
|
|
@ -289,6 +324,13 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -302,9 +344,9 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
|
|||
}
|
||||
|
||||
const getUsersByPhones = `-- name: GetUsersByPhones :many
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
FROM users
|
||||
WHERE phone = ANY($1::text[])
|
||||
WHERE phone = ANY($1::text[]) AND deleted_at IS NULL
|
||||
ORDER BY id
|
||||
`
|
||||
|
||||
|
|
@ -347,6 +389,13 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -377,12 +426,15 @@ WITH matched AS (
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.color_set,
|
||||
u.color,
|
||||
u.color_background_emoji_id,
|
||||
u.profile_color_set,
|
||||
u.profile_color,
|
||||
u.profile_color_background_emoji_id,
|
||||
u.linked_community_id,
|
||||
u.last_seen_at,
|
||||
(c.contact_user_id IS NOT NULL)::boolean AS contact,
|
||||
COALESCE(c.mutual, false)::boolean AS mutual,
|
||||
|
|
@ -397,6 +449,7 @@ WITH matched AS (
|
|||
FROM users u
|
||||
LEFT JOIN contacts c ON c.user_id = $4::bigint AND c.contact_user_id = u.id
|
||||
WHERE u.id <> $4::bigint
|
||||
AND u.deleted_at IS NULL
|
||||
AND $3::text <> ''
|
||||
AND (
|
||||
($2::text <> '' AND u.phone LIKE $2::text || '%')
|
||||
|
|
@ -425,12 +478,15 @@ SELECT
|
|||
premium_expires_at,
|
||||
emoji_status_document_id,
|
||||
emoji_status_until,
|
||||
emoji_status_collectible_id,
|
||||
emoji_status_collectible,
|
||||
color_set,
|
||||
color,
|
||||
color_background_emoji_id,
|
||||
profile_color_set,
|
||||
profile_color,
|
||||
profile_color_background_emoji_id,
|
||||
linked_community_id,
|
||||
last_seen_at,
|
||||
contact,
|
||||
mutual
|
||||
|
|
@ -463,12 +519,15 @@ type SearchUsersRow struct {
|
|||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
ColorSet bool
|
||||
Color int32
|
||||
ColorBackgroundEmojiID int64
|
||||
ProfileColorSet bool
|
||||
ProfileColor int32
|
||||
ProfileColorBackgroundEmojiID int64
|
||||
LinkedCommunityID int64
|
||||
LastSeenAt int64
|
||||
Contact bool
|
||||
Mutual bool
|
||||
|
|
@ -505,12 +564,15 @@ func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]Sea
|
|||
&i.PremiumExpiresAt,
|
||||
&i.EmojiStatusDocumentID,
|
||||
&i.EmojiStatusUntil,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.ColorSet,
|
||||
&i.Color,
|
||||
&i.ColorBackgroundEmojiID,
|
||||
&i.ProfileColorSet,
|
||||
&i.ProfileColor,
|
||||
&i.ProfileColorBackgroundEmojiID,
|
||||
&i.LinkedCommunityID,
|
||||
&i.LastSeenAt,
|
||||
&i.Contact,
|
||||
&i.Mutual,
|
||||
|
|
@ -529,8 +591,8 @@ const setUserPremiumUntil = `-- name: SetUserPremiumUntil :one
|
|||
UPDATE users
|
||||
SET premium_expires_at = $1::timestamptz,
|
||||
updated_at = now()
|
||||
WHERE id = $2::bigint
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email
|
||||
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
`
|
||||
|
||||
type SetUserPremiumUntilParams struct {
|
||||
|
|
@ -571,6 +633,13 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
|
|
@ -580,8 +649,8 @@ const setUserVerified = `-- name: SetUserVerified :one
|
|||
UPDATE users
|
||||
SET verified = $1::boolean,
|
||||
updated_at = now()
|
||||
WHERE id = $2::bigint
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email
|
||||
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
`
|
||||
|
||||
type SetUserVerifiedParams struct {
|
||||
|
|
@ -622,6 +691,13 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
|
|
@ -634,11 +710,12 @@ SET premium_expires_at = NULL,
|
|||
WHERE id IN (
|
||||
SELECT id FROM users
|
||||
WHERE premium_expires_at IS NOT NULL
|
||||
AND deleted_at IS NULL
|
||||
AND premium_expires_at <= $1::timestamptz
|
||||
ORDER BY premium_expires_at
|
||||
LIMIT $2::int
|
||||
)
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
`
|
||||
|
||||
type SweepExpiredPremiumParams struct {
|
||||
|
|
@ -685,6 +762,13 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -703,8 +787,8 @@ SET birthday_day = $1::int,
|
|||
birthday_month = $2::int,
|
||||
birthday_year = $3::int,
|
||||
updated_at = now()
|
||||
WHERE id = $4::bigint
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email
|
||||
WHERE id = $4::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
`
|
||||
|
||||
type UpdateUserBirthdayParams struct {
|
||||
|
|
@ -752,6 +836,13 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
|
|
@ -763,8 +854,8 @@ SET color_set = $1::boolean,
|
|||
color = $2::int,
|
||||
color_background_emoji_id = $3::bigint,
|
||||
updated_at = now()
|
||||
WHERE id = $4::bigint
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email
|
||||
WHERE id = $4::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
`
|
||||
|
||||
type UpdateUserColorParams struct {
|
||||
|
|
@ -812,6 +903,13 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
|
|
@ -821,19 +919,29 @@ const updateUserEmojiStatus = `-- name: UpdateUserEmojiStatus :one
|
|||
UPDATE users
|
||||
SET emoji_status_document_id = $1::bigint,
|
||||
emoji_status_until = $2::bigint,
|
||||
emoji_status_collectible_id = $3::bigint,
|
||||
emoji_status_collectible = $4::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id = $3::bigint
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email
|
||||
WHERE id = $5::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
`
|
||||
|
||||
type UpdateUserEmojiStatusParams struct {
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
ID int64
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmojiStatusParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, updateUserEmojiStatus, arg.EmojiStatusDocumentID, arg.EmojiStatusUntil, arg.ID)
|
||||
row := q.db.QueryRow(ctx, updateUserEmojiStatus,
|
||||
arg.EmojiStatusDocumentID,
|
||||
arg.EmojiStatusUntil,
|
||||
arg.EmojiStatusCollectibleID,
|
||||
arg.EmojiStatusCollectible,
|
||||
arg.ID,
|
||||
)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
|
|
@ -865,6 +973,13 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
|
|
@ -874,7 +989,7 @@ const updateUserLastSeen = `-- name: UpdateUserLastSeen :exec
|
|||
UPDATE users
|
||||
SET last_seen_at = GREATEST(last_seen_at, $1::bigint),
|
||||
updated_at = now()
|
||||
WHERE id = $2::bigint
|
||||
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
type UpdateUserLastSeenParams struct {
|
||||
|
|
@ -891,8 +1006,8 @@ const updateUserPersonalChannel = `-- name: UpdateUserPersonalChannel :one
|
|||
UPDATE users
|
||||
SET personal_channel_id = $1::bigint,
|
||||
updated_at = now()
|
||||
WHERE id = $2::bigint
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email
|
||||
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
`
|
||||
|
||||
type UpdateUserPersonalChannelParams struct {
|
||||
|
|
@ -933,6 +1048,13 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
|
|
@ -942,8 +1064,8 @@ const updateUserPhone = `-- name: UpdateUserPhone :one
|
|||
UPDATE users
|
||||
SET phone = $1::text,
|
||||
updated_at = now()
|
||||
WHERE id = $2::bigint
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email
|
||||
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
`
|
||||
|
||||
type UpdateUserPhoneParams struct {
|
||||
|
|
@ -984,6 +1106,13 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
|
|
@ -995,7 +1124,7 @@ SET phone = $1::text,
|
|||
signup_email = $2::text,
|
||||
updated_at = now()
|
||||
WHERE id = $3::bigint
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
`
|
||||
|
||||
type UpdateUserPhoneAndSignupEmailParams struct {
|
||||
|
|
@ -1037,6 +1166,13 @@ func (q *Queries) UpdateUserPhoneAndSignupEmail(ctx context.Context, arg UpdateU
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
|
|
@ -1048,8 +1184,8 @@ SET first_name = $2,
|
|||
last_name = $3,
|
||||
about = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
`
|
||||
|
||||
type UpdateUserProfileParams struct {
|
||||
|
|
@ -1097,6 +1233,13 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
|
|
@ -1108,8 +1251,8 @@ SET profile_color_set = $1::boolean,
|
|||
profile_color = $2::int,
|
||||
profile_color_background_emoji_id = $3::bigint,
|
||||
updated_at = now()
|
||||
WHERE id = $4::bigint
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email
|
||||
WHERE id = $4::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
`
|
||||
|
||||
type UpdateUserProfileColorParams struct {
|
||||
|
|
@ -1157,6 +1300,13 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
|
|
@ -1166,8 +1316,8 @@ const updateUserUsername = `-- name: UpdateUserUsername :one
|
|||
UPDATE users
|
||||
SET username = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, signup_email
|
||||
`
|
||||
|
||||
type UpdateUserUsernameParams struct {
|
||||
|
|
@ -1208,6 +1358,13 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LinkedCommunityID,
|
||||
&i.SignupEmail,
|
||||
)
|
||||
return i, err
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ INSERT INTO user_update_events (
|
|||
folder_peers,
|
||||
story_payload,
|
||||
reaction_payload,
|
||||
emoji_status_payload,
|
||||
message_box_id,
|
||||
peer_type,
|
||||
peer_id,
|
||||
|
|
@ -51,43 +52,45 @@ INSERT INTO user_update_events (
|
|||
$13::jsonb,
|
||||
$14::jsonb,
|
||||
$15::jsonb,
|
||||
$16,
|
||||
$17::text,
|
||||
$18::bigint,
|
||||
$19::int,
|
||||
$16::jsonb,
|
||||
$17,
|
||||
$18::text,
|
||||
$19::bigint,
|
||||
$20::int,
|
||||
$21::int,
|
||||
$22::int,
|
||||
$23::boolean,
|
||||
$24::int
|
||||
$23::int,
|
||||
$24::boolean,
|
||||
$25::int
|
||||
)
|
||||
`
|
||||
|
||||
type AppendUserUpdateEventParams struct {
|
||||
UserID int64
|
||||
Pts int32
|
||||
PtsCount int32
|
||||
Date int32
|
||||
EventType string
|
||||
EventBool bool
|
||||
EventPhone string
|
||||
EventPeers []byte
|
||||
PeerSettings []byte
|
||||
MessageIds []byte
|
||||
DialogFilter []byte
|
||||
FilterOrder []byte
|
||||
FolderPeers []byte
|
||||
StoryPayload []byte
|
||||
ReactionPayload []byte
|
||||
MessageBoxID *int32
|
||||
PeerType *string
|
||||
PeerID *int64
|
||||
FilterID int32
|
||||
MaxID int32
|
||||
StillUnreadCount int32
|
||||
ChannelPts int32
|
||||
TagsEnabled bool
|
||||
FolderID int32
|
||||
UserID int64
|
||||
Pts int32
|
||||
PtsCount int32
|
||||
Date int32
|
||||
EventType string
|
||||
EventBool bool
|
||||
EventPhone string
|
||||
EventPeers []byte
|
||||
PeerSettings []byte
|
||||
MessageIds []byte
|
||||
DialogFilter []byte
|
||||
FilterOrder []byte
|
||||
FolderPeers []byte
|
||||
StoryPayload []byte
|
||||
ReactionPayload []byte
|
||||
EmojiStatusPayload []byte
|
||||
MessageBoxID *int32
|
||||
PeerType *string
|
||||
PeerID *int64
|
||||
FilterID int32
|
||||
MaxID int32
|
||||
StillUnreadCount int32
|
||||
ChannelPts int32
|
||||
TagsEnabled bool
|
||||
FolderID int32
|
||||
}
|
||||
|
||||
func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdateEventParams) error {
|
||||
|
|
@ -107,6 +110,7 @@ func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdat
|
|||
arg.FolderPeers,
|
||||
arg.StoryPayload,
|
||||
arg.ReactionPayload,
|
||||
arg.EmojiStatusPayload,
|
||||
arg.MessageBoxID,
|
||||
arg.PeerType,
|
||||
arg.PeerID,
|
||||
|
|
@ -137,6 +141,7 @@ SELECT
|
|||
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
|
||||
COALESCE(e.story_payload::text, '{}')::text AS story_payload_json,
|
||||
COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json,
|
||||
COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json,
|
||||
COALESCE(e.peer_type, '')::text AS event_peer_type,
|
||||
COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
|
||||
e.filter_id,
|
||||
|
|
@ -274,6 +279,7 @@ type BatchListDispatchEventsRow struct {
|
|||
FolderPeersJson string
|
||||
StoryPayloadJson string
|
||||
ReactionPayloadJson string
|
||||
EmojiStatusPayloadJson string
|
||||
EventPeerType string
|
||||
EventPeerID int64
|
||||
FilterID int32
|
||||
|
|
@ -409,6 +415,7 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp
|
|||
&i.FolderPeersJson,
|
||||
&i.StoryPayloadJson,
|
||||
&i.ReactionPayloadJson,
|
||||
&i.EmojiStatusPayloadJson,
|
||||
&i.EventPeerType,
|
||||
&i.EventPeerID,
|
||||
&i.FilterID,
|
||||
|
|
@ -788,6 +795,7 @@ SELECT
|
|||
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
|
||||
COALESCE(e.story_payload::text, '{}')::text AS story_payload_json,
|
||||
COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json,
|
||||
COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json,
|
||||
COALESCE(e.peer_type, '')::text AS event_peer_type,
|
||||
COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
|
||||
e.filter_id,
|
||||
|
|
@ -928,6 +936,7 @@ type ListUserUpdateEventsAfterRow struct {
|
|||
FolderPeersJson string
|
||||
StoryPayloadJson string
|
||||
ReactionPayloadJson string
|
||||
EmojiStatusPayloadJson string
|
||||
EventPeerType string
|
||||
EventPeerID int64
|
||||
FilterID int32
|
||||
|
|
@ -1061,6 +1070,7 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd
|
|||
&i.FolderPeersJson,
|
||||
&i.StoryPayloadJson,
|
||||
&i.ReactionPayloadJson,
|
||||
&i.EmojiStatusPayloadJson,
|
||||
&i.EventPeerType,
|
||||
&i.EventPeerID,
|
||||
&i.FilterID,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,16 @@ func NewStarGiftStore(db sqlcgen.DBTX) *StarGiftStore {
|
|||
|
||||
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
|
||||
|
|
@ -75,6 +85,16 @@ func (s *StarGiftStore) CatalogRevision(ctx context.Context, revisionID int64) (
|
|||
}
|
||||
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
|
||||
|
|
@ -95,14 +115,34 @@ WHERE r.id = $1`, revisionID))
|
|||
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)
|
||||
|
|
@ -148,8 +188,12 @@ func (s *StarGiftStore) CreateCatalogRevision(ctx context.Context, write domain.
|
|||
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)
|
||||
VALUES ($1,$2,$3,$4)`, giftID, revisionID, write.Enabled, write.SortOrder); err != nil {
|
||||
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 {
|
||||
|
|
@ -180,20 +224,38 @@ WHERE gift_id = $1`, giftID).Scan(&revision); err != nil {
|
|||
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
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`,
|
||||
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.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, updated_at=now()
|
||||
WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -208,6 +270,62 @@ WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder); err != n
|
|||
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()
|
||||
|
|
@ -267,6 +385,16 @@ WHERE c.gift_id=$1`, giftID).Scan(&raw)
|
|||
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,
|
||||
|
|
@ -280,8 +408,20 @@ 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,
|
||||
|
|
@ -291,6 +431,15 @@ WHERE c.gift_id=$1`, giftID)
|
|||
); 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
|
||||
|
|
@ -315,14 +464,14 @@ func (s *StarGiftStore) Create(ctx context.Context, gift domain.SavedStarGift) (
|
|||
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, message)
|
||||
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
|
||||
$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.Message).Scan(&id)
|
||||
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)
|
||||
}
|
||||
|
|
@ -347,7 +496,7 @@ func (s *StarGiftStore) ListByOwnerFiltered(ctx context.Context, filter domain.S
|
|||
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", "NOT p.converted"}
|
||||
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")
|
||||
|
|
@ -386,15 +535,35 @@ WHERE ci.saved_gift_id = p.id AND ci.collection_id = $%d
|
|||
}
|
||||
page := domain.SavedStarGiftPage{Count: total}
|
||||
|
||||
if cursor, ok := domain.DecodeStarGiftCursor(offset); ok {
|
||||
args = append(args, cursor)
|
||||
where += fmt.Sprintf(" AND p.id < $%d", len(args))
|
||||
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.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
|
||||
|
|
@ -402,7 +571,7 @@ SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.ca
|
|||
WHERE i.saved_gift_id=p.id), ARRAY[]::integer[])
|
||||
FROM peer_star_gifts p `+joins+`
|
||||
WHERE `+where+`
|
||||
ORDER BY p.id DESC
|
||||
`+orderBy+`
|
||||
LIMIT $`+fmt.Sprint(limitPlaceholder), args...)
|
||||
if err != nil {
|
||||
return domain.SavedStarGiftPage{}, fmt.Errorf("list star gifts: %w", err)
|
||||
|
|
@ -421,7 +590,12 @@ LIMIT $`+fmt.Sprint(limitPlaceholder), args...)
|
|||
}
|
||||
if len(gifts) > limit {
|
||||
gifts = gifts[:limit]
|
||||
page.NextOffset = domain.EncodeStarGiftCursor(gifts[len(gifts)-1].ID)
|
||||
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
|
||||
|
|
@ -434,47 +608,95 @@ func (s *StarGiftStore) ResolveSavedIDs(ctx context.Context, owner domain.Peer,
|
|||
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))
|
||||
seenValues := make(map[int64]struct{}, len(refs))
|
||||
column := "msg_id"
|
||||
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 {
|
||||
column = "saved_id"
|
||||
value = ref.SavedID
|
||||
}
|
||||
if _, duplicate := seenValues[value]; duplicate {
|
||||
key := fmt.Sprintf("id:%d", value)
|
||||
if _, duplicate := seenKeys[key]; duplicate {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
seenValues[value] = struct{}{}
|
||||
seenKeys[key] = struct{}{}
|
||||
keys = append(keys, resolveKey{value: value})
|
||||
values = append(values, value)
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `SELECT `+column+`::bigint, id FROM peer_star_gifts
|
||||
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND NOT converted AND `+column+`::bigint=ANY($3::bigint[])`, string(owner.Type), owner.ID, values)
|
||||
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 p.msg_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.msg_id::bigint=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()
|
||||
resolved := make(map[int64]int64, len(values))
|
||||
resolvedValues := make(map[int64]int64, len(values))
|
||||
resolvedSlugs := make(map[string]int64, len(slugs))
|
||||
for rows.Next() {
|
||||
var value, id int64
|
||||
if err := rows.Scan(&value, &id); err != nil {
|
||||
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)
|
||||
}
|
||||
resolved[value] = id
|
||||
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(values))
|
||||
for _, value := range values {
|
||||
id := resolved[value]
|
||||
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
|
||||
|
|
@ -487,7 +709,9 @@ func (s *StarGiftStore) GetByRef(ctx context.Context, ref domain.SavedStarGiftRe
|
|||
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.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
|
||||
|
|
@ -510,7 +734,7 @@ func (s *StarGiftStore) CountByOwner(ctx context.Context, owner domain.Peer) (in
|
|||
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 NOT converted AND NOT unsaved`, string(owner.Type), owner.ID).Scan(&n); err != nil {
|
||||
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
|
||||
|
|
@ -524,7 +748,7 @@ func (s *StarGiftStore) SetUnsaved(ctx context.Context, ref domain.SavedStarGift
|
|||
args = append(args, unsaved)
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE peer_star_gifts SET unsaved = $4
|
||||
WHERE `+where+` AND NOT converted`, args...)
|
||||
WHERE `+where+` AND lifecycle_status='active'`, args...)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("set star gift unsaved: %w", err)
|
||||
}
|
||||
|
|
@ -543,7 +767,9 @@ func (s *StarGiftStore) MarkConverted(ctx context.Context, ref domain.SavedStarG
|
|||
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.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
|
||||
|
|
@ -564,13 +790,14 @@ WHERE `+where+` FOR UPDATE`, args...)
|
|||
if g.UniqueGiftID != 0 {
|
||||
return domain.ErrStarGiftAlreadyUpgraded
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET converted = true, unsaved = true, pinned_order = 0 WHERE id = $1`, g.ID); err != nil {
|
||||
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
|
||||
|
|
@ -587,7 +814,9 @@ 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.Message, &g.UniqueGiftID,
|
||||
&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
|
||||
}
|
||||
|
|
@ -597,6 +826,10 @@ func scanSavedStarGift(row rowScanner) (domain.SavedStarGift, error) {
|
|||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,34 @@ import (
|
|||
"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)
|
||||
|
|
@ -38,13 +66,15 @@ SELECT COALESCE(MAX(revision), 0) + 1 FROM star_gift_collectible_revisions WHERE
|
|||
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)
|
||||
VALUES ($1,$2,$3,$4,$5,'draft',$6,$7)
|
||||
RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, write.SlugPrefix, write.Actor, write.CommandID).Scan(&revisionID); err != nil {
|
||||
(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) error {
|
||||
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)
|
||||
|
|
@ -53,35 +83,53 @@ RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, wr
|
|||
return fmt.Errorf("put collectible %s blob: %w", attribute.Kind, err)
|
||||
}
|
||||
animation := attribute.Animation
|
||||
query := fmt.Sprintf(`
|
||||
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_permille, sort_order)
|
||||
VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, 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,
|
||||
attribute.RarityPermille, attribute.SortOrder); err != nil {
|
||||
return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err)
|
||||
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); err != nil {
|
||||
if err := insertAnimated("star_gift_collectible_models", write.Models, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := insertAnimated("star_gift_collectible_patterns", write.Patterns); err != nil {
|
||||
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_permille, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, revisionID, strings.TrimSpace(attribute.Name), attribute.BackdropID,
|
||||
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,
|
||||
attribute.RarityPermille, attribute.SortOrder); err != nil {
|
||||
string(attribute.RarityKind), nullablePermille(attribute), attribute.SortOrder); err != nil {
|
||||
return fmt.Errorf("insert collectible backdrop: %w", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -152,10 +200,11 @@ func collectibleRevisionByID(ctx context.Context, db sqlcgen.DBTX, revisionID in
|
|||
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
|
||||
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)
|
||||
}
|
||||
|
|
@ -184,13 +233,20 @@ func listAnimatedCollectibleAttributes(ctx context.Context, db sqlcgen.DBTX, rev
|
|||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
rows, err := db.Query(ctx, fmt.Sprintf(`
|
||||
SELECT a.id, a.collectible_revision_id, a.name, a.rarity_permille, a.sort_order,
|
||||
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,
|
||||
a.animation_json::text, 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 a JOIN documents d ON d.id=a.document_id
|
||||
WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisionID)
|
||||
WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`,
|
||||
func() string {
|
||||
if kind == domain.StarGiftCollectibleModel {
|
||||
return "a.crafted"
|
||||
}
|
||||
return "false"
|
||||
}(), table), revisionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list collectible %s attributes: %w", kind, err)
|
||||
}
|
||||
|
|
@ -199,7 +255,8 @@ WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisio
|
|||
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.RarityPermille, &attribute.SortOrder,
|
||||
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,
|
||||
|
|
@ -221,7 +278,7 @@ WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisio
|
|||
func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64) ([]domain.StarGiftCollectibleAttribute, error) {
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT id, collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
|
||||
text_color, rarity_permille, sort_order
|
||||
text_color, rarity_kind, COALESCE(rarity_permille,0), sort_order
|
||||
FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, revisionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list collectible backdrops: %w", err)
|
||||
|
|
@ -232,7 +289,7 @@ FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY s
|
|||
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.RarityPermille, &attribute.SortOrder); err != nil {
|
||||
&attribute.RarityKind, &attribute.RarityPermille, &attribute.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, attribute)
|
||||
|
|
@ -292,6 +349,37 @@ func (s *StarGiftStore) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64)
|
|||
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)
|
||||
|
|
@ -307,14 +395,24 @@ func (s *StarGiftStore) uniqueByPredicate(ctx context.Context, predicate string,
|
|||
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,
|
||||
u.owner_peer_type, u.owner_peer_id, u.keep_original_details, u.created_at,
|
||||
r.issued, r.supply_total, sg.from_user_id, sg.owner_peer_type, sg.owner_peer_id,
|
||||
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_permille, md.id, md.access_hash, md.file_reference, md.date,
|
||||
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_permille, pd.id, pd.access_hash, pd.file_reference, pd.date,
|
||||
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_permille
|
||||
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
|
||||
|
|
@ -323,12 +421,14 @@ 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 string
|
||||
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
|
||||
|
|
@ -337,23 +437,40 @@ func scanUniqueStarGift(row rowScanner) (domain.UniqueStarGift, error) {
|
|||
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.AvailabilityIssued, &unique.AvailabilityTotal,
|
||||
&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.RarityPermille,
|
||||
&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.RarityPermille,
|
||||
&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.RarityPermille); err != nil {
|
||||
&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
|
||||
|
|
@ -603,7 +720,7 @@ func validatePostgresCollectionGiftIDs(ctx context.Context, db sqlcgen.DBTX, own
|
|||
}
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT id FROM peer_star_gifts
|
||||
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND NOT converted AND id=ANY($3::bigint[])
|
||||
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
|
||||
|
|
|
|||
|
|
@ -34,26 +34,35 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
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", RarityPermille: 1000,
|
||||
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,
|
||||
}},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+2, "pattern.tgs"),
|
||||
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"),
|
||||
}},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectibleBackdrop, Name: "Midnight", BackdropID: 1,
|
||||
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
|
||||
RarityPermille: 1000,
|
||||
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 999,
|
||||
}},
|
||||
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) != 1 || len(poolRevision.Patterns) != 1 || len(poolRevision.Backdrops) != 1 {
|
||||
if !poolRevision.Published || poolRevision.Issued != 0 || len(poolRevision.Models) != 2 || len(poolRevision.Patterns) != 1 || len(poolRevision.Backdrops) != 1 ||
|
||||
!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)
|
||||
}
|
||||
availability, err := gifts.CollectibleAvailability(ctx, []int64{entry.Gift.ID, entry.Gift.ID + 1})
|
||||
|
|
@ -74,21 +83,19 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
t.Fatalf("issued after rejected manual update = %d err %v, want 0", guardedIssued, err)
|
||||
}
|
||||
|
||||
savedID, err := gifts.Create(ctx, domain.SavedStarGift{
|
||||
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,
|
||||
MsgID: 700001, Date: 1700001000, ConvertStars: 25, Message: "original",
|
||||
Date: 1700001000, ConvertStars: 25, Message: "original",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create saved gift: %v", err)
|
||||
}
|
||||
savedID := saved.ID
|
||||
stars := NewStarsStore(pool)
|
||||
if _, _, err := stars.EnsureGrant(ctx, owner.ID, 1000, 1700001001); err != nil {
|
||||
t.Fatalf("grant upgrade stars: %v", err)
|
||||
}
|
||||
messages := NewMessageStore(pool)
|
||||
upgrades := NewStarGiftUpgradeStore(pool, messages)
|
||||
req := domain.StarGiftUpgradeRequest{
|
||||
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700001},
|
||||
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: saved.MsgID},
|
||||
KeepOriginalDetails: true, ChargeStars: 100, FormID: 991,
|
||||
CommandKey: "paid-" + suffix, Date: 1700001002,
|
||||
}
|
||||
|
|
@ -108,6 +115,31 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
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 != int64(saved.MsgID) {
|
||||
t.Fatalf("unique action saved_id = %d, want stable source msg id %d", uniqueAction.SavedID, saved.MsgID)
|
||||
}
|
||||
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
|
||||
|
|
@ -128,12 +160,19 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
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 {
|
||||
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
|
||||
|
|
@ -152,17 +191,15 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
t.Fatalf("balance after retries = %+v err %v", bal, err)
|
||||
}
|
||||
|
||||
prepaidSavedID, err := gifts.Create(ctx, domain.SavedStarGift{
|
||||
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.
|
||||
MsgID: 700002, Date: 1700001004, ConvertStars: 25, PrepaidUpgradeStars: 50,
|
||||
Date: 1700001004, ConvertStars: 25, PrepaidUpgradeStars: 50,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create prepaid saved gift: %v", err)
|
||||
}
|
||||
prepaidSavedID := prepaidSaved.ID
|
||||
prepaid, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
|
||||
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700002},
|
||||
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: prepaidSaved.MsgID},
|
||||
RequirePrepaid: true, CommandKey: "prepaid-" + suffix, Date: 1700001005,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -174,26 +211,24 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
t.Fatalf("prepaid upgrade = %+v", prepaid)
|
||||
}
|
||||
|
||||
insufficientSavedID, err := gifts.Create(ctx, domain.SavedStarGift{
|
||||
insufficientSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
|
||||
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
|
||||
MsgID: 700003, Date: 1700001006, ConvertStars: 25,
|
||||
Date: 1700001006, ConvertStars: 25,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create insufficient saved gift: %v", err)
|
||||
}
|
||||
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: 700003},
|
||||
ChargeStars: 100, CommandKey: "insufficient-" + suffix, Date: 1700001008,
|
||||
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)
|
||||
}
|
||||
insufficientSaved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003})
|
||||
if err != nil || !found || insufficientSaved.ID != insufficientSavedID || insufficientSaved.UniqueGiftID != 0 {
|
||||
t.Fatalf("saved gift after rejected upgrade = %+v found %v err %v", insufficientSaved, found, 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)
|
||||
|
|
@ -220,12 +255,10 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
|
||||
concurrentOwner := createTestUser(t, ctx, users, "+1778"+suffix+"43", "ConcurrentOwner", "")
|
||||
concurrentPeer := domain.Peer{Type: domain.PeerTypeUser, ID: concurrentOwner.ID}
|
||||
if _, err := gifts.Create(ctx, domain.SavedStarGift{
|
||||
concurrentSaved := createCollectibleSavedGift(t, ctx, messages, gifts, entry.Gift, domain.SavedStarGift{
|
||||
Owner: concurrentPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
|
||||
MsgID: 700004, Date: 1700001010, ConvertStars: 25,
|
||||
}); err != nil {
|
||||
t.Fatalf("create concurrent upgrade target: %v", err)
|
||||
}
|
||||
Date: 1700001010, ConvertStars: 25,
|
||||
})
|
||||
if _, _, err := stars.EnsureGrant(ctx, concurrentOwner.ID, 150, 1700001011); err != nil {
|
||||
t.Fatalf("grant concurrent balance: %v", err)
|
||||
}
|
||||
|
|
@ -238,7 +271,7 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
go func() {
|
||||
<-start
|
||||
_, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
|
||||
UserID: concurrentOwner.ID, Ref: domain.SavedStarGiftRef{Owner: concurrentPeer, MsgID: 700004},
|
||||
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}
|
||||
|
|
@ -302,19 +335,19 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
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", RarityPermille: 1000,
|
||||
Kind: domain.StarGiftCollectibleModel, Name: "Nova", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+101, "nova-model.tgs"),
|
||||
Blob: collectibleTestBlobPtr(baseDocumentID+101, "nova-model"), Animation: collectibleTestAnimationPtr("nova-model.tgs"),
|
||||
}},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectiblePattern, Name: "Ray", RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+102, "nova-pattern.tgs"),
|
||||
Kind: domain.StarGiftCollectiblePattern, Name: "Ray", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+102, "nova-pattern.tgs"),
|
||||
Blob: collectibleTestBlobPtr(baseDocumentID+102, "nova-pattern"), Animation: collectibleTestAnimationPtr("nova-pattern.tgs"),
|
||||
}},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectibleBackdrop, Name: "Void", BackdropID: 2,
|
||||
CenterColor: 0x101010, EdgeColor: 0x202020, PatternColor: 0x303030, TextColor: 0xffffff,
|
||||
RarityPermille: 1000,
|
||||
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
}},
|
||||
Actor: "integration", CommandID: "soldout-pool-" + suffix,
|
||||
})
|
||||
|
|
@ -323,27 +356,26 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
}
|
||||
soldOutOwner := createTestUser(t, ctx, users, "+1778"+suffix+"44", "SoldOutOwner", "")
|
||||
soldOutPeer := domain.Peer{Type: domain.PeerTypeUser, ID: soldOutOwner.ID}
|
||||
for index, msgID := range []int{700010, 700011} {
|
||||
if _, err := gifts.Create(ctx, domain.SavedStarGift{
|
||||
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,
|
||||
MsgID: msgID, Date: 1700001020 + index, ConvertStars: 10,
|
||||
}); err != nil {
|
||||
t.Fatalf("create sold-out target %d: %v", msgID, err)
|
||||
}
|
||||
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: 700010},
|
||||
ChargeStars: 10, CommandKey: "soldout-first-" + suffix, Date: 1700001023,
|
||||
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: 700011},
|
||||
ChargeStars: 10, CommandKey: "soldout-second-" + suffix, Date: 1700001024,
|
||||
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)
|
||||
}
|
||||
|
|
@ -357,7 +389,7 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("create ordinary collection: %v", err)
|
||||
}
|
||||
converted, err := gifts.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003})
|
||||
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)
|
||||
}
|
||||
|
|
@ -386,6 +418,106 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+1, "no-craft-model.tgs"),
|
||||
Blob: collectibleTestBlobPtr(baseDocumentID+1, "no-craft-model"), Animation: collectibleTestAnimationPtr("no-craft-model.tgs"),
|
||||
}},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "no-craft-pattern.tgs"),
|
||||
Blob: collectibleTestBlobPtr(baseDocumentID+2, "no-craft-pattern"), Animation: collectibleTestAnimationPtr("no-craft-pattern.tgs"),
|
||||
}},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 1,
|
||||
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
|
||||
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
}},
|
||||
Actor: "integration", CommandID: "no-craft-pool-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("publish no-craft pool: %v", err)
|
||||
}
|
||||
if len(revision.Models) != 1 || revision.Models[0].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 collectibleTestAnimation(name string) domain.StarGiftAnimation {
|
||||
return domain.StarGiftAnimation{
|
||||
SourceName: name, SourceFormat: domain.StarGiftAnimationTGS,
|
||||
|
|
@ -416,6 +548,13 @@ func collectibleTestDocumentPtr(id int64, name string) *domain.Document {
|
|||
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,
|
||||
|
|
@ -427,3 +566,53 @@ 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}
|
||||
}
|
||||
|
|
|
|||
1094
internal/store/postgres/star_gift_craft_auction.go
Normal file
1094
internal/store/postgres/star_gift_craft_auction.go
Normal file
File diff suppressed because it is too large
Load diff
227
internal/store/postgres/star_gift_craft_projection.go
Normal file
227
internal/store/postgres/star_gift_craft_projection.go
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
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 = mediaJSON
|
||||
}
|
||||
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
|
||||
}
|
||||
282
internal/store/postgres/star_gift_entitlements.go
Normal file
282
internal/store/postgres/star_gift_entitlements.go
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
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
|
||||
}, after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
||||
if req.Owner.Type != domain.PeerTypeChannel {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -57,13 +58,16 @@ func TestStarGiftStorePostgres(t *testing.T) {
|
|||
})
|
||||
|
||||
// 创建三份礼物(msg_id 递增)。
|
||||
savedIDs := make([]int64, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := st.Create(ctx, domain.SavedStarGift{
|
||||
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,
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create gift #%d: %v", i, err)
|
||||
}
|
||||
savedIDs[i] = savedID
|
||||
}
|
||||
|
||||
// active revision 更新后,已收到的礼物必须继续固定到购买瞬间的 immutable revision。
|
||||
|
|
@ -113,6 +117,35 @@ func TestStarGiftStorePostgres(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
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)
|
||||
|
|
|
|||
1693
internal/store/postgres/star_gift_lifecycle.go
Normal file
1693
internal/store/postgres/star_gift_lifecycle.go
Normal file
File diff suppressed because it is too large
Load diff
865
internal/store/postgres/star_gift_lifecycle_integration_test.go
Normal file
865
internal/store/postgres/star_gift_lifecycle_integration_test.go
Normal file
|
|
@ -0,0 +1,865 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
now := int(time.Now().Unix())
|
||||
users := NewUserStore(pool)
|
||||
buyer := createTestUser(t, ctx, users, "+1881"+suffix+"01", "GiftBuyer", "")
|
||||
owner := createTestUser(t, ctx, users, "+1881"+suffix+"02", "GiftOwner", "")
|
||||
offerBuyer := createTestUser(t, ctx, users, "+1881"+suffix+"03", "OfferBuyer", "")
|
||||
resaleBuyer := createTestUser(t, ctx, users, "+1881"+suffix+"04", "ResaleBuyer", "")
|
||||
loser := createTestUser(t, ctx, users, "+1881"+suffix+"05", "AuctionLoser", "")
|
||||
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
||||
|
||||
stars := NewStarsStore(pool)
|
||||
for _, user := range []domain.User{buyer, owner, offerBuyer, resaleBuyer, loser} {
|
||||
if _, _, err := stars.EnsureGrant(ctx, user.ID, 10000, now); err != nil {
|
||||
t.Fatalf("grant stars to %d: %v", user.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
gifts := NewStarGiftStore(pool)
|
||||
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
|
||||
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Title: "Lifecycle " + suffix, Stars: 50, ConvertStars: 20, Enabled: true,
|
||||
Document: collectibleTestDocument(baseDocumentID, "lifecycle.tgs"),
|
||||
Blob: collectibleTestBlob(baseDocumentID, "lifecycle"), Animation: collectibleTestAnimation("lifecycle.tgs"),
|
||||
Actor: "integration", CommandID: "lifecycle-catalog-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create lifecycle catalog: %v", err)
|
||||
}
|
||||
if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
|
||||
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 20, SlugPrefix: "life-" + suffix,
|
||||
Models: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Base", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+1, "model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "model"), Animation: collectibleTestAnimationPtr("model.tgs")},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Crafted", RarityKind: domain.StarGiftRarityLegendary, Crafted: true,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+2, "crafted.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "crafted"), Animation: collectibleTestAnimationPtr("crafted.tgs")},
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")}},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleBackdrop, Name: "Night", BackdropID: 77,
|
||||
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
|
||||
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000}},
|
||||
Actor: "integration", CommandID: "lifecycle-pool-" + suffix,
|
||||
}); err != nil {
|
||||
t.Fatalf("publish lifecycle 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,
|
||||
}))
|
||||
|
||||
purchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: buyer.ID, To: ownerPeer,
|
||||
GiftID: entry.Gift.ID, CommandKey: "purchase-" + suffix, Date: now, Message: "hello"})
|
||||
purchased, err := lifecycle.PurchaseStarGift(ctx, purchaseReq)
|
||||
if err != nil {
|
||||
t.Fatalf("purchase gift: %v", err)
|
||||
}
|
||||
if purchased.Saved.ID <= 0 || purchased.Saved.MsgID <= 0 || purchased.Saved.PrepaidUpgradeHash == "" || purchased.Balance.Balance != 9950 {
|
||||
t.Fatalf("purchase result = %+v", purchased)
|
||||
}
|
||||
ordinaryAction := purchased.Send.RecipientMessage.Media.ServiceAction.StarGift
|
||||
if ordinaryAction == nil || !ordinaryAction.CanUpgrade || ordinaryAction.PrepaidUpgrade ||
|
||||
ordinaryAction.UpgradePriceStars != 100 || ordinaryAction.UpgradeStars != 0 {
|
||||
t.Fatalf("ordinary purchase action mixed paid price with prepaid amount: %+v", ordinaryAction)
|
||||
}
|
||||
replayedPurchase, err := lifecycle.PurchaseStarGift(ctx, purchaseReq)
|
||||
if err != nil || !replayedPurchase.Duplicate || replayedPurchase.Saved.ID != purchased.Saved.ID || replayedPurchase.Balance.Balance != 9950 ||
|
||||
replayedPurchase.Send.SenderMessage.ID != purchased.Send.SenderMessage.ID ||
|
||||
replayedPurchase.Send.RecipientMessage.ID != purchased.Send.RecipientMessage.ID {
|
||||
t.Fatalf("purchase replay = %+v err %v", replayedPurchase, err)
|
||||
}
|
||||
|
||||
target, price, err := lifecycle.PrepaidUpgradeTarget(ctx, ownerPeer, purchased.Saved.PrepaidUpgradeHash)
|
||||
if err != nil || target.ID != purchased.Saved.ID || price != 100 {
|
||||
t.Fatalf("prepaid target = %+v price %d err %v", target, price, err)
|
||||
}
|
||||
prepaid, err := lifecycle.PrepayStarGiftUpgrade(ctx, domain.StarGiftPrepaidUpgradeRequest{
|
||||
PayerUserID: buyer.ID, Owner: ownerPeer, Hash: purchased.Saved.PrepaidUpgradeHash,
|
||||
ChargeStars: 100, FormID: 11002, CommandKey: "prepay-" + suffix, Date: now + 1,
|
||||
})
|
||||
if err != nil || prepaid.Saved.PrepaidUpgradeStars != 100 || prepaid.Saved.PrepaidUpgradeHash != "" || prepaid.Balance.Balance != 9850 {
|
||||
t.Fatalf("prepay upgrade = %+v err %v", prepaid, err)
|
||||
}
|
||||
upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
|
||||
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID},
|
||||
RequirePrepaid: true, KeepOriginalDetails: true, CommandKey: "upgrade-" + suffix, Date: now + 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upgrade prepaid gift: %v", err)
|
||||
}
|
||||
if upgraded.Saved.TransferStars != 25 || upgraded.Saved.DropOriginalDetailsStars != 25 ||
|
||||
upgraded.Unique.CraftChancePermille != 500 || !upgraded.Unique.KeepOriginalDetails {
|
||||
t.Fatalf("issued lifecycle snapshot = saved %+v unique %+v", upgraded.Saved, upgraded.Unique)
|
||||
}
|
||||
upgradeAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
|
||||
ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID)
|
||||
if upgradeAction == nil || upgradeAction.SavedID != int64(purchased.Saved.MsgID) ||
|
||||
ownerSourceEdit.Message.Media == nil || ownerSourceEdit.Message.Media.ServiceAction == nil ||
|
||||
ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil ||
|
||||
ownerSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Saved.UpgradeMsgID ||
|
||||
ownerSourceEdit.Message.Media.ServiceAction.StarGift.CanUpgrade {
|
||||
t.Fatalf("upgrade message linkage = action %+v source edit %+v", upgradeAction, ownerSourceEdit)
|
||||
}
|
||||
dropped, err := lifecycle.DropStarGiftOriginalDetails(ctx, domain.StarGiftDropOriginalDetailsRequest{
|
||||
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID},
|
||||
ChargeStars: 25, FormID: 11003, CommandKey: "drop-" + suffix, Date: now + 3,
|
||||
})
|
||||
if err != nil || dropped.Unique.KeepOriginalDetails || dropped.Saved.DropOriginalDetailsStars != 0 || dropped.Balance.Balance != 9975 {
|
||||
t.Fatalf("drop original details = %+v err %v", dropped, err)
|
||||
}
|
||||
|
||||
// Expiry is driven by the background sweep, refunds exactly once and emits a
|
||||
// durable declined/expired service message even when no user opens the offer.
|
||||
expiring, err := lifecycle.SendStarGiftOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: offerBuyer.ID,
|
||||
Owner: ownerPeer, Slug: upgraded.Unique.Slug, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 300},
|
||||
Duration: 120, RandomID: 22001, Date: now + 10,
|
||||
})
|
||||
if err != nil || expiring.Balance.Balance != 9700 {
|
||||
t.Fatalf("send expiring offer = %+v err %v", expiring, err)
|
||||
}
|
||||
if err := lifecycle.SweepStarGiftLifecycle(ctx, now+131, 1000); err != nil {
|
||||
t.Fatalf("sweep expired offer: %v", err)
|
||||
}
|
||||
var expiredStatus string
|
||||
var resolutionNotified bool
|
||||
if err := pool.QueryRow(ctx, `SELECT status,resolution_notified FROM star_gift_offers WHERE id=$1`, expiring.Offer.ID).
|
||||
Scan(&expiredStatus, &resolutionNotified); err != nil || expiredStatus != "expired" || !resolutionNotified {
|
||||
t.Fatalf("expired offer state = %q notified %v err %v", expiredStatus, resolutionNotified, err)
|
||||
}
|
||||
if balance, err := stars.GetBalance(ctx, offerBuyer.ID); err != nil || balance.Balance != 10000 {
|
||||
t.Fatalf("expired offer refund balance = %+v err %v", balance, err)
|
||||
}
|
||||
|
||||
// TON offers use the same durable offer state machine, but only mutate the
|
||||
// internal telesrv TON ledger. Idempotent replay must report that ledger's
|
||||
// balance instead of accidentally projecting the buyer's Stars balance.
|
||||
tonOfferReq := domain.StarGiftOfferRequest{BuyerUserID: offerBuyer.ID,
|
||||
Owner: ownerPeer, Slug: upgraded.Unique.Slug, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 300},
|
||||
Duration: 120, RandomID: 22003, Date: now + 132}
|
||||
tonOffer, err := lifecycle.SendStarGiftOffer(ctx, tonOfferReq)
|
||||
if err != nil || tonOffer.Balance.Balance != 999700 {
|
||||
t.Fatalf("send TON offer = %+v err %v", tonOffer, err)
|
||||
}
|
||||
tonOfferReplay, err := lifecycle.SendStarGiftOffer(ctx, tonOfferReq)
|
||||
if err != nil || !tonOfferReplay.Duplicate || tonOfferReplay.Balance.Balance != 999700 {
|
||||
t.Fatalf("replay TON offer = %+v err %v", tonOfferReplay, err)
|
||||
}
|
||||
if _, err := lifecycle.ResolveStarGiftOffer(ctx, domain.StarGiftResolveOfferRequest{
|
||||
OwnerUserID: owner.ID, OfferMsgID: tonOffer.Offer.OfferMsgID, Decline: true, Date: now + 133,
|
||||
}); err != nil {
|
||||
t.Fatalf("decline TON offer: %v", err)
|
||||
}
|
||||
if balance, err := lifecycle.TonBalance(ctx, offerBuyer.ID); err != nil || balance != 1_000_000 {
|
||||
t.Fatalf("declined TON offer refund balance = %d err %v", balance, err)
|
||||
}
|
||||
|
||||
acceptedOffer, err := lifecycle.SendStarGiftOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: offerBuyer.ID,
|
||||
Owner: ownerPeer, Slug: upgraded.Unique.Slug, Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 300},
|
||||
Duration: 120, RandomID: 22002, Date: now + 140,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send accepted offer: %v", err)
|
||||
}
|
||||
accepted, err := lifecycle.ResolveStarGiftOffer(ctx, domain.StarGiftResolveOfferRequest{
|
||||
OwnerUserID: owner.ID, OfferMsgID: acceptedOffer.Offer.OfferMsgID, Date: now + 141,
|
||||
})
|
||||
if err != nil || accepted.Offer.Status != "accepted" || accepted.Unique.Owner.ID != offerBuyer.ID || accepted.Saved.MsgID <= 0 {
|
||||
t.Fatalf("accept offer = %+v err %v", accepted, err)
|
||||
}
|
||||
if balance, err := stars.GetBalance(ctx, owner.ID); err != nil || balance.Balance != 10245 {
|
||||
t.Fatalf("offer seller balance = %+v err %v", balance, err)
|
||||
}
|
||||
var offerCommission int64
|
||||
if err := pool.QueryRow(ctx, `SELECT commission_amount FROM star_gift_sales WHERE command_key=$1`,
|
||||
fmt.Sprintf("offer:%d", acceptedOffer.Offer.ID)).Scan(&offerCommission); err != nil || offerCommission != 30 {
|
||||
t.Fatalf("accepted Stars offer commission = %d err %v", offerCommission, err)
|
||||
}
|
||||
|
||||
listed, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: offerBuyer.ID,
|
||||
Ref: domain.SavedStarGiftRef{Owner: domain.Peer{Type: domain.PeerTypeUser, ID: offerBuyer.ID}, MsgID: accepted.Saved.MsgID},
|
||||
Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, Date: now + 142,
|
||||
})
|
||||
if err != nil || listed.ResellAmount == nil || listed.ResellAmount.Currency != domain.StarGiftCurrencyTON {
|
||||
t.Fatalf("TON listing = %+v err %v", listed, err)
|
||||
}
|
||||
tonBefore, err := lifecycle.TonBalance(ctx, resaleBuyer.ID)
|
||||
if err != nil || tonBefore != 1_000_000 {
|
||||
t.Fatalf("resale buyer TON grant = %d err %v", tonBefore, err)
|
||||
}
|
||||
resold, err := lifecycle.PurchaseResaleStarGift(ctx, domain.StarGiftResalePurchaseRequest{
|
||||
BuyerUserID: resaleBuyer.ID, Slug: listed.Slug, To: domain.Peer{Type: domain.PeerTypeUser, ID: resaleBuyer.ID},
|
||||
Amount: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, FormID: 11004,
|
||||
CommandKey: "resale-" + suffix, Date: now + 143,
|
||||
})
|
||||
if err != nil || resold.Unique.Owner.ID != resaleBuyer.ID || resold.Balance.Balance != 999000 || resold.Saved.TransferStars != 25 {
|
||||
t.Fatalf("TON resale = %+v err %v", resold, err)
|
||||
}
|
||||
selected, valid := domain.CollectibleEmojiStatus(resold.Unique)
|
||||
if !valid {
|
||||
t.Fatalf("resold collectible cannot project emoji status: %+v", resold.Unique)
|
||||
}
|
||||
if _, err := users.UpdateEmojiStatus(ctx, resaleBuyer.ID, domain.UserEmojiStatus{
|
||||
DocumentID: selected.DocumentID,
|
||||
Collectible: selected,
|
||||
}); err != nil {
|
||||
t.Fatalf("wear resold collectible: %v", err)
|
||||
}
|
||||
updateEvents := NewUpdateEventStore(pool)
|
||||
statusPtsBeforeTransfer, err := updateEvents.MaxContiguousPts(ctx, resaleBuyer.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("emoji status pts before transfer: %v", err)
|
||||
}
|
||||
if sellerTON, err := lifecycle.TonBalance(ctx, offerBuyer.ID); err != nil || sellerTON != 1_000_900 {
|
||||
t.Fatalf("TON seller local balance = %d err %v", sellerTON, err)
|
||||
}
|
||||
var resaleCommission int64
|
||||
if err := pool.QueryRow(ctx, `SELECT commission_amount FROM star_gift_sales WHERE command_key=$1`, "resale-"+suffix).
|
||||
Scan(&resaleCommission); err != nil || resaleCommission != 100 {
|
||||
t.Fatalf("TON resale commission = %d err %v", resaleCommission, err)
|
||||
}
|
||||
tonPage, err := lifecycle.TonTransactions(ctx, resaleBuyer.ID, "", 20)
|
||||
if err != nil || tonPage.Balance != 999000 || len(tonPage.Transactions) < 2 {
|
||||
t.Fatalf("TON ledger page = %+v err %v", tonPage, err)
|
||||
}
|
||||
|
||||
transferred, err := lifecycle.TransferStarGift(ctx, domain.StarGiftTransferRequest{ActorUserID: resaleBuyer.ID,
|
||||
Ref: domain.SavedStarGiftRef{Owner: domain.Peer{Type: domain.PeerTypeUser, ID: resaleBuyer.ID}, MsgID: resold.Saved.MsgID},
|
||||
To: ownerPeer, ChargeStars: 25, FormID: 11005, CommandKey: "transfer-back-" + suffix, Date: now + 144,
|
||||
})
|
||||
if err != nil || transferred.Unique.Owner != ownerPeer || transferred.Saved.TransferStars != 25 || transferred.Balance.Balance != 9975 {
|
||||
t.Fatalf("paid transfer = %+v err %v", transferred, err)
|
||||
}
|
||||
clearedUser, found, err := users.ByID(ctx, resaleBuyer.ID)
|
||||
if err != nil || !found || !clearedUser.EmojiStatus().Empty() {
|
||||
t.Fatalf("transferred collectible status was not cleared: user=%+v found=%v err=%v", clearedUser, found, err)
|
||||
}
|
||||
statusEvents, err := updateEvents.ListAfter(ctx, resaleBuyer.ID, statusPtsBeforeTransfer, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("load collectible invalidation event: %v", err)
|
||||
}
|
||||
var clearEvent domain.UpdateEvent
|
||||
for _, event := range statusEvents {
|
||||
if event.Type == domain.UpdateEventUserEmojiStatus {
|
||||
clearEvent = event
|
||||
break
|
||||
}
|
||||
}
|
||||
if clearEvent.Pts == 0 || !clearEvent.EmojiStatus.Empty() || clearEvent.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: resaleBuyer.ID}) {
|
||||
t.Fatalf("collectible invalidation event = %+v", clearEvent)
|
||||
}
|
||||
var clearOutboxCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
|
||||
WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBuyer.ID, clearEvent.Pts).Scan(&clearOutboxCount); err != nil || clearOutboxCount != 1 {
|
||||
t.Fatalf("collectible invalidation outbox count=%d err=%v, want 1", clearOutboxCount, err)
|
||||
}
|
||||
|
||||
// A second prepaid collectible makes craft chance exactly 1000‰. Success
|
||||
// preserves the first aggregate as crafted and burns the other input. The
|
||||
// fresh payment intent must create another gift even though buyer, owner and
|
||||
// catalog gift are identical to the first purchase.
|
||||
secondPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: buyer.ID, To: ownerPeer,
|
||||
GiftID: entry.Gift.ID, IncludeUpgrade: true, CommandKey: "purchase-second-" + suffix, Date: now + 145})
|
||||
secondPurchase, err := lifecycle.PurchaseStarGift(ctx, secondPurchaseReq)
|
||||
if err != nil {
|
||||
t.Fatalf("purchase second prepaid gift: %v", err)
|
||||
}
|
||||
prepaidAction := secondPurchase.Send.RecipientMessage.Media.ServiceAction.StarGift
|
||||
if prepaidAction == nil || !prepaidAction.PrepaidUpgrade || prepaidAction.UpgradePriceStars != 100 || prepaidAction.UpgradeStars != 100 {
|
||||
t.Fatalf("prepaid purchase action lost price/entitlement split: %+v", prepaidAction)
|
||||
}
|
||||
secondUpgrade, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{UserID: owner.ID,
|
||||
Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: secondPurchase.Saved.MsgID}, RequirePrepaid: true,
|
||||
CommandKey: "upgrade-second-" + suffix, Date: now + 146,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upgrade second prepaid gift: %v", err)
|
||||
}
|
||||
listedForCraft, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: owner.ID,
|
||||
Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: transferred.Saved.MsgID},
|
||||
Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 125}, Date: now + 146,
|
||||
})
|
||||
if err != nil || listedForCraft.ResellAmount == nil || listedForCraft.ResellAmount.Amount != 125 {
|
||||
t.Fatalf("list craft input = %+v err %v", listedForCraft, err)
|
||||
}
|
||||
loserBalanceBeforeOffer, err := stars.GetBalance(ctx, loser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("craft offer buyer balance: %v", err)
|
||||
}
|
||||
pendingCraftOffer, err := lifecycle.SendStarGiftOffer(ctx, domain.StarGiftOfferRequest{BuyerUserID: loser.ID,
|
||||
Owner: ownerPeer, Slug: transferred.Unique.Slug,
|
||||
Price: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 125},
|
||||
Duration: 120, RandomID: 22003, Date: now + 146,
|
||||
})
|
||||
if err != nil || pendingCraftOffer.Offer.Status != "pending" {
|
||||
t.Fatalf("pending craft offer = %+v err %v", pendingCraftOffer, err)
|
||||
}
|
||||
resolvedCraftIDs, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
|
||||
{Owner: ownerPeer, MsgID: transferred.Saved.MsgID},
|
||||
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
|
||||
})
|
||||
if err != nil || len(resolvedCraftIDs) != 2 || resolvedCraftIDs[0] != transferred.Saved.ID || resolvedCraftIDs[1] != secondUpgrade.Saved.ID {
|
||||
t.Fatalf("resolve mixed craft refs = %v err %v", resolvedCraftIDs, err)
|
||||
}
|
||||
if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
|
||||
{Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID},
|
||||
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
|
||||
}); !errors.Is(err, domain.ErrStarGiftNotFound) {
|
||||
t.Fatalf("upgrade message id lookup err = %v, want ErrStarGiftNotFound", err)
|
||||
}
|
||||
if saved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{
|
||||
Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID,
|
||||
}); err != nil || found {
|
||||
t.Fatalf("upgrade message id resolved a gift: saved=%+v found=%v err=%v", saved, found, err)
|
||||
}
|
||||
if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
|
||||
{Owner: ownerPeer, MsgID: secondUpgrade.Saved.MsgID},
|
||||
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
|
||||
}); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("duplicate official identities err = %v", err)
|
||||
}
|
||||
crafted, err := lifecycle.CraftStarGift(ctx, domain.StarGiftCraftRequest{UserID: owner.ID,
|
||||
Refs: []domain.SavedStarGiftRef{
|
||||
{Owner: ownerPeer, MsgID: transferred.Saved.MsgID},
|
||||
// TDesktop sends collectibles without a manage id as the official
|
||||
// inputSavedStarGiftSlug alias.
|
||||
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
|
||||
}, CommandKey: "craft-" + suffix, Date: now + 147,
|
||||
})
|
||||
if err != nil || !crafted.Success || crafted.Chance != 1000 || crafted.Gift == nil || !crafted.Gift.Crafted || crafted.Send.RecipientMessage.ID <= 0 {
|
||||
t.Fatalf("craft result = %+v err %v", crafted, err)
|
||||
}
|
||||
craftedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, transferred.Unique.ID)
|
||||
craftedInputAction := starGiftUniqueActionFromEdit(craftedInputEdit)
|
||||
burnedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, secondUpgrade.Unique.ID)
|
||||
burnedInputAction := starGiftUniqueActionFromEdit(burnedInputEdit)
|
||||
if craftedInputAction == nil || !craftedInputAction.Gift.Crafted || craftedInputAction.Gift.Burned ||
|
||||
craftedInputAction.Gift.CraftChancePermille != 0 || !craftedInputAction.Saved || craftedInputAction.CanCraftAt != 0 {
|
||||
t.Fatalf("crafted input message projection = %+v", craftedInputAction)
|
||||
}
|
||||
if burnedInputAction == nil || !burnedInputAction.Gift.Burned || burnedInputAction.Gift.CraftChancePermille != 0 ||
|
||||
burnedInputAction.Saved || burnedInputAction.CanCraftAt != 0 {
|
||||
t.Fatalf("burned input message projection = %+v", burnedInputAction)
|
||||
}
|
||||
craftReq := domain.StarGiftCraftRequest{UserID: owner.ID,
|
||||
Refs: []domain.SavedStarGiftRef{
|
||||
{Owner: ownerPeer, MsgID: transferred.Saved.MsgID},
|
||||
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
|
||||
}, CommandKey: "craft-" + suffix, Date: now + 147,
|
||||
}
|
||||
craftedReplay, err := lifecycle.CraftStarGift(ctx, craftReq)
|
||||
if err != nil || !craftedReplay.Duplicate || !craftedReplay.Success || craftedReplay.Gift == nil ||
|
||||
craftedReplay.Send.RecipientMessage.ID != crafted.Send.RecipientMessage.ID ||
|
||||
craftedSourceEditForUserAndGift(craftedReplay, owner.ID, transferred.Unique.ID).Event.Pts != craftedInputEdit.Event.Pts ||
|
||||
craftedSourceEditForUserAndGift(craftedReplay, owner.ID, secondUpgrade.Unique.ID).Event.Pts != burnedInputEdit.Event.Pts {
|
||||
t.Fatalf("craft success replay = %+v err %v", craftedReplay, err)
|
||||
}
|
||||
var craftListings, resaleAvailability int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_listings WHERE unique_gift_id=ANY($1::bigint[])`,
|
||||
[]int64{transferred.Unique.ID, secondUpgrade.Unique.ID}).Scan(&craftListings); err != nil || craftListings != 0 {
|
||||
t.Fatalf("craft input listings = %d err %v", craftListings, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT availability_resale FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&resaleAvailability); err != nil || resaleAvailability != 0 {
|
||||
t.Fatalf("craft resale projection = %d err %v", resaleAvailability, err)
|
||||
}
|
||||
var craftOfferStatus string
|
||||
if err := pool.QueryRow(ctx, `SELECT status FROM star_gift_offers WHERE id=$1`, pendingCraftOffer.Offer.ID).Scan(&craftOfferStatus); err != nil || craftOfferStatus != "cancelled" {
|
||||
t.Fatalf("craft offer status = %q err %v", craftOfferStatus, err)
|
||||
}
|
||||
loserBalanceAfterCraft, err := stars.GetBalance(ctx, loser.ID)
|
||||
if err != nil || loserBalanceAfterCraft.Balance != loserBalanceBeforeOffer.Balance {
|
||||
t.Fatalf("craft offer refund balance = %+v err %v, want %d", loserBalanceAfterCraft, err, loserBalanceBeforeOffer.Balance)
|
||||
}
|
||||
var secondStatus string
|
||||
if err := pool.QueryRow(ctx, `SELECT lifecycle_status FROM peer_star_gifts WHERE id=$1`, secondUpgrade.Saved.ID).Scan(&secondStatus); err != nil || secondStatus != "burned" {
|
||||
t.Fatalf("second craft input status = %q err %v", secondStatus, err)
|
||||
}
|
||||
|
||||
// A failed draw is just as terminal as success: the input aggregate and both
|
||||
// users' message snapshots are burned in the outcome transaction. An exact
|
||||
// retry replays the receipt, while a fresh command cannot consume it again.
|
||||
thirdPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: buyer.ID, To: ownerPeer,
|
||||
GiftID: entry.Gift.ID, IncludeUpgrade: true, CommandKey: "purchase-third-" + suffix, Date: now + 148})
|
||||
thirdPurchase, err := lifecycle.PurchaseStarGift(ctx, thirdPurchaseReq)
|
||||
if err != nil {
|
||||
t.Fatalf("purchase third prepaid gift: %v", err)
|
||||
}
|
||||
thirdUpgrade, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{UserID: owner.ID,
|
||||
Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: thirdPurchase.Saved.MsgID}, RequirePrepaid: true,
|
||||
CommandKey: "upgrade-third-" + suffix, Date: now + 149,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upgrade third prepaid gift: %v", err)
|
||||
}
|
||||
failingLifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000,
|
||||
WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{StarsProceedsPermille: 900, TONProceedsPermille: 900}),
|
||||
WithStarGiftCraftDraw(func(upper int) (int, error) { return upper - 1, nil }))
|
||||
failureReq := domain.StarGiftCraftRequest{UserID: owner.ID,
|
||||
Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: thirdUpgrade.Saved.MsgID}},
|
||||
CommandKey: "craft-fail-" + suffix, Date: now + 150,
|
||||
}
|
||||
failedCraft, err := failingLifecycle.CraftStarGift(ctx, failureReq)
|
||||
if err != nil || failedCraft.Success || failedCraft.Chance != 500 || failedCraft.Gift != nil {
|
||||
t.Fatalf("craft failure result = %+v err %v", failedCraft, err)
|
||||
}
|
||||
failedInputEdit := craftedSourceEditForUserAndGift(failedCraft, owner.ID, thirdUpgrade.Unique.ID)
|
||||
failedInputAction := starGiftUniqueActionFromEdit(failedInputEdit)
|
||||
if failedInputAction == nil || !failedInputAction.Gift.Burned || failedInputAction.Gift.CraftChancePermille != 0 ||
|
||||
failedInputAction.Gift.OfferMinStars != 0 || failedInputAction.Saved || failedInputAction.CanCraftAt != 0 {
|
||||
t.Fatalf("failed craft message projection = %+v", failedInputAction)
|
||||
}
|
||||
var failedLifecycle string
|
||||
var failedUnsaved bool
|
||||
var failedTransferStars int64
|
||||
var failedCanExportAt, failedCanTransferAt, failedCanResellAt, failedCanCraftAt int
|
||||
var failedDropStars int64
|
||||
if err := pool.QueryRow(ctx, `SELECT lifecycle_status,unsaved,transfer_stars,can_export_at,can_transfer_at,
|
||||
can_resell_at,drop_original_details_stars,can_craft_at FROM peer_star_gifts WHERE id=$1`, thirdUpgrade.Saved.ID).
|
||||
Scan(&failedLifecycle, &failedUnsaved, &failedTransferStars, &failedCanExportAt, &failedCanTransferAt,
|
||||
&failedCanResellAt, &failedDropStars, &failedCanCraftAt); err != nil || failedLifecycle != "burned" || !failedUnsaved ||
|
||||
failedTransferStars != 0 || failedCanExportAt != 0 || failedCanTransferAt != 0 || failedCanResellAt != 0 ||
|
||||
failedDropStars != 0 || failedCanCraftAt != 0 {
|
||||
t.Fatalf("failed craft saved aggregate = status %q unsaved %v transfer %d export %d transfer_at %d resale %d drop %d craft %d err %v",
|
||||
failedLifecycle, failedUnsaved, failedTransferStars, failedCanExportAt, failedCanTransferAt,
|
||||
failedCanResellAt, failedDropStars, failedCanCraftAt, err)
|
||||
}
|
||||
var failedBurned bool
|
||||
var failedChance, failedOfferMin int
|
||||
if err := pool.QueryRow(ctx, `SELECT burned,craft_chance_permille,offer_min_stars FROM unique_star_gifts WHERE id=$1`, thirdUpgrade.Unique.ID).
|
||||
Scan(&failedBurned, &failedChance, &failedOfferMin); err != nil || !failedBurned || failedChance != 0 || failedOfferMin != 0 {
|
||||
t.Fatalf("failed craft unique aggregate = burned %v chance %d offer %d err %v", failedBurned, failedChance, failedOfferMin, err)
|
||||
}
|
||||
failedReplay, err := failingLifecycle.CraftStarGift(ctx, failureReq)
|
||||
if err != nil || !failedReplay.Duplicate || failedReplay.Success || failedReplay.Chance != failedCraft.Chance ||
|
||||
craftedSourceEditForUserAndGift(failedReplay, owner.ID, thirdUpgrade.Unique.ID).Event.Pts != failedInputEdit.Event.Pts {
|
||||
t.Fatalf("craft failure replay = %+v err %v", failedReplay, err)
|
||||
}
|
||||
invalidRetry := failureReq
|
||||
invalidRetry.CommandKey = "craft-fail-new-command-" + suffix
|
||||
if _, err := failingLifecycle.CraftStarGift(ctx, invalidRetry); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) {
|
||||
t.Fatalf("fresh command reused burned craft input: %v", err)
|
||||
}
|
||||
craftCandidates, err := lifecycle.ListCraftStarGifts(ctx, owner.ID, entry.Gift.ID, "", 20)
|
||||
if err != nil || craftCandidates.Count != 0 || len(craftCandidates.Gifts) != 0 {
|
||||
t.Fatalf("terminal craft inputs remained candidates: %+v err %v", craftCandidates, err)
|
||||
}
|
||||
|
||||
withdrawalReq := domain.StarGiftWithdrawalRequest{UserID: owner.ID,
|
||||
Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: transferred.Saved.MsgID}, Date: now + 151}
|
||||
recorded, err := lifecycle.RecordStarGiftWithdrawal(ctx, withdrawalReq, "local", "withdraw-"+suffix,
|
||||
"https://telesrv.invalid/gift-withdrawal/"+suffix, now+748)
|
||||
if err != nil || recorded.Status != "pending" {
|
||||
t.Fatalf("record local withdrawal = %+v err %v", recorded, err)
|
||||
}
|
||||
completed, err := lifecycle.CompleteStarGiftWithdrawal(ctx, recorded.ProviderRequestID, now+152)
|
||||
if err != nil || completed.Status != "completed" || completed.Gift.OwnerAddress == "" || completed.Gift.GiftAddress == "" {
|
||||
t.Fatalf("complete local withdrawal = %+v err %v", completed, err)
|
||||
}
|
||||
|
||||
// Auction winner reservation is consumed; the unreachable lower bid is
|
||||
// refunded atomically. Award delivery is durable and includes gift_num.
|
||||
auctionEntry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Title: "Auction " + suffix, Stars: 100, Enabled: true, Limited: true, Auction: true,
|
||||
AvailabilityTotal: 1, AvailabilityRemains: 1, GiftsPerRound: 1, AuctionStartDate: now - 10,
|
||||
AuctionSlug: "auction-" + suffix,
|
||||
Document: collectibleTestDocument(baseDocumentID+100, "auction.tgs"),
|
||||
Blob: collectibleTestBlob(baseDocumentID+100, "auction"), Animation: collectibleTestAnimation("auction.tgs"),
|
||||
Actor: "integration", CommandID: "auction-catalog-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create auction catalog: %v", err)
|
||||
}
|
||||
winnerState, _, err := lifecycle.BidStarGiftAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: resaleBuyer.ID,
|
||||
GiftID: auctionEntry.Gift.ID, Peer: ownerPeer, BidAmount: 200, FormID: 12001, Date: now, Message: "winner"})
|
||||
if err != nil || winnerState.UserState.BidAmount != 200 {
|
||||
t.Fatalf("winner bid state = %+v err %v", winnerState, err)
|
||||
}
|
||||
if _, _, err := lifecycle.BidStarGiftAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: loser.ID,
|
||||
GiftID: auctionEntry.Gift.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: loser.ID},
|
||||
BidAmount: 150, FormID: 12002, Date: now + 1}); err != nil {
|
||||
t.Fatalf("loser bid: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE star_gift_auctions SET next_round_at=$2 WHERE gift_id=$1`, auctionEntry.Gift.ID, now+2); err != nil {
|
||||
t.Fatalf("make auction round due: %v", err)
|
||||
}
|
||||
if err := lifecycle.SweepStarGiftLifecycle(ctx, now+2, 1000); err != nil {
|
||||
t.Fatalf("settle auction sweep: %v", err)
|
||||
}
|
||||
acquired, err := lifecycle.StarGiftAuctionAcquired(ctx, resaleBuyer.ID, auctionEntry.Gift.ID)
|
||||
if err != nil || len(acquired) != 1 || acquired[0].GiftNum != 1 || acquired[0].BidAmount != 200 {
|
||||
t.Fatalf("auction acquired = %+v err %v", acquired, err)
|
||||
}
|
||||
if loserBalance, err := stars.GetBalance(ctx, loser.ID); err != nil || loserBalance.Balance != 10000 {
|
||||
t.Fatalf("auction loser refund = %+v err %v", loserBalance, err)
|
||||
}
|
||||
var auctionSavedCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE gift_id=$1 AND gift_num=1 AND convert_stars=0`, auctionEntry.Gift.ID).
|
||||
Scan(&auctionSavedCount); err != nil || auctionSavedCount != 1 {
|
||||
t.Fatalf("auction saved award count = %d err %v", auctionSavedCount, err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
now := int(time.Now().Unix())
|
||||
users := NewUserStore(pool)
|
||||
actor := createTestUser(t, ctx, users, "+1882"+suffix+"01", "ChannelGiftActor", "")
|
||||
if _, _, err := NewStarsStore(pool).EnsureGrant(ctx, actor.ID, 10000, now); err != nil {
|
||||
t.Fatalf("grant actor stars: %v", err)
|
||||
}
|
||||
created, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: actor.ID, Title: "Gift Channel " + suffix, Megagroup: true, Date: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create gift channel: %v", err)
|
||||
}
|
||||
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
|
||||
createdTarget, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: actor.ID, Title: "Gift Target Channel " + suffix, Megagroup: true, Date: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create target gift channel: %v", err)
|
||||
}
|
||||
targetChannelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: createdTarget.Channel.ID}
|
||||
gifts := NewStarGiftStore(pool)
|
||||
baseDocumentID := (time.Now().UnixNano() & 0x7ffffffffffff000) + 500
|
||||
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Title: "Channel Gift " + suffix, Stars: 50, ConvertStars: 20, Enabled: true, Limited: true,
|
||||
AvailabilityTotal: 5, AvailabilityRemains: 5,
|
||||
Document: collectibleTestDocument(baseDocumentID, "channel-gift.tgs"), Blob: collectibleTestBlob(baseDocumentID, "channel-gift"),
|
||||
Animation: collectibleTestAnimation("channel-gift.tgs"), Actor: "integration", CommandID: "channel-gift-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel gift catalog: %v", err)
|
||||
}
|
||||
if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
|
||||
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 5, SlugPrefix: "channel-life-" + suffix,
|
||||
Models: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleModel, Name: "Channel Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+1, "channel-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "channel-model"), Animation: collectibleTestAnimationPtr("channel-model.tgs")}},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Channel Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "channel-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "channel-pattern"), Animation: collectibleTestAnimationPtr("channel-pattern.tgs")}},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleBackdrop, Name: "Channel Backdrop", BackdropID: 88,
|
||||
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
|
||||
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000}},
|
||||
Actor: "integration", CommandID: "channel-gift-pool-" + suffix,
|
||||
}); err != nil {
|
||||
t.Fatalf("publish channel gift 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,
|
||||
}))
|
||||
channelPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer,
|
||||
GiftID: entry.Gift.ID, CommandKey: "channel-purchase-" + suffix, Date: now + 1})
|
||||
purchased, err := lifecycle.PurchaseStarGift(ctx, channelPurchaseReq)
|
||||
if err != nil || purchased.Saved.SavedID <= 0 || purchased.Balance.Balance != 9950 {
|
||||
t.Fatalf("atomic channel purchase = %+v err %v", purchased, err)
|
||||
}
|
||||
var regularLogs int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
|
||||
WHERE channel_id=$1 AND event_type='send_message' AND message::text LIKE '%star_gift%'`, created.Channel.ID).Scan(®ularLogs); err != nil || regularLogs != 1 {
|
||||
t.Fatalf("channel purchase admin logs = %d err %v", regularLogs, err)
|
||||
}
|
||||
var channelPrice string
|
||||
var channelPrepaidAmount any
|
||||
if err := pool.QueryRow(ctx, `SELECT message #>> '{Action,StarGift,upgrade_price_stars}', message #> '{Action,StarGift,upgrade_stars}'
|
||||
FROM channel_admin_log_events WHERE channel_id=$1 AND event_type='send_message' ORDER BY id DESC LIMIT 1`, created.Channel.ID).
|
||||
Scan(&channelPrice, &channelPrepaidAmount); err != nil || channelPrice != "100" || channelPrepaidAmount != nil {
|
||||
t.Fatalf("channel ordinary action price=%q prepaid=%v err=%v", channelPrice, channelPrepaidAmount, err)
|
||||
}
|
||||
if replay, err := lifecycle.PurchaseStarGift(ctx, channelPurchaseReq); err != nil || !replay.Duplicate {
|
||||
t.Fatalf("channel purchase replay = %+v err %v", replay, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
|
||||
WHERE channel_id=$1 AND event_type='send_message' AND message::text LIKE '%star_gift%'`, created.Channel.ID).Scan(®ularLogs); err != nil || regularLogs != 1 {
|
||||
t.Fatalf("channel replay duplicated admin log count=%d err %v", regularLogs, err)
|
||||
}
|
||||
|
||||
converted, err := lifecycle.ConvertStarGift(ctx, domain.StarGiftConvertRequest{ActorUserID: actor.ID,
|
||||
Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: purchased.Saved.SavedID}, Date: now + 2})
|
||||
if err != nil || !converted.Saved.Converted || converted.OwnerBalance != 20 {
|
||||
t.Fatalf("atomic channel conversion = %+v err %v", converted, err)
|
||||
}
|
||||
var channelBalance, conversionRows, conversionTxns int64
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 20 {
|
||||
t.Fatalf("channel conversion balance = %d err %v", channelBalance, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_conversions WHERE saved_gift_id=$1`, purchased.Saved.ID).Scan(&conversionRows); err != nil || conversionRows != 1 {
|
||||
t.Fatalf("channel conversion command rows = %d err %v", conversionRows, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_stars_transactions WHERE channel_id=$1 AND gift_id=$2`, created.Channel.ID, entry.Gift.ID).Scan(&conversionTxns); err != nil || conversionTxns != 1 {
|
||||
t.Fatalf("channel conversion transactions = %d err %v", conversionTxns, err)
|
||||
}
|
||||
if balance, err := lifecycle.ChannelStarsBalance(ctx, created.Channel.ID); err != nil || balance != 20 {
|
||||
t.Fatalf("channel stars balance projection = %d err %v", balance, err)
|
||||
}
|
||||
starsPage, err := lifecycle.ChannelStarsTransactions(ctx, created.Channel.ID, "", 20)
|
||||
if err != nil || starsPage.Balance != 20 || len(starsPage.Transactions) != 1 ||
|
||||
starsPage.Transactions[0].Amount != 20 || starsPage.Transactions[0].Reason != domain.StarsReasonGift {
|
||||
t.Fatalf("channel stars transaction projection = %+v err %v", starsPage, err)
|
||||
}
|
||||
if _, err := lifecycle.ConvertStarGift(ctx, domain.StarGiftConvertRequest{ActorUserID: actor.ID,
|
||||
Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: purchased.Saved.SavedID}, Date: now + 3}); !errors.Is(err, domain.ErrStarGiftAlreadyConverted) {
|
||||
t.Fatalf("repeated channel conversion err = %v, want already converted", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 20 {
|
||||
t.Fatalf("channel balance after replay = %d err %v", channelBalance, err)
|
||||
}
|
||||
|
||||
// A third party may prepay the upgrade entitlement of a channel-owned gift.
|
||||
// The payer's personal Stars and the channel saved-gift entitlement commit
|
||||
// together; the payment is also visible in channel Recent Actions.
|
||||
channelPrepayTargetReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer,
|
||||
GiftID: entry.Gift.ID, CommandKey: "channel-prepay-target-" + suffix, Date: now + 4})
|
||||
channelPrepayTarget, err := lifecycle.PurchaseStarGift(ctx, channelPrepayTargetReq)
|
||||
if err != nil || channelPrepayTarget.Saved.PrepaidUpgradeHash == "" {
|
||||
t.Fatalf("channel prepay target purchase = %+v err %v", channelPrepayTarget, err)
|
||||
}
|
||||
prepayTarget, prepayPrice, err := lifecycle.PrepaidUpgradeTarget(ctx, channelPeer, channelPrepayTarget.Saved.PrepaidUpgradeHash)
|
||||
if err != nil || prepayTarget.ID != channelPrepayTarget.Saved.ID || prepayPrice != 100 {
|
||||
t.Fatalf("channel prepay target = %+v price=%d err=%v", prepayTarget, prepayPrice, err)
|
||||
}
|
||||
channelPrepayReq := domain.StarGiftPrepaidUpgradeRequest{
|
||||
PayerUserID: actor.ID, Owner: channelPeer, Hash: channelPrepayTarget.Saved.PrepaidUpgradeHash,
|
||||
ChargeStars: 100, FormID: 21006, CommandKey: "channel-prepay-" + suffix, Date: now + 4,
|
||||
}
|
||||
channelPrepay, err := lifecycle.PrepayStarGiftUpgrade(ctx, channelPrepayReq)
|
||||
if err != nil || channelPrepay.Saved.PrepaidUpgradeStars != 100 || channelPrepay.Saved.PrepaidUpgradeHash != "" ||
|
||||
channelPrepay.Send.RecipientMessage.OwnerUserID != actor.ID {
|
||||
t.Fatalf("channel prepaid entitlement = %+v err %v", channelPrepay, err)
|
||||
}
|
||||
var prepayLogs int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
|
||||
WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel.ID).Scan(&prepayLogs); err != nil || prepayLogs != 2 {
|
||||
t.Fatalf("channel prepaid upgrade admin logs = %d err %v", prepayLogs, err)
|
||||
}
|
||||
channelPrepayReplay, err := lifecycle.PrepayStarGiftUpgrade(ctx, channelPrepayReq)
|
||||
if err != nil || !channelPrepayReplay.Duplicate || channelPrepayReplay.Saved.ID != channelPrepay.Saved.ID {
|
||||
t.Fatalf("channel prepaid entitlement replay = %+v err %v", channelPrepayReplay, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
|
||||
WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel.ID).Scan(&prepayLogs); err != nil || prepayLogs != 2 {
|
||||
t.Fatalf("channel prepaid upgrade replay logs = %d err %v", prepayLogs, err)
|
||||
}
|
||||
|
||||
var ptsBeforeUpgrade int
|
||||
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&ptsBeforeUpgrade); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepaidPurchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID, To: channelPeer,
|
||||
GiftID: entry.Gift.ID, IncludeUpgrade: true, CommandKey: "channel-prepaid-purchase-" + suffix, Date: now + 4})
|
||||
prepaidPurchase, err := lifecycle.PurchaseStarGift(ctx, prepaidPurchaseReq)
|
||||
if err != nil || prepaidPurchase.Saved.PrepaidUpgradeStars != 100 || prepaidPurchase.Saved.SavedID <= 0 {
|
||||
t.Fatalf("channel prepaid gift purchase = %+v err %v", prepaidPurchase, err)
|
||||
}
|
||||
upgradeReq := domain.StarGiftUpgradeRequest{UserID: actor.ID,
|
||||
Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: prepaidPurchase.Saved.SavedID}, RequirePrepaid: true,
|
||||
KeepOriginalDetails: true, CommandKey: "channel-upgrade-" + suffix, Date: now + 5,
|
||||
}
|
||||
upgraded, err := upgrades.UpgradeStarGift(ctx, upgradeReq)
|
||||
if err != nil || upgraded.Saved.Owner != channelPeer || upgraded.Unique.Owner != channelPeer ||
|
||||
upgraded.Saved.SavedID != prepaidPurchase.Saved.SavedID || upgraded.Send.RecipientMessage.OwnerUserID != actor.ID {
|
||||
t.Fatalf("channel prepaid upgrade = %+v err %v", upgraded, err)
|
||||
}
|
||||
action := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
|
||||
if action == nil || action.FromUserID != domain.OfficialSystemUserID || action.Peer != channelPeer ||
|
||||
action.SavedID != prepaidPurchase.Saved.SavedID || !action.Upgrade || !action.PrepaidUpgrade || action.TransferStars != 25 {
|
||||
t.Fatalf("channel upgrade service action = %+v", action)
|
||||
}
|
||||
var ptsAfterUpgrade int
|
||||
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&ptsAfterUpgrade); err != nil || ptsAfterUpgrade != ptsBeforeUpgrade {
|
||||
t.Fatalf("channel pts after profile gift upgrade = %d want %d err %v", ptsAfterUpgrade, ptsBeforeUpgrade, err)
|
||||
}
|
||||
var upgradeLogs int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
|
||||
WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channel.ID).Scan(&upgradeLogs); err != nil || upgradeLogs != 1 {
|
||||
t.Fatalf("channel upgrade admin logs = %d err %v", upgradeLogs, err)
|
||||
}
|
||||
replayedUpgrade, err := upgrades.UpgradeStarGift(ctx, upgradeReq)
|
||||
if err != nil || !replayedUpgrade.Duplicate || replayedUpgrade.Unique.ID != upgraded.Unique.ID {
|
||||
t.Fatalf("channel upgrade replay = %+v err %v", replayedUpgrade, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
|
||||
WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channel.ID).Scan(&upgradeLogs); err != nil || upgradeLogs != 1 {
|
||||
t.Fatalf("channel upgrade replay admin logs = %d err %v", upgradeLogs, err)
|
||||
}
|
||||
dropped, err := lifecycle.DropStarGiftOriginalDetails(ctx, domain.StarGiftDropOriginalDetailsRequest{
|
||||
UserID: actor.ID, Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: upgraded.Saved.SavedID},
|
||||
ChargeStars: 25, FormID: 21007, CommandKey: "channel-drop-details-" + suffix, Date: now + 6,
|
||||
})
|
||||
if err != nil || dropped.Saved.Owner != channelPeer || dropped.Unique.KeepOriginalDetails || dropped.Saved.DropOriginalDetailsStars != 0 {
|
||||
t.Fatalf("channel drop original details = %+v err %v", dropped, err)
|
||||
}
|
||||
|
||||
listed, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: actor.ID,
|
||||
Ref: domain.SavedStarGiftRef{Owner: channelPeer, SavedID: upgraded.Saved.SavedID},
|
||||
Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, Date: now + 6,
|
||||
})
|
||||
if err != nil || listed.ResellAmount == nil || listed.Owner != channelPeer {
|
||||
t.Fatalf("list channel collectible = %+v err %v", listed, err)
|
||||
}
|
||||
if balance, err := lifecycle.TonBalance(ctx, actor.ID); err != nil || balance != 1_000_000 {
|
||||
t.Fatalf("channel resale buyer local TON grant = %d err %v", balance, err)
|
||||
}
|
||||
resaleReq := domain.StarGiftResalePurchaseRequest{BuyerUserID: actor.ID, Slug: listed.Slug, To: targetChannelPeer,
|
||||
Amount: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: 1000}, FormID: 21004,
|
||||
CommandKey: "channel-to-channel-resale-" + suffix, Date: now + 7,
|
||||
}
|
||||
resold, err := lifecycle.PurchaseResaleStarGift(ctx, resaleReq)
|
||||
if err != nil || resold.Unique.Owner != targetChannelPeer || resold.Saved.Owner != targetChannelPeer ||
|
||||
resold.Saved.SavedID != upgraded.Saved.ID || resold.Balance.Balance != 999000 {
|
||||
t.Fatalf("channel-to-channel local TON resale = %+v err %v", resold, err)
|
||||
}
|
||||
var channelTON, channelTONTxns, targetResaleLogs, commission int64
|
||||
if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM channel_ton_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelTON); err != nil || channelTON != 900 {
|
||||
t.Fatalf("channel local TON proceeds = %d err %v", channelTON, err)
|
||||
}
|
||||
if balance, err := lifecycle.ChannelTonBalance(ctx, created.Channel.ID); err != nil || balance != 900 {
|
||||
t.Fatalf("channel ton balance projection = %d err %v", balance, err)
|
||||
}
|
||||
tonPage, err := lifecycle.ChannelTonTransactions(ctx, created.Channel.ID, "", 20)
|
||||
if err != nil || tonPage.Balance != 900 || len(tonPage.Transactions) != 1 ||
|
||||
tonPage.Transactions[0].Amount != 900 || tonPage.Transactions[0].Reason != domain.StarsReasonGiftResale {
|
||||
t.Fatalf("channel ton transaction projection = %+v err %v", tonPage, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_ton_transactions WHERE channel_id=$1 AND gift_id=$2`, created.Channel.ID, listed.ID).Scan(&channelTONTxns); err != nil || channelTONTxns != 1 {
|
||||
t.Fatalf("channel local TON transactions = %d err %v", channelTONTxns, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`,
|
||||
createdTarget.Channel.ID).Scan(&targetResaleLogs); err != nil || targetResaleLogs != 1 {
|
||||
t.Fatalf("target channel resale admin logs = %d err %v", targetResaleLogs, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT commission_amount FROM star_gift_sales WHERE command_key=$1`, resaleReq.CommandKey).Scan(&commission); err != nil || commission != 100 {
|
||||
t.Fatalf("channel TON resale commission = %d err %v", commission, err)
|
||||
}
|
||||
resaleReplay, err := lifecycle.PurchaseResaleStarGift(ctx, resaleReq)
|
||||
if err != nil || !resaleReplay.Duplicate || resaleReplay.Unique.ID != resold.Unique.ID {
|
||||
t.Fatalf("channel resale replay = %+v err %v", resaleReplay, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM channel_ton_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelTON); err != nil || channelTON != 900 {
|
||||
t.Fatalf("channel TON proceeds after replay = %d err %v", channelTON, err)
|
||||
}
|
||||
|
||||
var remainsBefore int
|
||||
if err := pool.QueryRow(ctx, `SELECT availability_remains FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&remainsBefore); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
balanceBefore, _ := NewStarsStore(pool).GetBalance(ctx, actor.ID)
|
||||
invalidChannelReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{BuyerUserID: actor.ID,
|
||||
To: domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID + 999999}, GiftID: entry.Gift.ID,
|
||||
CommandKey: "invalid-channel-purchase-" + suffix, Date: now + 2})
|
||||
_, err = lifecycle.PurchaseStarGift(ctx, invalidChannelReq)
|
||||
if err == nil {
|
||||
t.Fatal("purchase to missing channel unexpectedly succeeded")
|
||||
}
|
||||
var remainsAfter int
|
||||
if err := pool.QueryRow(ctx, `SELECT availability_remains FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&remainsAfter); err != nil || remainsAfter != remainsBefore {
|
||||
t.Fatalf("inventory after rolled-back channel purchase = %d want %d err %v", remainsAfter, remainsBefore, err)
|
||||
}
|
||||
if balanceAfter, err := NewStarsStore(pool).GetBalance(ctx, actor.ID); err != nil || balanceAfter.Balance != balanceBefore.Balance {
|
||||
t.Fatalf("balance after rolled-back channel purchase = %+v want %+v err %v", balanceAfter, balanceBefore, err)
|
||||
}
|
||||
|
||||
auctionEntry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Title: "Channel Auction " + suffix, Stars: 100, Enabled: true, Limited: true, Auction: true,
|
||||
AvailabilityTotal: 1, AvailabilityRemains: 1, GiftsPerRound: 1, AuctionStartDate: now - 10,
|
||||
AuctionSlug: "channel-auction-" + suffix,
|
||||
Document: collectibleTestDocument(baseDocumentID+100, "channel-auction.tgs"), Blob: collectibleTestBlob(baseDocumentID+100, "channel-auction"),
|
||||
Animation: collectibleTestAnimation("channel-auction.tgs"), Actor: "integration", CommandID: "channel-auction-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel auction: %v", err)
|
||||
}
|
||||
if _, _, err := lifecycle.BidStarGiftAuction(ctx, domain.StarGiftAuctionBidRequest{UserID: actor.ID,
|
||||
GiftID: auctionEntry.Gift.ID, Peer: channelPeer, BidAmount: 100, FormID: 22001, Date: now + 3,
|
||||
}); err != nil {
|
||||
t.Fatalf("bid channel auction: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE star_gift_auctions SET next_round_at=$2 WHERE gift_id=$1`, auctionEntry.Gift.ID, now+4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := lifecycle.SweepStarGiftLifecycle(ctx, now+4, 1000); err != nil {
|
||||
t.Fatalf("settle channel auction: %v", err)
|
||||
}
|
||||
var awardSavedID int64
|
||||
if err := pool.QueryRow(ctx, `SELECT saved_gift_id FROM star_gift_auction_acquired WHERE gift_id=$1`, auctionEntry.Gift.ID).Scan(&awardSavedID); err != nil || awardSavedID <= 0 {
|
||||
t.Fatalf("channel auction saved id = %d err %v", awardSavedID, err)
|
||||
}
|
||||
var awardLogs int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM channel_admin_log_events
|
||||
WHERE channel_id=$1 AND message::text LIKE '%auction_acquired%'`, created.Channel.ID).Scan(&awardLogs); err != nil || awardLogs != 1 {
|
||||
t.Fatalf("channel auction admin logs = %d err %v", awardLogs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func issueLifecyclePurchaseForm(t *testing.T, ctx context.Context, lifecycle *StarGiftLifecycleStore,
|
||||
req domain.StarGiftPurchaseRequest) domain.StarGiftPurchaseRequest {
|
||||
t.Helper()
|
||||
var revisionID int64
|
||||
if err := lifecycle.db.QueryRow(ctx, `SELECT active_revision_id FROM star_gift_catalog WHERE gift_id=$1`, req.GiftID).Scan(&revisionID); err != nil {
|
||||
t.Fatalf("load active gift revision: %v", err)
|
||||
}
|
||||
gift, found, err := NewStarGiftStore(lifecycle.db).CatalogRevision(ctx, revisionID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load gift revision %d: found=%v err=%v", revisionID, found, err)
|
||||
}
|
||||
req.RevisionID = gift.RevisionID
|
||||
req.ChargeStars = gift.Stars
|
||||
if req.IncludeUpgrade {
|
||||
req.ChargeStars += gift.UpgradeStars
|
||||
}
|
||||
issued, err := lifecycle.IssueStarGiftPurchaseForm(ctx, domain.StarGiftPurchaseForm{
|
||||
BuyerUserID: req.BuyerUserID, To: req.To, GiftID: req.GiftID, RevisionID: req.RevisionID,
|
||||
IncludeUpgrade: req.IncludeUpgrade, HideName: req.HideName, Message: req.Message, ChargeStars: req.ChargeStars,
|
||||
IssuedAt: req.Date, ExpiresAt: req.Date + 600,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue purchase form: %v", err)
|
||||
}
|
||||
req.FormID = issued.FormID
|
||||
return req
|
||||
}
|
||||
|
||||
func craftedSourceEditForUserAndGift(result domain.StarGiftCraftResult, userID, uniqueGiftID int64) domain.EditedMessageForUser {
|
||||
for _, edit := range result.SourceEdits {
|
||||
if edit.UserID != userID {
|
||||
continue
|
||||
}
|
||||
action := starGiftUniqueActionFromEdit(edit)
|
||||
if action != nil && action.Gift.ID == uniqueGiftID {
|
||||
return edit
|
||||
}
|
||||
}
|
||||
return domain.EditedMessageForUser{UserID: userID}
|
||||
}
|
||||
|
||||
func starGiftUniqueActionFromEdit(edit domain.EditedMessageForUser) *domain.MessageStarGiftUniqueAction {
|
||||
if edit.Message.Media == nil || edit.Message.Media.ServiceAction == nil {
|
||||
return nil
|
||||
}
|
||||
return edit.Message.Media.ServiceAction.StarGiftUnique
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
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 != 121 {
|
||||
t.Fatalf("migration status = %+v, want clean version 121", status)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
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 = 0
|
||||
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")},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectiblePattern, baseID+2, "pattern")},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectibleBackdrop, 0, "backdrop")},
|
||||
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")},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{attribute(domain.StarGiftCollectibleBackdrop, 0, "backdrop")},
|
||||
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)
|
||||
}
|
||||
}
|
||||
344
internal/store/postgres/star_gift_purchase.go
Normal file
344
internal/store/postgres/star_gift_purchase.go
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
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
|
||||
},
|
||||
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 := 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
|
||||
}
|
||||
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}
|
||||
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)))
|
||||
}
|
||||
|
|
@ -21,18 +21,38 @@ import (
|
|||
// upgrades. It intentionally shares MessageStore's allocator and transaction
|
||||
// machinery so Stars, issuance, the saved gift and durable updates commit once.
|
||||
type StarGiftUpgradeStore struct {
|
||||
db sqlcgen.DBTX
|
||||
messages *MessageStore
|
||||
db sqlcgen.DBTX
|
||||
messages *MessageStore
|
||||
lifecycle domain.StarGiftLifecyclePolicy
|
||||
}
|
||||
|
||||
func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore) *StarGiftUpgradeStore {
|
||||
return &StarGiftUpgradeStore{db: db, messages: messages}
|
||||
type StarGiftUpgradeOption func(*StarGiftUpgradeStore)
|
||||
|
||||
func WithStarGiftLifecyclePolicy(policy domain.StarGiftLifecyclePolicy) StarGiftUpgradeOption {
|
||||
return func(s *StarGiftUpgradeStore) {
|
||||
if policy.Valid() {
|
||||
s.lifecycle = policy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore, opts ...StarGiftUpgradeOption) *StarGiftUpgradeStore {
|
||||
s := &StarGiftUpgradeStore{db: db, messages: messages, lifecycle: domain.StarGiftLifecyclePolicy{
|
||||
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 250,
|
||||
}}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
|
||||
if s == nil || s.db == nil || s.messages == nil || req.UserID <= 0 || !req.Ref.Valid() ||
|
||||
req.Ref.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) ||
|
||||
req.ChargeStars < 0 || req.Date <= 0 || strings.TrimSpace(req.CommandKey) == "" || len(req.CommandKey) > 256 {
|
||||
(req.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.ID != req.UserID) ||
|
||||
(req.Ref.Owner.Type != domain.PeerTypeUser && req.Ref.Owner.Type != domain.PeerTypeChannel) ||
|
||||
req.ChargeStars < 0 || (req.RequirePrepaid && (req.ChargeStars != 0 || req.FormID != 0)) ||
|
||||
(!req.RequirePrepaid && (req.ChargeStars <= 0 || req.FormID == 0)) ||
|
||||
req.Date <= 0 || strings.TrimSpace(req.CommandKey) == "" || len(req.CommandKey) > 256 {
|
||||
return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
saved, found, err := NewStarGiftStore(s.db).GetByRef(ctx, req.Ref)
|
||||
|
|
@ -48,7 +68,11 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S
|
|||
"telesrv:star-gift-upgrade:v1:%s:%d:%d:%t:%d:%t",
|
||||
commandKey, saved.ID, req.ChargeStars, req.RequirePrepaid, req.FormID, req.KeepOriginalDetails,
|
||||
)))
|
||||
randomID := starGiftUpgradeRandomID(saved.FromUserID, req.UserID, commandKey)
|
||||
messageSenderID := saved.FromUserID
|
||||
if saved.Owner.Type == domain.PeerTypeChannel {
|
||||
messageSenderID = domain.OfficialSystemUserID
|
||||
}
|
||||
randomID := starGiftUpgradeRandomID(messageSenderID, req.UserID, commandKey)
|
||||
placeholder := &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
|
|
@ -57,7 +81,7 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S
|
|||
},
|
||||
}
|
||||
messageReq := domain.SendPrivateTextRequest{
|
||||
SenderUserID: saved.FromUserID,
|
||||
SenderUserID: messageSenderID,
|
||||
RecipientUserID: req.UserID,
|
||||
RandomID: randomID,
|
||||
Media: placeholder,
|
||||
|
|
@ -89,6 +113,19 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var craftable bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS (
|
||||
SELECT 1 FROM star_gift_collectible_models
|
||||
WHERE collectible_revision_id=$1 AND crafted
|
||||
)`, revision.ID).Scan(&craftable); err != nil {
|
||||
return fmt.Errorf("load collectible craft capability: %w", err)
|
||||
}
|
||||
craftChancePermille := 0
|
||||
canCraftAt := 0
|
||||
if craftable {
|
||||
craftChancePermille = s.lifecycle.CraftChancePermille
|
||||
canCraftAt = starGiftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds)
|
||||
}
|
||||
if revision.Issued >= revision.SupplyTotal {
|
||||
return domain.ErrStarGiftCollectibleSoldOut
|
||||
}
|
||||
|
|
@ -134,10 +171,12 @@ func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.S
|
|||
INSERT INTO unique_star_gifts
|
||||
(id, gift_id, collectible_revision_id, source_saved_gift_id, title, slug, num,
|
||||
owner_peer_type, owner_peer_id, model_attribute_id, pattern_attribute_id,
|
||||
backdrop_attribute_id, keep_original_details)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
|
||||
backdrop_attribute_id, keep_original_details, original_owner_peer_type, original_owner_peer_id,
|
||||
craft_chance_permille, offer_min_stars)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`,
|
||||
uniqueID, locked.GiftID, revision.ID, locked.ID, title, slug, num,
|
||||
string(locked.Owner.Type), locked.Owner.ID, modelID, patternID, backdropID, req.KeepOriginalDetails); err != nil {
|
||||
string(locked.Owner.Type), locked.Owner.ID, modelID, patternID, backdropID, req.KeepOriginalDetails,
|
||||
string(locked.Owner.Type), locked.Owner.ID, craftChancePermille, s.lifecycle.OfferMinStars); err != nil {
|
||||
return fmt.Errorf("insert unique star gift: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE star_gift_collectible_revisions SET issued=issued+1 WHERE id=$1`, revision.ID); err != nil {
|
||||
|
|
@ -145,14 +184,21 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
|
|||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE peer_star_gifts
|
||||
SET unique_gift_id=$2, prepaid_upgrade_stars=0, convert_stars=0
|
||||
WHERE id=$1 AND unique_gift_id IS NULL AND NOT converted`, locked.ID, uniqueID); err != nil {
|
||||
SET unique_gift_id=$2, prepaid_upgrade_stars=0, prepaid_upgrade_hash='', convert_stars=0,
|
||||
transfer_stars=$3,can_export_at=$4,can_transfer_at=$5,can_resell_at=$6,
|
||||
drop_original_details_stars=$7,can_craft_at=$8
|
||||
WHERE id=$1 AND unique_gift_id IS NULL AND lifecycle_status='active'`, locked.ID, uniqueID,
|
||||
s.lifecycle.TransferStars, starGiftReadyAt(req.Date, s.lifecycle.ExportDelaySeconds),
|
||||
starGiftReadyAt(req.Date, s.lifecycle.TransferDelaySeconds), starGiftReadyAt(req.Date, s.lifecycle.ResellDelaySeconds),
|
||||
s.lifecycle.DropOriginalDetailsStars, canCraftAt); err != nil {
|
||||
return fmt.Errorf("upgrade saved star gift: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO star_gift_upgrade_commands
|
||||
(user_id, command_key, source_saved_gift_id, form_id, unique_gift_id, balance_after)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniqueID, balance.Balance); err != nil {
|
||||
(user_id, command_key, source_saved_gift_id, form_id, unique_gift_id, balance_after,
|
||||
charge_stars, require_prepaid, keep_original_details)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.FormID, uniqueID, balance.Balance,
|
||||
req.ChargeStars, req.RequirePrepaid, req.KeepOriginalDetails); err != nil {
|
||||
return fmt.Errorf("insert star gift upgrade command: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -166,21 +212,20 @@ VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniq
|
|||
locked.UniqueGiftID = uniqueID
|
||||
locked.PrepaidUpgradeStars = 0
|
||||
locked.ConvertStars = 0
|
||||
locked.TransferStars = s.lifecycle.TransferStars
|
||||
locked.CanExportAt = starGiftReadyAt(req.Date, s.lifecycle.ExportDelaySeconds)
|
||||
locked.CanTransferAt = starGiftReadyAt(req.Date, s.lifecycle.TransferDelaySeconds)
|
||||
locked.CanResellAt = starGiftReadyAt(req.Date, s.lifecycle.ResellDelaySeconds)
|
||||
locked.DropOriginalDetailsStars = s.lifecycle.DropOriginalDetailsStars
|
||||
locked.CanCraftAt = canCraftAt
|
||||
locked.Unique = &unique
|
||||
result.Saved, result.Unique, result.Balance = locked, unique, balance
|
||||
action := starGiftUpgradeUniqueAction(locked, unique, req, messageSenderID)
|
||||
messageReq.Media = &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique,
|
||||
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Gift: unique, FromUserID: func() int64 {
|
||||
if locked.NameHidden {
|
||||
return 0
|
||||
}
|
||||
return locked.FromUserID
|
||||
}(), Peer: locked.Owner, Upgrade: true, Saved: !locked.Unsaved,
|
||||
PrepaidUpgrade: req.RequirePrepaid,
|
||||
},
|
||||
Kind: domain.MessageServiceActionStarGiftUnique,
|
||||
StarGiftUnique: action,
|
||||
},
|
||||
}
|
||||
return nil
|
||||
|
|
@ -201,6 +246,40 @@ VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniq
|
|||
return fmt.Errorf("save star gift upgrade message id lost aggregate row")
|
||||
}
|
||||
result.Saved.UpgradeMsgID = ownerMessageID
|
||||
if result.Saved.Owner.Type == domain.PeerTypeUser {
|
||||
edits, err := s.markPrivateStarGiftSourceUpgradedTx(ctx, tx, req, result.Saved, sent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.SourceEdits = edits
|
||||
ownerEditPts := 0
|
||||
for _, edit := range edits {
|
||||
if edit.UserID == req.UserID {
|
||||
ownerEditPts = edit.Event.Pts
|
||||
break
|
||||
}
|
||||
}
|
||||
if ownerEditPts <= 0 {
|
||||
return fmt.Errorf("upgrade source edit missing owner event")
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE star_gift_upgrade_commands SET source_edit_pts=$3
|
||||
WHERE user_id=$1 AND command_key=$2`, req.UserID, commandKey, ownerEditPts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save star gift source edit pts: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return fmt.Errorf("save star gift source edit pts lost command row")
|
||||
}
|
||||
} else {
|
||||
action := starGiftUpgradeUniqueAction(result.Saved, result.Unique, req, messageSenderID)
|
||||
if err := NewChannelStore(tx).appendStarGiftAdminLogTx(ctx, tx, result.Saved.Owner.ID,
|
||||
req.UserID, result.Saved.SavedID, req.Date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionStarGiftUnique, StarGiftUnique: action,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("append channel star gift upgrade admin log: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
|
@ -216,11 +295,186 @@ VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniq
|
|||
return result, nil
|
||||
}
|
||||
|
||||
func starGiftUpgradeUniqueAction(saved domain.SavedStarGift, unique domain.UniqueStarGift, req domain.StarGiftUpgradeRequest, messageSenderID int64) *domain.MessageStarGiftUniqueAction {
|
||||
fromUserID := saved.FromUserID
|
||||
if saved.NameHidden {
|
||||
fromUserID = 0
|
||||
}
|
||||
if saved.Owner.Type == domain.PeerTypeChannel {
|
||||
// TDesktop recognizes a channel-owned upgrade from the official service
|
||||
// peer plus action.peer=channel and action.saved_id.
|
||||
fromUserID = messageSenderID
|
||||
}
|
||||
savedID := saved.SavedID
|
||||
if saved.Owner.Type == domain.PeerTypeUser {
|
||||
// For user-owned gifts messageActionStarGiftUnique.saved_id is the
|
||||
// stable source gift message id. TDesktop uses this back-reference as
|
||||
// inputSavedStarGiftUser.msg_id for crafting and later lifecycle RPCs.
|
||||
savedID = int64(saved.MsgID)
|
||||
}
|
||||
return &domain.MessageStarGiftUniqueAction{
|
||||
Gift: unique, FromUserID: fromUserID, Peer: saved.Owner, SavedID: savedID,
|
||||
Upgrade: true, Saved: !saved.Unsaved, PrepaidUpgrade: req.RequirePrepaid,
|
||||
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars,
|
||||
CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt,
|
||||
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt,
|
||||
}
|
||||
}
|
||||
|
||||
// markPrivateStarGiftSourceUpgradedTx rewrites both visible copies of the
|
||||
// original gift service message in the same transaction that creates the
|
||||
// unique gift message. upgrade_msg_id is box-local, so each owner projection
|
||||
// must point at that owner's copy of the new service message. Every rewrite is
|
||||
// a durable edit_message event with its own pts and outbox row.
|
||||
func (s *StarGiftUpgradeStore) markPrivateStarGiftSourceUpgradedTx(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
req domain.StarGiftUpgradeRequest,
|
||||
saved domain.SavedStarGift,
|
||||
sent domain.SendPrivateTextResult,
|
||||
) ([]domain.EditedMessageForUser, error) {
|
||||
if saved.Owner.Type != domain.PeerTypeUser || saved.Owner.ID != req.UserID || saved.MsgID <= 0 {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
q := sqlcgen.New(tx)
|
||||
target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{
|
||||
OwnerUserID: req.UserID,
|
||||
BoxID: int32(saved.MsgID),
|
||||
PeerType: string(domain.PeerTypeUser),
|
||||
PeerID: saved.FromUserID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return nil, fmt.Errorf("lock star gift source 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, fmt.Errorf("list star gift source message boxes: %w", err)
|
||||
}
|
||||
if len(boxes) == 0 {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
upgradeMessageIDs := make(map[int64]int, 2)
|
||||
if sent.SenderMessage.OwnerUserID > 0 && sent.SenderMessage.ID > 0 {
|
||||
upgradeMessageIDs[sent.SenderMessage.OwnerUserID] = sent.SenderMessage.ID
|
||||
}
|
||||
if sent.RecipientMessage.OwnerUserID > 0 && sent.RecipientMessage.ID > 0 {
|
||||
upgradeMessageIDs[sent.RecipientMessage.OwnerUserID] = sent.RecipientMessage.ID
|
||||
}
|
||||
edits := make([]domain.EditedMessageForUser, 0, len(boxes))
|
||||
var privateMediaJSON []byte
|
||||
for _, box := range boxes {
|
||||
upgradeMessageID := upgradeMessageIDs[box.OwnerUserID]
|
||||
if upgradeMessageID <= 0 {
|
||||
return nil, fmt.Errorf("upgrade service message missing box for user %d", box.OwnerUserID)
|
||||
}
|
||||
media, err := decodeMessageMedia(box.MediaJson)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode star gift source media: %w", err)
|
||||
}
|
||||
if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
|
||||
media.ServiceAction.Kind != domain.MessageServiceActionStarGift || media.ServiceAction.StarGift == nil {
|
||||
return nil, fmt.Errorf("star gift source message %d has invalid media", box.BoxID)
|
||||
}
|
||||
action := media.ServiceAction.StarGift
|
||||
if action.UpgradeMsgID != 0 && action.UpgradeMsgID != upgradeMessageID {
|
||||
return nil, fmt.Errorf("star gift source message %d has conflicting upgrade message %d", box.BoxID, action.UpgradeMsgID)
|
||||
}
|
||||
action.UpgradeMsgID = upgradeMessageID
|
||||
action.CanUpgrade = false
|
||||
mediaJSON, err := encodeMessageMedia(media)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode upgraded star gift source media: %w", err)
|
||||
}
|
||||
pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("allocate star gift source 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, fmt.Errorf("update star gift source message box: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return nil, fmt.Errorf("update star gift source message box 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: req.Date, Message: msg,
|
||||
}
|
||||
if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil {
|
||||
return nil, fmt.Errorf("append star gift source edit event: %w", err)
|
||||
}
|
||||
dispatchAuthKeyID := [8]byte{}
|
||||
dispatchSessionID := int64(0)
|
||||
if msg.OwnerUserID == req.UserID {
|
||||
dispatchAuthKeyID = req.OriginAuthKeyID
|
||||
dispatchSessionID = req.OriginSessionID
|
||||
}
|
||||
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, fmt.Errorf("enqueue star gift source edit: %w", err)
|
||||
}
|
||||
if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 {
|
||||
privateMediaJSON = mediaJSON
|
||||
}
|
||||
edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event})
|
||||
}
|
||||
if len(privateMediaJSON) == 0 {
|
||||
return nil, fmt.Errorf("upgrade source message missing private media 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, fmt.Errorf("update star gift source private message: %w", err)
|
||||
}
|
||||
return edits, nil
|
||||
}
|
||||
|
||||
func starGiftReadyAt(date, delaySeconds int) int {
|
||||
if date <= 0 || delaySeconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
const maxProtocolDate = int(1<<31 - 1)
|
||||
if delaySeconds > maxProtocolDate-date {
|
||||
return maxProtocolDate
|
||||
}
|
||||
return date + delaySeconds
|
||||
}
|
||||
|
||||
func lockSavedStarGiftForUpgrade(ctx context.Context, tx pgx.Tx, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
|
||||
where, args := savedStarGiftRefWhere(ref)
|
||||
return lockSavedStarGiftWhere(ctx, tx, where, args...)
|
||||
}
|
||||
|
||||
func lockSavedStarGiftByID(ctx context.Context, tx pgx.Tx, savedID int64) (domain.SavedStarGift, error) {
|
||||
return lockSavedStarGiftWhere(ctx, tx, "p.id = $1", savedID)
|
||||
}
|
||||
|
||||
func lockSavedStarGiftWhere(ctx context.Context, tx pgx.Tx, where string, args ...any) (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.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
|
||||
|
|
@ -283,7 +537,13 @@ func debitStarGiftUpgrade(ctx context.Context, tx pgx.Tx, userID, amount int64,
|
|||
}
|
||||
|
||||
func chooseCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, revisionID int64) (int64, error) {
|
||||
rows, err := tx.Query(ctx, fmt.Sprintf(`SELECT id, rarity_permille FROM %s WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, table), revisionID)
|
||||
extra := ""
|
||||
if table == "star_gift_collectible_models" {
|
||||
extra = " AND NOT crafted"
|
||||
}
|
||||
rows, err := tx.Query(ctx, fmt.Sprintf(`SELECT id, rarity_permille FROM %s
|
||||
WHERE collectible_revision_id=$1 AND rarity_kind='permille' AND rarity_permille > 0%s
|
||||
ORDER BY sort_order, id`, table, extra), revisionID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("list collectible attributes for issuance: %w", err)
|
||||
}
|
||||
|
|
@ -305,7 +565,7 @@ func chooseCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, re
|
|||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(items) == 0 || total != 1000 {
|
||||
if len(items) == 0 || total <= 0 {
|
||||
return 0, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
draw, err := rand.Int(rand.Reader, big.NewInt(int64(total)))
|
||||
|
|
@ -346,20 +606,94 @@ func (s *StarGiftUpgradeStore) loadUpgradeReplay(ctx context.Context, req domain
|
|||
}
|
||||
return domain.StarGiftUpgradeResult{}, err
|
||||
}
|
||||
var commandUniqueID int64
|
||||
var balanceAfter int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT unique_gift_id, balance_after FROM star_gift_upgrade_commands WHERE user_id=$1 AND command_key=$2`, req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&commandUniqueID, &balanceAfter); err != nil {
|
||||
receipt, found, err := s.StarGiftUpgradeReceipt(ctx, req.UserID, req.CommandKey)
|
||||
if err != nil {
|
||||
return domain.StarGiftUpgradeResult{}, fmt.Errorf("load star gift upgrade replay: %w", err)
|
||||
}
|
||||
if commandUniqueID != unique.ID || saved.ID != original.ID {
|
||||
if !found || receipt.UniqueGiftID != unique.ID || receipt.SourceSavedGiftID != saved.ID || saved.ID != original.ID ||
|
||||
receipt.FormID != req.FormID || receipt.ChargeStars != req.ChargeStars || receipt.RequirePrepaid != req.RequirePrepaid ||
|
||||
receipt.KeepOriginalDetails != req.KeepOriginalDetails {
|
||||
return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
uniqueCopy := unique
|
||||
saved.Unique = &uniqueCopy
|
||||
sourceEdits, err := s.loadUpgradeSourceReplay(ctx, req, saved, receipt.SourceEditPts)
|
||||
if err != nil {
|
||||
return domain.StarGiftUpgradeResult{}, err
|
||||
}
|
||||
return domain.StarGiftUpgradeResult{
|
||||
Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.UserID, Balance: balanceAfter},
|
||||
Send: sent, Duplicate: true,
|
||||
Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.UserID, Balance: receipt.BalanceAfter},
|
||||
Send: sent, SourceEdits: sourceEdits, Duplicate: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftUpgradeStore) loadUpgradeSourceReplay(ctx context.Context, req domain.StarGiftUpgradeRequest, saved domain.SavedStarGift, pts int) ([]domain.EditedMessageForUser, error) {
|
||||
if saved.Owner.Type != domain.PeerTypeUser {
|
||||
return nil, nil
|
||||
}
|
||||
if pts <= 0 || saved.MsgID <= 0 {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
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.MsgID, saved.FromUserID).Scan(&privateMessageID, &messageSenderID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// A later delete event is authoritative; replaying the old edit here
|
||||
// would transiently resurrect the source message.
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load star gift source 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 star gift source replay box: %w", err)
|
||||
}
|
||||
if len(boxes) != 1 || int(boxes[0].BoxID) != saved.MsgID {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
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, pts, saved.MsgID).Scan(&eventDate)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return nil, fmt.Errorf("load star gift source replay event: %w", err)
|
||||
}
|
||||
msg, err := messageFromVisibleBoxRow(boxes[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.Pts = pts
|
||||
event := domain.UpdateEvent{UserID: req.UserID, Type: domain.UpdateEventEditMessage, Pts: pts, PtsCount: 1, Date: eventDate, Message: msg}
|
||||
return []domain.EditedMessageForUser{{UserID: req.UserID, Message: msg, Event: event}}, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftUpgradeStore) StarGiftUpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) {
|
||||
commandKey = strings.TrimSpace(commandKey)
|
||||
if s == nil || s.db == nil || userID <= 0 || commandKey == "" || len(commandKey) > 256 {
|
||||
return domain.StarGiftUpgradeReceipt{}, false, nil
|
||||
}
|
||||
receipt := domain.StarGiftUpgradeReceipt{UserID: userID}
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT source_saved_gift_id,form_id,unique_gift_id,charge_stars,balance_after,source_edit_pts,require_prepaid,keep_original_details
|
||||
FROM star_gift_upgrade_commands WHERE user_id=$1 AND command_key=$2`, userID, commandKey).Scan(
|
||||
&receipt.SourceSavedGiftID, &receipt.FormID, &receipt.UniqueGiftID, &receipt.ChargeStars,
|
||||
&receipt.BalanceAfter, &receipt.SourceEditPts, &receipt.RequirePrepaid, &receipt.KeepOriginalDetails)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.StarGiftUpgradeReceipt{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.StarGiftUpgradeReceipt{}, false, err
|
||||
}
|
||||
return receipt, true, nil
|
||||
}
|
||||
|
||||
var _ store.StarGiftUpgradeStore = (*StarGiftUpgradeStore)(nil)
|
||||
|
|
|
|||
|
|
@ -211,31 +211,36 @@ func appendUserUpdateEvent(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Quer
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
emojiStatusPayload, err := encodeEventEmojiStatus(event.EmojiStatus)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{
|
||||
UserID: userID,
|
||||
Pts: int32(event.Pts),
|
||||
PtsCount: int32(event.PtsCount),
|
||||
Date: int32(event.Date),
|
||||
EventType: string(event.Type),
|
||||
EventBool: event.Bool,
|
||||
EventPhone: event.Phone,
|
||||
EventPeers: peers,
|
||||
PeerSettings: settings,
|
||||
MessageIds: messageIDs,
|
||||
DialogFilter: dialogFilter,
|
||||
FilterOrder: filterOrder,
|
||||
FolderPeers: folderPeers,
|
||||
StoryPayload: storyPayload,
|
||||
ReactionPayload: reactionPayload,
|
||||
MaxID: pgInt32NonNegative(event.MaxID),
|
||||
StillUnreadCount: int32(event.StillUnreadCount),
|
||||
ChannelPts: int32(event.ChannelPts),
|
||||
FilterID: pgInt32NonNegative(event.FilterID),
|
||||
TagsEnabled: event.TagsEnabled,
|
||||
FolderID: pgInt32NonNegative(event.FolderID),
|
||||
MessageBoxID: messageID,
|
||||
PeerType: peerType,
|
||||
PeerID: peerID,
|
||||
UserID: userID,
|
||||
Pts: int32(event.Pts),
|
||||
PtsCount: int32(event.PtsCount),
|
||||
Date: int32(event.Date),
|
||||
EventType: string(event.Type),
|
||||
EventBool: event.Bool,
|
||||
EventPhone: event.Phone,
|
||||
EventPeers: peers,
|
||||
PeerSettings: settings,
|
||||
MessageIds: messageIDs,
|
||||
DialogFilter: dialogFilter,
|
||||
FilterOrder: filterOrder,
|
||||
FolderPeers: folderPeers,
|
||||
StoryPayload: storyPayload,
|
||||
ReactionPayload: reactionPayload,
|
||||
EmojiStatusPayload: emojiStatusPayload,
|
||||
MaxID: pgInt32NonNegative(event.MaxID),
|
||||
StillUnreadCount: int32(event.StillUnreadCount),
|
||||
ChannelPts: int32(event.ChannelPts),
|
||||
FilterID: pgInt32NonNegative(event.FilterID),
|
||||
TagsEnabled: event.TagsEnabled,
|
||||
FolderID: pgInt32NonNegative(event.FolderID),
|
||||
MessageBoxID: messageID,
|
||||
PeerType: peerType,
|
||||
PeerID: peerID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -385,6 +390,10 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("decode reaction payload: %w", err)
|
||||
}
|
||||
emojiStatus, err := decodeEventEmojiStatus(row.EmojiStatusPayloadJson)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode emoji status payload: %w", err)
|
||||
}
|
||||
media, err := decodeMessageMedia(row.MediaJson)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode message media: %w", err)
|
||||
|
|
@ -420,6 +429,7 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim
|
|||
TagsEnabled: row.TagsEnabled,
|
||||
FolderID: int(row.FolderID),
|
||||
Reaction: reaction,
|
||||
EmojiStatus: emojiStatus,
|
||||
Message: domain.Message{
|
||||
ID: int(row.MessageID),
|
||||
UID: row.PrivateMessageID,
|
||||
|
|
@ -579,6 +589,10 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("decode reaction payload: %w", err)
|
||||
}
|
||||
emojiStatus, err := decodeEventEmojiStatus(row.EmojiStatusPayloadJson)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode emoji status payload: %w", err)
|
||||
}
|
||||
media, err := decodeMessageMedia(row.MediaJson)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode message media: %w", err)
|
||||
|
|
@ -614,6 +628,7 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev
|
|||
TagsEnabled: row.TagsEnabled,
|
||||
FolderID: int(row.FolderID),
|
||||
Reaction: reaction,
|
||||
EmojiStatus: emojiStatus,
|
||||
Message: domain.Message{
|
||||
ID: int(row.MessageID),
|
||||
UID: row.PrivateMessageID,
|
||||
|
|
@ -979,6 +994,31 @@ func decodeEventReaction(raw string) (*domain.MessageReaction, error) {
|
|||
return decodeStoryReaction(raw)
|
||||
}
|
||||
|
||||
func encodeEventEmojiStatus(status domain.UserEmojiStatus) ([]byte, error) {
|
||||
if !status.Valid() {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
raw, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal event emoji status: %w", err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func decodeEventEmojiStatus(raw string) (domain.UserEmojiStatus, error) {
|
||||
if raw == "" || raw == "{}" || raw == "null" {
|
||||
return domain.UserEmojiStatus{}, nil
|
||||
}
|
||||
var status domain.UserEmojiStatus
|
||||
if err := json.Unmarshal([]byte(raw), &status); err != nil {
|
||||
return domain.UserEmojiStatus{}, err
|
||||
}
|
||||
if !status.Valid() {
|
||||
return domain.UserEmojiStatus{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
type peerSettingsJSON struct {
|
||||
AddContact bool `json:"add_contact,omitempty"`
|
||||
BlockContact bool `json:"block_contact,omitempty"`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
|
@ -152,27 +153,30 @@ func (s *UserStore) Search(ctx context.Context, currentUserID int64, query, phon
|
|||
Results: make([]domain.User, 0, len(rows)),
|
||||
}
|
||||
for _, row := range rows {
|
||||
collectible := mustDecodeEmojiStatusCollectible(row.EmojiStatusCollectibleID, row.EmojiStatusCollectible)
|
||||
u := domain.User{
|
||||
ID: row.ID,
|
||||
AccessHash: row.AccessHash,
|
||||
Phone: row.Phone,
|
||||
FirstName: row.FirstName,
|
||||
LastName: row.LastName,
|
||||
About: row.About,
|
||||
Username: row.Username,
|
||||
CountryCode: row.CountryCode,
|
||||
Verified: row.Verified,
|
||||
Support: row.Support,
|
||||
Bot: row.IsBot,
|
||||
BotInfoVersion: int(row.BotInfoVersion),
|
||||
PremiumUntil: premiumUntilFromModel(row.PremiumExpiresAt),
|
||||
EmojiStatusDocumentID: row.EmojiStatusDocumentID,
|
||||
EmojiStatusUntil: int(row.EmojiStatusUntil),
|
||||
Color: peerColorFromModel(row.ColorSet, row.Color, row.ColorBackgroundEmojiID),
|
||||
ProfileColor: peerColorFromModel(row.ProfileColorSet, row.ProfileColor, row.ProfileColorBackgroundEmojiID),
|
||||
LastSeenAt: int(row.LastSeenAt),
|
||||
Contact: row.Contact,
|
||||
Mutual: row.Mutual,
|
||||
ID: row.ID,
|
||||
AccessHash: row.AccessHash,
|
||||
Phone: row.Phone,
|
||||
FirstName: row.FirstName,
|
||||
LastName: row.LastName,
|
||||
About: row.About,
|
||||
Username: row.Username,
|
||||
CountryCode: row.CountryCode,
|
||||
Verified: row.Verified,
|
||||
Support: row.Support,
|
||||
Bot: row.IsBot,
|
||||
BotInfoVersion: int(row.BotInfoVersion),
|
||||
PremiumUntil: premiumUntilFromModel(row.PremiumExpiresAt),
|
||||
EmojiStatusDocumentID: row.EmojiStatusDocumentID,
|
||||
EmojiStatusUntil: int(row.EmojiStatusUntil),
|
||||
EmojiStatusCollectible: collectible,
|
||||
Color: peerColorFromModel(row.ColorSet, row.Color, row.ColorBackgroundEmojiID),
|
||||
ProfileColor: peerColorFromModel(row.ProfileColorSet, row.ProfileColor, row.ProfileColorBackgroundEmojiID),
|
||||
LinkedCommunityID: row.LinkedCommunityID,
|
||||
LastSeenAt: int(row.LastSeenAt),
|
||||
Contact: row.Contact,
|
||||
Mutual: row.Mutual,
|
||||
}
|
||||
if row.Contact {
|
||||
out.MyResults = append(out.MyResults, u)
|
||||
|
|
@ -218,7 +222,7 @@ func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username s
|
|||
}()
|
||||
qtx := s.q.WithTx(tx)
|
||||
var lockedUserID int64
|
||||
if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&lockedUserID); err != nil {
|
||||
if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`, userID).Scan(&lockedUserID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.User{}, domain.ErrUsernameNotOccupied
|
||||
}
|
||||
|
|
@ -355,22 +359,110 @@ func (s *UserStore) SweepExpiredPremium(ctx context.Context, now int64, limit in
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除)。
|
||||
func (s *UserStore) UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) {
|
||||
row, err := s.q.UpdateUserEmojiStatus(ctx, sqlcgen.UpdateUserEmojiStatusParams{
|
||||
ID: userID,
|
||||
EmojiStatusDocumentID: documentID,
|
||||
EmojiStatusUntil: int64(until),
|
||||
})
|
||||
// UpdateEmojiStatus atomically replaces the complete emoji-status snapshot.
|
||||
func (s *UserStore) UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
|
||||
collectibleJSON, collectibleID, err := encodeEmojiStatusCollectible(status)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
params := sqlcgen.UpdateUserEmojiStatusParams{
|
||||
ID: userID,
|
||||
EmojiStatusDocumentID: status.DocumentID,
|
||||
EmojiStatusUntil: int64(status.Until),
|
||||
EmojiStatusCollectibleID: collectibleID,
|
||||
EmojiStatusCollectible: collectibleJSON,
|
||||
}
|
||||
var row sqlcgen.User
|
||||
if status.Collectible.Empty() {
|
||||
row, err = updateEmojiStatusRow(ctx, s.db, s.q, userID, status, params)
|
||||
} else {
|
||||
// Serialize selection against transfer/export/burn. RPC-level ownership
|
||||
// checks are advisory; this lock is the write-boundary invariant that
|
||||
// prevents a concurrent lifecycle commit from leaving a non-owned gift
|
||||
// installed after its invalidation trigger already ran.
|
||||
err = withTx(ctx, s.db, "update collectible emoji status", func(tx pgx.Tx) error {
|
||||
row, err = updateEmojiStatusRow(ctx, tx, sqlcgen.New(tx), userID, status, params)
|
||||
return err
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
return domain.User{}, err
|
||||
}
|
||||
return domain.User{}, fmt.Errorf("update user emoji status: %w", err)
|
||||
}
|
||||
return userFromModel(row), nil
|
||||
}
|
||||
|
||||
// UpdateEmojiStatusWithEvent commits the user snapshot, allocated pts event
|
||||
// and dispatch outbox row as one aggregate transaction. This is the production
|
||||
// boundary used by account.updateEmojiStatus; no success can expose a users
|
||||
// row whose change is absent from updates.getDifference.
|
||||
func (s *UserStore) UpdateEmojiStatusWithEvent(ctx context.Context, userID int64, status domain.UserEmojiStatus, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.User, domain.UpdateEvent, error) {
|
||||
collectibleJSON, collectibleID, err := encodeEmojiStatusCollectible(status)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.UpdateEvent{}, err
|
||||
}
|
||||
if event.Type != domain.UpdateEventUserEmojiStatus || event.EmojiStatus != status ||
|
||||
event.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) {
|
||||
return domain.User{}, domain.UpdateEvent{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
params := sqlcgen.UpdateUserEmojiStatusParams{
|
||||
ID: userID,
|
||||
EmojiStatusDocumentID: status.DocumentID,
|
||||
EmojiStatusUntil: int64(status.Until),
|
||||
EmojiStatusCollectibleID: collectibleID,
|
||||
EmojiStatusCollectible: collectibleJSON,
|
||||
}
|
||||
var row sqlcgen.User
|
||||
err = withTx(ctx, s.db, "update emoji status with event", func(tx pgx.Tx) error {
|
||||
row, err = updateEmojiStatusRow(ctx, tx, sqlcgen.New(tx), userID, status, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event, err = NewUpdateEventStore(tx).AppendAllocatedWithDispatch(
|
||||
ctx, userID, event, excludeAuthKeyID, excludeSessionID,
|
||||
)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.User{}, domain.UpdateEvent{}, domain.ErrUserNotFound
|
||||
}
|
||||
if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
return domain.User{}, domain.UpdateEvent{}, err
|
||||
}
|
||||
return domain.User{}, domain.UpdateEvent{}, fmt.Errorf("update user emoji status with event: %w", err)
|
||||
}
|
||||
return userFromModel(row), event, nil
|
||||
}
|
||||
|
||||
func updateEmojiStatusRow(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, userID int64, status domain.UserEmojiStatus, params sqlcgen.UpdateUserEmojiStatusParams) (sqlcgen.User, error) {
|
||||
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 q.UpdateUserEmojiStatus(ctx, params)
|
||||
}
|
||||
|
||||
// UpdateBirthday 更新用户生日(零值 Birthday 表示清除)。
|
||||
func (s *UserStore) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) {
|
||||
row, err := s.q.UpdateUserBirthday(ctx, sqlcgen.UpdateUserBirthdayParams{
|
||||
|
|
@ -463,29 +555,74 @@ func escapeLike(s string) string {
|
|||
}
|
||||
|
||||
func userFromModel(r sqlcgen.User) domain.User {
|
||||
return domain.User{
|
||||
ID: r.ID,
|
||||
AccessHash: r.AccessHash,
|
||||
Phone: r.Phone,
|
||||
SignupEmail: r.SignupEmail,
|
||||
FirstName: r.FirstName,
|
||||
LastName: r.LastName,
|
||||
About: r.About,
|
||||
Username: r.Username,
|
||||
CountryCode: r.CountryCode,
|
||||
Verified: r.Verified,
|
||||
Support: r.Support,
|
||||
Bot: r.IsBot,
|
||||
BotInfoVersion: int(r.BotInfoVersion),
|
||||
PremiumUntil: premiumUntilFromModel(r.PremiumExpiresAt),
|
||||
EmojiStatusDocumentID: r.EmojiStatusDocumentID,
|
||||
EmojiStatusUntil: int(r.EmojiStatusUntil),
|
||||
Birthday: domain.Birthday{Day: int(r.BirthdayDay), Month: int(r.BirthdayMonth), Year: int(r.BirthdayYear)},
|
||||
PersonalChannelID: r.PersonalChannelID,
|
||||
Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID),
|
||||
ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID),
|
||||
LastSeenAt: int(r.LastSeenAt),
|
||||
collectible := mustDecodeEmojiStatusCollectible(r.EmojiStatusCollectibleID, r.EmojiStatusCollectible)
|
||||
u := domain.User{
|
||||
ID: r.ID,
|
||||
AccessHash: r.AccessHash,
|
||||
Phone: r.Phone,
|
||||
SignupEmail: r.SignupEmail,
|
||||
FirstName: r.FirstName,
|
||||
LastName: r.LastName,
|
||||
About: r.About,
|
||||
Username: r.Username,
|
||||
CountryCode: r.CountryCode,
|
||||
Verified: r.Verified,
|
||||
Support: r.Support,
|
||||
Bot: r.IsBot,
|
||||
BotInfoVersion: int(r.BotInfoVersion),
|
||||
PremiumUntil: premiumUntilFromModel(r.PremiumExpiresAt),
|
||||
EmojiStatusDocumentID: r.EmojiStatusDocumentID,
|
||||
EmojiStatusUntil: int(r.EmojiStatusUntil),
|
||||
EmojiStatusCollectible: collectible,
|
||||
Birthday: domain.Birthday{Day: int(r.BirthdayDay), Month: int(r.BirthdayMonth), Year: int(r.BirthdayYear)},
|
||||
PersonalChannelID: r.PersonalChannelID,
|
||||
LinkedCommunityID: r.LinkedCommunityID,
|
||||
Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID),
|
||||
ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID),
|
||||
LastSeenAt: int(r.LastSeenAt),
|
||||
Deleted: r.DeletedAt.Valid,
|
||||
DeletionSource: domain.AccountDeletionSource(r.DeletionSource),
|
||||
DeletionReason: r.DeletionReason,
|
||||
CreatedAt: r.CreatedAt.Time,
|
||||
AccountDeleteAt: r.AccountDeleteAt.Time,
|
||||
}
|
||||
if r.DeletedAt.Valid {
|
||||
u.DeletedAt = r.DeletedAt.Time.Unix()
|
||||
return u.DeletedTombstone()
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func encodeEmojiStatusCollectible(status domain.UserEmojiStatus) ([]byte, *int64, error) {
|
||||
if !status.Valid() {
|
||||
return nil, nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
if status.Collectible.Empty() {
|
||||
return []byte(`{}`), nil, nil
|
||||
}
|
||||
raw, err := json.Marshal(status.Collectible)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("encode collectible emoji status: %w", err)
|
||||
}
|
||||
id := status.Collectible.CollectibleID
|
||||
return raw, &id, nil
|
||||
}
|
||||
|
||||
func mustDecodeEmojiStatusCollectible(id *int64, raw []byte) domain.EmojiStatusCollectible {
|
||||
var collectible domain.EmojiStatusCollectible
|
||||
if err := json.Unmarshal(raw, &collectible); err != nil {
|
||||
panic(fmt.Sprintf("invalid users.emoji_status_collectible JSON: %v", err))
|
||||
}
|
||||
if id == nil {
|
||||
if !collectible.Empty() {
|
||||
panic("users emoji-status invariant: snapshot exists without collectible id")
|
||||
}
|
||||
return domain.EmojiStatusCollectible{}
|
||||
}
|
||||
if !collectible.Valid() || collectible.CollectibleID != *id {
|
||||
panic("users emoji-status invariant: incomplete or mismatched collectible snapshot")
|
||||
}
|
||||
return collectible
|
||||
}
|
||||
|
||||
func peerColorFromModel(hasColor bool, color int32, backgroundEmojiID int64) domain.PeerColor {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue