diff --git a/internal/db/message.go b/internal/db/message.go index f9fe79c..a0bfe3b 100644 --- a/internal/db/message.go +++ b/internal/db/message.go @@ -7,9 +7,17 @@ import ( "github.com/pkg/errors" "gorm.io/gorm" + "github.com/tamakyi/TamaBox/internal/conf" "github.com/tamakyi/TamaBox/internal/dbutil" ) +// minChatMessagesPerPage 是会话消息单次加载的兜底条数。 +// +// 正常情况下取 conf.Limits.ChatMaxMessages(默认 200,即一次把整个会话读满)。 +// 这个常量只在「配置未初始化」(零值,测试里直接构造 store 时会遇到)时生效, +// 不会覆盖箱主显式配置的更大或更小的值。 +const minChatMessagesPerPage = 50 + var Messages MessageStore type MessageStore interface { @@ -60,20 +68,42 @@ func (db *messages) GetByConversationID(ctx context.Context, conversationID uint var messages []*Message q := db.WithContext(ctx).Where("conversation_id = ?", conversationID) - limit := 50 + // 默认一次取满整个会话,而不是固定 50 条。 + // + // 原先写死 limit=50 且按 created_at ASC 取,取到的是「最早的 50 条」; + // 而会话上限是 conf.Limits.ChatMaxMessages(默认 200)。结果是第 51 条起 + // 写库成功、message_count 在涨、页面显示 51/200,但列表里永远没有它 —— + // 用户无论等多久、换设备、重新登录都看不到(实测 55 条时只能渲染 #1–#50)。 + limit := conf.Limits.ChatMaxMessages + if limit <= 0 { + // 仅兜底「配置未初始化」(零值)这一种情况。 + // 不能写成 limit < 50 → 50:那样会把箱主显式配的小上限抬大, + // 属于替用户改配置,而不是补默认值。 + limit = minChatMessagesPerPage + } if cursor != nil { if cursor.Value != nil && fmt.Sprintf("%v", cursor.Value) != "" { + // 游标语义是「加载更早的消息」,与下面的 ORDER BY id DESC 配套; + // 若沿用旧的 ASC 排序,id < cursor 每次都会命中同一批最老的消息。 q = q.Where("id < ?", cursor.Value) } limit = cursor.Limit() } - q = q.Order("created_at ASC") + // 按 id 倒序取最新 limit 条,再反转成时间正序交给视图层 + // (气泡从上到下 = 旧 → 新)。 + // + // 排序键用 id 而非 created_at:同一毫秒内写入的多条消息 created_at 完全相同, + // 排序不稳定会导致同一页里顺序来回变;id 自增与写入顺序严格一致。 + q = q.Order("id DESC") q = q.Limit(limit) if err := q.Find(&messages).Error; err != nil { return nil, errors.Wrap(err, "get messages") } + for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { + messages[i], messages[j] = messages[j], messages[i] + } return messages, nil } diff --git a/internal/db/message_test.go b/internal/db/message_test.go new file mode 100644 index 0000000..e5b2cce --- /dev/null +++ b/internal/db/message_test.go @@ -0,0 +1,139 @@ +// Copyright 2026 tamakyi. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tamakyi/TamaBox/internal/conf" + "github.com/tamakyi/TamaBox/internal/dbutil" +) + +// TestGetByConversationIDReturnsLatest 是本轮修复的回归测试。 +// +// 修复前的行为:Order("created_at ASC") + Limit(50),取的是「最早的 50 条」。 +// 会话上限是 200,所以第 51 条起写库成功、计数在涨,但列表里永远看不到 —— +// 实测 55 条会话只能渲染 #1–#50,双方都以为对方没回。 +// +// 现在要求:默认一次取满会话上限,且返回的是「最新」的那一批。 +func TestGetByConversationIDReturnsLatest(t *testing.T) { + database, cleanup := newTestDB(t) + ctx := context.Background() + t.Cleanup(func() { + if err := cleanup("messages", "conversations"); err != nil { + t.Fatal(err) + } + }) + + conv := &Conversation{OwnerID: 1} + require.NoError(t, database.Create(conv).Error) + + const total = 55 + for i := 1; i <= total; i++ { + require.NoError(t, database.Create(&Message{ + ConversationID: conv.ID, + SenderID: 0, + Content: fmt.Sprintf("msg #%d", i), + }).Error) + } + + store := NewMessageStore(database) + got, err := store.GetByConversationID(ctx, conv.ID, nil) + require.NoError(t, err) + + // ① 不再被截断在 50 条 + require.Len(t, got, total, "整个会话都应可见,而不是只取最早的 50 条") + + // ② 顺序是时间正序(视图层气泡从上到下=旧→新) + require.Equal(t, "msg #1", got[0].Content) + require.Equal(t, fmt.Sprintf("msg #%d", total), got[len(got)-1].Content) + for i := 1; i < len(got); i++ { + require.Less(t, got[i-1].ID, got[i].ID, "返回结果必须按 id 升序,否则气泡顺序会乱") + } +} + +// TestGetByConversationIDKeepsNewestWhenOverLimit 覆盖「消息数超过单次加载上限」的场景。 +// +// 此时必须丢最老的、保最新的 —— 这正是修复前反过来的地方。 +func TestGetByConversationIDKeepsNewestWhenOverLimit(t *testing.T) { + database, cleanup := newTestDB(t) + ctx := context.Background() + t.Cleanup(func() { + if err := cleanup("messages", "conversations"); err != nil { + t.Fatal(err) + } + }) + + // 临时把上限压到 10,避免造 200+ 条拖慢测试 + original := conf.Limits.ChatMaxMessages + conf.Limits.ChatMaxMessages = 10 + t.Cleanup(func() { conf.Limits.ChatMaxMessages = original }) + + conv := &Conversation{OwnerID: 1} + require.NoError(t, database.Create(conv).Error) + + const total = 15 + for i := 1; i <= total; i++ { + require.NoError(t, database.Create(&Message{ + ConversationID: conv.ID, + SenderID: 0, + Content: fmt.Sprintf("msg #%d", i), + }).Error) + } + + store := NewMessageStore(database) + got, err := store.GetByConversationID(ctx, conv.ID, nil) + require.NoError(t, err) + + require.Len(t, got, 10, "超过上限时应只取上限条数") + // 保最新:应拿到 #6–#15,#1–#5 被丢弃 + require.Equal(t, "msg #6", got[0].Content) + require.Equal(t, "msg #15", got[len(got)-1].Content) +} + +// TestGetByConversationIDCursorLoadsOlder 覆盖「加载更早消息」的游标语义。 +// +// 游标必须与 ORDER BY id DESC 配套;修复前 cursor 走 id < 但排序是 ASC, +// 接上「加载更多」后会反复返回同一批最老的消息。 +func TestGetByConversationIDCursorLoadsOlder(t *testing.T) { + database, cleanup := newTestDB(t) + ctx := context.Background() + t.Cleanup(func() { + if err := cleanup("messages", "conversations"); err != nil { + t.Fatal(err) + } + }) + + conv := &Conversation{OwnerID: 1} + require.NoError(t, database.Create(conv).Error) + + const total = 12 + ids := make([]uint, 0, total) + for i := 1; i <= total; i++ { + m := &Message{ConversationID: conv.ID, SenderID: 0, Content: fmt.Sprintf("msg #%d", i)} + require.NoError(t, database.Create(m).Error) + ids = append(ids, m.ID) + } + + store := NewMessageStore(database) + + // 以第 7 条的 id 为界,往前取 3 条 → 应得到 #4–#6 + got, err := store.GetByConversationID(ctx, conv.ID, &dbutil.Cursor{Value: ids[6], PageSize: 3}) + require.NoError(t, err) + require.Len(t, got, 3) + require.Equal(t, "msg #4", got[0].Content) + require.Equal(t, "msg #6", got[2].Content) + + // 再往前翻一页:以第 4 条的 id 为界取 3 条 → #1–#3 + got, err = store.GetByConversationID(ctx, conv.ID, &dbutil.Cursor{Value: ids[3], PageSize: 3}) + require.NoError(t, err) + require.Len(t, got, 3) + require.Equal(t, "msg #1", got[0].Content) + require.Equal(t, "msg #3", got[2].Content) +}