fix(session,qq-obv11): 修「判主好了、会话车道没跟上」(QQ 私聊没镜像进主会话)
真因(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:
1 parent
b16ab2c5e8
commit
d532eb3fcd
5 files changed
+494
-38
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user