复审 r1 的 P1-1 被证实:车道键(a.Name()+ConversationKey,含频道与账号维度)与会话键 (platform \x00 channelID)是两套没有共同来源的判据,于是 kook 同一 guild 的两个 text channel、以及同平台两个账号对同一对端,都会得到「一个 session、两条车道、两个回合并发」 —— 两条 run 共享 sessionID,checkpoint 行 id 互相覆盖 / flow 桶被先收尾者删掉。 - P1-1 车道键与会话键同源:`internal/channel/session` 导出 ChannelKey / Key(channelID 与原缓存键派生**原样**导出,manager 内部改为走同一个 sessionKey,语义一字未改); `laneKey` 现在逐字是 `session.Key(msg)`(主人私聊仍是 `main|web` 这个目的地键), **去掉 a.Name() 维度**。同平台多账号对同一对端因此共用一条车道(它们是同一个 session), 出站路由不受影响:回复取每条消息自己的 platform/ChannelID 与它自己的适配器。 - P2-1 溢出区保序:`spill` 非空后新条目一律追加 spill、不再回写 `ch`,修掉复审实测的 `[m1 m2 m3 m5 m4]` 超车(现为到达顺序)。 - P2-2 口径修正:lane_test 文件头改指 `TestLaneRejectsNewestWhenFull`(语义是「门前拒收 最新、队列不动」);laneKey 注释改写「多账号必须共用一条车道」并写明取舍。 - P2-3 生产可见性:丢弃 WARN 带上 `dropped_stale_total` / `dropped_overflow_total` 累计 计数(不新开端点);主人私聊积压改成按倍率 16/32/64… 告警,不再只响一次。 断言:TestLaneKeyNeverCollidesAcrossSessions(表驱动,含 kook c1/c2@同 guild、 telegram conf0/conf1@同对端两行 + 不同群仍并行的控制行)、 TestLaneOwnerPrivateSpillKeepsArrivalOrder(逐条放行,钉住到达顺序)、 internal/channel/session/keys_test.go(键派生与会话缓存键形状)。
286 lines
12 KiB
Go
286 lines
12 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, actor_role FROM channel_sessions WHERE platform = ? AND channel_id = ?`
|
|
|
|
// updateActorRoleSQL 只改身份车道列(不碰 session_id / created_at):渠道↔会话的
|
|
// 绑定不因身份变化而改变,同一段对话继续落进同一个 session。
|
|
const updateActorRoleSQL = `
|
|
UPDATE channel_sessions SET actor_role = ?, updated_at = ?
|
|
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.
|
|
//
|
|
// 已绑定的渠道若解析出的身份车道与缓存里的不一致(典型:主人刚把该账号加进
|
|
// 白名单并热重载,见 rebindLocked 的说明),本次调用会把车道刷新到会话上——
|
|
// 否则这段对话会一直留在绑定时的旧车道上(陌生人:隔离会话 + 记忆只读)。
|
|
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 := sessionKey(msg.Platform, cid)
|
|
|
|
// 快路径:已绑定且车道与本次解析一致——只读锁即可返回。
|
|
if s, ok := m.lookupCached(key); ok && s.ActorRole == actor.Role {
|
|
return s, nil
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if s, ok := m.cache[key]; ok {
|
|
if s.ActorRole == actor.Role {
|
|
return s, nil
|
|
}
|
|
return m.rebindLocked(ctx, key, s, actor), nil
|
|
}
|
|
|
|
sessionID, err := m.persist(ctx, msg.Platform, cid, actor)
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
|
|
s := sessionWithActor(Session{Platform: msg.Platform, ChannelID: cid, SessionID: sessionID}, actor)
|
|
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 := sessionKey(platform, 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, storedRole string
|
|
if err := m.db.QueryRowContext(ctx, selectSessionSQL, platform, cid).Scan(&sessionID, &storedRole); err != nil {
|
|
return "", fmt.Errorf("session: read back %s/%s: %w", platform, cid, err)
|
|
}
|
|
if storedRole != string(actor.Role) {
|
|
m.syncActorRole(ctx, platform, cid, actor)
|
|
}
|
|
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
|
|
}
|
|
|
|
// sessionWithActor 按本次结构化解析出的 actor 派生会话的身份字段。Platform /
|
|
// ChannelID / SessionID 原样保留——它们描述的是渠道绑定,不是身份:身份变了,
|
|
// 这段对话仍属于同一个 session(绝不因判主结果不同而搬家、换 session id)。
|
|
func sessionWithActor(s Session, actor adapter.Actor) Session {
|
|
s.ActorRole = actor.Role
|
|
s.Owner = actor.IsOwner()
|
|
s.IsGroup = actor.IsGroupChat()
|
|
s.MemWritable = actor.IsOwner() && !actor.IsGroupChat()
|
|
return s
|
|
}
|
|
|
|
// rebindLocked 用本次解析出的 actor 刷新一个已绑定会话的身份车道,并把新车道写回
|
|
// channel_sessions。调用者必须持有写锁。
|
|
//
|
|
// 为什么必须刷新(真机事故 2026-10-05,QQ 私聊被当陌生人):主人把 QQ 号经面板加进
|
|
// 白名单、热重载成功后,ResolveActor 立刻命中 owner,但这条渠道**早已在它是陌生人
|
|
// 时绑定过**。旧实现直接返回缓存里的旧车道,于是主人私聊仍按陌生人处理——不镜像进
|
|
// Web 主会话(前端看不到)、MemWritable 恒 false(记忆只读的隔离上下文),要等进程
|
|
// 重启才恢复。刷新后新车道立即对后续消息生效,且与 owner.yaml / channels.json 的
|
|
// 热重载承诺(design/11 §9)一致。
|
|
//
|
|
// 车道只由 ResolveActor 的结构化判定(白名单 + 每渠道 adminIds)给出:本函数不读
|
|
// 消息正文,也不看适配器上报的 role,因此刷新不引入任何「靠提示词判主」的通道。
|
|
func (m *SessionManager) rebindLocked(ctx context.Context, key string, s Session, actor adapter.Actor) Session {
|
|
from := s.ActorRole
|
|
s = sessionWithActor(s, actor)
|
|
m.cache[key] = s
|
|
m.syncActorRole(ctx, s.Platform, s.ChannelID, actor)
|
|
if m.log != nil {
|
|
m.log.Debug("channel session identity lane updated",
|
|
"platform", s.Platform, "channel_id", s.ChannelID, "session_id", s.SessionID,
|
|
"from", from, "to", actor.Role)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// syncActorRole 把身份车道写回绑定表。落盘只为让车道跨重启存活,失败不能把消息整条
|
|
// 丢掉:内存里的车道已经是这次结构化判定的结论,如实记一条 WARN,下一条消息会再试。
|
|
func (m *SessionManager) syncActorRole(ctx context.Context, platform, cid string, actor adapter.Actor) {
|
|
if err := m.updateActorRole(ctx, platform, cid, actor.Role); err != nil && m.log != nil {
|
|
m.log.Warn("channel session actor role persist failed",
|
|
"platform", platform, "channel_id", cid, "err", err)
|
|
}
|
|
}
|
|
|
|
// updateActorRole 把刷新后的身份车道写回绑定表。
|
|
func (m *SessionManager) updateActorRole(ctx context.Context, platform, cid string, role adapter.Role) error {
|
|
if _, err := m.db.ExecContext(ctx, updateActorRoleSQL, string(role), formatSessionTime(time.Now()), platform, cid); err != nil {
|
|
return fmt.Errorf("session: update actor role %s/%s: %w", platform, cid, err)
|
|
}
|
|
return nil
|
|
}
|