chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
132
internal/app/dialogs/list_hash_cache.go
Normal file
132
internal/app/dialogs/list_hash_cache.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDialogListHashCacheTTL = 24 * time.Hour
|
||||
dialogListHashCacheMaxEntries = 4096
|
||||
)
|
||||
|
||||
type dialogListHashCacheKey struct {
|
||||
userID int64
|
||||
pinnedOnly bool
|
||||
excludePinned bool
|
||||
hasFolderID bool
|
||||
folderID int
|
||||
limit int
|
||||
}
|
||||
|
||||
type dialogListHashValue struct {
|
||||
hash int64
|
||||
count int
|
||||
}
|
||||
|
||||
// dialogListHashCache 由统一缓存原语承载,走「外部构建再写回」(GetDialogs 在加载前 cacheEpoch
|
||||
// 快照 epoch → rememberDialogListHash 经 putIfEpoch 写回)。原语的 epoch 仅在 Invalidate/Flush
|
||||
// (写驱动失效)自增,被动 TTL 过期纯 per-key 不动 epoch——正是本缓存所需(避免误返 NotModified)。
|
||||
type dialogListHashCache struct {
|
||||
cache *readmodelcache.Cache[dialogListHashCacheKey, dialogListHashValue]
|
||||
}
|
||||
|
||||
func newDialogListHashCache(ttl time.Duration) *dialogListHashCache {
|
||||
if ttl <= 0 {
|
||||
ttl = defaultDialogListHashCacheTTL
|
||||
}
|
||||
return &dialogListHashCache{
|
||||
cache: readmodelcache.New[dialogListHashCacheKey, dialogListHashValue](readmodelcache.Config[dialogListHashCacheKey, dialogListHashValue]{
|
||||
MaxEntries: dialogListHashCacheMaxEntries,
|
||||
TTL: ttl,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) GetDialogsHash(_ context.Context, userID int64, filter domain.DialogFilter) (domain.DialogHashCheck, error) {
|
||||
if s == nil || s.listHashCache == nil || filter.Hash == 0 {
|
||||
return domain.DialogHashCheck{}, nil
|
||||
}
|
||||
key, ok := dialogListHashKey(userID, filter)
|
||||
if !ok {
|
||||
return domain.DialogHashCheck{}, nil
|
||||
}
|
||||
snap, ok := s.listHashCache.lookup(key)
|
||||
if !ok {
|
||||
return domain.DialogHashCheck{}, nil
|
||||
}
|
||||
return domain.DialogHashCheck{
|
||||
Known: true,
|
||||
Matched: snap.hash == filter.Hash,
|
||||
Hash: snap.hash,
|
||||
Count: snap.count,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) rememberDialogListHash(userID int64, filter domain.DialogFilter, list domain.DialogList, loadEpoch uint64) {
|
||||
if s == nil || s.listHashCache == nil || list.Hash == 0 {
|
||||
return
|
||||
}
|
||||
key, ok := dialogListHashKey(userID, filter)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.listHashCache.putIfEpoch(key, list.Hash, list.Count, loadEpoch)
|
||||
}
|
||||
|
||||
func dialogListHashKey(userID int64, filter domain.DialogFilter) (dialogListHashCacheKey, bool) {
|
||||
if userID == 0 || filter.OffsetDate != 0 || filter.OffsetID != 0 || filter.HasOffsetPeer {
|
||||
return dialogListHashCacheKey{}, false
|
||||
}
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
return dialogListHashCacheKey{
|
||||
userID: userID,
|
||||
pinnedOnly: filter.PinnedOnly,
|
||||
excludePinned: filter.ExcludePinned,
|
||||
hasFolderID: filter.HasFolderID,
|
||||
folderID: filter.FolderID,
|
||||
limit: limit,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (c *dialogListHashCache) lookup(key dialogListHashCacheKey) (dialogListHashValue, bool) {
|
||||
if c == nil {
|
||||
return dialogListHashValue{}, false
|
||||
}
|
||||
return c.cache.Peek(key)
|
||||
}
|
||||
|
||||
// putIfEpoch 仅在 epoch 未变(加载期间没有写驱动失效)时写入,堵住 stale write-back race。
|
||||
func (c *dialogListHashCache) putIfEpoch(key dialogListHashCacheKey, hash int64, count int, loadEpoch uint64) {
|
||||
if c == nil || key.userID == 0 || hash == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.StoreIfEpoch(key, dialogListHashValue{hash: hash, count: count}, loadEpoch)
|
||||
}
|
||||
|
||||
func (c *dialogListHashCache) cacheEpoch() uint64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.cache.LoadEpoch()
|
||||
}
|
||||
|
||||
func (c *dialogListHashCache) invalidateOwner(userID int64) {
|
||||
if c == nil || userID == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.InvalidateWhere(func(key dialogListHashCacheKey) bool { return key.userID == userID })
|
||||
}
|
||||
|
||||
func (c *dialogListHashCache) flush() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.cache.Flush()
|
||||
}
|
||||
506
internal/app/dialogs/read_model_cache.go
Normal file
506
internal/app/dialogs/read_model_cache.go
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
dialogLightReadModel = readmodel.ModelDialogLight
|
||||
channelBaseReadModel = readmodel.ModelChannelBase
|
||||
channelMemberReadModel = readmodel.ModelChannelMember
|
||||
defaultDialogPeerReadModelTTL = 24 * time.Hour
|
||||
dialogPeerReadModelMaxEntries = 8192
|
||||
)
|
||||
|
||||
type dialogPeerCacheKey struct {
|
||||
userID int64
|
||||
peer domain.Peer
|
||||
}
|
||||
|
||||
// dialogPeerReadModelCache 由统一缓存原语承载(epoch 守卫 / LRU / clone)。它走 per-peer
|
||||
// 外部构建:Service 按 peer 查缓存、把 miss 合批打一次后端、再 per-peer 写回。版本闸门用
|
||||
// 值自带的 DialogList.Hash 比对(原语存 hash=0,版本由值携带)。返回的是整批原始 list(非
|
||||
// per-peer 切片重组),故不用 GetOrLoadBatch——它返回 per-key 值会丢非 peer 归属的全局元素。
|
||||
type dialogPeerReadModelCache struct {
|
||||
cache *readmodelcache.Cache[dialogPeerCacheKey, domain.DialogList]
|
||||
}
|
||||
|
||||
func newDialogPeerReadModelCache(ttl time.Duration) *dialogPeerReadModelCache {
|
||||
if ttl <= 0 {
|
||||
ttl = defaultDialogPeerReadModelTTL
|
||||
}
|
||||
return &dialogPeerReadModelCache{
|
||||
cache: readmodelcache.New[dialogPeerCacheKey, domain.DialogList](readmodelcache.Config[dialogPeerCacheKey, domain.DialogList]{
|
||||
MaxEntries: dialogPeerReadModelMaxEntries,
|
||||
TTL: ttl,
|
||||
Clone: cloneDialogList,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) userPeerDialogsReadModel(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
|
||||
if s == nil {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
unique := uniqueUserPeers(peers)
|
||||
if len(unique) == 0 {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
return s.cachedPeerDialogsReadModel(ctx, userID, unique, s.userDialogHashes, s.loadUserPeerDialogs)
|
||||
}
|
||||
|
||||
func (s *Service) channelPeerDialogsReadModel(ctx context.Context, userID int64, channelIDs []int64) (domain.DialogList, error) {
|
||||
if s == nil {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
unique := uniqueChannelPeers(channelIDs)
|
||||
if len(unique) == 0 {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
return s.cachedPeerDialogsReadModel(ctx, userID, unique, s.channelDialogHashes, s.loadChannelPeerDialogsByPeers)
|
||||
}
|
||||
|
||||
func (s *Service) cachedPeerDialogsReadModel(
|
||||
ctx context.Context,
|
||||
userID int64,
|
||||
peers []domain.Peer,
|
||||
hashesFor func(context.Context, int64, []domain.Peer) (map[domain.Peer]int64, error),
|
||||
load func(context.Context, int64, []domain.Peer) (domain.DialogList, error),
|
||||
) (domain.DialogList, error) {
|
||||
if s.peerCache == nil || s.versions == nil {
|
||||
return load(ctx, userID, peers)
|
||||
}
|
||||
hashes, err := hashesFor(ctx, userID, peers)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
loadEpoch := s.peerCache.cacheEpoch()
|
||||
var out domain.DialogList
|
||||
misses := make([]domain.Peer, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
hash := hashes[peer]
|
||||
if hash != 0 {
|
||||
if cached, ok := s.peerCache.lookup(dialogPeerCacheKey{userID: userID, peer: peer}, hash); ok {
|
||||
out = mergeDialogLists(out, cached)
|
||||
continue
|
||||
}
|
||||
}
|
||||
misses = append(misses, peer)
|
||||
}
|
||||
if len(misses) == 0 {
|
||||
out.Count = len(out.Dialogs)
|
||||
return out, nil
|
||||
}
|
||||
list, err := load(ctx, userID, misses)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
for _, peer := range misses {
|
||||
hash := hashes[peer]
|
||||
if hash == 0 {
|
||||
continue
|
||||
}
|
||||
peerList := dialogListForPeer(list, peer)
|
||||
peerList.Hash = hash
|
||||
s.peerCache.putIfEpoch(dialogPeerCacheKey{userID: userID, peer: peer}, peerList, hash, loadEpoch)
|
||||
}
|
||||
if len(out.Dialogs) > 0 || len(out.Messages) > 0 || len(out.ChannelMessages) > 0 || len(out.Users) > 0 || len(out.Channels) > 0 {
|
||||
return mergeDialogLists(out, list), nil
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadUserPeerDialogs(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
|
||||
if s == nil || s.dialogs == nil || len(peers) == 0 {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
list, err := s.dialogs.ListByPeers(ctx, userID, peers)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if err := s.attachDrafts(ctx, userID, &list); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if err := s.projectDialogUsers(ctx, userID, &list); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadChannelPeerDialogsByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
|
||||
if s == nil || s.channels == nil || len(peers) == 0 {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
channelIDs := channelPeerIDs(peers)
|
||||
if len(channelIDs) == 0 {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
list, err := s.channels.GetChannelDialogs(ctx, userID, channelIDs)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out := mergeChannelDialogs(domain.DialogList{}, 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
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) userDialogHashes(ctx context.Context, userID int64, peers []domain.Peer) (map[domain.Peer]int64, error) {
|
||||
keys := make([]store.ReadModelKey, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
keys = append(keys, store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID})
|
||||
}
|
||||
rows, err := s.versions.ReadModelHashes(ctx, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[domain.Peer]int64, len(peers))
|
||||
for _, peer := range peers {
|
||||
out[peer] = rows[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) channelDialogHashes(ctx context.Context, userID int64, peers []domain.Peer) (map[domain.Peer]int64, error) {
|
||||
keys := make([]store.ReadModelKey, 0, len(peers)*3)
|
||||
for _, peer := range peers {
|
||||
keys = append(keys,
|
||||
store.ReadModelKey{Model: channelBaseReadModel, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID},
|
||||
store.ReadModelKey{Model: channelMemberReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID},
|
||||
store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID},
|
||||
)
|
||||
}
|
||||
rows, err := s.versions.ReadModelHashes(ctx, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[domain.Peer]int64, len(peers))
|
||||
for _, peer := range peers {
|
||||
base := rows[store.ReadModelKey{Model: channelBaseReadModel, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}]
|
||||
if base == 0 {
|
||||
continue
|
||||
}
|
||||
member := rows[store.ReadModelKey{Model: channelMemberReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}]
|
||||
dialog := rows[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}]
|
||||
out[peer] = readmodel.MixHashes(base, member, dialog)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// lookup 命中且版本(值自带 DialogList.Hash)匹配才返回;原语已在返回边界 clone。
|
||||
func (c *dialogPeerReadModelCache) lookup(key dialogPeerCacheKey, currentHash int64) (domain.DialogList, bool) {
|
||||
if c == nil {
|
||||
return domain.DialogList{}, false
|
||||
}
|
||||
list, ok := c.cache.Peek(key)
|
||||
if !ok || (currentHash != 0 && list.Hash != currentHash) {
|
||||
return domain.DialogList{}, false
|
||||
}
|
||||
return list, true
|
||||
}
|
||||
|
||||
func (c *dialogPeerReadModelCache) putIfEpoch(key dialogPeerCacheKey, list domain.DialogList, hash int64, expectedEpoch uint64) {
|
||||
if c == nil || key.userID == 0 || key.peer.Type == "" || key.peer.ID == 0 || hash == 0 {
|
||||
return
|
||||
}
|
||||
list.Hash = hash
|
||||
c.cache.StoreIfEpoch(key, list, expectedEpoch)
|
||||
}
|
||||
|
||||
func (c *dialogPeerReadModelCache) invalidate(key dialogPeerCacheKey) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.cache.Invalidate(key)
|
||||
}
|
||||
|
||||
func (c *dialogPeerReadModelCache) flush() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.cache.Flush()
|
||||
}
|
||||
|
||||
func (c *dialogPeerReadModelCache) cacheEpoch() uint64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.cache.LoadEpoch()
|
||||
}
|
||||
|
||||
func (s *Service) InvalidateDialog(userID int64, peer domain.Peer) {
|
||||
if s == nil || userID == 0 {
|
||||
return
|
||||
}
|
||||
s.invalidateDialogListHashes(userID)
|
||||
if s.peerCache == nil || peer.Type == "" || peer.ID == 0 {
|
||||
return
|
||||
}
|
||||
s.peerCache.invalidate(dialogPeerCacheKey{userID: userID, peer: peer})
|
||||
}
|
||||
|
||||
func (s *Service) FlushReadModelCache() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.peerCache != nil {
|
||||
s.peerCache.flush()
|
||||
}
|
||||
if s.listHashCache != nil {
|
||||
s.listHashCache.flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) invalidateDialogListHashes(userID int64) {
|
||||
if s == nil || s.listHashCache == nil || userID == 0 {
|
||||
return
|
||||
}
|
||||
s.listHashCache.invalidateOwner(userID)
|
||||
}
|
||||
|
||||
func uniqueUserPeers(peers []domain.Peer) []domain.Peer {
|
||||
return uniquePeersOfType(peers, domain.PeerTypeUser)
|
||||
}
|
||||
|
||||
func uniqueChannelPeers(channelIDs []int64) []domain.Peer {
|
||||
out := make([]domain.Peer, 0, len(channelIDs))
|
||||
seen := make(map[int64]struct{}, len(channelIDs))
|
||||
for _, id := range channelIDs {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, domain.Peer{Type: domain.PeerTypeChannel, ID: id})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func uniquePeersOfType(peers []domain.Peer, peerType domain.PeerType) []domain.Peer {
|
||||
out := make([]domain.Peer, 0, len(peers))
|
||||
seen := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
if peer.Type != peerType || peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
out = append(out, peer)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func channelPeerIDs(peers []domain.Peer) []int64 {
|
||||
out := make([]int64, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
if peer.Type == domain.PeerTypeChannel && peer.ID != 0 {
|
||||
out = append(out, peer.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dialogListForPeer(list domain.DialogList, peer domain.Peer) domain.DialogList {
|
||||
out := domain.DialogList{Hash: list.Hash}
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer == peer {
|
||||
out.Dialogs = append(out.Dialogs, cloneDialog(dialog))
|
||||
}
|
||||
}
|
||||
for _, msg := range list.Messages {
|
||||
if msg.Peer == peer {
|
||||
out.Messages = append(out.Messages, cloneMessageForDialogCache(msg))
|
||||
}
|
||||
}
|
||||
for _, msg := range list.ChannelMessages {
|
||||
if msg.ChannelID == peer.ID && peer.Type == domain.PeerTypeChannel {
|
||||
out.ChannelMessages = append(out.ChannelMessages, cloneChannelMessageForDialogCache(msg))
|
||||
}
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
for _, user := range list.Users {
|
||||
if user.ID == peer.ID {
|
||||
out.Users = append(out.Users, cloneDialogUser(user))
|
||||
}
|
||||
}
|
||||
case domain.PeerTypeChannel:
|
||||
var linkedID int64
|
||||
for _, channel := range list.Channels {
|
||||
if channel.ID == peer.ID {
|
||||
out.Channels = append(out.Channels, cloneDialogChannel(channel))
|
||||
// monoforum 与母广播频道互为 linked_monoforum_id。per-peer 缓存必须把同批下发的关联频道
|
||||
// 一并保留,否则缓存命中时 getPeerDialogs 只回该 peer 自身、丢掉关联频道,客户端无法
|
||||
// resolve linked_monoforum_id(GetChannelDialogs 的同批下发在缓存层被抹掉)。
|
||||
if channel.LinkedMonoforumID != 0 && (channel.Monoforum || channel.BroadcastMessagesAllowed) {
|
||||
linkedID = channel.LinkedMonoforumID
|
||||
}
|
||||
}
|
||||
}
|
||||
if linkedID != 0 {
|
||||
for _, channel := range list.Channels {
|
||||
if channel.ID == linkedID {
|
||||
out.Channels = append(out.Channels, cloneDialogChannel(channel))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.Count = len(out.Dialogs)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneDialogList(in domain.DialogList) domain.DialogList {
|
||||
in.Dialogs = cloneDialogSlice(in.Dialogs)
|
||||
in.Messages = cloneDialogMessages(in.Messages)
|
||||
in.ChannelMessages = cloneDialogChannelMessages(in.ChannelMessages)
|
||||
in.Users = cloneDialogUsers(in.Users)
|
||||
in.Channels = cloneDialogChannels(in.Channels)
|
||||
return in
|
||||
}
|
||||
|
||||
func cloneDialogSlice(in []domain.Dialog) []domain.Dialog {
|
||||
out := make([]domain.Dialog, len(in))
|
||||
for i := range in {
|
||||
out[i] = cloneDialog(in[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneDialog(in domain.Dialog) domain.Dialog {
|
||||
if in.Draft != nil {
|
||||
draft := cloneDraft(*in.Draft)
|
||||
in.Draft = &draft
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func cloneDialogMessages(in []domain.Message) []domain.Message {
|
||||
out := make([]domain.Message, len(in))
|
||||
for i := range in {
|
||||
out[i] = cloneMessageForDialogCache(in[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMessageForDialogCache(msg domain.Message) domain.Message {
|
||||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||||
if msg.ReplyTo != nil {
|
||||
reply := *msg.ReplyTo
|
||||
reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...)
|
||||
msg.ReplyTo = &reply
|
||||
}
|
||||
if msg.Forward != nil {
|
||||
forward := *msg.Forward
|
||||
msg.Forward = &forward
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func cloneDialogChannelMessages(in []domain.ChannelMessage) []domain.ChannelMessage {
|
||||
out := make([]domain.ChannelMessage, len(in))
|
||||
for i := range in {
|
||||
out[i] = cloneChannelMessageForDialogCache(in[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneChannelMessageForDialogCache(msg domain.ChannelMessage) domain.ChannelMessage {
|
||||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||||
if msg.ReplyTo != nil {
|
||||
reply := *msg.ReplyTo
|
||||
reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...)
|
||||
msg.ReplyTo = &reply
|
||||
}
|
||||
if msg.Forward != nil {
|
||||
forward := *msg.Forward
|
||||
msg.Forward = &forward
|
||||
}
|
||||
if msg.SendAs != nil {
|
||||
sendAs := *msg.SendAs
|
||||
msg.SendAs = &sendAs
|
||||
}
|
||||
if msg.Reactions != nil {
|
||||
reactions := *msg.Reactions
|
||||
reactions.Results = append([]domain.ChannelMessageReactionCount(nil), msg.Reactions.Results...)
|
||||
reactions.Recent = append([]domain.ChannelMessagePeerReaction(nil), msg.Reactions.Recent...)
|
||||
msg.Reactions = &reactions
|
||||
}
|
||||
if msg.ReplyMarkup != nil {
|
||||
msg.ReplyMarkup = cloneReplyMarkupForDialogCache(msg.ReplyMarkup)
|
||||
}
|
||||
if msg.Action != nil {
|
||||
action := *msg.Action
|
||||
action.UserIDs = append([]int64(nil), msg.Action.UserIDs...)
|
||||
action.Completed = append([]int(nil), msg.Action.Completed...)
|
||||
action.Incompleted = append([]int(nil), msg.Action.Incompleted...)
|
||||
action.TodoItems = append([]domain.MessageTodoItem(nil), msg.Action.TodoItems...)
|
||||
msg.Action = &action
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func cloneReplyMarkupForDialogCache(in *domain.MessageReplyMarkup) *domain.MessageReplyMarkup {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := &domain.MessageReplyMarkup{}
|
||||
if len(in.Inline) > 0 {
|
||||
out.Inline = make([][]domain.MarkupButton, len(in.Inline))
|
||||
for i, row := range in.Inline {
|
||||
out.Inline[i] = make([]domain.MarkupButton, len(row))
|
||||
for j, button := range row {
|
||||
out.Inline[i][j] = button
|
||||
out.Inline[i][j].Data = append([]byte(nil), button.Data...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneDialogUsers(in []domain.User) []domain.User {
|
||||
out := make([]domain.User, len(in))
|
||||
for i := range in {
|
||||
out[i] = cloneDialogUser(in[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneDialogUser(in domain.User) domain.User {
|
||||
if in.PhotoStripped != nil {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func cloneDialogChannels(in []domain.Channel) []domain.Channel {
|
||||
out := make([]domain.Channel, len(in))
|
||||
for i := range in {
|
||||
out[i] = cloneDialogChannel(in[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneDialogChannel(in domain.Channel) domain.Channel {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
in.ReactionPolicy.Emoticons = append([]string(nil), in.ReactionPolicy.Emoticons...)
|
||||
in.ReactionPolicy.CustomEmojiIDs = append([]int64(nil), in.ReactionPolicy.CustomEmojiIDs...)
|
||||
return in
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,12 +4,376 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type countingDialogStore struct {
|
||||
store.DialogStore
|
||||
listByUserCalls int
|
||||
listByPeersCalls int
|
||||
listByPeersBatches [][]domain.Peer
|
||||
listDraftsCalls int
|
||||
}
|
||||
|
||||
func (s *countingDialogStore) ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
|
||||
s.listByUserCalls++
|
||||
return s.DialogStore.ListByUser(ctx, userID, filter)
|
||||
}
|
||||
|
||||
func (s *countingDialogStore) ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
|
||||
s.listByPeersCalls++
|
||||
s.listByPeersBatches = append(s.listByPeersBatches, append([]domain.Peer(nil), peers...))
|
||||
return s.DialogStore.ListByPeers(ctx, userID, peers)
|
||||
}
|
||||
|
||||
func (s *countingDialogStore) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
|
||||
s.listDraftsCalls++
|
||||
return s.DialogStore.ListDrafts(ctx, userID, limit)
|
||||
}
|
||||
|
||||
type fakeDialogReadModelVersions struct {
|
||||
hashes map[store.ReadModelKey]int64
|
||||
}
|
||||
|
||||
func (f *fakeDialogReadModelVersions) ReadModelHash(_ context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64) (int64, bool, error) {
|
||||
hash := f.hashes[store.ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID}]
|
||||
return hash, hash != 0, nil
|
||||
}
|
||||
|
||||
func (f *fakeDialogReadModelVersions) ReadModelHashes(_ context.Context, keys []store.ReadModelKey) (map[store.ReadModelKey]int64, error) {
|
||||
out := make(map[store.ReadModelKey]int64, len(keys))
|
||||
for _, key := range keys {
|
||||
if hash := f.hashes[key]; hash != 0 {
|
||||
out[key] = hash
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type countingDialogChannelStore struct {
|
||||
*memory.ChannelStore
|
||||
getChannelCalls int
|
||||
getChannelsCalls int
|
||||
getChannelDialogsCalls int
|
||||
}
|
||||
|
||||
func (s *countingDialogChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) {
|
||||
s.getChannelCalls++
|
||||
return s.ChannelStore.GetChannel(ctx, viewerUserID, channelID)
|
||||
}
|
||||
|
||||
func (s *countingDialogChannelStore) GetChannels(ctx context.Context, viewerUserID int64, channelIDs []int64) ([]domain.ChannelView, error) {
|
||||
s.getChannelsCalls++
|
||||
return s.ChannelStore.GetChannels(ctx, viewerUserID, channelIDs)
|
||||
}
|
||||
|
||||
func (s *countingDialogChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) {
|
||||
s.getChannelDialogsCalls++
|
||||
return s.ChannelStore.GetChannelDialogs(ctx, viewerUserID, channelIDs)
|
||||
}
|
||||
|
||||
func TestGetDialogsHashUsesWarmStableHashCacheAndInvalidatesOnWrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveList(ctx, ownerID, domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{
|
||||
Peer: peer,
|
||||
TopMessage: 7,
|
||||
TopMessageDate: 70,
|
||||
UnreadCount: 1,
|
||||
}},
|
||||
Messages: []domain.Message{{
|
||||
ID: 7,
|
||||
OwnerUserID: ownerID,
|
||||
Peer: peer,
|
||||
From: peer,
|
||||
Date: 70,
|
||||
Body: "cached top",
|
||||
}},
|
||||
Users: []domain.User{{ID: peer.ID, AccessHash: 22, FirstName: "Peer"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveList: %v", err)
|
||||
}
|
||||
counting := &countingDialogStore{DialogStore: base}
|
||||
dialogs := NewService(counting)
|
||||
filter := domain.DialogFilter{ExcludePinned: true, Limit: 10}
|
||||
list, err := dialogs.GetDialogs(ctx, ownerID, filter)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs warm: %v", err)
|
||||
}
|
||||
if list.Hash == 0 {
|
||||
t.Fatal("warmed list hash = 0, want stable non-zero hash")
|
||||
}
|
||||
|
||||
check, err := dialogs.GetDialogsHash(ctx, ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 10, Hash: list.Hash})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogsHash: %v", err)
|
||||
}
|
||||
if !check.Known || !check.Matched || check.Count != list.Count {
|
||||
t.Fatalf("hash check = %+v, want known matched count %d", check, list.Count)
|
||||
}
|
||||
if counting.listByUserCalls != 1 {
|
||||
t.Fatalf("ListByUser calls = %d, want only warm load", counting.listByUserCalls)
|
||||
}
|
||||
|
||||
if _, err := dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 71, Message: "new draft"}); err != nil {
|
||||
t.Fatalf("SaveDraft: %v", err)
|
||||
}
|
||||
check, err = dialogs.GetDialogsHash(ctx, ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 10, Hash: list.Hash})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogsHash after invalidation: %v", err)
|
||||
}
|
||||
if check.Known {
|
||||
t.Fatalf("hash check after invalidation = %+v, want unknown", check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDraftNoopsWhenOnlyDateChanges(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
base := memory.NewDialogStore()
|
||||
dialogs := NewService(base)
|
||||
|
||||
changed, err := dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 71, Message: "draft"})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDraft first: %v", err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatalf("SaveDraft first changed = false, want true")
|
||||
}
|
||||
changed, err = dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 72, Message: "draft"})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDraft same content: %v", err)
|
||||
}
|
||||
if changed {
|
||||
t.Fatalf("SaveDraft same content changed = true, want false")
|
||||
}
|
||||
got, found, err := base.GetDraft(ctx, ownerID, peer, 0)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetDraft = found %v err %v, want stored draft", found, err)
|
||||
}
|
||||
if got.Date != 71 {
|
||||
t.Fatalf("draft date = %d, want original 71", got.Date)
|
||||
}
|
||||
|
||||
changed, err = dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 73, Message: "updated"})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDraft updated: %v", err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatalf("SaveDraft updated changed = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPeerDialogsCachesPrivatePeerReadModelByHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveList(ctx, ownerID, domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{
|
||||
Peer: peer,
|
||||
TopMessage: 7,
|
||||
TopMessageDate: 70,
|
||||
UnreadCount: 1,
|
||||
}},
|
||||
Messages: []domain.Message{{
|
||||
ID: 7,
|
||||
OwnerUserID: ownerID,
|
||||
Peer: peer,
|
||||
From: peer,
|
||||
Date: 70,
|
||||
Body: "cached top",
|
||||
}},
|
||||
Users: []domain.User{{ID: peer.ID, AccessHash: 22, FirstName: "Peer"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveList: %v", err)
|
||||
}
|
||||
if err := base.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 71, Message: "draft"}); err != nil {
|
||||
t.Fatalf("SaveDraft: %v", err)
|
||||
}
|
||||
counting := &countingDialogStore{DialogStore: base}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 101,
|
||||
}}
|
||||
dialogs := NewService(counting).Configure(WithReadModelVersions(versions))
|
||||
|
||||
first, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer})
|
||||
if err != nil {
|
||||
t.Fatalf("first GetPeerDialogs: %v", err)
|
||||
}
|
||||
if len(first.Dialogs) != 1 || first.Dialogs[0].Draft == nil || first.Dialogs[0].Draft.Message != "draft" {
|
||||
t.Fatalf("first dialog = %+v, want cached draft attached", first.Dialogs)
|
||||
}
|
||||
second, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer})
|
||||
if err != nil {
|
||||
t.Fatalf("second GetPeerDialogs: %v", err)
|
||||
}
|
||||
if len(second.Dialogs) != 1 || second.Dialogs[0].TopMessage != 7 {
|
||||
t.Fatalf("second dialog = %+v, want cached top message", second.Dialogs)
|
||||
}
|
||||
if counting.listByPeersCalls != 1 || counting.listDraftsCalls != 1 {
|
||||
t.Fatalf("store calls ListByPeers/ListDrafts = %d/%d, want 1/1 after cache hit", counting.listByPeersCalls, counting.listDraftsCalls)
|
||||
}
|
||||
|
||||
versions.hashes[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}] = 202
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("third GetPeerDialogs after hash bump: %v", err)
|
||||
}
|
||||
if counting.listByPeersCalls != 2 || counting.listDraftsCalls != 2 {
|
||||
t.Fatalf("store calls after hash bump = %d/%d, want 2/2", counting.listByPeersCalls, counting.listDraftsCalls)
|
||||
}
|
||||
|
||||
if _, err := dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 72, Message: "new draft"}); err != nil {
|
||||
t.Fatalf("service SaveDraft: %v", err)
|
||||
}
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("GetPeerDialogs after service invalidation: %v", err)
|
||||
}
|
||||
if counting.listByPeersCalls != 3 || counting.listDraftsCalls != 3 {
|
||||
t.Fatalf("store calls after explicit invalidation = %d/%d, want 3/3", counting.listByPeersCalls, counting.listDraftsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPeerDialogsReloadsOnlyReadModelCacheMisses(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
firstPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
secondPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1003}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveList(ctx, ownerID, domain.DialogList{
|
||||
Dialogs: []domain.Dialog{
|
||||
{Peer: firstPeer, TopMessage: 7, TopMessageDate: 70},
|
||||
{Peer: secondPeer, TopMessage: 8, TopMessageDate: 80},
|
||||
},
|
||||
Messages: []domain.Message{
|
||||
{ID: 7, OwnerUserID: ownerID, Peer: firstPeer, From: firstPeer, Date: 70, Body: "first"},
|
||||
{ID: 8, OwnerUserID: ownerID, Peer: secondPeer, From: secondPeer, Date: 80, Body: "second"},
|
||||
},
|
||||
Users: []domain.User{
|
||||
{ID: firstPeer.ID, AccessHash: 22, FirstName: "First"},
|
||||
{ID: secondPeer.ID, AccessHash: 33, FirstName: "Second"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveList: %v", err)
|
||||
}
|
||||
counting := &countingDialogStore{DialogStore: base}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: firstPeer.Type, PeerID: firstPeer.ID}: 101,
|
||||
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: secondPeer.Type, PeerID: secondPeer.ID}: 202,
|
||||
}}
|
||||
dialogs := NewService(counting).Configure(WithReadModelVersions(versions))
|
||||
peers := []domain.Peer{firstPeer, secondPeer}
|
||||
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, peers); err != nil {
|
||||
t.Fatalf("first GetPeerDialogs: %v", err)
|
||||
}
|
||||
versions.hashes[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: secondPeer.Type, PeerID: secondPeer.ID}] = 303
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, peers); err != nil {
|
||||
t.Fatalf("second GetPeerDialogs after one hash bump: %v", err)
|
||||
}
|
||||
if counting.listByPeersCalls != 2 {
|
||||
t.Fatalf("ListByPeers calls = %d, want 2", counting.listByPeersCalls)
|
||||
}
|
||||
lastBatch := counting.listByPeersBatches[len(counting.listByPeersBatches)-1]
|
||||
if len(lastBatch) != 1 || lastBatch[0] != secondPeer {
|
||||
t.Fatalf("last ListByPeers batch = %+v, want only %+v", lastBatch, secondPeer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPeerDialogsCachesChannelPeerReadModelByCompositeHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
dialogStore := &countingDialogStore{DialogStore: memory.NewDialogStore()}
|
||||
channelStore := &countingDialogChannelStore{ChannelStore: memory.NewChannelStore()}
|
||||
channels := appchannels.NewService(channelStore)
|
||||
created, err := channels.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{
|
||||
Title: "Cached Channel Dialog",
|
||||
Megagroup: true,
|
||||
Date: 1700003200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
sent, err := channels.SendMessage(ctx, ownerID, domain.SendChannelMessageRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
RandomID: 11,
|
||||
Message: "cached channel top",
|
||||
Date: 1700003210,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: channelBaseReadModel, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}: 11,
|
||||
{Model: channelMemberReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 22,
|
||||
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 33,
|
||||
}}
|
||||
dialogs := NewService(dialogStore, channelStore).Configure(WithReadModelVersions(versions))
|
||||
|
||||
first, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer})
|
||||
if err != nil {
|
||||
t.Fatalf("first GetPeerDialogs: %v", err)
|
||||
}
|
||||
if len(first.Dialogs) != 1 || first.Dialogs[0].TopMessage != sent.Message.ID {
|
||||
t.Fatalf("first dialogs = %+v, want channel top %d", first.Dialogs, sent.Message.ID)
|
||||
}
|
||||
if len(first.ChannelMessages) != 1 || first.ChannelMessages[0].Body != "cached channel top" {
|
||||
t.Fatalf("first channel messages = %+v, want cached top message", first.ChannelMessages)
|
||||
}
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("second GetPeerDialogs: %v", err)
|
||||
}
|
||||
if channelStore.getChannelDialogsCalls != 1 || dialogStore.listDraftsCalls != 1 {
|
||||
t.Fatalf("store calls GetChannelDialogs/ListDrafts = %d/%d, want 1/1 after channel cache hit",
|
||||
channelStore.getChannelDialogsCalls, dialogStore.listDraftsCalls)
|
||||
}
|
||||
|
||||
versions.hashes[store.ReadModelKey{Model: channelMemberReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}] = 44
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("third GetPeerDialogs after member hash bump: %v", err)
|
||||
}
|
||||
if channelStore.getChannelDialogsCalls != 2 || dialogStore.listDraftsCalls != 2 {
|
||||
t.Fatalf("store calls after member hash bump = %d/%d, want 2/2",
|
||||
channelStore.getChannelDialogsCalls, dialogStore.listDraftsCalls)
|
||||
}
|
||||
|
||||
if _, err := dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 1700003220, Message: "channel draft"}); err != nil {
|
||||
t.Fatalf("SaveDraft: %v", err)
|
||||
}
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("GetPeerDialogs after draft invalidation: %v", err)
|
||||
}
|
||||
if channelStore.getChannelDialogsCalls != 3 || dialogStore.listDraftsCalls != 3 {
|
||||
t.Fatalf("store calls after draft invalidation = %d/%d, want 3/3",
|
||||
channelStore.getChannelDialogsCalls, dialogStore.listDraftsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogPeerReadModelCacheRejectsStaleFillAfterInvalidation(t *testing.T) {
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
key := dialogPeerCacheKey{userID: 1001, peer: peer}
|
||||
cache := newDialogPeerReadModelCache(time.Hour)
|
||||
epoch := cache.cacheEpoch()
|
||||
cache.invalidate(key)
|
||||
cache.putIfEpoch(key, domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 7}},
|
||||
Hash: 101,
|
||||
}, 101, epoch)
|
||||
if _, ok := cache.lookup(key, 101); ok {
|
||||
t.Fatalf("stale cache fill survived invalidation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsIncludesChannelReadOutboxAfterOfflineRead(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := memory.NewChannelStore()
|
||||
|
|
@ -143,14 +507,14 @@ func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) {
|
|||
firstPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: first.Channel.ID}
|
||||
secondPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: second.Channel.ID}
|
||||
|
||||
if changed, err := dialogs.TogglePinned(ctx, 1001, firstPeer, true); err != nil || !changed {
|
||||
if changed, _, err := dialogs.TogglePinned(ctx, 1001, firstPeer, true); err != nil || !changed {
|
||||
t.Fatalf("TogglePinned first = changed %v err %v, want changed", changed, err)
|
||||
}
|
||||
if changed, err := dialogs.TogglePinned(ctx, 1001, secondPeer, true); err != nil || !changed {
|
||||
if changed, _, err := dialogs.TogglePinned(ctx, 1001, secondPeer, true); err != nil || !changed {
|
||||
t.Fatalf("TogglePinned second = changed %v err %v, want changed", changed, err)
|
||||
}
|
||||
if err := dialogs.ReorderPinned(ctx, 1001, []domain.Peer{secondPeer, firstPeer}, true); err != nil {
|
||||
t.Fatalf("ReorderPinned: %v", err)
|
||||
if changed, err := dialogs.ReorderPinned(ctx, 1001, domain.DialogMainFolderID, []domain.Peer{secondPeer, firstPeer}, true); err != nil || changed {
|
||||
t.Fatalf("ReorderPinned same order = changed %v err %v, want no-op", changed, err)
|
||||
}
|
||||
if changed, err := dialogs.MarkUnread(ctx, 1001, firstPeer, true); err != nil || !changed {
|
||||
t.Fatalf("MarkUnread = changed %v err %v, want changed", changed, err)
|
||||
|
|
@ -165,9 +529,15 @@ func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("GetDialogs: %v", err)
|
||||
}
|
||||
firstDialog := findChannelDialog(t, list, first.Channel.ID)
|
||||
if !firstDialog.Pinned || firstDialog.PinnedOrder != 1 || !firstDialog.UnreadMark || firstDialog.FolderID != domain.DialogArchiveFolderID {
|
||||
t.Fatalf("first dialog = %+v, want pinned order 1, unread mark, archived", firstDialog)
|
||||
// 归档对话不再出现在主列表(缺省 folder 视为 folder 0),主列表以
|
||||
// ArchiveSummary 聚合呈现归档状态。
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID == first.Channel.ID {
|
||||
t.Fatalf("archived dialog leaked into main list: %+v", dialog)
|
||||
}
|
||||
}
|
||||
if list.ArchiveSummary == nil {
|
||||
t.Fatalf("main list archive summary = nil, want attached after archiving")
|
||||
}
|
||||
secondDialog := findChannelDialog(t, list, second.Channel.ID)
|
||||
if !secondDialog.Pinned || secondDialog.PinnedOrder != 2 {
|
||||
|
|
@ -188,9 +558,15 @@ func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("GetDialogs archive: %v", err)
|
||||
}
|
||||
if got := findChannelDialog(t, archived, first.Channel.ID); got.FolderID != domain.DialogArchiveFolderID {
|
||||
got := findChannelDialog(t, archived, first.Channel.ID)
|
||||
if got.FolderID != domain.DialogArchiveFolderID {
|
||||
t.Fatalf("archived dialog = %+v, want archive folder", got)
|
||||
}
|
||||
// 归档清除 pinned(对齐 TDesktop History::setFolderPointer 的本地 unpin),
|
||||
// unread_mark 保留。
|
||||
if got.Pinned || got.PinnedOrder != 0 || !got.UnreadMark {
|
||||
t.Fatalf("archived dialog = %+v, want unpinned with unread mark", got)
|
||||
}
|
||||
|
||||
custom, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{
|
||||
HasFolderID: true,
|
||||
|
|
@ -206,6 +582,64 @@ func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTogglePinnedPromotesNewestPinnedAcrossPrivateAndChannelDialogs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
dialogStore := memory.NewDialogStore()
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore)
|
||||
dialogs := NewService(dialogStore, channelStore)
|
||||
privatePeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
if err := dialogStore.SaveList(ctx, ownerID, domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{
|
||||
Peer: privatePeer,
|
||||
TopMessage: 30,
|
||||
TopMessageDate: 3000,
|
||||
}},
|
||||
Messages: []domain.Message{{
|
||||
ID: 30,
|
||||
OwnerUserID: ownerID,
|
||||
Peer: privatePeer,
|
||||
From: privatePeer,
|
||||
Date: 3000,
|
||||
Body: "newer private top",
|
||||
}},
|
||||
Users: []domain.User{{ID: privatePeer.ID, AccessHash: 22, FirstName: "Private"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveList private dialog: %v", err)
|
||||
}
|
||||
created, err := channels.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{
|
||||
Title: "Older Channel",
|
||||
Megagroup: true,
|
||||
Date: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
|
||||
|
||||
if changed, _, err := dialogs.TogglePinned(ctx, ownerID, privatePeer, true); err != nil || !changed {
|
||||
t.Fatalf("TogglePinned private = changed %v err %v, want changed", changed, err)
|
||||
}
|
||||
if changed, _, err := dialogs.TogglePinned(ctx, ownerID, channelPeer, true); err != nil || !changed {
|
||||
t.Fatalf("TogglePinned channel = changed %v err %v, want changed", changed, err)
|
||||
}
|
||||
|
||||
list, err := dialogs.GetDialogs(ctx, ownerID, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) < 2 {
|
||||
t.Fatalf("dialogs = %+v, want private and channel dialogs", list.Dialogs)
|
||||
}
|
||||
if list.Dialogs[0].Peer != channelPeer || list.Dialogs[0].PinnedOrder != 2 {
|
||||
t.Fatalf("first dialog = %+v, want newly pinned channel with highest order", list.Dialogs[0])
|
||||
}
|
||||
if list.Dialogs[1].Peer != privatePeer || list.Dialogs[1].PinnedOrder != 1 {
|
||||
t.Fatalf("second dialog = %+v, want older pinned private dialog", list.Dialogs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsAppliesChannelDialogOffset(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := memory.NewChannelStore()
|
||||
|
|
@ -328,6 +762,99 @@ func TestGetPeerDialogsIncludesPublicChannelPreviewForNonMember(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetPeerDialogsBatchesMissingChannelPreviews(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := &countingDialogChannelStore{ChannelStore: memory.NewChannelStore()}
|
||||
channels := appchannels.NewService(channelStore)
|
||||
dialogs := NewService(nil, channelStore)
|
||||
|
||||
first, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Batch Preview One",
|
||||
Broadcast: true,
|
||||
Date: 1700002100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel first: %v", err)
|
||||
}
|
||||
if _, err := channels.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{
|
||||
UserID: 1001,
|
||||
ChannelID: first.Channel.ID,
|
||||
Username: "batch_preview_one",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateUsername first: %v", err)
|
||||
}
|
||||
firstMsg, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
|
||||
ChannelID: first.Channel.ID,
|
||||
RandomID: 101,
|
||||
Message: "first public preview",
|
||||
Date: 1700002110,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage first: %v", err)
|
||||
}
|
||||
|
||||
second, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Batch Preview Two",
|
||||
Broadcast: true,
|
||||
Date: 1700002120,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel second: %v", err)
|
||||
}
|
||||
if _, err := channels.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{
|
||||
UserID: 1001,
|
||||
ChannelID: second.Channel.ID,
|
||||
Username: "batch_preview_two",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateUsername second: %v", err)
|
||||
}
|
||||
secondMsg, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
|
||||
ChannelID: second.Channel.ID,
|
||||
RandomID: 102,
|
||||
Message: "second public preview",
|
||||
Date: 1700002130,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage second: %v", err)
|
||||
}
|
||||
|
||||
private, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Batch Preview Private",
|
||||
Broadcast: true,
|
||||
Date: 1700002140,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel private: %v", err)
|
||||
}
|
||||
|
||||
channelStore.getChannelCalls = 0
|
||||
channelStore.getChannelsCalls = 0
|
||||
list, err := dialogs.GetPeerDialogs(ctx, 1002, []domain.Peer{
|
||||
{Type: domain.PeerTypeChannel, ID: first.Channel.ID},
|
||||
{Type: domain.PeerTypeChannel, ID: private.Channel.ID},
|
||||
{Type: domain.PeerTypeChannel, ID: second.Channel.ID},
|
||||
{Type: domain.PeerTypeChannel, ID: first.Channel.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetPeerDialogs batch previews: %v", err)
|
||||
}
|
||||
if channelStore.getChannelsCalls != 1 || channelStore.getChannelCalls != 0 {
|
||||
t.Fatalf("preview channel calls: GetChannels=%d GetChannel=%d, want one batch call only", channelStore.getChannelsCalls, channelStore.getChannelCalls)
|
||||
}
|
||||
if len(list.Dialogs) != 2 {
|
||||
t.Fatalf("dialogs = %+v, want two public previews", list.Dialogs)
|
||||
}
|
||||
if got := findChannelDialog(t, list, first.Channel.ID); got.TopMessage != firstMsg.Message.ID {
|
||||
t.Fatalf("first preview top = %d, want %d", got.TopMessage, firstMsg.Message.ID)
|
||||
}
|
||||
if got := findChannelDialog(t, list, second.Channel.ID); got.TopMessage != secondMsg.Message.ID {
|
||||
t.Fatalf("second preview top = %d, want %d", got.TopMessage, secondMsg.Message.ID)
|
||||
}
|
||||
if len(list.ChannelMessages) != 2 {
|
||||
t.Fatalf("channel messages = %+v, want two top messages", list.ChannelMessages)
|
||||
}
|
||||
}
|
||||
|
||||
func findChannelDialog(t *testing.T, list domain.DialogList, channelID int64) domain.Dialog {
|
||||
t.Helper()
|
||||
for _, dialog := range list.Dialogs {
|
||||
|
|
@ -361,3 +888,149 @@ func (p dialogProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.Pe
|
|||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestGetDialogsMainListAttachesArchiveSummary(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogStore := memory.NewDialogStore()
|
||||
dialogs := NewService(dialogStore)
|
||||
|
||||
archivedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2002}
|
||||
mainPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2003}
|
||||
if err := dialogStore.Upsert(ctx, 1001, domain.Dialog{
|
||||
Peer: archivedPeer,
|
||||
FolderID: domain.DialogArchiveFolderID,
|
||||
TopMessage: 7,
|
||||
TopMessageDate: 30,
|
||||
UnreadCount: 3,
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert archived dialog: %v", err)
|
||||
}
|
||||
if err := dialogStore.Upsert(ctx, 1001, domain.Dialog{
|
||||
Peer: mainPeer,
|
||||
TopMessage: 9,
|
||||
TopMessageDate: 40,
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert main dialog: %v", err)
|
||||
}
|
||||
|
||||
list, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs main: %v", err)
|
||||
}
|
||||
if list.ArchiveSummary == nil {
|
||||
t.Fatalf("main list archive summary = nil, want attached")
|
||||
}
|
||||
if list.ArchiveSummary.TopPeer != archivedPeer || list.ArchiveSummary.TopMessage != 7 {
|
||||
t.Fatalf("archive summary top = %+v, want peer %+v message 7", list.ArchiveSummary, archivedPeer)
|
||||
}
|
||||
if list.ArchiveSummary.UnreadPeersCount != 1 || list.ArchiveSummary.UnreadMessagesCount != 3 {
|
||||
t.Fatalf("archive summary counts = %+v, want 1 peer / 3 messages", list.ArchiveSummary)
|
||||
}
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer == archivedPeer {
|
||||
t.Fatalf("archived dialog leaked into main list: %+v", dialog)
|
||||
}
|
||||
}
|
||||
|
||||
archived, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{
|
||||
HasFolderID: true,
|
||||
FolderID: domain.DialogArchiveFolderID,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs archive: %v", err)
|
||||
}
|
||||
if archived.ArchiveSummary != nil {
|
||||
t.Fatalf("archive list summary = %+v, want nil", archived.ArchiveSummary)
|
||||
}
|
||||
|
||||
paged, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10, OffsetID: 9})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs paged: %v", err)
|
||||
}
|
||||
if paged.ArchiveSummary != nil {
|
||||
t.Fatalf("paged list summary = %+v, want nil (first page only)", paged.ArchiveSummary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsMainListSkipsArchiveSummaryWhenEmpty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogStore := memory.NewDialogStore()
|
||||
dialogs := NewService(dialogStore)
|
||||
if err := dialogStore.Upsert(ctx, 1001, domain.Dialog{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2003},
|
||||
TopMessage: 9,
|
||||
TopMessageDate: 40,
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert main dialog: %v", err)
|
||||
}
|
||||
list, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs: %v", err)
|
||||
}
|
||||
if list.ArchiveSummary != nil {
|
||||
t.Fatalf("archive summary = %+v, want nil when no archived dialogs", list.ArchiveSummary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsPinnedOnlyAttachesArchiveSummary(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogStore := memory.NewDialogStore()
|
||||
dialogs := NewService(dialogStore)
|
||||
archivedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2002}
|
||||
if err := dialogStore.Upsert(ctx, 1001, domain.Dialog{
|
||||
Peer: archivedPeer,
|
||||
FolderID: domain.DialogArchiveFolderID,
|
||||
TopMessage: 7,
|
||||
TopMessageDate: 30,
|
||||
UnreadCount: 3,
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert archived dialog: %v", err)
|
||||
}
|
||||
// getPinnedDialogs(folder_id=0) 路径:DrKLO 的 archive 行发现完全依赖
|
||||
// 该响应里的 dialogFolder 条目。
|
||||
pinned, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{
|
||||
PinnedOnly: true,
|
||||
HasFolderID: true,
|
||||
FolderID: domain.DialogMainFolderID,
|
||||
Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs pinned: %v", err)
|
||||
}
|
||||
if pinned.ArchiveSummary == nil || !pinned.ArchiveSummary.Pinned || pinned.ArchiveSummary.TopPeer != archivedPeer {
|
||||
t.Fatalf("pinned archive summary = %+v, want pinned archive entry", pinned.ArchiveSummary)
|
||||
}
|
||||
// archive 行被 unpin 后不属于 pinned 集合。
|
||||
if _, err := dialogStore.SetArchivePinned(ctx, 1001, false); err != nil {
|
||||
t.Fatalf("set archive pinned: %v", err)
|
||||
}
|
||||
unpinned, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{
|
||||
PinnedOnly: true,
|
||||
HasFolderID: true,
|
||||
FolderID: domain.DialogMainFolderID,
|
||||
Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs pinned after unpin: %v", err)
|
||||
}
|
||||
if unpinned.ArchiveSummary != nil {
|
||||
t.Fatalf("pinned archive summary after unpin = %+v, want nil", unpinned.ArchiveSummary)
|
||||
}
|
||||
// 主列表第一页仍输出条目(pinned flag 用真值),与官方一致。
|
||||
main, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs main: %v", err)
|
||||
}
|
||||
if main.ArchiveSummary == nil || main.ArchiveSummary.Pinned {
|
||||
t.Fatalf("main archive summary = %+v, want entry with pinned=false", main.ArchiveSummary)
|
||||
}
|
||||
// exclude_pinned 请求按官方语义不带条目。
|
||||
excluded, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{ExcludePinned: true, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs exclude pinned: %v", err)
|
||||
}
|
||||
if excluded.ArchiveSummary != nil {
|
||||
t.Fatalf("exclude_pinned archive summary = %+v, want nil", excluded.ArchiveSummary)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue