fix(session,qq-obv11): 修「判主好了、会话车道没跟上」(QQ 私聊没镜像进主会话)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

真因(agent-221 用三条独立证据推翻了「SenderID 为空」的假设):ev.UserID 有值;
owner.yaml 的 qq 条目是 10:37:57 才加的(此前那几条判陌生人属**正确的 fail-closed**);
而热重载成功后仍不对 —— 因为 internal/channel/session/manager.go 的 GetOrCreate
对**已绑定的渠道直接返回缓存会话**:判主门槛用新 actor 会过(所以她会回话),
但**车道用的是缓存的旧会话**(Owner=false ⇒ 不镜像进 Web 主会话、MemWritable=false、
落盘 actor_role 永不更新)。

修:缓存命中但 ActorRole 与本次解析不一致 ⇒ rebindLocked 刷新车道 + 同步落盘
(updateActorRoleSQL);persist 的 INSERT OR IGNORE 后读回落盘车道、不一致就地纠正
(覆盖上一轮进程留下的旧行);落盘失败只 WARN(不因记账失败丢主人消息);
**身份变化不搬家、不换 session id**;车道只来自 ResolveActor 的结构化白名单判定。

QQ 适配器(防御加固,非本次真机 bug 的修复):senderIDOf 兜底链 顶层 user_id →
sender.user_id;并用完整论证钉住「**群聊绝不能用 ChannelID 兜底**」(否则群里任何人
可顶群号冒充主人);顺手体检:自己发的消息(post_type=message_sent)与 meta_event
已被 handlePayload 过滤。为守 ≤500 行把 id/字符串小工具拆到 ids.go。

测试:session/ownerlane_test + qq/ownerlane_test 新增用例(含「群号等于主人号时
陌生人不得借群号冒充」「群聊发送者 id 缺失时 SenderID 必须为空而非群号」「白名单
热重载后车道立刻刷新且 session id 不变」「白名单移除后立即退回,不是单向提权」
「跨进程继承的旧行被就地纠正」)。
变异 5 条(去 sender.user_id 兜底 / 群聊用群号当 SenderID / 命中却判 unknown /
还原缓存直返 / 去掉旧行纠正),每条先确认可编译、贴红行、还原复绿(md5 逐字节校验)。
This commit is contained in:
AskaEth committed 2026-10-05 10:53:05 +08:00
1 parent b16ab2c5e8
commit d532eb3fcd
5 files changed
+494 -38

No files matched your search

+80 -14
View File
@@ -44,7 +44,13 @@ INSERT OR IGNORE INTO channel_sessions
VALUES (?, ?, ?, ?, ?, ?)`
const selectSessionSQL = `
SELECT session_id FROM channel_sessions WHERE platform = ? AND channel_id = ?`
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
@@ -94,6 +100,10 @@ func New(ctx context.Context, db *sql.DB, log *slog.Logger) (*SessionManager, er
// 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)
@@ -102,14 +112,18 @@ func (m *SessionManager) GetOrCreate(ctx context.Context, msg adapter.UnifiedMes
}
key := msg.Platform + "\x00" + cid
if s, ok := m.lookupCached(key); ok {
// 快路径:已绑定且车道与本次解析一致——只读锁即可返回。
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 {
return s, nil
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)
@@ -117,15 +131,7 @@ func (m *SessionManager) GetOrCreate(ctx context.Context, msg adapter.UnifiedMes
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(),
}
s := sessionWithActor(Session{Platform: msg.Platform, ChannelID: cid, SessionID: sessionID}, actor)
m.cache[key] = s
return s, nil
}
@@ -185,15 +191,22 @@ func (m *SessionManager) lookupCached(key string) (Session, bool) {
// 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 {
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)
@@ -217,3 +230,56 @@ func channelID(msg adapter.UnifiedMessage) string {
}
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
}
+179
View File
@@ -5,9 +5,12 @@ package session
import (
"context"
"database/sql"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
@@ -115,3 +118,179 @@ func TestOwnerFrameMemWritable(t *testing.T) {
groupSess.Owner, groupSess.MemWritable, groupSess.IsGroup)
}
}
// TestGetOrCreateCorrectsInheritedStaleRole 覆盖跨进程的同一件事:上一轮进程把这条
// 渠道按陌生人落盘(那时账号还没进白名单),重启后新进程首次入站必须把落盘车道一并
// 纠正——新进程的缓存是空的,但 channel_sessions 里的旧车道不会被 INSERT OR IGNORE
// 覆盖,不纠正的话面板/诊断会一直读到旧识别。
func TestGetOrCreateCorrectsInheritedStaleRole(t *testing.T) {
t.Parallel()
ctx := context.Background()
handle, err := db.Open(ctx, ":memory:", nil)
if err != nil {
t.Fatalf("db.Open = %v", err)
}
t.Cleanup(func() { _ = handle.Close() })
// 旧进程:账号还不在白名单里,落盘陌生人车道。
stale, err := New(ctx, handle, nil)
if err != nil {
t.Fatalf("New#1 = %v", err)
}
direct := directMsg("o-100")
direct.Sender = adapter.Actor{Role: adapter.RoleUnknown}
old, err := stale.GetOrCreate(ctx, direct, &user.OwnerProfile{ID: "own-1", DisplayName: "Owner"})
if err != nil {
t.Fatalf("GetOrCreate(旧进程) = %v", err)
}
if got := persistedRole(t, handle, old.Platform, old.ChannelID); got == string(adapter.RoleOwner) {
t.Fatalf("前置条件不成立:旧进程不该落盘 owner 车道(落盘 = %q)", got)
}
// 新进程:同一张表、空缓存,按当前白名单重建这条渠道。
fresh, err := New(ctx, handle, nil)
if err != nil {
t.Fatalf("New#2 = %v", err)
}
reborn, err := fresh.GetOrCreate(ctx, direct, testOwner())
if err != nil {
t.Fatalf("GetOrCreate(新进程) = %v", err)
}
if !reborn.Owner || !reborn.MemWritable || reborn.ActorRole != adapter.RoleOwner {
t.Fatalf("新进程首帧车道 = %+v, want owner 车道", reborn)
}
if reborn.SessionID != old.SessionID {
t.Errorf("渠道↔会话绑定丢了: %q != %q", reborn.SessionID, old.SessionID)
}
if got := persistedRole(t, handle, reborn.Platform, reborn.ChannelID); got != string(adapter.RoleOwner) {
t.Errorf("落盘车道 = %q, want %q(旧进程留下的旧识别必须被纠正)", got, adapter.RoleOwner)
}
}
// persistedRole 读取 channel_sessions 里落盘的身份车道。
func persistedRole(t *testing.T, handle *sql.DB, platform, cid string) string {
t.Helper()
var role string
if err := handle.QueryRowContext(context.Background(),
`SELECT actor_role FROM channel_sessions WHERE platform = ? AND channel_id = ?`,
platform, cid).Scan(&role); err != nil {
t.Fatalf("读取 channel_sessions(%s/%s) = %v", platform, cid, err)
}
return role
}
// TestGetOrCreateRefreshesLaneAfterWhitelistHotReload 覆盖真机事故(2026-10-05「QQ 私聊
// 被当陌生人」):一段渠道在**还不是主人**时先绑定过(缓存与 channel_sessions 都是陌生人
// 车道),之后主人把它加进白名单并热重载(design/11 §9)。此后该渠道的后续消息必须立刻
// 走新车道——只刷新判主门槛是不够的:会话若继续用绑定时的旧车道,主人私聊仍不镜像进
// Web 主会话(前端看不到)、MemWritable 恒 false(记忆只读的隔离上下文),要等进程重启。
//
// 反向也必须在场:白名单里**移除**该账号后,同一条渠道必须立刻退回陌生人车道(fail-closed),
// 刷新因此不是单向提权。
func TestGetOrCreateRefreshesLaneAfterWhitelistHotReload(t *testing.T) {
t.Parallel()
ctx := context.Background()
m, handle := newTestManager(t)
direct := directMsg("o-100")
group := groupMsg("o-100")
// OneBot 私聊帧不带 sender.role,适配器因此上报 unknown(真机的形状);这里照抄,
// 使「陌生人车道的初始值」与真机一致。
direct.Sender = adapter.Actor{Role: adapter.RoleUnknown}
// 活档案:与 gateway 的 hotSwapOwnerProfile 走同一条路径(同一个指针 + Replace)。
// 初始没有这个账号 —— 先让两条渠道以陌生人车道绑定(真机的初始状态)。
live := &user.OwnerProfile{ID: "own-1", DisplayName: "Owner"}
firstDirect, firstGroup := bindStrangerLanes(t, ctx, m, live, direct, group)
// 面板保存 owner.yaml → 白名单新增这个账号。
live.Replace(testOwner())
secondDirect, err := m.GetOrCreate(ctx, direct, live)
if err != nil {
t.Fatalf("GetOrCreate(热重载后私聊) = %v", err)
}
if !secondDirect.Owner || !secondDirect.MemWritable || secondDirect.ActorRole != adapter.RoleOwner {
t.Fatalf("热重载后私聊车道 = %+v, want owner 车道(否则主人私聊仍被隔离、前端看不到)", secondDirect)
}
if secondDirect.SessionID != firstDirect.SessionID {
t.Errorf("身份变化把渠道搬了家: session %q != %q", secondDirect.SessionID, firstDirect.SessionID)
}
if got := persistedRole(t, handle, secondDirect.Platform, secondDirect.ChannelID); got != string(adapter.RoleOwner) {
t.Errorf("channel_sessions.actor_role = %q, want %q(否则重启后又变回陌生人)", got, adapter.RoleOwner)
}
if !listedLane(t, m, secondDirect) {
t.Error("面板列出的会话仍是旧车道(List 未反映刷新后的身份)")
}
// 群聊:车道刷新为 owner,但**隔离不变**(群聊永远是 group_member、记忆只读)。
secondGroup, err := m.GetOrCreate(ctx, group, live)
if err != nil {
t.Fatalf("GetOrCreate(热重载后群聊) = %v", err)
}
if !secondGroup.Owner || secondGroup.MemWritable || !secondGroup.IsGroup {
t.Errorf("热重载后群聊车道 Owner=%v MemWritable=%v IsGroup=%v, want true/false/true",
secondGroup.Owner, secondGroup.MemWritable, secondGroup.IsGroup)
}
if got := secondGroup.Env(); got != restrict.ActorGroupMember {
t.Errorf("群聊 Env() = %q, want group_member", got)
}
if secondGroup.SessionID != firstGroup.SessionID {
t.Errorf("群聊 session 变了: %q != %q", secondGroup.SessionID, firstGroup.SessionID)
}
// 反向:白名单里移除该账号 ⇒ 同一条渠道立刻退回陌生人车道(不得单向提权)。
live.Replace(&user.OwnerProfile{ID: "own-1", DisplayName: "Owner"})
revoked, err := m.GetOrCreate(ctx, direct, live)
if err != nil {
t.Fatalf("GetOrCreate(白名单移除后) = %v", err)
}
if revoked.Owner || revoked.MemWritable || revoked.ActorRole == adapter.RoleOwner {
t.Errorf("白名单移除后车道 = %+v, want 退回陌生人车道", revoked)
}
if revoked.SessionID != firstDirect.SessionID {
t.Errorf("撤销判主把渠道搬了家: %q != %q", revoked.SessionID, firstDirect.SessionID)
}
if got := persistedRole(t, handle, revoked.Platform, revoked.ChannelID); got == string(adapter.RoleOwner) {
t.Errorf("channel_sessions.actor_role 仍是 owner, want 非主人(撤销未落盘)")
}
}
// bindStrangerLanes 在空白名单下把私聊与群聊两条渠道先绑定成陌生人车道并断言结果,
// 返回两个会话。这就是真机上的初始状态:账号还没进白名单,渠道却已经按陌生人落库并进缓存。
func bindStrangerLanes(t *testing.T, ctx context.Context, m *SessionManager,
live *user.OwnerProfile, direct, group adapter.UnifiedMessage) (Session, Session) {
t.Helper()
firstDirect, err := m.GetOrCreate(ctx, direct, live)
if err != nil {
t.Fatalf("GetOrCreate(白名单外私聊) = %v", err)
}
if firstDirect.Owner || firstDirect.MemWritable || firstDirect.ActorRole != adapter.RoleUnknown {
t.Fatalf("白名单外私聊车道 = %+v, want 陌生人车道", firstDirect)
}
firstGroup, err := m.GetOrCreate(ctx, group, live)
if err != nil {
t.Fatalf("GetOrCreate(白名单外群聊) = %v", err)
}
if firstGroup.Owner || firstGroup.MemWritable || !firstGroup.IsGroup {
t.Fatalf("白名单外群聊车道 = %+v, want 非主人/只读/群聊", firstGroup)
}
return firstDirect, firstGroup
}
// listedLane 报告 m.List()(面板/诊断读的那一份)里该渠道是否已经是 owner 车道。
func listedLane(t *testing.T, m *SessionManager, want Session) bool {
t.Helper()
got, err := m.List(context.Background())
if err != nil {
t.Fatalf("List() = %v", err)
}
for _, s := range got {
if s.Platform == want.Platform && s.ChannelID == want.ChannelID {
return s.Owner && s.ActorRole == adapter.RoleOwner
}
}
t.Fatalf("List() 里找不到 %s/%s", want.Platform, want.ChannelID)
return false
}