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:
Aska committed 2026-08-20 20:34:08 +08:00
1 parent f44bf26050
commit f8196c111c
11 files changed
+787 -27

No files matched your search

+50 -10
View File
@@ -166,20 +166,60 @@ func (r *Router) notificationHandler(w http.ResponseWriter, req *http.Request) {
func (r *Router) messageSearchHandler(w http.ResponseWriter, req *http.Request) {
q := req.URL.Query().Get("q")
uid := req.Header.Get("X-User-ID")
uid := r.userID(req)
if q == "" { writeJSON(w, 400, M{"error": "missing query"}); return }
// Uses session store DB — returns matching messages
writeJSON(w, 200, M{"messages": []any{}, "query": q, "user_id": uid, "total": 0})
if r.deps.ChatStore == nil { writeJSON(w, 503, M{"error": "chat store unavailable"}); return }
messages, err := r.deps.ChatStore.SearchMessages(req.Context(), uid, q, 20)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"messages": messages, "query": q, "total": len(messages)})
}
func (r *Router) sessionExportHandler(w http.ResponseWriter, req *http.Request) {
id := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/")
id = strings.TrimSuffix(id, "/export")
// Export session as markdown
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.md", id[:8]))
w.WriteHeader(200)
fmt.Fprintf(w, "# Cyrene Chat - Session %s\n\n*Exported at %s*\n\n", id[:8], time.Now().Format(time.RFC3339))
id := strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/"), "/export")
if r.deps.ChatStore == nil { writeJSON(w, 503, M{"error": "chat store unavailable"}); return }
messages, err := r.deps.ChatStore.ListMessages(req.Context(), id, 200)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
short := id
if len(short) > 8 { short = short[:8] }
format := req.URL.Query().Get("format")
exportedAt := time.Now().Format(time.RFC3339)
switch format {
case "json":
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.json", short))
writeJSON(w, 200, M{"session_id": id, "exported_at": exportedAt, "messages": messages})
case "txt":
var sb strings.Builder
fmt.Fprintf(&sb, "Cyrene Chat - Session %s (exported %s)\n\n", short, exportedAt)
for _, m := range messages {
fmt.Fprintf(&sb, "[%s] %s: %s\n\n", m.CreatedAt.Format("2006-01-02 15:04"), m.Role, m.Content)
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.txt", short))
w.WriteHeader(200)
w.Write([]byte(sb.String()))
default: // markdown
var sb strings.Builder
fmt.Fprintf(&sb, "# Cyrene Chat - Session %s\n\n*Exported at %s*\n\n", short, exportedAt)
for _, m := range messages {
who := "用户"
if m.Role == "assistant" { who = "昔涟" }
fmt.Fprintf(&sb, "**%s**(%s):\n\n%s\n\n---\n\n", who, m.CreatedAt.Format("2006-01-02 15:04"), m.Content)
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.md", short))
w.WriteHeader(200)
w.Write([]byte(sb.String()))
}
}
func (r *Router) imageAnalyzeHandler(w http.ResponseWriter, req *http.Request) {
+82 -8
View File
@@ -12,6 +12,7 @@ import (
"time"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/store"
)
// --- Interfaces for injected services ---
@@ -51,7 +52,7 @@ type VoiceService interface {
// OrchestratorPipeline handles the main dialogue flow.
type OrchestratorPipeline interface {
ProcessInput(ctx context.Context, sessionID, userMessage string) (<-chan model.StreamToken, *model.Intent, error)
ProcessInput(ctx context.Context, sessionID, userID, userMessage string) (<-chan model.StreamToken, *model.Intent, error)
}
// WebSocketHub manages WS connections.
@@ -85,6 +86,16 @@ type BridgeConfigManager interface {
DeleteAdapter(name string) error
}
// ChatStorer persists chat sessions and messages.
type ChatStorer interface {
CreateSession(ctx context.Context, id, userID, title string) (*store.Session, error)
ListSessions(ctx context.Context, userID string) ([]store.Session, error)
DeleteSession(ctx context.Context, id, userID string) error
ListMessages(ctx context.Context, sessionID string, limit int) ([]store.Message, error)
ClearMessages(ctx context.Context, sessionID string) error
SearchMessages(ctx context.Context, userID, query string, limit int) ([]store.Message, error)
}
// Dependencies holds all injectable services.
type Dependencies struct {
AuthMgr AuthManager
@@ -96,6 +107,7 @@ type Dependencies struct {
TraceLog TraceLogger
PerfMon PerfMonitor
BridgeCfg BridgeConfigManager
ChatStore ChatStorer
ModelConfig *ModelConfigStore
Automation *AutomationStore
Reminders *ReminderStore
@@ -257,29 +269,91 @@ func (r *Router) registerHandler(w http.ResponseWriter, req *http.Request) {
// --- Sessions ---
func (r *Router) sessionsHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.ChatStore == nil {
writeJSON(w, 503, M{"error": "chat store unavailable"})
return
}
uid := r.userID(req)
switch req.Method {
case "GET":
writeJSON(w, 200, M{"sessions": []any{}})
sessions, err := r.deps.ChatStore.ListSessions(req.Context(), uid)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"sessions": sessions})
case "POST":
writeJSON(w, 201, M{"session_id": randID()})
var body struct {
Title string `json:"title"`
}
json.NewDecoder(req.Body).Decode(&body)
sess, err := r.deps.ChatStore.CreateSession(req.Context(), randID(), uid, body.Title)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 201, M{"session_id": sess.ID, "title": sess.Title})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
func (r *Router) sessionByIDHandler(w http.ResponseWriter, req *http.Request) {
id := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/")
// Handle export
if strings.HasSuffix(id, "/export") {
id = strings.TrimSuffix(id, "/export")
if r.deps.ChatStore == nil {
writeJSON(w, 503, M{"error": "chat store unavailable"})
return
}
rest := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/")
// Sub-resources: /sessions/{id}/export and /sessions/{id}/messages
if strings.HasSuffix(rest, "/export") {
r.sessionExportHandler(w, req)
return
}
if strings.HasSuffix(rest, "/messages") {
r.sessionMessagesHandler(w, req)
return
}
id := rest
switch req.Method {
case "DELETE":
if err := r.deps.ChatStore.DeleteSession(req.Context(), id, r.userID(req)); err != nil {
writeJSON(w, 404, M{"error": err.Error()})
return
}
writeJSON(w, 204, nil)
case "GET":
writeJSON(w, 200, M{"session_id": id})
messages, err := r.deps.ChatStore.ListMessages(req.Context(), id, 100)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"session_id": id, "messages": messages})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
// sessionMessagesHandler serves GET (recent history) and DELETE (clear).
func (r *Router) sessionMessagesHandler(w http.ResponseWriter, req *http.Request) {
id := strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/"), "/messages")
switch req.Method {
case "GET":
limit := 0
fmt.Sscanf(req.URL.Query().Get("limit"), "%d", &limit)
messages, err := r.deps.ChatStore.ListMessages(req.Context(), id, limit)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"messages": messages})
case "DELETE":
if err := r.deps.ChatStore.ClearMessages(req.Context(), id); err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 204, nil)
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
+1 -1
View File
@@ -25,7 +25,7 @@ func TestHealth(t *testing.T) {
func TestLoginAndProtectedEndpoint(t *testing.T) {
auth := NewStubAuthManager()
r := New(Dependencies{AuthMgr: auth})
r := New(Dependencies{AuthMgr: auth, ChatStore: newFakeChatStore()})
srv := httptest.NewServer(r)
defer srv.Close()
@@ -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)
}