chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
|
|
@ -27,7 +27,7 @@ func seedEvent(t *testing.T, events *memory.UpdateEventStore, userID int64, pts
|
|||
}
|
||||
|
||||
// TestGetDifferenceStopsAtHole:getDifference 只返回从 from 起连续的事件,遇在途空洞即截断,
|
||||
// State.Pts 取最后连续值;补洞后下次拉取可继续,绝不跳过空洞。
|
||||
// State.Pts 取最后连续值;后续事件到齐后下次拉取可继续,绝不跳过空洞。
|
||||
func TestGetDifferenceStopsAtHole(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
|
|
@ -57,7 +57,7 @@ func TestGetDifferenceStopsAtHole(t *testing.T) {
|
|||
t.Fatalf("GetDifference after fill: %v", err)
|
||||
}
|
||||
if len(diff.Events) != 3 || diff.State.Pts != 6 {
|
||||
t.Fatalf("补洞后 diff = %d events, state.pts %d; want 4,5,6 到 pts=6", len(diff.Events), diff.State.Pts)
|
||||
t.Fatalf("events after gap filled = %d, state.pts %d; want 4,5,6 到 pts=6", len(diff.Events), diff.State.Pts)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"sort"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
|
@ -13,20 +15,20 @@ import (
|
|||
type Service struct {
|
||||
states store.UpdateStateStore
|
||||
events store.UpdateEventStore
|
||||
pts store.PtsAllocator
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
type dispatchingEventAppender interface {
|
||||
AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error
|
||||
AppendAllocatedWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, error)
|
||||
}
|
||||
|
||||
// ServiceOption 调整 updates 服务的运行时依赖。
|
||||
type ServiceOption func(*Service)
|
||||
|
||||
// WithPtsAllocator 使用外部 pts 分配器推进账号级 pts。
|
||||
func WithPtsAllocator(pts store.PtsAllocator) ServiceOption {
|
||||
// WithLogger 注入 update 状态机日志器,用于追踪 pts 分配、append 失败和 difference gap。
|
||||
func WithLogger(log *zap.Logger) ServiceOption {
|
||||
return func(s *Service) {
|
||||
s.pts = pts
|
||||
s.log = log
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -37,6 +39,9 @@ func NewService(states store.UpdateStateStore, events store.UpdateEventStore, op
|
|||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
if s.log == nil {
|
||||
s.log = zap.NewNop()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
|
|
@ -90,13 +95,31 @@ func (s *Service) CurrentState(ctx context.Context, userID int64) (domain.Update
|
|||
return s.currentState(ctx, userID)
|
||||
}
|
||||
|
||||
// AcknowledgeCurrentState 返回账号当前最大连续状态,并把该设备的确认水位推进到此。
|
||||
//
|
||||
// 供 updates.getState 使用:协议语义是客户端宣告「从现在开始同步」,启动期的
|
||||
// 离线数据由 getDialogs 快照承载(TDesktop 不持久化 pts,每次启动都走此路径)。
|
||||
// 若改为返回设备旧确认水位,客户端会在 getDialogs 最新快照之上再重放历史差分,
|
||||
// 造成未读重复累计、dialog 预览被旧消息抢占。持久化 pts 的客户端(Android)
|
||||
// 启动时直接带本地 pts 调 getDifference,不经过 getState,不受影响。
|
||||
func (s *Service) AcknowledgeCurrentState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) {
|
||||
st, err := s.currentState(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UpdateState{}, err
|
||||
}
|
||||
if err := s.saveConfirmedState(ctx, authKeyID, userID, st); err != nil {
|
||||
return domain.UpdateState{}, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// getDifferenceLimit 是单次 getDifference 返回的最大连续事件数;超出置 Partial 让客户端翻页。
|
||||
const getDifferenceLimit = 100
|
||||
|
||||
// GetDifference 返回当前 user 从 from 状态之后的增量事件。
|
||||
//
|
||||
// 对齐 MTProto:只返回从 from.Pts 起「连续」的事件(遇空洞即截断),State.Pts 取最后连续值,
|
||||
// 绝不让客户端跳过在途空洞而丢消息——空洞由并发发送的在途事务造成,提交/补洞后客户端下次拉取即可补齐。
|
||||
// 绝不让客户端跳过异常空洞而丢消息;正常写路径在 PG 事务内推进 pts 和 durable event。
|
||||
// 连续事件填满 limit 时置 Partial(映射 differenceSlice),客户端据返回 State 继续翻页。
|
||||
func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID int64, from domain.UpdateState) (domain.UpdateDifference, error) {
|
||||
st, err := s.currentState(ctx, userID)
|
||||
|
|
@ -116,11 +139,30 @@ func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID i
|
|||
if err != nil {
|
||||
return domain.UpdateDifference{}, err
|
||||
}
|
||||
contiguous := contiguousPrefix(events, from.Pts)
|
||||
contiguous, gapEvent, expectedPts := contiguousPrefixAndGap(events, from.Pts)
|
||||
last := from.Pts
|
||||
if len(contiguous) > 0 {
|
||||
last = contiguous[len(contiguous)-1].Pts
|
||||
}
|
||||
if gapEvent != nil {
|
||||
ptsCount := gapEvent.PtsCount
|
||||
if ptsCount <= 0 {
|
||||
ptsCount = 1
|
||||
}
|
||||
s.log.Warn("difference_stopped_at_gap",
|
||||
zap.String("scope", "user"),
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Int("request_pts", from.Pts),
|
||||
zap.Int("current_pts", st.Pts),
|
||||
zap.Int("returned_pts", last),
|
||||
zap.Int("expected_pts", expectedPts),
|
||||
zap.Int("got_pts", gapEvent.Pts),
|
||||
zap.Int("got_pts_count", ptsCount),
|
||||
zap.String("event_type", string(gapEvent.Type)),
|
||||
zap.Int("events_read", len(events)),
|
||||
zap.Int("events_returned", len(contiguous)),
|
||||
)
|
||||
}
|
||||
out := st
|
||||
out.Pts = last
|
||||
out.Seq = 0 // seq 恒 0,见 GetState 注释
|
||||
|
|
@ -160,8 +202,13 @@ func (s *Service) saveConfirmedState(ctx context.Context, authKeyID [8]byte, use
|
|||
// contiguousPrefix 返回从 from 起 pts 严格连续(from+1, from+2, ...)的事件前缀。
|
||||
// 先按 pts 升序排序以兼容存储返回顺序,遇到空洞即停。
|
||||
func contiguousPrefix(events []domain.UpdateEvent, from int) []domain.UpdateEvent {
|
||||
out, _, _ := contiguousPrefixAndGap(events, from)
|
||||
return out
|
||||
}
|
||||
|
||||
func contiguousPrefixAndGap(events []domain.UpdateEvent, from int) ([]domain.UpdateEvent, *domain.UpdateEvent, int) {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
return nil, nil, 0
|
||||
}
|
||||
sorted := make([]domain.UpdateEvent, len(events))
|
||||
copy(sorted, events)
|
||||
|
|
@ -173,13 +220,15 @@ func contiguousPrefix(events []domain.UpdateEvent, from int) []domain.UpdateEven
|
|||
if ptsCount <= 0 {
|
||||
ptsCount = 1
|
||||
}
|
||||
if event.Pts != cursor+ptsCount {
|
||||
break
|
||||
expected := cursor + ptsCount
|
||||
if event.Pts != expected {
|
||||
gap := event
|
||||
return out, &gap, expected
|
||||
}
|
||||
out = append(out, event)
|
||||
cursor = event.Pts
|
||||
}
|
||||
return out
|
||||
return out, nil, 0
|
||||
}
|
||||
|
||||
// ClearAuthKey 清理某 auth_key 的设备状态。
|
||||
|
|
@ -232,6 +281,142 @@ func (s *Service) RecordMessageReactions(ctx context.Context, authKeyID [8]byte,
|
|||
})
|
||||
}
|
||||
|
||||
// RecordMessagePoll records a durable marker for message poll state changes
|
||||
// (vote / close). updateMessagePoll has no pts fields in Layer 225 — same
|
||||
// bookkeeping shape as RecordMessageReactions.
|
||||
func (s *Service) RecordMessagePoll(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if userID == 0 {
|
||||
userID = msg.OwnerUserID
|
||||
}
|
||||
date := msg.Date
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
return s.recordEventWithoutState(ctx, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventMessagePoll,
|
||||
Date: date,
|
||||
Message: msg,
|
||||
Peer: msg.Peer,
|
||||
PtsCount: 1,
|
||||
})
|
||||
}
|
||||
|
||||
// RecordStory records a story snapshot change for offline difference replay.
|
||||
func (s *Service) RecordStory(ctx context.Context, authKeyID [8]byte, userID int64, story domain.Story, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if userID == 0 && story.Owner.Type == domain.PeerTypeUser {
|
||||
userID = story.Owner.ID
|
||||
}
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventStory,
|
||||
Date: story.Date,
|
||||
Peer: story.Owner,
|
||||
Story: story,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordStoryFanout records a story visibility change for a user who did not
|
||||
// initiate the RPC that caused it. It writes durable updates/outbox but does
|
||||
// not acknowledge any device-local update state.
|
||||
func (s *Service) RecordStoryFanout(ctx context.Context, userID int64, story domain.Story) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if userID == 0 {
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrStoryPeerInvalid
|
||||
}
|
||||
return s.recordEventCore(ctx, [8]byte{}, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventStory,
|
||||
Date: story.Date,
|
||||
Peer: story.Owner,
|
||||
Story: story,
|
||||
PtsCount: 1,
|
||||
}, true, 0, false)
|
||||
}
|
||||
|
||||
// RecordReadStories records a read boundary update for multi-device sync.
|
||||
func (s *Service) RecordReadStories(ctx context.Context, authKeyID [8]byte, userID int64, read domain.StoryReadResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if userID == 0 {
|
||||
userID = read.ViewerID
|
||||
}
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventReadStories,
|
||||
Date: read.Date,
|
||||
Peer: read.Peer,
|
||||
MaxID: read.MaxReadID,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordSentStoryReaction records the current user's story reaction for multi-device sync.
|
||||
func (s *Service) RecordSentStoryReaction(ctx context.Context, authKeyID [8]byte, userID int64, reaction domain.StoryReactionResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if userID == 0 {
|
||||
userID = reaction.ViewerID
|
||||
}
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventSentStoryReaction,
|
||||
Date: reaction.Date,
|
||||
Peer: reaction.Peer,
|
||||
MaxID: reaction.StoryID,
|
||||
Story: reaction.Story,
|
||||
Reaction: reaction.Reaction,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordNewStoryReaction records the story owner's notification for a reaction
|
||||
// sent by another user. It does not advance any owner device confirmation state:
|
||||
// the owner did not initiate the RPC, but online outbox and offline difference
|
||||
// must still see the durable event.
|
||||
func (s *Service) RecordNewStoryReaction(ctx context.Context, authKeyID [8]byte, ownerUserID int64, reaction domain.StoryReactionResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if ownerUserID == 0 && reaction.Story.Owner.Type == domain.PeerTypeUser {
|
||||
ownerUserID = reaction.Story.Owner.ID
|
||||
}
|
||||
if ownerUserID == 0 && reaction.Peer.Type == domain.PeerTypeUser {
|
||||
ownerUserID = reaction.Peer.ID
|
||||
}
|
||||
if ownerUserID == 0 || reaction.ViewerID == 0 || reaction.Reaction == nil {
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrStoryPeerInvalid
|
||||
}
|
||||
return s.recordEventCore(ctx, authKeyID, ownerUserID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventNewStoryReaction,
|
||||
Date: reaction.Date,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: reaction.ViewerID},
|
||||
MaxID: reaction.StoryID,
|
||||
Story: reaction.Story,
|
||||
Reaction: reaction.Reaction,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID, false)
|
||||
}
|
||||
|
||||
// RecordQuickReplyMutation records account-local quick reply state changes for
|
||||
// multi-device sync. Quick-reply TL updates do not carry pts, so outbox appends
|
||||
// auxiliary pts bookkeeping just like other account settings events.
|
||||
func (s *Service) RecordQuickReplyMutation(ctx context.Context, authKeyID [8]byte, userID int64, mutation domain.QuickReplyMutation, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if userID == 0 {
|
||||
userID = mutation.List.OwnerUserID
|
||||
}
|
||||
event := domain.UpdateEvent{
|
||||
Date: mutation.Date,
|
||||
PtsCount: 1,
|
||||
QuickReplies: append([]domain.QuickReply(nil), mutation.List.QuickReplies...),
|
||||
QuickReply: mutation.QuickReply,
|
||||
QuickReplyMessage: mutation.Message,
|
||||
MessageIDs: append([]int(nil), mutation.MessageIDs...),
|
||||
MaxID: mutation.ShortcutID,
|
||||
}
|
||||
switch mutation.Kind {
|
||||
case domain.QuickReplyMutationNew:
|
||||
event.Type = domain.UpdateEventNewQuickReply
|
||||
case domain.QuickReplyMutationDelete:
|
||||
event.Type = domain.UpdateEventDeleteQuickReply
|
||||
case domain.QuickReplyMutationMessage:
|
||||
event.Type = domain.UpdateEventQuickReplyMessage
|
||||
case domain.QuickReplyMutationIDs:
|
||||
event.Type = domain.UpdateEventDeleteQuickReplyMessages
|
||||
default:
|
||||
event.Type = domain.UpdateEventQuickReplies
|
||||
}
|
||||
return s.recordEvent(ctx, authKeyID, userID, event, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordReadHistory 推进 update 状态并追加一条 read_history_inbox 事件。
|
||||
func (s *Service) RecordReadHistory(ctx context.Context, authKeyID [8]byte, userID int64, read domain.ReadHistoryResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if userID == 0 {
|
||||
|
|
@ -244,10 +429,21 @@ func (s *Service) RecordReadHistory(ctx context.Context, authKeyID [8]byte, user
|
|||
Peer: read.Peer,
|
||||
MaxID: read.MaxID,
|
||||
StillUnreadCount: read.StillUnreadCount,
|
||||
ChannelPts: read.ChannelPts,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordChannelState 记录当前账号与某频道成员关系变化(leave/kick),
|
||||
// 离线设备经 difference 收到 updateChannel 后重拉 channel 状态。
|
||||
func (s *Service) RecordChannelState(ctx context.Context, authKeyID [8]byte, userID, channelID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventChannelState,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordContactsReset 记录通讯录视角变化,供离线设备通过 updates.getDifference 触发重拉。
|
||||
func (s *Service) RecordContactsReset(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
|
|
@ -256,20 +452,54 @@ func (s *Service) RecordContactsReset(ctx context.Context, authKeyID [8]byte, us
|
|||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordDialogPinned 记录单个会话置顶状态变化。
|
||||
func (s *Service) RecordDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
// RecordDraftMessage 记录某会话云草稿变化(保存/清空都是同一事件——草稿是绝对
|
||||
// 状态,重放时按 peer 重载当前值)。updateDraftMessage 无 pts 字段,走 LacksWirePts
|
||||
// aux 簿记;topMsgID 是 forum 话题草稿键(复用 MaxID 列持久化)。
|
||||
func (s *Service) RecordDraftMessage(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, topMsgID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventDraftMessage,
|
||||
Peer: peer,
|
||||
MaxID: topMsgID,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordDialogPinned 记录单个会话置顶状态变化;folderID 是会话所在 folder
|
||||
// (0 主列表/1 归档),缺失会让离线设备把归档内置顶重放到主列表。
|
||||
func (s *Service) RecordDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, folderID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventDialogPinned,
|
||||
Peer: peer,
|
||||
Bool: pinned,
|
||||
FolderID: folderID,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordPinnedDialogs 记录指定 folder 内置顶顺序变化,并把新顺序持久化给 getDifference/outbox。
|
||||
func (s *Service) RecordPinnedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, folderID int, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventPinnedDialogs,
|
||||
Peers: append([]domain.Peer(nil), order...),
|
||||
FolderID: folderID,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordSavedDialogPinned 记录收藏夹单个子会话置顶状态变化。
|
||||
func (s *Service) RecordSavedDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventSavedDialogPinned,
|
||||
Peer: peer,
|
||||
Bool: pinned,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordPinnedDialogs 记录置顶会话顺序变化,并把新顺序持久化给 getDifference/outbox。
|
||||
func (s *Service) RecordPinnedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
// RecordPinnedSavedDialogs 记录收藏夹置顶顺序变化,新顺序持久化给 getDifference/outbox。
|
||||
func (s *Service) RecordPinnedSavedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventPinnedDialogs,
|
||||
Type: domain.UpdateEventPinnedSavedDialogs,
|
||||
Peers: append([]domain.Peer(nil), order...),
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
|
|
@ -295,6 +525,18 @@ func (s *Service) RecordChannelViewForumAsMessages(ctx context.Context, authKeyI
|
|||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordChannelDiscussionInbox 记录 forum 话题级已读(updateReadChannelDiscussionInbox),
|
||||
// 占一个账号 pts(LacksWirePts),供自己其它设备在线同步与离线差分恢复。
|
||||
func (s *Service) RecordChannelDiscussionInbox(ctx context.Context, authKeyID [8]byte, userID, channelID int64, topicID, maxID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventReadChannelDiscussionInbox,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
|
||||
TopMsgID: topicID,
|
||||
MaxID: maxID,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordPeerSettings 记录 peer settings 变化。
|
||||
func (s *Service) RecordPeerSettings(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
|
|
@ -305,6 +547,16 @@ func (s *Service) RecordPeerSettings(ctx context.Context, authKeyID [8]byte, use
|
|||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordPeerStoryBlocked 记录当前账号 story blocklist 对某个 peer 的可见状态变化。
|
||||
func (s *Service) RecordPeerStoryBlocked(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, blocked bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventPeerStoryBlocked,
|
||||
Peer: peer,
|
||||
Bool: blocked,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordDialogFilter 记录单个 filter 的创建、更新或删除;folder 为 nil 表示删除。
|
||||
func (s *Service) RecordDialogFilter(ctx context.Context, authKeyID [8]byte, userID int64, folderID int, folder *domain.DialogFolder, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
var copyFolder *domain.DialogFolder
|
||||
|
|
@ -372,37 +624,48 @@ func (s *Service) recordEventCore(ctx context.Context, authKeyID [8]byte, userID
|
|||
if event.PtsCount == 0 {
|
||||
event.PtsCount = 1
|
||||
}
|
||||
pts, err := s.nextPtsN(ctx, userID, event.PtsCount)
|
||||
if err != nil {
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, err
|
||||
}
|
||||
st := domain.UpdateState{Pts: pts, Date: date, Seq: 0}
|
||||
event.UserID = userID
|
||||
event.Pts = st.Pts
|
||||
event.Date = date
|
||||
st := domain.UpdateState{Date: date, Seq: 0}
|
||||
if s.events != nil {
|
||||
var err error
|
||||
if dispatch {
|
||||
if appender, ok := s.events.(dispatchingEventAppender); ok {
|
||||
err = appender.AppendWithDispatch(ctx, userID, event, authKeyID, excludeSessionID)
|
||||
event, err = appender.AppendAllocatedWithDispatch(ctx, userID, event, authKeyID, excludeSessionID)
|
||||
} else {
|
||||
err = s.events.Append(ctx, userID, event)
|
||||
event, err = s.events.AppendAllocated(ctx, userID, event)
|
||||
}
|
||||
} else {
|
||||
err = s.events.Append(ctx, userID, event)
|
||||
event, err = s.events.AppendAllocated(ctx, userID, event)
|
||||
}
|
||||
if err != nil {
|
||||
if !dispatch {
|
||||
_ = s.events.Append(ctx, userID, domain.UpdateEvent{
|
||||
UserID: userID,
|
||||
Type: domain.UpdateEventNoop,
|
||||
Pts: pts,
|
||||
PtsCount: event.PtsCount,
|
||||
Date: date,
|
||||
})
|
||||
}
|
||||
s.log.Warn("update_event_append_failed",
|
||||
zap.String("scope", "user"),
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Int("pts", event.Pts),
|
||||
zap.Int("pts_count", event.PtsCount),
|
||||
zap.String("event_type", string(event.Type)),
|
||||
zap.Error(err),
|
||||
zap.Error(ctx.Err()),
|
||||
)
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, err
|
||||
}
|
||||
st.Pts = event.Pts
|
||||
s.log.Debug("update_event_appended",
|
||||
zap.String("scope", "user"),
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Int("pts", event.Pts),
|
||||
zap.Int("pts_count", event.PtsCount),
|
||||
zap.String("event_type", string(event.Type)),
|
||||
zap.Bool("dispatch", dispatch),
|
||||
)
|
||||
} else {
|
||||
current, err := s.currentPts(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, err
|
||||
}
|
||||
event.Pts = current + event.PtsCount
|
||||
st.Pts = event.Pts
|
||||
}
|
||||
if saveState && s.states != nil {
|
||||
if err := s.states.Save(ctx, authKeyID, userID, st); err != nil {
|
||||
|
|
@ -413,53 +676,10 @@ func (s *Service) recordEventCore(ctx context.Context, authKeyID [8]byte, userID
|
|||
}
|
||||
|
||||
// currentPts 供 GetState 报告「当前 pts」。对齐 MTProto:报告最大连续已提交 pts,
|
||||
// 而非 Redis allocator 的最大已分配值——后者在并发发送在途时会超前于已提交事件,
|
||||
// 会让首次登录基线越过在途空洞而丢消息。allocator 仅在无 events 存储时兜底。
|
||||
// PG 实现中该值由同一事务内的 pts 分配 + durable event 写入共同维护。
|
||||
func (s *Service) currentPts(ctx context.Context, userID int64) (int, error) {
|
||||
if s.events != nil {
|
||||
return s.events.MaxContiguousPts(ctx, userID)
|
||||
}
|
||||
if s.pts != nil {
|
||||
return s.pts.CurrentPts(ctx, userID)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *Service) nextPts(ctx context.Context, userID int64) (int, error) {
|
||||
if s.pts != nil {
|
||||
return s.pts.NextPts(ctx, userID)
|
||||
}
|
||||
current, err := s.currentPts(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return current + 1, nil
|
||||
}
|
||||
|
||||
func (s *Service) nextPtsN(ctx context.Context, userID int64, count int) (int, error) {
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
if count == 1 {
|
||||
return s.nextPts(ctx, userID)
|
||||
}
|
||||
if s.pts != nil {
|
||||
if ranges, ok := s.pts.(store.PtsRangeAllocator); ok {
|
||||
return ranges.NextPtsN(ctx, userID, count)
|
||||
}
|
||||
var pts int
|
||||
var err error
|
||||
for i := 0; i < count; i++ {
|
||||
pts, err = s.pts.NextPts(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return pts, nil
|
||||
}
|
||||
current, err := s.currentPts(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return current + count, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package updates
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -77,6 +78,38 @@ func TestRecordReadHistoryFeedsGetDifference(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRecordChannelReadHistoryKeepsChannelPtsPayload(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 7
|
||||
svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
|
||||
ownerUserID := int64(1000000001)
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 7001}
|
||||
|
||||
event, state, err := svc.RecordReadHistory(ctx, authKeyID, ownerUserID, domain.ReadHistoryResult{
|
||||
OwnerUserID: ownerUserID,
|
||||
Peer: peer,
|
||||
MaxID: 11,
|
||||
StillUnreadCount: 3,
|
||||
ChannelPts: 77,
|
||||
Changed: true,
|
||||
}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordReadHistory: %v", err)
|
||||
}
|
||||
if event.Pts != 1 || state.Pts != 1 || event.ChannelPts != 77 {
|
||||
t.Fatalf("event/state = %+v / %+v, want account pts=1 and channel pts payload=77", event, state)
|
||||
}
|
||||
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if len(diff.Events) != 1 || diff.Events[0].Peer != peer || diff.Events[0].ChannelPts != 77 {
|
||||
t.Fatalf("diff = %+v, want recorded channel read payload with channel pts=77", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSettingsEventsFeedGetDifference(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
|
|
@ -88,31 +121,34 @@ func TestRecordSettingsEventsFeedGetDifference(t *testing.T) {
|
|||
if _, _, err := svc.RecordContactsReset(ctx, authKeyID, ownerUserID, 0); err != nil {
|
||||
t.Fatalf("RecordContactsReset: %v", err)
|
||||
}
|
||||
if _, _, err := svc.RecordDialogPinned(ctx, authKeyID, ownerUserID, peer, true, 0); err != nil {
|
||||
if _, _, err := svc.RecordDialogPinned(ctx, authKeyID, ownerUserID, peer, true, 0, 0); err != nil {
|
||||
t.Fatalf("RecordDialogPinned: %v", err)
|
||||
}
|
||||
order := []domain.Peer{peer}
|
||||
if _, _, err := svc.RecordPinnedDialogs(ctx, authKeyID, ownerUserID, order, 0); err != nil {
|
||||
if _, _, err := svc.RecordPinnedDialogs(ctx, authKeyID, ownerUserID, 0, order, 0); err != nil {
|
||||
t.Fatalf("RecordPinnedDialogs: %v", err)
|
||||
}
|
||||
if _, _, err := svc.RecordDialogUnreadMark(ctx, authKeyID, ownerUserID, peer, false, 0); err != nil {
|
||||
t.Fatalf("RecordDialogUnreadMark: %v", err)
|
||||
}
|
||||
settings := domain.PeerSettings{ShareContact: true}
|
||||
stateEvent, state, err := svc.RecordPeerSettings(ctx, authKeyID, ownerUserID, peer, settings, 0)
|
||||
if err != nil {
|
||||
if _, _, err := svc.RecordPeerSettings(ctx, authKeyID, ownerUserID, peer, settings, 0); err != nil {
|
||||
t.Fatalf("RecordPeerSettings: %v", err)
|
||||
}
|
||||
if stateEvent.Pts != 5 || state.Pts != 5 {
|
||||
t.Fatalf("last event/state = %+v / %+v, want pts=5", stateEvent, state)
|
||||
stateEvent, state, err := svc.RecordPeerStoryBlocked(ctx, authKeyID, ownerUserID, peer, true, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordPeerStoryBlocked: %v", err)
|
||||
}
|
||||
if stateEvent.Pts != 6 || state.Pts != 6 {
|
||||
t.Fatalf("last event/state = %+v / %+v, want pts=6", stateEvent, state)
|
||||
}
|
||||
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if diff.State.Pts != 5 || len(diff.Events) != 5 {
|
||||
t.Fatalf("diff = %+v, want five settings events", diff)
|
||||
if diff.State.Pts != 6 || len(diff.Events) != 6 {
|
||||
t.Fatalf("diff = %+v, want six settings events", diff)
|
||||
}
|
||||
wantTypes := []domain.UpdateEventType{
|
||||
domain.UpdateEventContactsReset,
|
||||
|
|
@ -120,6 +156,7 @@ func TestRecordSettingsEventsFeedGetDifference(t *testing.T) {
|
|||
domain.UpdateEventPinnedDialogs,
|
||||
domain.UpdateEventDialogUnreadMark,
|
||||
domain.UpdateEventPeerSettings,
|
||||
domain.UpdateEventPeerStoryBlocked,
|
||||
}
|
||||
for i, typ := range wantTypes {
|
||||
if diff.Events[i].Type != typ || diff.Events[i].Pts != i+1 || diff.Events[i].PtsCount != 1 {
|
||||
|
|
@ -138,6 +175,9 @@ func TestRecordSettingsEventsFeedGetDifference(t *testing.T) {
|
|||
if diff.Events[4].Peer != peer || !diff.Events[4].Settings.ShareContact {
|
||||
t.Fatalf("peer settings event = %+v, want peer and settings", diff.Events[4])
|
||||
}
|
||||
if diff.Events[5].Peer != peer || !diff.Events[5].Bool {
|
||||
t.Fatalf("peer story blocked event = %+v, want peer and blocked=true", diff.Events[5])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSettingsEventUsesDispatchAppender(t *testing.T) {
|
||||
|
|
@ -148,7 +188,7 @@ func TestRecordSettingsEventUsesDispatchAppender(t *testing.T) {
|
|||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
|
||||
|
||||
event, state, err := svc.RecordDialogPinned(ctx, authKeyID, 1000000001, peer, true, 42)
|
||||
event, state, err := svc.RecordDialogPinned(ctx, authKeyID, 1000000001, peer, true, 0, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordDialogPinned: %v", err)
|
||||
}
|
||||
|
|
@ -160,6 +200,177 @@ func TestRecordSettingsEventUsesDispatchAppender(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRecordSettingsEventDispatchFailureDoesNotRecordEvent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 6
|
||||
events := &failingDispatchAppender{UpdateEventStore: memory.NewUpdateEventStore()}
|
||||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
|
||||
_, _, err := svc.RecordDialogPinned(ctx, authKeyID, 1000000001, domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, true, 0, 42)
|
||||
if !errors.Is(err, errDispatchFailed) {
|
||||
t.Fatalf("RecordDialogPinned err = %v, want dispatch failure", err)
|
||||
}
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, 1000000001, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if diff.State.Pts != 0 || len(diff.Events) != 0 {
|
||||
t.Fatalf("diff after dispatch failure = %+v, want no durable event before allocated append commits", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPeerStoryBlockedUsesDispatchAppender(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 7
|
||||
events := &captureDispatchAppender{UpdateEventStore: memory.NewUpdateEventStore()}
|
||||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
|
||||
|
||||
event, state, err := svc.RecordPeerStoryBlocked(ctx, authKeyID, 1000000001, peer, true, 91)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordPeerStoryBlocked: %v", err)
|
||||
}
|
||||
if event.Pts != 1 || state.Pts != 1 || !event.LacksWirePts() {
|
||||
t.Fatalf("event/state = %+v / %+v, want first aux pts event", event, state)
|
||||
}
|
||||
if !events.dispatched || events.excludeAuthKeyID != authKeyID || events.excludeSessionID != 91 || events.event.Type != domain.UpdateEventPeerStoryBlocked || events.event.Peer != peer || !events.event.Bool {
|
||||
t.Fatalf("dispatch capture = %+v exclude_auth=%v exclude_session=%d dispatched=%v, want peer_story_blocked outbox", events.event, events.excludeAuthKeyID, events.excludeSessionID, events.dispatched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordStoryUsesDispatchAppenderExcludeCurrentSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
authKeyID := [8]byte{8, 1, 0}
|
||||
events := &captureDispatchAppender{UpdateEventStore: memory.NewUpdateEventStore()}
|
||||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}
|
||||
story := domain.Story{
|
||||
Owner: owner,
|
||||
ID: 3,
|
||||
Date: 1700000100,
|
||||
ExpireDate: 1700086500,
|
||||
Public: true,
|
||||
Caption: "owner story",
|
||||
}
|
||||
|
||||
event, state, err := svc.RecordStory(ctx, authKeyID, owner.ID, story, 1234)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordStory: %v", err)
|
||||
}
|
||||
if event.Type != domain.UpdateEventStory || event.Pts != 1 || event.PtsCount != 1 || state.Pts != 1 {
|
||||
t.Fatalf("event/state = %+v / %+v, want first story pts event", event, state)
|
||||
}
|
||||
if !events.dispatched || events.userID != owner.ID || events.excludeAuthKeyID != authKeyID || events.excludeSessionID != 1234 {
|
||||
t.Fatalf("dispatch capture = user %d exclude_auth=%v exclude_session=%d dispatched=%v, want current session excluded", events.userID, events.excludeAuthKeyID, events.excludeSessionID, events.dispatched)
|
||||
}
|
||||
if events.event.Type != domain.UpdateEventStory || events.event.Peer != owner || events.event.Story.ID != story.ID {
|
||||
t.Fatalf("dispatch event = %+v, want story update for owner story", events.event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordStoryReadAndSentReactionExcludeCurrentSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
authKeyID := [8]byte{8, 1, 4}
|
||||
events := &captureDispatchAppender{UpdateEventStore: memory.NewUpdateEventStore()}
|
||||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
viewerID := int64(1000000002)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}
|
||||
story := domain.Story{
|
||||
Owner: peer,
|
||||
ID: 5,
|
||||
Date: 1700000200,
|
||||
ExpireDate: 1700086600,
|
||||
Public: true,
|
||||
}
|
||||
|
||||
event, state, err := svc.RecordReadStories(ctx, authKeyID, viewerID, domain.StoryReadResult{
|
||||
ViewerID: viewerID,
|
||||
Peer: peer,
|
||||
MaxReadID: story.ID,
|
||||
Advanced: true,
|
||||
Date: 1700000201,
|
||||
}, 2233)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordReadStories: %v", err)
|
||||
}
|
||||
if event.Type != domain.UpdateEventReadStories || event.Pts != 1 || state.Pts != 1 {
|
||||
t.Fatalf("read event/state = %+v / %+v, want first read story pts event", event, state)
|
||||
}
|
||||
if !events.dispatched || events.userID != viewerID || events.excludeAuthKeyID != authKeyID || events.excludeSessionID != 2233 || events.event.Type != domain.UpdateEventReadStories || events.event.MaxID != story.ID {
|
||||
t.Fatalf("read dispatch capture = %+v user %d exclude_auth=%v exclude_session=%d dispatched=%v, want current session excluded", events.event, events.userID, events.excludeAuthKeyID, events.excludeSessionID, events.dispatched)
|
||||
}
|
||||
|
||||
reaction := &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "🔥"}
|
||||
event, state, err = svc.RecordSentStoryReaction(ctx, authKeyID, viewerID, domain.StoryReactionResult{
|
||||
ViewerID: viewerID,
|
||||
Peer: peer,
|
||||
StoryID: story.ID,
|
||||
Story: story,
|
||||
Reaction: reaction,
|
||||
Changed: true,
|
||||
Date: 1700000202,
|
||||
}, 2233)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordSentStoryReaction: %v", err)
|
||||
}
|
||||
if event.Type != domain.UpdateEventSentStoryReaction || event.Pts != 2 || state.Pts != 2 {
|
||||
t.Fatalf("reaction event/state = %+v / %+v, want second sent story reaction pts event", event, state)
|
||||
}
|
||||
if !events.dispatched || events.userID != viewerID || events.excludeAuthKeyID != authKeyID || events.excludeSessionID != 2233 || events.event.Type != domain.UpdateEventSentStoryReaction || events.event.Reaction == nil || events.event.Reaction.Emoticon != "🔥" {
|
||||
t.Fatalf("reaction dispatch capture = %+v user %d exclude_auth=%v exclude_session=%d dispatched=%v, want current session excluded", events.event, events.userID, events.excludeAuthKeyID, events.excludeSessionID, events.dispatched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordNewStoryReactionDispatchesWithoutSavingDeviceState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 5
|
||||
states := &captureStateStore{}
|
||||
events := &captureDispatchAppender{UpdateEventStore: memory.NewUpdateEventStore()}
|
||||
svc := NewService(states, events)
|
||||
ownerID := int64(1000000001)
|
||||
viewerID := int64(1000000002)
|
||||
reaction := &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "👍"}
|
||||
|
||||
event, state, err := svc.RecordNewStoryReaction(ctx, authKeyID, 0, domain.StoryReactionResult{
|
||||
ViewerID: viewerID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID},
|
||||
StoryID: 3,
|
||||
Story: domain.Story{
|
||||
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID},
|
||||
ID: 3,
|
||||
Date: 1700000100,
|
||||
},
|
||||
Reaction: reaction,
|
||||
Date: 1700000101,
|
||||
}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordNewStoryReaction: %v", err)
|
||||
}
|
||||
if event.Type != domain.UpdateEventNewStoryReaction || event.UserID != ownerID || event.Peer.ID != viewerID || event.Reaction == nil || event.Reaction.Emoticon != "👍" {
|
||||
t.Fatalf("event = %+v, want owner-side new story reaction from viewer", event)
|
||||
}
|
||||
if state.Pts != 1 || state.Seq != 0 {
|
||||
t.Fatalf("state = %+v, want first account pts", state)
|
||||
}
|
||||
if states.saveCount != 0 {
|
||||
t.Fatalf("state saves = %d, want no device state save for remote owner notification", states.saveCount)
|
||||
}
|
||||
if !events.dispatched || events.userID != ownerID || events.event.Type != domain.UpdateEventNewStoryReaction {
|
||||
t.Fatalf("dispatch capture = %+v user=%d dispatched=%v, want owner outbox event", events.event, events.userID, events.dispatched)
|
||||
}
|
||||
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, ownerID, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if diff.State.Pts != 1 || len(diff.Events) != 1 || diff.Events[0].Type != domain.UpdateEventNewStoryReaction {
|
||||
t.Fatalf("diff = %+v, want one durable new story reaction", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAuthKeyDropsStateAndEvents(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
|
|
@ -233,6 +444,52 @@ func TestDeleteMessagesPtsRangeFeedsGetDifference(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestAcknowledgeCurrentStateAdvancesConfirmedWatermark 验证 updates.getState
|
||||
// 的语义:返回账号当前最新连续 pts(而非设备旧确认水位),并把确认水位推进
|
||||
// 到此——TDesktop 不持久化 pts,启动靠 getState+getDialogs 快照对齐,返回旧
|
||||
// 水位会诱导其重放快照前差分(未读重复累计、dialog 预览被旧消息抢占)。
|
||||
func TestAcknowledgeCurrentStateAdvancesConfirmedWatermark(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 11
|
||||
userID := int64(1000000001)
|
||||
events := memory.NewUpdateEventStore()
|
||||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
if err := events.Append(ctx, userID, domain.UpdateEvent{
|
||||
UserID: userID, Type: domain.UpdateEventNewMessage, Pts: 1, PtsCount: 1,
|
||||
Date: 1700000001, Message: domain.Message{ID: 1, OwnerUserID: userID},
|
||||
}); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
// 设备确认水位停在 pts=1 后账号又推进两格。
|
||||
if _, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 1}); err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
for pts := 2; pts <= 3; pts++ {
|
||||
if err := events.Append(ctx, userID, domain.UpdateEvent{
|
||||
UserID: userID, Type: domain.UpdateEventNewMessage, Pts: pts, PtsCount: 1,
|
||||
Date: 1700000001 + pts, Message: domain.Message{ID: pts, OwnerUserID: userID},
|
||||
}); err != nil {
|
||||
t.Fatalf("append pts=%d: %v", pts, err)
|
||||
}
|
||||
}
|
||||
|
||||
st, err := svc.AcknowledgeCurrentState(ctx, authKeyID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("AcknowledgeCurrentState: %v", err)
|
||||
}
|
||||
if st.Pts != 3 {
|
||||
t.Fatalf("acknowledged state pts = %d, want account current 3", st.Pts)
|
||||
}
|
||||
confirmed, err := svc.GetState(ctx, authKeyID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetState after acknowledge: %v", err)
|
||||
}
|
||||
if confirmed.Pts != 3 {
|
||||
t.Fatalf("confirmed watermark = %d, want advanced to 3", confirmed.Pts)
|
||||
}
|
||||
}
|
||||
|
||||
type captureDispatchAppender struct {
|
||||
*memory.UpdateEventStore
|
||||
dispatched bool
|
||||
|
|
@ -242,11 +499,74 @@ type captureDispatchAppender struct {
|
|||
excludeSessionID int64
|
||||
}
|
||||
|
||||
func (s *captureDispatchAppender) AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error {
|
||||
func (s *captureDispatchAppender) AppendAllocatedWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, error) {
|
||||
s.dispatched = true
|
||||
s.userID = userID
|
||||
s.event = event
|
||||
s.excludeAuthKeyID = excludeAuthKeyID
|
||||
s.excludeSessionID = excludeSessionID
|
||||
return s.UpdateEventStore.Append(ctx, userID, event)
|
||||
event, err := s.UpdateEventStore.AppendAllocated(ctx, userID, event)
|
||||
s.event = event
|
||||
return event, err
|
||||
}
|
||||
|
||||
var errDispatchFailed = errors.New("dispatch failed")
|
||||
|
||||
type failingDispatchAppender struct {
|
||||
*memory.UpdateEventStore
|
||||
}
|
||||
|
||||
func (s *failingDispatchAppender) AppendAllocatedWithDispatch(context.Context, int64, domain.UpdateEvent, [8]byte, int64) (domain.UpdateEvent, error) {
|
||||
return domain.UpdateEvent{}, errDispatchFailed
|
||||
}
|
||||
|
||||
type captureStateStore struct {
|
||||
saveCount int
|
||||
states map[[16]byte]domain.UpdateState
|
||||
}
|
||||
|
||||
func (s *captureStateStore) Get(_ context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, bool, error) {
|
||||
if s.states == nil {
|
||||
return domain.UpdateState{}, false, nil
|
||||
}
|
||||
st, ok := s.states[captureStateKey(authKeyID, userID)]
|
||||
return st, ok, nil
|
||||
}
|
||||
|
||||
func (s *captureStateStore) Save(_ context.Context, authKeyID [8]byte, userID int64, state domain.UpdateState) error {
|
||||
if s.states == nil {
|
||||
s.states = make(map[[16]byte]domain.UpdateState)
|
||||
}
|
||||
s.saveCount++
|
||||
s.states[captureStateKey(authKeyID, userID)] = state
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *captureStateStore) Delete(_ context.Context, authKeyID [8]byte, userID int64) error {
|
||||
if s.states != nil {
|
||||
delete(s.states, captureStateKey(authKeyID, userID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *captureStateStore) DeleteAuthKey(_ context.Context, authKeyID [8]byte) error {
|
||||
if s.states == nil {
|
||||
return nil
|
||||
}
|
||||
for key := range s.states {
|
||||
var got [8]byte
|
||||
copy(got[:], key[:8])
|
||||
if got == authKeyID {
|
||||
delete(s.states, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func captureStateKey(authKeyID [8]byte, userID int64) [16]byte {
|
||||
var key [16]byte
|
||||
copy(key[:8], authKeyID[:])
|
||||
for i := 0; i < 8; i++ {
|
||||
key[8+i] = byte(userID >> (8 * i))
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue