Initial open source release
This commit is contained in:
commit
74992e893f
377 changed files with 118084 additions and 0 deletions
126
internal/store/postgres/account.go
Normal file
126
internal/store/postgres/account.go
Normal 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
|
||||
}
|
||||
86
internal/store/postgres/auth_login_integration_test.go
Normal file
86
internal/store/postgres/auth_login_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
67
internal/store/postgres/authkey.go
Normal file
67
internal/store/postgres/authkey.go
Normal 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 按小端解释为 int64(MTProto 定义即 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
|
||||
}
|
||||
72
internal/store/postgres/authkey_integration_test.go
Normal file
72
internal/store/postgres/authkey_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
93
internal/store/postgres/authorization.go
Normal file
93
internal/store/postgres/authorization.go
Normal 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,
|
||||
}
|
||||
}
|
||||
288
internal/store/postgres/business_integration_test.go
Normal file
288
internal/store/postgres/business_integration_test.go
Normal 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[:]))
|
||||
}
|
||||
10149
internal/store/postgres/channel.go
Normal file
10149
internal/store/postgres/channel.go
Normal file
File diff suppressed because it is too large
Load diff
3165
internal/store/postgres/channel_integration_test.go
Normal file
3165
internal/store/postgres/channel_integration_test.go
Normal file
File diff suppressed because it is too large
Load diff
393
internal/store/postgres/contact.go
Normal file
393
internal/store/postgres/contact.go
Normal 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,
|
||||
¬e,
|
||||
¬eEntitiesJSON,
|
||||
&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())
|
||||
}
|
||||
279
internal/store/postgres/contact_dialog_integration_test.go
Normal file
279
internal/store/postgres/contact_dialog_integration_test.go
Normal 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
|
||||
}
|
||||
127
internal/store/postgres/contiguous_pts_integration_test.go
Normal file
127
internal/store/postgres/contiguous_pts_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
78
internal/store/postgres/counter_source.go
Normal file
78
internal/store/postgres/counter_source.go
Normal 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
|
||||
}
|
||||
722
internal/store/postgres/dialog.go
Normal file
722
internal/store/postgres/dialog.go
Normal 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())
|
||||
}
|
||||
98
internal/store/postgres/dialog_folders_integration_test.go
Normal file
98
internal/store/postgres/dialog_folders_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
140
internal/store/postgres/dispatch_outbox.go
Normal file
140
internal/store/postgres/dispatch_outbox.go
Normal 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
|
||||
}
|
||||
141
internal/store/postgres/help.go
Normal file
141
internal/store/postgres/help.go
Normal 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())
|
||||
}
|
||||
169
internal/store/postgres/langpack.go
Normal file
169
internal/store/postgres/langpack.go
Normal 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,
|
||||
}
|
||||
}
|
||||
509
internal/store/postgres/media.go
Normal file
509
internal/store/postgres/media.go
Normal 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 NULL;nil []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,
|
||||
})
|
||||
}
|
||||
88
internal/store/postgres/media_codec.go
Normal file
88
internal/store/postgres/media_codec.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// 媒体相关 JSON 编解码:domain 值对象 ↔ JSONB 列。domain.* 带 json tag,可直接 marshal。
|
||||
|
||||
// jsonArrayOrEmpty 把切片序列化为 JSONB;nil 序列化为 "[]"(列为 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
|
||||
}
|
||||
210
internal/store/postgres/media_integration_test.go
Normal file
210
internal/store/postgres/media_integration_test.go
Normal 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 blob(nil 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 JSONB;nil 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)
|
||||
}
|
||||
|
||||
// ---- photo(sizes 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
2561
internal/store/postgres/message.go
Normal file
2561
internal/store/postgres/message.go
Normal file
File diff suppressed because it is too large
Load diff
114
internal/store/postgres/message_concurrent_integration_test.go
Normal file
114
internal/store/postgres/message_concurrent_integration_test.go
Normal 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。
|
||||
// 用 perUserCounterAllocator(mutex 单调自增,忠实复刻 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 %d(pts 必须连续无洞)", 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)
|
||||
}
|
||||
}
|
||||
119
internal/store/postgres/message_deadlock_integration_test.go
Normal file
119
internal/store/postgres/message_deadlock_integration_test.go
Normal 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 读 A,goroutine 一起抢同一对用户的行锁。
|
||||
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)
|
||||
}
|
||||
}
|
||||
813
internal/store/postgres/message_integration_test.go
Normal file
813
internal/store/postgres/message_integration_test.go
Normal 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
|
||||
}
|
||||
181
internal/store/postgres/message_media_integration_test.go
Normal file
181
internal/store/postgres/message_media_integration_test.go
Normal 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 导致收件人/离线丢媒体,本测试守护该修复);
|
||||
// - history(ListByUser)读取也带 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)
|
||||
}
|
||||
}
|
||||
160
internal/store/postgres/message_plan_integration_test.go
Normal file
160
internal/store/postgres/message_plan_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
128
internal/store/postgres/postgres.go
Normal file
128
internal/store/postgres/postgres.go
Normal 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
|
||||
}
|
||||
22
internal/store/postgres/queries/account.sql
Normal file
22
internal/store/postgres/queries/account.sql
Normal 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();
|
||||
10
internal/store/postgres/queries/authkey.sql
Normal file
10
internal/store/postgres/queries/authkey.sql
Normal 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;
|
||||
24
internal/store/postgres/queries/authorization.sql
Normal file
24
internal/store/postgres/queries/authorization.sql
Normal 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;
|
||||
168
internal/store/postgres/queries/contact.sql
Normal file
168
internal/store/postgres/queries/contact.sql
Normal 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;
|
||||
773
internal/store/postgres/queries/dialog.sql
Normal file
773
internal/store/postgres/queries/dialog.sql
Normal 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;
|
||||
43
internal/store/postgres/queries/help.sql
Normal file
43
internal/store/postgres/queries/help.sql
Normal 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;
|
||||
47
internal/store/postgres/queries/langpack.sql
Normal file
47
internal/store/postgres/queries/langpack.sql
Normal 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;
|
||||
331
internal/store/postgres/queries/media.sql
Normal file
331
internal/store/postgres/queries/media.sql
Normal 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;
|
||||
932
internal/store/postgres/queries/message.sql
Normal file
932
internal/store/postgres/queries/message.sql
Normal 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;
|
||||
23
internal/store/postgres/queries/temp_auth_key.sql
Normal file
23
internal/store/postgres/queries/temp_auth_key.sql
Normal 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;
|
||||
24
internal/store/postgres/queries/update_state.sql
Normal file
24
internal/store/postgres/queries/update_state.sql
Normal 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;
|
||||
104
internal/store/postgres/queries/user.sql
Normal file
104
internal/store/postgres/queries/user.sql
Normal 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 *;
|
||||
464
internal/store/postgres/queries/user_update_event.sql
Normal file
464
internal/store/postgres/queries/user_update_event.sql
Normal 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;
|
||||
87
internal/store/postgres/sqlcgen/account.sql.go
Normal file
87
internal/store/postgres/sqlcgen/account.sql.go
Normal 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
|
||||
}
|
||||
46
internal/store/postgres/sqlcgen/authkey.sql.go
Normal file
46
internal/store/postgres/sqlcgen/authkey.sql.go
Normal 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
|
||||
}
|
||||
124
internal/store/postgres/sqlcgen/authorization.sql.go
Normal file
124
internal/store/postgres/sqlcgen/authorization.sql.go
Normal 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
|
||||
}
|
||||
426
internal/store/postgres/sqlcgen/contact.sql.go
Normal file
426
internal/store/postgres/sqlcgen/contact.sql.go
Normal 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
|
||||
}
|
||||
32
internal/store/postgres/sqlcgen/db.go
Normal file
32
internal/store/postgres/sqlcgen/db.go
Normal 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,
|
||||
}
|
||||
}
|
||||
1592
internal/store/postgres/sqlcgen/dialog.sql.go
Normal file
1592
internal/store/postgres/sqlcgen/dialog.sql.go
Normal file
File diff suppressed because it is too large
Load diff
159
internal/store/postgres/sqlcgen/help.sql.go
Normal file
159
internal/store/postgres/sqlcgen/help.sql.go
Normal 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
|
||||
}
|
||||
250
internal/store/postgres/sqlcgen/langpack.sql.go
Normal file
250
internal/store/postgres/sqlcgen/langpack.sql.go
Normal 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
|
||||
}
|
||||
1154
internal/store/postgres/sqlcgen/media.sql.go
Normal file
1154
internal/store/postgres/sqlcgen/media.sql.go
Normal file
File diff suppressed because it is too large
Load diff
2331
internal/store/postgres/sqlcgen/message.sql.go
Normal file
2331
internal/store/postgres/sqlcgen/message.sql.go
Normal file
File diff suppressed because it is too large
Load diff
681
internal/store/postgres/sqlcgen/models.go
Normal file
681
internal/store/postgres/sqlcgen/models.go
Normal 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
|
||||
}
|
||||
80
internal/store/postgres/sqlcgen/temp_auth_key.sql.go
Normal file
80
internal/store/postgres/sqlcgen/temp_auth_key.sql.go
Normal 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
|
||||
}
|
||||
103
internal/store/postgres/sqlcgen/update_state.sql.go
Normal file
103
internal/store/postgres/sqlcgen/update_state.sql.go
Normal 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
|
||||
}
|
||||
426
internal/store/postgres/sqlcgen/user.sql.go
Normal file
426
internal/store/postgres/sqlcgen/user.sql.go
Normal 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
|
||||
}
|
||||
1307
internal/store/postgres/sqlcgen/user_update_event.sql.go
Normal file
1307
internal/store/postgres/sqlcgen/user_update_event.sql.go
Normal file
File diff suppressed because it is too large
Load diff
54
internal/store/postgres/temp_auth_key.go
Normal file
54
internal/store/postgres/temp_auth_key.go
Normal 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
|
||||
}
|
||||
925
internal/store/postgres/update_event.go
Normal file
925
internal/store/postgres/update_event.go
Normal 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
|
||||
}
|
||||
72
internal/store/postgres/updatestate.go
Normal file
72
internal/store/postgres/updatestate.go
Normal 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
|
||||
}
|
||||
234
internal/store/postgres/user.go
Normal file
234
internal/store/postgres/user.go
Normal 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),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue