安卓 2026-10-26 回执关心的场景:新来一条语音消息时,**第一条**帧里有没有
`voice`。此前没有 —— `persistBatchUsers` 先 `persistToMain`/`persistIsolated`
(内部同步 notify 把帧发出去),之后才 `persistMessageVoice` 写列,帧发出那
一刻列还是空串。网关侧的帧投影(上一 commit)读的就是这一列,因此首帧必然
缺 voice,客户端要等重连或下一次 REST 拉取。
修法(A 路的唯一可行形态,理由见下):
- `persistBatchUsers` 的通知改成 `persistVoiceFirstNotify(ref)`:
**INSERT → 写 voice 列 → notify**。语句数不变,只把 notify 从 INSERT 内部
挪到最后;非语音消息(ref 零值)时该包装与 `notifyPersisted` 逐字等价。
- `persist.go` 抽出 `mainRow` / `isolatedRow`(字段清单只写一次)与
`persistIsolatedWith`(可注入通知的变体),`persistToMain`/
`persistIsolated` 的对外行为逐字不变。
为什么不走 B(给 `history.Message` 加 voice 随 INSERT 写):`persistMessageVoice`
是按 id 的 UPDATE,行还不存在时必然 `ErrMessageNotFound` ⇒ 写列**不可能**真正
挪到 INSERT 之前;可行的最小形态就是上面那次换序,无需动 domain 类型、列清单
与 7 条读语句。B 若要做,属于 design/24 §10.5 的独立切片(那时还应删掉这里的
两段式,避免同一事实两处写)。
整链验收(`inbound_voice_gateway_e2e_test.go`,真桥接器 + 真存储 + 真网关 WS):
- 主人私聊语音:第一帧就是 `message_persisted`,带 `voice`,且与
`GET /sessions/{id}/messages` 同一行**逐字相等**;随后的非语音消息帧整键缺席。
- 群聊语音(被点名 ⇒ 走完整回合):第一帧 `channel_persisted` 带 `voice`,
且与渠道日志读面同一行逐字相等。
- 变异实测(把顺序还原成「先广播、后写列」):两条用例都 FAIL,输出即报障症状
(帧里 content 是转写文本,却没有 voice 键)。
顺带把网关侧两处描述旧时序的注释改成新事实。
171 lines
9.7 KiB
Go
171 lines
9.7 KiB
Go
package channelbridge
|
||
|
||
import (
|
||
"context"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
)
|
||
|
||
// 本文件承载 channelbridge 的聊天历史持久化钩子(design/16
|
||
// §4.4):主人的私聊被路由进唯一的 Web 主会话并
|
||
// 打上渠道平台标签,随后广播出去,使 Web 客户端无需刷新
|
||
// 即可镜像。它与 channelbridge.go 分开,好让桥接器的入站
|
||
// 流水线文件保持在模块体积上限之内。
|
||
|
||
// modeChat 是每条 IM 持久化消息写入的行为模式:IM 只承载
|
||
// 聊天文本(design/02 §1 单向回流),因此那里永远不会出现其他模式。
|
||
const modeChat = "chat"
|
||
|
||
// SetOnPersisted 装配(或替换)主人私聊消息持久化进 Web 主会话
|
||
// 之后所调用的传输钩子。组合根中 gateway 在桥接器
|
||
// 之后构建,因此该钩子在 New 之后通过此 setter 挂上;
|
||
// 它可以安全空调用,也可与在途处理
|
||
// 安全并发。
|
||
func (b *Bridge) SetOnPersisted(fn func(history.Message)) {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
b.onPersisted = fn
|
||
}
|
||
|
||
// SetOnRejected 装配(或替换)消息被允许/拒绝黑白名单
|
||
// 丢弃后所调用的钩子(design/16 §4.6 §6)。组合根
|
||
// 把它接进 gateway 审计轨迹。它可以安全空调用,也可与
|
||
// 在途处理安全并发。
|
||
func (b *Bridge) SetOnRejected(fn func(adapter.UnifiedMessage, string)) {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
b.onRejected = fn
|
||
}
|
||
|
||
// notifyPersisted 在装配了传输钩子时,把刚持久化的消息交给它,
|
||
// 使 gateway 能广播 `message_persisted`(Web 聊天)或
|
||
// `channel_persisted`(IM 适配器日志)事件,实现多端实时同步。
|
||
func (b *Bridge) notifyPersisted(msg history.Message) {
|
||
b.mu.RLock()
|
||
fn := b.onPersisted
|
||
b.mu.RUnlock()
|
||
if fn != nil {
|
||
fn(msg)
|
||
}
|
||
}
|
||
|
||
// persistToMain 把一条消息写入 Web 主会话,并显式带上来源平台
|
||
// 与客户端 deviceID,随后通知传输钩子,让 Web 客户端无需刷新即可收到
|
||
// `message_persisted` 事件(design/16 §4.4)。deviceID 是
|
||
// 发送该消息的具体客户端(design/16 §4.5)——例如 IM 的 "weixin-ilink:conf0:wx_uid"。
|
||
// adapterID 是接收该消息的账号适配器注册名
|
||
// (如 "kook:3bef..."),便于管理日志按适配器查询收/发流;
|
||
// Web 来源的消息该字段为空。mood 是该轮角色的心情
|
||
// (design/01 §2 情绪状态机),只挂在助手回复上(design/16 §3.3:
|
||
// 助手消息承载 mode/mood);用户消息与未跟踪的心情传 ""。
|
||
// document 是这条消息携带的文档引用数组(design/24 §2.4、§6.1),
|
||
// 形状即 history.EncodeDocuments 的产物;没有附件时传 ""(那一列因此为
|
||
// 空,读侧 `documents` 键随之省略)。IM 入站的附件与 Web 上传共用这一列,
|
||
// 客户端两侧读到的附件芯片才是同一个东西。
|
||
// image 是这条消息携带的图片(data: URI 或 http(s) URL),写进 DTO 的 `image`
|
||
// 字段(design/16 §4.4);没有图片时传 ""。它**不是** document 列的一部分:
|
||
// 图片走视觉链路,文档走抽取,两者是两条路(design/24 §4.2 末的明确分野)。
|
||
// turnID 是这条消息所属的那**一路** IM 回合的权威标识(design/15 §2.5.9),由
|
||
// 驱动这次回合的桥接器生成(turnid.go):同一次入站回合的用户消息与助手回复传
|
||
// **同一个**值,前端才能按 (sessionId, turnId) 两级分桶。不属于任何回合的消息
|
||
// (主动推送等)传 "",那一列因此留空,行为与加入该列之前逐字相同。
|
||
//
|
||
// 它返回刚落库的完整消息(未装配历史存储、或写库失败时是零值),
|
||
// 使调用方能在回合结束后按 id 把文档抽取结论补进同一列
|
||
// (inbound_documents_persist.go 的两段式)。未装配历史存储时为空操作,
|
||
// 因此桥接器在没有存储的情况下行为不变。
|
||
//
|
||
// 写入本身走 persistMain(与主动推送同一个写入口径),通知走普通通道 notifyPersisted。
|
||
// **主动推送不经过本函数**:它直接调 persistMain,并额外走 notifyProactive 追加一条带
|
||
// 结构化判据的帧(切片 P0b)—— 因此带判据的帧只可能来自主动推送,**普通消息的帧永远
|
||
// 不带它**。
|
||
func (b *Bridge) persistToMain(ctx context.Context, role, content, mood, document, image, platform, deviceID, adapterID, turnID string) history.Message {
|
||
return b.persistMain(ctx, mainRow(role, content, mood, document, image, platform, deviceID, adapterID, turnID), b.notifyPersisted)
|
||
}
|
||
|
||
// mainRow 构造一条写进 Web 主会话的行。它只写一次字段清单:persistToMain 与
|
||
// 入站语音那条「先写列再广播」的路径(coalesce_batch.go 的 persistBatchUsers)
|
||
// 共用它,两处的列集合因此不可能分叉。
|
||
func mainRow(role, content, mood, document, image, platform, deviceID, adapterID, turnID string) history.Message {
|
||
return history.Message{
|
||
SessionID: history.MainSessionID, Role: role, Content: content, Mood: mood, Document: document, Image: image, Platform: platform, DeviceID: deviceID, AdapterID: adapterID, Mode: modeChat, TurnID: turnID,
|
||
}
|
||
}
|
||
|
||
// persistMain 是「写进主会话」的**唯一**写入口径:persistToMain(普通消息)与
|
||
// 主动推送(proactive.go)共用它,因此两条路的存储语义逐字一致 —— 未接线历史存储
|
||
// 时空操作并返回零值、写库失败 WARN 并返回零值(绝不谎称已落库)、成功则返回刚落库的
|
||
// 完整消息。**差别只在 notify**:那是「这条要不要通知实时通道」的接缝,由调用方给出
|
||
// —— 普通消息传 notifyPersisted(`message_persisted` / `channel_persisted`),主动推送
|
||
// 传 notifyProactive(带结构化判据的同一类帧,切片 P0b)。nil 表示本次写入不通知。
|
||
func (b *Bridge) persistMain(ctx context.Context, msg history.Message, notify func(history.Message)) history.Message {
|
||
if b.history == nil {
|
||
return history.Message{}
|
||
}
|
||
full, err := b.history.AppendFull(ctx, msg)
|
||
if err != nil {
|
||
b.log.Warn("persist main session", "err", err, "role", msg.Role, "platform", msg.Platform, "device_id", msg.DeviceID, "adapter", msg.AdapterID)
|
||
return history.Message{}
|
||
}
|
||
if notify != nil {
|
||
notify(full)
|
||
}
|
||
return full
|
||
}
|
||
|
||
// persistIsolated 把一条消息写入受限(群聊/陌生人)会话
|
||
// 自有的平台日志(design/16 §4.6 §4):消息落在隔离的 sess-xxx
|
||
// 会话(不是 Web 主会话)中,并带上渠道平台、客户端
|
||
// deviceID、发送者显示名与账号适配器注册名,便于
|
||
// IM 适配器日志页按适配器配置渲染非主人消息流。
|
||
// 该消息仍不会镜像进 Web 主会话(只有主人私聊
|
||
// 消息才镜像到 Web);它只交给传输钩子,让
|
||
// gateway 广播 `channel_persisted` 事件、IM 适配器日志页无需刷新
|
||
// 即可追加。document 是该消息携带的文档引用数组(design/24 §2.4),
|
||
// 与 persistToMain 同一列、同一形状;没有附件时传 ""。image 是该消息携带的图片
|
||
// (data: URI 或 http(s) URL),同样与 persistToMain 同一列(DTO 的 `image` 字段);
|
||
// 没有图片时传 ""。turnID 与 persistToMain 同义:这次入站回合的权威标识,
|
||
// 不属于任何回合(被静默登记的消息)时传 ""。
|
||
//
|
||
// 它返回刚落库的完整消息(未装配历史存储、或写库失败时是零值),
|
||
// 供调用方在回合结束后补回文档抽取结论。未装配历史存储时为空操作,
|
||
// 因此桥接器在没有存储的情况下行为不变。
|
||
func (b *Bridge) persistIsolated(ctx context.Context, role, content, document, image, deviceID string, sess session.Session, platform, senderName, adapterID, turnID string) history.Message {
|
||
return b.persistIsolatedWith(ctx, isolatedRow(role, content, document, image, deviceID, sess, platform, senderName, adapterID, turnID), b.notifyPersisted)
|
||
}
|
||
|
||
// isolatedRow 构造一条写进受限(群聊/陌生人)会话的行。与 mainRow 同一手法:字段清单
|
||
// 只写一次,persistIsolated 与入站语音那条「先写列再广播」的路径共用。
|
||
func isolatedRow(role, content, document, image, deviceID string, sess session.Session, platform, senderName, adapterID, turnID string) history.Message {
|
||
return history.Message{
|
||
SessionID: sess.SessionID, Role: role, Content: content, Document: document, Image: image, Platform: platform,
|
||
DeviceID: deviceID, SenderName: senderName, AdapterID: adapterID, Mode: modeChat, TurnID: turnID,
|
||
}
|
||
}
|
||
|
||
// persistIsolatedWith 是 persistIsolated 的**可注入通知**变体:写入语义逐字相同,只有
|
||
// 「落库成功后怎么通知」由调用方给出(nil = 不通知)。入站语音用它把「先写 `voice` 列、
|
||
// 再广播」这次换序塞进同一个落库点(见 inbound_voice_copy.go 的 persistVoiceFirstNotify)。
|
||
func (b *Bridge) persistIsolatedWith(ctx context.Context, row history.Message, notify func(history.Message)) history.Message {
|
||
if b.history == nil {
|
||
return history.Message{}
|
||
}
|
||
full, err := b.history.AppendFull(ctx, row)
|
||
if err != nil {
|
||
b.log.Warn("persist isolated session", "err", err, "role", row.Role,
|
||
"session", row.SessionID, "platform", row.Platform, "sender_name", row.SenderName, "adapter", row.AdapterID)
|
||
return history.Message{}
|
||
}
|
||
// 把隔离写入作为 `channel_persisted` 事件广播,让 IM 适配器
|
||
// 日志页实时追加。gateway 按会话区分事件类型:
|
||
// 这不是 Web 主会话,因此 Web 聊天没有它的处理器,
|
||
// 消息本身仍只落在隔离的 sess-xxx 会话(绝不在
|
||
// 主/`web` 会话,design/16 §4.6 §3)。
|
||
if notify != nil {
|
||
notify(full)
|
||
}
|
||
return full
|
||
}
|