chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -5,6 +5,7 @@ import (
"encoding/binary"
"errors"
"hash/fnv"
"reflect"
"sort"
"unicode/utf8"
@ -13,14 +14,21 @@ import (
"telesrv/internal/store"
)
// PremiumChecker 报告用户当前是否有效会员(pin 上限双档判断用)。
type PremiumChecker func(ctx context.Context, userID int64) bool
// Service 提供会话列表查询。
type Service struct {
dialogs store.DialogStore
channels store.ChannelStore
contacts store.ContactStore
photos userprojection.ProfilePhotoProvider
privacy userprojection.PrivacyEvaluator
projector *userprojection.Projector
dialogs store.DialogStore
channels store.ChannelStore
contacts store.ContactStore
photos userprojection.ProfilePhotoProvider
privacy userprojection.PrivacyEvaluator
premium PremiumChecker
projector *userprojection.Projector
versions store.ReadModelVersionStore
peerCache *dialogPeerReadModelCache
listHashCache *dialogListHashCache
}
// Option adjusts optional dialogs service dependencies.
@ -31,6 +39,11 @@ func WithContactStore(c store.ContactStore) Option {
return func(s *Service) { s.contacts = c }
}
// WithPremiumChecker 启用 pin 上限的 premium 双档(缺省一律按默认档)。
func WithPremiumChecker(p PremiumChecker) Option {
return func(s *Service) { s.premium = p }
}
// WithPhotoProvider enables current profile photo enrichment for dialog users.
func WithPhotoProvider(p userprojection.ProfilePhotoProvider) Option {
return func(s *Service) { s.photos = p }
@ -41,9 +54,18 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
return func(s *Service) { s.privacy = p }
}
// WithReadModelVersions enables durable version-token backed peer dialog caching.
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
return func(s *Service) { s.versions = v }
}
// NewService 创建 dialogs 服务。
func NewService(dialogs store.DialogStore, channels ...store.ChannelStore) *Service {
s := &Service{dialogs: dialogs}
s := &Service{
dialogs: dialogs,
peerCache: newDialogPeerReadModelCache(defaultDialogPeerReadModelTTL),
listHashCache: newDialogListHashCache(defaultDialogListHashCacheTTL),
}
if len(channels) > 0 {
s.channels = channels[0]
}
@ -76,6 +98,15 @@ func (s *Service) rebuildProjector() {
// GetDialogs 返回当前登录账号的会话摘要。未登录或无持久化实现时按空账号处理。
func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
return s.getDialogs(ctx, userID, filter, false)
}
// getDialogs 是 GetDialogs 的实现。lightweight=true 跳过草稿附加、viewer 投影与
// list-hash 写回,供 attachArchiveSummary 取归档顶部会话用:归档摘要只需 top
// peer/message,草稿无意义;且追加进来的归档 users 会被外层 GetDialogs 的
// projectDialogUsers 统一投影,内层再投影纯属重复(原归档递归走完整 GetDialogs
// 会多跑一次 ListDrafts + 一次投影)。
func (s *Service) getDialogs(ctx context.Context, userID int64, filter domain.DialogFilter, lightweight bool) (domain.DialogList, error) {
if s == nil || userID == 0 {
return domain.DialogList{}, nil
}
@ -92,6 +123,9 @@ func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.Di
}
filter.Folder = &folder
}
// 在加载任何会话状态前快照 list-hash epoch:若加载/投影期间发生 dialog_light 写失效,
// rememberDialogListHash 会据此拒绝写回 stale hash,避免后续 getDialogs 误返 NotModified。
listHashEpoch := s.listHashCache.cacheEpoch()
var out domain.DialogList
if s.dialogs != nil {
list, err := s.dialogs.ListByUser(ctx, userID, filter)
@ -125,15 +159,100 @@ func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.Di
if out.Count == 0 {
out.Count = len(out.Dialogs)
}
if err := s.attachArchiveSummary(ctx, userID, filter, &out); err != nil {
return domain.DialogList{}, err
}
if lightweight {
// 归档摘要顶部会话:不附草稿、不投影(由外层统一投影)、不写 list-hash。
return out, nil
}
if err := s.attachDrafts(ctx, userID, &out); err != nil {
return domain.DialogList{}, err
}
if err := s.projectDialogUsers(ctx, userID, &out); err != nil {
return domain.DialogList{}, err
}
s.rememberDialogListHash(userID, filter, out, listHashEpoch)
return out, nil
}
// attachArchiveSummary 在主列表第一页响应上聚合归档摘要:TDesktop 只能靠
// 响应头部的 dialogFolder 条目发现 archive(新登录设备没有任何 update 可
// 重放),缺少它归档会话将彻底不可见。归档/自定义 filter/置顶/翻页请求不附加。
func (s *Service) attachArchiveSummary(ctx context.Context, userID int64, filter domain.DialogFilter, out *domain.DialogList) error {
if s == nil || out == nil {
return nil
}
if filter.HasFolderID && filter.FolderID != domain.DialogMainFolderID {
return nil
}
// exclude_pinned 请求按官方语义排除 folder 条目(archive 行属 pinned 集合)。
// PinnedOnly(getPinnedDialogs)不能跳过:DrKLO 主列表 getDialogs 一律带
// exclude_pinned,archive 行的发现完全依赖 getPinnedDialogs 响应里的
// dialogFolder 条目(fetchFolderInLoadedPinnedDialogs)。
if filter.ExcludePinned {
return nil
}
if filter.OffsetID != 0 || filter.OffsetDate != 0 || filter.HasOffsetPeer {
return nil
}
top, err := s.getDialogs(ctx, userID, domain.DialogFilter{
HasFolderID: true,
FolderID: domain.DialogArchiveFolderID,
Limit: 1,
}, true)
if err != nil {
return err
}
if len(top.Dialogs) == 0 {
return nil
}
unreadPeers, unreadMessages := 0, 0
if s.dialogs != nil {
peers, messages, err := s.dialogs.CountArchiveUnread(ctx, userID)
if err != nil {
return err
}
unreadPeers += peers
unreadMessages += messages
}
if s.channels != nil {
peers, messages, err := s.channels.CountChannelArchiveUnread(ctx, userID)
if err != nil {
return err
}
unreadPeers += peers
unreadMessages += messages
}
archivePinned := true
if s.dialogs != nil {
pinned, err := s.dialogs.ArchivePinned(ctx, userID)
if err != nil {
return err
}
archivePinned = pinned
}
// getPinnedDialogs 只返回置顶集合:archive 行被 unpin 后不再属于它。
if filter.PinnedOnly && !archivePinned {
return nil
}
topDialog := top.Dialogs[0]
out.ArchiveSummary = &domain.DialogArchiveSummary{
TopPeer: topDialog.Peer,
TopMessage: topDialog.TopMessage,
UnreadPeersCount: unreadPeers,
UnreadMessagesCount: unreadMessages,
Pinned: archivePinned,
}
// dialogFolder.peer 指向的会话对象必须随响应下发:TDesktop
// Folder::applyDialog 会立即解引用该 peer(owner().history(peerId))。
out.Messages = append(out.Messages, top.Messages...)
out.ChannelMessages = append(out.ChannelMessages, top.ChannelMessages...)
out.Users = append(out.Users, top.Users...)
out.Channels = append(out.Channels, top.Channels...)
return nil
}
// GetPeerDialogs 返回指定 peer 的会话摘要。缺失的 peer 由 store 按空会话占位返回。
func (s *Service) GetPeerDialogs(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
if s == nil || userID == 0 || len(peers) == 0 {
@ -154,28 +273,18 @@ func (s *Service) GetPeerDialogs(ctx context.Context, userID int64, peers []doma
}
var out domain.DialogList
if len(userPeers) > 0 && s.dialogs != nil {
list, err := s.dialogs.ListByPeers(ctx, userID, userPeers)
list, err := s.userPeerDialogsReadModel(ctx, userID, userPeers)
if err != nil {
return domain.DialogList{}, err
}
out = mergeDialogLists(out, list)
}
if len(channelIDs) > 0 && s.channels != nil {
list, err := s.channels.GetChannelDialogs(ctx, userID, channelIDs)
channelOut, err := s.channelPeerDialogsReadModel(ctx, userID, channelIDs)
if err != nil {
return domain.DialogList{}, err
}
out = mergeChannelDialogs(out, list)
out, err = s.appendMissingChannelPeerPreviews(ctx, userID, channelIDs, out)
if err != nil {
return domain.DialogList{}, err
}
}
if err := s.attachDrafts(ctx, userID, &out); err != nil {
return domain.DialogList{}, err
}
if err := s.projectDialogUsers(ctx, userID, &out); err != nil {
return domain.DialogList{}, err
out = mergeDialogLists(out, channelOut)
}
return out, nil
}
@ -191,6 +300,7 @@ func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID i
}
}
seen := make(map[int64]struct{}, len(channelIDs))
missingIDs := make([]int64, 0, len(channelIDs))
for _, channelID := range channelIDs {
if channelID == 0 {
continue
@ -203,12 +313,28 @@ func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID i
continue
}
view, err := s.channels.GetChannel(ctx, userID, channelID)
if err != nil {
if isChannelPreviewAccessError(err) {
continue
}
return domain.DialogList{}, err
missingIDs = append(missingIDs, channelID)
}
if len(missingIDs) == 0 {
return out, nil
}
views, err := s.channels.GetChannels(ctx, userID, missingIDs)
if err != nil {
if isChannelPreviewAccessError(err) {
return out, nil
}
return domain.DialogList{}, err
}
viewsByID := make(map[int64]domain.ChannelView, len(views))
for _, view := range views {
if view.Channel.ID != 0 {
viewsByID[view.Channel.ID] = view
}
}
for _, channelID := range missingIDs {
view, ok := viewsByID[channelID]
if !ok || view.Forbidden {
continue
}
history, err := s.channels.ListChannelHistory(ctx, userID, domain.ChannelHistoryFilter{
ChannelID: channelID,
@ -256,26 +382,63 @@ func dialogFromChannelView(view domain.ChannelView) domain.Dialog {
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
Pts: view.Channel.Pts,
}
}
// SaveDraft stores or clears a cloud draft for one peer/topic.
func (s *Service) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error {
// It returns whether the authoritative draft content changed. A repeated save
// with identical content does not refresh Date, invalidate read models, or
// force a durable update.
func (s *Service) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) (bool, error) {
if s == nil || s.dialogs == nil || userID == 0 {
return nil
return false, nil
}
if err := validateDraft(draft); err != nil {
return err
return false, err
}
if draft.Empty() {
_, err := s.dialogs.DeleteDraft(ctx, userID, draft.Peer, draft.TopMessageID)
return err
changed, err := s.dialogs.DeleteDraft(ctx, userID, draft.Peer, draft.TopMessageID)
if err == nil && changed {
s.InvalidateDialog(userID, draft.Peer)
}
return changed, err
}
return s.dialogs.SaveDraft(ctx, userID, draft)
existing, found, err := s.dialogs.GetDraft(ctx, userID, draft.Peer, draft.TopMessageID)
if err != nil {
return false, err
}
if found && sameDialogDraftContent(existing, draft) {
return false, nil
}
if err := s.dialogs.SaveDraft(ctx, userID, draft); err != nil {
return false, err
}
s.InvalidateDialog(userID, draft.Peer)
return true, nil
}
func sameDialogDraftContent(a, b domain.DialogDraft) bool {
a.Date = 0
b.Date = 0
return reflect.DeepEqual(a, b)
}
// GetDraft 读取某会话当前云草稿(draft_message 事件重放时按 peer 重载用)。
func (s *Service) GetDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (domain.DialogDraft, bool, error) {
if s == nil || s.dialogs == nil || userID == 0 {
return domain.DialogDraft{}, false, nil
}
if err := validateDraftKey(peer, topMessageID); err != nil {
return domain.DialogDraft{}, false, err
}
return s.dialogs.GetDraft(ctx, userID, peer, topMessageID)
}
// DeleteDraft clears one cloud draft.
@ -286,7 +449,11 @@ func (s *Service) DeleteDraft(ctx context.Context, userID int64, peer domain.Pee
if err := validateDraftKey(peer, topMessageID); err != nil {
return false, err
}
return s.dialogs.DeleteDraft(ctx, userID, peer, topMessageID)
changed, err := s.dialogs.DeleteDraft(ctx, userID, peer, topMessageID)
if err == nil && changed {
s.InvalidateDialog(userID, peer)
}
return changed, err
}
// ListDrafts returns bounded cloud drafts for messages.getAllDrafts.
@ -302,42 +469,185 @@ func (s *Service) ClearDrafts(ctx context.Context, userID int64, limit int) ([]d
if s == nil || s.dialogs == nil || userID == 0 {
return nil, nil
}
return s.dialogs.ClearDrafts(ctx, userID, clampDraftLimit(limit))
drafts, err := s.dialogs.ClearDrafts(ctx, userID, clampDraftLimit(limit))
if err == nil {
for _, draft := range drafts {
s.InvalidateDialog(userID, draft.Peer)
}
}
return drafts, err
}
func (s *Service) TogglePinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) {
// TogglePinned 置顶/取消置顶一条会话;置顶顺序在会话当前 folder 内分配,
// 返回 (changed, 该会话所在 folder_id) 供 updateDialogPinned.folder_id 使用。
// pin 时按 folder 校验上限(重复 pin 幂等放行),超限返回 ErrPinnedDialogsTooMuch。
func (s *Service) TogglePinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, int, error) {
if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
return false, nil
return false, 0, nil
}
if pinned {
if err := s.checkPinnedLimit(ctx, userID, peer); err != nil {
return false, 0, err
}
}
switch peer.Type {
case domain.PeerTypeChannel:
if s.channels == nil {
return false, nil
return false, 0, nil
}
return s.channels.SetChannelDialogPinned(ctx, userID, peer.ID, pinned)
changed, folderID, err := s.channels.SetChannelDialogPinned(ctx, userID, peer.ID, pinned)
if err == nil && changed {
if pinned {
if err := s.promotePinnedDialog(ctx, userID, folderID, peer); err != nil {
return changed, folderID, err
}
}
s.InvalidateDialog(userID, peer)
}
return changed, folderID, err
default:
if s.dialogs == nil {
return false, nil
return false, 0, nil
}
return s.dialogs.SetPinned(ctx, userID, peer, pinned)
changed, folderID, err := s.dialogs.SetPinned(ctx, userID, peer, pinned)
if err == nil && changed {
if pinned {
if err := s.promotePinnedDialog(ctx, userID, folderID, peer); err != nil {
return changed, folderID, err
}
}
s.InvalidateDialog(userID, peer)
}
return changed, folderID, err
}
}
func (s *Service) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error {
if s == nil || userID == 0 {
func (s *Service) promotePinnedDialog(ctx context.Context, userID int64, folderID int, peer domain.Peer) error {
if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
return nil
}
list, err := s.GetDialogs(ctx, userID, domain.DialogFilter{
PinnedOnly: true,
HasFolderID: true,
FolderID: folderID,
Limit: domain.PinnedDialogsLimit(folderID, true),
})
if err != nil {
return err
}
order := make([]domain.Peer, 0, len(list.Dialogs))
order = append(order, peer)
seen := map[domain.Peer]struct{}{peer: {}}
found := false
for _, dialog := range list.Dialogs {
if dialog.Peer == peer {
found = true
continue
}
if !dialog.Pinned {
continue
}
if _, ok := seen[dialog.Peer]; ok {
continue
}
seen[dialog.Peer] = struct{}{}
order = append(order, dialog.Peer)
}
if !found {
return nil
}
_, err = s.ReorderPinned(ctx, userID, folderID, order, false)
return err
}
func (s *Service) checkPinnedLimit(ctx context.Context, userID int64, peer domain.Peer) error {
current, err := s.GetPeerDialogs(ctx, userID, []domain.Peer{peer})
if err != nil {
return err
}
folderID := domain.DialogMainFolderID
for _, dialog := range current.Dialogs {
if dialog.Peer != peer {
continue
}
if dialog.Pinned {
// 重复 pin 幂等,不占新名额。
return nil
}
folderID = dialog.FolderID
break
}
premium := s.premium != nil && s.premium(ctx, userID)
limit := domain.PinnedDialogsLimit(folderID, premium)
pinnedList, err := s.GetDialogs(ctx, userID, domain.DialogFilter{
PinnedOnly: true,
HasFolderID: true,
FolderID: folderID,
Limit: limit,
})
if err != nil {
return err
}
if pinnedList.Count >= limit {
return domain.ErrPinnedDialogsTooMuch
}
return nil
}
// ToggleArchivePinned 置顶/取消置顶 archive folder 行本身
// (toggleDialogPin(inputDialogPeerFolder)),返回是否变化。
func (s *Service) ToggleArchivePinned(ctx context.Context, userID int64, pinned bool) (bool, error) {
if s == nil || s.dialogs == nil || userID == 0 {
return false, nil
}
changed, err := s.dialogs.SetArchivePinned(ctx, userID, pinned)
if err == nil && changed {
s.invalidateDialogListHashes(userID)
}
return changed, err
}
// ReorderPinned 重排指定 folder(0 主列表/1 归档)内的置顶顺序;
// force 只清除该 folder 内不在 order 中的置顶,绝不跨 folder 误伤。
func (s *Service) ReorderPinned(ctx context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) {
if s == nil || userID == 0 {
return false, nil
}
changed := false
if s.dialogs != nil {
if err := s.dialogs.ReorderPinned(ctx, userID, order, force); err != nil {
return err
privateChanged, err := s.dialogs.ReorderPinned(ctx, userID, folderID, order, force)
if err != nil {
return false, err
}
if privateChanged {
changed = true
for _, peer := range order {
if peer.Type != domain.PeerTypeChannel {
s.InvalidateDialog(userID, peer)
}
}
}
}
if s.channels != nil {
if err := s.channels.ReorderChannelPinnedDialogs(ctx, userID, order, force); err != nil {
return err
channelChanged, err := s.channels.ReorderChannelPinnedDialogs(ctx, userID, folderID, order, force)
if err != nil {
return false, err
}
if channelChanged {
changed = true
for _, peer := range order {
if peer.Type == domain.PeerTypeChannel {
s.InvalidateDialog(userID, peer)
}
}
}
}
return nil
if changed && force {
// force may unpin peers omitted from order; the store returns only a boolean,
// so flush the small peer-dialog snapshot cache to avoid stale omitted peers.
s.FlushReadModelCache()
}
return changed, nil
}
func (s *Service) MarkUnread(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) {
@ -349,12 +659,20 @@ func (s *Service) MarkUnread(ctx context.Context, userID int64, peer domain.Peer
if s.channels == nil {
return false, nil
}
return s.channels.SetChannelDialogUnreadMark(ctx, userID, peer.ID, unread)
changed, err := s.channels.SetChannelDialogUnreadMark(ctx, userID, peer.ID, unread)
if err == nil && changed {
s.InvalidateDialog(userID, peer)
}
return changed, err
default:
if s.dialogs == nil {
return false, nil
}
return s.dialogs.SetUnreadMark(ctx, userID, peer, unread)
changed, err := s.dialogs.SetUnreadMark(ctx, userID, peer, unread)
if err == nil && changed {
s.InvalidateDialog(userID, peer)
}
return changed, err
}
}
@ -384,7 +702,11 @@ func (s *Service) HidePeerSettingsBar(ctx context.Context, userID int64, peer do
if s == nil || s.dialogs == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
return false, nil
}
return s.dialogs.SetPeerSettingsBarHidden(ctx, userID, peer)
changed, err := s.dialogs.SetPeerSettingsBarHidden(ctx, userID, peer)
if err == nil && changed {
s.InvalidateDialog(userID, peer)
}
return changed, err
}
func (s *Service) PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
@ -405,21 +727,33 @@ func (s *Service) SaveDialogFolder(ctx context.Context, userID int64, folder dom
if s == nil || s.dialogs == nil || userID == 0 {
return nil
}
return s.dialogs.UpsertFolder(ctx, userID, folder)
if err := s.dialogs.UpsertFolder(ctx, userID, folder); err != nil {
return err
}
s.invalidateDialogListHashes(userID)
return nil
}
func (s *Service) DeleteDialogFolder(ctx context.Context, userID int64, folderID int) error {
if s == nil || s.dialogs == nil || userID == 0 {
return nil
}
return s.dialogs.DeleteFolder(ctx, userID, folderID)
if err := s.dialogs.DeleteFolder(ctx, userID, folderID); err != nil {
return err
}
s.invalidateDialogListHashes(userID)
return nil
}
func (s *Service) ReorderDialogFolders(ctx context.Context, userID int64, order []int) error {
if s == nil || s.dialogs == nil || userID == 0 {
return nil
}
return s.dialogs.ReorderFolders(ctx, userID, order)
if err := s.dialogs.ReorderFolders(ctx, userID, order); err != nil {
return err
}
s.invalidateDialogListHashes(userID)
return nil
}
func (s *Service) ToggleDialogFolderTags(ctx context.Context, userID int64, enabled bool) error {
@ -446,11 +780,17 @@ func (s *Service) EditPeerFolders(ctx context.Context, userID int64, peers []dom
if err := s.dialogs.EditPeerFolders(ctx, userID, privatePeers); err != nil {
return err
}
for _, update := range privatePeers {
s.InvalidateDialog(userID, update.Peer)
}
}
if len(channelPeers) > 0 && s.channels != nil {
if err := s.channels.EditChannelPeerFolders(ctx, userID, channelPeers); err != nil {
return err
}
for _, update := range channelPeers {
s.InvalidateDialog(userID, update.Peer)
}
}
return nil
}
@ -591,7 +931,9 @@ func dialogHashWithDrafts(base int64, dialogs []domain.Dialog) int64 {
func mergeDialogLists(out, in domain.DialogList) domain.DialogList {
out.Dialogs = append(out.Dialogs, in.Dialogs...)
out.Messages = append(out.Messages, in.Messages...)
out.ChannelMessages = append(out.ChannelMessages, in.ChannelMessages...)
out.Users = append(out.Users, in.Users...)
out.Channels = append(out.Channels, in.Channels...)
out.Count += in.Count
out.Hash ^= in.Hash
return out