Compare commits

..

10 Commits

Author SHA1 Message Date
hhs
b57adf153b docs: 补充用户模块 REST API 接口契约
- PLAN_USER_MODULE.md 新增「前端 API 接口参考」章节
- 03-接口文档.md 同步认证接口、对话接口、WebSocket 认证变更
- 新增错误码 USERNAME_TAKEN / INVALID_CREDENTIALS / INVALID_TOKEN / INVALID_INPUT
- 数据模型补充 User / ConversationSummary / StoredMessage 及对应 TypeScript 类型
- 配置结构体补充 AuthConfig(JWTSecret / AccessTTL / RefreshTTL)
2026-06-14 18:05:50 +08:00
hhs
c3a32ce276 feat: Phase 8.5 — ConversationSummary 查询优化,支持 SQL 聚合获取消息统计 2026-06-14 17:58:42 +08:00
hhs
7b745018c5 feat: Phase 8.4 — 实现 LoadSession 和 LoadSessionFromDB,支持从 PostgreSQL 恢复会话到内存 2026-06-14 17:56:33 +08:00
hhs
f4515ce5e4 feat: Phase 8.3 — Session Manager 注入 MessageRepository,AppendMessage 启用 Write-Through 2026-06-14 17:54:33 +08:00
hhs
96f4bc7abb feat: Phase 8.2 — 实现 PostgreSQL MessageRepository 2026-06-14 17:53:14 +08:00
hhs
dae5722945 feat: Phase 8.1 — 定义 MessageRepository 接口和消息表迁移脚本 2026-06-14 17:52:21 +08:00
hhs
3c5c4943e8 feat: Phase 7.7 — 编写 WS 认证测试
- TestWS_AuthMissingToken: 无 token 返回 401
- TestWS_AuthInvalidToken: 无效 token 返回 401
- TestWS_AuthExpiredToken: 过期 token 返回 401
- TestWS_AuthValidToken: 有效 token 成功连接
- TestWS_AuthConversationIDResume: conversation_id 恢复已有对话
- TestWS_AuthConversationIDNotFound: 不存在的 conversation_id 返回 401
- TestWS_AuthConversationIDOwnership: 非 owner 访问返回 401
2026-06-14 17:48:47 +08:00
hhs
80c6b1b56e feat: Phase 7.3 — WS conversation_id 处理
- ?conversation_id=xxx 存在时校验 session 归属(UserID 匹配)
- 校验失败返回 401 SESSION_NOT_FOUND
- 校验通过则复用已有 session;否则创建新 session
2026-06-14 17:47:34 +08:00
hhs
905b56640e feat: Phase 7.2 — WS 连接 JWT 认证
- 从 ?token=xxx 查询参数提取 access_token
- 校验失败返回 401(missing token / invalid token)
- 校验成功后将 userID 用于创建会话
- 更新现有测试:setupTestServer 自动生成有效 token
2026-06-14 17:47:05 +08:00
hhs
2aa3c98ab6 feat: Phase 7.1 — 修改 ServeWS 签名,新增 tokenMgr 参数
- ServeWS 和 serveWS 函数新增 *auth.TokenManager 参数
- main.go 传入 tokenMgr 到 ServeWS
- handler_test.go 适配新签名
2026-06-14 17:46:11 +08:00
10 changed files with 1690 additions and 69 deletions

View File

@@ -145,7 +145,7 @@ func main() {
convHandler.RegisterRoutes(apiGroup)
// WebSocket
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg))
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr))
// HTTP Server
srv := &http.Server{

View File

@@ -10,6 +10,7 @@ import (
"github.com/hhs/camtalk/internal/logger"
"github.com/hhs/camtalk/internal/models"
"github.com/hhs/camtalk/internal/store"
)
const (
@@ -33,11 +34,23 @@ type MemoryManager struct {
ttl time.Duration
maxHistory int
stopCleaner chan struct{}
msgRepo store.MessageRepository // 可选消息持久化Write-Through
}
// Option MemoryManager 的函数式选项。
type Option func(*MemoryManager)
// WithMessageRepository 注入消息持久化仓库,启用 Write-Through 模式。
func WithMessageRepository(repo store.MessageRepository) Option {
return func(m *MemoryManager) {
m.msgRepo = repo
}
}
// NewMemoryManager 创建内存版 SessionManager。
// ttl 为会话过期时间maxHistory 为对话历史上限0 表示使用默认值 20
func NewMemoryManager(ttl time.Duration, maxHistory int) *MemoryManager {
// opts 为可选配置,如 WithMessageRepository 启用消息持久化。
func NewMemoryManager(ttl time.Duration, maxHistory int, opts ...Option) *MemoryManager {
if ttl <= 0 {
ttl = defaultTTL
}
@@ -52,6 +65,10 @@ func NewMemoryManager(ttl time.Duration, maxHistory int) *MemoryManager {
stopCleaner: make(chan struct{}),
}
for _, opt := range opts {
opt(m)
}
// 启动后台清理 goroutine每分钟清除过期会话。
go m.cleanLoop()
@@ -170,12 +187,13 @@ func (m *MemoryManager) UpdateTitle(_ context.Context, sessionID string, title s
}
// ListByUser 获取用户的对话列表(分页,按 UpdatedAt 降序)。
func (m *MemoryManager) ListByUser(_ context.Context, userID string, page, size int) ([]ConversationSummary, int, error) {
// 若配置了 MessageRepository消息统计从 PostgreSQL 聚合查询(更准确)。
func (m *MemoryManager) ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) {
m.mu.RLock()
defer m.mu.RUnlock()
// 收集该用户的所有 session
var list []ConversationSummary
var sessionIDs []string
for _, entry := range m.sessions {
if entry.session.UserID != userID {
continue
@@ -186,13 +204,30 @@ func (m *MemoryManager) ListByUser(_ context.Context, userID string, page, size
summary := ConversationSummary{
ID: entry.session.ID,
Title: entry.session.Title,
MessageCount: len(entry.history),
UpdatedAt: entry.lastActive,
}
// 先用内存值填充,后续可能被 PG 统计覆盖
summary.MessageCount = len(entry.history)
if len(entry.history) > 0 {
summary.LastMessage = entry.history[len(entry.history)-1].Content
}
list = append(list, summary)
sessionIDs = append(sessionIDs, entry.session.ID)
}
m.mu.RUnlock()
// 若配置了 msgRepo从 PostgreSQL 获取更准确的消息统计
if m.msgRepo != nil && len(sessionIDs) > 0 {
if stats, err := m.msgRepo.GetSessionMessageStats(ctx, sessionIDs); err == nil {
for i := range list {
if s, ok := stats[list[i].ID]; ok {
list[i].LastMessage = s.LastMessage
list[i].MessageCount = s.MessageCount
}
}
} else {
logger.Log.Warnw("get session message stats failed, falling back to in-memory", "error", err)
}
}
// 按 UpdatedAt 降序排序
@@ -242,12 +277,13 @@ func (m *MemoryManager) GetHistory(_ context.Context, sessionID string, limit in
}
// AppendMessage 追加一条对话消息,同时刷新 TTL。
// 若配置了 MessageRepository消息会异步写入 PostgreSQLWrite-Through
func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg models.Message) error {
m.mu.Lock()
defer m.mu.Unlock()
entry, ok := m.sessions[sessionID]
if !ok || m.isExpired(entry) {
m.mu.Unlock()
return ErrSessionNotFound
}
@@ -266,6 +302,17 @@ func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg m
now := time.Now()
entry.lastActive = now
entry.session.UpdatedAt = now
m.mu.Unlock()
// Write-Through异步写冷存储不阻塞调用方
if m.msgRepo != nil {
go func() {
if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil {
logger.Log.Warnw("persist message failed", "session", sessionID, "error", err)
}
}()
}
return nil
}
@@ -278,6 +325,48 @@ func generateTitle(firstMessage string) string {
return firstMessage
}
// LoadSession 从外部存储加载会话到内存热存储。
// 用于 conversation_id 恢复场景WS 连接时会话不在内存中,从 PostgreSQL 加载。
// 若会话已在内存中,返回 nil幂等
func (m *MemoryManager) LoadSession(sess *models.Session, messages []models.Message) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.sessions[sess.ID]; ok {
return nil // 已在内存中,无需重复加载
}
m.sessions[sess.ID] = &sessionEntry{
session: *sess,
history: messages,
lastActive: time.Now(),
}
logger.Log.Debugw("session loaded from DB", "session", sess.ID, "messages", len(messages))
return nil
}
// LoadSessionFromRepo 从 MessageRepository 加载会话消息并注册到内存。
// 适用于已注入 MessageRepository 的场景,调用方只需传入 session 元数据。
func (m *MemoryManager) LoadSessionFromRepo(ctx context.Context, sess *models.Session) error {
if m.msgRepo == nil {
return m.LoadSession(sess, nil)
}
// 从冷存储加载全部消息limit=0 表示全量)
stored, err := m.msgRepo.GetMessages(ctx, sess.ID, 0, 0)
if err != nil {
return err
}
messages := make([]models.Message, len(stored))
for i, s := range stored {
messages[i] = models.Message{Role: s.Role, Content: s.Content}
}
return m.LoadSession(sess, messages)
}
// SetActiveRequest 标记当前正在处理的请求 ID。
func (m *MemoryManager) SetActiveRequest(_ context.Context, sessionID string, requestID string) error {
m.mu.Lock()

View File

@@ -0,0 +1,50 @@
package store
import (
"context"
"errors"
"time"
"github.com/hhs/camtalk/internal/models"
)
var (
// ErrMessageNotFound 消息不存在。
ErrMessageNotFound = errors.New("message not found")
)
// MessageRepository 消息持久化接口。
type MessageRepository interface {
// SaveMessage 保存一条消息。
SaveMessage(ctx context.Context, sessionID string, msg models.Message, tokensUsed int) error
// GetMessages 获取会话的消息列表(分页,按 created_at 升序)。
// beforeID 为 0 时从最新开始查询。
GetMessages(ctx context.Context, sessionID string, limit int, beforeID int64) ([]StoredMessage, error)
// GetLastMessage 获取会话的最后一条消息。
GetLastMessage(ctx context.Context, sessionID string) (*StoredMessage, error)
// GetMessageCount 获取会话的消息总数。
GetMessageCount(ctx context.Context, sessionID string) (int, error)
// GetSessionMessageStats 批量查询多个会话的消息统计last_message + message_count
// 返回的 map key 为 sessionID仅包含有消息的会话。
GetSessionMessageStats(ctx context.Context, sessionIDs []string) (map[string]SessionMessageStats, error)
}
// SessionMessageStats 单个会话的消息统计SQL 聚合查询结果)。
type SessionMessageStats struct {
LastMessage string
MessageCount int
}
// StoredMessage 持久化消息模型store 层)。
type StoredMessage struct {
ID int64 `json:"id"`
SessionID string `json:"-"`
Role string `json:"role"`
Content string `json:"content"`
TokensUsed int `json:"tokens_used"`
CreatedAt time.Time `json:"created_at"`
}

View File

@@ -0,0 +1,163 @@
package store
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hhs/camtalk/internal/models"
)
// PgMessageRepository 基于 PostgreSQL 的 MessageRepository 实现。
type PgMessageRepository struct {
pool *pgxpool.Pool
}
// NewPgMessageRepository 创建 PgMessageRepository。
func NewPgMessageRepository(pool *pgxpool.Pool) *PgMessageRepository {
return &PgMessageRepository{pool: pool}
}
func (r *PgMessageRepository) SaveMessage(ctx context.Context, sessionID string, msg models.Message, tokensUsed int) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO messages (session_id, role, content, tokens_used) VALUES ($1, $2, $3, $4)`,
sessionID, msg.Role, msg.Content, tokensUsed,
)
return err
}
func (r *PgMessageRepository) GetMessages(ctx context.Context, sessionID string, limit int, beforeID int64) ([]StoredMessage, error) {
if limit <= 0 {
limit = 50
}
var rows []StoredMessage
var err error
if beforeID > 0 {
rows, err = r.queryMessages(ctx,
`SELECT id, session_id, role, content, tokens_used, created_at
FROM messages
WHERE session_id = $1 AND id < $2
ORDER BY id DESC
LIMIT $3`,
sessionID, beforeID, limit,
)
} else {
rows, err = r.queryMessages(ctx,
`SELECT id, session_id, role, content, tokens_used, created_at
FROM messages
WHERE session_id = $1
ORDER BY id DESC
LIMIT $2`,
sessionID, limit,
)
}
if err != nil {
return nil, err
}
// 反转为升序
for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 {
rows[i], rows[j] = rows[j], rows[i]
}
return rows, nil
}
func (r *PgMessageRepository) queryMessages(ctx context.Context, query string, args ...any) ([]StoredMessage, error) {
pgxRows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer pgxRows.Close()
var messages []StoredMessage
for pgxRows.Next() {
var m StoredMessage
if err := pgxRows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.TokensUsed, &m.CreatedAt); err != nil {
return nil, err
}
messages = append(messages, m)
}
if err := pgxRows.Err(); err != nil {
return nil, err
}
return messages, nil
}
func (r *PgMessageRepository) GetLastMessage(ctx context.Context, sessionID string) (*StoredMessage, error) {
var m StoredMessage
err := r.pool.QueryRow(ctx,
`SELECT id, session_id, role, content, tokens_used, created_at
FROM messages
WHERE session_id = $1
ORDER BY id DESC
LIMIT 1`,
sessionID,
).Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.TokensUsed, &m.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrMessageNotFound
}
if err != nil {
return nil, err
}
return &m, nil
}
func (r *PgMessageRepository) GetMessageCount(ctx context.Context, sessionID string) (int, error) {
var count int
err := r.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM messages WHERE session_id = $1`,
sessionID,
).Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
func (r *PgMessageRepository) GetSessionMessageStats(ctx context.Context, sessionIDs []string) (map[string]SessionMessageStats, error) {
if len(sessionIDs) == 0 {
return map[string]SessionMessageStats{}, nil
}
rows, err := r.pool.Query(ctx,
`WITH stats AS (
SELECT session_id, COUNT(*) AS cnt
FROM messages
WHERE session_id = ANY($1)
GROUP BY session_id
),
last_msg AS (
SELECT DISTINCT ON (session_id) session_id, content
FROM messages
WHERE session_id = ANY($1)
ORDER BY session_id, id DESC
)
SELECT s.session_id, s.cnt, COALESCE(lm.content, '')
FROM stats s
LEFT JOIN last_msg lm ON lm.session_id = s.session_id`,
sessionIDs,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]SessionMessageStats)
for rows.Next() {
var sid string
var stats SessionMessageStats
if err := rows.Scan(&sid, &stats.MessageCount, &stats.LastMessage); err != nil {
return nil, err
}
result[sid] = stats
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/hhs/camtalk/internal/auth"
"github.com/hhs/camtalk/internal/config"
"github.com/hhs/camtalk/internal/errors"
"github.com/hhs/camtalk/internal/logger"
@@ -91,7 +92,7 @@ func (w *WSClient) SendError(err models.WsError) error {
}
// ServeWS 处理 WebSocket 升级请求。
func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *config.Config) gin.HandlerFunc {
func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *config.Config, tokenMgr *auth.TokenManager) gin.HandlerFunc {
upgrader := newUpgrader(cfg)
heartbeatInterval := time.Duration(cfg.Server.HeartbeatInterval) * time.Second
heartbeatTimeout := time.Duration(cfg.Server.HeartbeatTimeout) * time.Second
@@ -100,12 +101,37 @@ func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *co
maxHistory := cfg.Session.MaxHistory
return func(c *gin.Context) {
serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, maxHistory)
serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, maxHistory, tokenMgr)
}
}
func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orchestrator,
upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, maxHistory int) {
upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, maxHistory int, tokenMgr *auth.TokenManager) {
// --- JWT 认证upgrade 前完成,失败直接返回 HTTP 错误) ---
token := c.Query("token")
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
claims, err := tokenMgr.ValidateAccess(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
userID := claims.UserID
username := claims.Username
// --- conversation_id 处理upgrade 前校验归属) ---
conversationID := c.Query("conversation_id")
if conversationID != "" {
sess, err := sessionMgr.Get(c.Request.Context(), conversationID)
if err != nil || sess.UserID != userID {
c.JSON(http.StatusUnauthorized, gin.H{"error": "SESSION_NOT_FOUND"})
return
}
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
logger.Log.Errorw("websocket upgrade failed", "error", err)
@@ -113,12 +139,18 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
}
defer conn.Close()
// 创建会话
sessionID, err := sessionMgr.Create(context.Background(), "", models.DefaultConfig())
// 创建或复用会话
var sessionID string
if conversationID != "" {
sessionID = conversationID
logger.Log.Infow("resuming conversation", "session", sessionID, "user_id", userID)
} else {
sessionID, err = sessionMgr.Create(context.Background(), userID, models.DefaultConfig())
if err != nil {
logger.Log.Errorw("create session failed", "error", err)
return
}
}
client := &Client{
conn: conn,
@@ -134,7 +166,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
SessionID: sessionID,
ServerVersion: version,
})
logger.Log.Infow("client connected", "session", sessionID)
logger.Log.Infow("client connected", "session", sessionID, "user_id", userID, "username", username)
// 心跳检测
lastPong := time.Now()

View File

@@ -2,6 +2,7 @@ package ws
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
@@ -13,6 +14,7 @@ import (
"github.com/stretchr/testify/require"
"context"
"github.com/hhs/camtalk/internal/auth"
"github.com/hhs/camtalk/internal/config"
"github.com/hhs/camtalk/internal/logger"
"github.com/hhs/camtalk/internal/models"
@@ -132,24 +134,29 @@ func (m *MockOrchestrator) ProcessQuery(
// --- 测试辅助函数 ---
// setupTestServer 创建测试用 Gin 服务器和 WebSocket URL。
// 返回的 wsURL 已包含有效 token可直接连接。
func setupTestServer(t *testing.T, orch orchestrator.Orchestrator) (*httptest.Server, string) {
t.Helper()
sessionMgr := session.NewMemoryManager(5*time.Minute, 20)
t.Cleanup(func() { sessionMgr.Stop() })
tokenMgr := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour)
r := gin.New()
cfg := &config.Config{
App: config.AppConfig{Version: "test"},
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
Session: config.SessionConfig{MaxHistory: 20},
}
r.GET("/ws", ServeWS(sessionMgr, orch, cfg))
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr))
srv := httptest.NewServer(r)
// 构造 WebSocket URL
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
// 生成有效 token 并构造 WebSocket URL
token, _, err := tokenMgr.GeneratePair("test-user", "testuser")
require.NoError(t, err)
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws?token=" + token
return srv, wsURL
}
@@ -567,3 +574,156 @@ func TestWS_QueryWithTTSDisabled(t *testing.T) {
err = conn.ReadJSON(&extra)
assert.Error(t, err, "不应有额外消息")
}
// --- 认证测试辅助 ---
// setupTestServerEx 创建测试服务器,返回 tokenMgr 和 sessionMgr 以便测试控制。
func setupTestServerEx(t *testing.T, orch orchestrator.Orchestrator) (*httptest.Server, *auth.TokenManager, *session.MemoryManager) {
t.Helper()
sessionMgr := session.NewMemoryManager(5*time.Minute, 20)
t.Cleanup(func() { sessionMgr.Stop() })
tokenMgr := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour)
r := gin.New()
cfg := &config.Config{
App: config.AppConfig{Version: "test"},
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
Session: config.SessionConfig{MaxHistory: 20},
}
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr))
srv := httptest.NewServer(r)
return srv, tokenMgr, sessionMgr
}
// httpGet 发送 HTTP GET 并返回状态码。
func httpGet(t *testing.T, url string) int {
t.Helper()
resp, err := http.Get(url)
require.NoError(t, err)
resp.Body.Close()
return resp.StatusCode
}
// --- 认证测试用例 ---
// TestWS_AuthMissingToken 验证无 token 时返回 401。
func TestWS_AuthMissingToken(t *testing.T) {
srv, _, _ := setupTestServerEx(t, &MockOrchestrator{})
defer srv.Close()
httpURL := srv.URL + "/ws"
status := httpGet(t, httpURL)
assert.Equal(t, http.StatusUnauthorized, status)
}
// TestWS_AuthInvalidToken 验证无效 token 时返回 401。
func TestWS_AuthInvalidToken(t *testing.T) {
srv, _, _ := setupTestServerEx(t, &MockOrchestrator{})
defer srv.Close()
httpURL := srv.URL + "/ws?token=invalid-token"
status := httpGet(t, httpURL)
assert.Equal(t, http.StatusUnauthorized, status)
}
// TestWS_AuthExpiredToken 验证过期 token 时返回 401。
func TestWS_AuthExpiredToken(t *testing.T) {
// 创建一个 access TTL 极短的 tokenMgr
sessionMgr := session.NewMemoryManager(5*time.Minute, 20)
defer sessionMgr.Stop()
tokenMgr := auth.NewTokenManager("test-secret", -1*time.Minute, 7*24*time.Hour) // 已过期
r := gin.New()
cfg := &config.Config{
App: config.AppConfig{Version: "test"},
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
Session: config.SessionConfig{MaxHistory: 20},
}
r.GET("/ws", ServeWS(sessionMgr, &MockOrchestrator{}, cfg, tokenMgr))
srv := httptest.NewServer(r)
defer srv.Close()
token, _, err := tokenMgr.GeneratePair("test-user", "testuser")
require.NoError(t, err)
httpURL := srv.URL + "/ws?token=" + token
status := httpGet(t, httpURL)
assert.Equal(t, http.StatusUnauthorized, status)
}
// TestWS_AuthValidToken 验证有效 token 能成功建立 WS 连接。
func TestWS_AuthValidToken(t *testing.T) {
srv, tokenMgr, _ := setupTestServerEx(t, &MockOrchestrator{})
defer srv.Close()
token, _, err := tokenMgr.GeneratePair("user-1", "alice")
require.NoError(t, err)
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws?token=" + token
conn := connectWS(t, wsURL)
msg := readJSON(t, conn)
assert.Equal(t, "connected", msg["type"])
assert.NotEmpty(t, msg["session_id"])
}
// TestWS_AuthConversationIDResume 验证通过 conversation_id 恢复已有对话。
func TestWS_AuthConversationIDResume(t *testing.T) {
srv, tokenMgr, sessionMgr := setupTestServerEx(t, &MockOrchestrator{})
defer srv.Close()
userID := "user-1"
// 先创建一个属于该用户的 session
ctx := context.Background()
sessionID, err := sessionMgr.Create(ctx, userID, models.DefaultConfig())
require.NoError(t, err)
token, _, err := tokenMgr.GeneratePair(userID, "alice")
require.NoError(t, err)
// 带 conversation_id 连接
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") +
"/ws?token=" + token + "&conversation_id=" + sessionID
conn := connectWS(t, wsURL)
msg := readJSON(t, conn)
assert.Equal(t, "connected", msg["type"])
assert.Equal(t, sessionID, msg["session_id"], "应复用已有 session")
}
// TestWS_AuthConversationIDNotFound 验证 conversation_id 不存在时返回 401。
func TestWS_AuthConversationIDNotFound(t *testing.T) {
srv, tokenMgr, _ := setupTestServerEx(t, &MockOrchestrator{})
defer srv.Close()
token, _, err := tokenMgr.GeneratePair("user-1", "alice")
require.NoError(t, err)
httpURL := srv.URL + "/ws?token=" + token + "&conversation_id=nonexistent-id"
status := httpGet(t, httpURL)
assert.Equal(t, http.StatusUnauthorized, status)
}
// TestWS_AuthConversationIDOwnership 验证 conversation_id 不属于当前用户时返回 401。
func TestWS_AuthConversationIDOwnership(t *testing.T) {
srv, tokenMgr, sessionMgr := setupTestServerEx(t, &MockOrchestrator{})
defer srv.Close()
ctx := context.Background()
// user-A 创建 session
sessionID, err := sessionMgr.Create(ctx, "user-A", models.DefaultConfig())
require.NoError(t, err)
// user-B 尝试连接该 session
token, _, err := tokenMgr.GeneratePair("user-B", "bob")
require.NoError(t, err)
httpURL := srv.URL + "/ws?token=" + token + "&conversation_id=" + sessionID
status := httpGet(t, httpURL)
assert.Equal(t, http.StatusUnauthorized, status, "非 owner 访问应返回 401")
}

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS messages;

