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

+9 -24
View File
@@ -82,7 +82,7 @@ func parseEvent(payload []byte) (*obEvent, error) {
func (a *Adapter) toUnified(ev *obEvent, raw []byte) adapter.UnifiedMessage {
text, media, mentions, replyID, replyText, hasText := a.parseMessage(ev)
senderID := formatID(ev.UserID)
senderID := senderIDOf(ev)
senderName := "unknown"
role := adapter.RoleUnknown
if ev.Sender != nil {
@@ -112,6 +112,11 @@ func (a *Adapter) toUnified(ev *obEvent, raw []byte) adapter.UnifiedMessage {
switch ev.MessageType {
case "group":
// 群聊的渠道键是**群号**,发送者仍是 senderID(上一步解析):两者绝不可互换。
// 用群号当 SenderID 会让群里任何人都顶着群号去撞白名单(冒充主人)。事件里
// 没有第二个群号来源,group_id 缺失时 ChannelID 就如实留空——下游
// session.channelID() 会以 ErrInvalidChannel 拒掉这条消息(fail-closed),
// 绝不退回用发送者号去编一个群会话键。
gid := formatID(ev.GroupID)
msg.ChannelType = adapter.ChannelGroup
msg.ChannelID = gid
@@ -125,6 +130,9 @@ func (a *Adapter) toUnified(ev *obEvent, raw []byte) adapter.UnifiedMessage {
}
case "private":
msg.ChannelType = adapter.ChannelDirect
// 私聊的对端**就是**发送者,所以这里的 ChannelID 可以安全地回落到 SenderID
// (对端号 == 发送者号)。群聊绝不允许这样回落:群号不是发送者,用 ChannelID
// 充当 SenderID 会让**任何群成员**都顶着群号去撞白名单——见 group 分支。
msg.ChannelID = senderID
}
return msg
@@ -443,26 +451,3 @@ func segStr(d map[string]any, key string) string {
return ""
}
}
func formatID(id int64) string {
if id == 0 {
return ""
}
return strconv.FormatInt(id, 10)
}
func parseInt64(s string) (int64, error) {
if s == "" {
return 0, fmt.Errorf("empty id")
}
return strconv.ParseInt(s, 10, 64)
}
func firstNonEmpty(ss ...string) string {
for _, s := range ss {
if s != "" {
return s
}
}
return ""
}