fix: sync Android cursor tolerance fixes

This commit is contained in:
A 2026-07-04 23:45:27 +08:00
parent ca79562fd8
commit 2d5a6236c2
5 changed files with 198 additions and 8 deletions

View file

@ -21,9 +21,13 @@ func (r *Router) onMessagesGetSavedDialogs(ctx context.Context, req *tg.Messages
if parent, hasParent, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer); err != nil {
return nil, err
} else if hasParent {
if req.Limit < 0 || req.OffsetID < 0 {
if req.Limit < 0 {
return nil, limitInvalidErr()
}
offsetID := req.OffsetID
if offsetID < 0 {
offsetID = 0
}
mono, isMono, err := r.resolveMonoforumForAdmin(ctx, userID, parent)
if err != nil {
return nil, err
@ -38,15 +42,23 @@ func (r *Router) onMessagesGetSavedDialogs(ctx context.Context, req *tg.Messages
}, nil
}
// parent_peer = monoforum:返回该频道私信的订阅者子会话列表(管理员视角)。
return r.monoforumSavedDialogs(ctx, userID, mono, req.Limit, req.OffsetID)
return r.monoforumSavedDialogs(ctx, userID, mono, req.Limit, offsetID)
}
if req.Limit < 0 || req.OffsetID < 0 || req.OffsetDate < 0 {
if req.Limit < 0 {
return nil, limitInvalidErr()
}
offsetID := req.OffsetID
if offsetID < 0 {
offsetID = 0
}
offsetDate := req.OffsetDate
if offsetDate < 0 {
offsetDate = 0
}
filter := domain.SavedDialogsFilter{
ExcludePinned: req.ExcludePinned,
OffsetID: req.OffsetID,
OffsetDate: req.OffsetDate,
OffsetID: offsetID,
OffsetDate: offsetDate,
Limit: req.Limit,
}
if req.OffsetPeer != nil {

View file

@ -7,6 +7,7 @@ import (
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest"
appcontacts "telesrv/internal/app/contacts"
@ -252,6 +253,34 @@ func TestMessagesGetSavedDialogsPagination(t *testing.T) {
}
}
// TestMessagesGetSavedDialogsNegativePendingOffset 验证 DrKLO Android self Saved
// Messages 分组里出现本地 pending 负消息 id 时,下一页请求不会被误判为
// LIMIT_INVALID。服务端持久化 top_message 仍保持正数;负 offset 仅是客户端本地
// 游标噪声,按首页/无 offset 处理。
func TestMessagesGetSavedDialogsNegativePendingOffset(t *testing.T) {
ctx := context.Background()
r, _, alice, _, _, _ := savedDialogsFixture(t)
res, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{
Limit: 20,
OffsetID: -1001,
OffsetDate: -1,
OffsetPeer: &tg.InputPeerSelf{},
})
if err != nil {
t.Fatalf("negative pending offset: %v", err)
}
dialogs, _, _, _, full := savedDialogPage(t, res)
if !full || len(dialogs) != 2 {
t.Fatalf("negative pending offset page = %d full %v, want first page 2 full", len(dialogs), full)
}
_, err = r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: -1})
if !tgerr.Is(err, "LIMIT_INVALID") {
t.Fatalf("negative limit err = %v, want LIMIT_INVALID", err)
}
}
// TestMessagesGetSavedDialogsHashNotModified 验证 Android calcHash 序列命中
// 返回 savedDialogsNotModified{count}。
func TestMessagesGetSavedDialogsHashNotModified(t *testing.T) {

View file

@ -77,6 +77,13 @@ func (r *Router) onStoriesGetAllStories(ctx context.Context, req *tg.StoriesGetA
now := int(r.clock.Now().Unix())
var cursor domain.StoryListCursor
if next {
if digest, ok := storyAllStoriesDigestFromCompleteState(requestState, hidden); ok {
list := domain.StoryList{Hidden: hidden, Count: digest.Count, State: requestState}
if r.deps.Stories == nil || userID == 0 {
return tgStoriesAllStories(userID, list), nil
}
return r.tgStoriesAllStories(ctx, userID, list), nil
}
cursor, err = storyAllStoriesCursorFromState(requestState, hidden)
if err != nil {
return nil, offsetInvalidErr()
@ -135,7 +142,16 @@ func validateStoriesGetAllStoriesRequest(req *tg.StoriesGetAllStoriesRequest) er
next := storyAllStoriesNext(req)
hidden := storyAllStoriesHidden(req)
if next {
if !hasState || state == "" || !storyAllStoriesCursorStateToken(state) {
if !hasState || state == "" {
return offsetInvalidErr()
}
if storyAllStoriesCompleteState(state) {
if !storyAllStoriesCompleteStateForHidden(state, hidden) {
return offsetInvalidErr()
}
return nil
}
if !storyAllStoriesCursorStateToken(state) {
return offsetInvalidErr()
}
if _, err := storyAllStoriesCursorFromState(state, hidden); err != nil {
@ -172,6 +188,22 @@ func storyAllStoriesCompleteState(state string) bool {
return strings.HasPrefix(state, "ts1:")
}
func storyAllStoriesDigestFromCompleteState(state string, hidden bool) (domain.StoryListDigest, bool) {
if !storyAllStoriesCompleteStateForHidden(state, hidden) {
return domain.StoryListDigest{}, false
}
parts := strings.Split(state, ":")
count, err := strconv.Atoi(parts[2])
if err != nil || count < 0 {
return domain.StoryListDigest{}, false
}
hash, err := strconv.ParseUint(parts[3], 16, 64)
if err != nil {
return domain.StoryListDigest{}, false
}
return domain.StoryListDigest{Count: count, Hash: hash}, true
}
func storyAllStoriesCompleteStateForHidden(state string, hidden bool) bool {
parts := strings.Split(state, ":")
if len(parts) != 4 || parts[0] != "ts1" {

View file

@ -298,8 +298,16 @@ func TestStoriesGetAllStoriesPaginatesByPeerState(t *testing.T) {
finalAsCursor := &tg.StoriesGetAllStoriesRequest{}
finalAsCursor.SetState(next.State)
finalAsCursor.SetNext(true)
if _, err := r.onStoriesGetAllStories(ctx, finalAsCursor); err == nil || !tgerr.Is(err, "OFFSET_INVALID") {
t.Fatalf("final state as cursor err = %v, want OFFSET_INVALID", err)
finalAsCursorClass, err := r.onStoriesGetAllStories(reqCtx, finalAsCursor)
if err != nil {
t.Fatalf("final state as cursor: %v", err)
}
finalAsCursorPage, ok := finalAsCursorClass.(*tg.StoriesAllStories)
if !ok {
t.Fatalf("final state as cursor response = %T, want stories.allStories terminal page", finalAsCursorClass)
}
if finalAsCursorPage.HasMore || len(finalAsCursorPage.PeerStories) != 0 || finalAsCursorPage.Count != domain.MaxStoryListLimit+1 || finalAsCursorPage.State != next.State {
t.Fatalf("final state as cursor page = %+v, want empty terminal page preserving request count/state", finalAsCursorPage)
}
}