feat(history): 消息正向增量拉取(A5) — GET /sessions/{id}/messages?after=
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- history/list_after.go: ListAfter(升序, created_at>after, 走覆盖索引, rowid兜底同毫秒)
- HistoryStore 新增 ListAfter(store+fakes同步)
- gateway history.go: handleListMessages解析after分流; after+before互斥400; 负数/非数字400
- 新增测试(list_after/history_after), build/vet/test绿+交叉编译
- Docs/api-client v0.26 + api-reference §3.4 同步
This commit is contained in:
AskaEth committed 2026-09-08 21:49:12 +08:00
1 parent 026b27b310
commit f43751fd07
11 files changed
+407 -35

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
package history
import (
"context"
"fmt"
)
// ListAfter returns up to limit messages for sessionID strictly newer than after
// (created_at > after) in oldest-first order, for forward incremental catch-up: a
// client that reconnected passes the newest createdAt it has already seen and pulls
// only what it missed, however many messages that is (the "before" cursor only walks
// backwards and cannot recover a gap larger than one page). When platform is non-empty
// the page is further restricted to messages carrying that source platform, exactly as
// in List. rowid breaks created_at ties so messages written in the same millisecond
// keep their insertion order. The returned slice is non-nil; limit is clamped to
// [1, maxPageLimit].
func (s *Store) ListAfter(ctx context.Context, sessionID string, after int64, limit int, platform string) ([]Message, error) {
limit = clampLimit(limit)
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
FROM chat_messages WHERE session_id = ? AND created_at > ?`
args := []any{sessionID, after}
if platform != "" {
query += ` AND platform = ?`
args = append(args, platform)
}
query += ` ORDER BY created_at ASC, rowid ASC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("history: list after %s: %w", sessionID, err)
}
defer func() { _ = rows.Close() }()
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan list after %s: %w", sessionID, err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate list after %s: %w", sessionID, err)
}
return msgs, nil
}
+129
View File
@@ -0,0 +1,129 @@
package history
import (
"context"
"testing"
)
// TestListAfterAscendingExclusive asserts the forward cursor returns only messages
// strictly newer than after, in oldest-first order (the reconnect catch-up direction).
func TestListAfterAscendingExclusive(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
for _, ts := range []int64{1000, 2000, 3000, 4000} {
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "x", CreatedAt: ts}); err != nil {
t.Fatalf("seed %d: %v", ts, err)
}
}
msgs, err := st.ListAfter(ctx, MainSessionID, 2000, 50, "")
if err != nil {
t.Fatalf("list after: %v", err)
}
if len(msgs) != 2 || msgs[0].CreatedAt != 3000 || msgs[1].CreatedAt != 4000 {
t.Fatalf("after=2000 = %+v, want ascending [3000 4000]", []int64{msgs[0].CreatedAt, msgs[1].CreatedAt})
}
// after at the newest message yields an empty (non-nil) page.
msgs, err = st.ListAfter(ctx, MainSessionID, 4000, 50, "")
if err != nil {
t.Fatalf("list after newest: %v", err)
}
if msgs == nil || len(msgs) != 0 {
t.Fatalf("after=4000 = %+v (nil=%v), want empty non-nil", msgs, msgs == nil)
}
// after=0 is the whole session, still ascending.
msgs, err = st.ListAfter(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list after 0: %v", err)
}
if len(msgs) != 4 || msgs[0].CreatedAt != 1000 || msgs[3].CreatedAt != 4000 {
t.Fatalf("after=0 = %+v, want ascending [1000 2000 3000 4000]", msgs)
}
}
// TestListAfterSameTimestampRowIDOrder asserts messages sharing a created_at keep their
// insertion order, so a same-millisecond batch is never reordered or dropped at a page
// boundary (rowid tiebreak).
func TestListAfterSameTimestampRowIDOrder(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
for _, c := range []string{"a", "b", "c"} {
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: c, CreatedAt: 5000}); err != nil {
t.Fatalf("seed %s: %v", c, err)
}
}
msgs, err := st.ListAfter(ctx, MainSessionID, 1000, 50, "")
if err != nil {
t.Fatalf("list after: %v", err)
}
if len(msgs) != 3 || msgs[0].Content != "a" || msgs[1].Content != "b" || msgs[2].Content != "c" {
t.Fatalf("same-ms order = %+v, want [a b c]", []string{msgs[0].Content, msgs[1].Content, msgs[2].Content})
}
}
// TestListAfterPlatformFilter asserts the optional platform filter applies to the forward
// query exactly as it does to the backward one.
func TestListAfterPlatformFilter(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
seeds := []Message{
{SessionID: MainSessionID, Role: RoleUser, Content: "web", Platform: PlatformWeb, CreatedAt: 1000},
{SessionID: MainSessionID, Role: RoleUser, Content: "tg1", Platform: "telegram", CreatedAt: 2000},
{SessionID: MainSessionID, Role: RoleUser, Content: "tg2", Platform: "telegram", CreatedAt: 3000},
}
for _, m := range seeds {
if err := st.Append(ctx, m); err != nil {
t.Fatalf("seed %s: %v", m.Content, err)
}
}
msgs, err := st.ListAfter(ctx, MainSessionID, 1000, 50, "telegram")
if err != nil {
t.Fatalf("list after platform: %v", err)
}
if len(msgs) != 2 || msgs[0].Content != "tg1" || msgs[1].Content != "tg2" {
t.Fatalf("telegram after=1000 = %+v, want [tg1 tg2]", msgs)
}
}
// TestListAfterLimitClamp asserts a client-supplied limit is capped at maxPageLimit and
// that advancing the cursor pages forward without gaps.
func TestListAfterLimitClamp(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
const total = maxPageLimit + 5
for i := 0; i < total; i++ {
ts := int64(1000 + i)
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "m", CreatedAt: ts}); err != nil {
t.Fatalf("seed %d: %v", ts, err)
}
}
page, err := st.ListAfter(ctx, MainSessionID, 999, 999, "")
if err != nil {
t.Fatalf("list after clamp: %v", err)
}
if len(page) != maxPageLimit {
t.Fatalf("clamped page len = %d, want %d", len(page), maxPageLimit)
}
if page[0].CreatedAt != 1000 || page[len(page)-1].CreatedAt != 1000+int64(maxPageLimit)-1 {
t.Fatalf("clamped page bounds = [%d..%d]", page[0].CreatedAt, page[len(page)-1].CreatedAt)
}
// Advance the cursor to the last seen createdAt: the remaining messages, no gap.
next, err := st.ListAfter(ctx, MainSessionID, page[len(page)-1].CreatedAt, 999, "")
if err != nil {
t.Fatalf("list after next: %v", err)
}
if len(next) != total-maxPageLimit {
t.Fatalf("next page len = %d, want %d", len(next), total-maxPageLimit)
}
if next[0].CreatedAt != 1000+int64(maxPageLimit) {
t.Fatalf("next page first = %d, want %d", next[0].CreatedAt, 1000+maxPageLimit)
}
}
+5
View File
@@ -132,6 +132,11 @@ type HistoryStore interface {
// returned (the Web client requests the full main session, an IM adapter asks for
// its own platform only).
List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error)
// ListAfter returns up to limit messages for sessionID strictly newer than after
// (created_at > after) in oldest-first order, so a client that reconnects can pull
// the messages it missed forward from the newest createdAt it has already seen.
// platform filters as in List. limit is clamped to [1, maxPageLimit].
ListAfter(ctx context.Context, sessionID string, after int64, limit int, platform string) ([]Message, error)
// Recent returns the most recent n messages for sessionID in oldest-first order,
// for WS replay / context tail usage.
Recent(ctx context.Context, sessionID string, n int) ([]Message, error)