View File

@@ -0,0 +1,17 @@
-- 消息表
CREATE TABLE IF NOT EXISTS messages (
id BIGSERIAL PRIMARY KEY,
session_id UUID NOT NULL,
role VARCHAR(16) NOT NULL, -- "user" | "assistant" | "system"
content TEXT NOT NULL,
tokens_used INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 按会话查询消息(分页核心索引)
CREATE INDEX IF NOT EXISTS idx_messages_session_id_created_at
ON messages(session_id, created_at);
-- 按会话查询最后一条消息
CREATE INDEX IF NOT EXISTS idx_messages_session_id_id_desc
ON messages(session_id, id DESC);

View File

@@ -13,16 +13,28 @@
```
浏览器 Go Gateway :8080
WebSocket Client <--> /ws (实时对话)
HTTP Client --> GET /api/health
HTTP Client <--> POST/DELETE /api/sessions
WebSocket Client <--> /ws?token=<jwt> (实时对话,需 JWT 认证
HTTP Client --> GET /api/health (健康检查)
HTTP Client <--> POST /api/auth/* (注册/登录/刷新/登出)
HTTP Client <--> GET/POST/PATCH/DELETE (对话 CRUD
/api/conversations/*
HTTP Client <--> GET /api/conversations/:id (历史消息)
/messages
HTTP Client ~~> POST/DELETE /api/sessions (已废弃,保留兼容)
```
---
## 一、WebSocket 协议
连接地址:`ws://localhost:8080/ws`
连接地址:`ws://localhost:8080/ws?token=<access_token>&conversation_id=<uuid>`
| 参数 | 必填 | 说明 |
|------|------|------|
| `token` | 是 | JWT access_token缺失或无效时返回 401 |
| `conversation_id` | 否 | 恢复已有对话;省略则创建新对话 |
> 详见"REST API → WebSocket 认证变更"章节。
### 消息格式约定
@@ -96,6 +108,7 @@ interface PingMessage {
interface ConnectedMessage {
type: "connected";
session_id: string; // 服务端生成的会话 ID
conversation_id: string; // 同 session_id便于前端统一使用
server_version: string; // 服务端版本号,如 "0.1.0"
}
```
@@ -265,13 +278,446 @@ Client Server
## 二、REST API
### 通用约定
#### 认证方式
需要认证的接口在请求头携带 JWT access token
```
Authorization: Bearer <access_token>
```
未认证或 token 过期时返回 `401 Unauthorized`
#### 错误响应格式
所有错误响应统一结构:
```typescript
interface ApiError {
code: string; // 机器可读错误码
message: string; // 人类可读描述
}
```
示例:
```json
{
"code": "USERNAME_TAKEN",
"message": "username already taken"
}
```
#### 输入校验规则
| 字段 | 规则 |
|------|------|
| `username` | 3-64 字符,仅允许字母、数字、下划线 |
| `password` | 8-72 字符 |
---
### 认证接口(`/api/auth`
#### 注册
```
POST /api/auth/register
Content-Type: application/json
```
**请求体**
```typescript
interface RegisterRequest {
username: string; // 3-64 字符
password: string; // 8-72 字符
}
```
**成功响应** `201 Created`
```typescript
interface AuthResponse {
user: {
id: string; // UUID
username: string;
created_at: string; // ISO 8601
};
access_token: string; // JWT15 分钟有效
refresh_token: string; // JWT7 天有效
}
```
```json
{
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"username": "alice",
"created_at": "2026-06-14T10:00:00Z"
},
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}
```
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 400 | `INVALID_INPUT` | 用户名/密码不符合校验规则 |
| 409 | `USERNAME_TAKEN` | 用户名已存在 |
#### 登录
```
POST /api/auth/login
Content-Type: application/json
```
**请求体**
```typescript
interface LoginRequest {
username: string;
password: string;
}
```
**成功响应** `200 OK`:同 `AuthResponse` 结构。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 400 | `INVALID_INPUT` | 请求参数缺失或格式错误 |
| 401 | `INVALID_CREDENTIALS` | 用户名或密码错误 |
#### 刷新 Token
```
POST /api/auth/refresh
Content-Type: application/json
```
**请求体**
```typescript
interface RefreshRequest {
refresh_token: string; // 之前签发的 refresh_token
}
```
**成功响应** `200 OK`:同 `AuthResponse` 结构(返回新的 access_token + refresh_token旧 refresh_token 失效——Token 轮转)。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | refresh_token 无效或已过期 |
#### 登出
```
POST /api/auth/logout
Content-Type: application/json
Authorization: Bearer <access_token>
```
**请求体**
```typescript
interface LogoutRequest {
refresh_token: string; // 要废弃的 refresh_token
}
```
**成功响应** `204 No Content`(无响应体)。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | access_token 无效或已过期 |
---
### 对话接口(`/api/conversations`
> 以下所有接口均需认证(`Authorization: Bearer <access_token>`),省略不重复标注。
#### 对话列表
```
GET /api/conversations?page=1&size=20
```
**查询参数**
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `page` | int | 1 | 页码,从 1 开始 |
| `size` | int | 20 | 每页条数,最大 50 |
**成功响应** `200 OK`
```typescript
interface ConversationListResponse {
conversations: ConversationSummary[];
total: number; // 总条数
page: number;
size: number;
}
interface ConversationSummary {
id: string; // 对话 ID即 session_id
title: string; // 对话标题(首条消息前 20 字)
last_message: string; // 最后一条消息内容预览
message_count: number; // 消息总数
updated_at: string; // ISO 8601最后活跃时间
}
```
```json
{
"conversations": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "这是一朵红色的玫瑰…",
"last_message": "它看起来很美丽。",
"message_count": 4,
"updated_at": "2026-06-14T10:05:30Z"
}
],
"total": 1,
"page": 1,
"size": 20
}
```
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
#### 创建对话
```
POST /api/conversations
Content-Type: application/json
```
**请求体**(可选,全部有默认值):
```typescript
interface CreateConversationRequest {
config?: {
tts_enabled?: boolean; // 默认 true
detail_level?: "low" | "high"; // 默认 "low"
language?: string; // 默认 "zh-CN"
};
}
```
**成功响应** `201 Created`
```typescript
interface ConversationDetail {
id: string;
title: string;
config: {
tts_enabled: boolean;
detail_level: "low" | "high";
language: string;
};
created_at: string; // ISO 8601
}
```
```json
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"title": "新对话",
"config": {
"tts_enabled": true,
"detail_level": "low",
"language": "zh-CN"
},
"created_at": "2026-06-14T11:00:00Z"
}
```
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
#### 获取对话详情
```
GET /api/conversations/:id
```
**成功响应** `200 OK`:同 `ConversationDetail` 结构。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
#### 更新对话标题
```
PATCH /api/conversations/:id
Content-Type: application/json
```
**请求体**
```typescript
interface UpdateTitleRequest {
title: string; // 1-100 字符
}
```
**成功响应** `200 OK`
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "新的自定义标题"
}
```
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 400 | `INVALID_INPUT` | title 为空或超长 |
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
#### 删除对话
```
DELETE /api/conversations/:id
```
**成功响应** `204 No Content`(无响应体)。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
#### 获取对话消息
```
GET /api/conversations/:id/messages?limit=50&before=<message_id>
```
**查询参数**
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `limit` | int | 50 | 返回条数,最大 100 |
| `before` | int64 | — | 游标分页:返回此 message_id 之前的消息(不含),用于加载更多 |
**成功响应** `200 OK`
```typescript
interface MessagesResponse {
messages: StoredMessage[];
has_more: boolean; // 是否还有更早的消息
}
interface StoredMessage {
id: number; // 自增 ID用于游标分页
role: "user" | "assistant";
content: string;
tokens_used: number; // 该条消息消耗的 token 数
created_at: string; // ISO 8601
}
```
```json
{
"messages": [
{
"id": 1001,
"role": "user",
"content": "这是什么花?",
"tokens_used": 0,
"created_at": "2026-06-14T10:01:00Z"
},
{
"id": 1002,
"role": "assistant",
"content": "这是一朵红色的玫瑰。",
"tokens_used": 42,
"created_at": "2026-06-14T10:01:02Z"
}
],
"has_more": false
}
```
**分页用法**:首次请求不带 `before`,获取最新消息。滚动到顶部时,取当前列表最小的 `id` 作为 `before` 参数请求更早的消息。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
---
### WebSocket 认证变更
连接地址变更为带 token 的查询参数:
```
ws://localhost:8080/ws?token=<access_token>&conversation_id=<uuid>
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `token` | 是 | JWT access_token |
| `conversation_id` | 否 | 恢复已有对话;省略则创建新对话 |
**认证失败响应**HTTP 升级前返回):
| 状态码 | 场景 |
|--------|------|
| 401 | token 缺失、无效或已过期 |
**conversation_id 校验失败**
| 场景 | 处理 |
|------|------|
| 对话不存在 | 返回 401`{"error": "SESSION_NOT_FOUND"}` |
| 对话不属于当前用户 | 返回 401`{"error": "SESSION_NOT_FOUND"}`(与不存在相同,避免信息泄露) |
---
### 健康检查
```
GET /api/health
```
响应:
无需认证。
**成功响应** `200 OK`
```json
{
@@ -282,43 +728,21 @@ GET /api/health
}
```
### 创建会话可选MVP 自动创建)
---
### ~~旧会话接口~~(已废弃)
> 以下端点已废弃,保留仅为向后兼容。新代码应使用 `/api/conversations` 系列接口。
```
POST /api/sessions
Content-Type: application/json
{
"config": {
"tts_enabled": true,
"detail_level": "low",
"language": "zh-CN"
}
}
POST /api/sessions → 改用 POST /api/conversations
DELETE /api/sessions/{id} → 改用 DELETE /api/conversations/{id}
```
响应:
```json
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"created_at": "2026-06-12T15:41:00Z"
}
```
### 销毁会话
```
DELETE /api/sessions/{session_id}
```
响应:`204 No Content`
### 预留端点(暂不实现)
| 端点 | 方法 | 用途 |
|------|------|------|
| `/api/sessions/{id}/messages` | GET | 查询对话历史 |
| `/api/usage` | GET | 查询用量统计 |
| `/api/users/{id}/preferences` | GET/PUT | 用户偏好管理 |
@@ -689,6 +1113,7 @@ type Config struct {
Redis RedisConfig `mapstructure:"redis"`
AI AIConfig `mapstructure:"ai"`
Storage StorageConfig `mapstructure:"storage"`
Auth AuthConfig `mapstructure:"auth"`
Log LogConfig `mapstructure:"log"`
}
@@ -746,6 +1171,12 @@ type StorageConfig struct {
DSN string `mapstructure:"dsn"` // PostgreSQL 连接串driver=postgres 时必填
}
type AuthConfig struct {
JWTSecret string `mapstructure:"jwt_secret"` // 必须通过 CAMTALK_AUTH_JWT_SECRET 设置
AccessTTL int `mapstructure:"access_ttl"` // 分钟,默认 15
RefreshTTL int `mapstructure:"refresh_ttl"` // 分钟,默认 100807 天)
}
type LogConfig struct {
Level string `mapstructure:"level"` // "debug" | "info" | "warn" | "error",默认 "info"
Format string `mapstructure:"format"` // "json" | "console",生产用 json
@@ -791,6 +1222,10 @@ ai:
storage:
driver: memory
auth:
access_ttl: 15 # access token 有效期(分钟)
refresh_ttl: 10080 # refresh token 有效期分钟7 天)
log:
level: info
format: console
@@ -811,6 +1246,9 @@ Viper 自动将配置项映射为环境变量,规则:**前缀 `CAMTALK_` +
| `ai.llm.model` | `CAMTALK_AI_LLM_MODEL` | `gpt-4o` |
| `storage.driver` | `CAMTALK_STORAGE_DRIVER` | `postgres` |
| `storage.dsn` | `CAMTALK_STORAGE_DSN` | — |
| `auth.jwt_secret` | `CAMTALK_AUTH_JWT_SECRET` | —(必填,仅环境变量) |
| `auth.access_ttl` | `CAMTALK_AUTH_ACCESS_TTL` | `15` |
| `auth.refresh_ttl` | `CAMTALK_AUTH_REFRESH_TTL` | `10080` |
| `app.env` | `CAMTALK_APP_ENV` | `prod` |
| `log.level` | `CAMTALK_LOG_LEVEL` | `warn` |
| `log.format` | `CAMTALK_LOG_FORMAT` | `json` |
@@ -892,6 +1330,7 @@ CAMTALK_AI_STT_API_KEY=xxx \
CAMTALK_AI_TTS_API_KEY=xxx \
CAMTALK_STORAGE_DRIVER=postgres \
CAMTALK_STORAGE_DSN="postgres://user:pass@db:5432/camtalk?sslmode=disable" \
CAMTALK_AUTH_JWT_SECRET="$(openssl rand -hex 32)" \
CAMTALK_LOG_LEVEL=warn \
CAMTALK_LOG_FORMAT=json \
./bin/camtalk
@@ -910,7 +1349,10 @@ CAMTALK_LOG_FORMAT=json \
type Session struct {
ID string `json:"session_id"`
UserID string `json:"user_id"` // 关联用户,空串表示匿名
Title string `json:"title"` // 对话标题
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Config SessionConfig `json:"config"`
}
@@ -932,6 +1374,33 @@ type Message struct {
Role string `json:"role"` // "user" | "assistant"
Content string `json:"content"`
}
// ---- 用户模块 ----
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ConversationSummary struct {
ID string `json:"id"`
Title string `json:"title"`
LastMessage string `json:"last_message"`
MessageCount int `json:"message_count"`
UpdatedAt time.Time `json:"updated_at"`
}
type StoredMessage struct {
ID int64 `json:"id"`
SessionID string `json:"-"`
Role string `json:"role"`
Content string `json:"content"`
TokensUsed int `json:"tokens_used"`
CreatedAt time.Time `json:"created_at"`
}
```
### TypeScript 前端模型
@@ -957,6 +1426,60 @@ interface ChatMessage {
tokensUsed?: number;
}
// ---- 用户模块 ----
interface AuthTokens {
accessToken: string;
refreshToken: string;
}
interface User {
id: string; // UUID
username: string;
created_at: string; // ISO 8601
}
interface AuthResponse {
user: User;
access_token: string;
refresh_token: string;
}
interface ConversationSummary {
id: string;
title: string;
last_message: string;
message_count: number;
updated_at: string;
}
interface ConversationListResponse {
conversations: ConversationSummary[];
total: number;
page: number;
size: number;
}
interface ConversationDetail {
id: string;
title: string;
config: SessionConfig;
created_at: string;
}
interface StoredMessage {
id: number;
role: "user" | "assistant";
content: string;
tokens_used: number;
created_at: string;
}
interface MessagesResponse {
messages: StoredMessage[];
has_more: boolean;
}
// WebSocket 消息联合类型
type ServerMessage =
| ConnectedMessage
@@ -1054,18 +1577,22 @@ func NewApp(cfg *Config) *App {
## 九、错误码
| 错误码 | 含义 | 客户端处理建议 |
|--------|------|--------------|
| `INVALID_MESSAGE` | 消息格式不合法 | 检查 JSON 结构,不重试 |
| `SESSION_NOT_FOUND` | 话不存在或已过期 | 重新建立 WebSocket 连接 |
| `RATE_LIMITED` | 请求频率超限 | 延迟后重试,提示用户稍等 |
| `IMAGE_TOO_LARGE` | 图像超过 4MB 限制 | 降低分辨率或压缩质量 |
| `AUDIO_TOO_SHORT` | 音频片段 < 250ms | 忽略,等待下次语音输入 |
| `LLM_TIMEOUT` | LLM 推理超时>10s | 提示用户重试 |
| `LLM_ERROR` | LLM 服务异常 | 提示用户重试,服务端记录日志 |
| `STT_ERROR` | 语音识别失败 | 回退到纯文本输入模式 |
| `TTS_ERROR` | 语音合成失败 | 静默回退到纯文本回复 |
| `INTERNAL_ERROR` | 服务端内部错误 | 提示用户重试 |
| 错误码 | HTTP 状态码 | 含义 | 客户端处理建议 |
|--------|-----------|------|--------------|
| `INVALID_MESSAGE` | — | 消息格式不合法WS | 检查 JSON 结构,不重试 |
| `SESSION_NOT_FOUND` | 404 | 会话/对话不存在或已过期 | 重新建立连接或刷新列表 |
| `RATE_LIMITED` | 429 | 请求频率超限 | 延迟后重试,提示用户稍等 |
| `IMAGE_TOO_LARGE` | — | 图像超过 4MB 限制WS | 降低分辨率或压缩质量 |
| `AUDIO_TOO_SHORT` | — | 音频片段 < 250msWS | 忽略,等待下次语音输入 |
| `LLM_TIMEOUT` | — | LLM 推理超时 >10sWS | 提示用户重试 |
| `LLM_ERROR` | — | LLM 服务异常WS | 提示用户重试,服务端记录日志 |
| `STT_ERROR` | — | 语音识别失败WS | 回退到纯文本输入模式 |
| `TTS_ERROR` | — | 语音合成失败WS | 静默回退到纯文本回复 |
| `INTERNAL_ERROR` | 500 | 服务端内部错误 | 提示用户重试 |
| `USERNAME_TAKEN` | 409 | 用户名已被注册 | 提示换一个用户名 |
| `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 | 提示检查输入 |
| `INVALID_TOKEN` | 401 | JWT 无效或已过期 | 尝试 refresh失败则重新登录 |
| `INVALID_INPUT` | 400 | 请求参数校验失败 | 检查字段规则后重试 |
## 十、连接管理

View File

@@ -713,6 +713,588 @@ func main() {
---
## 前端 API 接口参考
本章节为前端开发者提供完整的 REST API 契约。所有接口以 JSON 通信,基地址与 WebSocket 同源(开发环境 `http://localhost:8080`,生产环境通过 Nginx 反代)。
### 通用约定
#### 认证方式
需要认证的接口在请求头携带 JWT access token
```
Authorization: Bearer <access_token>
```
未认证或 token 过期时返回 `401 Unauthorized`
#### 错误响应格式
所有错误响应统一结构:
```typescript
interface ApiError {
code: string; // 机器可读错误码
message: string; // 人类可读描述
}
```
示例:
```json
{
"code": "USERNAME_TAKEN",
"message": "username already taken"
}
```
#### 新增错误码
| 错误码 | HTTP 状态码 | 含义 |
|--------|-----------|------|
| `USERNAME_TAKEN` | 409 | 用户名已被注册 |
| `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 |
| `INVALID_TOKEN` | 401 | JWT 无效或已过期 |
| `INVALID_INPUT` | 400 | 请求参数校验失败 |
| `SESSION_NOT_FOUND` | 404 | 对话不存在或无权访问 |
#### 输入校验规则
| 字段 | 规则 |
|------|------|
| `username` | 3-64 字符,仅允许字母、数字、下划线 |
| `password` | 8-72 字符 |
---
### 一、认证接口(`/api/auth`
#### 1.1 注册
```
POST /api/auth/register
Content-Type: application/json
```
**请求体**
```typescript
interface RegisterRequest {
username: string; // 3-64 字符
password: string; // 8-72 字符
}
```
**成功响应** `201 Created`
```typescript
interface AuthResponse {
user: {
id: string; // UUID
username: string;
created_at: string; // ISO 8601
};
access_token: string; // JWT15 分钟有效
refresh_token: string; // JWT7 天有效
}
```
```json
{
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"username": "alice",
"created_at": "2026-06-14T10:00:00Z"
},
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}
```
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 400 | `INVALID_INPUT` | 用户名/密码不符合校验规则 |
| 409 | `USERNAME_TAKEN` | 用户名已存在 |
---
#### 1.2 登录
```
POST /api/auth/login
Content-Type: application/json
```
**请求体**
```typescript
interface LoginRequest {
username: string;
password: string;
}
```
**成功响应** `200 OK`:同 `AuthResponse` 结构。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 400 | `INVALID_INPUT` | 请求参数缺失或格式错误 |
| 401 | `INVALID_CREDENTIALS` | 用户名或密码错误 |
---
#### 1.3 刷新 Token
```
POST /api/auth/refresh
Content-Type: application/json
```
**请求体**
```typescript
interface RefreshRequest {
refresh_token: string; // 之前签发的 refresh_token
}
```
**成功响应** `200 OK`:同 `AuthResponse` 结构(返回新的 access_token + refresh_token旧 refresh_token 失效——Token 轮转)。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | refresh_token 无效或已过期 |
---
#### 1.4 登出
```
POST /api/auth/logout
Content-Type: application/json
Authorization: Bearer <access_token>
```
**请求体**
```typescript
interface LogoutRequest {
refresh_token: string; // 要废弃的 refresh_token
}
```
**成功响应** `204 No Content`(无响应体)。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | access_token 无效或已过期 |
---
### 二、对话接口(`/api/conversations`
> 以下所有接口均需认证(`Authorization: Bearer <access_token>`),省略不重复标注。
#### 2.1 对话列表
```
GET /api/conversations?page=1&size=20
```
**查询参数**
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `page` | int | 1 | 页码,从 1 开始 |
| `size` | int | 20 | 每页条数,最大 50 |
**成功响应** `200 OK`
```typescript
interface ConversationListResponse {
conversations: ConversationSummary[];
total: number; // 总条数
page: number;
size: number;
}
interface ConversationSummary {
id: string; // 对话 ID即 session_id
title: string; // 对话标题(首条消息前 20 字)
last_message: string; // 最后一条消息内容预览
message_count: number; // 消息总数
updated_at: string; // ISO 8601最后活跃时间
}
```
```json
{
"conversations": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "这是一朵红色的玫瑰…",
"last_message": "它看起来很美丽。",
"message_count": 4,
"updated_at": "2026-06-14T10:05:30Z"
}
],
"total": 1,
"page": 1,
"size": 20
}
```
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
---
#### 2.2 创建对话
```
POST /api/conversations
Content-Type: application/json
```
**请求体**(可选,全部有默认值):
```typescript
interface CreateConversationRequest {
config?: {
tts_enabled?: boolean; // 默认 true
detail_level?: "low" | "high"; // 默认 "low"
language?: string; // 默认 "zh-CN"
};
}
```
**成功响应** `201 Created`
```typescript
interface ConversationDetail {
id: string;
title: string;
config: {
tts_enabled: boolean;
detail_level: "low" | "high";
language: string;
};
created_at: string; // ISO 8601
}
```
```json
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"title": "新对话",
"config": {
"tts_enabled": true,
"detail_level": "low",
"language": "zh-CN"
},
"created_at": "2026-06-14T11:00:00Z"
}
```
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
---
#### 2.3 获取对话详情
```
GET /api/conversations/:id
```
**成功响应** `200 OK`:同 `ConversationDetail` 结构。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
---
#### 2.4 更新对话标题
```
PATCH /api/conversations/:id
Content-Type: application/json
```
**请求体**
```typescript
interface UpdateTitleRequest {
title: string; // 1-100 字符
}
```
**成功响应** `200 OK`
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "新的自定义标题"
}
```
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 400 | `INVALID_INPUT` | title 为空或超长 |
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
---
#### 2.5 删除对话
```
DELETE /api/conversations/:id
```
**成功响应** `204 No Content`(无响应体)。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
---
#### 2.6 获取对话消息
```
GET /api/conversations/:id/messages?limit=50&before=<message_id>
```
**查询参数**
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `limit` | int | 50 | 返回条数,最大 100 |
| `before` | int64 | — | 游标分页:返回此 message_id 之前的消息(不含),用于加载更多 |
**成功响应** `200 OK`
```typescript
interface MessagesResponse {
messages: StoredMessage[];
has_more: boolean; // 是否还有更早的消息
}
interface StoredMessage {
id: number; // 自增 ID用于游标分页
role: "user" | "assistant";
content: string;
tokens_used: number; // 该条消息消耗的 token 数
created_at: string; // ISO 8601
}
```
```json
{
"messages": [
{
"id": 1001,
"role": "user",
"content": "这是什么花?",
"tokens_used": 0,
"created_at": "2026-06-14T10:01:00Z"
},
{
"id": 1002,
"role": "assistant",
"content": "这是一朵红色的玫瑰。",
"tokens_used": 42,
"created_at": "2026-06-14T10:01:02Z"
}
],
"has_more": false
}
```
**分页用法**:首次请求不带 `before`,获取最新消息。滚动到顶部时,取当前列表最小的 `id` 作为 `before` 参数请求更早的消息。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
---
### 三、WebSocket 认证变更
连接地址变更为带 token 的查询参数:
```
ws://localhost:8080/ws?token=<access_token>&conversation_id=<uuid>
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `token` | 是 | JWT access_token |
| `conversation_id` | 否 | 恢复已有对话;省略则创建新对话 |
**认证失败响应**HTTP 升级前返回):
| 状态码 | 场景 |
|--------|------|
| 401 | token 缺失、无效或已过期 |
**conversation_id 校验失败**
| 场景 | 处理 |
|------|------|
| 对话不存在 | 返回 401`{"error": "SESSION_NOT_FOUND"}` |
| 对话不属于当前用户 | 返回 401`{"error": "SESSION_NOT_FOUND"}`(与不存在相同,避免信息泄露) |
**连接成功后**`connected` 消息不变,新增 `conversation_id` 字段标识当前对话:
```typescript
interface ConnectedMessage {
type: "connected";
session_id: string; // 对话 ID
conversation_id: string; // 同 session_id便于前端统一使用
server_version: string;
}
```
---
### 四、前端调用示例
#### 认证状态管理
```typescript
// 存储 token建议 localStorage 或内存,视安全需求)
interface AuthTokens {
accessToken: string;
refreshToken: string;
}
// 请求拦截器:自动附加 Authorization 头
async function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
const tokens = getStoredTokens();
const headers = {
...options.headers,
"Authorization": `Bearer ${tokens.accessToken}`,
};
let resp = await fetch(url, { ...options, headers });
// 401 时尝试刷新 token
if (resp.status === 401 && tokens.refreshToken) {
const refreshResp = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: tokens.refreshToken }),
});
if (refreshResp.ok) {
const newTokens: AuthResponse = await refreshResp.json();
storeTokens({
accessToken: newTokens.access_token,
refreshToken: newTokens.refresh_token,
});
// 用新 token 重试原请求
headers["Authorization"] = `Bearer ${newTokens.access_token}`;
resp = await fetch(url, { ...options, headers });
} else {
// refresh 也失败,跳转登录
redirectToLogin();
}
}
return resp;
}
```
#### 注册 + 登录
```typescript
async function register(username: string, password: string): Promise<AuthResponse> {
const resp = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!resp.ok) {
const err: ApiError = await resp.json();
throw new Error(err.message); // "username already taken" 等
}
return resp.json();
}
```
#### 获取对话列表
```typescript
async function getConversations(page = 1, size = 20): Promise<ConversationListResponse> {
const resp = await authFetch(
`/api/conversations?page=${page}&size=${size}`
);
if (!resp.ok) throw new Error("Failed to load conversations");
return resp.json();
}
```
#### 加载对话历史消息
```typescript
async function getMessages(
conversationId: string,
limit = 50,
before?: number
): Promise<MessagesResponse> {
let url = `/api/conversations/${conversationId}/messages?limit=${limit}`;
if (before !== undefined) url += `&before=${before}`;
const resp = await authFetch(url);
if (!resp.ok) throw new Error("Failed to load messages");
return resp.json();
}
```
#### 建立 WebSocket 连接(带认证)
```typescript
function connectWebSocket(accessToken: string, conversationId?: string): WebSocket {
let url = `/ws?token=${encodeURIComponent(accessToken)}`;
if (conversationId) {
url += `&conversation_id=${encodeURIComponent(conversationId)}`;
}
return new WebSocket(url);
}
```
---
## 关键文件清单
```