fix(chat): 会话消息只取最早 50 条,第 51 条起对双方永久不可见
GetByConversationID 原来是 Order("created_at ASC") + Limit(50),而会话上限是
conf.Limits.ChatMaxMessages(默认 200)。唯一调用方 chat_handler 永远传 nil cursor,
于是取到的永远是「最早 50 条」:第 51 条起写库成功、message_count 原子递增、
页面显示 51/200,但列表里没有它 —— 等多久、换设备、重新登录都不会出现。
本地实测 55 条会话只渲染 #1–#50,双方都以为对方没回,直到 200 条被判满自动关闭。
改为:
- Order("id DESC") 取最新 limit 条,再在内存反转成时间正序(气泡从上到下=旧→新)
- limit 取 conf.Limits.ChatMaxMessages,兜底只处理零值(配置未初始化);
不写成 limit<50→50,那样会把箱主显式配的小上限抬大
- 排序键用 id 而非 created_at:同毫秒写入的 created_at 相同,排序不稳定
- 游标 query 去掉 NULL 风险:显式要求 cursor.Value 非空
验证:55 条实测渲染 55 条、范围 #1–#55;单测 3 项(全量可见 / 超限保留最新 /
游标翻页),变异验证把 ORDER 改回 ASC 时「超限保留最新」立刻变红。
This commit is contained in:
+32
-2
@@ -7,9 +7,17 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/tamakyi/TamaBox/internal/conf"
|
||||||
"github.com/tamakyi/TamaBox/internal/dbutil"
|
"github.com/tamakyi/TamaBox/internal/dbutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// minChatMessagesPerPage 是会话消息单次加载的兜底条数。
|
||||||
|
//
|
||||||
|
// 正常情况下取 conf.Limits.ChatMaxMessages(默认 200,即一次把整个会话读满)。
|
||||||
|
// 这个常量只在「配置未初始化」(零值,测试里直接构造 store 时会遇到)时生效,
|
||||||
|
// 不会覆盖箱主显式配置的更大或更小的值。
|
||||||
|
const minChatMessagesPerPage = 50
|
||||||
|
|
||||||
var Messages MessageStore
|
var Messages MessageStore
|
||||||
|
|
||||||
type MessageStore interface {
|
type MessageStore interface {
|
||||||
@@ -60,20 +68,42 @@ func (db *messages) GetByConversationID(ctx context.Context, conversationID uint
|
|||||||
var messages []*Message
|
var messages []*Message
|
||||||
q := db.WithContext(ctx).Where("conversation_id = ?", conversationID)
|
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 != nil {
|
||||||
if cursor.Value != nil && fmt.Sprintf("%v", cursor.Value) != "" {
|
if cursor.Value != nil && fmt.Sprintf("%v", cursor.Value) != "" {
|
||||||
|
// 游标语义是「加载更早的消息」,与下面的 ORDER BY id DESC 配套;
|
||||||
|
// 若沿用旧的 ASC 排序,id < cursor 每次都会命中同一批最老的消息。
|
||||||
q = q.Where("id < ?", cursor.Value)
|
q = q.Where("id < ?", cursor.Value)
|
||||||
}
|
}
|
||||||
limit = cursor.Limit()
|
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)
|
q = q.Limit(limit)
|
||||||
|
|
||||||
if err := q.Find(&messages).Error; err != nil {
|
if err := q.Find(&messages).Error; err != nil {
|
||||||
return nil, errors.Wrap(err, "get messages")
|
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
|
return messages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user