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:
2026-05-17 17:18:02 +08:00
parent 745b1c6aad
commit e7b7eff0d8
21 changed files with 1735 additions and 284 deletions
+16 -1
View File
@@ -1,6 +1,7 @@
package config
import (
"fmt"
"os"
"time"
@@ -49,6 +50,9 @@ type Config struct {
// WebSocket
WSMaxConnections int
// 会话闲置超时 (分钟) — 超过此时间后会话标记为 idle 但不删除
SessionIdleTimeoutMin int
// Webhook (第三方平台接入)
WebhookAPIKey string
}
@@ -87,12 +91,23 @@ func Load() *Config {
LLMAPIKey: getEnv("LLM_API_KEY", ""),
LLMModel: getEnv("LLM_MODEL", "gpt-4o"),
WSMaxConnections: getEnvInt("WS_MAX_CONNECTIONS", 1000),
WSMaxConnections: getEnvInt("WS_MAX_CONNECTIONS", 1000),
SessionIdleTimeoutMin: getEnvInt("SESSION_IDLE_TIMEOUT_MIN", 30),
WebhookAPIKey: getEnv("WEBHOOK_API_KEY", ""),
}
}
// DatabaseURL 构建 PostgreSQL 连接字符串
func (c *Config) DatabaseURL() string {
return fmt.Sprintf(
"postgres://%s:%s@%s:%s/%s?sslmode=disable",
c.PostgresUser, c.PostgresPass,
c.PostgresHost, c.PostgresPort,
c.PostgresDB,
)
}
// GenerateToken 生成JWT token
func (c *Config) GenerateToken(userID string) (string, error) {
claims := jwt.MapClaims{
@@ -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)
}
+10 -7
View File
@@ -9,17 +9,18 @@ import (
"github.com/yourname/cyrene-ai/gateway/internal/config"
"github.com/yourname/cyrene-ai/gateway/internal/handler"
"github.com/yourname/cyrene-ai/gateway/internal/middleware"
"github.com/yourname/cyrene-ai/gateway/internal/store"
"github.com/yourname/cyrene-ai/gateway/internal/ws"
)
// Setup 注册所有路由
func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config) {
func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.SessionStore) {
// 限流器
rateLimiter := middleware.NewRateLimiter(10, 20) // 每秒10个请求,突发20
// 初始化处理器
authHandler := handler.NewAuthHandler(cfg)
sessionHandler := handler.NewSessionHandler(hub)
sessionHandler := handler.NewSessionHandler(hub, sessionStore)
memoryHandler := handler.NewMemoryHandler(cfg.AICoreURL)
chatHandler := handler.NewChatHandler(cfg, hub)
webhookHandler := handler.NewWebhookHandler(cfg, hub)
@@ -54,11 +55,13 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config) {
// 会话管理
sessions := protected.Group("/sessions")
{
sessions.POST("", sessionHandler.Create)
sessions.GET("", sessionHandler.List)
sessions.GET("/:id", sessionHandler.Get)
sessions.DELETE("/:id", sessionHandler.Delete)
sessions.GET("/:id/messages", sessionHandler.GetMessages)
sessions.POST("", sessionHandler.Create) // POST /api/v1/sessions
sessions.GET("", sessionHandler.List) // GET /api/v1/sessions?user_id=xxx
sessions.DELETE("", sessionHandler.DeleteAll) // DELETE /api/v1/sessions?user_id=xxx
sessions.GET("/:id", sessionHandler.Get) // GET /api/v1/sessions/:id
sessions.DELETE("/:id", sessionHandler.Delete) // DELETE /api/v1/sessions/:id
sessions.GET("/:id/messages", sessionHandler.GetMessages) // GET /api/v1/sessions/:id/messages?limit=50
sessions.DELETE("/:id/messages", sessionHandler.ClearMessages) // DELETE /api/v1/sessions/:id/messages
}
// 记忆管理
@@ -0,0 +1,270 @@
package store
import (
"database/sql"
"fmt"
"log"
"time"
_ "github.com/lib/pq"
)
// Session 会话模型
type Session struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
IsMain bool `json:"is_main"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Message 消息模型
type Message struct {
ID int `json:"id"`
SessionID string `json:"session_id"`
Role string `json:"role"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
// SessionStore 会话持久化存储
type SessionStore struct {
db *sql.DB
}
// NewSessionStore 初始化数据库连接并自动建表
// 如果连接失败,返回 nil 和错误(调用方可以选择降级为仅内存模式)
func NewSessionStore(databaseURL string) (*SessionStore, error) {
db, err := sql.Open("postgres", databaseURL)
if err != nil {
return nil, fmt.Errorf("无法打开数据库连接: %w", err)
}
// 配置连接池
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
// 验证连接
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("数据库连接验证失败: %w", err)
}
store := &SessionStore{db: db}
// 自动建表
if err := store.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
log.Println("[SessionStore] PostgreSQL 持久化存储已初始化")
return store, nil
}
// migrate 自动创建表结构
func (s *SessionStore) migrate() error {
queries := []string{
`CREATE TABLE IF NOT EXISTS sessions (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(128) NOT NULL,
title VARCHAR(256) DEFAULT '',
is_main BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at DESC)`,
`CREATE TABLE IF NOT EXISTS messages (
id SERIAL PRIMARY KEY,
session_id VARCHAR(64) REFERENCES sessions(id) ON DELETE CASCADE,
role VARCHAR(16) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)`,
`CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(session_id, created_at)`,
}
for _, q := range queries {
if _, err := s.db.Exec(q); err != nil {
return fmt.Errorf("迁移SQL执行失败: %w\nSQL: %s", err, q)
}
}
return nil
}
// CreateSession 创建新会话
func (s *SessionStore) CreateSession(userID, sessionID, title string, isMain bool) error {
now := time.Now()
_, err := s.db.Exec(
`INSERT INTO sessions (id, user_id, title, is_main, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $5)
ON CONFLICT (id) DO UPDATE SET updated_at = $5`,
sessionID, userID, title, isMain, now,
)
if err != nil {
return fmt.Errorf("创建会话失败: %w", err)
}
return nil
}
// GetUserSessions 获取用户的所有会话(按 updated_at DESC 排序)
func (s *SessionStore) GetUserSessions(userID string) ([]Session, error) {
rows, err := s.db.Query(
`SELECT id, user_id, title, is_main, created_at, updated_at
FROM sessions WHERE user_id = $1
ORDER BY updated_at DESC`,
userID,
)
if err != nil {
return nil, fmt.Errorf("查询用户会话失败: %w", err)
}
defer rows.Close()
var sessions []Session
for rows.Next() {
var sess Session
if err := rows.Scan(&sess.ID, &sess.UserID, &sess.Title, &sess.IsMain, &sess.CreatedAt, &sess.UpdatedAt); err != nil {
return nil, fmt.Errorf("扫描会话行失败: %w", err)
}
sessions = append(sessions, sess)
}
if sessions == nil {
sessions = []Session{}
}
return sessions, rows.Err()
}
// GetSession 获取单个会话
func (s *SessionStore) GetSession(sessionID string) (*Session, error) {
var sess Session
err := s.db.QueryRow(
`SELECT id, user_id, title, is_main, created_at, updated_at
FROM sessions WHERE id = $1`,
sessionID,
).Scan(&sess.ID, &sess.UserID, &sess.Title, &sess.IsMain, &sess.CreatedAt, &sess.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("查询会话失败: %w", err)
}
return &sess, nil
}
// UpdateSessionTitle 更新会话标题
func (s *SessionStore) UpdateSessionTitle(sessionID, title string) error {
_, err := s.db.Exec(
`UPDATE sessions SET title = $1, updated_at = NOW() WHERE id = $2`,
title, sessionID,
)
if err != nil {
return fmt.Errorf("更新会话标题失败: %w", err)
}
return nil
}
// UpdateSessionTime 更新会话的 updated_at 时间戳
func (s *SessionStore) UpdateSessionTime(sessionID string) error {
_, err := s.db.Exec(
`UPDATE sessions SET updated_at = NOW() WHERE id = $1`,
sessionID,
)
if err != nil {
return fmt.Errorf("更新会话时间失败: %w", err)
}
return nil
}
// DeleteSession 删除会话(级联删除消息,但不删除记忆)
func (s *SessionStore) DeleteSession(sessionID string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE id = $1`, sessionID)
if err != nil {
return fmt.Errorf("删除会话失败: %w", err)
}
return nil
}
// DeleteAllUserSessions 删除用户的所有会话(但不删除记忆)
func (s *SessionStore) DeleteAllUserSessions(userID string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE user_id = $1`, userID)
if err != nil {
return fmt.Errorf("删除用户所有会话失败: %w", err)
}
return nil
}
// AddMessage 添加一条消息到会话
func (s *SessionStore) AddMessage(sessionID, role, content string) error {
_, err := s.db.Exec(
`INSERT INTO messages (session_id, role, content) VALUES ($1, $2, $3)`,
sessionID, role, content,
)
if err != nil {
return fmt.Errorf("添加消息失败: %w", err)
}
return nil
}
// GetMessages 获取会话的消息列表(按时间正序)
func (s *SessionStore) GetMessages(sessionID string, limit int) ([]Message, error) {
if limit <= 0 {
limit = 50
}
rows, err := s.db.Query(
`SELECT id, session_id, role, content, created_at
FROM messages WHERE session_id = $1
ORDER BY created_at ASC
LIMIT $2`,
sessionID, limit,
)
if err != nil {
return nil, fmt.Errorf("查询消息失败: %w", err)
}
defer rows.Close()
var messages []Message
for rows.Next() {
var msg Message
if err := rows.Scan(&msg.ID, &msg.SessionID, &msg.Role, &msg.Content, &msg.CreatedAt); err != nil {
return nil, fmt.Errorf("扫描消息行失败: %w", err)
}
messages = append(messages, msg)
}
if messages == nil {
messages = []Message{}
}
return messages, rows.Err()
}
// ClearSessionMessages 清空会话的所有消息但不删除会话本身
func (s *SessionStore) ClearSessionMessages(sessionID string) error {
_, err := s.db.Exec(`DELETE FROM messages WHERE session_id = $1`, sessionID)
if err != nil {
return fmt.Errorf("清空会话消息失败: %w", err)
}
return nil
}
// Close 关闭数据库连接
func (s *SessionStore) Close() error {
if s.db != nil {
return s.db.Close()
}
return nil
}
// IsAvailable 检查存储是否可用(数据库连接正常)
func (s *SessionStore) IsAvailable() bool {
if s.db == nil {
return false
}
return s.db.Ping() == nil
}
+93 -2
View File
@@ -8,6 +8,8 @@ import (
"os"
"sync"
"time"
"github.com/yourname/cyrene-ai/gateway/internal/store"
)
// SessionState 会话状态
@@ -60,6 +62,22 @@ type Hub struct {
iotServiceURL string
iotStopCh chan struct{}
iotPollRunning bool
// 持久化存储 (可选,数据库连接失败时为 nil)
store *store.SessionStore
// 闲置超时时间
idleTimeout time.Duration
}
// SetStore 设置持久化存储 (可选)
func (h *Hub) SetStore(s *store.SessionStore) {
h.store = s
}
// SetIdleTimeout 设置闲置超时时间
func (h *Hub) SetIdleTimeout(minutes int) {
h.idleTimeout = time.Duration(minutes) * time.Minute
}
// NewHub 创建WebSocket Hub
@@ -72,9 +90,79 @@ func NewHub() *Hub {
userClients: make(map[string]map[*Client]bool),
sessions: make(map[string]*SessionState),
iotStopCh: make(chan struct{}),
idleTimeout: 30 * time.Minute, // 默认30分钟
}
}
// StartIdleCleanup 启动闲置会话清理 goroutine
// 每5分钟检查一次,将超过 idleTimeout 无活动的会话标记为 idle
func (h *Hub) StartIdleCleanup() {
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
h.cleanupIdleSessions()
}
}()
log.Printf("[WS] 闲置会话清理已启动 (超时: %v)", h.idleTimeout)
}
// cleanupIdleSessions 标记超时会话为 idle(不删除状态)
func (h *Hub) cleanupIdleSessions() {
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
idleCount := 0
for sessionID, s := range h.sessions {
// 检查该 session 是否还有活跃连接
hasActiveConn := false
for _, clients := range h.userClients {
for c := range clients {
if c.SessionID == sessionID {
hasActiveConn = true
break
}
}
if hasActiveConn {
break
}
}
// 如果没有活跃连接且超过闲置超时,标记为 idle
if !hasActiveConn && now.Sub(s.LastActivity) > h.idleTimeout {
if s.State != "idle" {
s.State = "idle"
idleCount++
}
}
}
if idleCount > 0 {
log.Printf("[WS] 闲置清理: %d 个会话标记为 idle", idleCount)
}
}
// GetAllActiveSessions 返回所有会话状态(包括 idle),供 DevTools 监看使用
func (h *Hub) GetAllActiveSessions() []*SessionState {
h.mu.RLock()
defer h.mu.RUnlock()
if h.sessions == nil || len(h.sessions) == 0 {
return []*SessionState{}
}
result := make([]*SessionState, 0, len(h.sessions))
for _, s := range h.sessions {
cp := *s
cp.RecentMessages = nil
result = append(result, &cp)
}
return result
}
// Run 启动Hub主循环
func (h *Hub) Run() {
for {
@@ -119,7 +207,7 @@ func (h *Hub) Run() {
}
}
// 检查该session是否还有其他连接,没有则移除会话状态
// 检查该session是否还有其他连接,没有则标记为 idle 而非删除
hasOtherConn := false
if clients, ok := h.userClients[client.UserID]; ok {
for c := range clients {
@@ -130,7 +218,10 @@ func (h *Hub) Run() {
}
}
if !hasOtherConn {
delete(h.sessions, client.SessionID)
// 不再删除 session 状态,而是标记为 idle 保留在内存中
if s, ok := h.sessions[client.SessionID]; ok {
s.State = "idle"
}
}
}
h.mu.Unlock()