Files
Cyrene_v2/internal/channel/session/manager.go
T
Aska 04bfa2c7a7
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): admin 列表端点接真实数据——persona/session/memory 补 List() 并注入 gateway.Deps
根因:gateway.Server 定义 SessionStore/PersonaStore/MemoryStore 接口但生产从未满足:
- 底层 manager 无 List() 方法;
- wireFrontend 的 gateway.Deps{} 未传 Session/Personas/Memories。
→ /api/v1/sessions|personas 与 /api/v1/admin/{sessions,personas,memories} 恒返回 []。

改动:
- persona/registry:Registry.List() []Character(并发安全、值拷贝)。
- channel/session:SessionManager.List(ctx)(查 channel_sessions 表+按 actor_role 重建)。
- runtime/memory:Store.List(ctx)(全表,复用 scanItems)。
- gateway 三 Store 接口改为返回域类型(Character/MemoryItem),映射统一放 collect* 侧。
- cmd/cyrene/wire.go:Deps 补传 Session/Personas/Memories。
- .gitignore:cyrene→/cyrene(锚定根,不再误伤 cmd/cyrene 目录)。

实机验证:/api/v1/admin/personas 返回 {id:cyrene,name:昔涟,isDefault:true}。
2026-09-06 14:05:34 +08:00

212 lines
7.5 KiB
Go

