feat(gateway): persist chat sessions and messages in PostgreSQL
- store: new chat_sessions/chat_messages tables with CRUD, ILIKE message search, and auto-titling from the first user message - context: optional Persister on the sliding-window store — messages are saved asynchronously on Add, sessions not in memory are restored from the database on Get - orchestrator: ProcessInput/FinishRound now carry userID so persisted messages have an owner - api: real session handlers (list/create/delete), message history and clear endpoints, working message search and three-format export (markdown/txt/json), fixing the short-ID panic in export - main: migration, store wiring, ctxStore persister adapter Tests: chat store handler lifecycle (fake store), context persister save/restore, stub auth login flow updated.
This commit is contained in:
1 parent
f44bf26050
commit
f8196c111c
11 files changed
+787
-27
No files matched your search
@@ -0,0 +1,239 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/store"
|
||||
)
|
||||
|
||||
// fakeChatStore is an in-memory ChatStorer for tests.
|
||||
type fakeChatStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*store.Session
|
||||
messages map[string][]store.Message
|
||||
nextID int64
|
||||
}
|
||||
|
||||
func newFakeChatStore() *fakeChatStore {
|
||||
return &fakeChatStore{sessions: map[string]*store.Session{}, messages: map[string][]store.Message{}}
|
||||
}
|
||||
|
||||
func (f *fakeChatStore) CreateSession(_ context.Context, id, userID, title string) (*store.Session, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s := &store.Session{ID: id, UserID: userID, Title: title, CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||||
f.sessions[id] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (f *fakeChatStore) ListSessions(_ context.Context, userID string) ([]store.Session, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := []store.Session{}
|
||||
for _, s := range f.sessions {
|
||||
if s.UserID == userID {
|
||||
out = append(out, *s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeChatStore) DeleteSession(_ context.Context, id, userID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.sessions[id]; !ok {
|
||||
return fmt.Errorf("session not found")
|
||||
}
|
||||
delete(f.sessions, id)
|
||||
delete(f.messages, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeChatStore) AddMessage(sessionID, role, content string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.nextID++
|
||||
f.messages[sessionID] = append(f.messages[sessionID], store.Message{
|
||||
ID: f.nextID, SessionID: sessionID, Role: role, Content: content, CreatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (f *fakeChatStore) ListMessages(_ context.Context, sessionID string, limit int) ([]store.Message, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
msgs := f.messages[sessionID]
|
||||
if msgs == nil {
|
||||
msgs = []store.Message{}
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
func (f *fakeChatStore) ClearMessages(_ context.Context, sessionID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.messages[sessionID] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeChatStore) SearchMessages(_ context.Context, userID, query string, limit int) ([]store.Message, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := []store.Message{}
|
||||
for _, msgs := range f.messages {
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(m.Content, query) {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func authReq(method, url, body string) *http.Request {
|
||||
var r *http.Request
|
||||
if body != "" {
|
||||
r, _ = http.NewRequest(method, url, strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
r, _ = http.NewRequest(method, url, nil)
|
||||
}
|
||||
r.Header.Set("Authorization", "Bearer stub-token-admin")
|
||||
return r
|
||||
}
|
||||
|
||||
func TestSessionLifecycle(t *testing.T) {
|
||||
auth := NewStubAuthManager()
|
||||
auth.Login("admin", "admin") // register stub-token-admin
|
||||
chat := newFakeChatStore()
|
||||
r := New(Dependencies{AuthMgr: auth, ChatStore: chat})
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
// Create session
|
||||
resp, err := http.DefaultClient.Do(authReq("POST", srv.URL+"/api/v1/sessions", `{"title":""}`))
|
||||
if err != nil {
|
||||
t.Fatalf("POST sessions: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: expected 201, got %d", resp.StatusCode)
|
||||
}
|
||||
var created struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
readJSON(t, resp, &created)
|
||||
if created.SessionID == "" {
|
||||
t.Fatal("empty session_id")
|
||||
}
|
||||
|
||||
// Seed messages directly
|
||||
chat.AddMessage(created.SessionID, "user", "今晚想吃火锅")
|
||||
chat.AddMessage(created.SessionID, "assistant", "好呀,要不要叫上朋友")
|
||||
|
||||
// List sessions
|
||||
resp, _ = http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/sessions", ""))
|
||||
var listed struct {
|
||||
Sessions []store.Session `json:"sessions"`
|
||||
}
|
||||
readJSON(t, resp, &listed)
|
||||
if len(listed.Sessions) != 1 {
|
||||
t.Fatalf("list: expected 1 session, got %d", len(listed.Sessions))
|
||||
}
|
||||
|
||||
// Get messages
|
||||
resp, _ = http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/sessions/"+created.SessionID+"/messages", ""))
|
||||
var msgs struct {
|
||||
Messages []store.Message `json:"messages"`
|
||||
}
|
||||
readJSON(t, resp, &msgs)
|
||||
if len(msgs.Messages) != 2 {
|
||||
t.Fatalf("messages: expected 2, got %d", len(msgs.Messages))
|
||||
}
|
||||
|
||||
// Search
|
||||
resp, _ = http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/messages/search?q=火锅", ""))
|
||||
var found struct {
|
||||
Total int `json:"total"`
|
||||
}
|
||||
readJSON(t, resp, &found)
|
||||
if found.Total != 1 {
|
||||
t.Errorf("search: expected 1 hit, got %d", found.Total)
|
||||
}
|
||||
|
||||
// Export markdown
|
||||
resp, _ = http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/sessions/"+created.SessionID+"/export", ""))
|
||||
body := readBody(t, resp)
|
||||
if !strings.Contains(body, "火锅") || !strings.Contains(body, "# Cyrene Chat") {
|
||||
t.Errorf("export markdown missing content: %q", body)
|
||||
}
|
||||
|
||||
// Export json
|
||||
resp, _ = http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/sessions/"+created.SessionID+"/export?format=json", ""))
|
||||
var exported struct {
|
||||
Messages []store.Message `json:"messages"`
|
||||
}
|
||||
readJSON(t, resp, &exported)
|
||||
if len(exported.Messages) != 2 {
|
||||
t.Errorf("export json: expected 2 messages, got %d", len(exported.Messages))
|
||||
}
|
||||
|
||||
// Clear messages
|
||||
resp, _ = http.DefaultClient.Do(authReq("DELETE", srv.URL+"/api/v1/sessions/"+created.SessionID+"/messages", ""))
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("clear: expected 204, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Delete session
|
||||
resp, _ = http.DefaultClient.Do(authReq("DELETE", srv.URL+"/api/v1/sessions/"+created.SessionID, ""))
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("delete: expected 204, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, _ = http.DefaultClient.Do(authReq("DELETE", srv.URL+"/api/v1/sessions/"+created.SessionID, ""))
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("delete again: expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestSessionsUnavailableWithoutStore(t *testing.T) {
|
||||
auth := NewStubAuthManager()
|
||||
auth.Login("admin", "admin")
|
||||
r := New(Dependencies{AuthMgr: auth})
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
resp, _ := http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/sessions", ""))
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503 without store, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func readJSON(t *testing.T, resp *http.Response, v any) {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
Reference in new issue
Block a user