Compare commits
10 Commits
d01aeaba68
...
b57adf153b
| Author | SHA1 | Date | |
|---|---|---|---|
| b57adf153b | |||
| c3a32ce276 | |||
| 7b745018c5 | |||
| f4515ce5e4 | |||
| 96f4bc7abb | |||
| dae5722945 | |||
| 3c5c4943e8 | |||
| 80c6b1b56e | |||
| 905b56640e | |||
| 2aa3c98ab6 |
@@ -145,7 +145,7 @@ func main() {
|
|||||||
convHandler.RegisterRoutes(apiGroup)
|
convHandler.RegisterRoutes(apiGroup)
|
||||||
|
|
||||||
// WebSocket
|
// WebSocket
|
||||||
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg))
|
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr))
|
||||||
|
|
||||||
// HTTP Server
|
// HTTP Server
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
"github.com/hhs/camtalk/internal/logger"
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
|
"github.com/hhs/camtalk/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -33,11 +34,23 @@ type MemoryManager struct {
|
|||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
maxHistory int
|
maxHistory int
|
||||||
stopCleaner chan struct{}
|
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。
|
// NewMemoryManager 创建内存版 SessionManager。
|
||||||
// ttl 为会话过期时间,maxHistory 为对话历史上限(0 表示使用默认值 20)。
|
// 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 {
|
if ttl <= 0 {
|
||||||
ttl = defaultTTL
|
ttl = defaultTTL
|
||||||
}
|
}
|
||||||
@@ -52,6 +65,10 @@ func NewMemoryManager(ttl time.Duration, maxHistory int) *MemoryManager {
|
|||||||
stopCleaner: make(chan struct{}),
|
stopCleaner: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(m)
|
||||||
|
}
|
||||||
|
|
||||||
// 启动后台清理 goroutine,每分钟清除过期会话。
|
// 启动后台清理 goroutine,每分钟清除过期会话。
|
||||||
go m.cleanLoop()
|
go m.cleanLoop()
|
||||||
|
|
||||||
@@ -170,12 +187,13 @@ func (m *MemoryManager) UpdateTitle(_ context.Context, sessionID string, title s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListByUser 获取用户的对话列表(分页,按 UpdatedAt 降序)。
|
// 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()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
// 收集该用户的所有 session
|
// 收集该用户的所有 session
|
||||||
var list []ConversationSummary
|
var list []ConversationSummary
|
||||||
|
var sessionIDs []string
|
||||||
for _, entry := range m.sessions {
|
for _, entry := range m.sessions {
|
||||||
if entry.session.UserID != userID {
|
if entry.session.UserID != userID {
|
||||||
continue
|
continue
|
||||||
@@ -186,13 +204,30 @@ func (m *MemoryManager) ListByUser(_ context.Context, userID string, page, size
|
|||||||
summary := ConversationSummary{
|
summary := ConversationSummary{
|
||||||
ID: entry.session.ID,
|
ID: entry.session.ID,
|
||||||
Title: entry.session.Title,
|
Title: entry.session.Title,
|
||||||
MessageCount: len(entry.history),
|
|
||||||
UpdatedAt: entry.lastActive,
|
UpdatedAt: entry.lastActive,
|
||||||
}
|
}
|
||||||
|
// 先用内存值填充,后续可能被 PG 统计覆盖
|
||||||
|
summary.MessageCount = len(entry.history)
|
||||||
if len(entry.history) > 0 {
|
if len(entry.history) > 0 {
|
||||||
summary.LastMessage = entry.history[len(entry.history)-1].Content
|
summary.LastMessage = entry.history[len(entry.history)-1].Content
|
||||||
}
|
}
|
||||||
list = append(list, summary)
|
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 降序排序
|
// 按 UpdatedAt 降序排序
|
||||||
@@ -242,12 +277,13 @@ func (m *MemoryManager) GetHistory(_ context.Context, sessionID string, limit in
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AppendMessage 追加一条对话消息,同时刷新 TTL。
|
// AppendMessage 追加一条对话消息,同时刷新 TTL。
|
||||||
|
// 若配置了 MessageRepository,消息会异步写入 PostgreSQL(Write-Through)。
|
||||||
func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg models.Message) error {
|
func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg models.Message) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
entry, ok := m.sessions[sessionID]
|
entry, ok := m.sessions[sessionID]
|
||||||
if !ok || m.isExpired(entry) {
|
if !ok || m.isExpired(entry) {
|
||||||
|
m.mu.Unlock()
|
||||||
return ErrSessionNotFound
|
return ErrSessionNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +302,17 @@ func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg m
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
entry.lastActive = now
|
entry.lastActive = now
|
||||||
entry.session.UpdatedAt = 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +325,48 @@ func generateTitle(firstMessage string) string {
|
|||||||
return firstMessage
|
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。
|
// SetActiveRequest 标记当前正在处理的请求 ID。
|
||||||
func (m *MemoryManager) SetActiveRequest(_ context.Context, sessionID string, requestID string) error {
|
func (m *MemoryManager) SetActiveRequest(_ context.Context, sessionID string, requestID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
|||||||
50
backend/internal/store/message.go
Normal file
50
backend/internal/store/message.go
Normal 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"`
|
||||||
|
}
|
||||||
163
backend/internal/store/message_pg.go
Normal file
163
backend/internal/store/message_pg.go
Normal 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
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"github.com/hhs/camtalk/internal/auth"
|
||||||
"github.com/hhs/camtalk/internal/config"
|
"github.com/hhs/camtalk/internal/config"
|
||||||
"github.com/hhs/camtalk/internal/errors"
|
"github.com/hhs/camtalk/internal/errors"
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
"github.com/hhs/camtalk/internal/logger"
|
||||||
@@ -91,7 +92,7 @@ func (w *WSClient) SendError(err models.WsError) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ServeWS 处理 WebSocket 升级请求。
|
// 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)
|
upgrader := newUpgrader(cfg)
|
||||||
heartbeatInterval := time.Duration(cfg.Server.HeartbeatInterval) * time.Second
|
heartbeatInterval := time.Duration(cfg.Server.HeartbeatInterval) * time.Second
|
||||||
heartbeatTimeout := time.Duration(cfg.Server.HeartbeatTimeout) * 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
|
maxHistory := cfg.Session.MaxHistory
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
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,
|
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)
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Errorw("websocket upgrade failed", "error", err)
|
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()
|
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 {
|
if err != nil {
|
||||||
logger.Log.Errorw("create session failed", "error", err)
|
logger.Log.Errorw("create session failed", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
client := &Client{
|
client := &Client{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
@@ -134,7 +166,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
|||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
ServerVersion: version,
|
ServerVersion: version,
|
||||||
})
|
})
|
||||||
logger.Log.Infow("client connected", "session", sessionID)
|
logger.Log.Infow("client connected", "session", sessionID, "user_id", userID, "username", username)
|
||||||
|
|
||||||
// 心跳检测
|
// 心跳检测
|
||||||
lastPong := time.Now()
|
lastPong := time.Now()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ws
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/hhs/camtalk/internal/auth"
|
||||||
"github.com/hhs/camtalk/internal/config"
|
"github.com/hhs/camtalk/internal/config"
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
"github.com/hhs/camtalk/internal/logger"
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
@@ -132,24 +134,29 @@ func (m *MockOrchestrator) ProcessQuery(
|
|||||||
// --- 测试辅助函数 ---
|
// --- 测试辅助函数 ---
|
||||||
|
|
||||||
// setupTestServer 创建测试用 Gin 服务器和 WebSocket URL。
|
// setupTestServer 创建测试用 Gin 服务器和 WebSocket URL。
|
||||||
|
// 返回的 wsURL 已包含有效 token,可直接连接。
|
||||||
func setupTestServer(t *testing.T, orch orchestrator.Orchestrator) (*httptest.Server, string) {
|
func setupTestServer(t *testing.T, orch orchestrator.Orchestrator) (*httptest.Server, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
sessionMgr := session.NewMemoryManager(5*time.Minute, 20)
|
sessionMgr := session.NewMemoryManager(5*time.Minute, 20)
|
||||||
t.Cleanup(func() { sessionMgr.Stop() })
|
t.Cleanup(func() { sessionMgr.Stop() })
|
||||||
|
|
||||||
|
tokenMgr := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour)
|
||||||
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
App: config.AppConfig{Version: "test"},
|
App: config.AppConfig{Version: "test"},
|
||||||
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
||||||
Session: config.SessionConfig{MaxHistory: 20},
|
Session: config.SessionConfig{MaxHistory: 20},
|
||||||
}
|
}
|
||||||
r.GET("/ws", ServeWS(sessionMgr, orch, cfg))
|
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr))
|
||||||
|
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
|
|
||||||
// 构造 WebSocket URL
|
// 生成有效 token 并构造 WebSocket URL
|
||||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
|
token, _, err := tokenMgr.GeneratePair("test-user", "testuser")
|
||||||
|
require.NoError(t, err)
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws?token=" + token
|
||||||
|
|
||||||
return srv, wsURL
|
return srv, wsURL
|
||||||
}
|
}
|
||||||
@@ -567,3 +574,156 @@ func TestWS_QueryWithTTSDisabled(t *testing.T) {
|
|||||||
err = conn.ReadJSON(&extra)
|
err = conn.ReadJSON(&extra)
|
||||||
assert.Error(t, err, "不应有额外消息")
|
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")
|
||||||
|
}
|
||||||
|
|||||||
1
backend/migrations/002_messages.down.sql
Normal file
1
backend/migrations/002_messages.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS messages;
|
||||||
17
backend/migrations/002_messages.up.sql
Normal file
17
backend/migrations/002_messages.up.sql
Normal 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);
|
||||||
619
docs/03-接口文档.md
619
docs/03-接口文档.md
@@ -13,16 +13,28 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
浏览器 Go Gateway :8080
|
浏览器 Go Gateway :8080
|
||||||
WebSocket Client <--> /ws (实时对话)
|
WebSocket Client <--> /ws?token=<jwt> (实时对话,需 JWT 认证)
|
||||||
HTTP Client --> GET /api/health
|
HTTP Client --> GET /api/health (健康检查)
|
||||||
HTTP Client <--> POST/DELETE /api/sessions
|
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 协议
|
## 一、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 {
|
interface ConnectedMessage {
|
||||||
type: "connected";
|
type: "connected";
|
||||||
session_id: string; // 服务端生成的会话 ID
|
session_id: string; // 服务端生成的会话 ID
|
||||||
|
conversation_id: string; // 同 session_id,便于前端统一使用
|
||||||
server_version: string; // 服务端版本号,如 "0.1.0"
|
server_version: string; // 服务端版本号,如 "0.1.0"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -265,13 +278,446 @@ Client Server
|
|||||||
|
|
||||||
## 二、REST API
|
## 二、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; // JWT,15 分钟有效
|
||||||
|
refresh_token: string; // JWT,7 天有效
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```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
|
GET /api/health
|
||||||
```
|
```
|
||||||
|
|
||||||
响应:
|
无需认证。
|
||||||
|
|
||||||
|
**成功响应** `200 OK`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -282,43 +728,21 @@ GET /api/health
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 创建会话(可选,MVP 自动创建)
|
---
|
||||||
|
|
||||||
|
### ~~旧会话接口~~(已废弃)
|
||||||
|
|
||||||
|
> 以下端点已废弃,保留仅为向后兼容。新代码应使用 `/api/conversations` 系列接口。
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/sessions
|
POST /api/sessions → 改用 POST /api/conversations
|
||||||
Content-Type: application/json
|
DELETE /api/sessions/{id} → 改用 DELETE /api/conversations/{id}
|
||||||
|
|
||||||
{
|
|
||||||
"config": {
|
|
||||||
"tts_enabled": true,
|
|
||||||
"detail_level": "low",
|
|
||||||
"language": "zh-CN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
响应:
|
|
||||||
|
|
||||||
```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/usage` | GET | 查询用量统计 |
|
||||||
| `/api/users/{id}/preferences` | GET/PUT | 用户偏好管理 |
|
| `/api/users/{id}/preferences` | GET/PUT | 用户偏好管理 |
|
||||||
|
|
||||||
@@ -689,6 +1113,7 @@ type Config struct {
|
|||||||
Redis RedisConfig `mapstructure:"redis"`
|
Redis RedisConfig `mapstructure:"redis"`
|
||||||
AI AIConfig `mapstructure:"ai"`
|
AI AIConfig `mapstructure:"ai"`
|
||||||
Storage StorageConfig `mapstructure:"storage"`
|
Storage StorageConfig `mapstructure:"storage"`
|
||||||
|
Auth AuthConfig `mapstructure:"auth"`
|
||||||
Log LogConfig `mapstructure:"log"`
|
Log LogConfig `mapstructure:"log"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,6 +1171,12 @@ type StorageConfig struct {
|
|||||||
DSN string `mapstructure:"dsn"` // PostgreSQL 连接串,driver=postgres 时必填
|
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"` // 分钟,默认 10080(7 天)
|
||||||
|
}
|
||||||
|
|
||||||
type LogConfig struct {
|
type LogConfig struct {
|
||||||
Level string `mapstructure:"level"` // "debug" | "info" | "warn" | "error",默认 "info"
|
Level string `mapstructure:"level"` // "debug" | "info" | "warn" | "error",默认 "info"
|
||||||
Format string `mapstructure:"format"` // "json" | "console",生产用 json
|
Format string `mapstructure:"format"` // "json" | "console",生产用 json
|
||||||
@@ -791,6 +1222,10 @@ ai:
|
|||||||
storage:
|
storage:
|
||||||
driver: memory
|
driver: memory
|
||||||
|
|
||||||
|
auth:
|
||||||
|
access_ttl: 15 # access token 有效期(分钟)
|
||||||
|
refresh_ttl: 10080 # refresh token 有效期(分钟,7 天)
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: info
|
level: info
|
||||||
format: console
|
format: console
|
||||||
@@ -811,6 +1246,9 @@ Viper 自动将配置项映射为环境变量,规则:**前缀 `CAMTALK_` +
|
|||||||
| `ai.llm.model` | `CAMTALK_AI_LLM_MODEL` | `gpt-4o` |
|
| `ai.llm.model` | `CAMTALK_AI_LLM_MODEL` | `gpt-4o` |
|
||||||
| `storage.driver` | `CAMTALK_STORAGE_DRIVER` | `postgres` |
|
| `storage.driver` | `CAMTALK_STORAGE_DRIVER` | `postgres` |
|
||||||
| `storage.dsn` | `CAMTALK_STORAGE_DSN` | — |
|
| `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` |
|
| `app.env` | `CAMTALK_APP_ENV` | `prod` |
|
||||||
| `log.level` | `CAMTALK_LOG_LEVEL` | `warn` |
|
| `log.level` | `CAMTALK_LOG_LEVEL` | `warn` |
|
||||||
| `log.format` | `CAMTALK_LOG_FORMAT` | `json` |
|
| `log.format` | `CAMTALK_LOG_FORMAT` | `json` |
|
||||||
@@ -892,6 +1330,7 @@ CAMTALK_AI_STT_API_KEY=xxx \
|
|||||||
CAMTALK_AI_TTS_API_KEY=xxx \
|
CAMTALK_AI_TTS_API_KEY=xxx \
|
||||||
CAMTALK_STORAGE_DRIVER=postgres \
|
CAMTALK_STORAGE_DRIVER=postgres \
|
||||||
CAMTALK_STORAGE_DSN="postgres://user:pass@db:5432/camtalk?sslmode=disable" \
|
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_LEVEL=warn \
|
||||||
CAMTALK_LOG_FORMAT=json \
|
CAMTALK_LOG_FORMAT=json \
|
||||||
./bin/camtalk
|
./bin/camtalk
|
||||||
@@ -910,7 +1349,10 @@ CAMTALK_LOG_FORMAT=json \
|
|||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
ID string `json:"session_id"`
|
ID string `json:"session_id"`
|
||||||
|
UserID string `json:"user_id"` // 关联用户,空串表示匿名
|
||||||
|
Title string `json:"title"` // 对话标题
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
Config SessionConfig `json:"config"`
|
Config SessionConfig `json:"config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -932,6 +1374,33 @@ type Message struct {
|
|||||||
Role string `json:"role"` // "user" | "assistant"
|
Role string `json:"role"` // "user" | "assistant"
|
||||||
Content string `json:"content"`
|
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 前端模型
|
### TypeScript 前端模型
|
||||||
@@ -957,6 +1426,60 @@ interface ChatMessage {
|
|||||||
tokensUsed?: number;
|
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 消息联合类型
|
// WebSocket 消息联合类型
|
||||||
type ServerMessage =
|
type ServerMessage =
|
||||||
| ConnectedMessage
|
| ConnectedMessage
|
||||||
@@ -1054,18 +1577,22 @@ func NewApp(cfg *Config) *App {
|
|||||||
|
|
||||||
## 九、错误码
|
## 九、错误码
|
||||||
|
|
||||||
| 错误码 | 含义 | 客户端处理建议 |
|
| 错误码 | HTTP 状态码 | 含义 | 客户端处理建议 |
|
||||||
|--------|------|--------------|
|
|--------|-----------|------|--------------|
|
||||||
| `INVALID_MESSAGE` | 消息格式不合法 | 检查 JSON 结构,不重试 |
|
| `INVALID_MESSAGE` | — | 消息格式不合法(WS) | 检查 JSON 结构,不重试 |
|
||||||
| `SESSION_NOT_FOUND` | 会话不存在或已过期 | 重新建立 WebSocket 连接 |
|
| `SESSION_NOT_FOUND` | 404 | 会话/对话不存在或已过期 | 重新建立连接或刷新列表 |
|
||||||
| `RATE_LIMITED` | 请求频率超限 | 延迟后重试,提示用户稍等 |
|
| `RATE_LIMITED` | 429 | 请求频率超限 | 延迟后重试,提示用户稍等 |
|
||||||
| `IMAGE_TOO_LARGE` | 图像超过 4MB 限制 | 降低分辨率或压缩质量 |
|
| `IMAGE_TOO_LARGE` | — | 图像超过 4MB 限制(WS) | 降低分辨率或压缩质量 |
|
||||||
| `AUDIO_TOO_SHORT` | 音频片段 < 250ms | 忽略,等待下次语音输入 |
|
| `AUDIO_TOO_SHORT` | — | 音频片段 < 250ms(WS) | 忽略,等待下次语音输入 |
|
||||||
| `LLM_TIMEOUT` | LLM 推理超时(>10s) | 提示用户重试 |
|
| `LLM_TIMEOUT` | — | LLM 推理超时 >10s(WS) | 提示用户重试 |
|
||||||
| `LLM_ERROR` | LLM 服务异常 | 提示用户重试,服务端记录日志 |
|
| `LLM_ERROR` | — | LLM 服务异常(WS) | 提示用户重试,服务端记录日志 |
|
||||||
| `STT_ERROR` | 语音识别失败 | 回退到纯文本输入模式 |
|
| `STT_ERROR` | — | 语音识别失败(WS) | 回退到纯文本输入模式 |
|
||||||
| `TTS_ERROR` | 语音合成失败 | 静默回退到纯文本回复 |
|
| `TTS_ERROR` | — | 语音合成失败(WS) | 静默回退到纯文本回复 |
|
||||||
| `INTERNAL_ERROR` | 服务端内部错误 | 提示用户重试 |
|
| `INTERNAL_ERROR` | 500 | 服务端内部错误 | 提示用户重试 |
|
||||||
|
| `USERNAME_TAKEN` | 409 | 用户名已被注册 | 提示换一个用户名 |
|
||||||
|
| `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 | 提示检查输入 |
|
||||||
|
| `INVALID_TOKEN` | 401 | JWT 无效或已过期 | 尝试 refresh,失败则重新登录 |
|
||||||
|
| `INVALID_INPUT` | 400 | 请求参数校验失败 | 检查字段规则后重试 |
|
||||||
|
|
||||||
## 十、连接管理
|
## 十、连接管理
|
||||||
|
|
||||||
|
|||||||
@@ -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; // JWT,15 分钟有效
|
||||||
|
refresh_token: string; // JWT,7 天有效
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 关键文件清单
|
## 关键文件清单
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user