feat: 扩展 Session Manager 接口,新增 ListByUser、UpdateTitle 方法

- Manager.Create 签名新增 userID 参数
- 新增 ConversationSummary 类型和 ListByUser 分页查询
- 新增 UpdateTitle 方法
- MemoryManager 实现:ListByUser 遍历+过滤+排序,UpdateTitle,自动标题生成
- RedisManager 实现:user:{id}:sessions 索引,ListByUser 通过 SMEMBERS 查询
- AppendMessage 自动更新标题(首条 user 消息时,取前 20 字符)
- 更新 ws handler、api/session.go、orchestrator mock 的 Create 调用
This commit is contained in:
hhs
2026-06-14 17:35:20 +08:00
parent 9ff971fd89
commit 6487a8ecab
7 changed files with 293 additions and 38 deletions

View File

@@ -36,7 +36,7 @@ func (h *SessionHandler) CreateSession(c *gin.Context) {
cfg = *req.Config cfg = *req.Config
} }
sessionID, err := h.sessionMgr.Create(c.Request.Context(), cfg) sessionID, err := h.sessionMgr.Create(c.Request.Context(), "", cfg)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"code": "INTERNAL_ERROR", "code": "INTERNAL_ERROR",

View File

@@ -16,6 +16,7 @@ import (
"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"
"github.com/hhs/camtalk/internal/session"
) )
func init() { func init() {
@@ -63,11 +64,21 @@ type MockSessionManager struct {
mock.Mock mock.Mock
} }
func (m *MockSessionManager) Create(ctx context.Context, config models.SessionConfig) (string, error) { func (m *MockSessionManager) Create(ctx context.Context, userID string, config models.SessionConfig) (string, error) {
args := m.Called(ctx, config) args := m.Called(ctx, userID, config)
return args.String(0), args.Error(1) return args.String(0), args.Error(1)
} }
func (m *MockSessionManager) UpdateTitle(ctx context.Context, sessionID string, title string) error {
args := m.Called(ctx, sessionID, title)
return args.Error(0)
}
func (m *MockSessionManager) ListByUser(ctx context.Context, userID string, page, size int) ([]session.ConversationSummary, int, error) {
args := m.Called(ctx, userID, page, size)
return args.Get(0).([]session.ConversationSummary), args.Int(1), args.Error(2)
}
func (m *MockSessionManager) Get(ctx context.Context, sessionID string) (*models.Session, error) { func (m *MockSessionManager) Get(ctx context.Context, sessionID string) (*models.Session, error) {
args := m.Called(ctx, sessionID) args := m.Called(ctx, sessionID)
if args.Get(0) == nil { if args.Get(0) == nil {

View File

@@ -4,6 +4,7 @@ package session
import ( import (
"context" "context"
"errors" "errors"
"time"
"github.com/hhs/camtalk/internal/models" "github.com/hhs/camtalk/internal/models"
) )
@@ -11,11 +12,20 @@ import (
// ErrSessionNotFound 会话不存在或已过期。 // ErrSessionNotFound 会话不存在或已过期。
var ErrSessionNotFound = errors.New("session not found") var ErrSessionNotFound = errors.New("session not found")
// ConversationSummary 对话摘要(列表展示用)。
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"`
}
// Manager 会话管理器接口。 // Manager 会话管理器接口。
// WebSocket Handler 通过此接口操作会话,不直接接触存储层。 // WebSocket Handler 通过此接口操作会话,不直接接触存储层。
type Manager interface { type Manager interface {
// Create 创建新会话,返回 session ID。 // Create 创建新会话,返回 session ID。userID 为空表示匿名会话。
Create(ctx context.Context, config models.SessionConfig) (string, error) Create(ctx context.Context, userID string, config models.SessionConfig) (string, error)
// Get 获取会话(含 config。不存在返回 ErrSessionNotFound。 // Get 获取会话(含 config。不存在返回 ErrSessionNotFound。
Get(ctx context.Context, sessionID string) (*models.Session, error) Get(ctx context.Context, sessionID string) (*models.Session, error)
@@ -23,6 +33,12 @@ type Manager interface {
// UpdateConfig 更新会话配置config 消息触发)。 // UpdateConfig 更新会话配置config 消息触发)。
UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error
// UpdateTitle 更新会话标题。
UpdateTitle(ctx context.Context, sessionID string, title string) error
// ListByUser 获取用户的对话列表(分页,按 UpdatedAt 降序)。
ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error)
// GetHistory 获取最近 N 轮对话历史(供 Orchestrator 构建 LLM 上下文)。 // GetHistory 获取最近 N 轮对话历史(供 Orchestrator 构建 LLM 上下文)。
GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error) GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error)

View File

@@ -2,6 +2,7 @@ package session
import ( import (
"context" "context"
"sort"
"sync" "sync"
"time" "time"
@@ -95,8 +96,8 @@ func (m *MemoryManager) isExpired(entry *sessionEntry) bool {
return time.Since(entry.lastActive) > m.ttl return time.Since(entry.lastActive) > m.ttl
} }
// Create 创建新会话。 // Create 创建新会话。userID 为空表示匿名会话。
func (m *MemoryManager) Create(_ context.Context, config models.SessionConfig) (string, error) { func (m *MemoryManager) Create(_ context.Context, userID string, config models.SessionConfig) (string, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -105,14 +106,17 @@ func (m *MemoryManager) Create(_ context.Context, config models.SessionConfig) (
m.sessions[id] = &sessionEntry{ m.sessions[id] = &sessionEntry{
session: models.Session{ session: models.Session{
ID: id, ID: id,
UserID: userID,
Title: models.DefaultSessionTitle,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now,
Config: config, Config: config,
}, },
history: make([]models.Message, 0), history: make([]models.Message, 0),
lastActive: now, lastActive: now,
} }
logger.Log.Debugw("session created", "session", id) logger.Log.Debugw("session created", "session", id, "user_id", userID)
return id, nil return id, nil
} }
@@ -147,6 +151,76 @@ func (m *MemoryManager) UpdateConfig(_ context.Context, sessionID string, patch
return nil return nil
} }
// UpdateTitle 更新会话标题。
func (m *MemoryManager) UpdateTitle(_ context.Context, sessionID string, title string) error {
m.mu.Lock()
defer m.mu.Unlock()
entry, ok := m.sessions[sessionID]
if !ok || m.isExpired(entry) {
return ErrSessionNotFound
}
entry.session.Title = title
entry.session.UpdatedAt = time.Now()
entry.lastActive = time.Now()
logger.Log.Debugw("session title updated", "session", sessionID, "title", title)
return nil
}
// ListByUser 获取用户的对话列表(分页,按 UpdatedAt 降序)。
func (m *MemoryManager) ListByUser(_ context.Context, userID string, page, size int) ([]ConversationSummary, int, error) {
m.mu.RLock()
defer m.mu.RUnlock()
// 收集该用户的所有 session
var list []ConversationSummary
for _, entry := range m.sessions {
if entry.session.UserID != userID {
continue
}
if m.isExpired(entry) {
continue
}
summary := ConversationSummary{
ID: entry.session.ID,
Title: entry.session.Title,
MessageCount: len(entry.history),
UpdatedAt: entry.lastActive,
}
if len(entry.history) > 0 {
summary.LastMessage = entry.history[len(entry.history)-1].Content
}
list = append(list, summary)
}
// 按 UpdatedAt 降序排序
sort.Slice(list, func(i, j int) bool {
return list[i].UpdatedAt.After(list[j].UpdatedAt)
})
total := len(list)
// 分页
if page <= 0 {
page = 1
}
if size <= 0 {
size = 20
}
start := (page - 1) * size
if start >= total {
return []ConversationSummary{}, total, nil
}
end := start + size
if end > total {
end = total
}
return list[start:end], total, nil
}
// GetHistory 获取最近 N 轮对话历史。 // GetHistory 获取最近 N 轮对话历史。
func (m *MemoryManager) GetHistory(_ context.Context, sessionID string, limit int) ([]models.Message, error) { func (m *MemoryManager) GetHistory(_ context.Context, sessionID string, limit int) ([]models.Message, error) {
m.mu.RLock() m.mu.RLock()
@@ -179,15 +253,31 @@ func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg m
entry.history = append(entry.history, msg) entry.history = append(entry.history, msg)
// 自动更新标题:首条 user 消息时,如果标题为默认值,自动更新为消息前 20 字符
if msg.Role == "user" && entry.session.Title == models.DefaultSessionTitle {
entry.session.Title = generateTitle(msg.Content)
}
// 超过上限时裁剪,保留最新的 maxHistory 条 // 超过上限时裁剪,保留最新的 maxHistory 条
if len(entry.history) > m.maxHistory { if len(entry.history) > m.maxHistory {
entry.history = entry.history[len(entry.history)-m.maxHistory:] entry.history = entry.history[len(entry.history)-m.maxHistory:]
} }
entry.lastActive = time.Now() now := time.Now()
entry.lastActive = now
entry.session.UpdatedAt = now
return nil return nil
} }
// generateTitle 从首条消息生成对话标题(取前 20 个字符)。
func generateTitle(firstMessage string) string {
runes := []rune(firstMessage)
if len(runes) > 20 {
return string(runes[:20]) + "…"
}
return firstMessage
}
// 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()

View File

@@ -19,7 +19,7 @@ func TestCreateAndGet(t *testing.T) {
ctx := context.Background() ctx := context.Background()
config := models.DefaultConfig() config := models.DefaultConfig()
id, err := m.Create(ctx, config) id, err := m.Create(ctx, "", config)
if err != nil { if err != nil {
t.Fatalf("Create: %v", err) t.Fatalf("Create: %v", err)
} }
@@ -56,7 +56,7 @@ func TestExpire(t *testing.T) {
defer m.Stop() defer m.Stop()
ctx := context.Background() ctx := context.Background()
id, _ := m.Create(ctx, models.DefaultConfig()) id, _ := m.Create(ctx, "", models.DefaultConfig())
// 未过期时应能获取 // 未过期时应能获取
_, err := m.Get(ctx, id) _, err := m.Get(ctx, id)
@@ -78,7 +78,7 @@ func TestDestroy(t *testing.T) {
defer m.Stop() defer m.Stop()
ctx := context.Background() ctx := context.Background()
id, _ := m.Create(ctx, models.DefaultConfig()) id, _ := m.Create(ctx, "", models.DefaultConfig())
if err := m.Destroy(ctx, id); err != nil { if err := m.Destroy(ctx, id); err != nil {
t.Fatalf("Destroy: %v", err) t.Fatalf("Destroy: %v", err)
@@ -106,7 +106,7 @@ func TestAppendMessageAndGetHistory(t *testing.T) {
defer m.Stop() defer m.Stop()
ctx := context.Background() ctx := context.Background()
id, _ := m.Create(ctx, models.DefaultConfig()) id, _ := m.Create(ctx, "", models.DefaultConfig())
msgs := []models.Message{ msgs := []models.Message{
{Role: "user", Content: "你好"}, {Role: "user", Content: "你好"},
@@ -138,7 +138,7 @@ func TestGetHistoryLimit(t *testing.T) {
defer m.Stop() defer m.Stop()
ctx := context.Background() ctx := context.Background()
id, _ := m.Create(ctx, models.DefaultConfig()) id, _ := m.Create(ctx, "", models.DefaultConfig())
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
m.AppendMessage(ctx, id, models.Message{Role: "user", Content: "msg"}) m.AppendMessage(ctx, id, models.Message{Role: "user", Content: "msg"})
@@ -159,7 +159,7 @@ func TestHistoryLimit(t *testing.T) {
defer m.Stop() defer m.Stop()
ctx := context.Background() ctx := context.Background()
id, _ := m.Create(ctx, models.DefaultConfig()) id, _ := m.Create(ctx, "", models.DefaultConfig())
// 插入超过上限的消息 // 插入超过上限的消息
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
@@ -180,7 +180,7 @@ func TestUpdateConfig(t *testing.T) {
defer m.Stop() defer m.Stop()
ctx := context.Background() ctx := context.Background()
id, _ := m.Create(ctx, models.DefaultConfig()) id, _ := m.Create(ctx, "", models.DefaultConfig())
ttsEnabled := false ttsEnabled := false
detailLevel := "high" detailLevel := "high"
@@ -211,7 +211,7 @@ func TestActiveRequest(t *testing.T) {
defer m.Stop() defer m.Stop()
ctx := context.Background() ctx := context.Background()
id, _ := m.Create(ctx, models.DefaultConfig()) id, _ := m.Create(ctx, "", models.DefaultConfig())
// 初始应为空 // 初始应为空
reqID, err := m.GetActiveRequestID(ctx, id) reqID, err := m.GetActiveRequestID(ctx, id)
@@ -246,7 +246,7 @@ func TestTouchRefreshesTTL(t *testing.T) {
defer m.Stop() defer m.Stop()
ctx := context.Background() ctx := context.Background()
id, _ := m.Create(ctx, models.DefaultConfig()) id, _ := m.Create(ctx, "", models.DefaultConfig())
// 50ms 后 Touch应重置 TTL // 50ms 后 Touch应重置 TTL
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
@@ -278,8 +278,8 @@ func TestActiveCount(t *testing.T) {
t.Errorf("initial ActiveCount = %d, want 0", m.ActiveCount()) t.Errorf("initial ActiveCount = %d, want 0", m.ActiveCount())
} }
m.Create(ctx, models.DefaultConfig()) m.Create(ctx, "", models.DefaultConfig())
m.Create(ctx, models.DefaultConfig()) m.Create(ctx, "", models.DefaultConfig())
if m.ActiveCount() != 2 { if m.ActiveCount() != 2 {
t.Errorf("ActiveCount = %d, want 2", m.ActiveCount()) t.Errorf("ActiveCount = %d, want 2", m.ActiveCount())
} }

View File

@@ -18,6 +18,7 @@ import (
// 数据结构: // 数据结构:
// - session:{id}:meta → Hash会话元数据 // - session:{id}:meta → Hash会话元数据
// - session:{id}:history → List对话历史 // - session:{id}:history → List对话历史
// - user:{id}:sessions → Set用户会话索引
type RedisManager struct { type RedisManager struct {
rdb *redis.Client rdb *redis.Client
ttl time.Duration ttl time.Duration
@@ -37,35 +38,46 @@ func NewRedisManager(rdb *redis.Client, ttl time.Duration, maxHistory int) *Redi
func metaKey(id string) string { return fmt.Sprintf("session:%s:meta", id) } func metaKey(id string) string { return fmt.Sprintf("session:%s:meta", id) }
func histKey(id string) string { return fmt.Sprintf("session:%s:history", id) } func histKey(id string) string { return fmt.Sprintf("session:%s:history", id) }
func userSessKey(id string) string { return fmt.Sprintf("user:%s:sessions", id) }
// Create 创建新会话。 // Create 创建新会话。userID 为空表示匿名会话。
func (m *RedisManager) Create(ctx context.Context, config models.SessionConfig) (string, error) { func (m *RedisManager) Create(ctx context.Context, userID string, config models.SessionConfig) (string, error) {
id := uuidNew() id := uuidNew()
now := time.Now().UTC() now := time.Now().UTC()
pipe := m.rdb.Pipeline() pipe := m.rdb.Pipeline()
// 写入 meta Hash // 写入 meta Hash
pipe.HSet(ctx, metaKey(id), map[string]interface{}{ meta := map[string]interface{}{
"session_id": id, "session_id": id,
"user_id": userID,
"title": models.DefaultSessionTitle,
"config.tts_enabled": strconv.FormatBool(config.TTSEnabled), "config.tts_enabled": strconv.FormatBool(config.TTSEnabled),
"config.detail_level": config.DetailLevel, "config.detail_level": config.DetailLevel,
"config.language": config.Language, "config.language": config.Language,
"created_at": now.Format(time.RFC3339), "created_at": now.Format(time.RFC3339),
"updated_at": now.Format(time.RFC3339),
"last_active": now.Format(time.RFC3339), "last_active": now.Format(time.RFC3339),
"active_request_id": "", "active_request_id": "",
}) }
pipe.HSet(ctx, metaKey(id), meta)
pipe.Expire(ctx, metaKey(id), m.ttl) pipe.Expire(ctx, metaKey(id), m.ttl)
// 初始化空 history List // 初始化空 history List
pipe.RPush(ctx, histKey(id), placeholderHistoryMark) pipe.RPush(ctx, histKey(id), placeholderHistoryMark)
pipe.Expire(ctx, histKey(id), m.ttl) pipe.Expire(ctx, histKey(id), m.ttl)
// 如果有 userID添加到用户会话索引
if userID != "" {
pipe.SAdd(ctx, userSessKey(userID), id)
pipe.Expire(ctx, userSessKey(userID), m.ttl)
}
if _, err := pipe.Exec(ctx); err != nil { if _, err := pipe.Exec(ctx); err != nil {
return "", fmt.Errorf("redis create session: %w", err) return "", fmt.Errorf("redis create session: %w", err)
} }
logger.Log.Debugw("redis session created", "session", id) logger.Log.Debugw("redis session created", "session", id, "user_id", userID)
return id, nil return id, nil
} }
@@ -84,8 +96,11 @@ func (m *RedisManager) Get(ctx context.Context, sessionID string) (*models.Sessi
sess := &models.Session{ sess := &models.Session{
ID: vals["session_id"], ID: vals["session_id"],
UserID: vals["user_id"],
Title: vals["title"],
} }
sess.CreatedAt, _ = time.Parse(time.RFC3339, vals["created_at"]) sess.CreatedAt, _ = time.Parse(time.RFC3339, vals["created_at"])
sess.UpdatedAt, _ = time.Parse(time.RFC3339, vals["updated_at"])
sess.Config.TTSEnabled, _ = strconv.ParseBool(vals["config.tts_enabled"]) sess.Config.TTSEnabled, _ = strconv.ParseBool(vals["config.tts_enabled"])
sess.Config.DetailLevel = vals["config.detail_level"] sess.Config.DetailLevel = vals["config.detail_level"]
sess.Config.Language = vals["config.language"] sess.Config.Language = vals["config.language"]
@@ -104,8 +119,10 @@ func (m *RedisManager) UpdateConfig(ctx context.Context, sessionID string, patch
return ErrSessionNotFound return ErrSessionNotFound
} }
now := time.Now().UTC().Format(time.RFC3339)
fields := map[string]interface{}{ fields := map[string]interface{}{
"last_active": time.Now().UTC().Format(time.RFC3339), "last_active": now,
"updated_at": now,
} }
if patch.TTSEnabled != nil { if patch.TTSEnabled != nil {
fields["config.tts_enabled"] = strconv.FormatBool(*patch.TTSEnabled) fields["config.tts_enabled"] = strconv.FormatBool(*patch.TTSEnabled)
@@ -127,6 +144,109 @@ func (m *RedisManager) UpdateConfig(ctx context.Context, sessionID string, patch
return nil return nil
} }
// UpdateTitle 更新会话标题。
func (m *RedisManager) UpdateTitle(ctx context.Context, sessionID string, title string) error {
exists, err := m.rdb.Exists(ctx, metaKey(sessionID)).Result()
if err != nil {
return fmt.Errorf("redis check session: %w", err)
}
if exists == 0 {
return ErrSessionNotFound
}
now := time.Now().UTC().Format(time.RFC3339)
if err := m.rdb.HSet(ctx, metaKey(sessionID), "title", title, "updated_at", now, "last_active", now).Err(); err != nil {
return fmt.Errorf("redis update title: %w", err)
}
m.rdb.Expire(ctx, metaKey(sessionID), m.ttl)
logger.Log.Debugw("redis session title updated", "session", sessionID, "title", title)
return nil
}
// ListByUser 获取用户的对话列表(分页,按 UpdatedAt 降序)。
func (m *RedisManager) ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) {
if page <= 0 {
page = 1
}
if size <= 0 {
size = 20
}
// 从用户会话索引获取所有 session ID
sessionIDs, err := m.rdb.SMembers(ctx, userSessKey(userID)).Result()
if err != nil {
return nil, 0, fmt.Errorf("redis list user sessions: %w", err)
}
// 收集有效的会话摘要
var list []ConversationSummary
for _, sid := range sessionIDs {
vals, err := m.rdb.HGetAll(ctx, metaKey(sid)).Result()
if err != nil || len(vals) == 0 {
continue
}
updatedAt, _ := time.Parse(time.RFC3339, vals["updated_at"])
lastActive, _ := time.Parse(time.RFC3339, vals["last_active"])
// 检查是否过期
if time.Since(lastActive) > m.ttl {
continue
}
// 获取最后一条消息
lastMsg := ""
msgCount := 0
raws, err := m.rdb.LRange(ctx, histKey(sid), 0, 0).Result()
if err == nil && len(raws) > 0 && raws[0] != placeholderHistoryMark {
var msg models.Message
if json.Unmarshal([]byte(raws[0]), &msg) == nil {
lastMsg = msg.Content
}
}
// 获取消息总数(减去占位符)
totalLen, err := m.rdb.LLen(ctx, histKey(sid)).Result()
if err == nil {
msgCount = int(totalLen)
if msgCount > 0 {
msgCount-- // 减去占位符
}
}
list = append(list, ConversationSummary{
ID: vals["session_id"],
Title: vals["title"],
LastMessage: lastMsg,
MessageCount: msgCount,
UpdatedAt: updatedAt,
})
}
// 按 UpdatedAt 降序排序
for i := 0; i < len(list); i++ {
for j := i + 1; j < len(list); j++ {
if list[j].UpdatedAt.After(list[i].UpdatedAt) {
list[i], list[j] = list[j], list[i]
}
}
}
total := len(list)
// 分页
start := (page - 1) * size
if start >= total {
return []ConversationSummary{}, total, nil
}
end := start + size
if end > total {
end = total
}
return list[start:end], total, nil
}
// GetHistory 获取最近 N 轮对话历史。 // GetHistory 获取最近 N 轮对话历史。
func (m *RedisManager) GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error) { func (m *RedisManager) GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error) {
// 检查会话是否存在 // 检查会话是否存在
@@ -193,8 +313,18 @@ func (m *RedisManager) AppendMessage(ctx context.Context, sessionID string, msg
// 刷新 TTL // 刷新 TTL
pipe.Expire(ctx, histKey(sessionID), m.ttl) pipe.Expire(ctx, histKey(sessionID), m.ttl)
pipe.Expire(ctx, metaKey(sessionID), m.ttl) pipe.Expire(ctx, metaKey(sessionID), m.ttl)
// 更新 last_active
pipe.HSet(ctx, metaKey(sessionID), "last_active", time.Now().UTC().Format(time.RFC3339)) now := time.Now().UTC().Format(time.RFC3339)
// 更新 last_active 和 updated_at
pipe.HSet(ctx, metaKey(sessionID), "last_active", now, "updated_at", now)
// 自动更新标题:首条 user 消息时,如果标题为默认值
if msg.Role == "user" {
title, _ := m.rdb.HGet(ctx, metaKey(sessionID), "title").Result()
if title == models.DefaultSessionTitle {
pipe.HSet(ctx, metaKey(sessionID), "title", generateTitle(msg.Content))
}
}
if _, err := pipe.Exec(ctx); err != nil { if _, err := pipe.Exec(ctx); err != nil {
return fmt.Errorf("redis append message: %w", err) return fmt.Errorf("redis append message: %w", err)
@@ -280,6 +410,9 @@ func (m *RedisManager) Touch(ctx context.Context, sessionID string) error {
// Destroy 显式销毁会话。 // Destroy 显式销毁会话。
func (m *RedisManager) Destroy(ctx context.Context, sessionID string) error { func (m *RedisManager) Destroy(ctx context.Context, sessionID string) error {
// 先获取 user_id 以便清理索引
userID, _ := m.rdb.HGet(ctx, metaKey(sessionID), "user_id").Result()
deleted, err := m.rdb.Del(ctx, metaKey(sessionID), histKey(sessionID)).Result() deleted, err := m.rdb.Del(ctx, metaKey(sessionID), histKey(sessionID)).Result()
if err != nil { if err != nil {
return fmt.Errorf("redis destroy session: %w", err) return fmt.Errorf("redis destroy session: %w", err)
@@ -288,6 +421,11 @@ func (m *RedisManager) Destroy(ctx context.Context, sessionID string) error {
return ErrSessionNotFound return ErrSessionNotFound
} }
// 清理用户会话索引
if userID != "" {
m.rdb.SRem(ctx, userSessKey(userID), sessionID)
}
logger.Log.Debugw("redis session destroyed", "session", sessionID) logger.Log.Debugw("redis session destroyed", "session", sessionID)
return nil return nil
} }

View File

@@ -114,7 +114,7 @@ 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()) sessionID, err := sessionMgr.Create(context.Background(), "", 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