merged from gramsrv upstream

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

View file

@ -20,6 +20,8 @@ type DialogStore struct {
q *sqlcgen.Queries
}
const dialogListSnapshotLimit = 10000
// NewDialogStore 基于 pgx 连接池(或事务)创建 DialogStore。
func NewDialogStore(db sqlcgen.DBTX) *DialogStore {
return &DialogStore{db: db, q: sqlcgen.New(db)}
@ -33,12 +35,19 @@ func (s *DialogStore) enrichDialogTopMessages(ctx context.Context, userID int64,
}
func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
return s.listByUser(ctx, userID, filter, 500)
}
func (s *DialogStore) listByUser(ctx context.Context, userID int64, filter domain.DialogFilter, maxLimit int) (domain.DialogList, error) {
limit := filter.Limit
if limit <= 0 {
limit = 100
}
if limit > 500 {
limit = 500
if maxLimit <= 0 {
maxLimit = 500
}
if limit > maxLimit {
limit = maxLimit
}
offsetPeerID := int64(0)
if filter.HasOffsetPeer {
@ -248,6 +257,195 @@ func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domai
return out, nil
}
// ListDialogSnapshotHeaders returns the complete bounded private-dialog owner
// index without hydrating peer users or top-message payloads. Page payloads are
// resolved later through the versioned per-peer read model.
func (s *DialogStore) ListDialogSnapshotHeaders(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
folderParams := dialogFolderQueryParams(filter.Folder)
rows, 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 snapshot headers: %w", err)
}
if len(rows) > dialogListSnapshotLimit {
return domain.DialogList{}, fmt.Errorf("private dialog snapshot exceeds %d entries", dialogListSnapshotLimit)
}
dialogs := make([]domain.Dialog, 0, len(rows))
peerTypes := make([]string, 0, len(rows))
peerIDs := make([]int64, 0, len(rows))
for _, row := range rows {
peerTypes = append(peerTypes, row.PeerType)
peerIDs = append(peerIDs, row.PeerID)
dialogs = append(dialogs, 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),
TTLPeriod: int(row.TtlPeriod),
ThemeEmoticon: row.ThemeEmoticon,
HasScheduled: row.HasScheduled,
Pinned: row.Pinned,
PinnedOrder: int(row.PinnedOrder),
UnreadMark: row.UnreadMark,
PeerSettingsBarHidden: row.HiddenPeerSettingsBar,
})
}
var dependencyHash int64
if len(peerIDs) > 0 {
if err := s.db.QueryRow(ctx, `
WITH requested AS (
SELECT peer_type, peer_id
FROM unnest($2::text[], $3::bigint[]) AS peer(peer_type, peer_id)
)
SELECT COALESCE(bit_xor(v.hash), 0)::bigint
FROM requested peer
JOIN read_model_versions v
ON v.model = 'dialog_light'
AND v.owner_user_id = $1
AND v.peer_type = peer.peer_type
AND v.peer_id = peer.peer_id`, userID, peerTypes, peerIDs).Scan(&dependencyHash); err != nil {
return domain.DialogList{}, fmt.Errorf("read private dialog snapshot dependency hash: %w", err)
}
}
return domain.DialogList{
Dialogs: dialogs,
Count: len(dialogs),
Hash: mixDialogListDependencyHash(dialogListHash(dialogs), dependencyHash),
}, nil
}
// ListAllBuiltinDialogSnapshotHeaders loads one owner base across main and
// archive folders. Pinned/exclude-pinned/folder variants are derived by the app
// layer from this immutable base instead of repeating the owner scan.
func (s *DialogStore) ListAllBuiltinDialogSnapshotHeaders(ctx context.Context, userID int64) (domain.DialogList, error) {
if userID == 0 {
return domain.DialogList{}, nil
}
rows, err := s.db.Query(ctx, `
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.ttl_period,
d.theme_emoticon,
d.has_scheduled,
d.pinned,
d.pinned_order,
d.unread_mark,
d.hidden_peer_settings_bar
FROM dialogs AS d
WHERE d.user_id = $1
AND d.folder_id IN (0, 1)
ORDER BY d.pinned DESC,
CASE WHEN d.pinned THEN COALESCE(d.pinned_order, 0) ELSE 0 END DESC,
d.top_message_date DESC,
d.top_message_id DESC,
d.peer_id DESC
LIMIT $2`, userID, dialogListSnapshotLimit+1)
if err != nil {
return domain.DialogList{}, fmt.Errorf("list all built-in private dialog snapshot headers: %w", err)
}
defer rows.Close()
dialogs := make([]domain.Dialog, 0, 128)
for rows.Next() {
var dialog domain.Dialog
var peerType string
if err := rows.Scan(
&peerType,
&dialog.Peer.ID,
&dialog.FolderID,
&dialog.TopMessage,
&dialog.TopMessageDate,
&dialog.ReadInboxMaxID,
&dialog.ReadOutboxMaxID,
&dialog.UnreadCount,
&dialog.UnreadMentions,
&dialog.UnreadReactions,
&dialog.TTLPeriod,
&dialog.ThemeEmoticon,
&dialog.HasScheduled,
&dialog.Pinned,
&dialog.PinnedOrder,
&dialog.UnreadMark,
&dialog.PeerSettingsBarHidden,
); err != nil {
return domain.DialogList{}, fmt.Errorf("scan all built-in private dialog snapshot headers: %w", err)
}
dialog.Peer.Type = domain.PeerType(peerType)
dialogs = append(dialogs, dialog)
}
if err := rows.Err(); err != nil {
return domain.DialogList{}, fmt.Errorf("list all built-in private dialog snapshot header rows: %w", err)
}
if len(dialogs) > dialogListSnapshotLimit {
return domain.DialogList{}, fmt.Errorf("private dialog snapshot exceeds %d entries", dialogListSnapshotLimit)
}
return domain.DialogList{Dialogs: dialogs, Count: len(dialogs)}, nil
}
// ListPrivateDialogPeerIDs is the narrow presence-fanout read model. Presence
// needs only private peer IDs; routing it through GetDialogs would hydrate
// channels, top messages, drafts and viewer projections and can even omit
// private peers when the first page is channel-heavy.
func (s *DialogStore) ListPrivateDialogPeerIDs(ctx context.Context, userID int64, limit int) ([]int64, error) {
if userID == 0 {
return nil, nil
}
if limit <= 0 || limit > 4096 {
limit = 4096
}
rows, err := s.db.Query(ctx, `
SELECT peer_id
FROM dialogs
WHERE user_id = $1
AND peer_type = 'user'
AND peer_id <> $1
ORDER BY top_message_date DESC, top_message_id DESC, peer_id DESC
LIMIT $2`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list private dialog peer ids: %w", err)
}
defer rows.Close()
ids := make([]int64, 0, minInt(limit, 128))
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return ids, nil
}
func (s *DialogStore) ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
if len(peers) == 0 {
return domain.DialogList{}, nil
@ -634,6 +832,35 @@ func (s *DialogStore) ListUnreadMarked(ctx context.Context, userID int64) ([]dom
return out, nil
}
func (s *DialogStore) ListDraftsByPeers(ctx context.Context, userID int64, peers []domain.Peer) ([]domain.DialogDraft, error) {
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.ID == 0 {
continue
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
peerTypes = append(peerTypes, string(peer.Type))
peerIDs = append(peerIDs, peer.ID)
}
if len(peerIDs) == 0 {
return nil, nil
}
rows, err := s.q.ListDialogDraftsByPeers(ctx, sqlcgen.ListDialogDraftsByPeersParams{
UserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
})
if err != nil {
return nil, fmt.Errorf("list dialog drafts by peers: %w", err)
}
return decodeDialogDrafts(rows)
}
func (s *DialogStore) SetChatTheme(ctx context.Context, userID int64, peer domain.Peer, emoticon string) (bool, error) {
if userID == 0 || peer.Type == "" || peer.ID == 0 {
return false, nil
@ -1050,3 +1277,26 @@ func dialogListHash(dialogs []domain.Dialog) int64 {
}
return int64(h.Sum64())
}
// mixDialogListDependencyHash turns durable read-model version tokens into the
// list hash without forcing the owner ordering scan to derive every mutable
// dialog field. A token change is sufficient to reject an old client hash;
// the page itself is then hydrated from the exact per-peer projection.
func mixDialogListDependencyHash(base int64, dependencies ...int64) int64 {
if base == 0 && len(dependencies) == 0 {
return 0
}
h := fnv.New64a()
var buf [8]byte
binary.LittleEndian.PutUint64(buf[:], uint64(base))
_, _ = h.Write(buf[:])
for _, dependency := range dependencies {
binary.LittleEndian.PutUint64(buf[:], uint64(dependency))
_, _ = h.Write(buf[:])
}
sum := int64(h.Sum64() & 0x7fffffffffffffff)
if sum == 0 {
return 1
}
return sum
}