Initial open source release

This commit is contained in:
A 2026-06-04 01:37:39 +08:00
commit 74992e893f
377 changed files with 118084 additions and 0 deletions

View file

@ -0,0 +1,126 @@
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// PasswordStore 用 PostgreSQL 实现 store.PasswordStore。
type PasswordStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewPasswordStore 基于 pgx 连接池(或事务)创建 PasswordStore。
func NewPasswordStore(db sqlcgen.DBTX) *PasswordStore {
return &PasswordStore{db: db, q: sqlcgen.New(db)}
}
func (s *PasswordStore) GetByUser(ctx context.Context, userID int64) (domain.PasswordSettings, bool, error) {
row, err := s.q.GetPasswordByUser(ctx, userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.PasswordSettings{}, false, nil
}
return domain.PasswordSettings{}, false, fmt.Errorf("get account password: %w", err)
}
return domain.PasswordSettings{
HasRecovery: row.HasRecovery,
HasSecureValues: row.HasSecureValues,
HasPassword: row.HasPassword,
Hint: row.Hint,
EmailUnconfirmedPattern: row.EmailUnconfirmedPattern,
LoginEmailPattern: row.LoginEmailPattern,
SecureRandom: append([]byte(nil), row.SecureRandom...),
}, true, nil
}
func (s *PasswordStore) Save(ctx context.Context, userID int64, settings domain.PasswordSettings) error {
if err := s.q.UpsertPassword(ctx, sqlcgen.UpsertPasswordParams{
UserID: userID,
HasRecovery: settings.HasRecovery,
HasSecureValues: settings.HasSecureValues,
HasPassword: settings.HasPassword,
Hint: settings.Hint,
EmailUnconfirmedPattern: settings.EmailUnconfirmedPattern,
LoginEmailPattern: settings.LoginEmailPattern,
SecureRandom: settings.SecureRandom,
}); err != nil {
return fmt.Errorf("upsert account password: %w", err)
}
return nil
}
func (s *PasswordStore) GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, bool, error) {
row := s.db.QueryRow(ctx, `
SELECT messages_notify_from, stories_notify_from, poll_votes_notify_from, show_previews,
default_reaction_type, default_reaction_value,
paid_privacy_kind, paid_privacy_peer_type, paid_privacy_peer_id
FROM account_reaction_settings
WHERE user_id = $1`, userID)
var messagesFrom, storiesFrom, pollVotesFrom string
var defaultType, defaultValue string
var paidKind string
var paidPeerType sql.NullString
var paidPeerID sql.NullInt64
settings := domain.DefaultAccountReactionSettings()
if err := row.Scan(
&messagesFrom, &storiesFrom, &pollVotesFrom, &settings.Notify.ShowPreviews,
&defaultType, &defaultValue, &paidKind, &paidPeerType, &paidPeerID,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountReactionSettings{}, false, nil
}
return domain.AccountReactionSettings{}, false, fmt.Errorf("get account reaction settings: %w", err)
}
settings.Notify.MessagesFrom = domain.ReactionNotifyFrom(messagesFrom)
settings.Notify.StoriesFrom = domain.ReactionNotifyFrom(storiesFrom)
settings.Notify.PollVotesFrom = domain.ReactionNotifyFrom(pollVotesFrom)
settings.DefaultReaction = domain.MessageReaction{Type: domain.MessageReactionType(defaultType), Emoticon: defaultValue}
settings.PaidPrivacy = domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyKind(paidKind)}
if settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyPeer && paidPeerType.Valid && paidPeerID.Valid {
peer := domain.Peer{Type: domain.PeerType(paidPeerType.String), ID: paidPeerID.Int64}
settings.PaidPrivacy.Peer = &peer
}
return settings, true, nil
}
func (s *PasswordStore) SaveReactionSettings(ctx context.Context, userID int64, settings domain.AccountReactionSettings) error {
var paidPeerType any
var paidPeerID any
if settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyPeer && settings.PaidPrivacy.Peer != nil {
paidPeerType = string(settings.PaidPrivacy.Peer.Type)
paidPeerID = settings.PaidPrivacy.Peer.ID
}
if _, err := s.db.Exec(ctx, `
INSERT INTO account_reaction_settings (
user_id, messages_notify_from, stories_notify_from, poll_votes_notify_from, show_previews,
default_reaction_type, default_reaction_value, paid_privacy_kind, paid_privacy_peer_type, paid_privacy_peer_id
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (user_id) DO UPDATE SET
messages_notify_from = EXCLUDED.messages_notify_from,
stories_notify_from = EXCLUDED.stories_notify_from,
poll_votes_notify_from = EXCLUDED.poll_votes_notify_from,
show_previews = EXCLUDED.show_previews,
default_reaction_type = EXCLUDED.default_reaction_type,
default_reaction_value = EXCLUDED.default_reaction_value,
paid_privacy_kind = EXCLUDED.paid_privacy_kind,
paid_privacy_peer_type = EXCLUDED.paid_privacy_peer_type,
paid_privacy_peer_id = EXCLUDED.paid_privacy_peer_id,
updated_at = now()`,
userID,
string(settings.Notify.MessagesFrom), string(settings.Notify.StoriesFrom), string(settings.Notify.PollVotesFrom), settings.Notify.ShowPreviews,
string(settings.DefaultReaction.Type), settings.DefaultReaction.Emoticon,
string(settings.PaidPrivacy.Kind), paidPeerType, paidPeerID,
); err != nil {
return fmt.Errorf("save account reaction settings: %w", err)
}
return nil
}

View file

@ -0,0 +1,86 @@
package postgres
import (
"context"
"crypto/rand"
"fmt"
"strings"
"testing"
"time"
appauth "telesrv/internal/app/auth"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
func TestAuthSignUpWritesOfficialLoginMessagePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
phone := fmt.Sprintf("1555%d31", time.Now().UnixNano())
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE phone = $1", phone)
})
users := NewUserStore(pool)
dialogs := NewDialogStore(pool)
messages := NewMessageStore(pool)
svc := appauth.NewService(
users,
NewAuthorizationStore(pool),
memory.NewCodeStore(),
nil,
nil,
"12345",
appauth.WithLoginMessages(messages, dialogs),
)
var authKeyID [8]byte
var authKeyBody [256]byte
if _, err := rand.Read(authKeyID[:]); err != nil {
t.Fatal(err)
}
if _, err := rand.Read(authKeyBody[:]); err != nil {
t.Fatal(err)
}
if err := NewAuthKeyStore(pool).Save(ctx, store.AuthKeyData{ID: authKeyID, Value: authKeyBody}); err != nil {
t.Fatalf("save auth key: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(authKeyID))
})
hash, err := svc.SendCode(ctx, phone)
if err != nil {
t.Fatalf("SendCode: %v", err)
}
if _, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "12345"); err != nil || !needSignUp {
t.Fatalf("SignIn needSignUp = %v err = %v, want need sign-up", needSignUp, err)
}
u, msg, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "PgLogin", "Test")
if err != nil {
t.Fatalf("SignUp: %v", err)
}
if u.Phone != phone || msg.ID == 0 || !strings.Contains(msg.Body, "Login code: 12345") {
t.Fatalf("sign-up user/message = user %+v message %+v, want login message", u, msg)
}
systemUser, found, err := users.ByID(ctx, domain.OfficialSystemUserID)
if err != nil || !found || !systemUser.Verified || !systemUser.Support {
t.Fatalf("official system user = %+v found=%v err=%v, want seeded verified support user", systemUser, found, err)
}
list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("ListByUser: %v", err)
}
if len(list.Dialogs) != 1 || list.Dialogs[0].Peer.ID != domain.OfficialSystemUserID {
t.Fatalf("dialogs = %+v, want official login dialog", list.Dialogs)
}
if len(list.Messages) != 1 || list.Messages[0].ID != msg.ID || !strings.Contains(list.Messages[0].Body, "Login code: 12345") {
t.Fatalf("messages = %+v, want returned login message", list.Messages)
}
if len(list.Users) != 1 || list.Users[0].ID != domain.OfficialSystemUserID || !list.Users[0].Verified || !list.Users[0].Support {
t.Fatalf("users = %+v, want official support user", list.Users)
}
}

View file

@ -0,0 +1,67 @@
package postgres
import (
"context"
"encoding/binary"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// AuthKeyStore 用 PostgreSQL 实现 store.AuthKeyStore。
type AuthKeyStore struct {
q *sqlcgen.Queries
}
// NewAuthKeyStore 基于 pgx 连接池(或事务)创建 AuthKeyStore。
func NewAuthKeyStore(db sqlcgen.DBTX) *AuthKeyStore {
return &AuthKeyStore{q: sqlcgen.New(db)}
}
// Save 实现 store.AuthKeyStore。auth_key_id 以小端解释为 int64 存入 BIGINT
// created_at 交由 DB 默认值now()),故传入的 CreatedAt 不落库。
func (s *AuthKeyStore) Save(ctx context.Context, k store.AuthKeyData) error {
if err := s.q.UpsertAuthKey(ctx, sqlcgen.UpsertAuthKeyParams{
AuthKeyID: authKeyIDToInt64(k.ID),
Body: k.Value[:],
ServerSalt: k.ServerSalt,
}); err != nil {
return fmt.Errorf("upsert auth key: %w", err)
}
return nil
}
// Get 实现 store.AuthKeyStore。不存在时 found=false。
func (s *AuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
row, err := s.q.GetAuthKey(ctx, authKeyIDToInt64(id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return store.AuthKeyData{}, false, nil
}
return store.AuthKeyData{}, false, fmt.Errorf("get auth key: %w", err)
}
if len(row.Body) != len(store.AuthKeyData{}.Value) {
return store.AuthKeyData{}, false, fmt.Errorf("auth key body length = %d, want 256", len(row.Body))
}
data := store.AuthKeyData{ID: id, ServerSalt: row.ServerSalt}
copy(data.Value[:], row.Body)
if row.CreatedAt.Valid {
data.CreatedAt = row.CreatedAt.Time.Unix()
}
return data, true, nil
}
// authKeyIDToInt64 把 [8]byte 的 auth_key_id 按小端解释为 int64MTProto 定义即 SHA1 低 64 位)。
func authKeyIDToInt64(id [8]byte) int64 {
return int64(binary.LittleEndian.Uint64(id[:]))
}
func authKeyIDFromInt64(v int64) [8]byte {
var id [8]byte
binary.LittleEndian.PutUint64(id[:], uint64(v))
return id
}

View file

@ -0,0 +1,72 @@
package postgres
import (
"context"
"crypto/rand"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/store"
)
// testPool 连接 TELESRV_TEST_POSTGRES_DSN 指向的库(迁移到最新),未设则跳过。
func testPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
}
if err := Migrate(dsn); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := Open(context.Background(), dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
// TestAuthKeyStoreRoundTrip 验证 auth_key 落 PG 后,用全新 store 实例(模拟进程重启、无内存缓存)能原样读回。
// 这是「server 重启保住 auth_key」的直接证明。
func TestAuthKeyStoreRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
var id [8]byte
var val [256]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatal(err)
}
if _, err := rand.Read(val[:]); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(id))
})
want := store.AuthKeyData{ID: id, Value: val, ServerSalt: 0x0badf00d}
if err := NewAuthKeyStore(pool).Save(ctx, want); err != nil {
t.Fatalf("save: %v", err)
}
got, found, err := NewAuthKeyStore(pool).Get(ctx, id)
if err != nil {
t.Fatalf("get: %v", err)
}
if !found {
t.Fatal("auth key not found after save (重启后丢失)")
}
if got.ID != want.ID || got.Value != want.Value || got.ServerSalt != want.ServerSalt {
t.Fatalf("round trip mismatch: got salt=%#x value[:4]=%x, want salt=%#x value[:4]=%x",
got.ServerSalt, got.Value[:4], want.ServerSalt, want.Value[:4])
}
var missing [8]byte
missing[0] = id[0] ^ 0xff
if _, found, err := NewAuthKeyStore(pool).Get(ctx, missing); err != nil || found {
t.Fatalf("missing key: found=%v err=%v, want found=false err=nil", found, err)
}
}

View file

@ -0,0 +1,93 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// AuthorizationStore 用 PostgreSQL 实现 store.AuthorizationStore。
type AuthorizationStore struct {
q *sqlcgen.Queries
}
// NewAuthorizationStore 基于 pgx 连接池(或事务)创建 AuthorizationStore。
func NewAuthorizationStore(db sqlcgen.DBTX) *AuthorizationStore {
return &AuthorizationStore{q: sqlcgen.New(db)}
}
func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) error {
if err := s.q.UpsertAuthorization(ctx, sqlcgen.UpsertAuthorizationParams{
AuthKeyID: authKeyIDToInt64(a.AuthKeyID),
UserID: a.UserID,
Layer: int32(a.Layer),
DeviceModel: a.DeviceModel,
Platform: a.Platform,
SystemVersion: a.SystemVersion,
ApiID: int32(a.APIID),
AppVersion: a.AppVersion,
Ip: a.IP,
}); err != nil {
return fmt.Errorf("upsert authorization: %w", err)
}
return nil
}
func (s *AuthorizationStore) ByAuthKey(ctx context.Context, id [8]byte) (domain.Authorization, bool, error) {
row, err := s.q.GetAuthorizationByAuthKey(ctx, authKeyIDToInt64(id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Authorization{}, false, nil
}
return domain.Authorization{}, false, fmt.Errorf("get authorization: %w", err)
}
return domain.Authorization{
AuthKeyID: id,
UserID: row.UserID,
Layer: int(row.Layer),
DeviceModel: row.DeviceModel,
Platform: row.Platform,
SystemVersion: row.SystemVersion,
APIID: int(row.ApiID),
AppVersion: row.AppVersion,
IP: row.Ip,
}, true, nil
}
func (s *AuthorizationStore) ListByUser(ctx context.Context, userID int64) ([]domain.Authorization, error) {
rows, err := s.q.ListAuthorizationsByUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("list authorizations by user: %w", err)
}
out := make([]domain.Authorization, 0, len(rows))
for _, row := range rows {
out = append(out, authorizationFromRow(row))
}
return out, nil
}
func (s *AuthorizationStore) Delete(ctx context.Context, id [8]byte) error {
if err := s.q.DeleteAuthorization(ctx, authKeyIDToInt64(id)); err != nil {
return fmt.Errorf("delete authorization: %w", err)
}
return nil
}
func authorizationFromRow(row sqlcgen.Authorization) domain.Authorization {
return domain.Authorization{
AuthKeyID: authKeyIDFromInt64(row.AuthKeyID),
UserID: row.UserID,
Layer: int(row.Layer),
DeviceModel: row.DeviceModel,
Platform: row.Platform,
SystemVersion: row.SystemVersion,
APIID: int(row.ApiID),
AppVersion: row.AppVersion,
IP: row.Ip,
}
}

View file

@ -0,0 +1,288 @@
package postgres
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func TestBusinessStoresRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
var authID [8]byte
var authBody [256]byte
if _, err := rand.Read(authID[:]); err != nil {
t.Fatal(err)
}
if _, err := rand.Read(authBody[:]); err != nil {
t.Fatal(err)
}
if err := NewAuthKeyStore(pool).Save(ctx, store.AuthKeyData{
ID: authID,
Value: authBody,
ServerSalt: 42,
}); err != nil {
t.Fatalf("save auth key: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(authID))
})
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: 1,
Phone: "+1555" + suffix + "01",
FirstName: "Owner",
})
if err != nil {
t.Fatalf("create owner: %v", err)
}
if owner.ID < domain.UserIDSequenceBase {
t.Fatalf("owner id = %d, want >= base %d", owner.ID, domain.UserIDSequenceBase)
}
friend, err := users.Create(ctx, domain.User{
AccessHash: 2,
Phone: "+1555" + suffix + "02",
FirstName: "Friend",
})
if err != nil {
t.Fatalf("create friend: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID})
})
username := "owner_" + suffix
owner, err = users.UpdateUsername(ctx, owner.ID, username)
if err != nil {
t.Fatalf("update owner username: %v", err)
}
if owner.Username != username {
t.Fatalf("owner username = %q, want %q", owner.Username, username)
}
byUsername, found, err := users.ByUsername(ctx, strings.ToUpper(username))
if err != nil || !found || byUsername.ID != owner.ID {
t.Fatalf("by username = user %+v found %v err %v, want owner", byUsername, found, err)
}
if _, err := users.UpdateUsername(ctx, friend.ID, strings.ToUpper(username)); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("duplicate username err = %v, want username occupied", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO contacts (user_id, contact_user_id, mutual)
VALUES ($1, $2, true)
`, owner.ID, friend.ID); err != nil {
t.Fatalf("insert contact: %v", err)
}
contacts, err := NewContactStore(pool).ListByUser(ctx, owner.ID)
if err != nil {
t.Fatalf("list contacts: %v", err)
}
if len(contacts.Contacts) != 1 || contacts.Contacts[0].User.ID != friend.ID || !contacts.Contacts[0].Mutual {
t.Fatalf("contacts = %+v, want friend mutual contact", contacts)
}
if contacts.Hash == 0 {
t.Fatal("contacts hash is zero for non-empty contact list")
}
search, err := users.Search(ctx, owner.ID, "Friend", "", 10)
if err != nil {
t.Fatalf("search users: %v", err)
}
if len(search.MyResults) != 1 || search.MyResults[0].ID != friend.ID || !search.MyResults[0].Contact {
t.Fatalf("search contacts = %+v, want friend in my results", search)
}
if _, err := pool.Exec(ctx, `
INSERT INTO dialogs (user_id, peer_type, peer_id, top_message_id, unread_count, pinned)
VALUES ($1, 'user', $2, 10, 2, true)
`, owner.ID, friend.ID); err != nil {
t.Fatalf("insert dialog: %v", err)
}
dialogs, err := NewDialogStore(pool).ListByUser(ctx, owner.ID, domain.DialogFilter{PinnedOnly: true, Limit: 10})
if err != nil {
t.Fatalf("list dialogs: %v", err)
}
if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].Peer.ID != friend.ID || !dialogs.Dialogs[0].Pinned {
t.Fatalf("dialogs = %+v, want pinned friend dialog", dialogs)
}
emptyDialogsPage, err := NewDialogStore(pool).ListByUser(ctx, owner.ID, domain.DialogFilter{
PinnedOnly: true,
OffsetID: dialogs.Dialogs[0].TopMessage,
HasOffsetPeer: true,
OffsetPeer: dialogs.Dialogs[0].Peer,
Limit: 10,
})
if err != nil {
t.Fatalf("list empty dialogs page: %v", err)
}
if len(emptyDialogsPage.Dialogs) != 0 || emptyDialogsPage.Count != dialogs.Count || emptyDialogsPage.Hash != dialogs.Hash {
t.Fatalf("empty dialogs page = %+v, want empty rows with full count/hash from %+v", emptyDialogsPage, dialogs)
}
peerDialogs, err := NewDialogStore(pool).ListByPeers(ctx, owner.ID, []domain.Peer{
{Type: domain.PeerTypeUser, ID: friend.ID},
{Type: domain.PeerTypeUser, ID: owner.ID},
})
if err != nil {
t.Fatalf("list peer dialogs: %v", err)
}
if len(peerDialogs.Dialogs) != 2 || peerDialogs.Dialogs[0].Peer.ID != friend.ID || peerDialogs.Dialogs[0].TopMessage != 10 {
t.Fatalf("peer dialogs = %+v, want friend dialog plus owner placeholder", peerDialogs)
}
if peerDialogs.Dialogs[1].Peer.ID != owner.ID || peerDialogs.Dialogs[1].TopMessage != 0 {
t.Fatalf("placeholder dialog = %+v, want owner empty dialog", peerDialogs.Dialogs[1])
}
if len(peerDialogs.Users) != 2 {
t.Fatalf("peer dialog users = %+v, want both requested users", peerDialogs.Users)
}
wantState := domain.UpdateState{Pts: 11, Qts: 12, Date: 13, Seq: 14}
states := NewUpdateStateStore(pool)
if err := states.Save(ctx, authID, owner.ID, wantState); err != nil {
t.Fatalf("save update state: %v", err)
}
gotState, found, err := states.Get(ctx, authID, owner.ID)
if err != nil {
t.Fatalf("get update state: %v", err)
}
if !found || gotState != wantState {
t.Fatalf("update state = %+v found=%v, want %+v found=true", gotState, found, wantState)
}
if err := states.Delete(ctx, authID, owner.ID); err != nil {
t.Fatalf("delete update state: %v", err)
}
if _, found, err := states.Get(ctx, authID, owner.ID); err != nil || found {
t.Fatalf("state after delete found=%v err=%v, want not found", found, err)
}
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
TempAuthKeyID: authID,
PermAuthKeyID: 12345,
Nonce: 67890,
TempSessionID: 24680,
ExpiresAt: 111,
EncryptedMessage: []byte("binding"),
}); err != nil {
t.Fatalf("save temp auth key binding: %v", err)
}
var bindingCount int
var tempSessionID int64
if err := pool.QueryRow(ctx, "SELECT count(*), coalesce(max(temp_session_id), 0) FROM temp_auth_key_bindings WHERE temp_auth_key_id = $1", authKeyIDToInt64(authID)).Scan(&bindingCount, &tempSessionID); err != nil {
t.Fatalf("count temp auth key binding: %v", err)
}
if bindingCount != 1 || tempSessionID != 24680 {
t.Fatalf("temp auth key binding count/session = %d/%d, want 1/24680", bindingCount, tempSessionID)
}
passwords := NewPasswordStore(pool)
wantPassword := domain.PasswordSettings{
Hint: "dev",
SecureRandom: []byte("secure-random"),
}
if err := passwords.Save(ctx, owner.ID, wantPassword); err != nil {
t.Fatalf("save password settings: %v", err)
}
gotPassword, found, err := passwords.GetByUser(ctx, owner.ID)
if err != nil {
t.Fatalf("get password settings: %v", err)
}
if !found || gotPassword.Hint != wantPassword.Hint || string(gotPassword.SecureRandom) != string(wantPassword.SecureRandom) {
t.Fatalf("password settings = %+v found=%v, want %+v found=true", gotPassword, found, wantPassword)
}
help := NewHelpStore(pool)
client := "tdesktop-test-" + suffix
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM app_configs WHERE client = $1", client)
})
if err := help.UpsertAppConfig(ctx, domain.AppConfig{Client: client, Hash: 9, JSON: []byte(`{"test":true}`)}); err != nil {
t.Fatalf("upsert app config: %v", err)
}
cfg, found, err := help.GetAppConfig(ctx, client)
if err != nil {
t.Fatalf("get app config: %v", err)
}
if !found || cfg.Hash != 9 || string(cfg.JSON) != `{"test": true}` {
t.Fatalf("app config = %+v found=%v, want hash=9 json", cfg, found)
}
countryISO := "T" + suffix[:1]
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM countries WHERE iso2 = $1", countryISO)
})
if err := help.UpsertCountries(ctx, []domain.Country{{
ISO2: countryISO,
DefaultName: "Testland",
CountryCodes: []domain.CountryCode{
{CountryCode: "999", Prefixes: []string{"999"}, Patterns: []string{"XXX"}},
},
}}); err != nil {
t.Fatalf("upsert countries: %v", err)
}
countryList, err := help.ListCountries(ctx, "en")
if err != nil {
t.Fatalf("list countries: %v", err)
}
var foundCountry bool
for _, country := range countryList.Countries {
if country.ISO2 == countryISO && len(country.CountryCodes) == 1 && country.CountryCodes[0].CountryCode == "999" {
foundCountry = true
}
}
if !foundCountry {
t.Fatalf("country %s not found in %+v", countryISO, countryList)
}
langCode := "test-" + suffix
lang := NewLangPackStore(pool)
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM lang_packs WHERE lang_pack = $1 AND lang_code = $2", "tdesktop", langCode)
})
if err := lang.UpsertPack(ctx, domain.LangPack{
LangPack: "tdesktop",
LangCode: langCode,
Version: 7,
Strings: []domain.LangPackString{
{Key: "lng_test", Value: "Test"},
{Key: "lng_items", Pluralized: true, OneValue: "{count} item", OtherValue: "{count} items"},
},
}); err != nil {
t.Fatalf("upsert lang pack: %v", err)
}
pack, err := lang.GetPack(ctx, "tdesktop", langCode, 0)
if err != nil {
t.Fatalf("get lang pack: %v", err)
}
if pack.Version != 7 || len(pack.Strings) != 2 {
t.Fatalf("lang pack = %+v, want version 7 with 2 strings", pack)
}
notModified, err := lang.GetPack(ctx, "tdesktop", langCode, 7)
if err != nil {
t.Fatalf("get lang pack not modified: %v", err)
}
if notModified.Version != 7 || len(notModified.Strings) != 0 {
t.Fatalf("not modified pack = %+v, want version 7 with no strings", notModified)
}
selected, err := lang.GetStrings(ctx, "tdesktop", langCode, []string{"lng_test"})
if err != nil {
t.Fatalf("get lang pack strings: %v", err)
}
if len(selected.Strings) != 1 || selected.Strings[0].Value != "Test" {
t.Fatalf("selected strings = %+v, want lng_test=Test", selected.Strings)
}
}
func randomSuffix(t *testing.T) string {
t.Helper()
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
t.Fatal(err)
}
return fmt.Sprintf("%s", hex.EncodeToString(b[:]))
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,393 @@
package postgres
import (
"context"
"encoding/binary"
"errors"
"fmt"
"hash/fnv"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// ContactStore 用 PostgreSQL 实现 store.ContactStore。
type ContactStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewContactStore 基于 pgx 连接池(或事务)创建 ContactStore。
func NewContactStore(db sqlcgen.DBTX) *ContactStore {
return &ContactStore{db: db, q: sqlcgen.New(db)}
}
func (s *ContactStore) ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) {
rows, err := s.q.ListContactsByUser(ctx, userID)
if err != nil {
return domain.ContactList{}, fmt.Errorf("list contacts: %w", err)
}
out := domain.ContactList{Contacts: make([]domain.Contact, 0, len(rows))}
for _, row := range rows {
contact, err := contactFromListRow(row)
if err != nil {
return domain.ContactList{}, err
}
out.Contacts = append(out.Contacts, contact)
}
out.Hash = contactListHash(out.Contacts)
return out, nil
}
func (s *ContactStore) Get(ctx context.Context, userID, contactUserID int64) (domain.Contact, bool, error) {
row, err := s.q.GetContact(ctx, sqlcgen.GetContactParams{
UserID: userID,
ContactUserID: contactUserID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Contact{}, false, nil
}
return domain.Contact{}, false, fmt.Errorf("get contact: %w", err)
}
contact, err := contactFromGetRow(row)
if err != nil {
return domain.Contact{}, false, err
}
return contact, true, nil
}
func (s *ContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
entities, err := encodeMessageEntities(input.NoteEntities)
if err != nil {
return domain.Contact{}, err
}
row, err := s.q.UpsertContact(ctx, sqlcgen.UpsertContactParams{
UserID: userID,
ContactUserID: input.ContactUserID,
ContactPhone: input.Phone,
ContactFirstName: input.FirstName,
ContactLastName: input.LastName,
Note: input.Note,
NoteEntities: entities,
})
if err != nil {
return domain.Contact{}, fmt.Errorf("upsert contact: %w", err)
}
contact, err := contactFromUpsertRow(row)
if err != nil {
return domain.Contact{}, err
}
return contact, nil
}
const upsertContactsManySQL = `
WITH input AS (
SELECT
$1::bigint AS user_id,
i.contact_user_id,
i.contact_phone,
i.contact_first_name,
i.contact_last_name,
i.note,
i.note_entities_json::jsonb AS note_entities,
i.ord
FROM unnest(
$2::bigint[],
$3::text[],
$4::text[],
$5::text[],
$6::text[],
$7::text[]
) WITH ORDINALITY AS i(contact_user_id, contact_phone, contact_first_name, contact_last_name, note, note_entities_json, ord)
),
reverse AS (
SELECT
i.contact_user_id,
EXISTS (
SELECT 1
FROM contacts c
WHERE c.user_id = i.contact_user_id
AND c.contact_user_id = i.user_id
)::boolean AS mutual
FROM input i
),
upserted AS (
INSERT INTO contacts (
user_id,
contact_user_id,
contact_phone,
contact_first_name,
contact_last_name,
note,
note_entities,
mutual
)
SELECT
i.user_id,
i.contact_user_id,
i.contact_phone,
i.contact_first_name,
i.contact_last_name,
i.note,
i.note_entities,
r.mutual
FROM input i
JOIN reverse r ON r.contact_user_id = i.contact_user_id
ON CONFLICT (user_id, contact_user_id) DO UPDATE SET
contact_phone = EXCLUDED.contact_phone,
contact_first_name = EXCLUDED.contact_first_name,
contact_last_name = EXCLUDED.contact_last_name,
note = EXCLUDED.note,
note_entities = EXCLUDED.note_entities,
mutual = contacts.mutual OR EXCLUDED.mutual,
updated_at = now()
RETURNING *
),
reverse_updated AS (
UPDATE contacts c
SET mutual = true,
updated_at = now()
FROM upserted u
WHERE c.user_id = u.contact_user_id
AND c.contact_user_id = $1::bigint
AND NOT c.mutual
RETURNING c.user_id
)
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at,
EXISTS (SELECT 1 FROM reverse_updated ru WHERE ru.user_id = c.contact_user_id)::boolean AS reverse_mutual_changed
FROM upserted c
JOIN users u ON u.id = c.contact_user_id
JOIN input i ON i.contact_user_id = c.contact_user_id
ORDER BY i.ord
`
func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error) {
if len(inputs) == 0 {
return nil, nil
}
contactUserIDs := make([]int64, 0, len(inputs))
phones := make([]string, 0, len(inputs))
firstNames := make([]string, 0, len(inputs))
lastNames := make([]string, 0, len(inputs))
notes := make([]string, 0, len(inputs))
noteEntities := make([]string, 0, len(inputs))
for _, input := range inputs {
if input.ContactUserID == 0 {
continue
}
raw, err := encodeMessageEntities(input.NoteEntities)
if err != nil {
return nil, err
}
contactUserIDs = append(contactUserIDs, input.ContactUserID)
phones = append(phones, input.Phone)
firstNames = append(firstNames, input.FirstName)
lastNames = append(lastNames, input.LastName)
notes = append(notes, input.Note)
noteEntities = append(noteEntities, string(raw))
}
if len(contactUserIDs) == 0 {
return nil, nil
}
rows, err := s.db.Query(ctx, upsertContactsManySQL, userID, contactUserIDs, phones, firstNames, lastNames, notes, noteEntities)
if err != nil {
return nil, fmt.Errorf("upsert contacts many: %w", err)
}
defer rows.Close()
out := make([]domain.Contact, 0, len(contactUserIDs))
for rows.Next() {
var (
contactUserID int64
mutual bool
contactPhone string
contactFirstName string
contactLastName string
note string
noteEntitiesJSON string
id int64
accessHash int64
phone string
firstName string
lastName string
username string
countryCode string
verified bool
support bool
lastSeenAt int64
reverseMutualChanged bool
)
if err := rows.Scan(
&contactUserID,
&mutual,
&contactPhone,
&contactFirstName,
&contactLastName,
&note,
&noteEntitiesJSON,
&id,
&accessHash,
&phone,
&firstName,
&lastName,
&username,
&countryCode,
&verified,
&support,
&lastSeenAt,
&reverseMutualChanged,
); err != nil {
return nil, fmt.Errorf("scan upsert contacts many: %w", err)
}
_ = reverseMutualChanged
entities, err := decodeMessageEntities(noteEntitiesJSON)
if err != nil {
return nil, fmt.Errorf("decode contact note entities: %w", err)
}
out = append(out, contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate upsert contacts many: %w", err)
}
return out, nil
}
func (s *ContactStore) UpdateNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error) {
raw, err := encodeMessageEntities(entities)
if err != nil {
return domain.Contact{}, false, err
}
row, err := s.q.UpdateContactNote(ctx, sqlcgen.UpdateContactNoteParams{
UserID: userID,
ContactUserID: contactUserID,
Note: note,
NoteEntities: raw,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Contact{}, false, nil
}
return domain.Contact{}, false, fmt.Errorf("update contact note: %w", err)
}
contact, err := contactFromUpdateNoteRow(row)
if err != nil {
return domain.Contact{}, false, err
}
return contact, true, nil
}
func (s *ContactStore) Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) {
if len(contactUserIDs) == 0 {
return 0, nil
}
count, err := s.q.DeleteContacts(ctx, sqlcgen.DeleteContactsParams{
UserID: userID,
ContactUserIds: contactUserIDs,
})
if err != nil {
return 0, fmt.Errorf("delete contacts: %w", err)
}
return int(count), nil
}
func contactFromListRow(row sqlcgen.ListContactsByUserRow) (domain.Contact, error) {
entities, err := decodeMessageEntities(row.NoteEntitiesJson)
if err != nil {
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err)
}
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual), nil
}
func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) {
entities, err := decodeMessageEntities(row.NoteEntitiesJson)
if err != nil {
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err)
}
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual), nil
}
func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error) {
entities, err := decodeMessageEntities(row.NoteEntitiesJson)
if err != nil {
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err)
}
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual), nil
}
func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact, error) {
entities, err := decodeMessageEntities(row.NoteEntitiesJson)
if err != nil {
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err)
}
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual), nil
}
func contactFromFields(id, accessHash int64, phone, firstName, lastName, username, countryCode string, verified, support bool, lastSeenAt int, contactFirstName, contactLastName, contactPhone, note string, noteEntities []domain.MessageEntity, mutual bool) domain.Contact {
return domain.Contact{
User: domain.User{
ID: id,
AccessHash: accessHash,
Phone: phone,
FirstName: firstName,
LastName: lastName,
Username: username,
CountryCode: countryCode,
Verified: verified,
Support: support,
LastSeenAt: lastSeenAt,
Contact: true,
Mutual: mutual,
},
FirstName: contactFirstName,
LastName: contactLastName,
Phone: contactPhone,
Note: note,
NoteEntities: noteEntities,
Mutual: mutual,
}
}
func contactListHash(contacts []domain.Contact) int64 {
if len(contacts) == 0 {
return 0
}
h := fnv.New64a()
var buf [16]byte
for _, c := range contacts {
binary.LittleEndian.PutUint64(buf[:8], uint64(c.User.ID))
if c.Mutual {
buf[8] = 1
} else {
buf[8] = 0
}
_, _ = h.Write(buf[:9])
_, _ = h.Write([]byte(c.FirstName))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(c.LastName))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(c.Phone))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(c.Note))
_, _ = h.Write([]byte{0})
}
return int64(h.Sum64())
}

View file

@ -0,0 +1,279 @@
package postgres
import (
"context"
"testing"
contactsapp "telesrv/internal/app/contacts"
"telesrv/internal/domain"
)
func TestContactProfilesOwnerScopedRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner := createTestUser(t, ctx, users, "+1900"+suffix+"01", "Owner", "One")
altOwner := createTestUser(t, ctx, users, "+1900"+suffix+"02", "AltOwner", "Two")
friend := createTestUser(t, ctx, users, "+1900"+suffix+"03", "Canonical", "Friend")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, altOwner.ID, friend.ID})
})
contactStore := NewContactStore(pool)
contacts := contactsapp.NewService(contactStore, users)
if _, err := contacts.AddContact(ctx, owner.ID, domain.ContactInput{
ContactUserID: friend.ID,
Phone: "10001",
FirstName: "OwnerRemark",
LastName: "A",
Note: "first note",
}); err != nil {
t.Fatalf("owner add contact: %v", err)
}
if _, err := contacts.AddContact(ctx, altOwner.ID, domain.ContactInput{
ContactUserID: friend.ID,
Phone: "20002",
FirstName: "AltRemark",
LastName: "B",
Note: "alt note",
}); err != nil {
t.Fatalf("alt owner add contact: %v", err)
}
ownerList, _, err := contacts.GetContacts(ctx, owner.ID, 0)
if err != nil {
t.Fatalf("owner contacts: %v", err)
}
altList, _, err := contacts.GetContacts(ctx, altOwner.ID, 0)
if err != nil {
t.Fatalf("alt contacts: %v", err)
}
if got := ownerList.Contacts[0].User.FirstName; got != "OwnerRemark" {
t.Fatalf("owner contact first name = %q, want owner remark", got)
}
if got := altList.Contacts[0].User.FirstName; got != "AltRemark" {
t.Fatalf("alt contact first name = %q, want alt remark", got)
}
if ownerList.Hash == 0 || altList.Hash == 0 || ownerList.Hash == altList.Hash {
t.Fatalf("contact hashes owner=%d alt=%d, want non-zero owner-specific hashes", ownerList.Hash, altList.Hash)
}
beforeHash := ownerList.Hash
updated, err := contacts.UpdateContactNote(ctx, owner.ID, friend.ID, "fresh note", []domain.MessageEntity{
{Type: domain.MessageEntityBold, Offset: 0, Length: 5},
})
if err != nil {
t.Fatalf("update contact note: %v", err)
}
if updated.Note != "fresh note" || len(updated.NoteEntities) != 1 {
t.Fatalf("updated contact = %+v, want note with entity", updated)
}
ownerList, _, err = contacts.GetContacts(ctx, owner.ID, 0)
if err != nil {
t.Fatalf("owner contacts after note: %v", err)
}
if ownerList.Hash == beforeHash {
t.Fatalf("owner contact hash did not change after note update: %d", ownerList.Hash)
}
altList, _, err = contacts.GetContacts(ctx, altOwner.ID, 0)
if err != nil {
t.Fatalf("alt contacts after owner note: %v", err)
}
if altList.Contacts[0].Note != "alt note" {
t.Fatalf("alt contact note = %q, want isolated alt note", altList.Contacts[0].Note)
}
if _, err := contacts.AddContact(ctx, friend.ID, domain.ContactInput{
ContactUserID: owner.ID,
FirstName: "OwnerBack",
}); err != nil {
t.Fatalf("friend reciprocal add: %v", err)
}
ownerContact, found, err := contactStore.Get(ctx, owner.ID, friend.ID)
if err != nil {
t.Fatalf("get owner contact: %v", err)
}
if !found || !ownerContact.Mutual {
t.Fatalf("owner contact = %+v found=%v, want mutual after reciprocal add", ownerContact, found)
}
friendContact, found, err := contactStore.Get(ctx, friend.ID, owner.ID)
if err != nil {
t.Fatalf("get friend contact: %v", err)
}
if !found || !friendContact.Mutual {
t.Fatalf("friend contact = %+v found=%v, want mutual after reciprocal add", friendContact, found)
}
deleted, err := contacts.DeleteContacts(ctx, owner.ID, []int64{friend.ID})
if err != nil {
t.Fatalf("delete owner contact: %v", err)
}
if deleted != 1 {
t.Fatalf("deleted = %d, want 1", deleted)
}
if _, found, err := contactStore.Get(ctx, owner.ID, friend.ID); err != nil || found {
t.Fatalf("owner contact after delete found=%v err=%v, want not found", found, err)
}
friendContact, found, err = contactStore.Get(ctx, friend.ID, owner.ID)
if err != nil {
t.Fatalf("get friend contact after delete: %v", err)
}
if !found || friendContact.Mutual {
t.Fatalf("friend contact after delete = %+v found=%v, want reverse mutual cleared", friendContact, found)
}
}
func TestDialogUserViewUsesContactProfileAndDialogFlags(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
ownerA := createTestUser(t, ctx, users, "+1910"+suffix+"01", "OwnerA", "")
ownerB := createTestUser(t, ctx, users, "+1910"+suffix+"02", "OwnerB", "")
friend := createTestUser(t, ctx, users, "+1910"+suffix+"03", "Shared", "Friend")
other := createTestUser(t, ctx, users, "+1910"+suffix+"04", "Other", "Peer")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{ownerA.ID, ownerB.ID, friend.ID, other.ID})
})
contacts := contactsapp.NewService(NewContactStore(pool), users)
if _, err := contacts.AddContact(ctx, ownerA.ID, domain.ContactInput{ContactUserID: friend.ID, FirstName: "RemarkA"}); err != nil {
t.Fatalf("owner A add contact: %v", err)
}
if _, err := contacts.AddContact(ctx, ownerB.ID, domain.ContactInput{ContactUserID: friend.ID, FirstName: "RemarkB"}); err != nil {
t.Fatalf("owner B add contact: %v", err)
}
messages := NewMessageStore(pool)
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: friend.ID,
RecipientUserID: ownerA.ID,
RandomID: 301,
Message: "to owner A",
Date: 1700000301,
}); err != nil {
t.Fatalf("send to owner A: %v", err)
}
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: friend.ID,
RecipientUserID: ownerB.ID,
RandomID: 302,
Message: "to owner B",
Date: 1700000302,
}); err != nil {
t.Fatalf("send to owner B: %v", err)
}
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: other.ID,
RecipientUserID: ownerA.ID,
RandomID: 303,
Message: "second dialog",
Date: 1700000303,
}); err != nil {
t.Fatalf("send second dialog: %v", err)
}
dialogs := NewDialogStore(pool)
listA, err := dialogs.ListByUser(ctx, ownerA.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("list owner A dialogs: %v", err)
}
listB, err := dialogs.ListByUser(ctx, ownerB.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("list owner B dialogs: %v", err)
}
userA, ok := findDialogUserByID(listA.Users, friend.ID)
if !ok || userA.FirstName != "RemarkA" || !userA.Contact {
t.Fatalf("owner A dialog user = %+v found=%v, want RemarkA contact", userA, ok)
}
userB, ok := findDialogUserByID(listB.Users, friend.ID)
if !ok || userB.FirstName != "RemarkB" || !userB.Contact {
t.Fatalf("owner B dialog user = %+v found=%v, want RemarkB contact", userB, ok)
}
friendPeer := domain.Peer{Type: domain.PeerTypeUser, ID: friend.ID}
otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
if changed, err := dialogs.SetPinned(ctx, ownerA.ID, friendPeer, true); err != nil || !changed {
t.Fatalf("pin friend changed=%v err=%v, want changed", changed, err)
}
if changed, err := dialogs.SetPinned(ctx, ownerA.ID, otherPeer, true); err != nil || !changed {
t.Fatalf("pin other changed=%v err=%v, want changed", changed, err)
}
if err := dialogs.ReorderPinned(ctx, ownerA.ID, []domain.Peer{otherPeer, friendPeer}, true); err != nil {
t.Fatalf("reorder pinned: %v", err)
}
pinned, err := dialogs.ListByUser(ctx, ownerA.ID, domain.DialogFilter{PinnedOnly: true, Limit: 10})
if err != nil {
t.Fatalf("list pinned dialogs: %v", err)
}
if len(pinned.Dialogs) != 2 || pinned.Dialogs[0].Peer != otherPeer || pinned.Dialogs[0].PinnedOrder != 1 || pinned.Dialogs[1].Peer != friendPeer || pinned.Dialogs[1].PinnedOrder != 2 {
t.Fatalf("pinned dialogs = %+v, want other then friend with stable order", pinned.Dialogs)
}
if changed, err := dialogs.SetUnreadMark(ctx, ownerA.ID, friendPeer, true); err != nil || !changed {
t.Fatalf("mark unread changed=%v err=%v, want changed", changed, err)
}
marks, err := dialogs.ListUnreadMarked(ctx, ownerA.ID)
if err != nil {
t.Fatalf("list unread marks: %v", err)
}
if !containsPeer(marks, friendPeer) {
t.Fatalf("unread marks = %+v, want friend peer", marks)
}
if _, err := dialogs.MarkRead(ctx, ownerA.ID, friendPeer, 0); err != nil {
t.Fatalf("mark read: %v", err)
}
peerDialogs, err := dialogs.ListByPeers(ctx, ownerA.ID, []domain.Peer{friendPeer})
if err != nil {
t.Fatalf("list peer dialogs after read: %v", err)
}
if len(peerDialogs.Dialogs) != 1 || peerDialogs.Dialogs[0].UnreadMark {
t.Fatalf("peer dialog after read = %+v, want unread mark cleared", peerDialogs.Dialogs)
}
if changed, err := dialogs.SetPeerSettingsBarHidden(ctx, ownerA.ID, friendPeer); err != nil || !changed {
t.Fatalf("hide peer settings bar changed=%v err=%v, want changed", changed, err)
}
hidden, err := dialogs.PeerSettingsBarHidden(ctx, ownerA.ID, friendPeer)
if err != nil {
t.Fatalf("peer settings bar hidden: %v", err)
}
if !hidden {
t.Fatal("peer settings bar hidden = false, want true")
}
}
func createTestUser(t *testing.T, ctx context.Context, users *UserStore, phone, firstName, lastName string) domain.User {
t.Helper()
user, err := users.Create(ctx, domain.User{
AccessHash: int64(len(phone) + len(firstName)*100 + len(lastName)*1000),
Phone: phone,
FirstName: firstName,
LastName: lastName,
})
if err != nil {
t.Fatalf("create user %s: %v", phone, err)
}
return user
}
func findDialogUserByID(users []domain.User, id int64) (domain.User, bool) {
for _, user := range users {
if user.ID == id {
return user, true
}
}
return domain.User{}, false
}
func containsPeer(peers []domain.Peer, want domain.Peer) bool {
for _, peer := range peers {
if peer == want {
return true
}
}
return false
}

View file

@ -0,0 +1,127 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
// TestMaxContiguousPtsStopsAtHole 用真实 PG 验证 RecentUserPts 窗口查询 + 连续计算:
// 存在在途空洞时只报告最大连续 pts补洞后回升。
func TestMaxContiguousPtsStopsAtHole(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
owner, err := NewUserStore(pool).Create(ctx, domain.User{
AccessHash: 1,
Phone: "+1556" + suffix + "01",
FirstName: "Contig",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = $1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
events := NewUpdateEventStore(pool)
appendPts := func(pts int) {
if err := events.Append(ctx, owner.ID, domain.UpdateEvent{
Type: domain.UpdateEventNoop,
Pts: pts,
PtsCount: 1,
Date: 1700000000 + pts,
}); err != nil {
t.Fatalf("append pts=%d: %v", pts, err)
}
}
for _, p := range []int{1, 2, 3, 5, 6} { // pts=4 为在途空洞
appendPts(p)
}
if _, err := pool.Exec(ctx, "DELETE FROM user_update_watermarks WHERE user_id = $1", owner.ID); err != nil {
t.Fatalf("delete watermark fallback row: %v", err)
}
got, err := events.MaxContiguousPts(ctx, owner.ID)
if err != nil {
t.Fatalf("MaxContiguousPts: %v", err)
}
if got != 3 {
t.Fatalf("contiguous = %d, want 3止于 pts=4 空洞,最大已提交为 6", got)
}
appendPts(4) // 在途事务提交/补洞
got, err = events.MaxContiguousPts(ctx, owner.ID)
if err != nil {
t.Fatalf("MaxContiguousPts after fill: %v", err)
}
if got != 6 {
t.Fatalf("contiguous after fill = %d, want 6", got)
}
}
func TestAppendWithDispatchWritesEventAndOutboxAtomically(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
owner, err := NewUserStore(pool).Create(ctx, domain.User{
AccessHash: 2,
Phone: "+1557" + suffix + "01",
FirstName: "Dispatch",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = $1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = $1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
event := domain.UpdateEvent{
UserID: owner.ID,
Type: domain.UpdateEventDialogPinned,
Pts: 1,
PtsCount: 1,
Date: 1700000001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002},
Settings: domain.PeerSettings{
ShareContact: true,
},
Bool: true,
}
var excludeAuthKeyID [8]byte
excludeAuthKeyID[0] = 9
if err := NewUpdateEventStore(pool).AppendWithDispatch(ctx, owner.ID, event, excludeAuthKeyID, 77); err != nil {
t.Fatalf("AppendWithDispatch: %v", err)
}
got, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, 0, 10)
if err != nil {
t.Fatalf("ListAfter: %v", err)
}
if len(got) != 1 || got[0].Type != event.Type || got[0].Peer != event.Peer || !got[0].Bool {
t.Fatalf("events = %+v, want dialog pinned event", got)
}
var outbox struct {
pts int
eventType string
excludeAuthKeyID int64
excludeSessionID int64
}
if err := pool.QueryRow(ctx, `
SELECT pts, event_type, exclude_auth_key_id, exclude_session_id
FROM dispatch_outbox
WHERE target_user_id = $1
`, owner.ID).Scan(&outbox.pts, &outbox.eventType, &outbox.excludeAuthKeyID, &outbox.excludeSessionID); err != nil {
t.Fatalf("query dispatch outbox: %v", err)
}
if outbox.pts != 1 || outbox.eventType != string(domain.UpdateEventDialogPinned) || outbox.excludeAuthKeyID != authKeyIDToInt64(excludeAuthKeyID) || outbox.excludeSessionID != 77 {
t.Fatalf("outbox = %+v, want event dispatch excluding current session", outbox)
}
}

View file

@ -0,0 +1,78 @@
package postgres
import (
"context"
"fmt"
"telesrv/internal/store/postgres/sqlcgen"
)
// MessageBoxCounterSource 从 message_boxes durable log 恢复某 owner 的当前最大 box_id。
type MessageBoxCounterSource struct {
q *sqlcgen.Queries
}
// NewMessageBoxCounterSource 创建 Redis BoxIDAllocator 的 PG 恢复源。
func NewMessageBoxCounterSource(db sqlcgen.DBTX) *MessageBoxCounterSource {
return &MessageBoxCounterSource{q: sqlcgen.New(db)}
}
func (s *MessageBoxCounterSource) Current(ctx context.Context, userID int64) (int, error) {
v, err := s.q.MaxMessageBoxID(ctx, userID)
if err != nil {
return 0, fmt.Errorf("max message box id: %w", err)
}
return int(v), nil
}
// ChannelIDCounterSource 从 channels durable 表恢复全局 channel id。
type ChannelIDCounterSource struct {
db sqlcgen.DBTX
}
// NewChannelIDCounterSource 创建 Redis ChannelIDAllocator 的 PG 恢复源。
func NewChannelIDCounterSource(db sqlcgen.DBTX) *ChannelIDCounterSource {
return &ChannelIDCounterSource{db: db}
}
func (s *ChannelIDCounterSource) Current(ctx context.Context, _ int64) (int, error) {
var id int
if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channels`).Scan(&id); err != nil {
return 0, fmt.Errorf("max channel id: %w", err)
}
return id, nil
}
// ChannelPtsCounterSource 从 channel_update_events 恢复某 channel 的当前最大 pts。
type ChannelPtsCounterSource struct {
db sqlcgen.DBTX
}
func NewChannelPtsCounterSource(db sqlcgen.DBTX) *ChannelPtsCounterSource {
return &ChannelPtsCounterSource{db: db}
}
func (s *ChannelPtsCounterSource) Current(ctx context.Context, channelID int64) (int, error) {
var pts int
if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(pts), 0) FROM channel_update_events WHERE channel_id = $1`, channelID).Scan(&pts); err != nil {
return 0, fmt.Errorf("max channel pts: %w", err)
}
return pts, nil
}
// ChannelMessageIDCounterSource 从 channel_messages 恢复某 channel 的当前最大 message id。
type ChannelMessageIDCounterSource struct {
db sqlcgen.DBTX
}
func NewChannelMessageIDCounterSource(db sqlcgen.DBTX) *ChannelMessageIDCounterSource {
return &ChannelMessageIDCounterSource{db: db}
}
func (s *ChannelMessageIDCounterSource) Current(ctx context.Context, channelID int64) (int, error) {
var id int
if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id = $1`, channelID).Scan(&id); err != nil {
return 0, fmt.Errorf("max channel message id: %w", err)
}
return id, nil
}

View file

@ -0,0 +1,722 @@
package postgres
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// DialogStore 用 PostgreSQL 实现 store.DialogStore。
type DialogStore struct {
q *sqlcgen.Queries
}
// NewDialogStore 基于 pgx 连接池(或事务)创建 DialogStore。
func NewDialogStore(db sqlcgen.DBTX) *DialogStore {
return &DialogStore{q: sqlcgen.New(db)}
}
func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
limit := filter.Limit
if limit <= 0 {
limit = 100
}
if limit > 500 {
limit = 500
}
offsetPeerID := int64(0)
if filter.HasOffsetPeer {
offsetPeerID = filter.OffsetPeer.ID
}
folderParams := dialogFolderQueryParams(filter.Folder)
summaryRows, err := s.q.ListDialogSummaryByUser(ctx, sqlcgen.ListDialogSummaryByUserParams{
UserID: userID,
HasFolderID: filter.HasFolderID,
FolderID: pgInt32NonNegative(filter.FolderID),
FolderExcludeArchived: folderParams.excludeArchived,
FolderExcludeRead: folderParams.excludeRead,
FolderExcludePeerTypes: folderParams.excludeTypes,
FolderExcludePeerIds: folderParams.excludeIDs,
FolderIncludePeerTypes: folderParams.includeTypes,
FolderIncludePeerIds: folderParams.includeIDs,
FolderPinnedPeerTypes: folderParams.pinnedTypes,
FolderPinnedPeerIds: folderParams.pinnedIDs,
FolderContacts: folderParams.contacts,
FolderNonContacts: folderParams.nonContacts,
PinnedOnly: filter.PinnedOnly,
ExcludePinned: filter.ExcludePinned,
})
if err != nil {
return domain.DialogList{}, fmt.Errorf("list dialog summary: %w", err)
}
summary := make([]domain.Dialog, 0, len(summaryRows))
for _, row := range summaryRows {
summary = append(summary, domain.Dialog{
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
FolderID: int(row.FolderID),
TopMessage: int(row.TopMessageID),
TopMessageDate: int(row.TopMessageDate),
ReadInboxMaxID: int(row.ReadInboxMaxID),
ReadOutboxMaxID: int(row.ReadOutboxMaxID),
UnreadCount: int(row.UnreadCount),
UnreadMentions: int(row.UnreadMentionsCount),
UnreadReactions: int(row.UnreadReactionsCount),
Pinned: row.Pinned,
PinnedOrder: int(row.PinnedOrder),
UnreadMark: row.UnreadMark,
PeerSettingsBarHidden: row.HiddenPeerSettingsBar,
})
}
out := domain.DialogList{
Dialogs: make([]domain.Dialog, 0, limit),
Count: len(summary),
Hash: dialogListHash(summary),
}
if len(summary) == 0 {
return out, nil
}
rows, err := s.q.ListDialogsByUser(ctx, sqlcgen.ListDialogsByUserParams{
UserID: userID,
LimitCount: int32(limit),
HasFolderID: filter.HasFolderID,
FolderID: pgInt32NonNegative(filter.FolderID),
FolderExcludeArchived: folderParams.excludeArchived,
FolderExcludeRead: folderParams.excludeRead,
FolderExcludePeerTypes: folderParams.excludeTypes,
FolderExcludePeerIds: folderParams.excludeIDs,
FolderIncludePeerTypes: folderParams.includeTypes,
FolderIncludePeerIds: folderParams.includeIDs,
FolderPinnedPeerTypes: folderParams.pinnedTypes,
FolderPinnedPeerIds: folderParams.pinnedIDs,
FolderContacts: folderParams.contacts,
FolderNonContacts: folderParams.nonContacts,
PinnedOnly: filter.PinnedOnly,
ExcludePinned: filter.ExcludePinned,
OffsetID: pgInt32NonNegative(filter.OffsetID),
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
HasOffsetPeer: filter.HasOffsetPeer,
OffsetPeerID: offsetPeerID,
})
if err != nil {
return domain.DialogList{}, fmt.Errorf("list dialogs: %w", err)
}
out.Messages = make([]domain.Message, 0, len(rows))
out.Users = make([]domain.User, 0, len(rows))
seenUsers := map[int64]struct{}{}
for _, row := range rows {
dialog := domain.Dialog{
Peer: domain.Peer{
Type: domain.PeerType(row.PeerType),
ID: row.PeerID,
},
FolderID: int(row.FolderID),
TopMessage: int(row.TopMessageID),
TopMessageDate: int(row.TopMessageDate),
ReadInboxMaxID: int(row.ReadInboxMaxID),
ReadOutboxMaxID: int(row.ReadOutboxMaxID),
UnreadCount: int(row.UnreadCount),
UnreadMentions: int(row.UnreadMentionsCount),
UnreadReactions: int(row.UnreadReactionsCount),
Pinned: row.Pinned,
PinnedOrder: int(row.PinnedOrder),
UnreadMark: row.UnreadMark,
PeerSettingsBarHidden: row.HiddenPeerSettingsBar,
}
out.Dialogs = append(out.Dialogs, dialog)
if row.PeerUserID != 0 {
if _, ok := seenUsers[row.PeerUserID]; !ok {
seenUsers[row.PeerUserID] = struct{}{}
out.Users = append(out.Users, domain.User{
ID: row.PeerUserID,
AccessHash: row.PeerAccessHash,
Phone: row.PeerPhone,
FirstName: row.PeerFirstName,
LastName: row.PeerLastName,
Username: row.PeerUsername,
CountryCode: row.PeerCountryCode,
Verified: row.PeerVerified,
Support: row.PeerSupport,
LastSeenAt: int(row.PeerLastSeenAt),
Contact: row.PeerContact,
Mutual: row.PeerMutual,
})
}
}
if row.MessageID != 0 {
entities, err := decodeMessageEntities(row.MessageEntitiesJson)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message entities: %w", err)
}
out.Messages = append(out.Messages, domain.Message{
ID: int(row.MessageID),
OwnerUserID: row.UserID,
Peer: dialog.Peer,
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID},
Date: int(row.MessageDate),
Out: row.MessageOutgoing,
Body: row.MessageBody,
Entities: entities,
})
}
}
return out, nil
}
func (s *DialogStore) ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
if len(peers) == 0 {
return domain.DialogList{}, nil
}
peerTypes := make([]string, 0, len(peers))
peerIDs := make([]int64, 0, len(peers))
for _, peer := range peers {
if peer.Type == "" || peer.ID == 0 {
continue
}
peerTypes = append(peerTypes, string(peer.Type))
peerIDs = append(peerIDs, peer.ID)
}
if len(peerTypes) == 0 {
return domain.DialogList{}, nil
}
rows, err := s.q.ListDialogsByPeers(ctx, sqlcgen.ListDialogsByPeersParams{
UserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
})
if err != nil {
return domain.DialogList{}, fmt.Errorf("list dialogs by peers: %w", err)
}
out := domain.DialogList{
Dialogs: make([]domain.Dialog, 0, len(rows)),
Messages: make([]domain.Message, 0, len(rows)),
Users: make([]domain.User, 0, len(rows)),
}
seenUsers := map[int64]struct{}{}
for _, row := range rows {
dialog := domain.Dialog{
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
FolderID: int(row.FolderID),
TopMessage: int(row.TopMessageID),
TopMessageDate: int(row.TopMessageDate),
ReadInboxMaxID: int(row.ReadInboxMaxID),
ReadOutboxMaxID: int(row.ReadOutboxMaxID),
UnreadCount: int(row.UnreadCount),
UnreadMentions: int(row.UnreadMentionsCount),
UnreadReactions: int(row.UnreadReactionsCount),
Pinned: row.Pinned,
PinnedOrder: int(row.PinnedOrder),
UnreadMark: row.UnreadMark,
PeerSettingsBarHidden: row.HiddenPeerSettingsBar,
}
out.Dialogs = append(out.Dialogs, dialog)
if row.PeerUserID != 0 {
if _, ok := seenUsers[row.PeerUserID]; !ok {
seenUsers[row.PeerUserID] = struct{}{}
out.Users = append(out.Users, domain.User{
ID: row.PeerUserID,
AccessHash: row.PeerAccessHash,
Phone: row.PeerPhone,
FirstName: row.PeerFirstName,
LastName: row.PeerLastName,
Username: row.PeerUsername,
CountryCode: row.PeerCountryCode,
Verified: row.PeerVerified,
Support: row.PeerSupport,
LastSeenAt: int(row.PeerLastSeenAt),
Contact: row.PeerContact,
Mutual: row.PeerMutual,
})
}
}
if row.MessageID != 0 {
entities, err := decodeMessageEntities(row.MessageEntitiesJson)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message entities: %w", err)
}
out.Messages = append(out.Messages, domain.Message{
ID: int(row.MessageID),
OwnerUserID: row.UserID,
Peer: dialog.Peer,
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID},
Date: int(row.MessageDate),
Out: row.MessageOutgoing,
Body: row.MessageBody,
Entities: entities,
})
}
}
out.Count = len(out.Dialogs)
out.Hash = dialogListHash(out.Dialogs)
return out, nil
}
func (s *DialogStore) Upsert(ctx context.Context, userID int64, dialog domain.Dialog) error {
if err := s.q.UpsertDialog(ctx, sqlcgen.UpsertDialogParams{
UserID: userID,
PeerType: string(dialog.Peer.Type),
PeerID: dialog.Peer.ID,
TopMessageID: int32(dialog.TopMessage),
TopMessageDate: int32(dialog.TopMessageDate),
ReadInboxMaxID: int32(dialog.ReadInboxMaxID),
ReadOutboxMaxID: int32(dialog.ReadOutboxMaxID),
UnreadCount: int32(dialog.UnreadCount),
UnreadMentionsCount: int32(dialog.UnreadMentions),
UnreadReactionsCount: int32(dialog.UnreadReactions),
Pinned: dialog.Pinned,
UnreadMark: dialog.UnreadMark,
}); err != nil {
return fmt.Errorf("upsert dialog: %w", err)
}
return nil
}
func (s *DialogStore) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error {
data, err := json.Marshal(draft)
if err != nil {
return fmt.Errorf("marshal dialog draft: %w", err)
}
if err := s.q.UpsertDialogDraft(ctx, sqlcgen.UpsertDialogDraftParams{
UserID: userID,
PeerType: string(draft.Peer.Type),
PeerID: draft.Peer.ID,
TopMessageID: int32(draft.TopMessageID),
Date: int32(draft.Date),
DraftJson: data,
}); err != nil {
return fmt.Errorf("upsert dialog draft: %w", err)
}
return nil
}
func (s *DialogStore) DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) {
changed, err := s.q.DeleteDialogDraft(ctx, sqlcgen.DeleteDialogDraftParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
TopMessageID: int32(topMessageID),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("delete dialog draft: %w", err)
}
return changed, nil
}
func (s *DialogStore) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
rows, err := s.q.ListDialogDrafts(ctx, sqlcgen.ListDialogDraftsParams{
UserID: userID,
LimitCount: int32(clampDialogDraftLimit(limit)),
})
if err != nil {
return nil, fmt.Errorf("list dialog drafts: %w", err)
}
return decodeDialogDrafts(rows)
}
func (s *DialogStore) ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
rows, err := s.q.ClearDialogDrafts(ctx, sqlcgen.ClearDialogDraftsParams{
UserID: userID,
LimitCount: int32(clampDialogDraftLimit(limit)),
})
if err != nil {
return nil, fmt.Errorf("clear dialog drafts: %w", err)
}
return decodeDialogDrafts(rows)
}
func (s *DialogStore) MarkRead(ctx context.Context, userID int64, peer domain.Peer, maxID int) (domain.ReadHistoryResult, error) {
row, err := s.q.MarkDialogRead(ctx, sqlcgen.MarkDialogReadParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
MaxID: pgInt32NonNegative(maxID),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ReadHistoryResult{OwnerUserID: userID, Peer: peer, MaxID: maxID}, nil
}
return domain.ReadHistoryResult{}, fmt.Errorf("mark dialog read: %w", err)
}
return domain.ReadHistoryResult{
OwnerUserID: row.UserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
MaxID: int(row.ReadInboxMaxID),
StillUnreadCount: int(row.UnreadCount),
Changed: row.Changed,
}, nil
}
func (s *DialogStore) SetPinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) {
changed, err := s.q.SetDialogPinned(ctx, sqlcgen.SetDialogPinnedParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
Pinned: pinned,
})
if err != nil {
return false, fmt.Errorf("set dialog pinned: %w", err)
}
return changed, nil
}
func (s *DialogStore) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error {
peerTypes, peerIDs := peerArrays(order)
if force {
if err := s.q.ClearPinnedDialogsNotInOrder(ctx, sqlcgen.ClearPinnedDialogsNotInOrderParams{
UserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
}); err != nil {
return fmt.Errorf("clear pinned dialogs not in order: %w", err)
}
}
if len(peerTypes) == 0 {
return nil
}
if err := s.q.ReorderPinnedDialogs(ctx, sqlcgen.ReorderPinnedDialogsParams{
UserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
}); err != nil {
return fmt.Errorf("reorder pinned dialogs: %w", err)
}
return nil
}
func (s *DialogStore) SetUnreadMark(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) {
changed, err := s.q.SetDialogUnreadMark(ctx, sqlcgen.SetDialogUnreadMarkParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
Unread: unread,
})
if err != nil {
return false, fmt.Errorf("set dialog unread mark: %w", err)
}
return changed, nil
}
func (s *DialogStore) ListUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error) {
rows, err := s.q.ListDialogUnreadMarks(ctx, userID)
if err != nil {
return nil, fmt.Errorf("list dialog unread marks: %w", err)
}
out := make([]domain.Peer, 0, len(rows))
for _, row := range rows {
out = append(out, domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID})
}
return out, nil
}
func (s *DialogStore) SetPeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
changed, err := s.q.SetPeerSettingsBarHidden(ctx, sqlcgen.SetPeerSettingsBarHiddenParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
})
if err != nil {
return false, fmt.Errorf("set peer settings bar hidden: %w", err)
}
return changed, nil
}
func (s *DialogStore) PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
hidden, err := s.q.GetPeerSettingsBarHidden(ctx, sqlcgen.GetPeerSettingsBarHiddenParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("get peer settings bar hidden: %w", err)
}
return hidden, nil
}
func (s *DialogStore) ListFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error) {
rows, err := s.q.ListDialogFolders(ctx, userID)
if err != nil {
return domain.DialogFolderList{}, fmt.Errorf("list dialog folders: %w", err)
}
tagsEnabled := false
if enabled, err := s.q.GetDialogFolderTags(ctx, userID); err == nil {
tagsEnabled = enabled
} else if !errors.Is(err, pgx.ErrNoRows) {
return domain.DialogFolderList{}, fmt.Errorf("get dialog folder tags: %w", err)
}
out := domain.DialogFolderList{
TagsEnabled: tagsEnabled,
Folders: make([]domain.DialogFolder, 0, len(rows)),
}
for _, row := range rows {
folder, err := decodeDialogFolder(row.FilterJson)
if err != nil {
return domain.DialogFolderList{}, fmt.Errorf("decode dialog folder %d: %w", row.FilterID, err)
}
folder.ID = int(row.FilterID)
folder.IsChatlist = row.IsChatlist
out.Folders = append(out.Folders, folder)
}
return out, nil
}
func (s *DialogStore) GetFolder(ctx context.Context, userID int64, folderID int) (domain.DialogFolder, bool, error) {
row, err := s.q.GetDialogFolder(ctx, sqlcgen.GetDialogFolderParams{
UserID: userID,
FilterID: pgInt32NonNegative(folderID),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.DialogFolder{}, false, nil
}
return domain.DialogFolder{}, false, fmt.Errorf("get dialog folder: %w", err)
}
folder, err := decodeDialogFolder(row.FilterJson)
if err != nil {
return domain.DialogFolder{}, false, fmt.Errorf("decode dialog folder: %w", err)
}
folder.ID = int(row.FilterID)
folder.IsChatlist = row.IsChatlist
return folder, true, nil
}
func (s *DialogStore) UpsertFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error {
data, err := json.Marshal(folder)
if err != nil {
return fmt.Errorf("marshal dialog folder: %w", err)
}
if err := s.q.UpsertDialogFolder(ctx, sqlcgen.UpsertDialogFolderParams{
UserID: userID,
FilterID: pgInt32NonNegative(folder.ID),
IsChatlist: folder.IsChatlist,
FilterJson: data,
}); err != nil {
return fmt.Errorf("upsert dialog folder: %w", err)
}
return nil
}
func (s *DialogStore) DeleteFolder(ctx context.Context, userID int64, folderID int) error {
if err := s.q.DeleteDialogFolder(ctx, sqlcgen.DeleteDialogFolderParams{
UserID: userID,
FilterID: pgInt32NonNegative(folderID),
}); err != nil {
return fmt.Errorf("delete dialog folder: %w", err)
}
return nil
}
func (s *DialogStore) ReorderFolders(ctx context.Context, userID int64, order []int) error {
if err := s.q.ReorderDialogFolders(ctx, sqlcgen.ReorderDialogFoldersParams{
UserID: userID,
FilterIds: int32s(order),
}); err != nil {
return fmt.Errorf("reorder dialog folders: %w", err)
}
return nil
}
func (s *DialogStore) SetFolderTagsEnabled(ctx context.Context, userID int64, enabled bool) error {
if err := s.q.SetDialogFolderTags(ctx, sqlcgen.SetDialogFolderTagsParams{
UserID: userID,
TagsEnabled: enabled,
}); err != nil {
return fmt.Errorf("set dialog folder tags: %w", err)
}
return nil
}
func (s *DialogStore) EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error {
peerTypes := make([]string, 0, len(peers))
peerIDs := make([]int64, 0, len(peers))
folderIDs := make([]int32, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, item := range peers {
if item.Peer.Type == "" || item.Peer.ID == 0 {
continue
}
if _, ok := seen[item.Peer]; ok {
continue
}
seen[item.Peer] = struct{}{}
peerTypes = append(peerTypes, string(item.Peer.Type))
peerIDs = append(peerIDs, item.Peer.ID)
folderIDs = append(folderIDs, pgInt32NonNegative(item.FolderID))
}
if len(peerTypes) == 0 {
return nil
}
if err := s.q.EditDialogPeerFolders(ctx, sqlcgen.EditDialogPeerFoldersParams{
UserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
FolderIds: folderIDs,
}); err != nil {
return fmt.Errorf("edit dialog peer folders: %w", err)
}
return nil
}
type dialogFolderParams struct {
contacts bool
nonContacts bool
excludeArchived bool
excludeRead bool
includeTypes []string
includeIDs []int64
pinnedTypes []string
pinnedIDs []int64
excludeTypes []string
excludeIDs []int64
}
func dialogFolderQueryParams(folder *domain.DialogFolder) dialogFolderParams {
if folder == nil {
return dialogFolderParams{}
}
includeTypes, includeIDs := folderPeerArrays(folder.IncludePeers)
pinnedTypes, pinnedIDs := folderPeerArrays(folder.PinnedPeers)
excludeTypes, excludeIDs := folderPeerArrays(folder.ExcludePeers)
return dialogFolderParams{
contacts: folder.Contacts,
nonContacts: folder.NonContacts,
excludeArchived: folder.ExcludeArchived,
excludeRead: folder.ExcludeRead,
includeTypes: includeTypes,
includeIDs: includeIDs,
pinnedTypes: pinnedTypes,
pinnedIDs: pinnedIDs,
excludeTypes: excludeTypes,
excludeIDs: excludeIDs,
}
}
func folderPeerArrays(peers []domain.DialogFolderPeer) ([]string, []int64) {
peerTypes := make([]string, 0, len(peers))
peerIDs := make([]int64, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, item := range peers {
peer := item.Peer
if peer.Type == "" || peer.ID == 0 {
continue
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
peerTypes = append(peerTypes, string(peer.Type))
peerIDs = append(peerIDs, peer.ID)
}
return peerTypes, peerIDs
}
func decodeDialogFolder(data string) (domain.DialogFolder, error) {
if data == "" {
return domain.DialogFolder{}, nil
}
var folder domain.DialogFolder
if err := json.Unmarshal([]byte(data), &folder); err != nil {
return domain.DialogFolder{}, err
}
return folder, nil
}
func decodeDialogDrafts(rows []string) ([]domain.DialogDraft, error) {
out := make([]domain.DialogDraft, 0, len(rows))
for _, row := range rows {
draft, err := decodeDialogDraft(row)
if err != nil {
return nil, err
}
out = append(out, draft)
}
return out, nil
}
func decodeDialogDraft(data string) (domain.DialogDraft, error) {
if data == "" {
return domain.DialogDraft{}, nil
}
var draft domain.DialogDraft
if err := json.Unmarshal([]byte(data), &draft); err != nil {
return domain.DialogDraft{}, fmt.Errorf("decode dialog draft: %w", err)
}
if draft.Entities == nil {
draft.Entities = []domain.MessageEntity{}
}
return draft, nil
}
func clampDialogDraftLimit(limit int) int {
if limit <= 0 || limit > domain.MaxDialogDraftsPerUser {
return domain.MaxDialogDraftsPerUser
}
return limit
}
func peerArrays(peers []domain.Peer) ([]string, []int64) {
peerTypes := make([]string, 0, len(peers))
peerIDs := make([]int64, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if peer.Type == "" || peer.ID == 0 {
continue
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
peerTypes = append(peerTypes, string(peer.Type))
peerIDs = append(peerIDs, peer.ID)
}
return peerTypes, peerIDs
}
func dialogListHash(dialogs []domain.Dialog) int64 {
if len(dialogs) == 0 {
return 0
}
h := fnv.New64a()
var buf [47]byte
for _, d := range dialogs {
binary.LittleEndian.PutUint64(buf[:8], uint64(d.Peer.ID))
binary.LittleEndian.PutUint32(buf[8:12], uint32(d.FolderID))
binary.LittleEndian.PutUint32(buf[12:16], uint32(d.TopMessage))
binary.LittleEndian.PutUint32(buf[16:20], uint32(d.TopMessageDate))
binary.LittleEndian.PutUint32(buf[20:24], uint32(d.ReadInboxMaxID))
binary.LittleEndian.PutUint32(buf[24:28], uint32(d.ReadOutboxMaxID))
binary.LittleEndian.PutUint32(buf[28:32], uint32(d.UnreadCount))
binary.LittleEndian.PutUint32(buf[32:36], uint32(d.UnreadMentions))
binary.LittleEndian.PutUint32(buf[36:40], uint32(d.UnreadReactions))
if d.Pinned {
buf[40] = 1
} else {
buf[40] = 0
}
binary.LittleEndian.PutUint32(buf[41:45], uint32(d.PinnedOrder))
if d.UnreadMark {
buf[45] = 1
} else {
buf[45] = 0
}
if d.PeerSettingsBarHidden {
buf[46] = 1
} else {
buf[46] = 0
}
_, _ = h.Write(buf[:])
}
return int64(h.Sum64())
}

View file

@ -0,0 +1,98 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestDialogFoldersRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 11, Phone: "+1666" + suffix + "01", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
friend, err := users.Create(ctx, domain.User{AccessHash: 22, Phone: "+1666" + suffix + "02", FirstName: "Friend"})
if err != nil {
t.Fatalf("create friend: %v", err)
}
stranger, err := users.Create(ctx, domain.User{AccessHash: 33, Phone: "+1666" + suffix + "03", FirstName: "Stranger"})
if err != nil {
t.Fatalf("create stranger: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID, stranger.ID})
})
if _, err := pool.Exec(ctx, `
INSERT INTO contacts (user_id, contact_user_id, mutual)
VALUES ($1, $2, true)
`, owner.ID, friend.ID); err != nil {
t.Fatalf("insert contact: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO dialogs (user_id, peer_type, peer_id, folder_id, top_message_id, top_message_date, unread_count)
VALUES
($1, 'user', $2, 0, 10, 1000, 0),
($1, 'user', $3, 1, 9, 900, 1)
`, owner.ID, friend.ID, stranger.ID); err != nil {
t.Fatalf("insert dialogs: %v", err)
}
dialogs := NewDialogStore(pool)
main, err := dialogs.ListByUser(ctx, owner.ID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogMainFolderID, Limit: 10})
if err != nil {
t.Fatalf("list main folder: %v", err)
}
if len(main.Dialogs) != 1 || main.Dialogs[0].Peer.ID != friend.ID {
t.Fatalf("main dialogs = %+v, want friend only", main.Dialogs)
}
archive, err := dialogs.ListByUser(ctx, owner.ID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 10})
if err != nil {
t.Fatalf("list archive folder: %v", err)
}
if len(archive.Dialogs) != 1 || archive.Dialogs[0].Peer.ID != stranger.ID || archive.Dialogs[0].FolderID != domain.DialogArchiveFolderID {
t.Fatalf("archive dialogs = %+v, want archived stranger", archive.Dialogs)
}
folder := domain.DialogFolder{
ID: 2,
Title: "Work",
Contacts: true,
ExcludeArchived: true,
IncludePeers: []domain.DialogFolderPeer{{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: stranger.ID}, AccessHash: stranger.AccessHash}},
}
if err := dialogs.UpsertFolder(ctx, owner.ID, folder); err != nil {
t.Fatalf("upsert folder: %v", err)
}
if err := dialogs.SetFolderTagsEnabled(ctx, owner.ID, true); err != nil {
t.Fatalf("set tags: %v", err)
}
folders, err := dialogs.ListFolders(ctx, owner.ID)
if err != nil {
t.Fatalf("list folders: %v", err)
}
if !folders.TagsEnabled || len(folders.Folders) != 1 || folders.Folders[0].Title != "Work" {
t.Fatalf("folders = %+v, want tags plus work folder", folders)
}
custom, err := dialogs.ListByUser(ctx, owner.ID, domain.DialogFilter{HasFolderID: true, FolderID: 2, Folder: &folder, Limit: 10})
if err != nil {
t.Fatalf("list custom folder: %v", err)
}
if len(custom.Dialogs) != 1 || custom.Dialogs[0].Peer.ID != friend.ID {
t.Fatalf("custom dialogs = %+v, want contact only because archived explicit peer is excluded", custom.Dialogs)
}
if err := dialogs.EditPeerFolders(ctx, owner.ID, []domain.FolderPeerUpdate{{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: stranger.ID}, FolderID: domain.DialogMainFolderID}}); err != nil {
t.Fatalf("edit peer folders: %v", err)
}
archive, err = dialogs.ListByUser(ctx, owner.ID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 10})
if err != nil {
t.Fatalf("list archive after edit: %v", err)
}
if len(archive.Dialogs) != 0 {
t.Fatalf("archive after edit = %+v, want empty", archive.Dialogs)
}
}

View file

@ -0,0 +1,140 @@
package postgres
import (
"context"
"fmt"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// defaultDispatchLease 是 'dispatching' 行被判定租约过期、可被重新 claim 的默认时长。
// 与 docs/message-module.md 的 outbox 背压参数对应;生产由 config 注入覆盖。
const defaultDispatchLease = 30 * time.Second
// DispatchOutboxStore 用 PostgreSQL 实现 transactional outbox。
type DispatchOutboxStore struct {
q *sqlcgen.Queries
leaseSeconds int32
}
// DispatchOutboxOption 调整 DispatchOutboxStore 的 claim 行为。
type DispatchOutboxOption func(*DispatchOutboxStore)
// WithLeaseTimeout 设置租约超时;<=0 时保持默认。
func WithLeaseTimeout(d time.Duration) DispatchOutboxOption {
return func(s *DispatchOutboxStore) {
if d > 0 {
s.leaseSeconds = int32(d / time.Second)
if s.leaseSeconds < 1 {
s.leaseSeconds = 1
}
}
}
}
// NewDispatchOutboxStore 基于 pgx 连接池(或事务)创建 DispatchOutboxStore。
func NewDispatchOutboxStore(db sqlcgen.DBTX, opts ...DispatchOutboxOption) *DispatchOutboxStore {
s := &DispatchOutboxStore{
q: sqlcgen.New(db),
leaseSeconds: int32(defaultDispatchLease / time.Second),
}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
return s
}
func (s *DispatchOutboxStore) ClaimPending(ctx context.Context, limit int) ([]store.DispatchOutboxItem, error) {
if limit <= 0 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
rows, err := s.q.ClaimDispatchOutbox(ctx, sqlcgen.ClaimDispatchOutboxParams{
LeaseSeconds: s.leaseSeconds,
LimitCount: int32(limit),
})
if err != nil {
return nil, fmt.Errorf("claim dispatch outbox: %w", err)
}
out := make([]store.DispatchOutboxItem, 0, len(rows))
for _, row := range rows {
out = append(out, store.DispatchOutboxItem{
ID: row.ID,
TargetUserID: row.TargetUserID,
Pts: int(row.Pts),
EventType: domain.UpdateEventType(row.EventType),
ExcludeAuthKeyID: authKeyIDFromInt64(row.ExcludeAuthKeyID),
ExcludeSessionID: row.ExcludeSessionID,
Attempts: int(row.Attempts),
})
}
return out, nil
}
// MarkDeliveredBatch 一次性删除一批已投递的 outbox 行(方案 A投递成功即删取代逐条 MarkDelivered。
func (s *DispatchOutboxStore) MarkDeliveredBatch(ctx context.Context, items []store.DispatchOutboxItem) error {
if len(items) == 0 {
return nil
}
targetUserIDs := make([]int64, len(items))
ids := make([]int64, len(items))
for i, it := range items {
targetUserIDs[i] = it.TargetUserID
ids[i] = it.ID
}
if err := s.q.MarkDispatchDeliveredBatch(ctx, sqlcgen.MarkDispatchDeliveredBatchParams{
TargetUserIds: targetUserIDs,
Ids: ids,
}); err != nil {
return fmt.Errorf("mark dispatch delivered batch: %w", err)
}
return nil
}
func (s *DispatchOutboxStore) MarkDelivered(ctx context.Context, targetUserID, id int64) error {
if err := s.q.MarkDispatchDelivered(ctx, sqlcgen.MarkDispatchDeliveredParams{
TargetUserID: targetUserID,
ID: id,
}); err != nil {
return fmt.Errorf("mark dispatch delivered: %w", err)
}
return nil
}
func (s *DispatchOutboxStore) MarkFailed(ctx context.Context, targetUserID, id int64, lastError string) error {
if err := s.q.MarkDispatchFailed(ctx, sqlcgen.MarkDispatchFailedParams{
TargetUserID: targetUserID,
ID: id,
LastError: lastError,
}); err != nil {
return fmt.Errorf("mark dispatch failed: %w", err)
}
return nil
}
func (s *DispatchOutboxStore) DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error) {
if olderThan <= 0 {
olderThan = 24 * time.Hour
}
if limit <= 0 {
limit = 10000
}
if limit > 100000 {
limit = 100000
}
deleted, err := s.q.DeleteFailedDispatchOutbox(ctx, sqlcgen.DeleteFailedDispatchOutboxParams{
OlderThanSeconds: int32(olderThan / time.Second),
LimitCount: int32(limit),
})
if err != nil {
return 0, fmt.Errorf("delete failed dispatch outbox: %w", err)
}
return int(deleted), nil
}

View file

@ -0,0 +1,141 @@
package postgres
import (
"context"
"encoding/binary"
"errors"
"fmt"
"hash/fnv"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// HelpStore 用 PostgreSQL 实现 store.AppConfigStore 和 store.CountryStore。
type HelpStore struct {
q *sqlcgen.Queries
}
// NewHelpStore 基于 pgx 连接池(或事务)创建 HelpStore。
func NewHelpStore(db sqlcgen.DBTX) *HelpStore {
return &HelpStore{q: sqlcgen.New(db)}
}
func (s *HelpStore) GetAppConfig(ctx context.Context, client string) (domain.AppConfig, bool, error) {
row, err := s.q.GetAppConfig(ctx, client)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.AppConfig{Client: client, JSON: []byte("{}")}, false, nil
}
return domain.AppConfig{}, false, fmt.Errorf("get app config: %w", err)
}
return domain.AppConfig{
Client: row.Client,
Hash: int(row.Hash),
JSON: []byte(row.ConfigJson),
}, true, nil
}
func (s *HelpStore) UpsertAppConfig(ctx context.Context, cfg domain.AppConfig) error {
if len(cfg.JSON) == 0 {
cfg.JSON = []byte("{}")
}
if err := s.q.UpsertAppConfig(ctx, sqlcgen.UpsertAppConfigParams{
Client: cfg.Client,
Hash: int32(cfg.Hash),
ConfigJson: cfg.JSON,
}); err != nil {
return fmt.Errorf("upsert app config: %w", err)
}
return nil
}
func (s *HelpStore) ListCountries(ctx context.Context, _ string) (domain.CountriesList, error) {
rows, err := s.q.ListCountries(ctx)
if err != nil {
return domain.CountriesList{}, fmt.Errorf("list countries: %w", err)
}
byISO := make(map[string]int)
out := domain.CountriesList{}
for _, row := range rows {
idx, ok := byISO[row.Iso2]
if !ok {
idx = len(out.Countries)
byISO[row.Iso2] = idx
out.Countries = append(out.Countries, domain.Country{
ISO2: row.Iso2,
DefaultName: row.DefaultName,
Name: row.Name,
Hidden: row.Hidden,
})
}
out.Countries[idx].CountryCodes = append(out.Countries[idx].CountryCodes, domain.CountryCode{
CountryCode: row.CountryCode,
Prefixes: append([]string(nil), row.Prefixes...),
Patterns: append([]string(nil), row.Patterns...),
})
}
out.Hash = countriesHash(out.Countries)
return out, nil
}
func (s *HelpStore) UpsertCountries(ctx context.Context, countries []domain.Country) error {
for i, country := range countries {
if err := s.q.UpsertCountry(ctx, sqlcgen.UpsertCountryParams{
Iso2: country.ISO2,
DefaultName: country.DefaultName,
Name: country.Name,
Hidden: country.Hidden,
OrderIndex: int32(i + 1),
}); err != nil {
return fmt.Errorf("upsert country %q: %w", country.ISO2, err)
}
for j, code := range country.CountryCodes {
if err := s.q.UpsertCountryCode(ctx, sqlcgen.UpsertCountryCodeParams{
Iso2: country.ISO2,
CountryCode: code.CountryCode,
Prefixes: code.Prefixes,
Patterns: code.Patterns,
OrderIndex: int32(j + 1),
}); err != nil {
return fmt.Errorf("upsert country code %q/%q: %w", country.ISO2, code.CountryCode, err)
}
}
}
return nil
}
func countriesHash(countries []domain.Country) int {
if len(countries) == 0 {
return 0
}
h := fnv.New32a()
var buf [4]byte
for _, country := range countries {
_, _ = h.Write([]byte(country.ISO2))
_, _ = h.Write([]byte(country.DefaultName))
_, _ = h.Write([]byte(country.Name))
if country.Hidden {
buf[0] = 1
} else {
buf[0] = 0
}
_, _ = h.Write(buf[:1])
for _, code := range country.CountryCodes {
_, _ = h.Write([]byte(code.CountryCode))
binary.LittleEndian.PutUint32(buf[:], uint32(len(code.Prefixes)))
_, _ = h.Write(buf[:])
for _, prefix := range code.Prefixes {
_, _ = h.Write([]byte(prefix))
}
binary.LittleEndian.PutUint32(buf[:], uint32(len(code.Patterns)))
_, _ = h.Write(buf[:])
for _, pattern := range code.Patterns {
_, _ = h.Write([]byte(pattern))
}
}
}
return int(h.Sum32())
}

View file

@ -0,0 +1,169 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// LangPackStore 用 PostgreSQL 实现 store.LangPackStore。
type LangPackStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewLangPackStore 基于 pgx 连接池(或事务)创建 LangPackStore。
func NewLangPackStore(db sqlcgen.DBTX) *LangPackStore {
return &LangPackStore{db: db, q: sqlcgen.New(db)}
}
func (s *LangPackStore) GetPack(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) {
meta, found, err := s.meta(ctx, langPack, langCode)
if err != nil || !found {
return meta, err
}
meta.FromVersion = fromVersion
if meta.Version <= fromVersion {
return meta, nil
}
rows, err := s.q.ListLangPackStrings(ctx, sqlcgen.ListLangPackStringsParams{
LangPack: langPack,
LangCode: langCode,
})
if err != nil {
return domain.LangPack{}, fmt.Errorf("list lang pack strings: %w", err)
}
meta.Strings = make([]domain.LangPackString, 0, len(rows))
for _, row := range rows {
meta.Strings = append(meta.Strings, langPackStringFromListRow(row))
}
return meta, nil
}
func (s *LangPackStore) GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) {
meta, found, err := s.meta(ctx, langPack, langCode)
if err != nil || !found {
return meta, err
}
if len(keys) == 0 {
return s.GetPack(ctx, langPack, langCode, 0)
}
rows, err := s.q.GetLangPackStringsByKeys(ctx, sqlcgen.GetLangPackStringsByKeysParams{
LangPack: langPack,
LangCode: langCode,
Keys: keys,
})
if err != nil {
return domain.LangPack{}, fmt.Errorf("get lang pack strings: %w", err)
}
meta.Strings = make([]domain.LangPackString, 0, len(rows))
for _, row := range rows {
meta.Strings = append(meta.Strings, langPackStringFromKeysRow(row))
}
return meta, nil
}
func (s *LangPackStore) UpsertPack(ctx context.Context, pack domain.LangPack) error {
if txer, ok := s.db.(interface {
Begin(context.Context) (pgx.Tx, error)
}); ok {
tx, err := txer.Begin(ctx)
if err != nil {
return fmt.Errorf("begin lang pack upsert: %w", err)
}
q := s.q.WithTx(tx)
if err := upsertPackWith(ctx, q, pack); err != nil {
_ = tx.Rollback(ctx)
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit lang pack upsert: %w", err)
}
return nil
}
return upsertPackWith(ctx, s.q, pack)
}
func upsertPackWith(ctx context.Context, q *sqlcgen.Queries, pack domain.LangPack) error {
if err := q.UpsertLangPackMeta(ctx, sqlcgen.UpsertLangPackMetaParams{
LangPack: pack.LangPack,
LangCode: pack.LangCode,
Version: int32(pack.Version),
StringsCount: int32(len(pack.Strings)),
}); err != nil {
return fmt.Errorf("upsert lang pack meta: %w", err)
}
for _, item := range pack.Strings {
if err := q.UpsertLangPackString(ctx, sqlcgen.UpsertLangPackStringParams{
LangPack: pack.LangPack,
LangCode: pack.LangCode,
Key: item.Key,
Version: int32(pack.Version),
Pluralized: item.Pluralized,
Value: item.Value,
ZeroValue: item.ZeroValue,
OneValue: item.OneValue,
TwoValue: item.TwoValue,
FewValue: item.FewValue,
ManyValue: item.ManyValue,
OtherValue: item.OtherValue,
Deleted: item.Deleted,
}); err != nil {
return fmt.Errorf("upsert lang pack string %q: %w", item.Key, err)
}
}
return nil
}
func (s *LangPackStore) meta(ctx context.Context, langPack, langCode string) (domain.LangPack, bool, error) {
row, err := s.q.GetLangPackMeta(ctx, sqlcgen.GetLangPackMetaParams{
LangPack: langPack,
LangCode: langCode,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.LangPack{LangPack: langPack, LangCode: langCode}, false, nil
}
return domain.LangPack{}, false, fmt.Errorf("get lang pack meta: %w", err)
}
return domain.LangPack{
LangPack: row.LangPack,
LangCode: row.LangCode,
Version: int(row.Version),
}, true, nil
}
func langPackStringFromListRow(row sqlcgen.ListLangPackStringsRow) domain.LangPackString {
return domain.LangPackString{
Key: row.Key,
Value: row.Value,
Pluralized: row.Pluralized,
ZeroValue: row.ZeroValue,
OneValue: row.OneValue,
TwoValue: row.TwoValue,
FewValue: row.FewValue,
ManyValue: row.ManyValue,
OtherValue: row.OtherValue,
Deleted: row.Deleted,
}
}
func langPackStringFromKeysRow(row sqlcgen.GetLangPackStringsByKeysRow) domain.LangPackString {
return domain.LangPackString{
Key: row.Key,
Value: row.Value,
Pluralized: row.Pluralized,
ZeroValue: row.ZeroValue,
OneValue: row.OneValue,
TwoValue: row.TwoValue,
FewValue: row.FewValue,
ManyValue: row.ManyValue,
OtherValue: row.OtherValue,
Deleted: row.Deleted,
}
}

View file

@ -0,0 +1,509 @@
package postgres
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// MediaStore 用 PostgreSQL 实现 store.MediaStore媒体元数据 + blob 索引)。
type MediaStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewMediaStore 基于 pgx 连接池(或事务)创建 MediaStore。
func NewMediaStore(db sqlcgen.DBTX) *MediaStore {
return &MediaStore{db: db, q: sqlcgen.New(db)}
}
// bytesOrEmpty 把 nil []byte 归一为空切片,避免落入 NOT NULL bytea 列时被当作 NULL。
func bytesOrEmpty(b []byte) []byte {
if b == nil {
return []byte{}
}
return b
}
var _ store.MediaStore = (*MediaStore)(nil)
// ---- 上传分片 ----
func (s *MediaStore) SaveFilePart(ctx context.Context, part domain.UploadPart) error {
return s.q.SaveUploadPart(ctx, sqlcgen.SaveUploadPartParams{
OwnerUserID: part.OwnerUserID,
FileID: part.FileID,
Part: int32(part.Part),
TotalParts: int32(part.TotalParts),
IsBig: part.Big,
Bytes: part.Bytes,
})
}
func (s *MediaStore) LoadFileParts(ctx context.Context, ownerUserID, fileID int64) ([]domain.UploadPart, error) {
rows, err := s.q.ListUploadParts(ctx, sqlcgen.ListUploadPartsParams{OwnerUserID: ownerUserID, FileID: fileID})
if err != nil {
return nil, err
}
out := make([]domain.UploadPart, 0, len(rows))
for _, r := range rows {
out = append(out, domain.UploadPart{
OwnerUserID: ownerUserID,
FileID: fileID,
Part: int(r.Part),
TotalParts: int(r.TotalParts),
Big: r.IsBig,
Bytes: r.Bytes,
})
}
return out, nil
}
func (s *MediaStore) DeleteFileParts(ctx context.Context, ownerUserID, fileID int64) error {
return s.q.DeleteUploadParts(ctx, sqlcgen.DeleteUploadPartsParams{OwnerUserID: ownerUserID, FileID: fileID})
}
// ---- blob 索引 ----
func (s *MediaStore) PutFileBlob(ctx context.Context, blob domain.FileBlob) error {
backend := string(blob.Backend)
if backend == "" {
backend = string(domain.MediaBackendLocalFS)
}
sha := blob.SHA256
if sha == nil {
sha = []byte{} // 列为 NOT NULLnil []byte 会被 pgx 当作 NULL。
}
return s.q.PutFileBlob(ctx, sqlcgen.PutFileBlobParams{
LocationKey: blob.LocationKey,
Backend: backend,
ObjectKey: blob.ObjectKey,
Size: blob.Size,
Sha256: sha,
MimeType: blob.MimeType,
})
}
func (s *MediaStore) GetFileBlob(ctx context.Context, locationKey string) (domain.FileBlob, bool, error) {
row, err := s.q.GetFileBlob(ctx, locationKey)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.FileBlob{}, false, nil
}
return domain.FileBlob{}, false, err
}
return domain.FileBlob{
LocationKey: row.LocationKey,
Backend: domain.MediaBackend(row.Backend),
ObjectKey: row.ObjectKey,
Size: row.Size,
SHA256: row.Sha256,
MimeType: row.MimeType,
}, true, nil
}
// ---- 文档 ----
func (s *MediaStore) PutDocument(ctx context.Context, doc domain.Document) error {
attrs, err := jsonArrayOrEmpty(doc.Attributes)
if err != nil {
return err
}
thumbs, err := jsonArrayOrEmpty(doc.Thumbs)
if err != nil {
return err
}
return s.q.PutDocument(ctx, sqlcgen.PutDocumentParams{
ID: doc.ID,
AccessHash: doc.AccessHash,
FileReference: bytesOrEmpty(doc.FileReference),
Date: int32(doc.Date),
MimeType: doc.MimeType,
Size: doc.Size,
DcID: int32(doc.DCID),
AttributesJson: attrs,
ThumbsJson: thumbs,
})
}
func (s *MediaStore) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) {
row, err := s.q.GetDocument(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Document{}, false, nil
}
return domain.Document{}, false, err
}
doc, err := documentFromRow(row)
if err != nil {
return domain.Document{}, false, err
}
return doc, true, nil
}
func (s *MediaStore) GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) {
if len(ids) == 0 {
return nil, nil
}
rows, err := s.q.GetDocuments(ctx, ids)
if err != nil {
return nil, err
}
out := make([]domain.Document, 0, len(rows))
for _, r := range rows {
doc, err := documentFromRow(sqlcgen.GetDocumentRow(r))
if err != nil {
return nil, err
}
out = append(out, doc)
}
return out, nil
}
func documentFromRow(row sqlcgen.GetDocumentRow) (domain.Document, error) {
attrs, err := decodeDocumentAttributes(row.AttributesJson)
if err != nil {
return domain.Document{}, err
}
thumbs, err := decodePhotoSizes(row.ThumbsJson)
if err != nil {
return domain.Document{}, err
}
return domain.Document{
ID: row.ID,
AccessHash: row.AccessHash,
FileReference: row.FileReference,
Date: int(row.Date),
MimeType: row.MimeType,
Size: row.Size,
DCID: int(row.DcID),
Attributes: attrs,
Thumbs: thumbs,
}, nil
}
// ---- 照片 ----
func (s *MediaStore) PutPhoto(ctx context.Context, photo domain.Photo) error {
sizes, err := jsonArrayOrEmpty(photo.Sizes)
if err != nil {
return err
}
return s.q.PutPhoto(ctx, sqlcgen.PutPhotoParams{
ID: photo.ID,
AccessHash: photo.AccessHash,
FileReference: bytesOrEmpty(photo.FileReference),
Date: int32(photo.Date),
DcID: int32(photo.DCID),
HasStickers: photo.HasStickers,
SizesJson: sizes,
})
}
func (s *MediaStore) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) {
row, err := s.q.GetPhoto(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Photo{}, false, nil
}
return domain.Photo{}, false, err
}
sizes, err := decodePhotoSizes(row.SizesJson)
if err != nil {
return domain.Photo{}, false, err
}
return domain.Photo{
ID: row.ID,
AccessHash: row.AccessHash,
FileReference: row.FileReference,
Date: int(row.Date),
DCID: int(row.DcID),
HasStickers: row.HasStickers,
Sizes: sizes,
}, true, nil
}
// ---- 贴纸集 ----
func (s *MediaStore) PutStickerSet(ctx context.Context, set domain.StickerSet) error {
thumbs, err := jsonArrayOrEmpty(set.Thumbs)
if err != nil {
return err
}
docIDs, err := jsonArrayOrEmpty(set.DocumentIDs)
if err != nil {
return err
}
packs, err := jsonArrayOrEmpty(set.Packs)
if err != nil {
return err
}
kind := string(set.Kind)
if kind == "" {
kind = string(domain.StickerSetKindStickers)
}
return s.q.PutStickerSet(ctx, sqlcgen.PutStickerSetParams{
ID: set.ID,
AccessHash: set.AccessHash,
ShortName: set.ShortName,
Title: set.Title,
Count: int32(set.Count),
Hash: int32(set.Hash),
SetKind: kind,
Official: set.Official,
Animated: set.Animated,
Videos: set.Videos,
Emojis: set.Emojis,
Masks: set.Masks,
Installed: set.Installed,
Archived: set.Archived,
InstalledDate: int32(set.InstalledDate),
ThumbDocumentID: set.ThumbDocumentID,
ThumbsJson: thumbs,
ThumbDcID: int32(set.ThumbDCID),
ThumbVersion: int32(set.ThumbVersion),
DocumentIdsJson: docIDs,
PacksJson: packs,
SortOrder: int32(set.SortOrder),
SystemKey: set.SystemKey,
})
}
func (s *MediaStore) GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) {
row, err := s.q.GetStickerSetByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.StickerSet{}, false, nil
}
return domain.StickerSet{}, false, err
}
return stickerSetFromRow(row)
}
func (s *MediaStore) GetStickerSetByShortName(ctx context.Context, shortName string) (domain.StickerSet, bool, error) {
row, err := s.q.GetStickerSetByShortName(ctx, shortName)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.StickerSet{}, false, nil
}
return domain.StickerSet{}, false, err
}
return stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(row))
}
func (s *MediaStore) GetStickerSetBySystemKey(ctx context.Context, systemKey string) (domain.StickerSet, bool, error) {
row, err := s.q.GetStickerSetBySystemKey(ctx, systemKey)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.StickerSet{}, false, nil
}
return domain.StickerSet{}, false, err
}
return stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(row))
}
func (s *MediaStore) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) {
rows, err := s.q.ListStickerSetsByKind(ctx, string(kind))
if err != nil {
return nil, err
}
out := make([]domain.StickerSet, 0, len(rows))
for _, r := range rows {
set, _, err := stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(r))
if err != nil {
return nil, err
}
out = append(out, set)
}
return out, nil
}
func (s *MediaStore) CountStickerSets(ctx context.Context) (int, error) {
n, err := s.q.CountStickerSets(ctx)
return int(n), err
}
func stickerSetFromRow(row sqlcgen.GetStickerSetByIDRow) (domain.StickerSet, bool, error) {
thumbs, err := decodePhotoSizes(row.ThumbsJson)
if err != nil {
return domain.StickerSet{}, false, err
}
docIDs, err := decodeInt64Slice(row.DocumentIdsJson)
if err != nil {
return domain.StickerSet{}, false, err
}
packs, err := decodeStickerPacks(row.PacksJson)
if err != nil {
return domain.StickerSet{}, false, err
}
return domain.StickerSet{
ID: row.ID,
AccessHash: row.AccessHash,
ShortName: row.ShortName,
Title: row.Title,
Count: int(row.Count),
Hash: int(row.Hash),
Kind: domain.StickerSetKind(row.SetKind),
Official: row.Official,
Animated: row.Animated,
Videos: row.Videos,
Emojis: row.Emojis,
Masks: row.Masks,
Installed: row.Installed,
Archived: row.Archived,
InstalledDate: int(row.InstalledDate),
ThumbDocumentID: row.ThumbDocumentID,
Thumbs: thumbs,
ThumbDCID: int(row.ThumbDcID),
ThumbVersion: int(row.ThumbVersion),
DocumentIDs: docIDs,
Packs: packs,
SortOrder: int(row.SortOrder),
SystemKey: row.SystemKey,
}, true, nil
}
// ---- 可用 reaction ----
func (s *MediaStore) PutAvailableReaction(ctx context.Context, r domain.AvailableReaction) error {
return s.q.PutAvailableReaction(ctx, sqlcgen.PutAvailableReactionParams{
Reaction: r.Reaction,
Title: r.Title,
Inactive: r.Inactive,
Premium: r.Premium,
StaticIconID: r.StaticIconID,
AppearAnimationID: r.AppearAnimationID,
SelectAnimationID: r.SelectAnimationID,
ActivateAnimationID: r.ActivateAnimationID,
EffectAnimationID: r.EffectAnimationID,
AroundAnimationID: r.AroundAnimationID,
CenterIconID: r.CenterIconID,
SortOrder: int32(r.Order),
})
}
func (s *MediaStore) ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) {
rows, err := s.q.ListAvailableReactions(ctx)
if err != nil {
return nil, err
}
out := make([]domain.AvailableReaction, 0, len(rows))
for _, r := range rows {
out = append(out, domain.AvailableReaction{
Reaction: r.Reaction,
Title: r.Title,
Inactive: r.Inactive,
Premium: r.Premium,
StaticIconID: r.StaticIconID,
AppearAnimationID: r.AppearAnimationID,
SelectAnimationID: r.SelectAnimationID,
ActivateAnimationID: r.ActivateAnimationID,
EffectAnimationID: r.EffectAnimationID,
AroundAnimationID: r.AroundAnimationID,
CenterIconID: r.CenterIconID,
Order: int(r.SortOrder),
})
}
return out, nil
}
func (s *MediaStore) CountAvailableReactions(ctx context.Context) (int, error) {
n, err := s.q.CountAvailableReactions(ctx)
return int(n), err
}
// ---- 头像历史 ----
func (s *MediaStore) AddProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) error {
next, err := s.q.NextProfilePhotoOrder(ctx, sqlcgen.NextProfilePhotoOrderParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
})
if err != nil {
return err
}
return s.q.AddProfilePhoto(ctx, sqlcgen.AddProfilePhotoParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
PhotoID: photoID,
Date: int32(date),
SortOrder: next + 1,
})
}
func (s *MediaStore) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (int64, bool, error) {
id, err := s.q.CurrentProfilePhoto(ctx, sqlcgen.CurrentProfilePhotoParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, false, nil
}
return 0, false, err
}
return id, true, nil
}
func (s *MediaStore) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
if len(ownerIDs) == 0 {
return map[int64]domain.ProfilePhotoRef{}, nil
}
rows, err := s.q.CurrentProfilePhotosForOwners(ctx, sqlcgen.CurrentProfilePhotosForOwnersParams{
OwnerPeerType: string(ownerType),
OwnerIds: ownerIDs,
})
if err != nil {
return nil, err
}
out := make(map[int64]domain.ProfilePhotoRef, len(rows))
for _, r := range rows {
sizes, err := decodePhotoSizes(r.SizesJson)
if err != nil {
return nil, err
}
out[r.OwnerPeerID] = domain.ProfilePhotoRef{
PhotoID: r.PhotoID,
DCID: int(r.DcID),
Stripped: domain.StrippedFromSizes(sizes),
}
}
return out, nil
}
func (s *MediaStore) ListProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]int64, int, error) {
ids, err := s.q.ListProfilePhotos(ctx, sqlcgen.ListProfilePhotosParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
MaxID: maxID,
OffsetCount: int32(offset),
LimitCount: int32(limit),
})
if err != nil {
return nil, 0, err
}
total, err := s.q.CountProfilePhotos(ctx, sqlcgen.CountProfilePhotosParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
})
if err != nil {
return nil, 0, err
}
return ids, int(total), nil
}
func (s *MediaStore) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error) {
if len(photoIDs) == 0 {
return nil, nil
}
return s.q.DeactivateProfilePhotos(ctx, sqlcgen.DeactivateProfilePhotosParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
PhotoIds: photoIDs,
})
}

View file

@ -0,0 +1,88 @@
package postgres
import (
"encoding/json"
"telesrv/internal/domain"
)
// 媒体相关 JSON 编解码domain 值对象 ↔ JSONB 列。domain.* 带 json tag可直接 marshal。
// jsonArrayOrEmpty 把切片序列化为 JSONBnil 序列化为 "[]"(列为 NOT NULL DEFAULT '[]')。
func jsonArrayOrEmpty(v any) ([]byte, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
if string(b) == "null" {
return []byte("[]"), nil
}
return b, nil
}
// encodeMessageMedia 把消息媒体快照序列化为 JSONB无媒体序列化为 "{}"。
func encodeMessageMedia(m *domain.MessageMedia) ([]byte, error) {
if m.IsZero() {
return []byte("{}"), nil
}
return json.Marshal(m)
}
// decodeMessageMedia 把消息行的 media JSONB 文本还原为 *MessageMedia空载荷返回 nil。
func decodeMessageMedia(s string) (*domain.MessageMedia, error) {
if s == "" || s == "{}" || s == "null" {
return nil, nil
}
var m domain.MessageMedia
if err := json.Unmarshal([]byte(s), &m); err != nil {
return nil, err
}
if m.IsZero() {
return nil, nil
}
return &m, nil
}
func decodePhotoSizes(s string) ([]domain.PhotoSize, error) {
if s == "" || s == "[]" || s == "null" {
return nil, nil
}
var out []domain.PhotoSize
if err := json.Unmarshal([]byte(s), &out); err != nil {
return nil, err
}
return out, nil
}
func decodeDocumentAttributes(s string) ([]domain.DocumentAttribute, error) {
if s == "" || s == "[]" || s == "null" {
return nil, nil
}
var out []domain.DocumentAttribute
if err := json.Unmarshal([]byte(s), &out); err != nil {
return nil, err
}
return out, nil
}
func decodeInt64Slice(s string) ([]int64, error) {
if s == "" || s == "[]" || s == "null" {
return nil, nil
}
var out []int64
if err := json.Unmarshal([]byte(s), &out); err != nil {
return nil, err
}
return out, nil
}
func decodeStickerPacks(s string) ([]domain.StickerPack, error) {
if s == "" || s == "[]" || s == "null" {
return nil, nil
}
var out []domain.StickerPack
if err := json.Unmarshal([]byte(s), &out); err != nil {
return nil, err
}
return out, nil
}

View file

@ -0,0 +1,210 @@
package postgres
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
// TestMediaStoreRoundTrip 验证 MediaStore 各表的写读往返(含 nil bytea 归一、JSONB attributes/sizes、
// 头像历史 current/list/delete、上传分片。直接证明媒体元数据落 PG 后可原样读回。
func TestMediaStoreRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewMediaStore(pool)
const docID = int64(9100000000000000001)
const photoID = int64(9100000000000000002)
const setID = int64(9100000000000000003)
const ownerID = int64(9100000000000000099)
const reactionEmoji = "\U0001f9ea"
cleanupMediaStoreRoundTripRows(t, ctx, pool)
t.Cleanup(func() {
cleanupMediaStoreRoundTripRows(t, context.Background(), pool)
})
// ---- file blobnil sha256 应被归一为空,不报 NOT NULL----
if err := s.PutFileBlob(ctx, domain.FileBlob{
LocationKey: "doc:9100000000000000001",
ObjectKey: "ab/cd/abcdef",
Size: 1234,
MimeType: "application/x-tgsticker",
}); err != nil {
t.Fatalf("put file blob (nil sha256): %v", err)
}
blob, ok, err := s.GetFileBlob(ctx, "doc:9100000000000000001")
if err != nil || !ok {
t.Fatalf("get file blob: ok=%v err=%v", ok, err)
}
if blob.ObjectKey != "ab/cd/abcdef" || blob.Size != 1234 || blob.Backend != domain.MediaBackendLocalFS {
t.Fatalf("file blob mismatch: %+v", blob)
}
// ---- document含 sticker 属性 + thumbs JSONBnil file_reference 路径)----
doc := domain.Document{
ID: docID,
AccessHash: 77,
DCID: 2,
MimeType: "application/x-tgsticker",
Size: 2048,
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
{Kind: domain.DocAttrSticker, Alt: "\U0001f600", StickerSetID: setID, StickerSetAccessHash: 5},
},
Thumbs: []domain.PhotoSize{{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte{1, 2, 3}}},
}
if err := s.PutDocument(ctx, doc); err != nil {
t.Fatalf("put document: %v", err)
}
got, ok, err := s.GetDocument(ctx, docID)
if err != nil || !ok {
t.Fatalf("get document: ok=%v err=%v", ok, err)
}
if got.DCID != 2 || len(got.Attributes) != 2 || len(got.Thumbs) != 1 {
t.Fatalf("document mismatch: %+v", got)
}
if id, hash, ok := got.StickerSetRef(); !ok || id != setID || hash != 5 {
t.Fatalf("document sticker set ref = (%d,%d,%v)", id, hash, ok)
}
docs, err := s.GetDocuments(ctx, []int64{docID})
if err != nil || len(docs) != 1 {
t.Fatalf("get documents: n=%d err=%v", len(docs), err)
}
// ---- photosizes JSONB----
photo := domain.Photo{
ID: photoID,
AccessHash: 88,
DCID: 2,
Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 800, H: 600, Size: 4096}},
}
if err := s.PutPhoto(ctx, photo); err != nil {
t.Fatalf("put photo: %v", err)
}
gotPhoto, ok, err := s.GetPhoto(ctx, photoID)
if err != nil || !ok || len(gotPhoto.Sizes) != 1 || gotPhoto.Sizes[0].Type != "x" {
t.Fatalf("get photo mismatch: ok=%v err=%v photo=%+v", ok, err, gotPhoto)
}
// ---- sticker set ----
set := domain.StickerSet{
ID: setID,
AccessHash: 5,
ShortName: "telesrv_test_set_9100000000000000003",
Title: "Test Set",
Count: 1,
Kind: domain.StickerSetKindStickers,
Animated: true,
Installed: true,
DocumentIDs: []int64{docID},
Packs: []domain.StickerPack{{Emoticon: "\U0001f600", DocumentIDs: []int64{docID}}},
SystemKey: "test_system_9100000000000000003",
}
if err := s.PutStickerSet(ctx, set); err != nil {
t.Fatalf("put sticker set: %v", err)
}
byID, ok, err := s.GetStickerSetByID(ctx, setID)
if err != nil || !ok || len(byID.DocumentIDs) != 1 || len(byID.Packs) != 1 {
t.Fatalf("get sticker set by id: ok=%v err=%v set=%+v", ok, err, byID)
}
if byShort, ok, _ := s.GetStickerSetByShortName(ctx, set.ShortName); !ok || byShort.ID != setID {
t.Fatalf("get sticker set by short name failed: ok=%v", ok)
}
if bySys, ok, _ := s.GetStickerSetBySystemKey(ctx, set.SystemKey); !ok || bySys.ID != setID {
t.Fatalf("get sticker set by system key failed: ok=%v", ok)
}
// ---- available reaction ----
if err := s.PutAvailableReaction(ctx, domain.AvailableReaction{
Reaction: reactionEmoji, Title: "Test", StaticIconID: docID, SelectAnimationID: docID, Order: 9999,
}); err != nil {
t.Fatalf("put available reaction: %v", err)
}
reactions, err := s.ListAvailableReactions(ctx)
if err != nil {
t.Fatalf("list available reactions: %v", err)
}
foundReaction := false
for _, r := range reactions {
if r.Reaction == reactionEmoji {
foundReaction = true
if r.StaticIconID != docID {
t.Fatalf("reaction static icon id = %d", r.StaticIconID)
}
}
}
if !foundReaction {
t.Fatal("inserted reaction not found in list")
}
// ---- profile photo 历史 ----
if err := s.AddProfilePhoto(ctx, domain.PeerTypeUser, ownerID, photoID, 1700000000); err != nil {
t.Fatalf("add profile photo: %v", err)
}
cur, ok, err := s.CurrentProfilePhoto(ctx, domain.PeerTypeUser, ownerID)
if err != nil || !ok || cur != photoID {
t.Fatalf("current profile photo = (%d,%v,%v)", cur, ok, err)
}
refs, err := s.CurrentProfilePhotos(ctx, domain.PeerTypeUser, []int64{ownerID})
if err != nil || refs[ownerID].PhotoID != photoID || refs[ownerID].DCID != 2 {
t.Fatalf("current profile photos batch = %+v err=%v", refs, err)
}
ids, total, err := s.ListProfilePhotos(ctx, domain.PeerTypeUser, ownerID, 0, 10, 0)
if err != nil || total < 1 || len(ids) < 1 {
t.Fatalf("list profile photos: ids=%v total=%d err=%v", ids, total, err)
}
deleted, err := s.DeleteProfilePhotos(ctx, domain.PeerTypeUser, ownerID, []int64{photoID})
if err != nil || len(deleted) != 1 {
t.Fatalf("delete profile photos: deleted=%v err=%v", deleted, err)
}
if _, ok, _ := s.CurrentProfilePhoto(ctx, domain.PeerTypeUser, ownerID); ok {
t.Fatal("profile photo still current after delete")
}
// ---- upload parts ----
if err := s.SaveFilePart(ctx, domain.UploadPart{OwnerUserID: ownerID, FileID: 555, Part: 0, Bytes: []byte("hello")}); err != nil {
t.Fatalf("save file part: %v", err)
}
parts, err := s.LoadFileParts(ctx, ownerID, 555)
if err != nil || len(parts) != 1 || string(parts[0].Bytes) != "hello" {
t.Fatalf("load file parts: parts=%+v err=%v", parts, err)
}
if err := s.DeleteFileParts(ctx, ownerID, 555); err != nil {
t.Fatalf("delete file parts: %v", err)
}
if parts, _ := s.LoadFileParts(ctx, ownerID, 555); len(parts) != 0 {
t.Fatal("file parts not cleared")
}
}
func cleanupMediaStoreRoundTripRows(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
t.Helper()
const docID = int64(9100000000000000001)
const photoID = int64(9100000000000000002)
const setID = int64(9100000000000000003)
const ownerID = int64(9100000000000000099)
const reactionEmoji = "\U0001f9ea"
statements := []struct {
sql string
args []any
}{
{sql: "DELETE FROM upload_parts WHERE owner_user_id = $1 AND file_id = 555", args: []any{ownerID}},
{sql: "DELETE FROM profile_photos WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND photo_id = $2", args: []any{ownerID, photoID}},
{sql: "DELETE FROM available_reactions WHERE reaction IN ($1, 'telesrv-test-😀')", args: []any{reactionEmoji}},
{sql: "DELETE FROM sticker_sets WHERE id = $1 OR short_name = 'telesrv_test_set_9100000000000000003' OR system_key = 'test_system_9100000000000000003'", args: []any{setID}},
{sql: "DELETE FROM file_blobs WHERE location_key = 'doc:9100000000000000001'"},
{sql: "DELETE FROM documents WHERE id = $1", args: []any{docID}},
{sql: "DELETE FROM photos WHERE id = $1", args: []any{photoID}},
}
for _, stmt := range statements {
if _, err := pool.Exec(ctx, stmt.sql, stmt.args...); err != nil {
t.Fatalf("cleanup media store round trip rows: %v", err)
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,114 @@
package postgres
import (
"context"
"sync"
"testing"
"time"
"telesrv/internal/domain"
)
// TestSendPrivateTextConcurrentNoPtsGap 是「Redis 分配移出事务」的正确性核心证明:
// N 条消息并发 sender→recipient 发送,全部成功提交后,接收方账号事件 pts 必须严格连续 1..N
// (无空洞、无重复、无丢失),且 MaxContiguousPts == N。
// 用 perUserCounterAllocatormutex 单调自增,忠实复刻 Redis INCR 原子性)驱动分配器,
// 验证事务外分配 + 连续 pts 兜底在高并发下不丢消息,不引入 Redis 依赖。
func TestSendPrivateTextConcurrentNoPtsGap(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{
AccessHash: 51,
Phone: "+1999" + suffix + "01",
FirstName: "ConcSender",
})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{
AccessHash: 52,
Phone: "+1999" + suffix + "02",
FirstName: "ConcRecipient",
})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
ids := []int64{sender.ID, recipient.ID}
t.Cleanup(func() {
// 按 FK 依赖序清理子表message_boxes.from_user_id 为 RESTRICT再删用户。
_, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids)
})
messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{}, &perUserCounterAllocator{}))
const n = 200
base := time.Now().UnixNano()
date := int(time.Now().Unix())
errs := make([]error, n)
recipPts := make([]int, n)
sem := make(chan struct{}, 32)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
sem <- struct{}{}
go func(i int) {
defer wg.Done()
defer func() { <-sem }()
res, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: base + int64(i),
Message: "concurrent body",
Date: date,
})
errs[i] = err
recipPts[i] = res.RecipientMessage.Pts
}(i)
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("send %d: %v", i, err)
}
}
// 进程内检测 allocator 是否给接收方分配了重复 pts。
seen := map[int]int{}
for i := 0; i < n; i++ {
if prev, ok := seen[recipPts[i]]; ok {
t.Errorf("recipient pts %d 被发送 #%d 和 #%d 同时分配allocator 并发重复)", recipPts[i], prev, i)
}
seen[recipPts[i]] = i
}
// 接收方应有 n 条事件pts 连续 1..n无空洞无重复无丢失
events := NewUpdateEventStore(pool)
got, err := events.ListAfter(ctx, recipient.ID, 0, n+10)
if err != nil {
t.Fatalf("list recipient events: %v", err)
}
if len(got) != n {
t.Fatalf("recipient events = %d, want %d无丢失无重复", len(got), n)
}
for i, ev := range got {
if ev.Pts != i+1 {
t.Fatalf("recipient event[%d].Pts = %d, want %dpts 必须连续无洞)", i, ev.Pts, i+1)
}
}
contig, err := events.MaxContiguousPts(ctx, recipient.ID)
if err != nil {
t.Fatalf("MaxContiguousPts: %v", err)
}
if contig != n {
t.Fatalf("MaxContiguousPts(recipient) = %d, want %d", contig, n)
}
}

View file

@ -0,0 +1,119 @@
package postgres
import (
"context"
"sync"
"testing"
"time"
"telesrv/internal/domain"
)
// TestMessageStoreBidirectionalConcurrencyNoDeadlock 验证 watermark/dialog 死锁修复advisory lock
//
// 背景send/read/edit/delete 在一个事务内会按业务顺序锁住收发双方的 user_update_watermarks 与
// channel/private dialog 行。A→B 与 B→A 反向并发时,两个事务以相反顺序竞争同一对用户的这些行
// watermark[A]→watermark[B] vs watermark[B]→watermark[A]dialog(A,B)→dialog(B,A) vs 反向),
// 形成 AB-BA 死锁——PostgreSQL 会检测并 abort 其中一个事务SQLSTATE 40P01表现为操作返回错误。
//
// 修复:每个写事务在任何行锁之前,用事务级 advisory lock 按 user_id 升序锁住涉及的用户
// lockUsersForUpdate把同一对用户的并发写事务串行化。advisory 与行锁处于独立锁空间且升序获取,
// 既不与行锁交叉成新死锁,也消除了 watermark 与 dialog 的 AB-BA。本测试在高并发反向负载下应
// 全部成功、零错误;若死锁回归,会以 40P01 错误形式被捕获。
func TestMessageStoreBidirectionalConcurrencyNoDeadlock(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
a, err := users.Create(ctx, domain.User{AccessHash: 71, Phone: "+1997" + suffix + "01", FirstName: "BidiA"})
if err != nil {
t.Fatalf("create a: %v", err)
}
b, err := users.Create(ctx, domain.User{AccessHash: 72, Phone: "+1997" + suffix + "02", FirstName: "BidiB"})
if err != nil {
t.Fatalf("create b: %v", err)
}
ids := []int64{a.ID, b.ID}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM user_update_watermarks WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids)
})
messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{}, &perUserCounterAllocator{}))
date := int(time.Now().Unix())
var ridMu sync.Mutex
rid := time.Now().UnixNano()
nextRID := func() int64 {
ridMu.Lock()
defer ridMu.Unlock()
rid++
return rid
}
// 预热:双向各发若干条,建立双向 dialog 与未读历史,使后续 read 真正推进 watermark命中行锁
for i := 0; i < 4; i++ {
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: b.ID, RandomID: nextRID(), Message: "warmup a->b", Date: date}); err != nil {
t.Fatalf("warmup a->b: %v", err)
}
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: b.ID, RecipientUserID: a.ID, RandomID: nextRID(), Message: "warmup b->a", Date: date}); err != nil {
t.Fatalf("warmup b->a: %v", err)
}
}
// 反向并发:每轮同时发起 A→B send、B→A send、A 读 B、B 读 Agoroutine 一起抢同一对用户的行锁。
const rounds = 80
var wg sync.WaitGroup
errCh := make(chan error, rounds*4)
sem := make(chan struct{}, 24)
submit := func(op func() error) {
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
if e := op(); e != nil {
errCh <- e
}
}()
}
for r := 0; r < rounds; r++ {
submit(func() error {
_, e := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: b.ID, RandomID: nextRID(), Message: "a->b", Date: date})
return e
})
submit(func() error {
_, e := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: b.ID, RecipientUserID: a.ID, RandomID: nextRID(), Message: "b->a", Date: date})
return e
})
submit(func() error {
_, e := messages.ReadHistory(ctx, domain.ReadHistoryRequest{OwnerUserID: a.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: b.ID}, MaxID: domain.MaxMessageBoxID, Date: date})
return e
})
submit(func() error {
_, e := messages.ReadHistory(ctx, domain.ReadHistoryRequest{OwnerUserID: b.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: a.ID}, MaxID: domain.MaxMessageBoxID, Date: date})
return e
})
}
wg.Wait()
close(errCh)
failed := 0
for e := range errCh {
failed++
if failed <= 5 {
t.Errorf("反向并发操作失败(疑似死锁回归): %v", e)
}
}
if failed > 0 {
t.Fatalf("%d/%d 反向并发操作失败", failed, rounds*4)
}
}

View file

@ -0,0 +1,813 @@
package postgres
import (
"context"
"sync"
"testing"
"telesrv/internal/domain"
)
func TestMessageStoreSendPrivateTextRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{
AccessHash: 11,
Phone: "+1666" + suffix + "01",
FirstName: "Sender",
})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{
AccessHash: 22,
Phone: "+1666" + suffix + "02",
FirstName: "Recipient",
})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
messages := NewMessageStore(pool)
var originAuthKeyID [8]byte
originAuthKeyID[0] = 5
req := domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: 123456,
Message: "hello from pg",
Entities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 5}},
Date: 1700000200,
OriginAuthKeyID: originAuthKeyID,
OriginSessionID: 77,
}
got, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
if got.SenderMessage.ID != 1 || got.SenderMessage.Pts != 1 || !got.SenderMessage.Out || got.SenderMessage.Peer.ID != recipient.ID {
t.Fatalf("sender message = %+v, want first outgoing box to recipient", got.SenderMessage)
}
if got.RecipientMessage.ID != 1 || got.RecipientMessage.Pts != 1 || got.RecipientMessage.Out || got.RecipientMessage.Peer.ID != sender.ID {
t.Fatalf("recipient message = %+v, want first incoming box from sender", got.RecipientMessage)
}
if got.SenderMessage.UID == 0 || got.SenderMessage.UID != got.RecipientMessage.UID {
t.Fatalf("uid = sender %d recipient %d, want shared private message uid", got.SenderMessage.UID, got.RecipientMessage.UID)
}
senderHistory, err := messages.ListByUser(ctx, sender.ID, domain.MessageFilter{HasPeer: true, Peer: got.SenderMessage.Peer, Limit: 10})
if err != nil {
t.Fatalf("sender history: %v", err)
}
recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{HasPeer: true, Peer: got.RecipientMessage.Peer, Limit: 10})
if err != nil {
t.Fatalf("recipient history: %v", err)
}
if len(senderHistory.Messages) != 1 || len(recipientHistory.Messages) != 1 {
t.Fatalf("history sizes = sender %d recipient %d, want both owner partitions populated", len(senderHistory.Messages), len(recipientHistory.Messages))
}
events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10)
if err != nil {
t.Fatalf("list recipient events: %v", err)
}
if len(events) != 1 || events[0].Message.ID != got.RecipientMessage.ID || len(events[0].Users) != 1 || events[0].Users[0].ID != sender.ID {
t.Fatalf("recipient events = %+v, want new message with sender user", events)
}
var pendingOutbox int
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM dispatch_outbox
WHERE target_user_id = ANY($1::bigint[])
AND status = 'pending'
`, []int64{sender.ID, recipient.ID}).Scan(&pendingOutbox); err != nil {
t.Fatalf("count dispatch outbox: %v", err)
}
if pendingOutbox != 2 {
t.Fatalf("pending outbox = %d, want sender + recipient dispatch rows", pendingOutbox)
}
var excludeAuthKeyID, excludeSessionID int64
if err := pool.QueryRow(ctx, `
SELECT exclude_auth_key_id, exclude_session_id
FROM dispatch_outbox
WHERE target_user_id = $1
`, sender.ID).Scan(&excludeAuthKeyID, &excludeSessionID); err != nil {
t.Fatalf("sender dispatch outbox: %v", err)
}
if excludeAuthKeyID != authKeyIDToInt64(originAuthKeyID) || excludeSessionID != 77 {
t.Fatalf("sender dispatch exclude = auth %d session %d, want origin auth/session", excludeAuthKeyID, excludeSessionID)
}
dup, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("SendPrivateText duplicate: %v", err)
}
if !dup.Duplicate || dup.SenderMessage.ID != got.SenderMessage.ID || dup.RecipientMessage.ID != got.RecipientMessage.ID {
t.Fatalf("duplicate = %+v, want original message boxes", dup)
}
}
func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
alice, err := users.Create(ctx, domain.User{
AccessHash: 91,
Phone: "+1667" + suffix + "01",
FirstName: "Alice",
})
if err != nil {
t.Fatalf("create alice: %v", err)
}
bob, err := users.Create(ctx, domain.User{
AccessHash: 92,
Phone: "+1667" + suffix + "02",
FirstName: "Bob",
})
if err != nil {
t.Fatalf("create bob: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{alice.ID, bob.ID})
})
messages := NewMessageStore(pool)
for i := 1; i <= 6; i++ {
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: alice.ID,
RecipientUserID: bob.ID,
RandomID: int64(700 + i),
Message: "history",
Date: 1700000000 + i,
}); err != nil {
t.Fatalf("seed message %d: %v", i, err)
}
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}
around, err := messages.ListByUser(ctx, bob.ID, domain.MessageFilter{
HasPeer: true,
Peer: peer,
OffsetID: 3,
AddOffset: -3,
Limit: 6,
NeedTotalCount: true,
})
if err != nil {
t.Fatalf("around history: %v", err)
}
if got := messageIDs(around.Messages); !sameInts(got, []int{6, 5, 4, 3, 2, 1}) {
t.Fatalf("around ids = %v, want unread/newer side plus older context", got)
}
if around.Count != 6 {
t.Fatalf("around count = %d, want full dialog count", around.Count)
}
forward, err := messages.ListByUser(ctx, bob.ID, domain.MessageFilter{
HasPeer: true,
Peer: peer,
OffsetID: 3,
AddOffset: -3,
Limit: 3,
NeedTotalCount: true,
})
if err != nil {
t.Fatalf("forward history: %v", err)
}
if got := messageIDs(forward.Messages); !sameInts(got, []int{6, 5, 4}) {
t.Fatalf("forward ids = %v, want messages newer than offset", got)
}
if forward.Count != 6 {
t.Fatalf("forward count = %d, want full dialog count", forward.Count)
}
}
func TestMessageStoreReadAndEditEmitDurableEvents(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{
AccessHash: 31,
Phone: "+1666" + suffix + "11",
FirstName: "ReadSender",
})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{
AccessHash: 32,
Phone: "+1666" + suffix + "12",
FirstName: "ReadRecipient",
})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
messages := NewMessageStore(pool)
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: 223344,
Message: "before edit",
Date: 1700000300,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
read, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{
OwnerUserID: recipient.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID},
Date: 1700000310,
})
if err != nil {
t.Fatalf("ReadHistory: %v", err)
}
if !read.Changed || read.InboxEvent.Pts != 2 || read.InboxEvent.Type != domain.UpdateEventReadHistoryInbox || read.InboxEvent.MaxID != sent.RecipientMessage.ID {
t.Fatalf("read inbox = %+v, want recipient pts=2 max recipient id", read)
}
if !read.OutboxChanged || read.OutboxEvent.Pts != 2 || read.OutboxEvent.Type != domain.UpdateEventReadHistoryOutbox || read.OutboxEvent.MaxID != sent.SenderMessage.ID {
t.Fatalf("read outbox = %+v, want sender pts=2 max sender id", read)
}
readDate, err := messages.GetOutboxReadDate(ctx, domain.OutboxReadDateRequest{
OwnerUserID: sender.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID},
ID: sent.SenderMessage.ID,
})
if err != nil || readDate != 1700000310 {
t.Fatalf("outbox read date = %d err=%v, want read date", readDate, err)
}
edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{
OwnerUserID: sender.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID},
ID: sent.SenderMessage.ID,
Message: "after edit",
EditDate: 1700000320,
})
if err != nil {
t.Fatalf("EditMessage: %v", err)
}
if self := edited.Self(); self.Event.Pts != 3 || self.Event.Type != domain.UpdateEventEditMessage || self.Message.Body != "after edit" {
t.Fatalf("self edit = %+v, want sender edit event pts=3", self)
}
recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID},
Limit: 10,
})
if err != nil {
t.Fatalf("recipient history: %v", err)
}
if len(recipientHistory.Messages) != 1 || recipientHistory.Messages[0].Body != "after edit" || recipientHistory.Messages[0].EditDate != 1700000320 {
t.Fatalf("recipient history = %+v, want edited message visible", recipientHistory.Messages)
}
senderEvents, err := NewUpdateEventStore(pool).ListAfter(ctx, sender.ID, 0, 10)
if err != nil {
t.Fatalf("sender events: %v", err)
}
if len(senderEvents) != 3 || senderEvents[1].Type != domain.UpdateEventReadHistoryOutbox || senderEvents[2].Type != domain.UpdateEventEditMessage {
t.Fatalf("sender events = %+v, want new/read_outbox/edit", senderEvents)
}
recipientEvents, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10)
if err != nil {
t.Fatalf("recipient events: %v", err)
}
if len(recipientEvents) != 3 || recipientEvents[1].Type != domain.UpdateEventReadHistoryInbox || recipientEvents[2].Type != domain.UpdateEventEditMessage || recipientEvents[2].Message.Body != "after edit" {
t.Fatalf("recipient events = %+v, want new/read_inbox/edit with edited body", recipientEvents)
}
}
func TestMessageStoreSendPrivateTextRollbackRecordsPtsNoop(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{
AccessHash: 31,
Phone: "+1777" + suffix + "01",
FirstName: "GapSender",
})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{
AccessHash: 32,
Phone: "+1777" + suffix + "02",
FirstName: "GapRecipient",
})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
messages := NewMessageStore(pool)
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: 223344,
Message: "seed box",
Date: 1700000210,
}); err != nil {
t.Fatalf("seed SendPrivateText: %v", err)
}
failing := NewMessageStore(pool, WithMessageAllocators(fixedBoxIDAllocator{next: 1}, fixedPtsAllocator{next: 42}))
_, err = failing.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: 223345,
Message: "should roll back",
Date: 1700000211,
})
if err == nil {
t.Fatal("SendPrivateText succeeded, want box id conflict")
}
events, err := NewUpdateEventStore(pool).ListAfter(ctx, sender.ID, 1, 10)
if err != nil {
t.Fatalf("list sender events: %v", err)
}
for _, event := range events {
if event.Pts == 42 && event.Type == domain.UpdateEventNoop {
return
}
}
t.Fatalf("events = %+v, want noop gap at pts=42", events)
}
func TestMessageStoreConcurrentRandomIDIdempotent(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{
AccessHash: 41,
Phone: "+1888" + suffix + "01",
FirstName: "ConcurrentSender",
})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{
AccessHash: 42,
Phone: "+1888" + suffix + "02",
FirstName: "ConcurrentRecipient",
})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
boxCounters := &perUserCounterAllocator{}
ptsCounters := &perUserCounterAllocator{}
messages := NewMessageStore(pool, WithMessageAllocators(boxCounters, ptsCounters))
req := domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: 556677,
Message: "same random id",
Date: 1700000220,
}
const workers = 8
results := make(chan domain.SendPrivateTextResult, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
res, err := messages.SendPrivateText(ctx, req)
if err != nil {
errs <- err
return
}
results <- res
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("SendPrivateText: %v", err)
}
var uid int64
duplicates := 0
successes := 0
for res := range results {
if res.SenderMessage.UID == 0 || res.RecipientMessage.UID == 0 {
t.Fatalf("result = %+v, want populated shared message uid", res)
}
if uid == 0 {
uid = res.SenderMessage.UID
}
if res.SenderMessage.UID != uid || res.RecipientMessage.UID != uid {
t.Fatalf("result = %+v, want same private message uid %d", res, uid)
}
if res.Duplicate {
duplicates++
} else {
successes++
}
}
if successes != 1 || duplicates != workers-1 {
t.Fatalf("successes=%d duplicates=%d, want one insert and duplicate rest", successes, duplicates)
}
var privateCount int
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM private_messages
WHERE sender_user_id = $1
AND random_id = $2
`, sender.ID, req.RandomID).Scan(&privateCount); err != nil {
t.Fatalf("count private_messages: %v", err)
}
if privateCount != 1 {
t.Fatalf("private message count = %d, want 1", privateCount)
}
var boxCount int
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM message_boxes
WHERE private_message_id = $1
`, uid).Scan(&boxCount); err != nil {
t.Fatalf("count message boxes: %v", err)
}
if boxCount != 2 {
t.Fatalf("message box count = %d, want sender + recipient boxes", boxCount)
}
}
func TestMessageStoreDeleteHistoryRebuildsDialogAndEmitsDeleteUpdates(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender := createTestUser(t, ctx, users, "+1991"+suffix+"01", "DeleteSender", "")
recipient := createTestUser(t, ctx, users, "+1991"+suffix+"02", "DeleteRecipient", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
messages := NewMessageStore(pool)
for i := 0; i < 2; i++ {
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: int64(7000 + i),
Message: "history",
Date: 1700000700 + i,
}); err != nil {
t.Fatalf("seed send %d: %v", i, err)
}
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}
deleted, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: sender.ID,
Peer: peer,
Date: 1700000800,
})
if err != nil {
t.Fatalf("DeleteHistory: %v", err)
}
if self := deleted.Self(); self.Event.Pts != 4 || self.Event.PtsCount != 2 || len(self.MessageIDs) != 2 {
t.Fatalf("delete result = %+v, want sender delete range pts=4 count=2 ids", self)
}
senderHistory, err := messages.ListByUser(ctx, sender.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10})
if err != nil {
t.Fatalf("sender history: %v", err)
}
recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10})
if err != nil {
t.Fatalf("recipient history: %v", err)
}
if len(senderHistory.Messages) != 0 || len(recipientHistory.Messages) != 2 {
t.Fatalf("history sizes sender=%d recipient=%d, want sender cleared only", len(senderHistory.Messages), len(recipientHistory.Messages))
}
senderDialogs, err := NewDialogStore(pool).ListByUser(ctx, sender.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("sender dialogs after delete: %v", err)
}
if len(senderDialogs.Dialogs) != 0 {
t.Fatalf("sender dialogs = %+v, want empty after full history delete", senderDialogs.Dialogs)
}
events, err := NewUpdateEventStore(pool).ListAfter(ctx, sender.ID, 2, 10)
if err != nil {
t.Fatalf("list sender events: %v", err)
}
if len(events) != 1 || events[0].Type != domain.UpdateEventDeleteMessages || events[0].Pts != 4 || events[0].PtsCount != 2 || len(events[0].MessageIDs) != 2 {
t.Fatalf("events = %+v, want delete messages event pts=4 pts_count=2", events)
}
rebuilt, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: 8000,
Message: "after clear",
Date: 1700000900,
})
if err != nil {
t.Fatalf("send after delete: %v", err)
}
senderDialogs, err = NewDialogStore(pool).ListByUser(ctx, sender.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("sender dialogs after rebuild: %v", err)
}
if len(senderDialogs.Dialogs) != 1 || senderDialogs.Dialogs[0].Peer != peer || senderDialogs.Dialogs[0].TopMessage != rebuilt.SenderMessage.ID {
t.Fatalf("rebuilt dialogs = %+v, want new top message %d", senderDialogs.Dialogs, rebuilt.SenderMessage.ID)
}
revoked, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{
OwnerUserID: sender.ID,
IDs: []int{rebuilt.SenderMessage.ID},
Revoke: true,
Date: 1700001000,
})
if err != nil {
t.Fatalf("DeleteMessages revoke: %v", err)
}
if len(revoked.Deleted) != 2 || !revoked.Changed() {
t.Fatalf("revoked = %+v, want delete events for both owners", revoked)
}
senderHistory, err = messages.ListByUser(ctx, sender.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10})
if err != nil {
t.Fatalf("sender history after revoke: %v", err)
}
recipientHistory, err = messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10})
if err != nil {
t.Fatalf("recipient history after revoke: %v", err)
}
if len(senderHistory.Messages) != 0 || len(recipientHistory.Messages) != 2 {
t.Fatalf("history sizes after revoke sender=%d recipient=%d, want new message removed from both owners", len(senderHistory.Messages), len(recipientHistory.Messages))
}
}
func TestMessageStoreDeleteHistoryJustClearPreservesEmptyDialog(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner := createTestUser(t, ctx, users, "+1992"+suffix+"01", "ClearOwner", "")
peerUser := createTestUser(t, ctx, users, "+1992"+suffix+"02", "ClearPeer", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, peerUser.ID})
})
messages := NewMessageStore(pool)
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: owner.ID,
RecipientUserID: peerUser.ID,
RandomID: 9000,
Message: "clear but keep dialog",
Date: 1700001100,
}); err != nil {
t.Fatalf("seed send: %v", err)
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerUser.ID}
if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner.ID,
Peer: peer,
JustClear: true,
Date: 1700001200,
}); err != nil {
t.Fatalf("DeleteHistory just_clear: %v", err)
}
dialogs, err := NewDialogStore(pool).ListByUser(ctx, owner.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("dialogs after just_clear: %v", err)
}
if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].Peer != peer || dialogs.Dialogs[0].TopMessage != 0 || len(dialogs.Messages) != 0 {
t.Fatalf("dialogs = %+v messages=%+v, want empty dialog preserved after just_clear", dialogs.Dialogs, dialogs.Messages)
}
history, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10, NeedTotalCount: true})
if err != nil {
t.Fatalf("history after just_clear: %v", err)
}
if len(history.Messages) != 0 {
t.Fatalf("history = %+v, want cleared", history.Messages)
}
}
func TestMessageStoreDeleteHistoryBatchesHugeMaxID(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner := createTestUser(t, ctx, users, "+1993"+suffix+"01", "BulkOwner", "")
peerUser := createTestUser(t, ctx, users, "+1993"+suffix+"02", "BulkPeer", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, peerUser.ID})
})
total := domain.MaxDeleteHistoryBatch + 2
if _, err := pool.Exec(ctx, `
WITH src AS (
SELECT generate_series(1, $3::int) AS g
),
pm AS (
INSERT INTO private_messages (
sender_user_id,
recipient_user_id,
random_id,
message_date,
body,
entities
)
SELECT
$1::bigint,
$2::bigint,
910000000 + g,
1700002000 + g,
'bulk history',
'[]'::jsonb
FROM src
RETURNING id, random_id, message_date
)
INSERT INTO message_boxes (
owner_user_id,
box_id,
private_message_id,
message_sender_id,
peer_type,
peer_id,
from_user_id,
message_date,
outgoing,
body,
entities,
pts
)
SELECT
$1::bigint,
(random_id - 910000000)::int,
id,
$1::bigint,
'user',
$2::bigint,
$1::bigint,
message_date,
true,
'bulk history',
'[]'::jsonb,
0
FROM pm
`, owner.ID, peerUser.ID, total); err != nil {
t.Fatalf("seed bulk history: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO dialogs (
user_id,
peer_type,
peer_id,
top_message_id,
top_message_date,
read_outbox_max_id,
unread_count
) VALUES ($1, 'user', $2, $3, $4, $3, 0)
`, owner.ID, peerUser.ID, total, 1700002000+total); err != nil {
t.Fatalf("seed dialog: %v", err)
}
messages := NewMessageStore(pool)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerUser.ID}
first, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner.ID,
Peer: peer,
MaxID: domain.MaxMessageBoxID,
Date: 1700003000,
})
if err != nil {
t.Fatalf("DeleteHistory first batch: %v", err)
}
self := first.Self()
if first.Offset != 1 || self.Event.Pts != domain.MaxDeleteHistoryBatch || self.Event.PtsCount != domain.MaxDeleteHistoryBatch || len(self.MessageIDs) != domain.MaxDeleteHistoryBatch {
t.Fatalf("first batch = %+v self=%+v, want offset=1 and exactly %d deleted ids", first, self, domain.MaxDeleteHistoryBatch)
}
history, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10, NeedTotalCount: true})
if err != nil {
t.Fatalf("history after first batch: %v", err)
}
if history.Count != 2 || len(history.Messages) != 2 || history.Messages[0].ID != 2 {
t.Fatalf("history after first batch = %+v, want only two oldest messages left", history)
}
second, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner.ID,
Peer: peer,
MaxID: domain.MaxMessageBoxID,
Date: 1700003001,
})
if err != nil {
t.Fatalf("DeleteHistory second batch: %v", err)
}
if second.Offset != 0 || second.Self().Event.PtsCount != 2 {
t.Fatalf("second batch = %+v, want final offset=0 pts_count=2", second)
}
}
type fixedBoxIDAllocator struct {
next int
}
func (a fixedBoxIDAllocator) NextBoxID(context.Context, int64) (int, error) {
return a.next, nil
}
func (a fixedBoxIDAllocator) CurrentBoxID(context.Context, int64) (int, error) {
return a.next, nil
}
type fixedPtsAllocator struct {
next int
}
func (a fixedPtsAllocator) NextPts(context.Context, int64) (int, error) {
return a.next, nil
}
func (a fixedPtsAllocator) CurrentPts(context.Context, int64) (int, error) {
return a.next, nil
}
type perUserCounterAllocator struct {
mu sync.Mutex
values map[int64]int
}
func (a *perUserCounterAllocator) NextBoxID(_ context.Context, userID int64) (int, error) {
return a.next(userID), nil
}
func (a *perUserCounterAllocator) CurrentBoxID(_ context.Context, userID int64) (int, error) {
return a.current(userID), nil
}
func (a *perUserCounterAllocator) NextPts(_ context.Context, userID int64) (int, error) {
return a.next(userID), nil
}
func (a *perUserCounterAllocator) CurrentPts(_ context.Context, userID int64) (int, error) {
return a.current(userID), nil
}
func (a *perUserCounterAllocator) next(userID int64) int {
a.mu.Lock()
defer a.mu.Unlock()
if a.values == nil {
a.values = map[int64]int{}
}
a.values[userID]++
return a.values[userID]
}
func (a *perUserCounterAllocator) current(userID int64) int {
a.mu.Lock()
defer a.mu.Unlock()
return a.values[userID]
}
func messageIDs(messages []domain.Message) []int {
out := make([]int, 0, len(messages))
for _, msg := range messages {
out = append(out, msg.ID)
}
return out
}
func sameInts(got, want []int) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}

View file

@ -0,0 +1,181 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
// TestSendPrivateMediaSurvivesUpdateEvent 验证带 media 的私聊消息:
// - 发送后 message_boxes 持久化 media 快照;
// - 接收方经 UpdateEventStore.ListAfter在线 outbox / 离线 getDifference 共用的重建路径)
// 能拿回 media曾因 update event 查询漏选 m.media 导致收件人/离线丢媒体,本测试守护该修复);
// - historyListByUser读取也带 media。
func TestSendPrivateMediaSurvivesUpdateEvent(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{AccessHash: 61, Phone: "+1998" + suffix + "01", FirstName: "MediaSender"})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{AccessHash: 62, Phone: "+1998" + suffix + "02", FirstName: "MediaRecipient"})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
ids := []int64{sender.ID, recipient.ID}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids)
})
messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{}, &perUserCounterAllocator{}))
media := &domain.MessageMedia{
Kind: domain.MessageMediaKindDocument,
Document: &domain.Document{
ID: 9200000000000000001,
AccessHash: 9,
DCID: 2,
MimeType: "application/x-tgsticker",
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker, Alt: "\U0001f600", StickerSetID: 5, StickerSetAccessHash: 7}},
},
}
res, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: time.Now().UnixNano(),
Message: "", // 仅媒体(无 caption
Media: media,
Date: int(time.Now().Unix()),
})
if err != nil {
t.Fatalf("send private media: %v", err)
}
// 发送结果双端均带 media。
for name, msg := range map[string]domain.Message{"sender": res.SenderMessage, "recipient": res.RecipientMessage} {
if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindDocument || msg.Media.Document == nil || msg.Media.Document.ID != media.Document.ID {
t.Fatalf("%s message media lost: %+v", name, msg.Media)
}
}
// 关键:接收方经更新事件重建(在线推送 / 离线 difference 共用路径)仍带 media。
events := NewUpdateEventStore(pool)
got, err := events.ListAfter(ctx, recipient.ID, 0, 10)
if err != nil {
t.Fatalf("list recipient events: %v", err)
}
var found bool
for _, ev := range got {
if ev.Type == domain.UpdateEventNewMessage && ev.Message.ID != 0 {
found = true
if ev.Message.Media == nil || ev.Message.Media.Document == nil || ev.Message.Media.Document.ID != media.Document.ID {
t.Fatalf("recipient update-event message lost media: %+v", ev.Message.Media)
}
}
}
if !found {
t.Fatal("no new_message event for recipient")
}
// history 读取也带 media。
list, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{Limit: 10})
if err != nil {
t.Fatalf("list by user: %v", err)
}
if len(list.Messages) == 0 || list.Messages[0].Media == nil || list.Messages[0].Media.Document == nil {
t.Fatalf("history message lost media: %+v", list.Messages)
}
}
func TestSendChannelMediaSurvivesDifference(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 63, Phone: "+1998" + suffix + "03", FirstName: "MediaChannelOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
member, err := users.Create(ctx, domain.User{AccessHash: 64, Phone: "+1998" + suffix + "04", FirstName: "MediaChannelMember"})
if err != nil {
t.Fatalf("create member: %v", err)
}
var channelID int64
t.Cleanup(func() {
if channelID != 0 {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
}
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID})
})
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Media Difference " + suffix,
Megagroup: true,
MemberUserIDs: []int64{member.ID},
Date: int(time.Now().Unix()),
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID = created.Channel.ID
media := &domain.MessageMedia{
Kind: domain.MessageMediaKindDocument,
Document: &domain.Document{
ID: 9200000000000000002,
AccessHash: 10,
DCID: 2,
MimeType: "application/octet-stream",
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "telesrv-media.bin"}},
},
}
sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID,
ChannelID: channelID,
RandomID: time.Now().UnixNano(),
Message: "",
Media: media,
Date: int(time.Now().Unix()),
})
if err != nil {
t.Fatalf("send channel media: %v", err)
}
if sent.Message.Media == nil || sent.Message.Media.Document == nil || sent.Message.Media.Document.ID != media.Document.ID {
t.Fatalf("send result lost media: %+v", sent.Message.Media)
}
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: member.ID,
ChannelID: channelID,
Pts: created.Event.Pts,
Limit: 10,
})
if err != nil {
t.Fatalf("list channel difference: %v", err)
}
var found bool
for _, msg := range diff.NewMessages {
if msg.ID == sent.Message.ID {
found = true
if msg.Media == nil || msg.Media.Document == nil || msg.Media.Document.ID != media.Document.ID {
t.Fatalf("channel difference message lost media: %+v", msg.Media)
}
}
}
if !found {
t.Fatalf("sent media message %d not found in channel difference: %+v", sent.Message.ID, diff.NewMessages)
}
}

View file

@ -0,0 +1,160 @@
package postgres
import (
"context"
"strings"
"testing"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
func TestMessagePartitionSeekPlansUseIndexes(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{
AccessHash: 51,
Phone: "+1999" + suffix + "01",
FirstName: "PlanSender",
})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{
AccessHash: 52,
Phone: "+1999" + suffix + "02",
FirstName: "PlanRecipient",
})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
messages := NewMessageStore(pool)
for i := 0; i < 3; i++ {
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: int64(9000 + i),
Message: "plan check",
Date: 1700000300 + i,
}); err != nil {
t.Fatalf("seed message %d: %v", i, err)
}
}
if _, err := pool.Exec(ctx, `
UPDATE dispatch_outbox
SET status = 'dispatching',
updated_at = now() - interval '1 minute'
WHERE target_user_id = $1
`, recipient.ID); err != nil {
t.Fatalf("mark dispatch stale: %v", err)
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin explain tx: %v", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, "SET LOCAL enable_seqscan = off"); err != nil {
t.Fatalf("disable seqscan: %v", err)
}
historyPlan := explainText(t, ctx, tx, `
SELECT box_id
FROM message_boxes
WHERE owner_user_id = $1
AND peer_type = 'user'
AND peer_id = $2
AND NOT deleted
AND box_id < $3
ORDER BY box_id DESC
LIMIT 20
`, recipient.ID, sender.ID, 100000)
requirePlanUsesPartitionIndex(t, historyPlan, "message_boxes")
requirePlanNotContains(t, historyPlan, "Append")
updatesPlan := explainText(t, ctx, tx, `
SELECT pts
FROM user_update_events
WHERE user_id = $1
AND pts > $2
ORDER BY pts ASC
LIMIT 100
`, recipient.ID, 0)
requirePlanUsesPartitionIndex(t, updatesPlan, "user_update_events")
requirePlanNotContains(t, updatesPlan, "Append")
dispatchPlan := explainText(t, ctx, tx, `
WITH picked AS (
SELECT target_user_id, id
FROM dispatch_outbox
WHERE (
status = 'pending'
AND next_attempt_at <= now()
)
OR (
status = 'dispatching'
AND updated_at < now() - interval '30 seconds'
)
ORDER BY next_attempt_at ASC, target_user_id ASC, id ASC
LIMIT 100
FOR UPDATE SKIP LOCKED
)
SELECT target_user_id, id
FROM picked
`)
requirePlanContains(t, dispatchPlan, "dispatch_outbox_p")
requirePlanContains(t, dispatchPlan, "Index")
requirePlanNotContains(t, dispatchPlan, "Seq Scan")
}
func explainText(t *testing.T, ctx context.Context, tx pgx.Tx, query string, args ...any) string {
t.Helper()
rows, err := tx.Query(ctx, "EXPLAIN (COSTS OFF) "+query, args...)
if err != nil {
t.Fatalf("explain query: %v", err)
}
defer rows.Close()
var b strings.Builder
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
t.Fatalf("scan explain row: %v", err)
}
b.WriteString(line)
b.WriteByte('\n')
}
if err := rows.Err(); err != nil {
t.Fatalf("read explain rows: %v", err)
}
return b.String()
}
func requirePlanUsesPartitionIndex(t *testing.T, plan, table string) {
t.Helper()
requirePlanContains(t, plan, table+"_p")
requirePlanContains(t, plan, "Index")
requirePlanNotContains(t, plan, "Seq Scan")
}
func requirePlanContains(t *testing.T, plan string, needle string) {
t.Helper()
if !strings.Contains(plan, needle) {
t.Fatalf("plan missing %q:\n%s", needle, plan)
}
}
func requirePlanNotContains(t *testing.T, plan string, needle string) {
t.Helper()
if strings.Contains(plan, needle) {
t.Fatalf("plan contains %q:\n%s", needle, plan)
}
}

View file

@ -0,0 +1,128 @@
// Package postgres 用 PostgreSQL 实现持久化存储接口第一阶段AuthKeyStore
//
// 查询代码由 sqlc 生成于 ./sqlcgen见 telesrv/sqlc.yaml本包在其上实现 store 接口。
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/pgx/v5" // 注册 pgx5:// migrate driver
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/deploy"
)
const defaultMinConns = 16
// PoolOption 调整 pgxpool 连接池配置。
type PoolOption func(*pgxpool.Config)
// WithMaxConns 设置连接池最大连接数;<=0 时保持 pgx 默认。
// 同时把 MinConns 预热到 min(maxConns, 16),降低 TDesktop 启动风暴下的冷连接尾延迟突刺。
func WithMaxConns(n int) PoolOption {
return func(cfg *pgxpool.Config) {
if n <= 0 {
return
}
cfg.MaxConns = int32(n)
minConns := int32(defaultMinConns)
if int32(n) < minConns {
minConns = int32(n)
}
cfg.MinConns = minConns
}
}
// WithMinConns 设置启动时预热的最小连接数;<=0 保持既有配置。
func WithMinConns(n int) PoolOption {
return func(cfg *pgxpool.Config) {
if n <= 0 {
return
}
minConns := int32(n)
if cfg.MaxConns > 0 && minConns > cfg.MaxConns {
minConns = cfg.MaxConns
}
cfg.MinConns = minConns
}
}
// Open 建立 pgxpool 连接池并 ping 验证。
func Open(ctx context.Context, dsn string, opts ...PoolOption) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("pgxpool parse config: %w", err)
}
for _, opt := range opts {
if opt != nil {
opt(cfg)
}
}
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("pgxpool new: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("pg ping: %w", err)
}
if err := warmMinConns(ctx, pool); err != nil {
pool.Close()
return nil, err
}
return pool, nil
}
func warmMinConns(ctx context.Context, pool *pgxpool.Pool) error {
target := pool.Config().MinConns
if target <= 0 {
return nil
}
conns := make([]*pgxpool.Conn, 0, target)
defer func() {
for _, conn := range conns {
conn.Release()
}
}()
for int32(len(conns)) < target {
conn, err := pool.Acquire(ctx)
if err != nil {
return fmt.Errorf("prewarm pg connection %d/%d: %w", len(conns)+1, target, err)
}
conns = append(conns, conn)
}
return nil
}
// Migrate 用嵌入的迁移脚本将数据库迁移到最新版本。幂等:已最新时返回 nil。
func Migrate(dsn string) error {
src, err := iofs.New(deploy.Migrations, "migrations")
if err != nil {
return fmt.Errorf("iofs source: %w", err)
}
m, err := migrate.NewWithSourceInstance("iofs", src, toPgx5DSN(dsn))
if err != nil {
return fmt.Errorf("migrate new: %w", err)
}
defer m.Close()
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("migrate up: %w", err)
}
return nil
}
// toPgx5DSN 把 pgxpool 用的 postgres:// DSN 转成 golang-migrate pgx5 driver 所需的 pgx5:// scheme。
func toPgx5DSN(dsn string) string {
if s, ok := strings.CutPrefix(dsn, "postgres://"); ok {
return "pgx5://" + s
}
if s, ok := strings.CutPrefix(dsn, "postgresql://"); ok {
return "pgx5://" + s
}
return dsn
}

View file

@ -0,0 +1,22 @@
-- name: GetPasswordByUser :one
SELECT
user_id, has_recovery, has_secure_values, has_password, hint,
email_unconfirmed_pattern, login_email_pattern, secure_random
FROM account_passwords
WHERE user_id = $1;
-- name: UpsertPassword :exec
INSERT INTO account_passwords (
user_id, has_recovery, has_secure_values, has_password, hint,
email_unconfirmed_pattern, login_email_pattern, secure_random
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (user_id) DO UPDATE SET
has_recovery = EXCLUDED.has_recovery,
has_secure_values = EXCLUDED.has_secure_values,
has_password = EXCLUDED.has_password,
hint = EXCLUDED.hint,
email_unconfirmed_pattern = EXCLUDED.email_unconfirmed_pattern,
login_email_pattern = EXCLUDED.login_email_pattern,
secure_random = EXCLUDED.secure_random,
updated_at = now();

View file

@ -0,0 +1,10 @@
-- name: GetAuthKey :one
SELECT auth_key_id, body, server_salt, created_at
FROM auth_keys
WHERE auth_key_id = $1;
-- name: UpsertAuthKey :exec
INSERT INTO auth_keys (auth_key_id, body, server_salt)
VALUES ($1, $2, $3)
ON CONFLICT (auth_key_id) DO UPDATE
SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt;

View file

@ -0,0 +1,24 @@
-- name: UpsertAuthorization :exec
INSERT INTO authorizations (auth_key_id, user_id, layer, device_model, platform, system_version, api_id, app_version, ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (auth_key_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
layer = EXCLUDED.layer,
device_model = EXCLUDED.device_model,
platform = EXCLUDED.platform,
system_version = EXCLUDED.system_version,
api_id = EXCLUDED.api_id,
app_version = EXCLUDED.app_version,
ip = EXCLUDED.ip,
active_at = now();
-- name: GetAuthorizationByAuthKey :one
SELECT * FROM authorizations WHERE auth_key_id = $1;
-- name: ListAuthorizationsByUser :many
SELECT * FROM authorizations
WHERE user_id = $1
ORDER BY active_at DESC, auth_key_id DESC;
-- name: DeleteAuthorization :exec
DELETE FROM authorizations WHERE auth_key_id = $1;

View file

@ -0,0 +1,168 @@
-- name: ListContactsByUser :many
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
WHERE c.user_id = $1
ORDER BY c.contact_first_name, c.contact_last_name, u.first_name, u.last_name, u.id;
-- name: GetContact :one
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
WHERE c.user_id = $1
AND c.contact_user_id = $2;
-- name: UpsertContact :one
WITH reverse AS (
SELECT EXISTS (
SELECT 1
FROM contacts
WHERE user_id = sqlc.arg(contact_user_id)::bigint
AND contact_user_id = sqlc.arg(user_id)::bigint
)::boolean AS mutual
),
upserted AS (
INSERT INTO contacts (
user_id,
contact_user_id,
contact_phone,
contact_first_name,
contact_last_name,
note,
note_entities,
mutual
)
SELECT
sqlc.arg(user_id)::bigint,
sqlc.arg(contact_user_id)::bigint,
sqlc.arg(contact_phone)::text,
sqlc.arg(contact_first_name)::text,
sqlc.arg(contact_last_name)::text,
sqlc.arg(note)::text,
sqlc.arg(note_entities)::jsonb,
reverse.mutual
FROM reverse
ON CONFLICT (user_id, contact_user_id) DO UPDATE SET
contact_phone = EXCLUDED.contact_phone,
contact_first_name = EXCLUDED.contact_first_name,
contact_last_name = EXCLUDED.contact_last_name,
note = EXCLUDED.note,
note_entities = EXCLUDED.note_entities,
mutual = contacts.mutual OR EXCLUDED.mutual,
updated_at = now()
RETURNING *
),
reverse_updated AS (
UPDATE contacts c
SET mutual = true,
updated_at = now()
WHERE c.user_id = sqlc.arg(contact_user_id)::bigint
AND c.contact_user_id = sqlc.arg(user_id)::bigint
AND NOT c.mutual
RETURNING c.user_id
)
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at,
EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed
FROM upserted c
JOIN users u ON u.id = c.contact_user_id;
-- name: UpdateContactNote :one
WITH updated AS (
UPDATE contacts c
SET note = sqlc.arg(note)::text,
note_entities = sqlc.arg(note_entities)::jsonb,
updated_at = now()
WHERE c.user_id = sqlc.arg(user_id)::bigint
AND c.contact_user_id = sqlc.arg(contact_user_id)::bigint
RETURNING *
)
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at
FROM updated c
JOIN users u ON u.id = c.contact_user_id;
-- name: DeleteContacts :one
WITH deleted AS (
DELETE FROM contacts
WHERE user_id = sqlc.arg(user_id)::bigint
AND contact_user_id = ANY(sqlc.arg(contact_user_ids)::bigint[])
RETURNING contact_user_id
),
reverse_updated AS (
UPDATE contacts c
SET mutual = false,
updated_at = now()
FROM deleted d
WHERE c.user_id = d.contact_user_id
AND c.contact_user_id = sqlc.arg(user_id)::bigint
RETURNING c.user_id
)
SELECT COUNT(*)::int AS deleted_count
FROM deleted;

View file

@ -0,0 +1,773 @@
-- name: ListDialogsByUser :many
WITH base AS (
SELECT
d.user_id,
d.peer_type,
d.peer_id,
d.folder_id,
d.top_message_id,
d.top_message_date,
d.read_inbox_max_id,
d.read_outbox_max_id,
d.unread_count,
d.unread_mentions_count,
d.unread_reactions_count,
d.pinned,
d.pinned_order,
d.unread_mark,
d.hidden_peer_settings_bar,
COALESCE(u.id, 0)::bigint AS peer_user_id,
COALESCE(u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone, '')::text AS peer_phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name, '')::text AS peer_first_name,
COALESCE(c.contact_last_name, u.last_name, '')::text AS peer_last_name,
COALESCE(u.username, '')::text AS peer_username,
COALESCE(u.country_code, '')::text AS peer_country_code,
COALESCE(u.verified, false)::boolean AS peer_verified,
COALESCE(u.support, false)::boolean AS peer_support,
COALESCE(u.last_seen_at, 0)::bigint AS peer_last_seen_at,
(c.contact_user_id IS NOT NULL)::boolean AS peer_contact,
COALESCE(c.mutual, false)::boolean AS peer_mutual,
COALESCE(m.box_id, 0)::int AS message_id,
COALESCE(m.from_user_id, 0)::bigint AS message_from_user_id,
COALESCE(m.message_date, 0)::int AS message_date,
COALESCE(m.outgoing, false)::boolean AS message_outgoing,
COALESCE(m.body, '')::text AS message_body,
COALESCE(m.entities::text, '[]')::text AS message_entities_json
FROM dialogs d
LEFT JOIN users u ON d.peer_type = 'user' AND u.id = d.peer_id
LEFT JOIN contacts c ON d.peer_type = 'user' AND c.user_id = d.user_id AND c.contact_user_id = d.peer_id
LEFT JOIN message_boxes m ON m.owner_user_id = d.user_id AND m.box_id = d.top_message_id AND NOT m.deleted
WHERE d.user_id = $1
AND (
NOT sqlc.arg(has_folder_id)::boolean
OR (
sqlc.arg(folder_id)::int < 2
AND d.folder_id = sqlc.arg(folder_id)::int
)
OR (
sqlc.arg(folder_id)::int >= 2
AND NOT (sqlc.arg(folder_exclude_archived)::boolean AND d.folder_id = 1)
AND NOT (sqlc.arg(folder_exclude_read)::boolean AND d.unread_count = 0 AND NOT d.unread_mark)
AND NOT EXISTS (
SELECT 1
FROM (
SELECT fpt.peer_type, fpi.peer_id
FROM unnest(sqlc.arg(folder_exclude_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord)
JOIN unnest(sqlc.arg(folder_exclude_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord)
) fp
WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id
)
AND (
EXISTS (
SELECT 1
FROM (
SELECT fpt.peer_type, fpi.peer_id
FROM unnest(sqlc.arg(folder_include_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord)
JOIN unnest(sqlc.arg(folder_include_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord)
) fp
WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id
)
OR EXISTS (
SELECT 1
FROM (
SELECT fpt.peer_type, fpi.peer_id
FROM unnest(sqlc.arg(folder_pinned_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord)
JOIN unnest(sqlc.arg(folder_pinned_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord)
) fp
WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id
)
OR (sqlc.arg(folder_contacts)::boolean AND c.contact_user_id IS NOT NULL)
OR (sqlc.arg(folder_non_contacts)::boolean AND c.contact_user_id IS NULL)
)
)
)
AND (NOT sqlc.arg(pinned_only)::boolean OR d.pinned)
AND (NOT sqlc.arg(exclude_pinned)::boolean OR NOT d.pinned)
),
paged AS (
SELECT *
FROM base
WHERE (
(sqlc.arg(offset_date)::int <= 0 AND sqlc.arg(offset_id)::int <= 0)
OR (
sqlc.arg(offset_date)::int > 0
AND (
top_message_date < sqlc.arg(offset_date)::int
OR (
top_message_date = sqlc.arg(offset_date)::int
AND (
sqlc.arg(offset_id)::int <= 0
OR top_message_id < sqlc.arg(offset_id)::int
OR (
top_message_id = sqlc.arg(offset_id)::int
AND sqlc.arg(has_offset_peer)::boolean
AND peer_id < sqlc.arg(offset_peer_id)::bigint
)
)
)
)
)
OR (
sqlc.arg(offset_date)::int <= 0
AND sqlc.arg(offset_id)::int > 0
AND (
top_message_id < sqlc.arg(offset_id)::int
OR (
top_message_id = sqlc.arg(offset_id)::int
AND sqlc.arg(has_offset_peer)::boolean
AND peer_id < sqlc.arg(offset_peer_id)::bigint
)
)
)
)
)
SELECT
user_id,
peer_type::text AS peer_type,
peer_id::bigint AS peer_id,
folder_id,
top_message_id,
top_message_date,
read_inbox_max_id,
read_outbox_max_id,
unread_count,
unread_mentions_count,
unread_reactions_count,
pinned,
pinned_order,
unread_mark,
hidden_peer_settings_bar,
peer_user_id,
peer_access_hash,
peer_phone,
peer_first_name,
peer_last_name,
peer_username,
peer_country_code,
peer_verified,
peer_support,
peer_last_seen_at,
peer_contact,
peer_mutual,
message_id,
message_from_user_id,
message_date,
message_outgoing,
message_body,
message_entities_json
FROM paged
ORDER BY
pinned DESC,
CASE WHEN pinned THEN COALESCE(NULLIF(pinned_order, 0), 2147483647) ELSE 2147483647 END ASC,
top_message_date DESC,
top_message_id DESC,
peer_id DESC
LIMIT sqlc.arg(limit_count);
-- name: ListDialogSummaryByUser :many
SELECT
d.peer_type,
d.peer_id,
d.folder_id,
d.top_message_id,
d.top_message_date,
d.read_inbox_max_id,
d.read_outbox_max_id,
d.unread_count,
d.unread_mentions_count,
d.unread_reactions_count,
d.pinned,
d.pinned_order,
d.unread_mark,
d.hidden_peer_settings_bar
FROM dialogs d
LEFT JOIN contacts c ON d.peer_type = 'user' AND c.user_id = d.user_id AND c.contact_user_id = d.peer_id
WHERE d.user_id = $1
AND (
NOT sqlc.arg(has_folder_id)::boolean
OR (
sqlc.arg(folder_id)::int < 2
AND d.folder_id = sqlc.arg(folder_id)::int
)
OR (
sqlc.arg(folder_id)::int >= 2
AND NOT (sqlc.arg(folder_exclude_archived)::boolean AND d.folder_id = 1)
AND NOT (sqlc.arg(folder_exclude_read)::boolean AND d.unread_count = 0 AND NOT d.unread_mark)
AND NOT EXISTS (
SELECT 1
FROM (
SELECT fpt.peer_type, fpi.peer_id
FROM unnest(sqlc.arg(folder_exclude_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord)
JOIN unnest(sqlc.arg(folder_exclude_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord)
) fp
WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id
)
AND (
EXISTS (
SELECT 1
FROM (
SELECT fpt.peer_type, fpi.peer_id
FROM unnest(sqlc.arg(folder_include_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord)
JOIN unnest(sqlc.arg(folder_include_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord)
) fp
WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id
)
OR EXISTS (
SELECT 1
FROM (
SELECT fpt.peer_type, fpi.peer_id
FROM unnest(sqlc.arg(folder_pinned_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord)
JOIN unnest(sqlc.arg(folder_pinned_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord)
) fp
WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id
)
OR (sqlc.arg(folder_contacts)::boolean AND c.contact_user_id IS NOT NULL)
OR (sqlc.arg(folder_non_contacts)::boolean AND c.contact_user_id IS NULL)
)
)
)
AND (NOT sqlc.arg(pinned_only)::boolean OR d.pinned)
AND (NOT sqlc.arg(exclude_pinned)::boolean OR NOT d.pinned)
ORDER BY
d.pinned DESC,
CASE WHEN d.pinned THEN COALESCE(NULLIF(d.pinned_order, 0), 2147483647) ELSE 2147483647 END ASC,
d.top_message_date DESC,
d.top_message_id DESC,
d.peer_id DESC;
-- name: ListDialogsByPeers :many
WITH requested AS (
SELECT
(sqlc.arg(peer_types)::text[])[i] AS peer_type,
(sqlc.arg(peer_ids)::bigint[])[i] AS peer_id,
i::int AS ord
FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i)
WHERE i <= cardinality(sqlc.arg(peer_types)::text[])
),
deduped AS (
SELECT DISTINCT ON (peer_type, peer_id)
peer_type,
peer_id,
ord
FROM requested
ORDER BY peer_type, peer_id, ord
),
base AS (
SELECT
sqlc.arg(user_id)::bigint AS user_id,
r.peer_type,
r.peer_id,
COALESCE(d.folder_id, 0)::int AS folder_id,
COALESCE(d.top_message_id, 0)::int AS top_message_id,
COALESCE(d.top_message_date, 0)::int AS top_message_date,
COALESCE(d.read_inbox_max_id, 0)::int AS read_inbox_max_id,
COALESCE(d.read_outbox_max_id, 0)::int AS read_outbox_max_id,
COALESCE(d.unread_count, 0)::int AS unread_count,
COALESCE(d.unread_mentions_count, 0)::int AS unread_mentions_count,
COALESCE(d.unread_reactions_count, 0)::int AS unread_reactions_count,
COALESCE(d.pinned, false)::boolean AS pinned,
COALESCE(d.pinned_order, 0)::int AS pinned_order,
COALESCE(d.unread_mark, false)::boolean AS unread_mark,
COALESCE(d.hidden_peer_settings_bar, false)::boolean AS hidden_peer_settings_bar,
COALESCE(u.id, 0)::bigint AS peer_user_id,
COALESCE(u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone, '')::text AS peer_phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name, '')::text AS peer_first_name,
COALESCE(c.contact_last_name, u.last_name, '')::text AS peer_last_name,
COALESCE(u.username, '')::text AS peer_username,
COALESCE(u.country_code, '')::text AS peer_country_code,
COALESCE(u.verified, false)::boolean AS peer_verified,
COALESCE(u.support, false)::boolean AS peer_support,
COALESCE(u.last_seen_at, 0)::bigint AS peer_last_seen_at,
(c.contact_user_id IS NOT NULL)::boolean AS peer_contact,
COALESCE(c.mutual, false)::boolean AS peer_mutual,
COALESCE(m.box_id, 0)::int AS message_id,
COALESCE(m.from_user_id, 0)::bigint AS message_from_user_id,
COALESCE(m.message_date, 0)::int AS message_date,
COALESCE(m.outgoing, false)::boolean AS message_outgoing,
COALESCE(m.body, '')::text AS message_body,
COALESCE(m.entities::text, '[]')::text AS message_entities_json,
r.ord
FROM deduped r
LEFT JOIN dialogs d
ON d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = r.peer_type
AND d.peer_id = r.peer_id
LEFT JOIN users u ON r.peer_type = 'user' AND u.id = r.peer_id
LEFT JOIN contacts c ON r.peer_type = 'user' AND c.user_id = sqlc.arg(user_id)::bigint AND c.contact_user_id = r.peer_id
LEFT JOIN message_boxes m ON m.owner_user_id = sqlc.arg(user_id)::bigint AND m.box_id = d.top_message_id AND NOT m.deleted
)
SELECT
user_id,
peer_type::text AS peer_type,
peer_id::bigint AS peer_id,
folder_id,
top_message_id,
top_message_date,
read_inbox_max_id,
read_outbox_max_id,
unread_count,
unread_mentions_count,
unread_reactions_count,
pinned,
pinned_order,
unread_mark,
hidden_peer_settings_bar,
peer_user_id,
peer_access_hash,
peer_phone,
peer_first_name,
peer_last_name,
peer_username,
peer_country_code,
peer_verified,
peer_support,
peer_last_seen_at,
peer_contact,
peer_mutual,
message_id,
message_from_user_id,
message_date,
message_outgoing,
message_body,
message_entities_json
FROM base
ORDER BY ord;
-- name: UpsertDialog :exec
INSERT INTO dialogs (
user_id,
peer_type,
peer_id,
top_message_id,
top_message_date,
read_inbox_max_id,
read_outbox_max_id,
unread_count,
unread_mentions_count,
unread_reactions_count,
pinned,
unread_mark
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12
)
ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET
top_message_id = EXCLUDED.top_message_id,
top_message_date = EXCLUDED.top_message_date,
read_inbox_max_id = EXCLUDED.read_inbox_max_id,
read_outbox_max_id = EXCLUDED.read_outbox_max_id,
unread_count = EXCLUDED.unread_count,
unread_mentions_count = EXCLUDED.unread_mentions_count,
unread_reactions_count = EXCLUDED.unread_reactions_count,
pinned = EXCLUDED.pinned,
unread_mark = EXCLUDED.unread_mark,
updated_at = now();
-- name: UpsertOutboxDialog :exec
INSERT INTO dialogs (
user_id,
peer_type,
peer_id,
top_message_id,
top_message_date,
unread_count
) VALUES (
$1, $2, $3, $4, $5, 0
)
ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET
top_message_id = EXCLUDED.top_message_id,
top_message_date = EXCLUDED.top_message_date,
updated_at = now();
-- name: UpsertInboxDialog :exec
INSERT INTO dialogs (
user_id,
peer_type,
peer_id,
top_message_id,
top_message_date,
unread_count
) VALUES (
$1, $2, $3, $4, $5, 1
)
ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET
top_message_id = EXCLUDED.top_message_id,
top_message_date = EXCLUDED.top_message_date,
unread_count = dialogs.unread_count + 1,
updated_at = now();
-- name: MarkDialogRead :one
WITH target AS (
SELECT
d.user_id,
d.peer_type,
d.peer_id,
d.top_message_id,
d.read_inbox_max_id,
d.unread_count
FROM dialogs d
WHERE d.user_id = $1
AND d.peer_type = $2
AND d.peer_id = $3
),
updated AS (
UPDATE dialogs d
SET
read_inbox_max_id = GREATEST(
d.read_inbox_max_id,
CASE WHEN sqlc.arg(max_id)::int > 0 THEN sqlc.arg(max_id)::int ELSE d.top_message_id END
),
unread_count = 0,
unread_mark = false,
unread_mentions_count = 0,
unread_reactions_count = 0,
updated_at = now()
FROM target
WHERE d.user_id = target.user_id
AND d.peer_type = target.peer_type
AND d.peer_id = target.peer_id
RETURNING
d.user_id,
d.peer_type,
d.peer_id,
d.read_inbox_max_id,
d.unread_count,
(
target.unread_count > 0
OR (
CASE WHEN sqlc.arg(max_id)::int > 0 THEN sqlc.arg(max_id)::int ELSE target.top_message_id END
) > target.read_inbox_max_id
)::boolean AS changed
)
SELECT
user_id,
peer_type,
peer_id,
read_inbox_max_id,
unread_count,
changed
FROM updated;
-- name: SetDialogPinned :one
WITH next_order AS (
SELECT COALESCE(MAX(pinned_order), 0)::int + 1 AS value
FROM dialogs
WHERE user_id = sqlc.arg(user_id)::bigint
AND pinned
),
updated AS (
UPDATE dialogs d
SET pinned = sqlc.arg(pinned)::boolean,
pinned_order = CASE
WHEN sqlc.arg(pinned)::boolean THEN
CASE WHEN d.pinned_order > 0 THEN d.pinned_order ELSE next_order.value END
ELSE 0
END,
updated_at = now()
FROM next_order
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = sqlc.arg(peer_type)::text
AND d.peer_id = sqlc.arg(peer_id)::bigint
RETURNING d.user_id
)
SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed;
-- name: SetDialogUnreadMark :one
WITH updated AS (
UPDATE dialogs d
SET unread_mark = sqlc.arg(unread)::boolean,
updated_at = now()
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = sqlc.arg(peer_type)::text
AND d.peer_id = sqlc.arg(peer_id)::bigint
RETURNING d.user_id
)
SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed;
-- name: ListDialogUnreadMarks :many
SELECT
peer_type,
peer_id
FROM dialogs
WHERE user_id = $1
AND unread_mark
ORDER BY top_message_date DESC, top_message_id DESC, peer_id DESC;
-- name: SetPeerSettingsBarHidden :one
WITH updated AS (
UPDATE dialogs d
SET hidden_peer_settings_bar = true,
updated_at = now()
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = sqlc.arg(peer_type)::text
AND d.peer_id = sqlc.arg(peer_id)::bigint
RETURNING d.user_id
)
SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed;
-- name: GetPeerSettingsBarHidden :one
SELECT hidden_peer_settings_bar
FROM dialogs
WHERE user_id = $1
AND peer_type = $2
AND peer_id = $3;
-- name: ReorderPinnedDialogs :exec
WITH requested AS (
SELECT
(sqlc.arg(peer_types)::text[])[i] AS peer_type,
(sqlc.arg(peer_ids)::bigint[])[i] AS peer_id,
i::int AS pos
FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i)
WHERE i <= cardinality(sqlc.arg(peer_types)::text[])
),
deduped AS (
SELECT DISTINCT ON (peer_type, peer_id)
peer_type,
peer_id,
pos::int AS ord
FROM requested
ORDER BY peer_type, peer_id, pos
)
UPDATE dialogs d
SET pinned = true,
pinned_order = deduped.ord,
updated_at = now()
FROM deduped
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = deduped.peer_type
AND d.peer_id = deduped.peer_id;
-- name: EditDialogPeerFolders :exec
WITH requested AS (
SELECT
(sqlc.arg(peer_types)::text[])[i] AS peer_type,
(sqlc.arg(peer_ids)::bigint[])[i] AS peer_id,
(sqlc.arg(folder_ids)::int[])[i] AS folder_id
FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i)
WHERE i <= cardinality(sqlc.arg(peer_types)::text[])
AND i <= cardinality(sqlc.arg(folder_ids)::int[])
),
deduped AS (
SELECT DISTINCT ON (peer_type, peer_id)
peer_type,
peer_id,
folder_id
FROM requested
WHERE folder_id IN (0, 1)
ORDER BY peer_type, peer_id
)
UPDATE dialogs d
SET folder_id = deduped.folder_id,
updated_at = now()
FROM deduped
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = deduped.peer_type
AND d.peer_id = deduped.peer_id;
-- name: ClearPinnedDialogsNotInOrder :exec
WITH requested AS (
SELECT
(sqlc.arg(peer_types)::text[])[i] AS peer_type,
(sqlc.arg(peer_ids)::bigint[])[i] AS peer_id
FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i)
WHERE i <= cardinality(sqlc.arg(peer_types)::text[])
)
UPDATE dialogs d
SET pinned = false,
pinned_order = 0,
updated_at = now()
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.pinned
AND NOT EXISTS (
SELECT 1
FROM requested r
WHERE r.peer_type = d.peer_type
AND r.peer_id = d.peer_id
);
-- name: RefreshDialogAfterMessageDelete :exec
UPDATE dialogs d
SET
top_message_id = sqlc.arg(top_message_id)::int,
top_message_date = sqlc.arg(top_message_date)::int,
unread_count = (
SELECT COUNT(*)::int
FROM message_boxes m
WHERE m.owner_user_id = d.user_id
AND m.peer_type = d.peer_type
AND m.peer_id = d.peer_id
AND NOT m.deleted
AND NOT m.outgoing
AND m.box_id > d.read_inbox_max_id
),
unread_mentions_count = 0,
unread_reactions_count = 0,
updated_at = now()
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = sqlc.arg(peer_type)::text
AND d.peer_id = sqlc.arg(peer_id)::bigint;
-- name: ClearDialogAfterHistoryDelete :exec
UPDATE dialogs d
SET
top_message_id = 0,
top_message_date = 0,
read_inbox_max_id = GREATEST(d.read_inbox_max_id, d.top_message_id),
read_outbox_max_id = GREATEST(d.read_outbox_max_id, d.top_message_id),
unread_count = 0,
unread_mark = false,
unread_mentions_count = 0,
unread_reactions_count = 0,
updated_at = now()
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = sqlc.arg(peer_type)::text
AND d.peer_id = sqlc.arg(peer_id)::bigint;
-- name: DeleteDialogByPeer :exec
DELETE FROM dialogs
WHERE user_id = $1
AND peer_type = $2
AND peer_id = $3;
-- name: ListDialogFolders :many
SELECT
filter_id,
is_chatlist,
filter::text AS filter_json
FROM dialog_filters
WHERE user_id = $1
ORDER BY order_value ASC, filter_id ASC;
-- name: GetDialogFolder :one
SELECT
filter_id,
is_chatlist,
filter::text AS filter_json
FROM dialog_filters
WHERE user_id = $1
AND filter_id = $2;
-- name: UpsertDialogFolder :exec
INSERT INTO dialog_filters (
user_id,
filter_id,
is_chatlist,
filter,
order_value
) VALUES (
$1,
$2,
$3,
sqlc.arg(filter_json)::jsonb,
COALESCE(
(SELECT order_value FROM dialog_filters WHERE user_id = $1 AND filter_id = $2),
(SELECT COALESCE(MAX(order_value), 0) + 1 FROM dialog_filters WHERE user_id = $1)
)
)
ON CONFLICT (user_id, filter_id) DO UPDATE SET
is_chatlist = EXCLUDED.is_chatlist,
filter = EXCLUDED.filter,
updated_at = now();
-- name: DeleteDialogFolder :exec
DELETE FROM dialog_filters
WHERE user_id = $1
AND filter_id = $2;
-- name: ReorderDialogFolders :exec
WITH requested AS (
SELECT filter_id, ord::int AS order_value
FROM unnest(sqlc.arg(filter_ids)::int[]) WITH ORDINALITY AS t(filter_id, ord)
),
deduped AS (
SELECT DISTINCT ON (filter_id)
filter_id,
order_value
FROM requested
WHERE filter_id >= 2
ORDER BY filter_id, order_value
)
UPDATE dialog_filters f
SET order_value = deduped.order_value,
updated_at = now()
FROM deduped
WHERE f.user_id = sqlc.arg(user_id)::bigint
AND f.filter_id = deduped.filter_id;
-- name: GetDialogFolderTags :one
SELECT tags_enabled
FROM dialog_filter_settings
WHERE user_id = $1;
-- name: SetDialogFolderTags :exec
INSERT INTO dialog_filter_settings (
user_id,
tags_enabled
) VALUES (
$1,
$2
)
ON CONFLICT (user_id) DO UPDATE SET
tags_enabled = EXCLUDED.tags_enabled,
updated_at = now();
-- name: UpsertDialogDraft :exec
INSERT INTO dialog_drafts (
user_id,
peer_type,
peer_id,
top_message_id,
date,
draft
) VALUES (
$1,
$2,
$3,
$4,
$5,
sqlc.arg(draft_json)::jsonb
)
ON CONFLICT (user_id, peer_type, peer_id, top_message_id) DO UPDATE SET
date = EXCLUDED.date,
draft = EXCLUDED.draft,
updated_at = now();
-- name: DeleteDialogDraft :one
WITH deleted AS (
DELETE FROM dialog_drafts
WHERE user_id = $1
AND peer_type = $2
AND peer_id = $3
AND top_message_id = $4
RETURNING user_id
)
SELECT EXISTS (SELECT 1 FROM deleted)::boolean AS changed;
-- name: ListDialogDrafts :many
SELECT draft::text AS draft_json
FROM dialog_drafts
WHERE user_id = $1
ORDER BY date DESC, peer_type ASC, peer_id DESC, top_message_id DESC
LIMIT sqlc.arg(limit_count);
-- name: ClearDialogDrafts :many
WITH doomed AS (
SELECT d.user_id, d.peer_type, d.peer_id, d.top_message_id
FROM dialog_drafts d
WHERE d.user_id = $1
ORDER BY d.date DESC, d.peer_type ASC, d.peer_id DESC, d.top_message_id DESC
LIMIT sqlc.arg(limit_count)
),
deleted AS (
DELETE FROM dialog_drafts d
USING doomed
WHERE d.user_id = doomed.user_id
AND d.peer_type = doomed.peer_type
AND d.peer_id = doomed.peer_id
AND d.top_message_id = doomed.top_message_id
RETURNING d.draft::text AS draft_json
)
SELECT draft_json
FROM deleted;

View file

@ -0,0 +1,43 @@
-- name: GetAppConfig :one
SELECT client, hash, config_json::text AS config_json
FROM app_configs
WHERE client = $1;
-- name: UpsertAppConfig :exec
INSERT INTO app_configs (client, hash, config_json)
VALUES ($1, $2, sqlc.arg(config_json)::jsonb)
ON CONFLICT (client) DO UPDATE SET
hash = EXCLUDED.hash,
config_json = EXCLUDED.config_json,
updated_at = now();
-- name: ListCountries :many
SELECT
c.iso2,
c.default_name,
c.name,
c.hidden,
cc.country_code,
cc.prefixes,
cc.patterns
FROM countries c
JOIN country_codes cc ON cc.iso2 = c.iso2
ORDER BY c.order_index, c.iso2, cc.order_index, cc.country_code;
-- name: UpsertCountry :exec
INSERT INTO countries (iso2, default_name, name, hidden, order_index)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (iso2) DO UPDATE SET
default_name = EXCLUDED.default_name,
name = EXCLUDED.name,
hidden = EXCLUDED.hidden,
order_index = EXCLUDED.order_index,
updated_at = now();
-- name: UpsertCountryCode :exec
INSERT INTO country_codes (iso2, country_code, prefixes, patterns, order_index)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (iso2, country_code) DO UPDATE SET
prefixes = EXCLUDED.prefixes,
patterns = EXCLUDED.patterns,
order_index = EXCLUDED.order_index;

View file

@ -0,0 +1,47 @@
-- name: GetLangPackMeta :one
SELECT lang_pack, lang_code, version, strings_count
FROM lang_packs
WHERE lang_pack = $1 AND lang_code = $2;
-- name: UpsertLangPackMeta :exec
INSERT INTO lang_packs (lang_pack, lang_code, version, strings_count)
VALUES ($1, $2, $3, $4)
ON CONFLICT (lang_pack, lang_code) DO UPDATE SET
version = EXCLUDED.version,
strings_count = EXCLUDED.strings_count,
updated_at = now();
-- name: UpsertLangPackString :exec
INSERT INTO lang_pack_strings (
lang_pack, lang_code, key, version, pluralized, value,
zero_value, one_value, two_value, few_value, many_value, other_value, deleted
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (lang_pack, lang_code, key) DO UPDATE SET
version = EXCLUDED.version,
pluralized = EXCLUDED.pluralized,
value = EXCLUDED.value,
zero_value = EXCLUDED.zero_value,
one_value = EXCLUDED.one_value,
two_value = EXCLUDED.two_value,
few_value = EXCLUDED.few_value,
many_value = EXCLUDED.many_value,
other_value = EXCLUDED.other_value,
deleted = EXCLUDED.deleted,
updated_at = now();
-- name: ListLangPackStrings :many
SELECT
lang_pack, lang_code, key, version, pluralized, value,
zero_value, one_value, two_value, few_value, many_value, other_value, deleted
FROM lang_pack_strings
WHERE lang_pack = $1 AND lang_code = $2 AND NOT deleted
ORDER BY key;
-- name: GetLangPackStringsByKeys :many
SELECT
lang_pack, lang_code, key, version, pluralized, value,
zero_value, one_value, two_value, few_value, many_value, other_value, deleted
FROM lang_pack_strings
WHERE lang_pack = $1 AND lang_code = $2 AND key = ANY(sqlc.arg(keys)::text[]) AND NOT deleted
ORDER BY key;

View file

@ -0,0 +1,331 @@
-- upload_parts ----------------------------------------------------------------
-- name: SaveUploadPart :exec
INSERT INTO upload_parts (owner_user_id, file_id, part, total_parts, is_big, bytes)
VALUES (
sqlc.arg(owner_user_id)::bigint,
sqlc.arg(file_id)::bigint,
sqlc.arg(part)::int,
sqlc.arg(total_parts)::int,
sqlc.arg(is_big)::boolean,
sqlc.arg(bytes)::bytea
)
ON CONFLICT (owner_user_id, file_id, part) DO UPDATE SET
total_parts = EXCLUDED.total_parts,
is_big = EXCLUDED.is_big,
bytes = EXCLUDED.bytes;
-- name: ListUploadParts :many
SELECT part, total_parts, is_big, bytes
FROM upload_parts
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
AND file_id = sqlc.arg(file_id)::bigint
ORDER BY part ASC;
-- name: DeleteUploadParts :exec
DELETE FROM upload_parts
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
AND file_id = sqlc.arg(file_id)::bigint;
-- file_blobs ------------------------------------------------------------------
-- name: PutFileBlob :exec
INSERT INTO file_blobs (location_key, backend, object_key, size, sha256, mime_type)
VALUES (
sqlc.arg(location_key)::text,
sqlc.arg(backend)::text,
sqlc.arg(object_key)::text,
sqlc.arg(size)::bigint,
sqlc.arg(sha256)::bytea,
sqlc.arg(mime_type)::text
)
ON CONFLICT (location_key) DO UPDATE SET
backend = EXCLUDED.backend,
object_key = EXCLUDED.object_key,
size = EXCLUDED.size,
sha256 = EXCLUDED.sha256,
mime_type = EXCLUDED.mime_type;
-- name: GetFileBlob :one
SELECT location_key, backend, object_key, size, sha256, mime_type
FROM file_blobs
WHERE location_key = sqlc.arg(location_key)::text;
-- documents -------------------------------------------------------------------
-- name: PutDocument :exec
INSERT INTO documents (id, access_hash, file_reference, date, mime_type, size, dc_id, attributes, thumbs)
VALUES (
sqlc.arg(id)::bigint,
sqlc.arg(access_hash)::bigint,
sqlc.arg(file_reference)::bytea,
sqlc.arg(date)::int,
sqlc.arg(mime_type)::text,
sqlc.arg(size)::bigint,
sqlc.arg(dc_id)::int,
sqlc.arg(attributes_json)::jsonb,
sqlc.arg(thumbs_json)::jsonb
)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
file_reference = EXCLUDED.file_reference,
date = EXCLUDED.date,
mime_type = EXCLUDED.mime_type,
size = EXCLUDED.size,
dc_id = EXCLUDED.dc_id,
attributes = EXCLUDED.attributes,
thumbs = EXCLUDED.thumbs;
-- name: GetDocument :one
SELECT id, access_hash, file_reference, date, mime_type, size, dc_id,
attributes::text AS attributes_json,
thumbs::text AS thumbs_json
FROM documents
WHERE id = sqlc.arg(id)::bigint;
-- name: GetDocuments :many
SELECT id, access_hash, file_reference, date, mime_type, size, dc_id,
attributes::text AS attributes_json,
thumbs::text AS thumbs_json
FROM documents
WHERE id = ANY(sqlc.arg(ids)::bigint[]);
-- photos ----------------------------------------------------------------------
-- name: PutPhoto :exec
INSERT INTO photos (id, access_hash, file_reference, date, dc_id, has_stickers, sizes)
VALUES (
sqlc.arg(id)::bigint,
sqlc.arg(access_hash)::bigint,
sqlc.arg(file_reference)::bytea,
sqlc.arg(date)::int,
sqlc.arg(dc_id)::int,
sqlc.arg(has_stickers)::boolean,
sqlc.arg(sizes_json)::jsonb
)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
file_reference = EXCLUDED.file_reference,
date = EXCLUDED.date,
dc_id = EXCLUDED.dc_id,
has_stickers = EXCLUDED.has_stickers,
sizes = EXCLUDED.sizes;
-- name: GetPhoto :one
SELECT id, access_hash, file_reference, date, dc_id, has_stickers,
sizes::text AS sizes_json
FROM photos
WHERE id = sqlc.arg(id)::bigint;
-- sticker_sets ----------------------------------------------------------------
-- name: PutStickerSet :exec
INSERT INTO sticker_sets (
id, access_hash, short_name, title, count, hash, set_kind,
official, animated, videos, emojis, masks, installed, archived, installed_date,
thumb_document_id, thumbs, thumb_dc_id, thumb_version, document_ids, packs, sort_order, system_key
) VALUES (
sqlc.arg(id)::bigint,
sqlc.arg(access_hash)::bigint,
sqlc.arg(short_name)::text,
sqlc.arg(title)::text,
sqlc.arg(count)::int,
sqlc.arg(hash)::int,
sqlc.arg(set_kind)::text,
sqlc.arg(official)::boolean,
sqlc.arg(animated)::boolean,
sqlc.arg(videos)::boolean,
sqlc.arg(emojis)::boolean,
sqlc.arg(masks)::boolean,
sqlc.arg(installed)::boolean,
sqlc.arg(archived)::boolean,
sqlc.arg(installed_date)::int,
sqlc.arg(thumb_document_id)::bigint,
sqlc.arg(thumbs_json)::jsonb,
sqlc.arg(thumb_dc_id)::int,
sqlc.arg(thumb_version)::int,
sqlc.arg(document_ids_json)::jsonb,
sqlc.arg(packs_json)::jsonb,
sqlc.arg(sort_order)::int,
sqlc.arg(system_key)::text
)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
short_name = EXCLUDED.short_name,
title = EXCLUDED.title,
count = EXCLUDED.count,
hash = EXCLUDED.hash,
set_kind = EXCLUDED.set_kind,
official = EXCLUDED.official,
animated = EXCLUDED.animated,
videos = EXCLUDED.videos,
emojis = EXCLUDED.emojis,
masks = EXCLUDED.masks,
installed = EXCLUDED.installed,
archived = EXCLUDED.archived,
installed_date = EXCLUDED.installed_date,
thumb_document_id = EXCLUDED.thumb_document_id,
thumbs = EXCLUDED.thumbs,
thumb_dc_id = EXCLUDED.thumb_dc_id,
thumb_version = EXCLUDED.thumb_version,
document_ids = EXCLUDED.document_ids,
packs = EXCLUDED.packs,
sort_order = EXCLUDED.sort_order,
system_key = EXCLUDED.system_key;
-- name: GetStickerSetByID :one
SELECT
id, access_hash, short_name, title, count, hash, set_kind,
official, animated, videos, emojis, masks, installed, archived, installed_date,
thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version,
document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key
FROM sticker_sets
WHERE id = sqlc.arg(id)::bigint;
-- name: GetStickerSetByShortName :one
SELECT
id, access_hash, short_name, title, count, hash, set_kind,
official, animated, videos, emojis, masks, installed, archived, installed_date,
thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version,
document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key
FROM sticker_sets
WHERE short_name = sqlc.arg(short_name)::text;
-- name: GetStickerSetBySystemKey :one
SELECT
id, access_hash, short_name, title, count, hash, set_kind,
official, animated, videos, emojis, masks, installed, archived, installed_date,
thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version,
document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key
FROM sticker_sets
WHERE system_key = sqlc.arg(system_key)::text;
-- name: ListStickerSetsByKind :many
SELECT
id, access_hash, short_name, title, count, hash, set_kind,
official, animated, videos, emojis, masks, installed, archived, installed_date,
thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version,
document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key
FROM sticker_sets
WHERE set_kind = sqlc.arg(set_kind)::text
ORDER BY sort_order ASC, id ASC;
-- name: CountStickerSets :one
SELECT count(*)::int AS total FROM sticker_sets;
-- available_reactions ---------------------------------------------------------
-- name: PutAvailableReaction :exec
INSERT INTO available_reactions (
reaction, title, inactive, premium,
static_icon_id, appear_animation_id, select_animation_id,
activate_animation_id, effect_animation_id, around_animation_id, center_icon_id, sort_order
) VALUES (
sqlc.arg(reaction)::text,
sqlc.arg(title)::text,
sqlc.arg(inactive)::boolean,
sqlc.arg(premium)::boolean,
sqlc.arg(static_icon_id)::bigint,
sqlc.arg(appear_animation_id)::bigint,
sqlc.arg(select_animation_id)::bigint,
sqlc.arg(activate_animation_id)::bigint,
sqlc.arg(effect_animation_id)::bigint,
sqlc.arg(around_animation_id)::bigint,
sqlc.arg(center_icon_id)::bigint,
sqlc.arg(sort_order)::int
)
ON CONFLICT (reaction) DO UPDATE SET
title = EXCLUDED.title,
inactive = EXCLUDED.inactive,
premium = EXCLUDED.premium,
static_icon_id = EXCLUDED.static_icon_id,
appear_animation_id = EXCLUDED.appear_animation_id,
select_animation_id = EXCLUDED.select_animation_id,
activate_animation_id = EXCLUDED.activate_animation_id,
effect_animation_id = EXCLUDED.effect_animation_id,
around_animation_id = EXCLUDED.around_animation_id,
center_icon_id = EXCLUDED.center_icon_id,
sort_order = EXCLUDED.sort_order;
-- name: ListAvailableReactions :many
SELECT
reaction, title, inactive, premium,
static_icon_id, appear_animation_id, select_animation_id,
activate_animation_id, effect_animation_id, around_animation_id, center_icon_id, sort_order
FROM available_reactions
ORDER BY sort_order ASC, reaction ASC;
-- name: CountAvailableReactions :one
SELECT count(*)::int AS total FROM available_reactions;
-- profile_photos --------------------------------------------------------------
-- name: AddProfilePhoto :exec
INSERT INTO profile_photos (owner_peer_type, owner_peer_id, photo_id, date, active, sort_order)
VALUES (
sqlc.arg(owner_peer_type)::text,
sqlc.arg(owner_peer_id)::bigint,
sqlc.arg(photo_id)::bigint,
sqlc.arg(date)::int,
true,
sqlc.arg(sort_order)::bigint
)
ON CONFLICT (owner_peer_type, owner_peer_id, photo_id) DO UPDATE SET
date = EXCLUDED.date,
active = true,
sort_order = EXCLUDED.sort_order;
-- name: NextProfilePhotoOrder :one
SELECT COALESCE(MAX(sort_order), 0)::bigint AS max_order
FROM profile_photos
WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text
AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint;
-- name: CurrentProfilePhoto :one
SELECT photo_id
FROM profile_photos
WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text
AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint
AND active
ORDER BY sort_order DESC
LIMIT 1;
-- name: CurrentProfilePhotosForOwners :many
SELECT DISTINCT ON (pp.owner_peer_id)
pp.owner_peer_id,
pp.photo_id,
ph.dc_id,
ph.sizes::text AS sizes_json
FROM profile_photos pp
JOIN photos ph ON ph.id = pp.photo_id
WHERE pp.owner_peer_type = sqlc.arg(owner_peer_type)::text
AND pp.owner_peer_id = ANY(sqlc.arg(owner_ids)::bigint[])
AND pp.active
ORDER BY pp.owner_peer_id, pp.sort_order DESC;
-- name: ListProfilePhotos :many
SELECT photo_id
FROM profile_photos
WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text
AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint
AND active
AND (sqlc.arg(max_id)::bigint <= 0 OR photo_id < sqlc.arg(max_id)::bigint)
ORDER BY sort_order DESC
OFFSET sqlc.arg(offset_count)::int
LIMIT sqlc.arg(limit_count)::int;
-- name: CountProfilePhotos :one
SELECT count(*)::int AS total
FROM profile_photos
WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text
AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint
AND active;
-- name: DeactivateProfilePhotos :many
UPDATE profile_photos
SET active = false
WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text
AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint
AND photo_id = ANY(sqlc.arg(photo_ids)::bigint[])
AND active
RETURNING photo_id;

View file

@ -0,0 +1,932 @@
-- name: CreateMessage :one
WITH pm AS (
INSERT INTO private_messages (
sender_user_id,
recipient_user_id,
random_id,
message_date,
body,
entities
) VALUES (
sqlc.arg(from_user_id),
sqlc.arg(owner_user_id),
0,
sqlc.arg(message_date),
sqlc.arg(body),
sqlc.arg(entities_json)::jsonb
)
RETURNING id, sender_user_id
),
box AS (
INSERT INTO message_boxes (
owner_user_id,
box_id,
private_message_id,
message_sender_id,
peer_type,
peer_id,
from_user_id,
message_date,
outgoing,
body,
entities,
pts
)
SELECT
sqlc.arg(owner_user_id),
sqlc.arg(box_id),
pm.id,
pm.sender_user_id,
sqlc.arg(peer_type),
sqlc.arg(peer_id),
sqlc.arg(from_user_id),
sqlc.arg(message_date),
sqlc.arg(outgoing),
sqlc.arg(body),
sqlc.arg(entities_json)::jsonb,
sqlc.arg(pts)
FROM pm
RETURNING
box_id,
private_message_id,
owner_user_id,
peer_type,
peer_id,
from_user_id,
message_date,
edit_date,
outgoing,
body,
entities::text AS entities_json,
pts
)
SELECT
box_id,
private_message_id,
owner_user_id,
peer_type,
peer_id,
from_user_id,
message_date,
edit_date,
outgoing,
body,
entities_json,
pts
FROM box;
-- name: CreatePrivateMessage :one
INSERT INTO private_messages (
sender_user_id,
recipient_user_id,
random_id,
message_date,
body,
entities,
silent,
noforwards,
reply_to_msg_id,
reply_to_peer_type,
reply_to_peer_id,
reply_to_top_id,
quote_text,
quote_entities,
quote_offset,
fwd_from_peer_type,
fwd_from_peer_id,
fwd_from_name,
fwd_date,
media
) VALUES (
$1, $2, $3, $4, $5, sqlc.arg(entities_json)::jsonb,
sqlc.arg(silent)::boolean,
sqlc.arg(noforwards)::boolean,
sqlc.arg(reply_to_msg_id)::int,
sqlc.arg(reply_to_peer_type)::text,
sqlc.arg(reply_to_peer_id)::bigint,
sqlc.arg(reply_to_top_id)::int,
sqlc.arg(quote_text)::text,
sqlc.arg(quote_entities_json)::jsonb,
sqlc.arg(quote_offset)::int,
sqlc.arg(fwd_from_peer_type)::text,
sqlc.arg(fwd_from_peer_id)::bigint,
sqlc.arg(fwd_from_name)::text,
sqlc.arg(fwd_date)::int,
sqlc.arg(media_json)::jsonb
)
ON CONFLICT (sender_user_id, random_id) WHERE random_id <> 0 DO NOTHING
RETURNING
id,
sender_user_id,
recipient_user_id,
random_id,
message_date,
edit_date,
body,
entities::text AS entities_json;
-- name: GetPrivateMessageByRandomID :one
SELECT
id,
sender_user_id,
recipient_user_id,
random_id,
message_date,
edit_date,
body,
entities::text AS entities_json
FROM private_messages
WHERE sender_user_id = $1
AND random_id = $2
AND random_id <> 0;
-- name: CreateMessageBox :one
INSERT INTO message_boxes (
owner_user_id,
box_id,
private_message_id,
message_sender_id,
peer_type,
peer_id,
from_user_id,
message_date,
outgoing,
body,
entities,
silent,
noforwards,
reply_to_msg_id,
reply_to_peer_type,
reply_to_peer_id,
reply_to_top_id,
quote_text,
quote_entities,
quote_offset,
fwd_from_peer_type,
fwd_from_peer_id,
fwd_from_name,
fwd_date,
pts,
media
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, sqlc.arg(entities_json)::jsonb,
sqlc.arg(silent)::boolean,
sqlc.arg(noforwards)::boolean,
sqlc.arg(reply_to_msg_id)::int,
sqlc.arg(reply_to_peer_type)::text,
sqlc.arg(reply_to_peer_id)::bigint,
sqlc.arg(reply_to_top_id)::int,
sqlc.arg(quote_text)::text,
sqlc.arg(quote_entities_json)::jsonb,
sqlc.arg(quote_offset)::int,
sqlc.arg(fwd_from_peer_type)::text,
sqlc.arg(fwd_from_peer_id)::bigint,
sqlc.arg(fwd_from_name)::text,
sqlc.arg(fwd_date)::int,
sqlc.arg(pts)::int,
sqlc.arg(media_json)::jsonb
)
RETURNING
box_id,
private_message_id,
owner_user_id,
peer_type,
peer_id,
from_user_id,
message_date,
edit_date,
outgoing,
body,
entities::text AS entities_json,
silent,
noforwards,
reply_to_msg_id,
reply_to_peer_type,
reply_to_peer_id,
reply_to_top_id,
quote_text,
quote_entities::text AS quote_entities_json,
quote_offset,
fwd_from_peer_type,
fwd_from_peer_id,
fwd_from_name,
fwd_date,
pts,
media::text AS media_json;
-- name: GetMessageBoxByPrivateMessage :one
SELECT
box_id,
private_message_id,
owner_user_id,
peer_type,
peer_id,
from_user_id,
message_date,
edit_date,
outgoing,
body,
entities::text AS entities_json,
silent,
noforwards,
reply_to_msg_id,
reply_to_peer_type,
reply_to_peer_id,
reply_to_top_id,
quote_text,
quote_entities::text AS quote_entities_json,
quote_offset,
fwd_from_peer_type,
fwd_from_peer_id,
fwd_from_name,
fwd_date,
pts,
media::text AS media_json
FROM message_boxes
WHERE owner_user_id = $1
AND private_message_id = $2
AND NOT deleted;
-- name: GetMessageBoxForReply :one
SELECT
box_id,
private_message_id,
message_sender_id
FROM message_boxes
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
AND peer_type = sqlc.arg(peer_type)::text
AND peer_id = sqlc.arg(peer_id)::bigint
AND box_id = sqlc.arg(box_id)::int
AND NOT deleted
LIMIT 1;
-- name: GetMessageBoxesForForward :many
WITH requested AS (
SELECT
id::int AS box_id,
ord::int AS ord
FROM unnest(sqlc.arg(box_ids)::int[]) WITH ORDINALITY AS r(id, ord)
)
SELECT
r.ord,
m.box_id,
m.private_message_id,
m.owner_user_id,
m.message_sender_id,
m.peer_type,
m.peer_id,
m.from_user_id,
m.message_date,
m.edit_date,
m.outgoing,
m.body,
m.entities::text AS entities_json,
m.silent,
m.noforwards,
m.reply_to_msg_id,
m.reply_to_peer_type,
m.reply_to_peer_id,
m.reply_to_top_id,
m.quote_text,
m.quote_entities::text AS quote_entities_json,
m.quote_offset,
m.fwd_from_peer_type,
m.fwd_from_peer_id,
m.fwd_from_name,
m.fwd_date,
m.pts,
m.media::text AS media_json
FROM requested r
JOIN message_boxes m
ON m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND m.peer_type = sqlc.arg(peer_type)::text
AND m.peer_id = sqlc.arg(peer_id)::bigint
AND m.box_id = r.box_id
AND NOT m.deleted
ORDER BY r.ord ASC;
-- name: MaxMessageBoxID :one
SELECT COALESCE(MAX(box_id), 0)::int AS max_box_id
FROM message_boxes
WHERE owner_user_id = $1;
-- name: ListMessagesByUser :many
WITH load_params AS (
SELECT
sqlc.arg(offset_id)::int AS offset_id,
sqlc.arg(offset_date)::int AS offset_date,
sqlc.arg(add_offset)::int AS add_offset,
sqlc.arg(limit_count)::int AS limit_count,
CASE
WHEN sqlc.arg(add_offset)::int >= 0 THEN 'backward'
WHEN sqlc.arg(add_offset)::int + sqlc.arg(limit_count)::int > 0 THEN 'around'
ELSE 'forward'
END::text AS load_type
),
base AS NOT MATERIALIZED (
SELECT
m.box_id,
m.private_message_id,
m.owner_user_id,
m.peer_type,
m.peer_id,
m.from_user_id,
m.message_date,
m.edit_date,
m.outgoing,
m.body,
m.entities::text AS entities_json,
m.silent,
m.noforwards,
m.reply_to_msg_id,
m.reply_to_peer_type,
m.reply_to_peer_id,
m.reply_to_top_id,
m.quote_text,
m.quote_entities::text AS quote_entities_json,
m.quote_offset,
m.fwd_from_peer_type,
m.fwd_from_peer_id,
m.fwd_from_name,
m.fwd_date,
m.pts,
m.media::text AS media_json,
COALESCE(peer_u.id, 0)::bigint AS peer_user_id,
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(peer_u.phone, '')::text AS peer_phone,
COALESCE(peer_u.first_name, '')::text AS peer_first_name,
COALESCE(peer_u.last_name, '')::text AS peer_last_name,
COALESCE(peer_u.username, '')::text AS peer_username,
COALESCE(peer_u.country_code, '')::text AS peer_country_code,
COALESCE(peer_u.verified, false)::boolean AS peer_verified,
COALESCE(peer_u.support, false)::boolean AS peer_support,
COALESCE(peer_u.last_seen_at, 0)::bigint AS peer_last_seen_at,
COALESCE(from_u.id, 0)::bigint AS from_user_user_id,
COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash,
COALESCE(from_u.phone, '')::text AS from_user_phone,
COALESCE(from_u.first_name, '')::text AS from_user_first_name,
COALESCE(from_u.last_name, '')::text AS from_user_last_name,
COALESCE(from_u.username, '')::text AS from_user_username,
COALESCE(from_u.country_code, '')::text AS from_user_country_code,
COALESCE(from_u.verified, false)::boolean AS from_user_verified,
COALESCE(from_u.support, false)::boolean AS from_user_support,
COALESCE(from_u.last_seen_at, 0)::bigint AS from_user_last_seen_at
FROM message_boxes m
LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id
LEFT JOIN users from_u ON from_u.id = m.from_user_id
WHERE m.owner_user_id = $1
AND NOT m.deleted
AND (
NOT sqlc.arg(has_peer)::boolean
OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint)
)
AND (
sqlc.arg(query)::text = ''
OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%')
)
AND (sqlc.arg(max_id)::int <= 0 OR m.box_id < sqlc.arg(max_id)::int)
AND (sqlc.arg(min_id)::int <= 0 OR m.box_id > sqlc.arg(min_id)::int)
),
total AS (
SELECT count(*)::int AS total_count
FROM base
WHERE sqlc.arg(need_total_count)::boolean
),
backward AS (
SELECT b.*
FROM base b
CROSS JOIN load_params p
WHERE p.load_type = 'backward'
AND (
(p.offset_date > 0 AND b.message_date < p.offset_date)
OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id < p.offset_id))
)
ORDER BY b.box_id DESC
OFFSET GREATEST((SELECT add_offset FROM load_params), 0)
LIMIT (SELECT limit_count FROM load_params)
),
around_forward AS (
SELECT f.*
FROM (
SELECT b.*
FROM base b
CROSS JOIN load_params p
WHERE p.load_type = 'around'
AND (
(p.offset_date > 0 AND b.message_date >= p.offset_date)
OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id)
)
ORDER BY b.box_id ASC
LIMIT LEAST(-(SELECT add_offset FROM load_params), (SELECT limit_count FROM load_params))
) f
),
around_backward AS (
SELECT b.*
FROM base b
CROSS JOIN load_params p
WHERE p.load_type = 'around'
AND (
(p.offset_date > 0 AND b.message_date < p.offset_date)
OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id <= p.offset_id))
)
ORDER BY b.box_id DESC
LIMIT GREATEST((SELECT limit_count + add_offset FROM load_params), 0)
),
forward AS (
SELECT f.*
FROM (
SELECT b.*
FROM base b
CROSS JOIN load_params p
WHERE p.load_type = 'forward'
AND (
(p.offset_date > 0 AND b.message_date >= p.offset_date)
OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id)
)
ORDER BY b.box_id ASC
LIMIT (SELECT limit_count FROM load_params)
) f
),
paged AS (
SELECT * FROM backward
UNION ALL
SELECT * FROM around_forward
UNION ALL
SELECT * FROM around_backward
UNION ALL
SELECT * FROM forward
)
SELECT
box_id,
private_message_id,
owner_user_id,
peer_type,
peer_id,
from_user_id,
message_date,
edit_date,
outgoing,
body,
entities_json,
silent,
noforwards,
reply_to_msg_id,
reply_to_peer_type,
reply_to_peer_id,
reply_to_top_id,
quote_text,
quote_entities_json,
quote_offset,
fwd_from_peer_type,
fwd_from_peer_id,
fwd_from_name,
fwd_date,
pts,
media_json,
peer_user_id,
peer_access_hash,
peer_phone,
peer_first_name,
peer_last_name,
peer_username,
peer_country_code,
peer_verified,
peer_support,
peer_last_seen_at,
from_user_user_id,
from_user_access_hash,
from_user_phone,
from_user_first_name,
from_user_last_name,
from_user_username,
from_user_country_code,
from_user_verified,
from_user_support,
from_user_last_seen_at,
COALESCE(total.total_count, 0)::int AS total_count
FROM paged
CROSS JOIN total
ORDER BY box_id DESC;
-- name: GetMessageBoxesByIDs :many
SELECT
wanted.box_id AS requested_box_id,
m.box_id,
m.private_message_id,
m.owner_user_id,
m.peer_type,
m.peer_id,
m.from_user_id,
m.message_date,
m.edit_date,
m.outgoing,
m.body,
m.entities::text AS entities_json,
m.silent,
m.noforwards,
m.reply_to_msg_id,
m.reply_to_peer_type,
m.reply_to_peer_id,
m.reply_to_top_id,
m.quote_text,
m.quote_entities::text AS quote_entities_json,
m.quote_offset,
m.fwd_from_peer_type,
m.fwd_from_peer_id,
m.fwd_from_name,
m.fwd_date,
m.pts,
m.media::text AS media_json,
COALESCE(peer_u.id, 0)::bigint AS peer_user_id,
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(peer_u.phone, '')::text AS peer_phone,
COALESCE(peer_u.first_name, '')::text AS peer_first_name,
COALESCE(peer_u.last_name, '')::text AS peer_last_name,
COALESCE(peer_u.username, '')::text AS peer_username,
COALESCE(peer_u.country_code, '')::text AS peer_country_code,
COALESCE(peer_u.verified, false)::boolean AS peer_verified,
COALESCE(peer_u.support, false)::boolean AS peer_support,
COALESCE(peer_u.last_seen_at, 0)::bigint AS peer_last_seen_at,
COALESCE(from_u.id, 0)::bigint AS from_user_user_id,
COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash,
COALESCE(from_u.phone, '')::text AS from_user_phone,
COALESCE(from_u.first_name, '')::text AS from_user_first_name,
COALESCE(from_u.last_name, '')::text AS from_user_last_name,
COALESCE(from_u.username, '')::text AS from_user_username,
COALESCE(from_u.country_code, '')::text AS from_user_country_code,
COALESCE(from_u.verified, false)::boolean AS from_user_verified,
COALESCE(from_u.support, false)::boolean AS from_user_support,
COALESCE(from_u.last_seen_at, 0)::bigint AS from_user_last_seen_at
FROM unnest(@box_ids::int[]) WITH ORDINALITY AS wanted(box_id, ord)
JOIN message_boxes m
ON m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND m.box_id = wanted.box_id
AND NOT m.deleted
LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id
LEFT JOIN users from_u ON from_u.id = m.from_user_id
ORDER BY wanted.ord ASC;
-- name: GetMessageBoxForEdit :one
SELECT
box_id,
private_message_id,
owner_user_id,
message_sender_id,
peer_type,
peer_id,
from_user_id,
message_date,
edit_date,
outgoing,
body,
entities::text AS entities_json,
silent,
noforwards,
reply_to_msg_id,
reply_to_peer_type,
reply_to_peer_id,
reply_to_top_id,
quote_text,
quote_entities::text AS quote_entities_json,
quote_offset,
fwd_from_peer_type,
fwd_from_peer_id,
fwd_from_name,
fwd_date,
pts,
media::text AS media_json
FROM message_boxes
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
AND box_id = sqlc.arg(box_id)::int
AND peer_type = sqlc.arg(peer_type)::text
AND peer_id = sqlc.arg(peer_id)::bigint
AND NOT deleted
LIMIT 1
FOR UPDATE;
-- name: ListVisibleMessageBoxesByPrivateMessage :many
SELECT
box_id,
private_message_id,
owner_user_id,
message_sender_id,
peer_type,
peer_id,
from_user_id,
message_date,
edit_date,
outgoing,
body,
entities::text AS entities_json,
silent,
noforwards,
reply_to_msg_id,
reply_to_peer_type,
reply_to_peer_id,
reply_to_top_id,
quote_text,
quote_entities::text AS quote_entities_json,
quote_offset,
fwd_from_peer_type,
fwd_from_peer_id,
fwd_from_name,
fwd_date,
pts,
media::text AS media_json
FROM message_boxes
WHERE message_sender_id = sqlc.arg(message_sender_id)::bigint
AND private_message_id = sqlc.arg(private_message_id)::bigint
AND NOT deleted
ORDER BY owner_user_id ASC, box_id ASC
FOR UPDATE;
-- name: UpdatePrivateMessageEdit :exec
UPDATE private_messages
SET body = sqlc.arg(body)::text,
entities = sqlc.arg(entities_json)::jsonb,
edit_date = sqlc.arg(edit_date)::int
WHERE sender_user_id = sqlc.arg(sender_user_id)::bigint
AND id = sqlc.arg(private_message_id)::bigint;
-- name: UpdateMessageBoxEdit :one
UPDATE message_boxes
SET body = sqlc.arg(body)::text,
entities = sqlc.arg(entities_json)::jsonb,
edit_date = sqlc.arg(edit_date)::int,
pts = sqlc.arg(pts)::int
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
AND box_id = sqlc.arg(box_id)::int
AND NOT deleted
RETURNING
box_id,
private_message_id,
owner_user_id,
message_sender_id,
peer_type,
peer_id,
from_user_id,
message_date,
edit_date,
outgoing,
body,
entities::text AS entities_json,
silent,
noforwards,
reply_to_msg_id,
reply_to_peer_type,
reply_to_peer_id,
reply_to_top_id,
quote_text,
quote_entities::text AS quote_entities_json,
quote_offset,
fwd_from_peer_type,
fwd_from_peer_id,
fwd_from_name,
fwd_date,
pts,
media::text AS media_json;
-- name: GetDialogReadStateForUpdate :one
SELECT
user_id,
peer_type,
peer_id,
top_message_id,
read_inbox_max_id,
unread_count
FROM dialogs
WHERE user_id = sqlc.arg(user_id)::bigint
AND peer_type = sqlc.arg(peer_type)::text
AND peer_id = sqlc.arg(peer_id)::bigint
FOR UPDATE;
-- name: LatestIncomingReadReceiptCandidate :one
SELECT
m.message_sender_id,
m.private_message_id,
sender_box.owner_user_id AS sender_owner_user_id,
sender_box.box_id AS sender_box_id
FROM message_boxes m
JOIN message_boxes sender_box
ON sender_box.message_sender_id = m.message_sender_id
AND sender_box.private_message_id = m.private_message_id
AND sender_box.owner_user_id = m.message_sender_id
AND sender_box.outgoing
AND NOT sender_box.deleted
WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND m.peer_type = sqlc.arg(peer_type)::text
AND m.peer_id = sqlc.arg(peer_id)::bigint
AND NOT m.outgoing
AND NOT m.deleted
AND m.box_id > sqlc.arg(old_read_inbox_max_id)::int
AND m.box_id <= sqlc.arg(new_read_inbox_max_id)::int
ORDER BY m.box_id DESC
LIMIT 1;
-- name: UpdateDialogReadInbox :one
UPDATE dialogs d
SET
read_inbox_max_id = GREATEST(d.read_inbox_max_id, sqlc.arg(read_inbox_max_id)::int),
unread_count = (
SELECT count(*)::int
FROM message_boxes m
WHERE m.owner_user_id = d.user_id
AND m.peer_type = d.peer_type
AND m.peer_id = d.peer_id
AND NOT m.deleted
AND NOT m.outgoing
AND m.box_id > GREATEST(d.read_inbox_max_id, sqlc.arg(read_inbox_max_id)::int)
),
unread_mark = false,
unread_mentions_count = 0,
unread_reactions_count = 0,
updated_at = now()
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = sqlc.arg(peer_type)::text
AND d.peer_id = sqlc.arg(peer_id)::bigint
RETURNING
d.read_inbox_max_id,
d.unread_count;
-- name: UpdateDialogReadOutbox :one
UPDATE dialogs
SET
read_outbox_max_id = GREATEST(read_outbox_max_id, sqlc.arg(read_outbox_max_id)::int),
updated_at = now()
WHERE user_id = sqlc.arg(user_id)::bigint
AND peer_type = sqlc.arg(peer_type)::text
AND peer_id = sqlc.arg(peer_id)::bigint
AND read_outbox_max_id < sqlc.arg(read_outbox_max_id)::int
RETURNING read_outbox_max_id;
-- name: GetOutboxMessageForReadDate :one
SELECT box_id
FROM message_boxes
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
AND peer_type = sqlc.arg(peer_type)::text
AND peer_id = sqlc.arg(peer_id)::bigint
AND box_id = sqlc.arg(box_id)::int
AND outgoing
AND NOT deleted
LIMIT 1;
-- name: GetOutboxReadDate :one
SELECT COALESCE(MIN(date), 0)::int AS read_date
FROM user_update_events
WHERE user_id = sqlc.arg(user_id)::bigint
AND event_type = 'read_history_outbox'
AND peer_type = sqlc.arg(peer_type)::text
AND peer_id = sqlc.arg(peer_id)::bigint
AND max_id >= sqlc.arg(message_id)::int;
-- name: DeleteMessageBoxesByIDs :many
WITH updated AS (
UPDATE message_boxes m
SET deleted = true
WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND m.box_id = ANY(sqlc.arg(box_ids)::int[])
AND NOT m.deleted
RETURNING
m.owner_user_id,
m.box_id,
m.private_message_id,
m.message_sender_id,
m.peer_type,
m.peer_id
)
SELECT
owner_user_id,
box_id,
private_message_id,
message_sender_id,
peer_type,
peer_id
FROM updated
ORDER BY box_id ASC;
-- name: DeleteMessageBoxesByPeer :many
WITH updated AS (
UPDATE message_boxes m
SET deleted = true
WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND m.peer_type = sqlc.arg(peer_type)::text
AND m.peer_id = sqlc.arg(peer_id)::bigint
AND (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int)
AND NOT m.deleted
RETURNING
m.owner_user_id,
m.box_id,
m.private_message_id,
m.message_sender_id,
m.peer_type,
m.peer_id
)
SELECT
owner_user_id,
box_id,
private_message_id,
message_sender_id,
peer_type,
peer_id
FROM updated
ORDER BY box_id ASC;
-- name: DeleteMessageBoxesByPeerBatch :many
WITH target AS (
SELECT
m.owner_user_id,
m.box_id
FROM message_boxes m
WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND m.peer_type = sqlc.arg(peer_type)::text
AND m.peer_id = sqlc.arg(peer_id)::bigint
AND (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int)
AND NOT m.deleted
ORDER BY m.box_id DESC
LIMIT sqlc.arg(limit_count)::int
FOR UPDATE SKIP LOCKED
),
updated AS (
UPDATE message_boxes m
SET deleted = true
FROM target t
WHERE m.owner_user_id = t.owner_user_id
AND m.box_id = t.box_id
RETURNING
m.owner_user_id,
m.box_id,
m.private_message_id,
m.message_sender_id,
m.peer_type,
m.peer_id
)
SELECT
owner_user_id,
box_id,
private_message_id,
message_sender_id,
peer_type,
peer_id
FROM updated
ORDER BY box_id ASC;
-- name: HasDeletableMessageBoxByPeer :one
SELECT EXISTS (
SELECT 1
FROM message_boxes m
WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND m.peer_type = sqlc.arg(peer_type)::text
AND m.peer_id = sqlc.arg(peer_id)::bigint
AND (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int)
AND NOT m.deleted
LIMIT 1
)::boolean AS more;
-- name: DeleteMessageBoxesByPrivateMessages :many
WITH requested AS (
SELECT
(sqlc.arg(message_sender_ids)::bigint[])[i] AS message_sender_id,
(sqlc.arg(private_message_ids)::bigint[])[i] AS private_message_id
FROM generate_subscripts(sqlc.arg(private_message_ids)::bigint[], 1) AS g(i)
WHERE i <= cardinality(sqlc.arg(message_sender_ids)::bigint[])
),
deduped AS (
SELECT DISTINCT message_sender_id, private_message_id
FROM requested
),
updated AS (
UPDATE message_boxes m
SET deleted = true
FROM deduped d
WHERE m.message_sender_id = d.message_sender_id
AND m.private_message_id = d.private_message_id
AND NOT m.deleted
RETURNING
m.owner_user_id,
m.box_id,
m.private_message_id,
m.message_sender_id,
m.peer_type,
m.peer_id
)
SELECT
owner_user_id,
box_id,
private_message_id,
message_sender_id,
peer_type,
peer_id
FROM updated
ORDER BY owner_user_id ASC, box_id ASC;
-- name: TopVisibleMessageBoxByPeer :one
SELECT
box_id,
message_date
FROM message_boxes
WHERE owner_user_id = $1
AND peer_type = $2
AND peer_id = $3
AND NOT deleted
ORDER BY box_id DESC
LIMIT 1;

View file

@ -0,0 +1,23 @@
-- name: UpsertTempAuthKeyBinding :exec
INSERT INTO temp_auth_key_bindings (
temp_auth_key_id, perm_auth_key_id, nonce, temp_session_id, expires_at, encrypted_message
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (temp_auth_key_id) DO UPDATE SET
perm_auth_key_id = EXCLUDED.perm_auth_key_id,
nonce = EXCLUDED.nonce,
temp_session_id = EXCLUDED.temp_session_id,
expires_at = EXCLUDED.expires_at,
encrypted_message = EXCLUDED.encrypted_message,
created_at = now();
-- name: GetTempAuthKeyBinding :one
SELECT
temp_auth_key_id,
perm_auth_key_id,
nonce,
temp_session_id,
expires_at,
encrypted_message
FROM temp_auth_key_bindings
WHERE temp_auth_key_id = $1;

View file

@ -0,0 +1,24 @@
-- name: GetUpdateState :one
SELECT auth_key_id, user_id, pts, qts, date, seq
FROM update_states
WHERE auth_key_id = $1
AND user_id = $2;
-- name: UpsertUpdateState :exec
INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (auth_key_id, user_id) DO UPDATE SET
pts = EXCLUDED.pts,
qts = EXCLUDED.qts,
date = EXCLUDED.date,
seq = EXCLUDED.seq,
updated_at = now();
-- name: DeleteUpdateState :exec
DELETE FROM update_states
WHERE auth_key_id = $1
AND user_id = $2;
-- name: DeleteUpdateStatesByAuthKey :exec
DELETE FROM update_states
WHERE auth_key_id = $1;

View file

@ -0,0 +1,104 @@
-- name: GetUserByID :one
SELECT * FROM users WHERE id = $1;
-- name: GetUsersByIDs :many
SELECT *
FROM users
WHERE id = ANY(sqlc.arg(ids)::bigint[])
ORDER BY id;
-- name: GetUserByPhone :one
SELECT * FROM users WHERE phone = $1;
-- name: GetUsersByPhones :many
SELECT *
FROM users
WHERE phone = ANY(sqlc.arg(phones)::text[])
ORDER BY id;
-- name: GetUserByUsername :one
SELECT * FROM users WHERE lower(username) = lower($1) AND username <> '';
-- name: SearchUsers :many
WITH matched AS (
SELECT
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.about,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at,
(c.contact_user_id IS NOT NULL)::boolean AS contact,
COALESCE(c.mutual, false)::boolean AS mutual,
CASE
WHEN sqlc.arg(phone_query)::text <> '' AND u.phone = sqlc.arg(phone_query)::text THEN 0
WHEN lower(u.username) = sqlc.arg(query_lower)::text THEN 1
WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = sqlc.arg(query_lower)::text THEN 2
WHEN lower(u.first_name) = sqlc.arg(query_lower)::text THEN 3
WHEN c.contact_user_id IS NOT NULL THEN 4
ELSE 5
END AS rank
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 sqlc.arg(query_lower)::text <> ''
AND (
(sqlc.arg(phone_query)::text <> '' AND u.phone LIKE sqlc.arg(phone_query)::text || '%')
OR lower(u.username) LIKE sqlc.arg(query_like)::text || '%' ESCAPE '\'
OR lower(u.first_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\'
OR lower(u.last_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\'
OR lower(trim(u.first_name || ' ' || u.last_name)) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\'
OR lower(c.contact_first_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\'
OR lower(c.contact_last_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\'
OR lower(trim(c.contact_first_name || ' ' || c.contact_last_name)) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\'
)
)
SELECT
id,
access_hash,
phone,
first_name,
last_name,
about,
username,
country_code,
verified,
support,
last_seen_at,
contact,
mutual
FROM matched
ORDER BY contact DESC, rank, id
LIMIT sqlc.arg(limit_count);
-- name: CreateUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: UpdateUserUsername :one
UPDATE users
SET username = $2,
updated_at = now()
WHERE id = $1
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;
-- name: UpdateUserProfile :one
UPDATE users
SET first_name = $2,
last_name = $3,
about = $4,
updated_at = now()
WHERE id = $1
RETURNING *;

View file

@ -0,0 +1,464 @@
-- name: AppendUserUpdateEvent :exec
INSERT INTO user_update_events (
user_id,
pts,
pts_count,
date,
event_type,
event_bool,
event_peers,
peer_settings,
message_ids,
dialog_filter,
filter_order,
folder_peers,
message_box_id,
peer_type,
peer_id,
filter_id,
max_id,
still_unread_count,
tags_enabled
) VALUES (
$1,
$2,
$3,
$4,
$5,
sqlc.arg(event_bool)::boolean,
sqlc.arg(event_peers)::jsonb,
sqlc.arg(peer_settings)::jsonb,
sqlc.arg(message_ids)::jsonb,
sqlc.arg(dialog_filter)::jsonb,
sqlc.arg(filter_order)::jsonb,
sqlc.arg(folder_peers)::jsonb,
sqlc.narg(message_box_id),
sqlc.narg(peer_type)::text,
sqlc.narg(peer_id)::bigint,
sqlc.arg(filter_id)::int,
sqlc.arg(max_id)::int,
sqlc.arg(still_unread_count)::int,
sqlc.arg(tags_enabled)::boolean
)
ON CONFLICT (user_id, pts) DO NOTHING;
-- name: ListUserUpdateEventsAfter :many
SELECT
e.user_id,
e.pts,
e.pts_count,
e.date,
e.event_type,
e.event_bool,
COALESCE(e.event_peers::text, '[]')::text AS event_peers_json,
COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json,
COALESCE(e.message_ids::text, '[]')::text AS message_ids_json,
COALESCE(e.dialog_filter::text, '{}')::text AS dialog_filter_json,
COALESCE(e.filter_order::text, '[]')::text AS filter_order_json,
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
COALESCE(e.peer_type, '')::text AS event_peer_type,
COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
e.filter_id,
e.max_id,
e.still_unread_count,
e.tags_enabled,
COALESCE(m.box_id, 0)::int AS message_id,
COALESCE(m.private_message_id, 0)::bigint AS private_message_id,
COALESCE(m.owner_user_id, 0)::bigint AS owner_user_id,
COALESCE(m.peer_type, '')::text AS peer_type,
COALESCE(m.peer_id, 0)::bigint AS peer_id,
COALESCE(m.from_user_id, 0)::bigint AS from_user_id,
COALESCE(m.message_date, 0)::int AS message_date,
COALESCE(m.edit_date, 0)::int AS edit_date,
COALESCE(m.outgoing, false)::boolean AS outgoing,
COALESCE(m.body, '')::text AS body,
COALESCE(m.entities::text, '[]')::text AS message_entities_json,
COALESCE(m.silent, false)::boolean AS silent,
COALESCE(m.noforwards, false)::boolean AS noforwards,
COALESCE(m.reply_to_msg_id, 0)::int AS reply_to_msg_id,
COALESCE(m.reply_to_peer_type, '')::text AS reply_to_peer_type,
COALESCE(m.reply_to_peer_id, 0)::bigint AS reply_to_peer_id,
COALESCE(m.reply_to_top_id, 0)::int AS reply_to_top_id,
COALESCE(m.quote_text, '')::text AS quote_text,
COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json,
COALESCE(m.quote_offset, 0)::int AS quote_offset,
COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type,
COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id,
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
COALESCE(m.fwd_date, 0)::int AS fwd_date,
COALESCE(m.media::text, '{}')::text AS media_json,
COALESCE(peer_u.id, 0)::bigint AS peer_user_id,
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(peer_u.phone, '')::text AS peer_phone,
COALESCE(peer_u.first_name, '')::text AS peer_first_name,
COALESCE(peer_u.last_name, '')::text AS peer_last_name,
COALESCE(peer_u.username, '')::text AS peer_username,
COALESCE(peer_u.country_code, '')::text AS peer_country_code,
COALESCE(peer_u.verified, false)::boolean AS peer_verified,
COALESCE(peer_u.support, false)::boolean AS peer_support,
COALESCE(from_u.id, 0)::bigint AS from_user_user_id,
COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash,
COALESCE(from_u.phone, '')::text AS from_user_phone,
COALESCE(from_u.first_name, '')::text AS from_user_first_name,
COALESCE(from_u.last_name, '')::text AS from_user_last_name,
COALESCE(from_u.username, '')::text AS from_user_username,
COALESCE(from_u.country_code, '')::text AS from_user_country_code,
COALESCE(from_u.verified, false)::boolean AS from_user_verified,
COALESCE(from_u.support, false)::boolean AS from_user_support,
COALESCE(fwd_u.id, 0)::bigint AS fwd_user_id,
COALESCE(fwd_u.access_hash, 0)::bigint AS fwd_user_access_hash,
COALESCE(fwd_u.phone, '')::text AS fwd_user_phone,
COALESCE(fwd_u.first_name, '')::text AS fwd_user_first_name,
COALESCE(fwd_u.last_name, '')::text AS fwd_user_last_name,
COALESCE(fwd_u.username, '')::text AS fwd_user_username,
COALESCE(fwd_u.country_code, '')::text AS fwd_user_country_code,
COALESCE(fwd_u.verified, false)::boolean AS fwd_user_verified,
COALESCE(fwd_u.support, false)::boolean AS fwd_user_support,
COALESCE(reply_u.id, 0)::bigint AS reply_user_id,
COALESCE(reply_u.access_hash, 0)::bigint AS reply_user_access_hash,
COALESCE(reply_u.phone, '')::text AS reply_user_phone,
COALESCE(reply_u.first_name, '')::text AS reply_user_first_name,
COALESCE(reply_u.last_name, '')::text AS reply_user_last_name,
COALESCE(reply_u.username, '')::text AS reply_user_username,
COALESCE(reply_u.country_code, '')::text AS reply_user_country_code,
COALESCE(reply_u.verified, false)::boolean AS reply_user_verified,
COALESCE(reply_u.support, false)::boolean AS reply_user_support,
COALESCE(fwd_ch.id, 0)::bigint AS fwd_channel_id,
COALESCE(fwd_ch.access_hash, 0)::bigint AS fwd_channel_access_hash,
COALESCE(fwd_ch.creator_user_id, 0)::bigint AS fwd_channel_creator_user_id,
COALESCE(fwd_ch.title, '')::text AS fwd_channel_title,
COALESCE(fwd_ch.about, '')::text AS fwd_channel_about,
COALESCE(fwd_ch.username, '')::text AS fwd_channel_username,
COALESCE(fwd_ch.broadcast, false)::boolean AS fwd_channel_broadcast,
COALESCE(fwd_ch.megagroup, false)::boolean AS fwd_channel_megagroup,
COALESCE(fwd_ch.forum, false)::boolean AS fwd_channel_forum,
COALESCE(fwd_ch.noforwards, false)::boolean AS fwd_channel_noforwards,
COALESCE(fwd_ch.signatures, false)::boolean AS fwd_channel_signatures,
COALESCE(fwd_ch.pre_history_hidden, false)::boolean AS fwd_channel_pre_history_hidden,
COALESCE(fwd_ch.slowmode_seconds, 0)::int AS fwd_channel_slowmode_seconds,
COALESCE(fwd_ch.default_banned_rights::text, '{}')::text AS fwd_channel_default_banned_rights,
COALESCE(fwd_ch.participants_count, 0)::int AS fwd_channel_participants_count,
COALESCE(fwd_ch.admins_count, 0)::int AS fwd_channel_admins_count,
COALESCE(fwd_ch.kicked_count, 0)::int AS fwd_channel_kicked_count,
COALESCE(fwd_ch.banned_count, 0)::int AS fwd_channel_banned_count,
COALESCE(fwd_ch.top_message_id, 0)::int AS fwd_channel_top_message_id,
COALESCE(fwd_ch.pinned_message_id, 0)::int AS fwd_channel_pinned_message_id,
COALESCE(fwd_ch.pts, 0)::int AS fwd_channel_pts,
COALESCE(fwd_ch.ttl_period, 0)::int AS fwd_channel_ttl_period,
COALESCE(fwd_ch.date, 0)::int AS fwd_channel_date,
COALESCE(fwd_ch.deleted, false)::boolean AS fwd_channel_deleted,
COALESCE(reply_ch.id, 0)::bigint AS reply_channel_id,
COALESCE(reply_ch.access_hash, 0)::bigint AS reply_channel_access_hash,
COALESCE(reply_ch.creator_user_id, 0)::bigint AS reply_channel_creator_user_id,
COALESCE(reply_ch.title, '')::text AS reply_channel_title,
COALESCE(reply_ch.about, '')::text AS reply_channel_about,
COALESCE(reply_ch.username, '')::text AS reply_channel_username,
COALESCE(reply_ch.broadcast, false)::boolean AS reply_channel_broadcast,
COALESCE(reply_ch.megagroup, false)::boolean AS reply_channel_megagroup,
COALESCE(reply_ch.forum, false)::boolean AS reply_channel_forum,
COALESCE(reply_ch.noforwards, false)::boolean AS reply_channel_noforwards,
COALESCE(reply_ch.signatures, false)::boolean AS reply_channel_signatures,
COALESCE(reply_ch.pre_history_hidden, false)::boolean AS reply_channel_pre_history_hidden,
COALESCE(reply_ch.slowmode_seconds, 0)::int AS reply_channel_slowmode_seconds,
COALESCE(reply_ch.default_banned_rights::text, '{}')::text AS reply_channel_default_banned_rights,
COALESCE(reply_ch.participants_count, 0)::int AS reply_channel_participants_count,
COALESCE(reply_ch.admins_count, 0)::int AS reply_channel_admins_count,
COALESCE(reply_ch.kicked_count, 0)::int AS reply_channel_kicked_count,
COALESCE(reply_ch.banned_count, 0)::int AS reply_channel_banned_count,
COALESCE(reply_ch.top_message_id, 0)::int AS reply_channel_top_message_id,
COALESCE(reply_ch.pinned_message_id, 0)::int AS reply_channel_pinned_message_id,
COALESCE(reply_ch.pts, 0)::int AS reply_channel_pts,
COALESCE(reply_ch.ttl_period, 0)::int AS reply_channel_ttl_period,
COALESCE(reply_ch.date, 0)::int AS reply_channel_date,
COALESCE(reply_ch.deleted, false)::boolean AS reply_channel_deleted
FROM user_update_events e
LEFT JOIN message_boxes m ON m.owner_user_id = e.user_id AND m.box_id = e.message_box_id
LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id
LEFT JOIN users from_u ON from_u.id = m.from_user_id
LEFT JOIN users fwd_u ON m.fwd_from_peer_type = 'user' AND fwd_u.id = m.fwd_from_peer_id
LEFT JOIN users reply_u ON m.reply_to_peer_type = 'user' AND reply_u.id = m.reply_to_peer_id
LEFT JOIN channels fwd_ch ON m.fwd_from_peer_type = 'channel' AND fwd_ch.id = m.fwd_from_peer_id
LEFT JOIN channels reply_ch ON m.reply_to_peer_type = 'channel' AND reply_ch.id = m.reply_to_peer_id
WHERE e.user_id = $1
AND e.pts > $2
ORDER BY e.pts ASC
LIMIT sqlc.arg(limit_count);
-- name: MaxUserPts :one
SELECT COALESCE(MAX(pts), 0)::int AS max_pts
FROM user_update_events
WHERE user_id = $1;
-- name: RecentUserPts :many
-- 取某 user 最近的一段 pts降序供计算「最大连续已提交 pts」用。
-- 只看顶部窗口:瞬时空洞只可能出现在最近在途事务区,窗口足够大即可覆盖其下方连续。
SELECT pts, pts_count
FROM user_update_events
WHERE user_id = $1
ORDER BY pts DESC
LIMIT sqlc.arg(window_size);
-- name: EnsureUserUpdateWatermark :exec
INSERT INTO user_update_watermarks (user_id, contiguous_pts)
VALUES ($1, 0)
ON CONFLICT (user_id) DO NOTHING;
-- name: GetUserUpdateWatermark :one
SELECT contiguous_pts
FROM user_update_watermarks
WHERE user_id = $1;
-- name: LockUserUpdateWatermark :one
SELECT contiguous_pts
FROM user_update_watermarks
WHERE user_id = $1
FOR UPDATE;
-- name: NextUserPtsAfter :many
SELECT pts, pts_count
FROM user_update_events
WHERE user_id = $1
AND pts > $2
ORDER BY pts ASC
LIMIT sqlc.arg(limit_count);
-- name: SaveUserUpdateWatermark :exec
INSERT INTO user_update_watermarks (user_id, contiguous_pts, updated_at)
VALUES ($1, $2, now())
ON CONFLICT (user_id) DO UPDATE SET
contiguous_pts = GREATEST(user_update_watermarks.contiguous_pts, EXCLUDED.contiguous_pts),
updated_at = now();
-- name: EnqueueDispatch :exec
INSERT INTO dispatch_outbox (
target_user_id,
pts,
event_type,
exclude_auth_key_id,
exclude_session_id
) VALUES (
$1, $2, $3, $4, $5
)
ON CONFLICT DO NOTHING;
-- name: ClaimDispatchOutbox :many
WITH picked AS (
SELECT target_user_id, id
FROM dispatch_outbox
WHERE (
status = 'pending'
AND next_attempt_at <= now()
)
OR (
status = 'dispatching'
AND updated_at < now() - make_interval(secs => sqlc.arg(lease_seconds)::int)
)
ORDER BY next_attempt_at ASC, target_user_id ASC, id ASC
LIMIT sqlc.arg(limit_count)
FOR UPDATE SKIP LOCKED
)
UPDATE dispatch_outbox d
SET
status = 'dispatching',
attempts = d.attempts + 1,
updated_at = now()
FROM picked p
WHERE d.target_user_id = p.target_user_id
AND d.id = p.id
RETURNING
d.id,
d.target_user_id,
d.pts,
d.event_type,
d.exclude_auth_key_id,
d.exclude_session_id,
d.attempts;
-- name: MarkDispatchDelivered :exec
-- 方案 A投递成功即删除。outbox 是任务队列delivered 行无保留价值
-- (消息在 message_boxes、离线补偿在 user_update_events删除让表维持「未完成任务」小稳态。
DELETE FROM dispatch_outbox
WHERE target_user_id = $1
AND id = $2;
-- name: MarkDispatchFailed :exec
UPDATE dispatch_outbox
SET
status = CASE WHEN attempts >= 5 THEN 'failed' ELSE 'pending' END,
next_attempt_at = CASE
WHEN attempts >= 5 THEN next_attempt_at
ELSE now() + make_interval(secs => LEAST(60, attempts * attempts))
END,
last_error = $3,
updated_at = now()
WHERE target_user_id = $1
AND id = $2;
-- name: BatchListDispatchEvents :many
-- 按 (user_id, pts) 精确批量取账号事件,供 outbox worker 一次性加载一批 claim 的事件详情,
-- 取代逐条 ListUserUpdateEventsAfter。列与 ListUserUpdateEventsAfter 完全一致以复用转换逻辑。
SELECT
e.user_id,
e.pts,
e.pts_count,
e.date,
e.event_type,
e.event_bool,
COALESCE(e.event_peers::text, '[]')::text AS event_peers_json,
COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json,
COALESCE(e.message_ids::text, '[]')::text AS message_ids_json,
COALESCE(e.dialog_filter::text, '{}')::text AS dialog_filter_json,
COALESCE(e.filter_order::text, '[]')::text AS filter_order_json,
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
COALESCE(e.peer_type, '')::text AS event_peer_type,
COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
e.filter_id,
e.max_id,
e.still_unread_count,
e.tags_enabled,
COALESCE(m.box_id, 0)::int AS message_id,
COALESCE(m.private_message_id, 0)::bigint AS private_message_id,
COALESCE(m.owner_user_id, 0)::bigint AS owner_user_id,
COALESCE(m.peer_type, '')::text AS peer_type,
COALESCE(m.peer_id, 0)::bigint AS peer_id,
COALESCE(m.from_user_id, 0)::bigint AS from_user_id,
COALESCE(m.message_date, 0)::int AS message_date,
COALESCE(m.edit_date, 0)::int AS edit_date,
COALESCE(m.outgoing, false)::boolean AS outgoing,
COALESCE(m.body, '')::text AS body,
COALESCE(m.entities::text, '[]')::text AS message_entities_json,
COALESCE(m.silent, false)::boolean AS silent,
COALESCE(m.noforwards, false)::boolean AS noforwards,
COALESCE(m.reply_to_msg_id, 0)::int AS reply_to_msg_id,
COALESCE(m.reply_to_peer_type, '')::text AS reply_to_peer_type,
COALESCE(m.reply_to_peer_id, 0)::bigint AS reply_to_peer_id,
COALESCE(m.reply_to_top_id, 0)::int AS reply_to_top_id,
COALESCE(m.quote_text, '')::text AS quote_text,
COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json,
COALESCE(m.quote_offset, 0)::int AS quote_offset,
COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type,
COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id,
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
COALESCE(m.fwd_date, 0)::int AS fwd_date,
COALESCE(m.media::text, '{}')::text AS media_json,
COALESCE(peer_u.id, 0)::bigint AS peer_user_id,
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(peer_u.phone, '')::text AS peer_phone,
COALESCE(peer_u.first_name, '')::text AS peer_first_name,
COALESCE(peer_u.last_name, '')::text AS peer_last_name,
COALESCE(peer_u.username, '')::text AS peer_username,
COALESCE(peer_u.country_code, '')::text AS peer_country_code,
COALESCE(peer_u.verified, false)::boolean AS peer_verified,
COALESCE(peer_u.support, false)::boolean AS peer_support,
COALESCE(from_u.id, 0)::bigint AS from_user_user_id,
COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash,
COALESCE(from_u.phone, '')::text AS from_user_phone,
COALESCE(from_u.first_name, '')::text AS from_user_first_name,
COALESCE(from_u.last_name, '')::text AS from_user_last_name,
COALESCE(from_u.username, '')::text AS from_user_username,
COALESCE(from_u.country_code, '')::text AS from_user_country_code,
COALESCE(from_u.verified, false)::boolean AS from_user_verified,
COALESCE(from_u.support, false)::boolean AS from_user_support,
COALESCE(fwd_u.id, 0)::bigint AS fwd_user_id,
COALESCE(fwd_u.access_hash, 0)::bigint AS fwd_user_access_hash,
COALESCE(fwd_u.phone, '')::text AS fwd_user_phone,
COALESCE(fwd_u.first_name, '')::text AS fwd_user_first_name,
COALESCE(fwd_u.last_name, '')::text AS fwd_user_last_name,
COALESCE(fwd_u.username, '')::text AS fwd_user_username,
COALESCE(fwd_u.country_code, '')::text AS fwd_user_country_code,
COALESCE(fwd_u.verified, false)::boolean AS fwd_user_verified,
COALESCE(fwd_u.support, false)::boolean AS fwd_user_support,
COALESCE(reply_u.id, 0)::bigint AS reply_user_id,
COALESCE(reply_u.access_hash, 0)::bigint AS reply_user_access_hash,
COALESCE(reply_u.phone, '')::text AS reply_user_phone,
COALESCE(reply_u.first_name, '')::text AS reply_user_first_name,
COALESCE(reply_u.last_name, '')::text AS reply_user_last_name,
COALESCE(reply_u.username, '')::text AS reply_user_username,
COALESCE(reply_u.country_code, '')::text AS reply_user_country_code,
COALESCE(reply_u.verified, false)::boolean AS reply_user_verified,
COALESCE(reply_u.support, false)::boolean AS reply_user_support,
COALESCE(fwd_ch.id, 0)::bigint AS fwd_channel_id,
COALESCE(fwd_ch.access_hash, 0)::bigint AS fwd_channel_access_hash,
COALESCE(fwd_ch.creator_user_id, 0)::bigint AS fwd_channel_creator_user_id,
COALESCE(fwd_ch.title, '')::text AS fwd_channel_title,
COALESCE(fwd_ch.about, '')::text AS fwd_channel_about,
COALESCE(fwd_ch.username, '')::text AS fwd_channel_username,
COALESCE(fwd_ch.broadcast, false)::boolean AS fwd_channel_broadcast,
COALESCE(fwd_ch.megagroup, false)::boolean AS fwd_channel_megagroup,
COALESCE(fwd_ch.forum, false)::boolean AS fwd_channel_forum,
COALESCE(fwd_ch.noforwards, false)::boolean AS fwd_channel_noforwards,
COALESCE(fwd_ch.signatures, false)::boolean AS fwd_channel_signatures,
COALESCE(fwd_ch.pre_history_hidden, false)::boolean AS fwd_channel_pre_history_hidden,
COALESCE(fwd_ch.slowmode_seconds, 0)::int AS fwd_channel_slowmode_seconds,
COALESCE(fwd_ch.default_banned_rights::text, '{}')::text AS fwd_channel_default_banned_rights,
COALESCE(fwd_ch.participants_count, 0)::int AS fwd_channel_participants_count,
COALESCE(fwd_ch.admins_count, 0)::int AS fwd_channel_admins_count,
COALESCE(fwd_ch.kicked_count, 0)::int AS fwd_channel_kicked_count,
COALESCE(fwd_ch.banned_count, 0)::int AS fwd_channel_banned_count,
COALESCE(fwd_ch.top_message_id, 0)::int AS fwd_channel_top_message_id,
COALESCE(fwd_ch.pinned_message_id, 0)::int AS fwd_channel_pinned_message_id,
COALESCE(fwd_ch.pts, 0)::int AS fwd_channel_pts,
COALESCE(fwd_ch.ttl_period, 0)::int AS fwd_channel_ttl_period,
COALESCE(fwd_ch.date, 0)::int AS fwd_channel_date,
COALESCE(fwd_ch.deleted, false)::boolean AS fwd_channel_deleted,
COALESCE(reply_ch.id, 0)::bigint AS reply_channel_id,
COALESCE(reply_ch.access_hash, 0)::bigint AS reply_channel_access_hash,
COALESCE(reply_ch.creator_user_id, 0)::bigint AS reply_channel_creator_user_id,
COALESCE(reply_ch.title, '')::text AS reply_channel_title,
COALESCE(reply_ch.about, '')::text AS reply_channel_about,
COALESCE(reply_ch.username, '')::text AS reply_channel_username,
COALESCE(reply_ch.broadcast, false)::boolean AS reply_channel_broadcast,
COALESCE(reply_ch.megagroup, false)::boolean AS reply_channel_megagroup,
COALESCE(reply_ch.forum, false)::boolean AS reply_channel_forum,
COALESCE(reply_ch.noforwards, false)::boolean AS reply_channel_noforwards,
COALESCE(reply_ch.signatures, false)::boolean AS reply_channel_signatures,
COALESCE(reply_ch.pre_history_hidden, false)::boolean AS reply_channel_pre_history_hidden,
COALESCE(reply_ch.slowmode_seconds, 0)::int AS reply_channel_slowmode_seconds,
COALESCE(reply_ch.default_banned_rights::text, '{}')::text AS reply_channel_default_banned_rights,
COALESCE(reply_ch.participants_count, 0)::int AS reply_channel_participants_count,
COALESCE(reply_ch.admins_count, 0)::int AS reply_channel_admins_count,
COALESCE(reply_ch.kicked_count, 0)::int AS reply_channel_kicked_count,
COALESCE(reply_ch.banned_count, 0)::int AS reply_channel_banned_count,
COALESCE(reply_ch.top_message_id, 0)::int AS reply_channel_top_message_id,
COALESCE(reply_ch.pinned_message_id, 0)::int AS reply_channel_pinned_message_id,
COALESCE(reply_ch.pts, 0)::int AS reply_channel_pts,
COALESCE(reply_ch.ttl_period, 0)::int AS reply_channel_ttl_period,
COALESCE(reply_ch.date, 0)::int AS reply_channel_date,
COALESCE(reply_ch.deleted, false)::boolean AS reply_channel_deleted
FROM unnest(@user_ids::bigint[]) WITH ORDINALITY AS u(user_id, ord)
JOIN unnest(@pts_list::int[]) WITH ORDINALITY AS p(pts, ord) USING (ord)
JOIN user_update_events e ON e.user_id = u.user_id AND e.pts = p.pts
LEFT JOIN message_boxes m ON m.owner_user_id = e.user_id AND m.box_id = e.message_box_id
LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id
LEFT JOIN users from_u ON from_u.id = m.from_user_id
LEFT JOIN users fwd_u ON m.fwd_from_peer_type = 'user' AND fwd_u.id = m.fwd_from_peer_id
LEFT JOIN users reply_u ON m.reply_to_peer_type = 'user' AND reply_u.id = m.reply_to_peer_id
LEFT JOIN channels fwd_ch ON m.fwd_from_peer_type = 'channel' AND fwd_ch.id = m.fwd_from_peer_id
LEFT JOIN channels reply_ch ON m.reply_to_peer_type = 'channel' AND reply_ch.id = m.reply_to_peer_id;
-- name: MarkDispatchDeliveredBatch :exec
-- 批量删除一批已投递的 (target_user_id, id)target_user_id 入 WHERE 保证分区裁剪。
DELETE FROM dispatch_outbox d
USING unnest(@target_user_ids::bigint[]) WITH ORDINALITY AS tu(target_user_id, ord)
JOIN unnest(@ids::bigint[]) WITH ORDINALITY AS di(id, ord) USING (ord)
WHERE d.target_user_id = tu.target_user_id
AND d.id = di.id;
-- name: DeleteFailedDispatchOutbox :one
WITH doomed AS (
SELECT target_user_id, id
FROM dispatch_outbox
WHERE status = 'failed'
AND updated_at < now() - make_interval(secs => sqlc.arg(older_than_seconds)::int)
ORDER BY updated_at ASC, target_user_id ASC, id ASC
LIMIT sqlc.arg(limit_count)
),
deleted AS (
DELETE FROM dispatch_outbox d
USING doomed x
WHERE d.target_user_id = x.target_user_id
AND d.id = x.id
RETURNING d.id
)
SELECT count(*)::int AS deleted_count
FROM deleted;

View file

@ -0,0 +1,87 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: account.sql
package sqlcgen
import (
"context"
)
const getPasswordByUser = `-- name: GetPasswordByUser :one
SELECT
user_id, has_recovery, has_secure_values, has_password, hint,
email_unconfirmed_pattern, login_email_pattern, secure_random
FROM account_passwords
WHERE user_id = $1
`
type GetPasswordByUserRow struct {
UserID int64
HasRecovery bool
HasSecureValues bool
HasPassword bool
Hint string
EmailUnconfirmedPattern string
LoginEmailPattern string
SecureRandom []byte
}
func (q *Queries) GetPasswordByUser(ctx context.Context, userID int64) (GetPasswordByUserRow, error) {
row := q.db.QueryRow(ctx, getPasswordByUser, userID)
var i GetPasswordByUserRow
err := row.Scan(
&i.UserID,
&i.HasRecovery,
&i.HasSecureValues,
&i.HasPassword,
&i.Hint,
&i.EmailUnconfirmedPattern,
&i.LoginEmailPattern,
&i.SecureRandom,
)
return i, err
}
const upsertPassword = `-- name: UpsertPassword :exec
INSERT INTO account_passwords (
user_id, has_recovery, has_secure_values, has_password, hint,
email_unconfirmed_pattern, login_email_pattern, secure_random
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (user_id) DO UPDATE SET
has_recovery = EXCLUDED.has_recovery,
has_secure_values = EXCLUDED.has_secure_values,
has_password = EXCLUDED.has_password,
hint = EXCLUDED.hint,
email_unconfirmed_pattern = EXCLUDED.email_unconfirmed_pattern,
login_email_pattern = EXCLUDED.login_email_pattern,
secure_random = EXCLUDED.secure_random,
updated_at = now()
`
type UpsertPasswordParams struct {
UserID int64
HasRecovery bool
HasSecureValues bool
HasPassword bool
Hint string
EmailUnconfirmedPattern string
LoginEmailPattern string
SecureRandom []byte
}
func (q *Queries) UpsertPassword(ctx context.Context, arg UpsertPasswordParams) error {
_, err := q.db.Exec(ctx, upsertPassword,
arg.UserID,
arg.HasRecovery,
arg.HasSecureValues,
arg.HasPassword,
arg.Hint,
arg.EmailUnconfirmedPattern,
arg.LoginEmailPattern,
arg.SecureRandom,
)
return err
}

View file

@ -0,0 +1,46 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: authkey.sql
package sqlcgen
import (
"context"
)
const getAuthKey = `-- name: GetAuthKey :one
SELECT auth_key_id, body, server_salt, created_at
FROM auth_keys
WHERE auth_key_id = $1
`
func (q *Queries) GetAuthKey(ctx context.Context, authKeyID int64) (AuthKey, error) {
row := q.db.QueryRow(ctx, getAuthKey, authKeyID)
var i AuthKey
err := row.Scan(
&i.AuthKeyID,
&i.Body,
&i.ServerSalt,
&i.CreatedAt,
)
return i, err
}
const upsertAuthKey = `-- name: UpsertAuthKey :exec
INSERT INTO auth_keys (auth_key_id, body, server_salt)
VALUES ($1, $2, $3)
ON CONFLICT (auth_key_id) DO UPDATE
SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt
`
type UpsertAuthKeyParams struct {
AuthKeyID int64
Body []byte
ServerSalt int64
}
func (q *Queries) UpsertAuthKey(ctx context.Context, arg UpsertAuthKeyParams) error {
_, err := q.db.Exec(ctx, upsertAuthKey, arg.AuthKeyID, arg.Body, arg.ServerSalt)
return err
}

View file

@ -0,0 +1,124 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: authorization.sql
package sqlcgen
import (
"context"
)
const deleteAuthorization = `-- name: DeleteAuthorization :exec
DELETE FROM authorizations WHERE auth_key_id = $1
`
func (q *Queries) DeleteAuthorization(ctx context.Context, authKeyID int64) error {
_, err := q.db.Exec(ctx, deleteAuthorization, authKeyID)
return err
}
const getAuthorizationByAuthKey = `-- name: GetAuthorizationByAuthKey :one
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, created_at, active_at FROM authorizations WHERE auth_key_id = $1
`
func (q *Queries) GetAuthorizationByAuthKey(ctx context.Context, authKeyID int64) (Authorization, error) {
row := q.db.QueryRow(ctx, getAuthorizationByAuthKey, authKeyID)
var i Authorization
err := row.Scan(
&i.AuthKeyID,
&i.UserID,
&i.Hash,
&i.Layer,
&i.DeviceModel,
&i.Platform,
&i.SystemVersion,
&i.ApiID,
&i.AppVersion,
&i.Ip,
&i.CreatedAt,
&i.ActiveAt,
)
return i, err
}
const listAuthorizationsByUser = `-- name: ListAuthorizationsByUser :many
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, created_at, active_at FROM authorizations
WHERE user_id = $1
ORDER BY active_at DESC, auth_key_id DESC
`
func (q *Queries) ListAuthorizationsByUser(ctx context.Context, userID int64) ([]Authorization, error) {
rows, err := q.db.Query(ctx, listAuthorizationsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Authorization
for rows.Next() {
var i Authorization
if err := rows.Scan(
&i.AuthKeyID,
&i.UserID,
&i.Hash,
&i.Layer,
&i.DeviceModel,
&i.Platform,
&i.SystemVersion,
&i.ApiID,
&i.AppVersion,
&i.Ip,
&i.CreatedAt,
&i.ActiveAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertAuthorization = `-- name: UpsertAuthorization :exec
INSERT INTO authorizations (auth_key_id, user_id, layer, device_model, platform, system_version, api_id, app_version, ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (auth_key_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
layer = EXCLUDED.layer,
device_model = EXCLUDED.device_model,
platform = EXCLUDED.platform,
system_version = EXCLUDED.system_version,
api_id = EXCLUDED.api_id,
app_version = EXCLUDED.app_version,
ip = EXCLUDED.ip,
active_at = now()
`
type UpsertAuthorizationParams struct {
AuthKeyID int64
UserID int64
Layer int32
DeviceModel string
Platform string
SystemVersion string
ApiID int32
AppVersion string
Ip string
}
func (q *Queries) UpsertAuthorization(ctx context.Context, arg UpsertAuthorizationParams) error {
_, err := q.db.Exec(ctx, upsertAuthorization,
arg.AuthKeyID,
arg.UserID,
arg.Layer,
arg.DeviceModel,
arg.Platform,
arg.SystemVersion,
arg.ApiID,
arg.AppVersion,
arg.Ip,
)
return err
}

View file

@ -0,0 +1,426 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: contact.sql
package sqlcgen
import (
"context"
)
const deleteContacts = `-- name: DeleteContacts :one
WITH deleted AS (
DELETE FROM contacts
WHERE user_id = $1::bigint
AND contact_user_id = ANY($2::bigint[])
RETURNING contact_user_id
),
reverse_updated AS (
UPDATE contacts c
SET mutual = false,
updated_at = now()
FROM deleted d
WHERE c.user_id = d.contact_user_id
AND c.contact_user_id = $1::bigint
RETURNING c.user_id
)
SELECT COUNT(*)::int AS deleted_count
FROM deleted
`
type DeleteContactsParams struct {
UserID int64
ContactUserIds []int64
}
func (q *Queries) DeleteContacts(ctx context.Context, arg DeleteContactsParams) (int32, error) {
row := q.db.QueryRow(ctx, deleteContacts, arg.UserID, arg.ContactUserIds)
var deleted_count int32
err := row.Scan(&deleted_count)
return deleted_count, err
}
const getContact = `-- name: GetContact :one
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
WHERE c.user_id = $1
AND c.contact_user_id = $2
`
type GetContactParams struct {
UserID int64
ContactUserID int64
}
type GetContactRow struct {
ContactUserID int64
Mutual bool
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntitiesJson string
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
Verified bool
Support bool
LastSeenAt int64
}
func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetContactRow, error) {
row := q.db.QueryRow(ctx, getContact, arg.UserID, arg.ContactUserID)
var i GetContactRow
err := row.Scan(
&i.ContactUserID,
&i.Mutual,
&i.ContactPhone,
&i.ContactFirstName,
&i.ContactLastName,
&i.Note,
&i.NoteEntitiesJson,
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.Verified,
&i.Support,
&i.LastSeenAt,
)
return i, err
}
const listContactsByUser = `-- name: ListContactsByUser :many
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
WHERE c.user_id = $1
ORDER BY c.contact_first_name, c.contact_last_name, u.first_name, u.last_name, u.id
`
type ListContactsByUserRow struct {
ContactUserID int64
Mutual bool
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntitiesJson string
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
Verified bool
Support bool
LastSeenAt int64
}
func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListContactsByUserRow, error) {
rows, err := q.db.Query(ctx, listContactsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListContactsByUserRow
for rows.Next() {
var i ListContactsByUserRow
if err := rows.Scan(
&i.ContactUserID,
&i.Mutual,
&i.ContactPhone,
&i.ContactFirstName,
&i.ContactLastName,
&i.Note,
&i.NoteEntitiesJson,
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.Verified,
&i.Support,
&i.LastSeenAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateContactNote = `-- name: UpdateContactNote :one
WITH updated AS (
UPDATE contacts c
SET note = $1::text,
note_entities = $2::jsonb,
updated_at = now()
WHERE c.user_id = $3::bigint
AND c.contact_user_id = $4::bigint
RETURNING user_id, contact_user_id, mutual, created_at, updated_at, contact_phone, contact_first_name, contact_last_name, note, note_entities, close_friend, stories_hidden
)
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at
FROM updated c
JOIN users u ON u.id = c.contact_user_id
`
type UpdateContactNoteParams struct {
Note string
NoteEntities []byte
UserID int64
ContactUserID int64
}
type UpdateContactNoteRow struct {
ContactUserID int64
Mutual bool
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntitiesJson string
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
Verified bool
Support bool
LastSeenAt int64
}
func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNoteParams) (UpdateContactNoteRow, error) {
row := q.db.QueryRow(ctx, updateContactNote,
arg.Note,
arg.NoteEntities,
arg.UserID,
arg.ContactUserID,
)
var i UpdateContactNoteRow
err := row.Scan(
&i.ContactUserID,
&i.Mutual,
&i.ContactPhone,
&i.ContactFirstName,
&i.ContactLastName,
&i.Note,
&i.NoteEntitiesJson,
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.Verified,
&i.Support,
&i.LastSeenAt,
)
return i, err
}
const upsertContact = `-- name: UpsertContact :one
WITH reverse AS (
SELECT EXISTS (
SELECT 1
FROM contacts
WHERE user_id = $1::bigint
AND contact_user_id = $2::bigint
)::boolean AS mutual
),
upserted AS (
INSERT INTO contacts (
user_id,
contact_user_id,
contact_phone,
contact_first_name,
contact_last_name,
note,
note_entities,
mutual
)
SELECT
$2::bigint,
$1::bigint,
$3::text,
$4::text,
$5::text,
$6::text,
$7::jsonb,
reverse.mutual
FROM reverse
ON CONFLICT (user_id, contact_user_id) DO UPDATE SET
contact_phone = EXCLUDED.contact_phone,
contact_first_name = EXCLUDED.contact_first_name,
contact_last_name = EXCLUDED.contact_last_name,
note = EXCLUDED.note,
note_entities = EXCLUDED.note_entities,
mutual = contacts.mutual OR EXCLUDED.mutual,
updated_at = now()
RETURNING user_id, contact_user_id, mutual, created_at, updated_at, contact_phone, contact_first_name, contact_last_name, note, note_entities, close_friend, stories_hidden
),
reverse_updated AS (
UPDATE contacts c
SET mutual = true,
updated_at = now()
WHERE c.user_id = $1::bigint
AND c.contact_user_id = $2::bigint
AND NOT c.mutual
RETURNING c.user_id
)
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at,
EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed
FROM upserted c
JOIN users u ON u.id = c.contact_user_id
`
type UpsertContactParams struct {
ContactUserID int64
UserID int64
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntities []byte
}
type UpsertContactRow struct {
ContactUserID int64
Mutual bool
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntitiesJson string
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
Verified bool
Support bool
LastSeenAt int64
ReverseMutualChanged bool
}
func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (UpsertContactRow, error) {
row := q.db.QueryRow(ctx, upsertContact,
arg.ContactUserID,
arg.UserID,
arg.ContactPhone,
arg.ContactFirstName,
arg.ContactLastName,
arg.Note,
arg.NoteEntities,
)
var i UpsertContactRow
err := row.Scan(
&i.ContactUserID,
&i.Mutual,
&i.ContactPhone,
&i.ContactFirstName,
&i.ContactLastName,
&i.Note,
&i.NoteEntitiesJson,
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.Verified,
&i.Support,
&i.LastSeenAt,
&i.ReverseMutualChanged,
)
return i, err
}

View file

@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package sqlcgen
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,159 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: help.sql
package sqlcgen
import (
"context"
)
const getAppConfig = `-- name: GetAppConfig :one
SELECT client, hash, config_json::text AS config_json
FROM app_configs
WHERE client = $1
`
type GetAppConfigRow struct {
Client string
Hash int32
ConfigJson string
}
func (q *Queries) GetAppConfig(ctx context.Context, client string) (GetAppConfigRow, error) {
row := q.db.QueryRow(ctx, getAppConfig, client)
var i GetAppConfigRow
err := row.Scan(&i.Client, &i.Hash, &i.ConfigJson)
return i, err
}
const listCountries = `-- name: ListCountries :many
SELECT
c.iso2,
c.default_name,
c.name,
c.hidden,
cc.country_code,
cc.prefixes,
cc.patterns
FROM countries c
JOIN country_codes cc ON cc.iso2 = c.iso2
ORDER BY c.order_index, c.iso2, cc.order_index, cc.country_code
`
type ListCountriesRow struct {
Iso2 string
DefaultName string
Name string
Hidden bool
CountryCode string
Prefixes []string
Patterns []string
}
func (q *Queries) ListCountries(ctx context.Context) ([]ListCountriesRow, error) {
rows, err := q.db.Query(ctx, listCountries)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListCountriesRow
for rows.Next() {
var i ListCountriesRow
if err := rows.Scan(
&i.Iso2,
&i.DefaultName,
&i.Name,
&i.Hidden,
&i.CountryCode,
&i.Prefixes,
&i.Patterns,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertAppConfig = `-- name: UpsertAppConfig :exec
INSERT INTO app_configs (client, hash, config_json)
VALUES ($1, $2, $3::jsonb)
ON CONFLICT (client) DO UPDATE SET
hash = EXCLUDED.hash,
config_json = EXCLUDED.config_json,
updated_at = now()
`
type UpsertAppConfigParams struct {
Client string
Hash int32
ConfigJson []byte
}
func (q *Queries) UpsertAppConfig(ctx context.Context, arg UpsertAppConfigParams) error {
_, err := q.db.Exec(ctx, upsertAppConfig, arg.Client, arg.Hash, arg.ConfigJson)
return err
}
const upsertCountry = `-- name: UpsertCountry :exec
INSERT INTO countries (iso2, default_name, name, hidden, order_index)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (iso2) DO UPDATE SET
default_name = EXCLUDED.default_name,
name = EXCLUDED.name,
hidden = EXCLUDED.hidden,
order_index = EXCLUDED.order_index,
updated_at = now()
`
type UpsertCountryParams struct {
Iso2 string
DefaultName string
Name string
Hidden bool
OrderIndex int32
}
func (q *Queries) UpsertCountry(ctx context.Context, arg UpsertCountryParams) error {
_, err := q.db.Exec(ctx, upsertCountry,
arg.Iso2,
arg.DefaultName,
arg.Name,
arg.Hidden,
arg.OrderIndex,
)
return err
}
const upsertCountryCode = `-- name: UpsertCountryCode :exec
INSERT INTO country_codes (iso2, country_code, prefixes, patterns, order_index)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (iso2, country_code) DO UPDATE SET
prefixes = EXCLUDED.prefixes,
patterns = EXCLUDED.patterns,
order_index = EXCLUDED.order_index
`
type UpsertCountryCodeParams struct {
Iso2 string
CountryCode string
Prefixes []string
Patterns []string
OrderIndex int32
}
func (q *Queries) UpsertCountryCode(ctx context.Context, arg UpsertCountryCodeParams) error {
_, err := q.db.Exec(ctx, upsertCountryCode,
arg.Iso2,
arg.CountryCode,
arg.Prefixes,
arg.Patterns,
arg.OrderIndex,
)
return err
}

View file

@ -0,0 +1,250 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: langpack.sql
package sqlcgen
import (
"context"
)
const getLangPackMeta = `-- name: GetLangPackMeta :one
SELECT lang_pack, lang_code, version, strings_count
FROM lang_packs
WHERE lang_pack = $1 AND lang_code = $2
`
type GetLangPackMetaParams struct {
LangPack string
LangCode string
}
type GetLangPackMetaRow struct {
LangPack string
LangCode string
Version int32
StringsCount int32
}
func (q *Queries) GetLangPackMeta(ctx context.Context, arg GetLangPackMetaParams) (GetLangPackMetaRow, error) {
row := q.db.QueryRow(ctx, getLangPackMeta, arg.LangPack, arg.LangCode)
var i GetLangPackMetaRow
err := row.Scan(
&i.LangPack,
&i.LangCode,
&i.Version,
&i.StringsCount,
)
return i, err
}
const getLangPackStringsByKeys = `-- name: GetLangPackStringsByKeys :many
SELECT
lang_pack, lang_code, key, version, pluralized, value,
zero_value, one_value, two_value, few_value, many_value, other_value, deleted
FROM lang_pack_strings
WHERE lang_pack = $1 AND lang_code = $2 AND key = ANY($3::text[]) AND NOT deleted
ORDER BY key
`
type GetLangPackStringsByKeysParams struct {
LangPack string
LangCode string
Keys []string
}
type GetLangPackStringsByKeysRow struct {
LangPack string
LangCode string
Key string
Version int32
Pluralized bool
Value string
ZeroValue string
OneValue string
TwoValue string
FewValue string
ManyValue string
OtherValue string
Deleted bool
}
func (q *Queries) GetLangPackStringsByKeys(ctx context.Context, arg GetLangPackStringsByKeysParams) ([]GetLangPackStringsByKeysRow, error) {
rows, err := q.db.Query(ctx, getLangPackStringsByKeys, arg.LangPack, arg.LangCode, arg.Keys)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetLangPackStringsByKeysRow
for rows.Next() {
var i GetLangPackStringsByKeysRow
if err := rows.Scan(
&i.LangPack,
&i.LangCode,
&i.Key,
&i.Version,
&i.Pluralized,
&i.Value,
&i.ZeroValue,
&i.OneValue,
&i.TwoValue,
&i.FewValue,
&i.ManyValue,
&i.OtherValue,
&i.Deleted,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listLangPackStrings = `-- name: ListLangPackStrings :many
SELECT
lang_pack, lang_code, key, version, pluralized, value,
zero_value, one_value, two_value, few_value, many_value, other_value, deleted
FROM lang_pack_strings
WHERE lang_pack = $1 AND lang_code = $2 AND NOT deleted
ORDER BY key
`
type ListLangPackStringsParams struct {
LangPack string
LangCode string
}
type ListLangPackStringsRow struct {
LangPack string
LangCode string
Key string
Version int32
Pluralized bool
Value string
ZeroValue string
OneValue string
TwoValue string
FewValue string
ManyValue string
OtherValue string
Deleted bool
}
func (q *Queries) ListLangPackStrings(ctx context.Context, arg ListLangPackStringsParams) ([]ListLangPackStringsRow, error) {
rows, err := q.db.Query(ctx, listLangPackStrings, arg.LangPack, arg.LangCode)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLangPackStringsRow
for rows.Next() {
var i ListLangPackStringsRow
if err := rows.Scan(
&i.LangPack,
&i.LangCode,
&i.Key,
&i.Version,
&i.Pluralized,
&i.Value,
&i.ZeroValue,
&i.OneValue,
&i.TwoValue,
&i.FewValue,
&i.ManyValue,
&i.OtherValue,
&i.Deleted,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertLangPackMeta = `-- name: UpsertLangPackMeta :exec
INSERT INTO lang_packs (lang_pack, lang_code, version, strings_count)
VALUES ($1, $2, $3, $4)
ON CONFLICT (lang_pack, lang_code) DO UPDATE SET
version = EXCLUDED.version,
strings_count = EXCLUDED.strings_count,
updated_at = now()
`
type UpsertLangPackMetaParams struct {
LangPack string
LangCode string
Version int32
StringsCount int32
}
func (q *Queries) UpsertLangPackMeta(ctx context.Context, arg UpsertLangPackMetaParams) error {
_, err := q.db.Exec(ctx, upsertLangPackMeta,
arg.LangPack,
arg.LangCode,
arg.Version,
arg.StringsCount,
)
return err
}
const upsertLangPackString = `-- name: UpsertLangPackString :exec
INSERT INTO lang_pack_strings (
lang_pack, lang_code, key, version, pluralized, value,
zero_value, one_value, two_value, few_value, many_value, other_value, deleted
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (lang_pack, lang_code, key) DO UPDATE SET
version = EXCLUDED.version,
pluralized = EXCLUDED.pluralized,
value = EXCLUDED.value,
zero_value = EXCLUDED.zero_value,
one_value = EXCLUDED.one_value,
two_value = EXCLUDED.two_value,
few_value = EXCLUDED.few_value,
many_value = EXCLUDED.many_value,
other_value = EXCLUDED.other_value,
deleted = EXCLUDED.deleted,
updated_at = now()
`
type UpsertLangPackStringParams struct {
LangPack string
LangCode string
Key string
Version int32
Pluralized bool
Value string
ZeroValue string
OneValue string
TwoValue string
FewValue string
ManyValue string
OtherValue string
Deleted bool
}
func (q *Queries) UpsertLangPackString(ctx context.Context, arg UpsertLangPackStringParams) error {
_, err := q.db.Exec(ctx, upsertLangPackString,
arg.LangPack,
arg.LangCode,
arg.Key,
arg.Version,
arg.Pluralized,
arg.Value,
arg.ZeroValue,
arg.OneValue,
arg.TwoValue,
arg.FewValue,
arg.ManyValue,
arg.OtherValue,
arg.Deleted,
)
return err
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,681 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package sqlcgen
import (
"github.com/jackc/pgx/v5/pgtype"
)
type AccountPassword struct {
UserID int64
HasRecovery bool
HasSecureValues bool
HasPassword bool
Hint string
EmailUnconfirmedPattern string
LoginEmailPattern string
SecureRandom []byte
UpdatedAt pgtype.Timestamptz
}
type AppConfig struct {
Client string
Hash int32
ConfigJson []byte
UpdatedAt pgtype.Timestamptz
}
type AuthKey struct {
AuthKeyID int64
Body []byte
ServerSalt int64
CreatedAt pgtype.Timestamptz
}
type Authorization struct {
AuthKeyID int64
UserID int64
Hash int64
Layer int32
DeviceModel string
Platform string
SystemVersion string
ApiID int32
AppVersion string
Ip string
CreatedAt pgtype.Timestamptz
ActiveAt pgtype.Timestamptz
}
type AvailableReaction struct {
Reaction string
Title string
Inactive bool
Premium bool
StaticIconID int64
AppearAnimationID int64
SelectAnimationID int64
ActivateAnimationID int64
EffectAnimationID int64
AroundAnimationID int64
CenterIconID int64
SortOrder int32
}
type Channel struct {
ID int64
AccessHash int64
CreatorUserID int64
Title string
About string
Username *string
Broadcast bool
Megagroup bool
Forum bool
ForumTabs bool
Noforwards bool
JoinToSend bool
JoinRequest bool
Signatures bool
PreHistoryHidden bool
ParticipantsHidden bool
Antispam bool
LinkedChatID int64
SlowmodeSeconds int32
DefaultBannedRights []byte
AvailableReactions []byte
ColorSet bool
Color int32
ColorBackgroundEmojiID int64
ProfileColorSet bool
ProfileColor int32
ProfileColorBackgroundEmojiID int64
EmojiStatusDocumentID int64
EmojiStatusUntil int32
ParticipantsCount int32
AdminsCount int32
KickedCount int32
BannedCount int32
TopMessageID int32
Pts int32
AdminLogSeq int64
TtlPeriod int32
Date int32
Deleted bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
PinnedMessageID int32
Autotranslation bool
RestrictedSponsored bool
BroadcastMessagesAllowed bool
SendPaidMessagesStars int64
PhotoID int64
PhotoDcID int32
PhotoStripped []byte
}
type ChannelAdminLogEvent struct {
ChannelID int64
ID int64
ActorUserID int64
EventDate int32
EventType string
PrevString string
NewString string
PrevBool bool
NewBool bool
PrevInt int32
NewInt int32
PrevParticipant []byte
NewParticipant []byte
Participant []byte
Message []byte
PrevMessage []byte
NewMessage []byte
Query string
CreatedAt pgtype.Timestamptz
}
type ChannelDialog struct {
UserID int64
ChannelID int64
FolderID int32
TopMessageID int32
TopMessageDate int32
ReadInboxMaxID int32
ReadOutboxMaxID int32
UnreadCount int32
UnreadMentionsCount int32
UnreadReactionsCount int32
Pinned bool
PinnedOrder int32
UnreadMark bool
ViewForumAsMessages bool
NotifySettings []byte
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
DefaultSendAsPeerType *string
DefaultSendAsPeerID *int64
}
type ChannelForumTopic struct {
ChannelID int64
TopicID int32
CreatorUserID int64
Title string
IconColor int32
IconEmojiID int64
TitleMissing bool
Closed bool
Hidden bool
Pinned bool
PinnedOrder int32
Date int32
TopMessageID int32
ReadInboxMaxID int32
ReadOutboxMaxID int32
UnreadCount int32
UnreadMentionsCount int32
UnreadReactionsCount int32
UnreadPollVotesCount int32
Deleted bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type ChannelInvite struct {
ChannelID int64
InviteID int64
Hash string
AdminUserID int64
Title string
Permanent bool
Revoked bool
RequestNeeded bool
ExpireDate *int32
UsageLimit *int32
UsageCount int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
RequestedCount int32
}
type ChannelInviteHash struct {
Hash string
ChannelID int64
InviteID int64
UpdatedAt pgtype.Timestamptz
}
type ChannelInviteImporter struct {
ChannelID int64
InviteID int64
UserID int64
Date int32
Requested bool
ApprovedBy int64
ViaChatlist bool
About string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type ChannelMember struct {
ChannelID int64
UserID int64
InviterUserID int64
Role string
Status string
JoinedAt int32
LeftAt int32
AdminRights []byte
BannedRights []byte
Rank string
AvailableMinID int32
AvailableMinPts int32
ReadInboxMaxID int32
ReadInboxDate int32
ReadOutboxMaxID int32
UnreadMark bool
SlowmodeLastSendDate int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type ChannelMessage struct {
ChannelID int64
ID int32
RandomID int64
SenderUserID int64
FromPeerType string
FromPeerID int64
SendAsPeerType *string
SendAsPeerID *int64
MessageDate int32
EditDate int32
Post bool
Silent bool
Noforwards bool
Body string
Entities []byte
ReplyTo []byte
ReplyToMsgID int32
ReplyToPeerType string
ReplyToPeerID int64
ReplyToTopID int32
FwdFrom []byte
DiscussionChannelID int64
DiscussionMessageID int32
Action []byte
Pts int32
Deleted bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
ViewsCount int32
Media []byte
}
type ChannelMessageReaction struct {
ChannelID int64
MessageID int32
ReactedUserID int64
SenderUserID int64
ReactionType string
ReactionValue string
Big bool
Unread bool
ChosenOrder int32
ReactionDate int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type ChannelMessageViewer struct {
ChannelID int64
MessageID int32
ViewerUserID int64
ViewedAt int32
CreatedAt pgtype.Timestamptz
}
type ChannelUnreadMention struct {
UserID int64
ChannelID int64
MessageID int32
TopMessageID int32
CreatedAt pgtype.Timestamptz
}
type ChannelUpdateEvent struct {
ChannelID int64
Pts int32
PtsCount int32
Date int32
EventType string
MessageID int32
MessageIds []byte
SenderUserID int64
UserIds []byte
Payload []byte
CreatedAt pgtype.Timestamptz
}
type ChannelUsername struct {
UsernameLower string
ChannelID int64
UpdatedAt pgtype.Timestamptz
}
type Contact struct {
UserID int64
ContactUserID int64
Mutual bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntities []byte
CloseFriend bool
StoriesHidden bool
}
type Country struct {
Iso2 string
DefaultName string
Name string
Hidden bool
OrderIndex int32
UpdatedAt pgtype.Timestamptz
}
type CountryCode struct {
ID int64
Iso2 string
CountryCode string
Prefixes []string
Patterns []string
OrderIndex int32
}
type Dialog struct {
UserID int64
PeerType string
PeerID int64
TopMessageID int32
TopMessageDate int32
ReadInboxMaxID int32
ReadOutboxMaxID int32
UnreadCount int32
UnreadMentionsCount int32
UnreadReactionsCount int32
Pinned bool
UpdatedAt pgtype.Timestamptz
PinnedOrder int32
UnreadMark bool
HiddenPeerSettingsBar bool
FolderID int32
}
type DialogDraft struct {
UserID int64
PeerType string
PeerID int64
TopMessageID int32
Date int32
Draft []byte
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type DialogFilter struct {
UserID int64
FilterID int32
IsChatlist bool
Filter []byte
OrderValue int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type DialogFilterSetting struct {
UserID int64
TagsEnabled bool
UpdatedAt pgtype.Timestamptz
}
type DispatchOutbox struct {
ID int64
TargetUserID int64
Pts int32
EventType string
ExcludeSessionID int64
Status string
Attempts int32
NextAttemptAt pgtype.Timestamptz
LastError string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
ExcludeAuthKeyID int64
}
type Document struct {
ID int64
AccessHash int64
FileReference []byte
Date int32
MimeType string
Size int64
DcID int32
Attributes []byte
Thumbs []byte
CreatedAt pgtype.Timestamptz
}
type FileBlob struct {
LocationKey string
Backend string
ObjectKey string
Size int64
Sha256 []byte
MimeType string
CreatedAt pgtype.Timestamptz
}
type LangPack struct {
LangPack string
LangCode string
Version int32
StringsCount int32
UpdatedAt pgtype.Timestamptz
}
type LangPackString struct {
LangPack string
LangCode string
Key string
Version int32
Pluralized bool
Value string
ZeroValue string
OneValue string
TwoValue string
FewValue string
ManyValue string
OtherValue string
Deleted bool
UpdatedAt pgtype.Timestamptz
}
type MessageBox struct {
OwnerUserID int64
BoxID int32
PrivateMessageID int64
MessageSenderID int64
PeerType string
PeerID int64
FromUserID int64
MessageDate int32
Outgoing bool
Body string
Entities []byte
Pts int32
Deleted bool
CreatedAt pgtype.Timestamptz
EditDate int32
Silent bool
Noforwards bool
ReplyToMsgID int32
ReplyToPeerType string
ReplyToPeerID int64
ReplyToTopID int32
QuoteText string
QuoteEntities []byte
QuoteOffset int32
FwdFromPeerType string
FwdFromPeerID int64
FwdFromName string
FwdDate int32
Media []byte
}
type Photo struct {
ID int64
AccessHash int64
FileReference []byte
Date int32
DcID int32
HasStickers bool
Sizes []byte
CreatedAt pgtype.Timestamptz
}
type PrivateMessage struct {
ID int64
SenderUserID int64
RecipientUserID int64
RandomID int64
MessageDate int32
Body string
Entities []byte
CreatedAt pgtype.Timestamptz
EditDate int32
Silent bool
Noforwards bool
ReplyToMsgID int32
ReplyToPeerType string
ReplyToPeerID int64
ReplyToTopID int32
QuoteText string
QuoteEntities []byte
QuoteOffset int32
FwdFromPeerType string
FwdFromPeerID int64
FwdFromName string
FwdDate int32
Media []byte
}
type ProfilePhoto struct {
OwnerPeerType string
OwnerPeerID int64
PhotoID int64
Date int32
Active bool
SortOrder int64
CreatedAt pgtype.Timestamptz
}
type StickerSet struct {
ID int64
AccessHash int64
ShortName string
Title string
Count int32
Hash int32
SetKind string
Official bool
Animated bool
Videos bool
Emojis bool
Masks bool
Installed bool
Archived bool
InstalledDate int32
ThumbDocumentID int64
Thumbs []byte
ThumbDcID int32
ThumbVersion int32
DocumentIds []byte
Packs []byte
SortOrder int32
SystemKey string
CreatedAt pgtype.Timestamptz
}
type TempAuthKeyBinding struct {
TempAuthKeyID int64
PermAuthKeyID int64
Nonce int64
ExpiresAt int32
EncryptedMessage []byte
CreatedAt pgtype.Timestamptz
TempSessionID int64
}
type UpdateState struct {
AuthKeyID int64
Pts int32
Qts int32
Date int32
Seq int32
UpdatedAt pgtype.Timestamptz
UserID int64
}
type UploadPart struct {
OwnerUserID int64
FileID int64
Part int32
TotalParts int32
IsBig bool
Bytes []byte
CreatedAt pgtype.Timestamptz
}
type User struct {
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
Verified bool
Support bool
About string
LastSeenAt int64
}
type UserRecentReaction struct {
UserID int64
ReactionType string
ReactionValue string
ReactionDate int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type UserSavedReactionTag struct {
UserID int64
ReactionType string
ReactionValue string
Title string
ReactionCount int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type UserTopReaction struct {
UserID int64
ReactionType string
ReactionValue string
ReactionCount int32
ReactionDate int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type UserUpdateEvent struct {
UserID int64
Pts int32
PtsCount int32
Date int32
EventType string
MessageBoxID *int32
PeerType *string
PeerID *int64
MaxID int32
StillUnreadCount int32
CreatedAt pgtype.Timestamptz
EventBool bool
EventPeers []byte
PeerSettings []byte
MessageIds []byte
DialogFilter []byte
FilterOrder []byte
FolderPeers []byte
FilterID int32
TagsEnabled bool
}
type UserUpdateWatermark struct {
UserID int64
ContiguousPts int32
UpdatedAt pgtype.Timestamptz
}

View file

@ -0,0 +1,80 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: temp_auth_key.sql
package sqlcgen
import (
"context"
)
const getTempAuthKeyBinding = `-- name: GetTempAuthKeyBinding :one
SELECT
temp_auth_key_id,
perm_auth_key_id,
nonce,
temp_session_id,
expires_at,
encrypted_message
FROM temp_auth_key_bindings
WHERE temp_auth_key_id = $1
`
type GetTempAuthKeyBindingRow struct {
TempAuthKeyID int64
PermAuthKeyID int64
Nonce int64
TempSessionID int64
ExpiresAt int32
EncryptedMessage []byte
}
func (q *Queries) GetTempAuthKeyBinding(ctx context.Context, tempAuthKeyID int64) (GetTempAuthKeyBindingRow, error) {
row := q.db.QueryRow(ctx, getTempAuthKeyBinding, tempAuthKeyID)
var i GetTempAuthKeyBindingRow
err := row.Scan(
&i.TempAuthKeyID,
&i.PermAuthKeyID,
&i.Nonce,
&i.TempSessionID,
&i.ExpiresAt,
&i.EncryptedMessage,
)
return i, err
}
const upsertTempAuthKeyBinding = `-- name: UpsertTempAuthKeyBinding :exec
INSERT INTO temp_auth_key_bindings (
temp_auth_key_id, perm_auth_key_id, nonce, temp_session_id, expires_at, encrypted_message
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (temp_auth_key_id) DO UPDATE SET
perm_auth_key_id = EXCLUDED.perm_auth_key_id,
nonce = EXCLUDED.nonce,
temp_session_id = EXCLUDED.temp_session_id,
expires_at = EXCLUDED.expires_at,
encrypted_message = EXCLUDED.encrypted_message,
created_at = now()
`
type UpsertTempAuthKeyBindingParams struct {
TempAuthKeyID int64
PermAuthKeyID int64
Nonce int64
TempSessionID int64
ExpiresAt int32
EncryptedMessage []byte
}
func (q *Queries) UpsertTempAuthKeyBinding(ctx context.Context, arg UpsertTempAuthKeyBindingParams) error {
_, err := q.db.Exec(ctx, upsertTempAuthKeyBinding,
arg.TempAuthKeyID,
arg.PermAuthKeyID,
arg.Nonce,
arg.TempSessionID,
arg.ExpiresAt,
arg.EncryptedMessage,
)
return err
}

View file

@ -0,0 +1,103 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: update_state.sql
package sqlcgen
import (
"context"
)
const deleteUpdateState = `-- name: DeleteUpdateState :exec
DELETE FROM update_states
WHERE auth_key_id = $1
AND user_id = $2
`
type DeleteUpdateStateParams struct {
AuthKeyID int64
UserID int64
}
func (q *Queries) DeleteUpdateState(ctx context.Context, arg DeleteUpdateStateParams) error {
_, err := q.db.Exec(ctx, deleteUpdateState, arg.AuthKeyID, arg.UserID)
return err
}
const deleteUpdateStatesByAuthKey = `-- name: DeleteUpdateStatesByAuthKey :exec
DELETE FROM update_states
WHERE auth_key_id = $1
`
func (q *Queries) DeleteUpdateStatesByAuthKey(ctx context.Context, authKeyID int64) error {
_, err := q.db.Exec(ctx, deleteUpdateStatesByAuthKey, authKeyID)
return err
}
const getUpdateState = `-- name: GetUpdateState :one
SELECT auth_key_id, user_id, pts, qts, date, seq
FROM update_states
WHERE auth_key_id = $1
AND user_id = $2
`
type GetUpdateStateParams struct {
AuthKeyID int64
UserID int64
}
type GetUpdateStateRow struct {
AuthKeyID int64
UserID int64
Pts int32
Qts int32
Date int32
Seq int32
}
func (q *Queries) GetUpdateState(ctx context.Context, arg GetUpdateStateParams) (GetUpdateStateRow, error) {
row := q.db.QueryRow(ctx, getUpdateState, arg.AuthKeyID, arg.UserID)
var i GetUpdateStateRow
err := row.Scan(
&i.AuthKeyID,
&i.UserID,
&i.Pts,
&i.Qts,
&i.Date,
&i.Seq,
)
return i, err
}
const upsertUpdateState = `-- name: UpsertUpdateState :exec
INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (auth_key_id, user_id) DO UPDATE SET
pts = EXCLUDED.pts,
qts = EXCLUDED.qts,
date = EXCLUDED.date,
seq = EXCLUDED.seq,
updated_at = now()
`
type UpsertUpdateStateParams struct {
AuthKeyID int64
UserID int64
Pts int32
Qts int32
Date int32
Seq int32
}
func (q *Queries) UpsertUpdateState(ctx context.Context, arg UpsertUpdateStateParams) error {
_, err := q.db.Exec(ctx, upsertUpdateState,
arg.AuthKeyID,
arg.UserID,
arg.Pts,
arg.Qts,
arg.Date,
arg.Seq,
)
return err
}

View file

@ -0,0 +1,426 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: user.sql
package sqlcgen
import (
"context"
)
const createUser = `-- name: CreateUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at
`
type CreateUserParams struct {
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRow(ctx, createUser,
arg.AccessHash,
arg.Phone,
arg.FirstName,
arg.LastName,
arg.Username,
arg.CountryCode,
)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
)
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 FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
row := q.db.QueryRow(ctx, getUserByID, id)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
)
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 FROM users WHERE phone = $1
`
func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) {
row := q.db.QueryRow(ctx, getUserByPhone, phone)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
)
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 FROM users WHERE lower(username) = lower($1) AND username <> ''
`
func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) {
row := q.db.QueryRow(ctx, getUserByUsername, lower)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
)
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
FROM users
WHERE id = ANY($1::bigint[])
ORDER BY id
`
func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error) {
rows, err := q.db.Query(ctx, getUsersByIDs, ids)
if err != nil {
return nil, err
}
defer rows.Close()
var items []User
for rows.Next() {
var i User
if err := rows.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
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
FROM users
WHERE phone = ANY($1::text[])
ORDER BY id
`
func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User, error) {
rows, err := q.db.Query(ctx, getUsersByPhones, phones)
if err != nil {
return nil, err
}
defer rows.Close()
var items []User
for rows.Next() {
var i User
if err := rows.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const searchUsers = `-- name: SearchUsers :many
WITH matched AS (
SELECT
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.about,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at,
(c.contact_user_id IS NOT NULL)::boolean AS contact,
COALESCE(c.mutual, false)::boolean AS mutual,
CASE
WHEN $2::text <> '' AND u.phone = $2::text THEN 0
WHEN lower(u.username) = $3::text THEN 1
WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = $3::text THEN 2
WHEN lower(u.first_name) = $3::text THEN 3
WHEN c.contact_user_id IS NOT NULL THEN 4
ELSE 5
END AS rank
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 $3::text <> ''
AND (
($2::text <> '' AND u.phone LIKE $2::text || '%')
OR lower(u.username) LIKE $5::text || '%' ESCAPE '\'
OR lower(u.first_name) LIKE '%' || $5::text || '%' ESCAPE '\'
OR lower(u.last_name) LIKE '%' || $5::text || '%' ESCAPE '\'
OR lower(trim(u.first_name || ' ' || u.last_name)) LIKE '%' || $5::text || '%' ESCAPE '\'
OR lower(c.contact_first_name) LIKE '%' || $5::text || '%' ESCAPE '\'
OR lower(c.contact_last_name) LIKE '%' || $5::text || '%' ESCAPE '\'
OR lower(trim(c.contact_first_name || ' ' || c.contact_last_name)) LIKE '%' || $5::text || '%' ESCAPE '\'
)
)
SELECT
id,
access_hash,
phone,
first_name,
last_name,
about,
username,
country_code,
verified,
support,
last_seen_at,
contact,
mutual
FROM matched
ORDER BY contact DESC, rank, id
LIMIT $1
`
type SearchUsersParams struct {
LimitCount int32
PhoneQuery string
QueryLower string
CurrentUserID int64
QueryLike string
}
type SearchUsersRow struct {
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
About string
Username string
CountryCode string
Verified bool
Support bool
LastSeenAt int64
Contact bool
Mutual bool
}
func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]SearchUsersRow, error) {
rows, err := q.db.Query(ctx, searchUsers,
arg.LimitCount,
arg.PhoneQuery,
arg.QueryLower,
arg.CurrentUserID,
arg.QueryLike,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SearchUsersRow
for rows.Next() {
var i SearchUsersRow
if err := rows.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.About,
&i.Username,
&i.CountryCode,
&i.Verified,
&i.Support,
&i.LastSeenAt,
&i.Contact,
&i.Mutual,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateUserLastSeen = `-- name: UpdateUserLastSeen :exec
UPDATE users
SET last_seen_at = GREATEST(last_seen_at, $1::bigint),
updated_at = now()
WHERE id = $2::bigint
`
type UpdateUserLastSeenParams struct {
LastSeenAt int64
ID int64
}
func (q *Queries) UpdateUserLastSeen(ctx context.Context, arg UpdateUserLastSeenParams) error {
_, err := q.db.Exec(ctx, updateUserLastSeen, arg.LastSeenAt, arg.ID)
return err
}
const updateUserProfile = `-- name: UpdateUserProfile :one
UPDATE users
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
`
type UpdateUserProfileParams struct {
ID int64
FirstName string
LastName string
About string
}
func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) {
row := q.db.QueryRow(ctx, updateUserProfile,
arg.ID,
arg.FirstName,
arg.LastName,
arg.About,
)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
)
return i, err
}
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
`
type UpdateUserUsernameParams struct {
ID int64
Username string
}
func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsernameParams) (User, error) {
row := q.db.QueryRow(ctx, updateUserUsername, arg.ID, arg.Username)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
)
return i, err
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,54 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// TempAuthKeyBindingStore 用 PostgreSQL 实现 store.TempAuthKeyBindingStore。
type TempAuthKeyBindingStore struct {
q *sqlcgen.Queries
}
// NewTempAuthKeyBindingStore 基于 pgx 连接池(或事务)创建 TempAuthKeyBindingStore。
func NewTempAuthKeyBindingStore(db sqlcgen.DBTX) *TempAuthKeyBindingStore {
return &TempAuthKeyBindingStore{q: sqlcgen.New(db)}
}
func (s *TempAuthKeyBindingStore) Save(ctx context.Context, b domain.TempAuthKeyBinding) error {
if err := s.q.UpsertTempAuthKeyBinding(ctx, sqlcgen.UpsertTempAuthKeyBindingParams{
TempAuthKeyID: authKeyIDToInt64(b.TempAuthKeyID),
PermAuthKeyID: b.PermAuthKeyID,
Nonce: b.Nonce,
TempSessionID: b.TempSessionID,
ExpiresAt: int32(b.ExpiresAt),
EncryptedMessage: b.EncryptedMessage,
}); err != nil {
return fmt.Errorf("upsert temp auth key binding: %w", err)
}
return nil
}
func (s *TempAuthKeyBindingStore) GetByTemp(ctx context.Context, tempAuthKeyID [8]byte) (domain.TempAuthKeyBinding, bool, error) {
row, err := s.q.GetTempAuthKeyBinding(ctx, authKeyIDToInt64(tempAuthKeyID))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.TempAuthKeyBinding{}, false, nil
}
return domain.TempAuthKeyBinding{}, false, fmt.Errorf("get temp auth key binding: %w", err)
}
return domain.TempAuthKeyBinding{
TempAuthKeyID: authKeyIDFromInt64(row.TempAuthKeyID),
PermAuthKeyID: row.PermAuthKeyID,
Nonce: row.Nonce,
TempSessionID: row.TempSessionID,
ExpiresAt: int(row.ExpiresAt),
EncryptedMessage: append([]byte(nil), row.EncryptedMessage...),
}, true, nil
}

View file

@ -0,0 +1,925 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// UpdateEventStore 用 PostgreSQL 实现 store.UpdateEventStore。
type UpdateEventStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewUpdateEventStore 基于 pgx 连接池(或事务)创建 UpdateEventStore。
func NewUpdateEventStore(db sqlcgen.DBTX) *UpdateEventStore {
return &UpdateEventStore{db: db, q: sqlcgen.New(db)}
}
func (s *UpdateEventStore) Append(ctx context.Context, userID int64, event domain.UpdateEvent) error {
beginner, ok := s.db.(interface {
Begin(context.Context) (pgx.Tx, error)
})
if !ok {
if err := appendUserUpdateEvent(ctx, s.q, userID, event); err != nil {
return fmt.Errorf("append update event: %w", err)
}
return nil
}
tx, err := beginner.Begin(ctx)
if err != nil {
return fmt.Errorf("begin append update event: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if err := appendUserUpdateEvent(ctx, sqlcgen.New(tx), userID, event); err != nil {
return fmt.Errorf("append update event: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit append update event: %w", err)
}
committed = true
return nil
}
// AppendWithDispatch 将账号级 update 事件与在线投递 outbox 放入同一个 PG 事务。
// 设置类 RPC 不像消息发送那样已有业务大事务;这里至少保证“事件已持久化”与
// “可靠在线投递任务已入队”同生共死,避免进程在手动 push 前退出造成在线通知漏投。
func (s *UpdateEventStore) AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error {
beginner, ok := s.db.(interface {
Begin(context.Context) (pgx.Tx, error)
})
if !ok {
if err := s.Append(ctx, userID, event); err != nil {
return err
}
if err := s.q.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
TargetUserID: userID,
Pts: int32(event.Pts),
EventType: string(event.Type),
ExcludeAuthKeyID: authKeyIDToInt64(excludeAuthKeyID),
ExcludeSessionID: excludeSessionID,
}); err != nil {
return fmt.Errorf("enqueue dispatch: %w", err)
}
return nil
}
tx, err := beginner.Begin(ctx)
if err != nil {
return fmt.Errorf("begin append update dispatch: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
qtx := sqlcgen.New(tx)
if err := appendUserUpdateEvent(ctx, qtx, userID, event); err != nil {
return fmt.Errorf("append update event: %w", err)
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
TargetUserID: userID,
Pts: int32(event.Pts),
EventType: string(event.Type),
ExcludeAuthKeyID: authKeyIDToInt64(excludeAuthKeyID),
ExcludeSessionID: excludeSessionID,
}); err != nil {
return fmt.Errorf("enqueue dispatch: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit append update dispatch: %w", err)
}
committed = true
return nil
}
func appendUserUpdateEvent(ctx context.Context, q *sqlcgen.Queries, userID int64, event domain.UpdateEvent) error {
var messageID *int32
if event.Message.ID != 0 {
id := int32(event.Message.ID)
messageID = &id
}
var peerType *string
var peerID *int64
peer := event.Peer
if peer.ID == 0 {
peer = event.Message.Peer
}
if peer.ID != 0 {
t := string(peer.Type)
id := peer.ID
peerType = &t
peerID = &id
}
peers, err := encodeEventPeers(event.Peers)
if err != nil {
return err
}
settings, err := encodePeerSettings(event.Settings)
if err != nil {
return err
}
messageIDs, err := encodeEventMessageIDs(event.MessageIDs)
if err != nil {
return err
}
dialogFilter, err := encodeEventDialogFilter(event.DialogFilter)
if err != nil {
return err
}
filterOrder, err := encodeEventFilterOrder(event.FilterOrder)
if err != nil {
return err
}
folderPeers, err := encodeEventFolderPeers(event.FolderPeers)
if err != nil {
return err
}
if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{
UserID: userID,
Pts: int32(event.Pts),
PtsCount: int32(event.PtsCount),
Date: int32(event.Date),
EventType: string(event.Type),
EventBool: event.Bool,
EventPeers: peers,
PeerSettings: settings,
MessageIds: messageIDs,
DialogFilter: dialogFilter,
FilterOrder: filterOrder,
FolderPeers: folderPeers,
MaxID: pgInt32NonNegative(event.MaxID),
StillUnreadCount: int32(event.StillUnreadCount),
FilterID: pgInt32NonNegative(event.FilterID),
TagsEnabled: event.TagsEnabled,
MessageBoxID: messageID,
PeerType: peerType,
PeerID: peerID,
}); err != nil {
return err
}
if _, err := advanceContiguousPts(ctx, q, userID); err != nil {
return fmt.Errorf("advance update watermark: %w", err)
}
return nil
}
func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, limit int) ([]domain.UpdateEvent, error) {
if limit <= 0 {
limit = 100
}
rows, err := s.q.ListUserUpdateEventsAfter(ctx, sqlcgen.ListUserUpdateEventsAfterParams{
UserID: userID,
Pts: int32(pts),
LimitCount: int32(limit),
})
if err != nil {
return nil, fmt.Errorf("list update events: %w", err)
}
out := make([]domain.UpdateEvent, 0, len(rows))
for _, row := range rows {
entities, err := decodeMessageEntities(row.MessageEntitiesJson)
if err != nil {
return nil, fmt.Errorf("decode message entities: %w", err)
}
silent, noforwards, reply, forward, err := messageMetadataFromFields(
row.Silent,
row.Noforwards,
row.ReplyToMsgID,
row.ReplyToPeerType,
row.ReplyToPeerID,
row.ReplyToTopID,
row.QuoteText,
row.QuoteEntitiesJson,
row.QuoteOffset,
row.FwdFromPeerType,
row.FwdFromPeerID,
row.FwdFromName,
row.FwdDate,
)
if err != nil {
return nil, fmt.Errorf("decode message metadata: %w", err)
}
peers, err := decodeEventPeers(row.EventPeersJson)
if err != nil {
return nil, fmt.Errorf("decode event peers: %w", err)
}
settings, err := decodePeerSettings(row.PeerSettingsJson)
if err != nil {
return nil, fmt.Errorf("decode peer settings: %w", err)
}
messageIDs, err := decodeEventMessageIDs(row.MessageIdsJson)
if err != nil {
return nil, fmt.Errorf("decode message ids: %w", err)
}
dialogFilter, err := decodeEventDialogFilter(row.DialogFilterJson)
if err != nil {
return nil, fmt.Errorf("decode dialog filter: %w", err)
}
filterOrder, err := decodeEventFilterOrder(row.FilterOrderJson)
if err != nil {
return nil, fmt.Errorf("decode filter order: %w", err)
}
folderPeers, err := decodeEventFolderPeers(row.FolderPeersJson)
if err != nil {
return nil, fmt.Errorf("decode folder peers: %w", err)
}
media, err := decodeMessageMedia(row.MediaJson)
if err != nil {
return nil, fmt.Errorf("decode message media: %w", err)
}
out = append(out, domain.UpdateEvent{
UserID: row.UserID,
Type: domain.UpdateEventType(row.EventType),
Pts: int(row.Pts),
PtsCount: int(row.PtsCount),
Date: int(row.Date),
Peer: domain.Peer{Type: domain.PeerType(row.EventPeerType), ID: row.EventPeerID},
Peers: peers,
Bool: row.EventBool,
Settings: settings,
MessageIDs: messageIDs,
MaxID: int(row.MaxID),
StillUnreadCount: int(row.StillUnreadCount),
FilterID: int(row.FilterID),
DialogFilter: dialogFilter,
FilterOrder: filterOrder,
FolderPeers: folderPeers,
TagsEnabled: row.TagsEnabled,
Message: domain.Message{
ID: int(row.MessageID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Media: media,
},
Users: usersFromUpdateEventRow(row),
Channels: channelsFromUpdateEventRow(row),
})
}
return out, nil
}
func (s *UpdateEventStore) Current(ctx context.Context, userID int64) (int, error) {
pts, err := s.q.MaxUserPts(ctx, userID)
if err != nil {
return 0, fmt.Errorf("max user pts: %w", err)
}
return int(pts), nil
}
func (s *UpdateEventStore) AdvanceContiguousPts(ctx context.Context, userID int64) (int, error) {
beginner, ok := s.db.(interface {
Begin(context.Context) (pgx.Tx, error)
})
if !ok {
pts, err := advanceContiguousPts(ctx, s.q, userID)
if err != nil {
return 0, fmt.Errorf("advance update watermark: %w", err)
}
return pts, nil
}
tx, err := beginner.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("begin advance update watermark: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
pts, err := advanceContiguousPts(ctx, sqlcgen.New(tx), userID)
if err != nil {
return 0, fmt.Errorf("advance update watermark: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("commit advance update watermark: %w", err)
}
committed = true
return pts, nil
}
// contiguousWindow 是计算最大连续 pts 时回看的顶部 pts 数量。
// 瞬时空洞只来自最近在途的发送事务(提交即填实、回退即补 noop单用户在途量远小于此
// 故窗口内若无空洞即可认定窗口下方连续。生产极端高 fan-in 可调大。
const contiguousWindow = 4096
// MaxContiguousPts 见 store.UpdateEventStore 接口说明。正常路径 O(1) 读账号水位;
// 缺行通常来自迁移前数据,允许一次性从 durable 事件计算并补写。
func (s *UpdateEventStore) MaxContiguousPts(ctx context.Context, userID int64) (int, error) {
pts, err := s.q.GetUserUpdateWatermark(ctx, userID)
if err == nil {
return int(pts), nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return 0, fmt.Errorf("get update watermark: %w", err)
}
return s.AdvanceContiguousPts(ctx, userID)
}
func advanceContiguousPts(ctx context.Context, q *sqlcgen.Queries, userID int64) (int, error) {
if err := q.EnsureUserUpdateWatermark(ctx, userID); err != nil {
return 0, err
}
locked, err := q.LockUserUpdateWatermark(ctx, userID)
if err != nil {
return 0, err
}
contiguous := int(locked)
for {
rows, err := q.NextUserPtsAfter(ctx, sqlcgen.NextUserPtsAfterParams{
UserID: userID,
Pts: int32(contiguous),
LimitCount: contiguousWindow,
})
if err != nil {
return 0, err
}
if len(rows) == 0 {
break
}
advanced := false
for _, row := range rows {
count := maxInt(int(row.PtsCount), 1)
expected := contiguous + count
if int(row.Pts) != expected {
if contiguous > int(locked) {
if err := saveUserUpdateWatermark(ctx, q, userID, contiguous); err != nil {
return 0, err
}
}
return contiguous, nil
}
contiguous = int(row.Pts)
advanced = true
}
if len(rows) < contiguousWindow || !advanced {
break
}
}
if contiguous > int(locked) {
if err := saveUserUpdateWatermark(ctx, q, userID, contiguous); err != nil {
return 0, err
}
}
return contiguous, nil
}
func saveUserUpdateWatermark(ctx context.Context, q *sqlcgen.Queries, userID int64, contiguous int) error {
return q.SaveUserUpdateWatermark(ctx, sqlcgen.SaveUserUpdateWatermarkParams{
UserID: userID,
ContiguousPts: int32(contiguous),
})
}
func computeContiguousPtsFromRecent(ctx context.Context, q *sqlcgen.Queries, userID int64) (int, error) {
rows, err := q.RecentUserPts(ctx, sqlcgen.RecentUserPtsParams{
UserID: userID,
WindowSize: contiguousWindow,
})
if err != nil {
return 0, fmt.Errorf("recent user pts: %w", err)
}
if len(rows) == 0 {
return 0, nil
}
nextByStart := make(map[int]int, len(rows))
floor := int(rows[0].Pts) - maxInt(int(rows[0].PtsCount), 1)
for _, p := range rows {
count := maxInt(int(p.PtsCount), 1)
v := int(p.Pts)
start := v - count
nextByStart[start] = v
if start < floor {
floor = start
}
}
contiguous := floor
for {
next, ok := nextByStart[contiguous]
if !ok {
break
}
contiguous = next
}
return contiguous, nil
}
// BatchByCursor 按 (user_id, pts) 一次性批量取多条账号事件,供 outbox worker 取代逐条 ListAfter。
// 返回顺序不保证与 cursors 一致,调用方按 (UserID,Pts) 自行索引。
func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.EventCursor) ([]domain.UpdateEvent, error) {
if len(cursors) == 0 {
return nil, nil
}
userIDs := make([]int64, len(cursors))
ptsList := make([]int32, len(cursors))
for i, c := range cursors {
userIDs[i] = c.UserID
ptsList[i] = int32(c.Pts)
}
rows, err := s.q.BatchListDispatchEvents(ctx, sqlcgen.BatchListDispatchEventsParams{
UserIds: userIDs,
PtsList: ptsList,
})
if err != nil {
return nil, fmt.Errorf("batch list dispatch events: %w", err)
}
out := make([]domain.UpdateEvent, 0, len(rows))
for _, row := range rows {
entities, err := decodeMessageEntities(row.MessageEntitiesJson)
if err != nil {
return nil, fmt.Errorf("decode message entities: %w", err)
}
silent, noforwards, reply, forward, err := messageMetadataFromFields(
row.Silent,
row.Noforwards,
row.ReplyToMsgID,
row.ReplyToPeerType,
row.ReplyToPeerID,
row.ReplyToTopID,
row.QuoteText,
row.QuoteEntitiesJson,
row.QuoteOffset,
row.FwdFromPeerType,
row.FwdFromPeerID,
row.FwdFromName,
row.FwdDate,
)
if err != nil {
return nil, fmt.Errorf("decode message metadata: %w", err)
}
peers, err := decodeEventPeers(row.EventPeersJson)
if err != nil {
return nil, fmt.Errorf("decode event peers: %w", err)
}
settings, err := decodePeerSettings(row.PeerSettingsJson)
if err != nil {
return nil, fmt.Errorf("decode peer settings: %w", err)
}
messageIDs, err := decodeEventMessageIDs(row.MessageIdsJson)
if err != nil {
return nil, fmt.Errorf("decode message ids: %w", err)
}
dialogFilter, err := decodeEventDialogFilter(row.DialogFilterJson)
if err != nil {
return nil, fmt.Errorf("decode dialog filter: %w", err)
}
filterOrder, err := decodeEventFilterOrder(row.FilterOrderJson)
if err != nil {
return nil, fmt.Errorf("decode filter order: %w", err)
}
folderPeers, err := decodeEventFolderPeers(row.FolderPeersJson)
if err != nil {
return nil, fmt.Errorf("decode folder peers: %w", err)
}
media, err := decodeMessageMedia(row.MediaJson)
if err != nil {
return nil, fmt.Errorf("decode message media: %w", err)
}
out = append(out, domain.UpdateEvent{
UserID: row.UserID,
Type: domain.UpdateEventType(row.EventType),
Pts: int(row.Pts),
PtsCount: int(row.PtsCount),
Date: int(row.Date),
Peer: domain.Peer{Type: domain.PeerType(row.EventPeerType), ID: row.EventPeerID},
Peers: peers,
Bool: row.EventBool,
Settings: settings,
MessageIDs: messageIDs,
MaxID: int(row.MaxID),
StillUnreadCount: int(row.StillUnreadCount),
FilterID: int(row.FilterID),
DialogFilter: dialogFilter,
FilterOrder: filterOrder,
FolderPeers: folderPeers,
TagsEnabled: row.TagsEnabled,
Message: domain.Message{
ID: int(row.MessageID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Media: media,
},
Users: usersFromBatchDispatchRow(row),
Channels: channelsFromBatchDispatchRow(row),
})
}
return out, nil
}
func usersFromUpdateEventRow(row sqlcgen.ListUserUpdateEventsAfterRow) []domain.User {
return mergeEventUsers(
domain.User{
ID: row.PeerUserID,
AccessHash: row.PeerAccessHash,
Phone: row.PeerPhone,
FirstName: row.PeerFirstName,
LastName: row.PeerLastName,
Username: row.PeerUsername,
CountryCode: row.PeerCountryCode,
Verified: row.PeerVerified,
Support: row.PeerSupport,
},
domain.User{
ID: row.FromUserUserID,
AccessHash: row.FromUserAccessHash,
Phone: row.FromUserPhone,
FirstName: row.FromUserFirstName,
LastName: row.FromUserLastName,
Username: row.FromUserUsername,
CountryCode: row.FromUserCountryCode,
Verified: row.FromUserVerified,
Support: row.FromUserSupport,
},
domain.User{
ID: row.FwdUserID,
AccessHash: row.FwdUserAccessHash,
Phone: row.FwdUserPhone,
FirstName: row.FwdUserFirstName,
LastName: row.FwdUserLastName,
Username: row.FwdUserUsername,
CountryCode: row.FwdUserCountryCode,
Verified: row.FwdUserVerified,
Support: row.FwdUserSupport,
},
domain.User{
ID: row.ReplyUserID,
AccessHash: row.ReplyUserAccessHash,
Phone: row.ReplyUserPhone,
FirstName: row.ReplyUserFirstName,
LastName: row.ReplyUserLastName,
Username: row.ReplyUserUsername,
CountryCode: row.ReplyUserCountryCode,
Verified: row.ReplyUserVerified,
Support: row.ReplyUserSupport,
},
)
}
// usersFromBatchDispatchRow 与 usersFromUpdateEventRow 等价,只是行类型为 BatchListDispatchEventsRow
// (两条查询列完全一致;改一处列时务必同步另一处)。
func usersFromBatchDispatchRow(row sqlcgen.BatchListDispatchEventsRow) []domain.User {
return mergeEventUsers(
domain.User{
ID: row.PeerUserID,
AccessHash: row.PeerAccessHash,
Phone: row.PeerPhone,
FirstName: row.PeerFirstName,
LastName: row.PeerLastName,
Username: row.PeerUsername,
CountryCode: row.PeerCountryCode,
Verified: row.PeerVerified,
Support: row.PeerSupport,
},
domain.User{
ID: row.FromUserUserID,
AccessHash: row.FromUserAccessHash,
Phone: row.FromUserPhone,
FirstName: row.FromUserFirstName,
LastName: row.FromUserLastName,
Username: row.FromUserUsername,
CountryCode: row.FromUserCountryCode,
Verified: row.FromUserVerified,
Support: row.FromUserSupport,
},
domain.User{
ID: row.FwdUserID,
AccessHash: row.FwdUserAccessHash,
Phone: row.FwdUserPhone,
FirstName: row.FwdUserFirstName,
LastName: row.FwdUserLastName,
Username: row.FwdUserUsername,
CountryCode: row.FwdUserCountryCode,
Verified: row.FwdUserVerified,
Support: row.FwdUserSupport,
},
domain.User{
ID: row.ReplyUserID,
AccessHash: row.ReplyUserAccessHash,
Phone: row.ReplyUserPhone,
FirstName: row.ReplyUserFirstName,
LastName: row.ReplyUserLastName,
Username: row.ReplyUserUsername,
CountryCode: row.ReplyUserCountryCode,
Verified: row.ReplyUserVerified,
Support: row.ReplyUserSupport,
},
)
}
func channelsFromUpdateEventRow(row sqlcgen.ListUserUpdateEventsAfterRow) []domain.Channel {
return mergeEventChannels(
eventChannelFromFields(
row.FwdChannelID, row.FwdChannelAccessHash, row.FwdChannelCreatorUserID, row.FwdChannelTitle, row.FwdChannelAbout, row.FwdChannelUsername,
row.FwdChannelBroadcast, row.FwdChannelMegagroup, row.FwdChannelForum, row.FwdChannelNoforwards, row.FwdChannelSignatures, row.FwdChannelPreHistoryHidden,
int(row.FwdChannelSlowmodeSeconds), row.FwdChannelDefaultBannedRights, int(row.FwdChannelParticipantsCount), int(row.FwdChannelAdminsCount),
int(row.FwdChannelKickedCount), int(row.FwdChannelBannedCount), int(row.FwdChannelTopMessageID), int(row.FwdChannelPinnedMessageID),
int(row.FwdChannelPts), int(row.FwdChannelTtlPeriod), int(row.FwdChannelDate), row.FwdChannelDeleted,
),
eventChannelFromFields(
row.ReplyChannelID, row.ReplyChannelAccessHash, row.ReplyChannelCreatorUserID, row.ReplyChannelTitle, row.ReplyChannelAbout, row.ReplyChannelUsername,
row.ReplyChannelBroadcast, row.ReplyChannelMegagroup, row.ReplyChannelForum, row.ReplyChannelNoforwards, row.ReplyChannelSignatures, row.ReplyChannelPreHistoryHidden,
int(row.ReplyChannelSlowmodeSeconds), row.ReplyChannelDefaultBannedRights, int(row.ReplyChannelParticipantsCount), int(row.ReplyChannelAdminsCount),
int(row.ReplyChannelKickedCount), int(row.ReplyChannelBannedCount), int(row.ReplyChannelTopMessageID), int(row.ReplyChannelPinnedMessageID),
int(row.ReplyChannelPts), int(row.ReplyChannelTtlPeriod), int(row.ReplyChannelDate), row.ReplyChannelDeleted,
),
)
}
func channelsFromBatchDispatchRow(row sqlcgen.BatchListDispatchEventsRow) []domain.Channel {
return mergeEventChannels(
eventChannelFromFields(
row.FwdChannelID, row.FwdChannelAccessHash, row.FwdChannelCreatorUserID, row.FwdChannelTitle, row.FwdChannelAbout, row.FwdChannelUsername,
row.FwdChannelBroadcast, row.FwdChannelMegagroup, row.FwdChannelForum, row.FwdChannelNoforwards, row.FwdChannelSignatures, row.FwdChannelPreHistoryHidden,
int(row.FwdChannelSlowmodeSeconds), row.FwdChannelDefaultBannedRights, int(row.FwdChannelParticipantsCount), int(row.FwdChannelAdminsCount),
int(row.FwdChannelKickedCount), int(row.FwdChannelBannedCount), int(row.FwdChannelTopMessageID), int(row.FwdChannelPinnedMessageID),
int(row.FwdChannelPts), int(row.FwdChannelTtlPeriod), int(row.FwdChannelDate), row.FwdChannelDeleted,
),
eventChannelFromFields(
row.ReplyChannelID, row.ReplyChannelAccessHash, row.ReplyChannelCreatorUserID, row.ReplyChannelTitle, row.ReplyChannelAbout, row.ReplyChannelUsername,
row.ReplyChannelBroadcast, row.ReplyChannelMegagroup, row.ReplyChannelForum, row.ReplyChannelNoforwards, row.ReplyChannelSignatures, row.ReplyChannelPreHistoryHidden,
int(row.ReplyChannelSlowmodeSeconds), row.ReplyChannelDefaultBannedRights, int(row.ReplyChannelParticipantsCount), int(row.ReplyChannelAdminsCount),
int(row.ReplyChannelKickedCount), int(row.ReplyChannelBannedCount), int(row.ReplyChannelTopMessageID), int(row.ReplyChannelPinnedMessageID),
int(row.ReplyChannelPts), int(row.ReplyChannelTtlPeriod), int(row.ReplyChannelDate), row.ReplyChannelDeleted,
),
)
}
// mergeEventUsers 合并事件依赖用户,跳过 ID=0 并按 ID 去重。
func mergeEventUsers(items ...domain.User) []domain.User {
users := make([]domain.User, 0, len(items))
add := func(u domain.User) {
if u.ID == 0 {
return
}
for _, existing := range users {
if existing.ID == u.ID {
return
}
}
users = append(users, u)
}
for _, item := range items {
add(item)
}
return users
}
func eventChannelFromFields(id, accessHash, creatorUserID int64, title, about, username string, broadcast, megagroup, forum, noforwards, signatures, preHistoryHidden bool, slowmodeSeconds int, defaultRights string, participantsCount, adminsCount, kickedCount, bannedCount, topMessageID, pinnedMessageID, pts, ttlPeriod, date int, deleted bool) domain.Channel {
if id == 0 {
return domain.Channel{}
}
ch := domain.Channel{
ID: id,
AccessHash: accessHash,
CreatorUserID: creatorUserID,
Title: title,
About: about,
Username: username,
Broadcast: broadcast,
Megagroup: megagroup,
Forum: forum,
NoForwards: noforwards,
Signatures: signatures,
PreHistoryHidden: preHistoryHidden,
SlowmodeSeconds: slowmodeSeconds,
ParticipantsCount: participantsCount,
AdminsCount: adminsCount,
KickedCount: kickedCount,
BannedCount: bannedCount,
TopMessageID: topMessageID,
PinnedMessageID: pinnedMessageID,
Pts: pts,
TTLPeriod: ttlPeriod,
Date: date,
Deleted: deleted,
}
_ = json.Unmarshal([]byte(defaultRights), &ch.DefaultBannedRights)
return ch
}
func mergeEventChannels(items ...domain.Channel) []domain.Channel {
channels := make([]domain.Channel, 0, len(items))
seen := make(map[int64]struct{}, len(items))
for _, ch := range items {
if ch.ID == 0 {
continue
}
if _, ok := seen[ch.ID]; ok {
continue
}
seen[ch.ID] = struct{}{}
channels = append(channels, ch)
}
return channels
}
type eventPeerJSON struct {
Type string `json:"type"`
ID int64 `json:"id"`
}
func encodeEventPeers(peers []domain.Peer) ([]byte, error) {
if len(peers) == 0 {
return []byte("[]"), nil
}
wire := make([]eventPeerJSON, 0, len(peers))
for _, peer := range peers {
if peer.ID == 0 {
continue
}
wire = append(wire, eventPeerJSON{Type: string(peer.Type), ID: peer.ID})
}
raw, err := json.Marshal(wire)
if err != nil {
return nil, fmt.Errorf("marshal event peers: %w", err)
}
return raw, nil
}
func decodeEventPeers(raw string) ([]domain.Peer, error) {
if raw == "" {
return nil, nil
}
var wire []eventPeerJSON
if err := json.Unmarshal([]byte(raw), &wire); err != nil {
return nil, err
}
out := make([]domain.Peer, 0, len(wire))
for _, peer := range wire {
if peer.ID == 0 {
continue
}
out = append(out, domain.Peer{Type: domain.PeerType(peer.Type), ID: peer.ID})
}
return out, nil
}
func encodeEventMessageIDs(ids []int) ([]byte, error) {
if len(ids) == 0 {
return []byte("[]"), nil
}
raw, err := json.Marshal(ids)
if err != nil {
return nil, fmt.Errorf("marshal event message ids: %w", err)
}
return raw, nil
}
func decodeEventMessageIDs(raw string) ([]int, error) {
if raw == "" {
return nil, nil
}
var ids []int
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
return nil, err
}
return ids, nil
}
func encodeEventDialogFilter(folder *domain.DialogFolder) ([]byte, error) {
if folder == nil {
return []byte("{}"), nil
}
raw, err := json.Marshal(folder)
if err != nil {
return nil, fmt.Errorf("marshal event dialog filter: %w", err)
}
return raw, nil
}
func decodeEventDialogFilter(raw string) (*domain.DialogFolder, error) {
if raw == "" || raw == "{}" {
return nil, nil
}
var folder domain.DialogFolder
if err := json.Unmarshal([]byte(raw), &folder); err != nil {
return nil, err
}
return &folder, nil
}
func encodeEventFilterOrder(order []int) ([]byte, error) {
if len(order) == 0 {
return []byte("[]"), nil
}
raw, err := json.Marshal(order)
if err != nil {
return nil, fmt.Errorf("marshal event filter order: %w", err)
}
return raw, nil
}
func decodeEventFilterOrder(raw string) ([]int, error) {
if raw == "" {
return nil, nil
}
var order []int
if err := json.Unmarshal([]byte(raw), &order); err != nil {
return nil, err
}
return order, nil
}
func encodeEventFolderPeers(peers []domain.FolderPeerUpdate) ([]byte, error) {
if len(peers) == 0 {
return []byte("[]"), nil
}
raw, err := json.Marshal(peers)
if err != nil {
return nil, fmt.Errorf("marshal event folder peers: %w", err)
}
return raw, nil
}
func decodeEventFolderPeers(raw string) ([]domain.FolderPeerUpdate, error) {
if raw == "" {
return nil, nil
}
var peers []domain.FolderPeerUpdate
if err := json.Unmarshal([]byte(raw), &peers); err != nil {
return nil, err
}
return peers, nil
}
type peerSettingsJSON struct {
AddContact bool `json:"add_contact,omitempty"`
BlockContact bool `json:"block_contact,omitempty"`
ShareContact bool `json:"share_contact,omitempty"`
NeedContactsException bool `json:"need_contacts_exception,omitempty"`
HiddenPeerSettingsBar bool `json:"hidden_peer_settings_bar,omitempty"`
}
func encodePeerSettings(settings domain.PeerSettings) ([]byte, error) {
raw, err := json.Marshal(peerSettingsJSON{
AddContact: settings.AddContact,
BlockContact: settings.BlockContact,
ShareContact: settings.ShareContact,
NeedContactsException: settings.NeedContactsException,
HiddenPeerSettingsBar: settings.HiddenPeerSettingsBar,
})
if err != nil {
return nil, fmt.Errorf("marshal peer settings: %w", err)
}
return raw, nil
}
func decodePeerSettings(raw string) (domain.PeerSettings, error) {
if raw == "" {
return domain.PeerSettings{}, nil
}
var wire peerSettingsJSON
if err := json.Unmarshal([]byte(raw), &wire); err != nil {
return domain.PeerSettings{}, err
}
return domain.PeerSettings{
AddContact: wire.AddContact,
BlockContact: wire.BlockContact,
ShareContact: wire.ShareContact,
NeedContactsException: wire.NeedContactsException,
HiddenPeerSettingsBar: wire.HiddenPeerSettingsBar,
}, nil
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}

View file

@ -0,0 +1,72 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// UpdateStateStore 用 PostgreSQL 实现 store.UpdateStateStore。
type UpdateStateStore struct {
q *sqlcgen.Queries
}
// NewUpdateStateStore 基于 pgx 连接池(或事务)创建 UpdateStateStore。
func NewUpdateStateStore(db sqlcgen.DBTX) *UpdateStateStore {
return &UpdateStateStore{q: sqlcgen.New(db)}
}
func (s *UpdateStateStore) Get(ctx context.Context, id [8]byte, userID int64) (domain.UpdateState, bool, error) {
row, err := s.q.GetUpdateState(ctx, sqlcgen.GetUpdateStateParams{
AuthKeyID: authKeyIDToInt64(id),
UserID: userID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.UpdateState{}, false, nil
}
return domain.UpdateState{}, false, fmt.Errorf("get update state: %w", err)
}
return domain.UpdateState{
Pts: int(row.Pts),
Qts: int(row.Qts),
Date: int(row.Date),
Seq: int(row.Seq),
}, true, nil
}
func (s *UpdateStateStore) Save(ctx context.Context, id [8]byte, userID int64, st domain.UpdateState) error {
if err := s.q.UpsertUpdateState(ctx, sqlcgen.UpsertUpdateStateParams{
AuthKeyID: authKeyIDToInt64(id),
UserID: userID,
Pts: int32(st.Pts),
Qts: int32(st.Qts),
Date: int32(st.Date),
Seq: int32(st.Seq),
}); err != nil {
return fmt.Errorf("upsert update state: %w", err)
}
return nil
}
func (s *UpdateStateStore) Delete(ctx context.Context, id [8]byte, userID int64) error {
if err := s.q.DeleteUpdateState(ctx, sqlcgen.DeleteUpdateStateParams{
AuthKeyID: authKeyIDToInt64(id),
UserID: userID,
}); err != nil {
return fmt.Errorf("delete update state: %w", err)
}
return nil
}
func (s *UpdateStateStore) DeleteAuthKey(ctx context.Context, id [8]byte) error {
if err := s.q.DeleteUpdateStatesByAuthKey(ctx, authKeyIDToInt64(id)); err != nil {
return fmt.Errorf("delete update states by auth key: %w", err)
}
return nil
}

View file

@ -0,0 +1,234 @@
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// UserStore 用 PostgreSQL 实现 store.UserStore。
type UserStore struct {
q *sqlcgen.Queries
}
// NewUserStore 基于 pgx 连接池(或事务)创建 UserStore。
func NewUserStore(db sqlcgen.DBTX) *UserStore {
return &UserStore{q: sqlcgen.New(db)}
}
func (s *UserStore) ByID(ctx context.Context, id int64) (domain.User, bool, error) {
row, err := s.q.GetUserByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, false, nil
}
return domain.User{}, false, fmt.Errorf("get user by id: %w", err)
}
return userFromModel(row), true, nil
}
func (s *UserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) {
if len(ids) == 0 {
return nil, nil
}
rows, err := s.q.GetUsersByIDs(ctx, ids)
if err != nil {
return nil, fmt.Errorf("get users by ids: %w", err)
}
out := make([]domain.User, 0, len(rows))
for _, row := range rows {
out = append(out, userFromModel(row))
}
return out, nil
}
func (s *UserStore) ByPhone(ctx context.Context, phone string) (domain.User, bool, error) {
row, err := s.q.GetUserByPhone(ctx, phone)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, false, nil
}
return domain.User{}, false, fmt.Errorf("get user by phone: %w", err)
}
return userFromModel(row), true, nil
}
func (s *UserStore) ByPhones(ctx context.Context, phones []string) ([]domain.User, error) {
if len(phones) == 0 {
return nil, nil
}
rows, err := s.q.GetUsersByPhones(ctx, phones)
if err != nil {
return nil, fmt.Errorf("get users by phones: %w", err)
}
out := make([]domain.User, 0, len(rows))
for _, row := range rows {
out = append(out, userFromModel(row))
}
return out, nil
}
func (s *UserStore) ByUsername(ctx context.Context, username string) (domain.User, bool, error) {
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
if username == "" {
return domain.User{}, false, nil
}
row, err := s.q.GetUserByUsername(ctx, username)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, false, nil
}
return domain.User{}, false, fmt.Errorf("get user by username: %w", err)
}
return userFromModel(row), true, nil
}
func (s *UserStore) Search(ctx context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error) {
query = strings.ToLower(strings.TrimSpace(query))
if currentUserID == 0 || query == "" {
return domain.UserSearchResult{}, nil
}
if limit <= 0 || limit > 50 {
limit = 50
}
rows, err := s.q.SearchUsers(ctx, sqlcgen.SearchUsersParams{
CurrentUserID: currentUserID,
QueryLower: query,
QueryLike: escapeLike(query),
PhoneQuery: phoneQuery,
LimitCount: int32(limit),
})
if err != nil {
return domain.UserSearchResult{}, fmt.Errorf("search users: %w", err)
}
out := domain.UserSearchResult{
MyResults: make([]domain.User, 0, len(rows)),
Results: make([]domain.User, 0, len(rows)),
}
for _, row := range rows {
u := domain.User{
ID: row.ID,
AccessHash: row.AccessHash,
Phone: row.Phone,
FirstName: row.FirstName,
LastName: row.LastName,
About: row.About,
Username: row.Username,
CountryCode: row.CountryCode,
Verified: row.Verified,
Support: row.Support,
LastSeenAt: int(row.LastSeenAt),
Contact: row.Contact,
Mutual: row.Mutual,
}
if row.Contact {
out.MyResults = append(out.MyResults, u)
} else {
out.Results = append(out.Results, u)
}
}
return out, nil
}
func (s *UserStore) UpdateProfile(ctx context.Context, userID int64, firstName, lastName, about string) (domain.User, error) {
row, err := s.q.UpdateUserProfile(ctx, sqlcgen.UpdateUserProfileParams{
ID: userID,
FirstName: firstName,
LastName: lastName,
About: about,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrFirstNameInvalid
}
return domain.User{}, fmt.Errorf("update user profile: %w", err)
}
return userFromModel(row), nil
}
func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) {
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
row, err := s.q.UpdateUserUsername(ctx, sqlcgen.UpdateUserUsernameParams{
ID: userID,
Username: username,
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation && pgErr.ConstraintName == "users_username_lower_unique_idx" {
return domain.User{}, domain.ErrUsernameOccupied
}
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUsernameNotOccupied
}
return domain.User{}, fmt.Errorf("update user username: %w", err)
}
return userFromModel(row), nil
}
func (s *UserStore) UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error {
if lastSeenAt <= 0 {
return nil
}
if err := s.q.UpdateUserLastSeen(ctx, sqlcgen.UpdateUserLastSeenParams{
ID: userID,
LastSeenAt: int64(lastSeenAt),
}); err != nil {
return fmt.Errorf("update user last seen: %w", err)
}
return nil
}
func (s *UserStore) Create(ctx context.Context, u domain.User) (domain.User, error) {
row, err := s.q.CreateUser(ctx, sqlcgen.CreateUserParams{
AccessHash: u.AccessHash,
Phone: u.Phone,
FirstName: u.FirstName,
LastName: u.LastName,
Username: u.Username,
CountryCode: u.CountryCode,
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation && pgErr.ConstraintName == "users_username_lower_unique_idx" {
return domain.User{}, domain.ErrUsernameOccupied
}
return domain.User{}, fmt.Errorf("create user: %w", err)
}
return userFromModel(row), nil
}
func escapeLike(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r == '%' || r == '_' || r == '\\' {
b.WriteRune('\\')
}
b.WriteRune(r)
}
return b.String()
}
func userFromModel(r sqlcgen.User) domain.User {
return domain.User{
ID: r.ID,
AccessHash: r.AccessHash,
Phone: r.Phone,
FirstName: r.FirstName,
LastName: r.LastName,
About: r.About,
Username: r.Username,
CountryCode: r.CountryCode,
Verified: r.Verified,
Support: r.Support,
LastSeenAt: int(r.LastSeenAt),
}
}