fix: 第二轮修复 — 数据库启动检查、会话持久化、URL路由、设备排序等
1. DevTools 启动前检查数据库状态,失败时自动尝试启动 2. ai-core 添加数据库断线重连机制 (30秒间隔) 3. Dashboard 添加数据库状态卡片 (启动/停止/重启) 4. Gateway 会话空闲超时管理 (30分钟标记空闲) 5. 会话/消息 PostgreSQL 持久化 (SessionStore + REST API) 6. 前端服务端会话持久化 + URL hash 路由 + 侧边栏管理 7. 管理员回到主对话按钮 8. IoT 设备卡片固定排序 9. 更新相关文档
This commit is contained in:
@@ -1,143 +1,267 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/middleware"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/store"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/ws"
|
||||
)
|
||||
|
||||
// SessionHandler 会话管理处理器
|
||||
type SessionHandler struct {
|
||||
// MVP阶段使用内存存储,后续迁移到PostgreSQL
|
||||
sessions map[string][]SessionInfo // userID -> sessions
|
||||
hub *ws.Hub
|
||||
}
|
||||
|
||||
// SessionInfo 会话信息
|
||||
type SessionInfo struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
MessageCount int `json:"message_count"`
|
||||
IsActive bool `json:"is_active"`
|
||||
store *store.SessionStore // PostgreSQL 持久化存储
|
||||
hub *ws.Hub
|
||||
useDB bool // 数据库是否可用
|
||||
}
|
||||
|
||||
// NewSessionHandler 创建会话处理器
|
||||
func NewSessionHandler(hub *ws.Hub) *SessionHandler {
|
||||
func NewSessionHandler(hub *ws.Hub, s *store.SessionStore) *SessionHandler {
|
||||
return &SessionHandler{
|
||||
sessions: make(map[string][]SessionInfo),
|
||||
hub: hub,
|
||||
store: s,
|
||||
hub: hub,
|
||||
useDB: s != nil && s.IsAvailable(),
|
||||
}
|
||||
}
|
||||
|
||||
// ========== POST /api/v1/sessions — 创建会话 ==========
|
||||
|
||||
type createSessionRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
Title string `json:"title"`
|
||||
IsMain bool `json:"is_main"`
|
||||
}
|
||||
|
||||
// Create 创建新会话
|
||||
func (h *SessionHandler) Create(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
var req createSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// 允许空body
|
||||
req.Title = "新的对话"
|
||||
// 允许空 body
|
||||
}
|
||||
if req.UserID != "" {
|
||||
userID = req.UserID
|
||||
}
|
||||
if req.Title == "" {
|
||||
req.Title = "新的对话"
|
||||
}
|
||||
|
||||
session := SessionInfo{
|
||||
ID: "session_" + randomID(12),
|
||||
UserID: userID,
|
||||
Title: req.Title,
|
||||
CreatedAt: time.Now().UnixMilli(),
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
if req.SessionID == "" {
|
||||
req.SessionID = "session_" + randomID(12)
|
||||
}
|
||||
|
||||
h.sessions[userID] = append([]SessionInfo{session}, h.sessions[userID]...)
|
||||
|
||||
c.JSON(http.StatusCreated, session)
|
||||
}
|
||||
|
||||
// List 获取会话列表
|
||||
func (h *SessionHandler) List(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
sessions, ok := h.sessions[userID]
|
||||
if !ok {
|
||||
sessions = []SessionInfo{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sessions": sessions,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete 删除会话
|
||||
func (h *SessionHandler) Delete(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
sessionID := c.Param("id")
|
||||
|
||||
sessions := h.sessions[userID]
|
||||
for i, s := range sessions {
|
||||
if s.ID == sessionID {
|
||||
h.sessions[userID] = append(sessions[:i], sessions[i+1:]...)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
if h.useDB {
|
||||
if err := h.store.CreateSession(userID, req.SessionID, req.Title, req.IsMain); err != nil {
|
||||
log.Printf("[SessionHandler] 创建会话失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建会话失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "会话不存在",
|
||||
"errorType": "session_not_found",
|
||||
"hint": "会话可能已被删除,或 Gateway 重启后内存数据已清空",
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": req.SessionID,
|
||||
"user_id": userID,
|
||||
"title": req.Title,
|
||||
"is_main": req.IsMain,
|
||||
"created_at": time.Now().UnixMilli(),
|
||||
"updated_at": time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// ========== GET /api/v1/sessions?user_id=xxx — 获取用户会话列表 ==========
|
||||
|
||||
// List 获取会话列表 (按 updated_at DESC 排序)
|
||||
func (h *SessionHandler) List(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
if userID == "" {
|
||||
userID = middleware.GetUserID(c)
|
||||
}
|
||||
|
||||
if h.useDB {
|
||||
sessions, err := h.store.GetUserSessions(userID)
|
||||
if err != nil {
|
||||
log.Printf("[SessionHandler] 查询会话列表失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询会话失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
// 转换为列表格式
|
||||
result := make([]gin.H, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
result = append(result, gin.H{
|
||||
"id": s.ID,
|
||||
"user_id": s.UserID,
|
||||
"title": s.Title,
|
||||
"is_main": s.IsMain,
|
||||
"created_at": s.CreatedAt.UnixMilli(),
|
||||
"updated_at": s.UpdatedAt.UnixMilli(),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"sessions": result})
|
||||
return
|
||||
}
|
||||
|
||||
// 降级:返回空列表
|
||||
c.JSON(http.StatusOK, gin.H{"sessions": []gin.H{}})
|
||||
}
|
||||
|
||||
// ========== GET /api/v1/sessions/:id — 获取单个会话 ==========
|
||||
|
||||
// Get 获取单个会话信息
|
||||
func (h *SessionHandler) Get(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
sessionID := c.Param("id")
|
||||
|
||||
for _, s := range h.sessions[userID] {
|
||||
if s.ID == sessionID {
|
||||
c.JSON(http.StatusOK, s)
|
||||
if h.useDB {
|
||||
session, err := h.store.GetSession(sessionID)
|
||||
if err != nil {
|
||||
log.Printf("[SessionHandler] 查询会话失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询会话失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "会话不存在",
|
||||
"errorType": "session_not_found",
|
||||
"hint": "该会话可能已被删除或尚未创建",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": session.ID,
|
||||
"user_id": session.UserID,
|
||||
"title": session.Title,
|
||||
"is_main": session.IsMain,
|
||||
"created_at": session.CreatedAt.UnixMilli(),
|
||||
"updated_at": session.UpdatedAt.UnixMilli(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "会话存储不可用",
|
||||
"errorType": "store_unavailable",
|
||||
"hint": "数据库连接未建立,Gateway 运行在仅内存模式",
|
||||
})
|
||||
}
|
||||
|
||||
// ========== DELETE /api/v1/sessions/:id — 删除会话 ==========
|
||||
|
||||
// Delete 删除会话 (不删除记忆)
|
||||
func (h *SessionHandler) Delete(c *gin.Context) {
|
||||
sessionID := c.Param("id")
|
||||
|
||||
if h.useDB {
|
||||
if err := h.store.DeleteSession(sessionID); err != nil {
|
||||
log.Printf("[SessionHandler] 删除会话失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除会话失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "会话不存在",
|
||||
"errorType": "session_not_found",
|
||||
"hint": "会话可能已被删除,或 Gateway 重启后内存数据已清空",
|
||||
})
|
||||
// 同时清理 Hub 中的缓存
|
||||
h.hub.DeleteConversation("", sessionID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
// ========== DELETE /api/v1/sessions?user_id=xxx — 删除用户所有会话 ==========
|
||||
|
||||
// DeleteAll 删除用户所有会话 (不删除记忆)
|
||||
func (h *SessionHandler) DeleteAll(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
if userID == "" {
|
||||
userID = middleware.GetUserID(c)
|
||||
}
|
||||
|
||||
if h.useDB {
|
||||
if err := h.store.DeleteAllUserSessions(userID); err != nil {
|
||||
log.Printf("[SessionHandler] 删除用户所有会话失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除会话失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
// ========== GET /api/v1/sessions/:id/messages?limit=50 — 获取会话消息 ==========
|
||||
|
||||
// GetMessages 获取会话的完整消息列表
|
||||
func (h *SessionHandler) GetMessages(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
sessionID := c.Param("id")
|
||||
limit := 50
|
||||
if l := c.Query("limit"); l != "" {
|
||||
parsed := 0
|
||||
for _, ch := range l {
|
||||
if ch < '0' || ch > '9' {
|
||||
break
|
||||
}
|
||||
parsed = parsed*10 + int(ch-'0')
|
||||
}
|
||||
if parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
messages := h.hub.GetConversation(userID, sessionID)
|
||||
if h.useDB {
|
||||
messages, err := h.store.GetMessages(sessionID, limit)
|
||||
if err != nil {
|
||||
log.Printf("[SessionHandler] 查询消息失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询消息失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
// 转换为统一格式
|
||||
result := make([]gin.H, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
result = append(result, gin.H{
|
||||
"id": m.ID,
|
||||
"session_id": m.SessionID,
|
||||
"role": m.Role,
|
||||
"content": m.Content,
|
||||
"created_at": m.CreatedAt.UnixMilli(),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"messages": result})
|
||||
return
|
||||
}
|
||||
|
||||
// 降级:从 Hub 内存缓存读取
|
||||
messages := h.hub.GetConversation("", sessionID)
|
||||
if messages == nil {
|
||||
messages = []ws.Message{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"messages": messages})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"messages": messages,
|
||||
})
|
||||
// ========== DELETE /api/v1/sessions/:id/messages — 清空会话消息 ==========
|
||||
|
||||
// ClearMessages 清空会话所有消息但不删除会话本身
|
||||
func (h *SessionHandler) ClearMessages(c *gin.Context) {
|
||||
sessionID := c.Param("id")
|
||||
|
||||
if h.useDB {
|
||||
if err := h.store.ClearSessionMessages(sessionID); err != nil {
|
||||
log.Printf("[SessionHandler] 清空消息失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "清空消息失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 同时清理 Hub 内存缓存
|
||||
h.hub.DeleteConversation("", sessionID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "cleared"})
|
||||
}
|
||||
|
||||
// ========== Admin 端点 ==========
|
||||
|
||||
// ListActiveSessions 获取当前所有活跃 WebSocket 会话列表 (管理员)
|
||||
func (h *SessionHandler) ListActiveSessions(c *gin.Context) {
|
||||
sessions := h.hub.GetActiveSessions()
|
||||
sessions := h.hub.GetAllActiveSessions()
|
||||
if sessions == nil {
|
||||
sessions = []*ws.SessionState{}
|
||||
}
|
||||
@@ -188,6 +312,5 @@ func randomID(n int) string {
|
||||
for i := range b {
|
||||
b[i] = letters[i%len(letters)]
|
||||
}
|
||||
// 使用纳秒时间戳增加唯一性
|
||||
return string(b)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user