Files
Cyrene_v2/internal/channel/session/manager.go
T
AskaEth fbbfcc95ee fix(persist,channel): 同类定宽时间戳修复 — checkpoint 恢复与渠道会话列表排序
- persist: formatTime 定宽;loadLatestSQL 补 rowid 兜底;New 时归一化历史行(避免 LoadLatest 挑到更旧检查点)
- channel/session: 写入定宽;listSessionSQL 补 rowid 兜底;核实无 INSERT 吃 DDL 默认值,故只加注释不改默认值
- 新增回归测试并做变异验证(改回 RFC3339Nano 即 FAIL)
2026-10-03 16:08:51 +08:00

220 lines
8.1 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.
//
// The created_at/updated_at DEFAULT expressions are a fallback only: every write
// path (insertOrIgnoreSessionSQL) supplies explicit fixed-width UTC timestamps
// via formatSessionTime, so the 3-digit-millisecond strftime defaults are never
// exercised and no row mixes timestamp widths.
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. created_at is fixed-width UTC text (see sessionTimeLayout),
// so its text order is its time order; rowid breaks created_at ties so mappings
// written in the same coarse clock tick still list in a stable order
// (channel_sessions is an ordinary rowid table, not WITHOUT ROWID).
const listSessionSQL = `
SELECT platform, channel_id, session_id, actor_role FROM channel_sessions
ORDER BY created_at DESC, rowid 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 := formatSessionTime(time.Now())
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
}