feat: sync account freeze lifecycle

This commit is contained in:
A 2026-07-15 20:24:37 +08:00
parent 76bfc5100f
commit 47fcf0ea41
40 changed files with 1363 additions and 196 deletions

View file

@ -166,57 +166,66 @@ func scanAdminCommand(row pgx.Row) (domain.AdminCommand, error) {
return cmd, nil
}
func (s *AdminStore) GetSendRestriction(ctx context.Context, userID int64) (domain.AccountSendRestriction, bool, error) {
func (s *AdminStore) GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) {
row := s.db.QueryRow(ctx, `
SELECT user_id, frozen, reason, actor, command_id, updated_at
FROM account_send_restrictions
SELECT user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
FROM account_restrictions
WHERE user_id = $1`, userID)
r, err := scanSendRestriction(row)
r, err := scanAccountFreeze(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountSendRestriction{}, false, nil
return domain.AccountFreeze{}, false, nil
}
return domain.AccountSendRestriction{}, false, fmt.Errorf("get send restriction: %w", err)
return domain.AccountFreeze{}, false, fmt.Errorf("get account freeze: %w", err)
}
return r, true, nil
}
func (s *AdminStore) SetSendRestriction(ctx context.Context, restriction domain.AccountSendRestriction) (domain.AccountSendRestriction, error) {
func (s *AdminStore) SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, error) {
var since, until any
if freeze.Frozen {
since = freeze.Since
until = freeze.Until
}
row := s.db.QueryRow(ctx, `
INSERT INTO account_send_restrictions (user_id, frozen, reason, actor, command_id, updated_at)
VALUES ($1,$2,$3,$4,$5,now())
INSERT INTO account_restrictions (
user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,now())
ON CONFLICT (user_id) DO UPDATE SET
frozen = EXCLUDED.frozen,
frozen_since = EXCLUDED.frozen_since,
frozen_until = EXCLUDED.frozen_until,
appeal_url = EXCLUDED.appeal_url,
reason = EXCLUDED.reason,
actor = EXCLUDED.actor,
command_id = EXCLUDED.command_id,
updated_at = now()
RETURNING user_id, frozen, reason, actor, command_id, updated_at`,
restriction.UserID, restriction.Frozen, restriction.Reason, restriction.Actor, restriction.CommandID,
RETURNING user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at`,
freeze.UserID, freeze.Frozen, since, until, freeze.AppealURL, freeze.Reason, freeze.Actor, freeze.CommandID,
)
out, err := scanSendRestriction(row)
out, err := scanAccountFreeze(row)
if err != nil {
return domain.AccountSendRestriction{}, fmt.Errorf("set send restriction: %w", err)
return domain.AccountFreeze{}, fmt.Errorf("set account freeze: %w", err)
}
return out, nil
}
func (s *AdminStore) IsSendFrozen(ctx context.Context, userID int64) (bool, error) {
var frozen bool
if err := s.db.QueryRow(ctx, `SELECT frozen FROM account_send_restrictions WHERE user_id = $1`, userID).Scan(&frozen); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("check send restriction: %w", err)
}
return frozen, nil
}
func scanSendRestriction(row pgx.Row) (domain.AccountSendRestriction, error) {
var r domain.AccountSendRestriction
func scanAccountFreeze(row pgx.Row) (domain.AccountFreeze, error) {
var r domain.AccountFreeze
var since, until pgtype.Timestamptz
var updated time.Time
if err := row.Scan(&r.UserID, &r.Frozen, &r.Reason, &r.Actor, &r.CommandID, &updated); err != nil {
return domain.AccountSendRestriction{}, err
if err := row.Scan(
&r.UserID, &r.Frozen, &since, &until, &r.AppealURL,
&r.Reason, &r.Actor, &r.CommandID, &updated,
); err != nil {
return domain.AccountFreeze{}, err
}
if since.Valid {
r.Since = since.Time
}
if until.Valid {
r.Until = until.Time
}
r.UpdatedAt = updated
return r, nil

View file

@ -0,0 +1,109 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/deploy"
"telesrv/internal/domain"
)
func TestAccountFreezeMigrationAndStoreRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(ctx) }()
downSQL, err := deploy.Migrations.ReadFile("migrations/0088_account_freeze_state.down.sql")
if err != nil {
t.Fatal(err)
}
upSQL, err := deploy.Migrations.ReadFile("migrations/0088_account_freeze_state.up.sql")
if err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(ctx, string(downSQL)); err != nil {
t.Fatalf("roll schema back to legacy restriction shape: %v", err)
}
const (
frozenUserID = int64(1999999881)
activeUserID = int64(1999999882)
)
for _, user := range []struct {
id int64
phone string
}{{frozenUserID, "1999999881"}, {activeUserID, "1999999882"}} {
if _, err := tx.Exec(ctx, `
INSERT INTO users (id, access_hash, phone, first_name)
VALUES ($1, $1, $2, 'Freeze migration test')`, user.id, user.phone); err != nil {
t.Fatalf("insert migration user %d: %v", user.id, err)
}
}
legacyUpdatedAt := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
if _, err := tx.Exec(ctx, `
INSERT INTO account_send_restrictions (user_id, frozen, reason, actor, command_id, updated_at)
VALUES ($1, true, 'legacy freeze', 'ops', 'legacy-freeze', $2)`, frozenUserID, legacyUpdatedAt); err != nil {
t.Fatalf("insert legacy restriction: %v", err)
}
if _, err := tx.Exec(ctx, string(upSQL)); err != nil {
t.Fatalf("apply account freeze migration: %v", err)
}
store := NewAdminStore(tx)
migrated, found, err := store.GetAccountFreeze(ctx, frozenUserID)
if err != nil || !found {
t.Fatalf("GetAccountFreeze migrated = %+v found=%v err=%v", migrated, found, err)
}
if !migrated.Frozen || !migrated.Since.Equal(legacyUpdatedAt) ||
!migrated.Until.Equal(legacyUpdatedAt.Add(7*24*time.Hour)) || migrated.AppealURL != "https://t.me/SpamBot" {
t.Fatalf("migrated freeze = %+v", migrated)
}
since := time.Date(2026, 7, 15, 2, 0, 0, 0, time.UTC)
want := domain.AccountFreeze{
UserID: activeUserID,
Frozen: true,
Since: since,
Until: since.Add(48 * time.Hour),
AppealURL: "https://appeals.example.test/users/1999999882",
Reason: "abuse review",
Actor: "ops",
CommandID: "freeze-round-trip",
}
if _, err := store.SetAccountFreeze(ctx, want); err != nil {
t.Fatalf("SetAccountFreeze active: %v", err)
}
got, found, err := store.GetAccountFreeze(ctx, activeUserID)
if err != nil || !found || !got.Frozen || !got.Since.Equal(want.Since) ||
!got.Until.Equal(want.Until) || got.AppealURL != want.AppealURL {
t.Fatalf("active round trip = %+v found=%v err=%v", got, found, err)
}
if _, err := store.SetAccountFreeze(ctx, domain.AccountFreeze{
UserID: activeUserID, Reason: "appeal accepted", Actor: "ops", CommandID: "unfreeze-round-trip",
}); err != nil {
t.Fatalf("SetAccountFreeze inactive: %v", err)
}
got, found, err = store.GetAccountFreeze(ctx, activeUserID)
if err != nil || !found || got.Frozen || !got.Since.IsZero() || !got.Until.IsZero() || got.AppealURL != "" {
t.Fatalf("inactive round trip = %+v found=%v err=%v", got, found, err)
}
if _, err := tx.Exec(ctx, "SAVEPOINT invalid_freeze"); err != nil {
t.Fatal(err)
}
_, invalidErr := tx.Exec(ctx, `
UPDATE account_restrictions
SET frozen = true, frozen_since = NULL, frozen_until = NULL, appeal_url = ''
WHERE user_id = $1`, activeUserID)
if invalidErr == nil {
t.Fatal("database accepted an active freeze without client-visible state")
}
if _, err := tx.Exec(ctx, "ROLLBACK TO SAVEPOINT invalid_freeze"); err != nil {
t.Fatalf("rollback invalid freeze savepoint: %v", err)
}
}

View file

@ -53,13 +53,16 @@ type AccountReactionSetting struct {
UpdatedAt pgtype.Timestamptz
}
type AccountSendRestriction struct {
UserID int64
Frozen bool
Reason string
Actor string
CommandID string
UpdatedAt pgtype.Timestamptz
type AccountRestriction struct {
UserID int64
Frozen bool
Reason string
Actor string
CommandID string
UpdatedAt pgtype.Timestamptz
FrozenSince pgtype.Timestamptz
FrozenUntil pgtype.Timestamptz
AppealUrl string
}
type AccountSetting struct {
@ -131,6 +134,17 @@ type AiComposeToneSafe struct {
SavedAt pgtype.Timestamptz
}
// Durable pre-send binding from album item random_id to grouped_id; never reconstructed from a retry subset.
type AlbumGroupReservation struct {
SenderUserID int64
PeerType string
PeerID int64
RandomID int64
IntentHash []byte
GroupedID int64
CreatedAt pgtype.Timestamptz
}
type AppConfig struct {
Client string
Hash int32
@ -164,18 +178,28 @@ type AttachMenuUserState struct {
}
type AuthKey struct {
AuthKeyID int64
Body []byte
ServerSalt int64
CreatedAt pgtype.Timestamptz
AuthKeyID int64
Body []byte
ServerSalt int64
CreatedAt pgtype.Timestamptz
Layer int32
DeviceModel string
Platform string
SystemVersion string
ApiID int32
AppVersion string
LastUsedAt pgtype.Timestamptz
ExpiresAt int32
LayerObservationID int64
}
type AuthKeySessionLayer struct {
RawAuthKeyID int64
SessionID int64
Layer int32
DeviceModel string
Platform string
SystemVersion string
ApiID int32
AppVersion string
LastUsedAt pgtype.Timestamptz
ExpiresAt int32
MsgID int64
ObservationID int64
ExpiresAt pgtype.Timestamptz
}
type Authorization struct {
@ -628,6 +652,7 @@ type ChannelMessage struct {
DeletePtsCount int32
DeleteDate int32
DeleteMessageIds []byte
RequestFingerprint []byte
}
type ChannelMessageMedium struct {
@ -1033,6 +1058,20 @@ type LangPackString struct {
UpdatedAt pgtype.Timestamptz
}
type LoginCodeMessageDelivery struct {
// SHA-256(phone_code_hash); raw phone_code_hash is never persisted
DeliveryKey []byte
// HMAC-SHA-256(code), keyed by the non-persisted raw phone_code_hash
CodeFingerprint []byte
UserID int64
PrivateMessageID int64
MessageBoxID int32
Pts int32
MessageDate int32
CreatedAt pgtype.Timestamptz
ExpiresAt pgtype.Timestamptz
}
type MessageBox struct {
OwnerUserID int64
BoxID int32
@ -1132,6 +1171,14 @@ type PeerStarGift struct {
SavedID int64
}
type PeerTranslationSetting struct {
UserID int64
PeerType string
PeerID int64
Disabled bool
UpdatedAt pgtype.Timestamptz
}
type PeerUsername struct {
UsernameLower string
PeerType string
@ -1631,6 +1678,7 @@ type UserStickerCollection struct {
Kind string
DocumentID int64
UsedAt int32
OrderKey int64
}
type UserStickerSet struct {