package session
import (
"context"
"database/sql"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// DDL for the channel↔session mapping. The composite primary key over
// (platform, channel_id) is the unique key the design requires: each real-world
// channel maps to exactly one internal session_id. created_at/updated_at follow
// the project's timestamp convention; actor_role records the identity lane so the
// mapping survives restarts.
const createTableSQL = `
CREATE TABLE IF NOT EXISTS channel_sessions (
platform TEXT NOT NULL,
channel_id TEXT NOT NULL,
session_id TEXT NOT NULL,
actor_role TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
PRIMARY KEY (platform, channel_id)
)`
const createIndexSQL = `
CREATE INDEX IF NOT EXISTS idx_channel_sessions_actor_role ON channel_sessions(actor_role)`
const insertOrIgnoreSessionSQL = `
INSERT OR IGNORE INTO channel_sessions
(platform, channel_id, session_id, actor_role, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`
const selectSessionSQL = `
SELECT session_id FROM channel_sessions WHERE platform = ? AND channel_id = ?`
// listSessionsSQL selects every persisted channel mapping, newest first. Group
// sessions key on a channel_id carrying groupChannelPrefix; direct sessions use
// their channel id.
const listSessionSQL = `
SELECT platform, channel_id, session_id, actor_role FROM channel_sessions
ORDER BY created_at DESC`
// groupChannelPrefix marks a channel id as a group conversation so a session
// reconstructed from the backing table (where no IsGroup flag is persisted) can
// still report group membership.
const groupChannelPrefix = "group:"
// SessionManager owns the channel↔session mapping. It backs the map with the
// SQLite table channel_sessions so a channel keeps its internal session across
// restarts, and it caches lookups in memory to avoid a query per message. The
// cache is guarded by an RWMutex for concurrent adapter use.
type SessionManager struct {
mu sync.RWMutex
db *sql.DB
log *slog.Logger
cache map[string]Session
}
// New builds a SessionManager, ensuring the channel_sessions table and its index
// exist. It performs no migration bookkeeping: the schema is created here so the
// module is self-contained. A nil db is rejected. ctx is used for DDL.
func New(ctx context.Context, db *sql.DB, log *slog.Logger) (*SessionManager, error) {
if db == nil {
return nil, ErrNilDB
}
log = logger.WithSource(log, "channel/session")
if _, err := db.ExecContext(ctx, createTableSQL); err != nil {
return nil, fmt.Errorf("session: ensure channel_sessions: %w", err)
}
if _, err := db.ExecContext(ctx, createIndexSQL); err != nil {
return nil, fmt.Errorf("session: ensure channel_sessions index: %w", err)
}
return &SessionManager{db: db, log: log, cache: make(map[string]Session)}, nil
}
// GetOrCreate resolves the actor for msg and returns the Session bound to the
// (platform, channel) the message represents, creating the mapping on first
// sight. The owner whitelist is applied here (ResolveActor), so the owner flag
// is set structurally rather than from prompt text. Concurrent calls for the
// same channel return the same session.
func (m *SessionManager) GetOrCreate(ctx context.Context, msg adapter.UnifiedMessage, owner *user.OwnerProfile) (Session, error) {
actor := ResolveActor(msg, owner)
cid := channelID(msg)
if cid == "" {
return Session{}, fmt.Errorf("session: get-or-create %s: %w", msg.Platform, ErrInvalidChannel)
}
key := msg.Platform + "\x00" + cid
if s, ok := m.lookupCached(key); ok {
return s, nil
}
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.cache[key]; ok {
return s, nil
}
sessionID, err := m.persist(ctx, msg.Platform, cid, actor)
if err != nil {
return Session{}, err
}
s := Session{
Platform: msg.Platform,
ChannelID: cid,
SessionID: sessionID,
ActorRole: actor.Role,
Owner: actor.IsOwner(),
IsGroup: actor.IsGroupChat(),
MemWritable: actor.IsOwner() && !actor.IsGroupChat(),
}
m.cache[key] = s
return s, nil
}
// List returns every known session, newest first, across the backing table and
// the in-memory cache. Rows that are still in the cache are returned with their
// full derived metadata (Owner/IsGroup/MemWritable from the original resolve);
// rows recovered from the table (for example from a previous process) are
// reconstructed from the persisted actor role. The returned slice is non-nil. It
// is safe for concurrent use.
func (m *SessionManager) List(ctx context.Context) ([]Session, error) {
rows, err := m.db.QueryContext(ctx, listSessionSQL)
if err != nil {
return nil, fmt.Errorf("session: list: %w", err)
}
defer func() { _ = rows.Close() }()
sessions := make([]Session, 0)
for rows.Next() {
var platform, cid, sessionID, roleStr string
if err := rows.Scan(&platform, &cid, &sessionID, &roleStr); err != nil {
return nil, fmt.Errorf("session: scan list: %w", err)
}
key := platform + "\x00" + cid
if cached, ok := m.lookupCached(key); ok {
sessions = append(sessions, cached)
continue
}
role := adapter.Role(roleStr)
owner := role == adapter.RoleOwner
isGroup := strings.HasPrefix(cid, groupChannelPrefix)
sessions = append(sessions, Session{
Platform: platform,
ChannelID: cid,
SessionID: sessionID,
ActorRole: role,
Owner: owner,
IsGroup: isGroup,
MemWritable: owner && !isGroup,
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("session: iterate list: %w", err)
}
return sessions, nil
}
// lookupCached returns the cached session for key under a read lock.
func (m *SessionManager) lookupCached(key string) (Session, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.cache[key]
return s, ok
}
// persist writes the channel mapping if it does not exist yet and reads back the
// canonical session_id. INSERT OR IGNORE makes it safe under concurrency: the
// winning writer's session_id becomes the binding. It is called under the write
// lock so the in-memory cache and the table stay in agreement.
func (m *SessionManager) persist(ctx context.Context, platform, cid string, actor adapter.Actor) (string, error) {
now := time.Now().UTC().Format(time.RFC3339Nano)
if _, err := m.db.ExecContext(ctx, insertOrIgnoreSessionSQL, platform, cid, newSessionID(), string(actor.Role), now, now); err != nil {
return "", fmt.Errorf("session: persist %s/%s: %w", platform, cid, err)
}
var sessionID string
if err := m.db.QueryRowContext(ctx, selectSessionSQL, platform, cid).Scan(&sessionID); err != nil {
return "", fmt.Errorf("session: read back %s/%s: %w", platform, cid, err)
}
if m.log != nil {
m.log.Debug("channel session bound",
"platform", platform, "channel_id", cid, "session_id", sessionID, "actor_role", actor.Role)
}
return sessionID, nil
}
// channelID derives the stable channel key used for session mapping. Group
// messages key on the group (prefixed so a group never collides with a direct
// channel of the same id); direct messages key on their channel id, falling back
// to the sender id when no explicit channel id is present.
func channelID(msg adapter.UnifiedMessage) string {
if msg.IsGroup() {
if msg.GroupID != "" {
return groupChannelPrefix + msg.GroupID
}
return msg.ChannelID
}
if msg.ChannelID != "" {
return msg.ChannelID
}
return msg.SenderID
}