merged from gramsrv upstream

This commit is contained in:
onysd 2026-09-01 12:06:31 +03:00
parent 79c64ee916
commit 21a0856587
651 changed files with 54774 additions and 4590 deletions

View file

@ -16,7 +16,7 @@ import (
)
// AccountLifecycleStore is the PostgreSQL implementation of the unified
// account tombstone, delayed deletion and deletion notification boundary.
// account tombstone and delayed deletion boundary.
type AccountLifecycleStore struct {
pool *pgxpool.Pool
}
@ -157,19 +157,15 @@ func (s *AccountLifecycleStore) ExecuteAccountDeletion(ctx context.Context, user
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
}
// Human account deletion is deliberately a short logical tombstone boundary.
// Relationships, history, memberships, settings and financial rows remain
// attached to the stable user id; reads project that id as Deleted Account.
// The physical cleanup helpers remain available only to the separate bot-
// deletion boundary, whose lifecycle semantics are intentionally different.
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)
}
@ -281,45 +277,6 @@ SELECT user_id, source, due_at FROM dedup ORDER BY due_at, user_id LIMIT $2`, no
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
}
@ -448,38 +405,6 @@ func truncateUTF8Bytes(value string, maxBytes int) string {
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 {
@ -514,10 +439,16 @@ FROM authorizations WHERE auth_key_id = $1 AND user_id = $2 FOR UPDATE`, id, use
return []domain.Authorization{a}, nil
}
func purgeDeletedAccountPrivateState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
func purgeDeletedBotPrivateState(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.
// The purge can empty the durable delivery lane. Fence concurrent appends
// before deleting its outbox/head/event facts so no committed task becomes
// undiscoverable during the account lifecycle transition.
if err := lockDispatchOutboxLanesExclusive(ctx, tx, []int64{userID}); err != nil {
return fmt.Errorf("lock deleted bot dispatch lane: %w", err)
}
statements := []string{
`DELETE FROM account_privacy_rules WHERE owner_user_id = $1`,
`DELETE FROM account_reaction_settings WHERE user_id = $1`,
@ -580,6 +511,7 @@ func purgeDeletedAccountPrivateState(ctx context.Context, tx pgx.Tx, userID int6
`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 welcome_message_deliveries WHERE target_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`,
@ -621,121 +553,3 @@ WHERE admin_user_id = $1 OR participant_user_id = $1`, userID); err != nil {
}
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
}