feat: sync account deletion lifecycle
Sync telesrv 73f5c91 (feat(account): implement unified account deletion lifecycle). Skipped telesrv docs changes per public sync rules.
This commit is contained in:
parent
96a419b565
commit
edb7057757
32 changed files with 3236 additions and 88 deletions
|
|
@ -436,7 +436,7 @@ func run(logger *zap.Logger) error {
|
|||
return fmt.Errorf("seed appearance: %w", err)
|
||||
} else if !stats.Skipped {
|
||||
logger.Info("外观种子导入完成",
|
||||
zap.String("source", "default-seed"),
|
||||
zap.String("source", "orange-live"),
|
||||
zap.Int("wallpapers", stats.Wallpapers),
|
||||
zap.Int("documents", stats.Documents),
|
||||
zap.Int("blobs", stats.Blobs),
|
||||
|
|
@ -526,6 +526,7 @@ func run(logger *zap.Logger) error {
|
|||
account.WithBusinessAutomation(passwordStore),
|
||||
account.WithUsers(userStore),
|
||||
account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts),
|
||||
account.WithAccountLifecycle(postgres.NewAccountLifecycleStore(pool)),
|
||||
account.WithPublicBaseURL(cfg.PublicBaseURL),
|
||||
}
|
||||
var webhookSender otpdelivery.Sender
|
||||
|
|
@ -871,6 +872,7 @@ func run(logger *zap.Logger) error {
|
|||
go router.RunPresenceSweeper(ctx, time.Minute)
|
||||
go activeSessions.RunPendingSweeper(ctx, time.Minute)
|
||||
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
|
||||
go router.RunAccountLifecycle(ctx, time.Minute, 500)
|
||||
go func() {
|
||||
interval := cfg.StarGiftSweepInterval
|
||||
if interval <= 0 {
|
||||
|
|
|
|||
25
deploy/migrations/0107_account_lifecycle.down.sql
Normal file
25
deploy/migrations/0107_account_lifecycle.down.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
DROP TRIGGER IF EXISTS account_settings_ttl_trigger ON public.account_settings;
|
||||
DROP FUNCTION IF EXISTS public.telesrv_account_settings_ttl_trigger();
|
||||
DROP TRIGGER IF EXISTS users_account_delete_at_trigger ON public.users;
|
||||
DROP FUNCTION IF EXISTS public.telesrv_users_account_delete_at_trigger();
|
||||
DROP FUNCTION IF EXISTS public.telesrv_account_delete_at(timestamp with time zone, bigint, integer);
|
||||
|
||||
DROP TRIGGER IF EXISTS account_passwords_changed_at_trigger ON public.account_passwords;
|
||||
DROP FUNCTION IF EXISTS public.telesrv_password_changed_at_trigger();
|
||||
ALTER TABLE public.account_passwords DROP COLUMN IF EXISTS password_changed_at;
|
||||
|
||||
ALTER TABLE public.account_settings
|
||||
DROP CONSTRAINT account_settings_account_ttl_days_check,
|
||||
ADD CONSTRAINT account_settings_account_ttl_days_check CHECK (account_ttl_days > 0);
|
||||
|
||||
DROP TABLE IF EXISTS public.account_deletion_notifications;
|
||||
DROP TABLE IF EXISTS public.account_deletion_requests;
|
||||
DROP INDEX IF EXISTS public.dialogs_user_peer_reverse_idx;
|
||||
|
||||
DROP INDEX IF EXISTS public.users_account_delete_due_idx;
|
||||
ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_deletion_state_check;
|
||||
ALTER TABLE public.users
|
||||
DROP COLUMN IF EXISTS account_delete_at,
|
||||
DROP COLUMN IF EXISTS deletion_reason,
|
||||
DROP COLUMN IF EXISTS deletion_source,
|
||||
DROP COLUMN IF EXISTS deleted_at;
|
||||
216
deploy/migrations/0107_account_lifecycle.up.sql
Normal file
216
deploy/migrations/0107_account_lifecycle.up.sql
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
-- Unified account deletion lifecycle. A deleted account remains as a minimal
|
||||
-- user tombstone so historical messages keep a stable sender id, while all
|
||||
-- reusable identity and profile fields are released atomically.
|
||||
ALTER TABLE public.users
|
||||
ADD COLUMN deleted_at timestamp with time zone,
|
||||
ADD COLUMN deletion_source text DEFAULT '' NOT NULL,
|
||||
ADD COLUMN deletion_reason text DEFAULT '' NOT NULL,
|
||||
ADD COLUMN account_delete_at timestamp with time zone;
|
||||
|
||||
ALTER TABLE public.account_passwords
|
||||
ADD COLUMN password_changed_at timestamp with time zone;
|
||||
|
||||
ALTER TABLE public.account_settings
|
||||
DROP CONSTRAINT account_settings_account_ttl_days_check,
|
||||
ADD CONSTRAINT account_settings_account_ttl_days_check
|
||||
CHECK (account_ttl_days BETWEEN 1 AND 3650);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.telesrv_password_changed_at_trigger()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
NEW.password_changed_at := CASE WHEN NEW.has_password THEN now() ELSE NULL END;
|
||||
ELSIF NEW.has_password IS DISTINCT FROM OLD.has_password
|
||||
OR NEW.srp_verifier IS DISTINCT FROM OLD.srp_verifier
|
||||
OR NEW.current_algo_salt1 IS DISTINCT FROM OLD.current_algo_salt1
|
||||
OR NEW.current_algo_salt2 IS DISTINCT FROM OLD.current_algo_salt2
|
||||
OR NEW.current_algo_g IS DISTINCT FROM OLD.current_algo_g
|
||||
OR NEW.current_algo_p IS DISTINCT FROM OLD.current_algo_p THEN
|
||||
NEW.password_changed_at := CASE WHEN NEW.has_password THEN now() ELSE NULL END;
|
||||
ELSE
|
||||
NEW.password_changed_at := OLD.password_changed_at;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
|
||||
UPDATE public.account_passwords
|
||||
SET password_changed_at = updated_at
|
||||
WHERE has_password = true AND password_changed_at IS NULL;
|
||||
|
||||
CREATE TRIGGER account_passwords_changed_at_trigger
|
||||
BEFORE INSERT OR UPDATE ON public.account_passwords
|
||||
FOR EACH ROW EXECUTE FUNCTION public.telesrv_password_changed_at_trigger();
|
||||
|
||||
ALTER TABLE public.users
|
||||
ADD CONSTRAINT users_deletion_state_check CHECK (
|
||||
(deleted_at IS NULL AND deletion_source = '' AND deletion_reason = '')
|
||||
OR
|
||||
(deleted_at IS NOT NULL
|
||||
AND deletion_source IN (
|
||||
'manual',
|
||||
'forgot_password',
|
||||
'tos_decline',
|
||||
'password_reset_expiry',
|
||||
'account_ttl',
|
||||
'freeze_expiry'
|
||||
)
|
||||
AND account_delete_at IS NULL
|
||||
AND phone = ''
|
||||
AND first_name = ''
|
||||
AND last_name = ''
|
||||
AND username = ''
|
||||
AND country_code = ''
|
||||
AND about = ''
|
||||
AND verified = false
|
||||
AND support = false
|
||||
AND premium_expires_at IS NULL
|
||||
AND emoji_status_document_id = 0
|
||||
AND emoji_status_until = 0
|
||||
AND color_set = false
|
||||
AND color = 0
|
||||
AND color_background_emoji_id = 0
|
||||
AND profile_color_set = false
|
||||
AND profile_color = 0
|
||||
AND profile_color_background_emoji_id = 0
|
||||
AND birthday_day = 0
|
||||
AND birthday_month = 0
|
||||
AND birthday_year = 0
|
||||
AND personal_channel_id = 0
|
||||
AND last_seen_at = 0
|
||||
AND octet_length(deletion_reason) <= 1024)
|
||||
);
|
||||
|
||||
CREATE INDEX users_account_delete_due_idx
|
||||
ON public.users (account_delete_at, id)
|
||||
WHERE deleted_at IS NULL AND is_bot = false AND account_delete_at IS NOT NULL;
|
||||
|
||||
CREATE TABLE public.account_deletion_requests (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id bigint NOT NULL REFERENCES public.users(id),
|
||||
requester_auth_key_id bigint NOT NULL,
|
||||
state text DEFAULT 'pending' NOT NULL,
|
||||
reason text DEFAULT '' NOT NULL,
|
||||
confirm_hash_digest bytea NOT NULL,
|
||||
requested_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
execute_at timestamp with time zone NOT NULL,
|
||||
completed_at timestamp with time zone,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT account_deletion_requests_state_check CHECK (
|
||||
(state = 'pending' AND completed_at IS NULL)
|
||||
OR (state IN ('cancelled', 'executed') AND completed_at IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT account_deletion_requests_reason_check CHECK (octet_length(reason) <= 1024),
|
||||
CONSTRAINT account_deletion_requests_hash_check CHECK (octet_length(confirm_hash_digest) = 32)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX account_deletion_requests_one_pending_user_idx
|
||||
ON public.account_deletion_requests(user_id) WHERE state = 'pending';
|
||||
CREATE UNIQUE INDEX account_deletion_requests_pending_hash_idx
|
||||
ON public.account_deletion_requests(confirm_hash_digest) WHERE state = 'pending';
|
||||
CREATE INDEX account_deletion_requests_due_idx
|
||||
ON public.account_deletion_requests(execute_at, id) WHERE state = 'pending';
|
||||
|
||||
-- updateUser is not a pts-bearing update. Keep a dedicated durable online-nudge
|
||||
-- queue so a crash between tombstone commit and best-effort fan-out is recovered.
|
||||
-- Offline clients converge from authoritative dialog/history hydration instead
|
||||
-- of an immortal retry queue or invented user pts events.
|
||||
CREATE TABLE public.account_deletion_notifications (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
target_user_id bigint NOT NULL REFERENCES public.users(id),
|
||||
deleted_user_id bigint NOT NULL REFERENCES public.users(id),
|
||||
status text DEFAULT 'pending' NOT NULL,
|
||||
attempts integer DEFAULT 0 NOT NULL,
|
||||
next_attempt_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
lease_until timestamp with time zone,
|
||||
last_error text DEFAULT '' NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT account_deletion_notifications_status_check
|
||||
CHECK (status IN ('pending', 'dispatching', 'delivered')),
|
||||
CONSTRAINT account_deletion_notifications_attempts_check CHECK (attempts >= 0),
|
||||
CONSTRAINT account_deletion_notifications_not_self_check CHECK (target_user_id <> deleted_user_id),
|
||||
UNIQUE (target_user_id, deleted_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX account_deletion_notifications_ready_idx
|
||||
ON public.account_deletion_notifications(next_attempt_at, id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- Account deletion discovers peers that have an inbound dialog row pointing at
|
||||
-- the deleted user. The ordinary dialog PK only covers the owner direction;
|
||||
-- keep the reverse lookup indexed so tombstoning one account never scans every
|
||||
-- user's dialog table.
|
||||
CREATE INDEX dialogs_user_peer_reverse_idx
|
||||
ON public.dialogs(peer_id, user_id)
|
||||
WHERE peer_type = 'user';
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.telesrv_account_delete_at(
|
||||
p_created_at timestamp with time zone,
|
||||
p_last_seen_at bigint,
|
||||
p_ttl_days integer
|
||||
) RETURNS timestamp with time zone
|
||||
LANGUAGE sql IMMUTABLE AS $$
|
||||
SELECT GREATEST(
|
||||
p_created_at,
|
||||
CASE WHEN p_last_seen_at > 0 THEN to_timestamp(p_last_seen_at) ELSE p_created_at END
|
||||
) + make_interval(days => p_ttl_days)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.telesrv_users_account_delete_at_trigger()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_ttl_days integer;
|
||||
BEGIN
|
||||
IF NEW.deleted_at IS NOT NULL OR NEW.is_bot THEN
|
||||
NEW.account_delete_at := NULL;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
SELECT account_ttl_days INTO v_ttl_days
|
||||
FROM public.account_settings WHERE user_id = NEW.id;
|
||||
NEW.account_delete_at := public.telesrv_account_delete_at(
|
||||
NEW.created_at,
|
||||
NEW.last_seen_at,
|
||||
COALESCE(v_ttl_days, 365)
|
||||
);
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER users_account_delete_at_trigger
|
||||
BEFORE INSERT OR UPDATE OF last_seen_at, deleted_at, is_bot
|
||||
ON public.users
|
||||
FOR EACH ROW EXECUTE FUNCTION public.telesrv_users_account_delete_at_trigger();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.telesrv_account_settings_ttl_trigger()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_user_id bigint;
|
||||
v_ttl_days integer;
|
||||
BEGIN
|
||||
v_user_id := COALESCE(NEW.user_id, OLD.user_id);
|
||||
v_ttl_days := CASE WHEN TG_OP = 'DELETE' THEN 365 ELSE NEW.account_ttl_days END;
|
||||
UPDATE public.users
|
||||
SET account_delete_at = public.telesrv_account_delete_at(created_at, last_seen_at, v_ttl_days),
|
||||
updated_at = now()
|
||||
WHERE id = v_user_id AND deleted_at IS NULL AND is_bot = false;
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER account_settings_ttl_trigger
|
||||
AFTER INSERT OR UPDATE OF account_ttl_days OR DELETE
|
||||
ON public.account_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION public.telesrv_account_settings_ttl_trigger();
|
||||
|
||||
UPDATE public.users u
|
||||
SET account_delete_at = public.telesrv_account_delete_at(
|
||||
u.created_at,
|
||||
u.last_seen_at,
|
||||
COALESCE((
|
||||
SELECT s.account_ttl_days
|
||||
FROM public.account_settings s
|
||||
WHERE s.user_id = u.id
|
||||
), 365)
|
||||
)
|
||||
WHERE u.deleted_at IS NULL AND u.is_bot = false;
|
||||
360
internal/app/account/lifecycle.go
Normal file
360
internal/app/account/lifecycle.go
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/otpdelivery"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const accountDeletionDelay = 7 * 24 * time.Hour
|
||||
|
||||
// DeleteAccount implements the official 2FA deletion decision. A supplied and
|
||||
// valid SRP proof always deletes immediately. Without a proof, an account whose
|
||||
// password is older than seven days and which was active during the last seven
|
||||
// days gets a cancellable seven-day window; all other cases delete immediately.
|
||||
func (s *Service) DeleteAccount(ctx context.Context, userID int64, authKeyID [8]byte, reason string, password *domain.PasswordCheck, now time.Time) (domain.AccountDeleteOutcome, error) {
|
||||
if s == nil || s.lifecycle == nil || userID == 0 || authKeyID == ([8]byte{}) {
|
||||
return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if len(reason) > 1024 {
|
||||
return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden
|
||||
}
|
||||
snapshot, found, err := s.lifecycle.AccountDeletionSnapshot(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.AccountDeleteOutcome{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.AccountDeleteOutcome{}, domain.ErrUserNotFound
|
||||
}
|
||||
if snapshot.User.Deleted {
|
||||
return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteImmediate, Deletion: domain.AccountDeletionResult{User: snapshot.User}}, nil
|
||||
}
|
||||
if snapshot.User.Bot || domain.IsSystemUserID(snapshot.User.ID) {
|
||||
return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden
|
||||
}
|
||||
if password != nil && !password.Empty {
|
||||
if !snapshot.HasPassword {
|
||||
return domain.AccountDeleteOutcome{}, domain.ErrPasswordHashInvalid
|
||||
}
|
||||
if err := s.CheckPassword(ctx, userID, *password); err != nil {
|
||||
return domain.AccountDeleteOutcome{}, err
|
||||
}
|
||||
return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now)
|
||||
}
|
||||
if !snapshot.HasPassword {
|
||||
return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now)
|
||||
}
|
||||
lastActive := snapshot.User.CreatedAt
|
||||
if snapshot.User.LastSeenAt > 0 {
|
||||
seen := time.Unix(int64(snapshot.User.LastSeenAt), 0).UTC()
|
||||
if seen.After(lastActive) {
|
||||
lastActive = seen
|
||||
}
|
||||
}
|
||||
passwordOldEnough := !snapshot.PasswordUpdatedAt.IsZero() && !snapshot.PasswordUpdatedAt.After(now.Add(-accountDeletionDelay))
|
||||
recentlyActive := !lastActive.IsZero() && !lastActive.Before(now.Add(-accountDeletionDelay))
|
||||
if !passwordOldEnough || !recentlyActive {
|
||||
return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now)
|
||||
}
|
||||
if snapshot.Pending != nil {
|
||||
return delayedDeleteOutcome(*snapshot.Pending, now), nil
|
||||
}
|
||||
rawToken, digest, err := newAccountDeletionToken()
|
||||
if err != nil {
|
||||
return domain.AccountDeleteOutcome{}, err
|
||||
}
|
||||
executeAt := now.Add(accountDeletionDelay)
|
||||
message := fmt.Sprintf(
|
||||
"A request was made to delete your Telegram account. If this wasn't you, cancel the request: tg://confirmphone?phone=%s&hash=%s",
|
||||
url.QueryEscape(snapshot.User.Phone), url.QueryEscape(rawToken),
|
||||
)
|
||||
pending, _, err := s.lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{
|
||||
UserID: userID,
|
||||
RequesterAuthKeyID: authKeyID,
|
||||
Reason: reason,
|
||||
ConfirmHashDigest: digest,
|
||||
ServiceMessage: message,
|
||||
RequestedAt: now,
|
||||
ExecuteAt: executeAt,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.AccountDeleteOutcome{}, err
|
||||
}
|
||||
return delayedDeleteOutcome(pending, now), nil
|
||||
}
|
||||
|
||||
func (s *Service) executeAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeleteOutcome, error) {
|
||||
result, err := s.lifecycle.ExecuteAccountDeletion(ctx, userID, source, reason, now)
|
||||
if err != nil {
|
||||
return domain.AccountDeleteOutcome{}, err
|
||||
}
|
||||
if s.userCache != nil {
|
||||
_ = s.userCache.Delete(ctx, []int64{userID})
|
||||
}
|
||||
return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteImmediate, Deletion: result}, nil
|
||||
}
|
||||
|
||||
func delayedDeleteOutcome(pending domain.AccountDeletionRequest, now time.Time) domain.AccountDeleteOutcome {
|
||||
wait := int(time.Until(pending.ExecuteAt).Seconds())
|
||||
if !now.IsZero() {
|
||||
wait = int(pending.ExecuteAt.Sub(now).Seconds())
|
||||
}
|
||||
if wait < 0 {
|
||||
wait = 0
|
||||
}
|
||||
return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteDelayed, WaitSeconds: wait, ExecuteAt: pending.ExecuteAt}
|
||||
}
|
||||
|
||||
func deletionSourceForReason(reason string) domain.AccountDeletionSource {
|
||||
switch strings.ToLower(strings.TrimSpace(reason)) {
|
||||
case "forgot password":
|
||||
return domain.AccountDeletionForgotPassword
|
||||
case "decline tos update":
|
||||
return domain.AccountDeletionTOSDecline
|
||||
default:
|
||||
return domain.AccountDeletionManual
|
||||
}
|
||||
}
|
||||
|
||||
func newAccountDeletionToken() (string, [32]byte, error) {
|
||||
var raw [32]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "", [32]byte{}, fmt.Errorf("generate account deletion token: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(raw[:])
|
||||
return token, sha256.Sum256([]byte(token)), nil
|
||||
}
|
||||
|
||||
func accountDeletionDigest(raw string) ([32]byte, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
decoded, err := hex.DecodeString(raw)
|
||||
if err != nil || len(decoded) != 32 {
|
||||
return [32]byte{}, domain.ErrAccountDeletionHashInvalid
|
||||
}
|
||||
return sha256.Sum256([]byte(raw)), nil
|
||||
}
|
||||
|
||||
// SendConfirmPhoneCode validates the secret confirmphone link and issues an
|
||||
// auth-key-scoped SMS code to the account's current phone.
|
||||
func (s *Service) SendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, rawHash string) (string, domain.AuthCodeDelivery, error) {
|
||||
digest, err := accountDeletionDigest(rawHash)
|
||||
if err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
if s == nil || s.lifecycle == nil || s.users == nil || s.codes == nil || userID == 0 || authKeyID == ([8]byte{}) {
|
||||
return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid
|
||||
}
|
||||
if _, found, err := s.lifecycle.PendingAccountDeletionByHash(ctx, userID, digest); err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
} else if !found {
|
||||
return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
if !found || u.Deleted || u.Phone == "" {
|
||||
return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid
|
||||
}
|
||||
return s.issueConfirmPhoneCode(ctx, userID, authKeyID, sessionID, u.Phone, digest)
|
||||
}
|
||||
|
||||
func (s *Service) issueConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone string, digest [32]byte) (string, domain.AuthCodeDelivery, error) {
|
||||
hash, err := phoneChangeHash()
|
||||
if err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
code := s.phoneChangeCode
|
||||
channel := store.PhoneCodeChannelPhone
|
||||
deliveryID := ""
|
||||
if s.phoneCodeSender != nil {
|
||||
code, err = randomDigits(s.phoneCodeLength)
|
||||
if err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
deliveryID, err = otpdelivery.NewDeliveryID()
|
||||
if err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
channel = store.PhoneCodeChannelSMS
|
||||
}
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return "", domain.AuthCodeDelivery{}, fmt.Errorf("confirm phone code service is not configured")
|
||||
}
|
||||
rec := store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
Phone: phone,
|
||||
Code: code,
|
||||
DeliveryID: deliveryID,
|
||||
Channel: channel,
|
||||
Purpose: store.PhoneCodePurposeConfirmPhone,
|
||||
UserID: userID,
|
||||
AuthKeyID: authKeyID,
|
||||
SessionID: sessionID,
|
||||
MaxAttempts: s.phoneChangeMaxAttempts,
|
||||
AccountDeletionHash: hex.EncodeToString(digest[:]),
|
||||
}
|
||||
expiresAt := time.Now().Add(s.phoneChangeCodeTTL)
|
||||
if err := s.codes.Set(ctx, hash, rec, s.phoneChangeCodeTTL); err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, fmt.Errorf("store confirm phone code: %w", err)
|
||||
}
|
||||
if s.phoneCodeSender != nil {
|
||||
if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{
|
||||
DeliveryID: deliveryID,
|
||||
Purpose: otpdelivery.PurposeConfirmPhone,
|
||||
Channel: otpdelivery.ChannelSMS,
|
||||
Recipient: phone,
|
||||
Code: code,
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
||||
defer cancel()
|
||||
if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil {
|
||||
return "", domain.AuthCodeDelivery{}, errors.Join(err, cleanupErr)
|
||||
}
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
}
|
||||
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(code)}, nil
|
||||
}
|
||||
|
||||
// ConfirmPhone consumes the scoped OTP, cancels the pending deletion and
|
||||
// revokes the auth key that initiated the deletion attempt.
|
||||
func (s *Service) ConfirmPhone(ctx context.Context, userID int64, authKeyID [8]byte, phoneCodeHash, code string, now time.Time) ([]domain.Authorization, error) {
|
||||
if strings.TrimSpace(phoneCodeHash) == "" || strings.TrimSpace(code) == "" {
|
||||
return nil, domain.ErrPhoneCodeEmpty
|
||||
}
|
||||
if s == nil || s.codes == nil || s.lifecycle == nil || s.users == nil {
|
||||
return nil, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found || u.Deleted || u.Phone == "" {
|
||||
return nil, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
scope := store.PhoneCodeScope{Purpose: store.PhoneCodePurposeConfirmPhone, UserID: userID, AuthKeyID: authKeyID, Phone: u.Phone}
|
||||
verified, err := s.codes.VerifyScoped(ctx, phoneCodeHash, scope, strings.TrimSpace(code), s.phoneChangeMaxAttempts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch verified.Status {
|
||||
case store.LoginCodeVerifyMissing:
|
||||
return nil, domain.ErrPhoneCodeExpired
|
||||
case store.LoginCodeVerifyInvalid:
|
||||
return nil, domain.ErrPhoneCodeInvalid
|
||||
case store.LoginCodeVerifyAccepted:
|
||||
default:
|
||||
return nil, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
digestBytes, err := hex.DecodeString(verified.Record.AccountDeletionHash)
|
||||
if err != nil || len(digestBytes) != 32 {
|
||||
return nil, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
var digest [32]byte
|
||||
copy(digest[:], digestBytes)
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
return s.lifecycle.CancelAccountDeletion(ctx, userID, digest, now)
|
||||
}
|
||||
|
||||
// ResendConfirmPhoneCode handles auth.resendCode only when the supplied hash is
|
||||
// an active confirm-phone code for this authorized user/auth key.
|
||||
func (s *Service) ResendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, oldHash string) (string, domain.AuthCodeDelivery, bool, error) {
|
||||
if s == nil || s.codes == nil || s.users == nil || userID == 0 {
|
||||
return "", domain.AuthCodeDelivery{}, false, nil
|
||||
}
|
||||
rec, found, err := s.codes.Get(ctx, oldHash)
|
||||
if err != nil || !found || rec.Purpose != store.PhoneCodePurposeConfirmPhone || rec.UserID != userID || rec.AuthKeyID != authKeyID {
|
||||
return "", domain.AuthCodeDelivery{}, false, err
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil || !found || u.Deleted || domain.NormalizePhone(phone) != domain.NormalizePhone(u.Phone) {
|
||||
if err == nil {
|
||||
err = domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
return "", domain.AuthCodeDelivery{}, true, err
|
||||
}
|
||||
consumed, found, err := s.codes.ConsumeScoped(ctx, oldHash, rec.Scope())
|
||||
if err != nil || !found {
|
||||
if err == nil {
|
||||
err = domain.ErrPhoneCodeExpired
|
||||
}
|
||||
return "", domain.AuthCodeDelivery{}, true, err
|
||||
}
|
||||
digestBytes, err := hex.DecodeString(consumed.AccountDeletionHash)
|
||||
if err != nil || len(digestBytes) != 32 {
|
||||
return "", domain.AuthCodeDelivery{}, true, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
var digest [32]byte
|
||||
copy(digest[:], digestBytes)
|
||||
hash, delivery, err := s.issueConfirmPhoneCode(ctx, userID, authKeyID, sessionID, u.Phone, digest)
|
||||
return hash, delivery, true, err
|
||||
}
|
||||
|
||||
func (s *Service) CancelConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, phone, hash string) (bool, error) {
|
||||
if s == nil || s.codes == nil || userID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
rec, found, err := s.codes.Get(ctx, hash)
|
||||
if err != nil || !found || rec.Purpose != store.PhoneCodePurposeConfirmPhone || rec.UserID != userID || rec.AuthKeyID != authKeyID {
|
||||
return false, err
|
||||
}
|
||||
if domain.NormalizePhone(phone) != domain.NormalizePhone(rec.Phone) {
|
||||
return true, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
_, _, err = s.codes.ConsumeScoped(ctx, hash, rec.Scope())
|
||||
return true, err
|
||||
}
|
||||
|
||||
func (s *Service) SweepDueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionResult, error) {
|
||||
if s == nil || s.lifecycle == nil || limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
candidates, err := s.lifecycle.DueAccountDeletions(ctx, now, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]domain.AccountDeletionResult, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
result, err := s.lifecycle.ExecuteAccountDeletion(ctx, candidate.UserID, candidate.Source, "", now)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if s.userCache != nil {
|
||||
_ = s.userCache.Delete(ctx, []int64{candidate.UserID})
|
||||
}
|
||||
out = append(out, result)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.lifecycle.ClaimAccountDeletionNotifications(ctx, now, limit, lease)
|
||||
}
|
||||
|
||||
func (s *Service) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return nil
|
||||
}
|
||||
return s.lifecycle.CompleteAccountDeletionNotification(ctx, id, now)
|
||||
}
|
||||
149
internal/app/account/lifecycle_test.go
Normal file
149
internal/app/account/lifecycle_test.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestDeleteAccountTwoFADelayDecisionMatrix(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
authKey := [8]byte{1}
|
||||
tests := []struct {
|
||||
name string
|
||||
hasPassword bool
|
||||
passwordUpdated time.Time
|
||||
createdAt time.Time
|
||||
lastSeen int
|
||||
wantKind domain.AccountDeleteKind
|
||||
}{
|
||||
{name: "no password deletes immediately", createdAt: now.Add(-time.Hour), lastSeen: int(now.Unix()), wantKind: domain.AccountDeleteImmediate},
|
||||
{name: "old password and recent activity delays", hasPassword: true, passwordUpdated: now.Add(-8 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-time.Hour).Unix()), wantKind: domain.AccountDeleteDelayed},
|
||||
{name: "recent password change deletes immediately", hasPassword: true, passwordUpdated: now.Add(-2 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-time.Hour).Unix()), wantKind: domain.AccountDeleteImmediate},
|
||||
{name: "inactive account deletes immediately", hasPassword: true, passwordUpdated: now.Add(-30 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-8 * 24 * time.Hour).Unix()), wantKind: domain.AccountDeleteImmediate},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
lifecycle := &fakeAccountLifecycleStore{snapshot: domain.AccountDeletionSnapshot{
|
||||
User: domain.User{ID: 42, Phone: "15550010000", CreatedAt: test.createdAt, LastSeenAt: test.lastSeen},
|
||||
HasPassword: test.hasPassword, PasswordUpdatedAt: test.passwordUpdated,
|
||||
}}
|
||||
svc := NewService(memory.NewPasswordStore(), WithAccountLifecycle(lifecycle))
|
||||
outcome, err := svc.DeleteAccount(context.Background(), 42, authKey, "manual", nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteAccount: %v", err)
|
||||
}
|
||||
if outcome.Kind != test.wantKind {
|
||||
t.Fatalf("kind = %q, want %q", outcome.Kind, test.wantKind)
|
||||
}
|
||||
if test.wantKind == domain.AccountDeleteDelayed {
|
||||
if lifecycle.scheduled == nil || !strings.Contains(lifecycle.scheduled.ServiceMessage, "tg://confirmphone?") || outcome.WaitSeconds != int(accountDeletionDelay.Seconds()) {
|
||||
t.Fatalf("delayed outcome=%+v scheduled=%+v", outcome, lifecycle.scheduled)
|
||||
}
|
||||
} else if lifecycle.executedSource == "" {
|
||||
t.Fatal("immediate path did not execute the tombstone boundary")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmPhoneCancelsPendingDeletionAndRevokesRequester(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
users := memory.NewUserStore()
|
||||
u, err := users.Create(ctx, domain.User{Phone: "15550010001", FirstName: "Alice"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requester := [8]byte{9}
|
||||
confirming := [8]byte{8}
|
||||
lifecycle := &fakeAccountLifecycleStore{snapshot: domain.AccountDeletionSnapshot{User: u}}
|
||||
svc := NewService(memory.NewPasswordStore(),
|
||||
WithUsers(users),
|
||||
WithPhoneChange(nil, nil, memory.NewCodeStore(), nil, "12345", 5*time.Minute, 5),
|
||||
WithAccountLifecycle(lifecycle),
|
||||
)
|
||||
rawToken, digest, err := newAccountDeletionToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lifecycle.pending = &domain.AccountDeletionRequest{
|
||||
ID: 1, UserID: u.ID, RequesterAuthKeyID: requester, State: domain.AccountDeletionPending,
|
||||
ConfirmHashDigest: digest, RequestedAt: now, ExecuteAt: now.Add(accountDeletionDelay),
|
||||
}
|
||||
hash, delivery, err := svc.SendConfirmPhoneCode(ctx, u.ID, confirming, 77, rawToken)
|
||||
if err != nil || hash == "" || delivery.Length != 5 {
|
||||
t.Fatalf("SendConfirmPhoneCode hash=%q delivery=%+v err=%v", hash, delivery, err)
|
||||
}
|
||||
revoked, err := svc.ConfirmPhone(ctx, u.ID, confirming, hash, "12345", now.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("ConfirmPhone: %v", err)
|
||||
}
|
||||
if len(revoked) != 1 || revoked[0].AuthKeyID != requester || lifecycle.pending != nil {
|
||||
t.Fatalf("revoked=%+v pending=%+v", revoked, lifecycle.pending)
|
||||
}
|
||||
if _, err := svc.ConfirmPhone(ctx, u.ID, confirming, hash, "12345", now.Add(2*time.Minute)); !errors.Is(err, domain.ErrPhoneCodeExpired) {
|
||||
t.Fatalf("replay error = %v, want expired", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAccountLifecycleStore struct {
|
||||
snapshot domain.AccountDeletionSnapshot
|
||||
pending *domain.AccountDeletionRequest
|
||||
scheduled *domain.ScheduleAccountDeletion
|
||||
executedSource domain.AccountDeletionSource
|
||||
}
|
||||
|
||||
func (f *fakeAccountLifecycleStore) AccountDeletionSnapshot(context.Context, int64) (domain.AccountDeletionSnapshot, bool, error) {
|
||||
f.snapshot.Pending = f.pending
|
||||
return f.snapshot, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountLifecycleStore) ScheduleAccountDeletion(_ context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error) {
|
||||
f.scheduled = &req
|
||||
pending := domain.AccountDeletionRequest{ID: 1, UserID: req.UserID, RequesterAuthKeyID: req.RequesterAuthKeyID, State: domain.AccountDeletionPending, Reason: req.Reason, ConfirmHashDigest: req.ConfirmHashDigest, RequestedAt: req.RequestedAt, ExecuteAt: req.ExecuteAt}
|
||||
f.pending = &pending
|
||||
return pending, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountLifecycleStore) PendingAccountDeletionByHash(_ context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error) {
|
||||
if f.pending == nil || f.pending.UserID != userID || f.pending.ConfirmHashDigest != digest {
|
||||
return domain.AccountDeletionRequest{}, false, nil
|
||||
}
|
||||
return *f.pending, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountLifecycleStore) ExecuteAccountDeletion(_ context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error) {
|
||||
f.executedSource = source
|
||||
u := f.snapshot.User
|
||||
u.Deleted = true
|
||||
u.DeletedAt = now.Unix()
|
||||
u.DeletionSource = source
|
||||
u.DeletionReason = reason
|
||||
u = u.DeletedTombstone()
|
||||
return domain.AccountDeletionResult{User: u, Changed: true}, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountLifecycleStore) CancelAccountDeletion(_ context.Context, userID int64, digest [32]byte, _ time.Time) ([]domain.Authorization, error) {
|
||||
if f.pending == nil || f.pending.UserID != userID || f.pending.ConfirmHashDigest != digest {
|
||||
return nil, domain.ErrAccountDeletionHashInvalid
|
||||
}
|
||||
revoked := []domain.Authorization{{AuthKeyID: f.pending.RequesterAuthKeyID, UserID: userID}}
|
||||
f.pending = nil
|
||||
return revoked, nil
|
||||
}
|
||||
|
||||
func (*fakeAccountLifecycleStore) DueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionCandidate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*fakeAccountLifecycleStore) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*fakeAccountLifecycleStore) CompleteAccountDeletionNotification(context.Context, int64, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ type Service struct {
|
|||
userCache store.UserCache
|
||||
authorizations store.AuthorizationStore
|
||||
phoneChanges store.PhoneChangeStore
|
||||
lifecycle store.AccountLifecycleStore
|
||||
publicBaseURL string
|
||||
codes store.CodeStore
|
||||
phoneChangeCode string
|
||||
|
|
@ -166,6 +167,14 @@ func WithPhoneCodeDelivery(sender otpdelivery.Sender, length int) ServiceOption
|
|||
}
|
||||
}
|
||||
|
||||
// WithAccountLifecycle installs the single durable account deletion boundary.
|
||||
// It shares the already configured phone-code delivery and user cache.
|
||||
func WithAccountLifecycle(lifecycle store.AccountLifecycleStore) ServiceOption {
|
||||
return func(s *Service) {
|
||||
s.lifecycle = lifecycle
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 account 服务。
|
||||
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
|
||||
s := &Service{
|
||||
|
|
|
|||
19
internal/app/userprojection/deleted_test.go
Normal file
19
internal/app/userprojection/deleted_test.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package userprojection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestDeletedUserProjectionCannotReintroducePII(t *testing.T) {
|
||||
in := domain.User{ID: 42, AccessHash: 99, Deleted: true, Phone: "stale", FirstName: "Stale", PhotoID: 123, Contact: true}
|
||||
got, err := New().One(context.Background(), 7, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Deleted || got.ID != 42 || got.Phone != "" || got.FirstName != "" || got.PhotoID != 0 || got.Contact {
|
||||
t.Fatalf("deleted projection leaked PII: %+v", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@ func New(opts ...Option) *Projector {
|
|||
|
||||
// ForViewer applies both current profile photos and owner-specific contact view.
|
||||
func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||
users = sanitizeDeletedUsers(users)
|
||||
if p == nil {
|
||||
return users, nil
|
||||
}
|
||||
|
|
@ -112,6 +113,7 @@ func (p *Projector) One(ctx context.Context, viewerUserID int64, user domain.Use
|
|||
// (无 O(owner) 反查接口),客户端下次 getChannelDifference/getHistory 会走 projectBatch 完整投影自愈。
|
||||
// 调用方传入的 users 不被修改(内部复制)。
|
||||
func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users []domain.User) (map[int64][]domain.User, error) {
|
||||
users = sanitizeDeletedUsers(users)
|
||||
out := make(map[int64][]domain.User, len(viewerUserIDs))
|
||||
if p == nil || len(users) == 0 {
|
||||
for _, v := range viewerUserIDs {
|
||||
|
|
@ -170,6 +172,10 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
if u.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if u.Deleted {
|
||||
projected[i] = u.DeletedTombstone()
|
||||
continue
|
||||
}
|
||||
if pj, ok := cache[u.ID]; ok {
|
||||
projected[i] = pj
|
||||
continue
|
||||
|
|
@ -276,13 +282,14 @@ func dedupNonZeroInt64(ids []int64) []int64 {
|
|||
// WithProfilePhotos enriches users with their current avatar from profile photo storage.
|
||||
// The lookup is best-effort: a storage error keeps the original user list.
|
||||
func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users []domain.User) []domain.User {
|
||||
users = sanitizeDeletedUsers(users)
|
||||
if photos == nil || len(users) == 0 {
|
||||
return users
|
||||
}
|
||||
ids := make([]int64, 0, len(users))
|
||||
seen := make(map[int64]struct{}, len(users))
|
||||
for _, u := range users {
|
||||
if u.ID == 0 {
|
||||
if u.ID == 0 || u.Deleted {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[u.ID]; ok {
|
||||
|
|
@ -312,6 +319,7 @@ func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users [
|
|||
// In particular, phone is visible for self and contacts; non-contacts should not
|
||||
// receive a phone field because TDesktop will prefer it over the public name.
|
||||
func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||
users = sanitizeDeletedUsers(users)
|
||||
if contacts == nil || viewerUserID == 0 || len(users) == 0 {
|
||||
return users, nil
|
||||
}
|
||||
|
|
@ -320,7 +328,7 @@ func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID in
|
|||
cache := make(map[int64]domain.User, len(users))
|
||||
for i := range out {
|
||||
u := out[i]
|
||||
if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
|
||||
if u.ID == 0 || u.Deleted || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
|
||||
continue
|
||||
}
|
||||
if projected, ok := cache[u.ID]; ok {
|
||||
|
|
@ -352,6 +360,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
}
|
||||
out := make([]domain.User, len(users))
|
||||
copy(out, users)
|
||||
out = sanitizeDeletedUsers(out)
|
||||
ids := uniqueUserIDs(out)
|
||||
var (
|
||||
profileRefs = map[int64]domain.ProfilePhotoRef{}
|
||||
|
|
@ -430,6 +439,10 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
if u.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if u.Deleted {
|
||||
out[i] = u.DeletedTombstone()
|
||||
continue
|
||||
}
|
||||
if projected, ok := cache[u.ID]; ok {
|
||||
out[i] = projected
|
||||
continue
|
||||
|
|
@ -463,7 +476,7 @@ func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, vi
|
|||
ids := make([]int64, 0, len(users))
|
||||
seen := make(map[int64]struct{}, len(users))
|
||||
for _, u := range users {
|
||||
if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
|
||||
if u.ID == 0 || u.Deleted || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[u.ID]; ok {
|
||||
|
|
@ -479,6 +492,9 @@ func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, vi
|
|||
}
|
||||
|
||||
func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID int64, user domain.User) (domain.User, error) {
|
||||
if user.Deleted {
|
||||
return user.DeletedTombstone(), nil
|
||||
}
|
||||
contact, found, err := contacts.Get(ctx, viewerUserID, user.ID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
|
|
@ -513,7 +529,7 @@ func uniqueUserIDs(users []domain.User) []int64 {
|
|||
seen := make(map[int64]struct{}, len(users))
|
||||
ids := make([]int64, 0, len(users))
|
||||
for _, user := range users {
|
||||
if user.ID == 0 {
|
||||
if user.ID == 0 || user.Deleted {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[user.ID]; ok {
|
||||
|
|
@ -526,6 +542,9 @@ func uniqueUserIDs(users []domain.User) []int64 {
|
|||
}
|
||||
|
||||
func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef, viewerUserID int64) domain.User {
|
||||
if user.Deleted {
|
||||
return user.DeletedTombstone()
|
||||
}
|
||||
if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) {
|
||||
return user
|
||||
}
|
||||
|
|
@ -548,6 +567,9 @@ func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs m
|
|||
}
|
||||
|
||||
func applyContactProjection(user domain.User, contact domain.Contact, found bool) domain.User {
|
||||
if user.Deleted {
|
||||
return user.DeletedTombstone()
|
||||
}
|
||||
if !found {
|
||||
user.Phone = ""
|
||||
user.Contact = false
|
||||
|
|
@ -574,6 +596,9 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
|
|||
}
|
||||
|
||||
func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) {
|
||||
if user.Deleted {
|
||||
return user.DeletedTombstone(), nil
|
||||
}
|
||||
if privacy == nil {
|
||||
return user, nil
|
||||
}
|
||||
|
|
@ -647,3 +672,21 @@ func clearPhoto(user *domain.User) {
|
|||
user.PhotoPersonal = false
|
||||
user.PhotoHasVideo = false
|
||||
}
|
||||
|
||||
func sanitizeDeletedUsers(users []domain.User) []domain.User {
|
||||
var out []domain.User
|
||||
for i, user := range users {
|
||||
if !user.Deleted {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = make([]domain.User, len(users))
|
||||
copy(out, users)
|
||||
}
|
||||
out[i] = user.DeletedTombstone()
|
||||
}
|
||||
if out != nil {
|
||||
return out
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,7 +174,12 @@ func DefaultAccountReactionSettings() AccountReactionSettings {
|
|||
}
|
||||
|
||||
// DefaultAccountTTLDays 是账号自毁默认期限(无显式设置时)。与历史固定回显一致。
|
||||
const DefaultAccountTTLDays = 365
|
||||
const (
|
||||
DefaultAccountTTLDays = 365
|
||||
// MaxAccountTTLDays prevents an untrusted int32 TL value from producing an
|
||||
// out-of-range PostgreSQL interval/timestamp during deadline maintenance.
|
||||
MaxAccountTTLDays = 3650
|
||||
)
|
||||
|
||||
// GlobalPrivacy 是 globalPrivacySettings 的业务层表达(账号级隐私开关)。
|
||||
// DisallowedGifts 依赖礼物资产模型(当前未实现),故不建模、保持默认。
|
||||
|
|
@ -212,7 +217,7 @@ func DefaultAccountSettings() AccountSettings {
|
|||
|
||||
// NormalizedTTLDays 返回钳制后的账号自毁期限(0/越界回落默认)。
|
||||
func (s AccountSettings) NormalizedTTLDays() int {
|
||||
if s.AccountTTLDays <= 0 {
|
||||
if s.AccountTTLDays <= 0 || s.AccountTTLDays > MaxAccountTTLDays {
|
||||
return DefaultAccountTTLDays
|
||||
}
|
||||
return s.AccountTTLDays
|
||||
|
|
|
|||
99
internal/domain/account_deletion.go
Normal file
99
internal/domain/account_deletion.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAccountDeleted = errors.New("account deleted")
|
||||
ErrAccountDeletionForbidden = errors.New("account deletion forbidden")
|
||||
ErrAccountDeletionHashInvalid = errors.New("account deletion hash invalid")
|
||||
ErrAccountDeletionNotPending = errors.New("account deletion not pending")
|
||||
)
|
||||
|
||||
// AccountDeletionSource is the single audited reason attached to a user
|
||||
// tombstone. Different entry points share one execution and cleanup path.
|
||||
type AccountDeletionSource string
|
||||
|
||||
const (
|
||||
AccountDeletionManual AccountDeletionSource = "manual"
|
||||
AccountDeletionForgotPassword AccountDeletionSource = "forgot_password"
|
||||
AccountDeletionTOSDecline AccountDeletionSource = "tos_decline"
|
||||
AccountDeletionPasswordResetExpiry AccountDeletionSource = "password_reset_expiry"
|
||||
AccountDeletionAccountTTL AccountDeletionSource = "account_ttl"
|
||||
AccountDeletionFreezeExpiry AccountDeletionSource = "freeze_expiry"
|
||||
)
|
||||
|
||||
type AccountDeletionRequestState string
|
||||
|
||||
const (
|
||||
AccountDeletionPending AccountDeletionRequestState = "pending"
|
||||
AccountDeletionCancelled AccountDeletionRequestState = "cancelled"
|
||||
AccountDeletionExecuted AccountDeletionRequestState = "executed"
|
||||
)
|
||||
|
||||
// AccountDeletionRequest represents the seven-day 2FA confirmation window.
|
||||
// ConfirmHashDigest is SHA-256(raw link token); the raw token is only included
|
||||
// in the durable service message and is never persisted as a credential.
|
||||
type AccountDeletionRequest struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
RequesterAuthKeyID [8]byte
|
||||
State AccountDeletionRequestState
|
||||
Reason string
|
||||
ConfirmHashDigest [32]byte
|
||||
RequestedAt time.Time
|
||||
ExecuteAt time.Time
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
type AccountDeletionSnapshot struct {
|
||||
User User
|
||||
HasPassword bool
|
||||
PasswordUpdatedAt time.Time
|
||||
Pending *AccountDeletionRequest
|
||||
}
|
||||
|
||||
type ScheduleAccountDeletion struct {
|
||||
UserID int64
|
||||
RequesterAuthKeyID [8]byte
|
||||
Reason string
|
||||
ConfirmHashDigest [32]byte
|
||||
ServiceMessage string
|
||||
RequestedAt time.Time
|
||||
ExecuteAt time.Time
|
||||
}
|
||||
|
||||
type AccountDeletionResult struct {
|
||||
User User
|
||||
Changed bool
|
||||
RevokedAuthorizations []Authorization
|
||||
}
|
||||
|
||||
type AccountDeleteKind string
|
||||
|
||||
const (
|
||||
AccountDeleteImmediate AccountDeleteKind = "immediate"
|
||||
AccountDeleteDelayed AccountDeleteKind = "delayed"
|
||||
)
|
||||
|
||||
type AccountDeleteOutcome struct {
|
||||
Kind AccountDeleteKind
|
||||
WaitSeconds int
|
||||
ExecuteAt time.Time
|
||||
Deletion AccountDeletionResult
|
||||
}
|
||||
|
||||
type AccountDeletionCandidate struct {
|
||||
UserID int64
|
||||
Source AccountDeletionSource
|
||||
DueAt time.Time
|
||||
}
|
||||
|
||||
type AccountDeletionNotification struct {
|
||||
ID int64
|
||||
TargetUserID int64
|
||||
DeletedUserID int64
|
||||
Attempts int
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// UserIDSequenceBase 是普通用户 ID 的起始值。
|
||||
//
|
||||
// 取 2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳。
|
||||
|
|
@ -63,6 +65,15 @@ type User struct {
|
|||
PhotoHasVideo bool
|
||||
LastSeenAt int
|
||||
Status UserStatus
|
||||
// Deleted is the durable tombstone state. Deleted users remain addressable by
|
||||
// ID so historical messages can render "Deleted Account", but all profile
|
||||
// and reusable identity fields are cleared at the store boundary.
|
||||
Deleted bool
|
||||
DeletedAt int64
|
||||
DeletionSource AccountDeletionSource
|
||||
DeletionReason string
|
||||
CreatedAt time.Time
|
||||
AccountDeleteAt time.Time
|
||||
}
|
||||
|
||||
// PremiumActiveAt 报告用户在 now(Unix 秒)时刻是否为有效会员。
|
||||
|
|
@ -81,6 +92,25 @@ func (u User) EmojiStatusActiveAt(now int64) bool {
|
|||
return u.EmojiStatusUntil == 0 || int64(u.EmojiStatusUntil) > now
|
||||
}
|
||||
|
||||
// DeletedTombstone strips every viewer-dependent or personally identifying
|
||||
// field while preserving the immutable id and lifecycle audit facts.
|
||||
func (u User) DeletedTombstone() User {
|
||||
if !u.Deleted {
|
||||
return u
|
||||
}
|
||||
return User{
|
||||
ID: u.ID,
|
||||
AccessHash: u.AccessHash,
|
||||
Deleted: true,
|
||||
DeletedAt: u.DeletedAt,
|
||||
DeletionSource: u.DeletionSource,
|
||||
DeletionReason: u.DeletionReason,
|
||||
CreatedAt: u.CreatedAt,
|
||||
AccountDeleteAt: u.AccountDeleteAt,
|
||||
Status: UserStatus{Kind: UserStatusEmpty},
|
||||
}
|
||||
}
|
||||
|
||||
// UserStatusKind is a protocol-neutral account presence state.
|
||||
type UserStatusKind int
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const (
|
|||
PurposeLoginEmailSetup Purpose = "login_email_setup"
|
||||
PurposeLoginEmailChange Purpose = "login_email_change"
|
||||
PurposeChangePhone Purpose = "change_phone"
|
||||
PurposeConfirmPhone Purpose = "confirm_phone"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
|
|
@ -46,7 +47,7 @@ func (r Request) Validate(now time.Time) error {
|
|||
return fmt.Errorf("delivery id is empty or too long")
|
||||
}
|
||||
switch r.Purpose {
|
||||
case PurposeLoginEmail, PurposeLoginSMS, PurposeLoginEmailSetup, PurposeLoginEmailChange, PurposeChangePhone:
|
||||
case PurposeLoginEmail, PurposeLoginSMS, PurposeLoginEmailSetup, PurposeLoginEmailChange, PurposeChangePhone, PurposeConfirmPhone:
|
||||
default:
|
||||
return fmt.Errorf("unsupported delivery purpose %q", r.Purpose)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,15 @@ import (
|
|||
|
||||
// registerAccount 注册 account.* RPC handler。
|
||||
func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
|
||||
registerRPC[*tg.AccountDeleteAccountRequest](d, tlprofile.SemanticMethodAccountDeleteAccount, func(ctx context.Context, req *tg.AccountDeleteAccountRequest) (any, error) {
|
||||
return r.onAccountDeleteAccount(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.AccountSendConfirmPhoneCodeRequest](d, tlprofile.SemanticMethodAccountSendConfirmPhoneCode, func(ctx context.Context, req *tg.AccountSendConfirmPhoneCodeRequest) (any, error) {
|
||||
return r.onAccountSendConfirmPhoneCode(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.AccountConfirmPhoneRequest](d, tlprofile.SemanticMethodAccountConfirmPhone, func(ctx context.Context, req *tg.AccountConfirmPhoneRequest) (any, error) {
|
||||
return r.onAccountConfirmPhone(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.AccountRegisterDeviceRequest](d, tlprofile.SemanticMethodAccountRegisterDevice, func(ctx context.Context, req *tg.AccountRegisterDeviceRequest) (any, error) {
|
||||
return true, nil
|
||||
})
|
||||
|
|
@ -902,7 +911,7 @@ func (r *Router) onAccountSetAccountTTL(ctx context.Context, ttl tg.AccountDaysT
|
|||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if ttl.Days <= 0 {
|
||||
if ttl.Days <= 0 || ttl.Days > domain.MaxAccountTTLDays {
|
||||
return false, tgerr400("TTL_DAYS_INVALID")
|
||||
}
|
||||
if svc, ok := r.accountSettingsSvc(); ok {
|
||||
|
|
|
|||
152
internal/rpc/account_deletion.go
Normal file
152
internal/rpc/account_deletion.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/postresponse"
|
||||
)
|
||||
|
||||
type accountDeletionService interface {
|
||||
DeleteAccount(ctx context.Context, userID int64, authKeyID [8]byte, reason string, password *domain.PasswordCheck, now time.Time) (domain.AccountDeleteOutcome, error)
|
||||
SendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, hash string) (string, domain.AuthCodeDelivery, error)
|
||||
ConfirmPhone(ctx context.Context, userID int64, authKeyID [8]byte, phoneCodeHash, code string, now time.Time) ([]domain.Authorization, error)
|
||||
ResendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, oldHash string) (string, domain.AuthCodeDelivery, bool, error)
|
||||
CancelConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, phone, hash string) (bool, error)
|
||||
}
|
||||
|
||||
func (r *Router) accountDeletionSvc() (accountDeletionService, bool) {
|
||||
svc, ok := r.deps.Account.(accountDeletionService)
|
||||
return svc, ok
|
||||
}
|
||||
|
||||
func (r *Router) onAccountDeleteAccount(ctx context.Context, req *tg.AccountDeleteAccountRequest) (bool, error) {
|
||||
userID, authorized, passwordPending, err := r.currentOrPendingPasswordUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if userID == 0 || (!authorized && !passwordPending) {
|
||||
return false, authKeyUnregisteredErr()
|
||||
}
|
||||
svc, ok := r.accountDeletionSvc()
|
||||
if !ok {
|
||||
return false, internalErr()
|
||||
}
|
||||
authKeyID, ok := AuthKeyIDFrom(ctx)
|
||||
if !ok || authKeyID == ([8]byte{}) {
|
||||
return false, authKeyUnregisteredErr()
|
||||
}
|
||||
var password *domain.PasswordCheck
|
||||
if check, present := req.GetPassword(); present {
|
||||
converted := domainPasswordCheck(check)
|
||||
password = &converted
|
||||
}
|
||||
outcome, err := svc.DeleteAccount(ctx, userID, authKeyID, req.Reason, password, time.Now().UTC())
|
||||
if err != nil {
|
||||
return false, accountDeletionErr(err)
|
||||
}
|
||||
if outcome.Kind == domain.AccountDeleteDelayed {
|
||||
wait := outcome.WaitSeconds
|
||||
if wait < 1 {
|
||||
wait = 1
|
||||
}
|
||||
return false, tgerr.New(420, fmt.Sprintf("2FA_CONFIRM_WAIT_%d", wait))
|
||||
}
|
||||
r.finishDeletedAccountAuthorizations(ctx, userID, outcome.Deletion.RevokedAuthorizations)
|
||||
r.invalidateRPCProjectionForUser(userID)
|
||||
dispatchNotifications := func() {
|
||||
dispatchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
r.runAccountLifecycleOnce(dispatchCtx, 500)
|
||||
}
|
||||
if !postresponse.Register(ctx, dispatchNotifications) {
|
||||
go dispatchNotifications()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) onAccountSendConfirmPhoneCode(ctx context.Context, req *tg.AccountSendConfirmPhoneCodeRequest) (tg.AuthSentCodeClass, error) {
|
||||
userID, authorized, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !authorized || userID == 0 {
|
||||
return nil, authKeyUnregisteredErr()
|
||||
}
|
||||
svc, ok := r.accountDeletionSvc()
|
||||
if !ok {
|
||||
return nil, internalErr()
|
||||
}
|
||||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||
sessionID, _ := SessionIDFrom(ctx)
|
||||
hash, delivery, err := svc.SendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.Hash)
|
||||
if err != nil {
|
||||
return nil, accountDeletionErr(err)
|
||||
}
|
||||
return tgSMSSentCode(hash, delivery.Length), nil
|
||||
}
|
||||
|
||||
func (r *Router) onAccountConfirmPhone(ctx context.Context, req *tg.AccountConfirmPhoneRequest) (bool, error) {
|
||||
userID, authorized, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if !authorized || userID == 0 {
|
||||
return false, authKeyUnregisteredErr()
|
||||
}
|
||||
svc, ok := r.accountDeletionSvc()
|
||||
if !ok {
|
||||
return false, internalErr()
|
||||
}
|
||||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||
revoked, err := svc.ConfirmPhone(ctx, userID, authKeyID, req.PhoneCodeHash, req.PhoneCode, time.Now().UTC())
|
||||
if err != nil {
|
||||
return false, accountDeletionErr(err)
|
||||
}
|
||||
r.finishDeletedAccountAuthorizations(ctx, userID, revoked)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) finishDeletedAccountAuthorizations(ctx context.Context, userID int64, revoked []domain.Authorization) {
|
||||
current, _ := AuthKeyIDFrom(ctx)
|
||||
for _, authorization := range revoked {
|
||||
a := authorization
|
||||
finish := func() {
|
||||
r.discardSecretChatsForAuthKey(context.Background(), businessAuthKeyInt64(a.AuthKeyID), userID)
|
||||
r.revokeAuthKeySessions(a.AuthKeyID)
|
||||
}
|
||||
if a.AuthKeyID == current {
|
||||
if postresponse.Register(ctx, finish) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
func accountDeletionErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrPasswordHashInvalid), errors.Is(err, domain.ErrSRPIDInvalid), errors.Is(err, domain.ErrSRPPasswordChanged):
|
||||
return passwordErr(err)
|
||||
case errors.Is(err, domain.ErrAccountDeletionHashInvalid), errors.Is(err, domain.ErrAccountDeletionNotPending):
|
||||
return tgerr.New(400, "HASH_INVALID")
|
||||
case errors.Is(err, domain.ErrPhoneCodeEmpty):
|
||||
return phoneCodeEmptyErr()
|
||||
case errors.Is(err, domain.ErrPhoneCodeInvalid):
|
||||
return phoneCodeInvalidErr()
|
||||
case errors.Is(err, domain.ErrPhoneCodeExpired):
|
||||
return phoneCodeExpiredErr()
|
||||
case errors.Is(err, domain.ErrAccountDeletionForbidden):
|
||||
return botMethodInvalidErr()
|
||||
case errors.Is(err, domain.ErrAccountDeleted):
|
||||
return authKeyUnregisteredErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
177
internal/rpc/account_deletion_rpc_test.go
Normal file
177
internal/rpc/account_deletion_rpc_test.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/proto"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appaccount "telesrv/internal/app/account"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/postresponse"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestAccountDeleteRPCDeliversResultBeforeClosingCurrentSession(t *testing.T) {
|
||||
current := [8]byte{1}
|
||||
other := [8]byte{2}
|
||||
accountSvc := &rpcDeletionAccountService{
|
||||
Service: appaccount.NewService(memory.NewPasswordStore()),
|
||||
outcome: domain.AccountDeleteOutcome{
|
||||
Kind: domain.AccountDeleteImmediate,
|
||||
Deletion: domain.AccountDeletionResult{Changed: true, RevokedAuthorizations: []domain.Authorization{
|
||||
{AuthKeyID: current, UserID: 42},
|
||||
{AuthKeyID: other, UserID: 42},
|
||||
}},
|
||||
},
|
||||
}
|
||||
sessions := &deletionCaptureSessions{}
|
||||
r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := postresponse.WithCallbacks(WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 42), current), 77))
|
||||
ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "manual"})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("delete account ok=%v err=%v", ok, err)
|
||||
}
|
||||
if sessions.wasClosed(current) {
|
||||
t.Fatal("current auth key closed before rpc_result delivery")
|
||||
}
|
||||
if !sessions.wasClosed(other) {
|
||||
t.Fatal("other auth key was not revoked immediately")
|
||||
}
|
||||
postresponse.Run(ctx)
|
||||
if !sessions.wasClosed(current) {
|
||||
t.Fatal("current auth key not closed after rpc_result delivery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDeleteRPCMapsDelayedTwoFAWait(t *testing.T) {
|
||||
accountSvc := &rpcDeletionAccountService{
|
||||
Service: appaccount.NewService(memory.NewPasswordStore()),
|
||||
outcome: domain.AccountDeleteOutcome{Kind: domain.AccountDeleteDelayed, WaitSeconds: 604800},
|
||||
}
|
||||
r := New(Config{}, Deps{Account: accountSvc}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := WithAuthKeyID(WithUserID(context.Background(), 42), [8]byte{1})
|
||||
ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "Forgot password"})
|
||||
if ok || !tgerr.Is(err, "2FA_CONFIRM_WAIT") || !strings.Contains(err.Error(), "604800") {
|
||||
t.Fatalf("delayed delete ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAccountAllowedWithoutFullAuthorization(t *testing.T) {
|
||||
if !rpcAllowedWithoutAuthorization(tg.AccountDeleteAccountRequestTypeID) {
|
||||
t.Fatal("account.deleteAccount must reach the narrow password_pending identity resolver")
|
||||
}
|
||||
if rpcAllowedWithoutAuthorization(tg.AccountConfirmPhoneRequestTypeID) || rpcAllowedWithoutAuthorization(tg.AccountSendConfirmPhoneCodeRequestTypeID) {
|
||||
t.Fatal("confirm-phone methods must remain fully authorized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDeletionNotificationCompletesForOfflineTarget(t *testing.T) {
|
||||
sessions := &offlineDeletionSessions{}
|
||||
svc := &deletionWorkerService{}
|
||||
r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System)
|
||||
r.dispatchAccountDeletionNotification(context.Background(), svc, domain.AccountDeletionNotification{
|
||||
ID: 9, TargetUserID: 42, DeletedUserID: 77, Attempts: 1,
|
||||
})
|
||||
if len(svc.completed) != 1 || svc.completed[0] != 9 {
|
||||
t.Fatalf("completed notifications = %v, want [9]", svc.completed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountLifecyclePartialSweepFinishesCommittedDeletion(t *testing.T) {
|
||||
revoked := [8]byte{3}
|
||||
svc := &rpcDeletionAccountService{
|
||||
Service: appaccount.NewService(memory.NewPasswordStore()),
|
||||
sweepResults: []domain.AccountDeletionResult{{
|
||||
Changed: true,
|
||||
User: domain.User{ID: 42, Deleted: true},
|
||||
RevokedAuthorizations: []domain.Authorization{{AuthKeyID: revoked, UserID: 42}},
|
||||
}},
|
||||
sweepErr: errors.New("later candidate failed"),
|
||||
}
|
||||
sessions := &deletionCaptureSessions{}
|
||||
r := New(Config{}, Deps{Account: svc, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
|
||||
r.runAccountLifecycleOnce(context.Background(), 10)
|
||||
if !sessions.wasClosed(revoked) {
|
||||
t.Fatal("committed deletion authorization was not closed after partial sweep failure")
|
||||
}
|
||||
}
|
||||
|
||||
type rpcDeletionAccountService struct {
|
||||
*appaccount.Service
|
||||
outcome domain.AccountDeleteOutcome
|
||||
err error
|
||||
sweepResults []domain.AccountDeletionResult
|
||||
sweepErr error
|
||||
}
|
||||
|
||||
func (s *rpcDeletionAccountService) DeleteAccount(context.Context, int64, [8]byte, string, *domain.PasswordCheck, time.Time) (domain.AccountDeleteOutcome, error) {
|
||||
return s.outcome, s.err
|
||||
}
|
||||
|
||||
func (*rpcDeletionAccountService) SendConfirmPhoneCode(context.Context, int64, [8]byte, int64, string) (string, domain.AuthCodeDelivery, error) {
|
||||
return "hash", domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: 5}, nil
|
||||
}
|
||||
|
||||
func (*rpcDeletionAccountService) ConfirmPhone(context.Context, int64, [8]byte, string, string, time.Time) ([]domain.Authorization, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*rpcDeletionAccountService) ResendConfirmPhoneCode(context.Context, int64, [8]byte, int64, string, string) (string, domain.AuthCodeDelivery, bool, error) {
|
||||
return "", domain.AuthCodeDelivery{}, false, nil
|
||||
}
|
||||
|
||||
func (*rpcDeletionAccountService) CancelConfirmPhoneCode(context.Context, int64, [8]byte, string, string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *rpcDeletionAccountService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
|
||||
return s.sweepResults, s.sweepErr
|
||||
}
|
||||
|
||||
type deletionCaptureSessions struct {
|
||||
captureSessions
|
||||
closed [][8]byte
|
||||
}
|
||||
|
||||
type offlineDeletionSessions struct{ captureSessions }
|
||||
|
||||
func (*offlineDeletionSessions) PushToUserExceptAuthKeySession(context.Context, int64, [8]byte, int64, proto.MessageType, tg.UpdatesClass) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type deletionWorkerService struct{ completed []int64 }
|
||||
|
||||
func (*deletionWorkerService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*deletionWorkerService) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *deletionWorkerService) CompleteAccountDeletionNotification(_ context.Context, id int64, _ time.Time) error {
|
||||
s.completed = append(s.completed, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *deletionCaptureSessions) CloseSessionsForBusinessAuthKey(id [8]byte) int {
|
||||
s.closed = append(s.closed, id)
|
||||
return 1
|
||||
}
|
||||
|
||||
func (s *deletionCaptureSessions) wasClosed(id [8]byte) bool {
|
||||
for _, closed := range s.closed {
|
||||
if closed == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
99
internal/rpc/account_lifecycle_worker.go
Normal file
99
internal/rpc/account_lifecycle_worker.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type accountLifecycleWorkerService interface {
|
||||
SweepDueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionResult, error)
|
||||
ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error)
|
||||
CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error
|
||||
}
|
||||
|
||||
// RunAccountLifecycle executes all due account deletion sources through one
|
||||
// tombstone path and drains the durable non-pts updateUser queue. The queue is
|
||||
// a crash-safe, bounded online nudge: offline users are completed after the
|
||||
// first attempt because getDialogs/getHistory hydration independently returns
|
||||
// the authoritative tombstone. This avoids an immortal retry queue for a
|
||||
// non-pts update that cannot participate in getDifference.
|
||||
func (r *Router) RunAccountLifecycle(ctx context.Context, interval time.Duration, batch int) {
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
if batch <= 0 {
|
||||
batch = 500
|
||||
}
|
||||
r.runAccountLifecycleOnce(ctx, batch)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.runAccountLifecycleOnce(ctx, batch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) runAccountLifecycleOnce(ctx context.Context, batch int) {
|
||||
svc, ok := r.deps.Account.(accountLifecycleWorkerService)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
now := r.clock.Now().UTC()
|
||||
sweepCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
results, err := svc.SweepDueAccountDeletions(sweepCtx, now, batch)
|
||||
cancel()
|
||||
for _, result := range results {
|
||||
if !result.Changed {
|
||||
continue
|
||||
}
|
||||
r.invalidateRPCProjectionForUser(result.User.ID)
|
||||
r.finishDeletedAccountAuthorizations(context.Background(), result.User.ID, result.RevokedAuthorizations)
|
||||
}
|
||||
if err != nil {
|
||||
// SweepDueAccountDeletions may return already-committed results before a
|
||||
// later candidate fails. Always finish those sessions/caches and drain
|
||||
// their durable notifications; the failed and remaining candidates are
|
||||
// retried from their authoritative due rows on the next tick.
|
||||
r.log.Warn("account lifecycle deletion sweep partially failed", zap.Int("completed", len(results)), zap.Error(err))
|
||||
}
|
||||
for {
|
||||
claimCtx, claimCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
notifications, err := svc.ClaimAccountDeletionNotifications(claimCtx, now, batch, 2*time.Minute)
|
||||
claimCancel()
|
||||
if err != nil {
|
||||
r.log.Warn("claim account deletion notifications failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, notification := range notifications {
|
||||
r.dispatchAccountDeletionNotification(ctx, svc, notification)
|
||||
}
|
||||
if len(notifications) < batch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) dispatchAccountDeletionNotification(ctx context.Context, svc accountLifecycleWorkerService, notification domain.AccountDeletionNotification) {
|
||||
now := r.clock.Now().UTC()
|
||||
updates := &tg.Updates{
|
||||
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: notification.DeletedUserID}},
|
||||
Users: []tg.UserClass{tgUser(domain.User{
|
||||
ID: notification.DeletedUserID,
|
||||
Deleted: true,
|
||||
})},
|
||||
Date: int(now.Unix()),
|
||||
}
|
||||
r.pushUserUpdates(ctx, notification.TargetUserID, updates)
|
||||
if err := svc.CompleteAccountDeletionNotification(ctx, notification.ID, now); err != nil {
|
||||
r.log.Warn("complete account deletion notification failed", zap.Int64("notification_id", notification.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
|
@ -490,6 +490,19 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq
|
|||
if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 {
|
||||
if svc, ok := r.deps.Account.(accountDeletionService); ok {
|
||||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||
sessionID, _ := SessionIDFrom(ctx)
|
||||
hash, delivery, handled, err := svc.ResendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.PhoneNumber, req.PhoneCodeHash)
|
||||
if handled {
|
||||
if err != nil {
|
||||
return nil, accountDeletionErr(err)
|
||||
}
|
||||
return tgSMSSentCode(hash, delivery.Length), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
var hash string
|
||||
var err error
|
||||
if scoped, ok := r.deps.Auth.(interface {
|
||||
|
|
@ -507,6 +520,18 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq
|
|||
}
|
||||
|
||||
func (r *Router) onAuthCancelCode(ctx context.Context, req *tg.AuthCancelCodeRequest) (bool, error) {
|
||||
if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 {
|
||||
if svc, ok := r.deps.Account.(accountDeletionService); ok {
|
||||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||
handled, err := svc.CancelConfirmPhoneCode(ctx, userID, authKeyID, req.PhoneNumber, req.PhoneCodeHash)
|
||||
if handled {
|
||||
if err != nil {
|
||||
return false, accountDeletionErr(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
var err error
|
||||
if scoped, ok := r.deps.Auth.(interface {
|
||||
CancelCodeForAuthKey(context.Context, [8]byte, string, string) error
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ func rpcAllowedWithoutAuthorization(id uint32) bool {
|
|||
tg.AuthReportMissingCodeRequestTypeID,
|
||||
tg.AuthResetLoginEmailRequestTypeID,
|
||||
tg.AccountGetPasswordRequestTypeID,
|
||||
// deleteAccount may complete the narrow password_pending login path when
|
||||
// the user forgot 2FA. The handler resolves only that bound identity.
|
||||
tg.AccountDeleteAccountRequestTypeID,
|
||||
// 登录邮箱 setup(emailVerifyPurposeLoginSetup)发生在登录流程中、尚未鉴权,
|
||||
// 故这两个 account.* 方法必须放行 pre-auth;loginChange 分支内部仍校验 userID。
|
||||
tg.AccountSendVerifyEmailCodeRequestTypeID,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import (
|
|||
|
||||
// tgSelfUser 把 domain.User 转为 self 标记的 tg.User(optional 字段由 Encode 自动 SetFlags)。
|
||||
func tgSelfUser(u domain.User) *tg.User {
|
||||
if u.Deleted {
|
||||
return &tg.User{ID: u.ID, Deleted: true}
|
||||
}
|
||||
out := &tg.User{
|
||||
ID: u.ID,
|
||||
AccessHash: u.AccessHash,
|
||||
|
|
@ -34,6 +37,9 @@ func tgSelfUser(u domain.User) *tg.User {
|
|||
}
|
||||
|
||||
func tgUser(u domain.User) *tg.User {
|
||||
if u.Deleted {
|
||||
return &tg.User{ID: u.ID, Deleted: true}
|
||||
}
|
||||
out := &tg.User{
|
||||
ID: u.ID,
|
||||
AccessHash: u.AccessHash,
|
||||
|
|
|
|||
56
internal/rpc/convert_users_deleted_test.go
Normal file
56
internal/rpc/convert_users_deleted_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestDeletedUserTLProjectionContainsOnlyTombstoneIdentity(t *testing.T) {
|
||||
u := domain.User{
|
||||
ID: 42, AccessHash: 99, Phone: "secret", FirstName: "Alice", LastName: "Private",
|
||||
Username: "released", About: "hidden", Verified: true, PremiumUntil: 2_000_000_000,
|
||||
PhotoID: 123, Deleted: true, DeletedAt: 1_800_000_000,
|
||||
}
|
||||
got := tgUser(u)
|
||||
if got.ID != u.ID || !got.Deleted {
|
||||
t.Fatalf("deleted user = %+v", got)
|
||||
}
|
||||
if got.AccessHash != 0 || got.Phone != "" || got.FirstName != "" || got.LastName != "" || got.Username != "" || got.Verified || got.Premium || got.Photo != nil || got.Status != nil || len(got.Usernames) != 0 {
|
||||
t.Fatalf("deleted user leaked profile state: %+v", got)
|
||||
}
|
||||
self := tgSelfUser(u)
|
||||
if !self.Deleted || self.Self || self.ID != u.ID {
|
||||
t.Fatalf("deleted self projection = %+v", self)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryHydrationReplacesStaleUserWithDeletedTombstone(t *testing.T) {
|
||||
viewer := domain.User{ID: 7, FirstName: "Viewer"}
|
||||
deleted := domain.User{ID: 42, AccessHash: 99, Deleted: true, DeletedAt: 1_800_000_000}
|
||||
r := New(Config{}, Deps{Users: mapUsersService{users: map[int64]domain.User{
|
||||
viewer.ID: viewer, deleted.ID: deleted,
|
||||
}}}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
list := r.enrichMessageList(context.Background(), viewer.ID, domain.MessageList{
|
||||
Messages: []domain.Message{{
|
||||
OwnerUserID: viewer.ID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
|
||||
Body: "retained history",
|
||||
}},
|
||||
// Simulate an old denormalized message query row. The authoritative
|
||||
// Users.ByIDs hydration must replace it, not keep an empty active user.
|
||||
Users: []domain.User{{ID: deleted.ID, Phone: "stale", FirstName: "Stale"}},
|
||||
})
|
||||
if len(list.Users) != 1 || !list.Users[0].Deleted || list.Users[0].Phone != "" || list.Users[0].FirstName != "" {
|
||||
t.Fatalf("history users = %+v, want authoritative tombstone", list.Users)
|
||||
}
|
||||
if got := tgUser(list.Users[0]); !got.Deleted || got.ID != deleted.ID {
|
||||
t.Fatalf("history TL user = %+v", got)
|
||||
}
|
||||
}
|
||||
22
internal/store/account_lifecycle.go
Normal file
22
internal/store/account_lifecycle.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// AccountLifecycleStore owns the atomic boundary between tombstoning a user,
|
||||
// purging private account state, revoking authorizations and enqueueing
|
||||
// non-pts updateUser notifications.
|
||||
type AccountLifecycleStore interface {
|
||||
AccountDeletionSnapshot(ctx context.Context, userID int64) (domain.AccountDeletionSnapshot, bool, error)
|
||||
ScheduleAccountDeletion(ctx context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error)
|
||||
PendingAccountDeletionByHash(ctx context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error)
|
||||
ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error)
|
||||
CancelAccountDeletion(ctx context.Context, userID int64, digest [32]byte, now time.Time) ([]domain.Authorization, error)
|
||||
DueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionCandidate, error)
|
||||
ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error)
|
||||
CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import (
|
|||
|
||||
const (
|
||||
PhoneCodePurposeChangePhone = "change_phone"
|
||||
PhoneCodePurposeConfirmPhone = "confirm_phone"
|
||||
PhoneCodeChannelPhone = "phone"
|
||||
PhoneCodeChannelSMS = "sms"
|
||||
PhoneCodeChannelEmailLogin = "email_login"
|
||||
|
|
@ -75,6 +76,10 @@ type PhoneCode struct {
|
|||
VerifiedEmail bool
|
||||
RequireSignUp bool
|
||||
LoginEmailHash string
|
||||
// AccountDeletionHash is the hex-encoded SHA-256 digest of the validated
|
||||
// confirmphone link token. It binds account.confirmPhone to one pending
|
||||
// deletion without persisting the raw link credential in the code record.
|
||||
AccountDeletionHash string
|
||||
}
|
||||
|
||||
type PhoneCodeSnapshot struct {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。
|
||||
|
|
@ -65,7 +66,7 @@ func (s *UserStore) ByPhone(_ context.Context, phone string) (domain.User, bool,
|
|||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, u := range s.byID {
|
||||
if u.Phone == phone {
|
||||
if !u.Deleted && u.Phone == phone {
|
||||
return u, true, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +88,9 @@ func (s *UserStore) ByPhones(_ context.Context, phones []string) ([]domain.User,
|
|||
out := make([]domain.User, 0, len(want))
|
||||
seenIDs := map[int64]struct{}{}
|
||||
for _, u := range s.byID {
|
||||
if u.Deleted {
|
||||
continue
|
||||
}
|
||||
if _, ok := want[u.Phone]; !ok {
|
||||
continue
|
||||
}
|
||||
|
|
@ -108,7 +112,7 @@ func (s *UserStore) ByUsername(_ context.Context, username string) (domain.User,
|
|||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, u := range s.byID {
|
||||
if strings.ToLower(u.Username) == username {
|
||||
if !u.Deleted && strings.ToLower(u.Username) == username {
|
||||
return u, true, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -123,7 +127,7 @@ func (s *UserStore) CheckUsername(_ context.Context, userID int64, username stri
|
|||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for id, u := range s.byID {
|
||||
if strings.ToLower(u.Username) == username && id != userID {
|
||||
if !u.Deleted && strings.ToLower(u.Username) == username && id != userID {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -143,7 +147,7 @@ func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQ
|
|||
defer s.mu.RUnlock()
|
||||
users := make([]domain.User, 0)
|
||||
for _, u := range s.byID {
|
||||
if u.ID == currentUserID {
|
||||
if u.ID == currentUserID || u.Deleted {
|
||||
continue
|
||||
}
|
||||
if userMatchesSearch(u, query, phoneQuery) {
|
||||
|
|
@ -165,7 +169,7 @@ func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username str
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUsernameNotOccupied
|
||||
}
|
||||
if usernameLower != "" {
|
||||
|
|
@ -184,7 +188,7 @@ func (s *UserStore) UpdateProfile(_ context.Context, userID int64, firstName, la
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUsernameNotOccupied
|
||||
}
|
||||
u.FirstName = firstName
|
||||
|
|
@ -198,7 +202,7 @@ func (s *UserStore) UpdateBirthday(_ context.Context, userID int64, birthday dom
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.Birthday = birthday
|
||||
|
|
@ -210,7 +214,7 @@ func (s *UserStore) UpdatePersonalChannel(_ context.Context, userID int64, chann
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.PersonalChannelID = channelID
|
||||
|
|
@ -224,7 +228,7 @@ func (s *UserStore) bumpBotInfoVersion(userID int64) (int, bool) {
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok || !u.Bot {
|
||||
if !ok || u.Deleted || !u.Bot {
|
||||
return 0, false
|
||||
}
|
||||
u.BotInfoVersion++
|
||||
|
|
@ -237,7 +241,7 @@ func (s *UserStore) updateBotProfile(userID int64, setName bool, name string, se
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok || !u.Bot {
|
||||
if !ok || u.Deleted || !u.Bot {
|
||||
return false
|
||||
}
|
||||
if setName {
|
||||
|
|
@ -255,7 +259,7 @@ func (s *UserStore) SetPremiumUntil(_ context.Context, userID int64, until int)
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if until < 0 {
|
||||
|
|
@ -271,7 +275,7 @@ func (s *UserStore) SetVerified(_ context.Context, userID int64, verified bool)
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.Verified = verified
|
||||
|
|
@ -288,7 +292,7 @@ func (s *UserStore) SweepExpiredPremium(_ context.Context, now int64, limit int)
|
|||
defer s.mu.Unlock()
|
||||
out := make([]domain.User, 0)
|
||||
for id, u := range s.byID {
|
||||
if u.PremiumUntil <= 0 || int64(u.PremiumUntil) > now {
|
||||
if u.Deleted || u.PremiumUntil <= 0 || int64(u.PremiumUntil) > now {
|
||||
continue
|
||||
}
|
||||
u.PremiumUntil = 0
|
||||
|
|
@ -307,7 +311,7 @@ func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, documentI
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if documentID == 0 {
|
||||
|
|
@ -323,7 +327,7 @@ func (s *UserStore) UpdateColor(_ context.Context, userID int64, forProfile bool
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if forProfile {
|
||||
|
|
@ -342,7 +346,7 @@ func (s *UserStore) UpdateLastSeen(_ context.Context, userID int64, lastSeenAt i
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.ErrUsernameNotOccupied
|
||||
}
|
||||
if lastSeenAt > u.LastSeenAt {
|
||||
|
|
@ -379,6 +383,9 @@ func (s *UserStore) Create(_ context.Context, u domain.User) (domain.User, error
|
|||
}
|
||||
u.ID = s.nextID
|
||||
s.nextID++
|
||||
if u.CreatedAt.IsZero() {
|
||||
u.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
s.byID[u.ID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
|
|
|||
737
internal/store/postgres/account_lifecycle.go
Normal file
737
internal/store/postgres/account_lifecycle.go
Normal file
|
|
@ -0,0 +1,737 @@
|
|||
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,
|
||||
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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -8,16 +8,16 @@ 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: 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 (
|
||||
|
|
@ -57,6 +57,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 || '%')
|
||||
|
|
@ -107,14 +108,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
|
||||
|
|
@ -122,28 +123,28 @@ 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: SetUserPremiumUntil :one
|
||||
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
|
||||
|
|
@ -153,6 +154,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
|
||||
|
|
@ -164,7 +166,7 @@ UPDATE users
|
|||
SET emoji_status_document_id = sqlc.arg(emoji_status_document_id)::bigint,
|
||||
emoji_status_until = sqlc.arg(emoji_status_until)::bigint,
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg(id)::bigint
|
||||
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateUserBirthday :one
|
||||
|
|
@ -173,14 +175,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
|
||||
|
|
@ -189,7 +191,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
|
||||
|
|
@ -198,5 +200,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 *;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
`
|
||||
|
||||
type InsertBotUserParams struct {
|
||||
|
|
@ -209,6 +209,10 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -694,6 +721,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
|
||||
|
|
@ -1156,19 +1219,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 +1494,382 @@ 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 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
|
||||
|
|
@ -1564,6 +2017,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 +2140,10 @@ type User struct {
|
|||
BirthdayMonth int32
|
||||
BirthdayYear int32
|
||||
PersonalChannelID int64
|
||||
DeletedAt pgtype.Timestamptz
|
||||
DeletionSource string
|
||||
DeletionReason string
|
||||
AccountDeleteAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type UserBusinessProfile struct {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
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
|
||||
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
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
|
|
@ -68,12 +68,16 @@ 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,
|
||||
)
|
||||
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 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 FROM users WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||
|
|
@ -109,12 +113,16 @@ 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,
|
||||
)
|
||||
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 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 FROM users WHERE phone = $1 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) {
|
||||
|
|
@ -150,12 +158,16 @@ 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,
|
||||
)
|
||||
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 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 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) {
|
||||
|
|
@ -191,12 +203,16 @@ 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,
|
||||
)
|
||||
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
|
||||
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
|
||||
FROM users
|
||||
WHERE id = ANY($1::bigint[])
|
||||
ORDER BY id
|
||||
|
|
@ -241,6 +257,10 @@ 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,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -253,9 +273,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
|
||||
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
|
||||
FROM users
|
||||
WHERE phone = ANY($1::text[])
|
||||
WHERE phone = ANY($1::text[]) AND deleted_at IS NULL
|
||||
ORDER BY id
|
||||
`
|
||||
|
||||
|
|
@ -298,6 +318,10 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -347,6 +371,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 || '%')
|
||||
|
|
@ -479,8 +504,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
|
||||
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
|
||||
`
|
||||
|
||||
type SetUserPremiumUntilParams struct {
|
||||
|
|
@ -521,6 +546,10 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -529,8 +558,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
|
||||
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
|
||||
`
|
||||
|
||||
type SetUserVerifiedParams struct {
|
||||
|
|
@ -571,6 +600,10 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -582,11 +615,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
|
||||
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
|
||||
`
|
||||
|
||||
type SweepExpiredPremiumParams struct {
|
||||
|
|
@ -633,6 +667,10 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -650,8 +688,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
|
||||
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
|
||||
`
|
||||
|
||||
type UpdateUserBirthdayParams struct {
|
||||
|
|
@ -699,6 +737,10 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -709,8 +751,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
|
||||
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
|
||||
`
|
||||
|
||||
type UpdateUserColorParams struct {
|
||||
|
|
@ -758,6 +800,10 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -767,8 +813,8 @@ UPDATE users
|
|||
SET emoji_status_document_id = $1::bigint,
|
||||
emoji_status_until = $2::bigint,
|
||||
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
|
||||
WHERE id = $3::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
|
||||
`
|
||||
|
||||
type UpdateUserEmojiStatusParams struct {
|
||||
|
|
@ -810,6 +856,10 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -818,7 +868,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 {
|
||||
|
|
@ -835,8 +885,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
|
||||
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
|
||||
`
|
||||
|
||||
type UpdateUserPersonalChannelParams struct {
|
||||
|
|
@ -877,6 +927,10 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -885,8 +939,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
|
||||
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
|
||||
`
|
||||
|
||||
type UpdateUserPhoneParams struct {
|
||||
|
|
@ -927,6 +981,10 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -937,8 +995,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
|
||||
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
|
||||
`
|
||||
|
||||
type UpdateUserProfileParams struct {
|
||||
|
|
@ -986,6 +1044,10 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -996,8 +1058,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
|
||||
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
|
||||
`
|
||||
|
||||
type UpdateUserProfileColorParams struct {
|
||||
|
|
@ -1045,6 +1107,10 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -1053,8 +1119,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
|
||||
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
|
||||
`
|
||||
|
||||
type UpdateUserUsernameParams struct {
|
||||
|
|
@ -1095,6 +1161,10 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername
|
|||
&i.BirthdayMonth,
|
||||
&i.BirthdayYear,
|
||||
&i.PersonalChannelID,
|
||||
&i.DeletedAt,
|
||||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("migrate star gift lifecycle schema: %v", err)
|
||||
}
|
||||
if status.Dirty || status.Empty || status.Version != 106 {
|
||||
t.Fatalf("migration status = %+v, want clean version 106", status)
|
||||
if status.Dirty || status.Empty || status.Version != 107 {
|
||||
t.Fatalf("migration status = %+v, want clean version 107", status)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,7 +200,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
|
||||
}
|
||||
|
|
@ -444,7 +444,7 @@ func escapeLike(s string) string {
|
|||
}
|
||||
|
||||
func userFromModel(r sqlcgen.User) domain.User {
|
||||
return domain.User{
|
||||
u := domain.User{
|
||||
ID: r.ID,
|
||||
AccessHash: r.AccessHash,
|
||||
Phone: r.Phone,
|
||||
|
|
@ -465,7 +465,17 @@ func userFromModel(r sqlcgen.User) domain.User {
|
|||
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 peerColorFromModel(hasColor bool, color int32, backgroundEmojiID int64) domain.PeerColor {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue