fix(channelbridge): M44 入站语音先写 voice 列再广播 ⇒ 首帧就带 voice
安卓 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 键)。
顺带把网关侧两处描述旧时序的注释改成新事实。
This commit is contained in:
1 parent
5717735da9
commit
b955a27479
6 files changed
+416
-19
No files matched your search
@@ -82,9 +82,16 @@ func (b *Bridge) notifyPersisted(msg history.Message) {
|
||||
// 结构化判据的帧(切片 P0b)—— 因此带判据的帧只可能来自主动推送,**普通消息的帧永远
|
||||
// 不带它**。
|
||||
func (b *Bridge) persistToMain(ctx context.Context, role, content, mood, document, image, platform, deviceID, adapterID, turnID string) history.Message {
|
||||
return b.persistMain(ctx, 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,
|
||||
}, b.notifyPersisted)
|
||||
}
|
||||
}
|
||||
|
||||
// persistMain 是「写进主会话」的**唯一**写入口径:persistToMain(普通消息)与
|
||||
@@ -126,16 +133,29 @@ func (b *Bridge) persistMain(ctx context.Context, msg history.Message, notify fu
|
||||
// 供调用方在回合结束后补回文档抽取结论。未装配历史存储时为空操作,
|
||||
// 因此桥接器在没有存储的情况下行为不变。
|
||||
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, 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,
|
||||
})
|
||||
full, err := b.history.AppendFull(ctx, row)
|
||||
if err != nil {
|
||||
b.log.Warn("persist isolated session", "err", err, "role", role,
|
||||
"session", sess.SessionID, "platform", platform, "sender_name", senderName, "adapter", adapterID)
|
||||
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 适配器
|
||||
@@ -143,6 +163,8 @@ func (b *Bridge) persistIsolated(ctx context.Context, role, content, document, i
|
||||
// 这不是 Web 主会话,因此 Web 聊天没有它的处理器,
|
||||
// 消息本身仍只落在隔离的 sess-xxx 会话(绝不在
|
||||
// 主/`web` 会话,design/16 §4.6 §3)。
|
||||
b.notifyPersisted(full)
|
||||
if notify != nil {
|
||||
notify(full)
|
||||
}
|
||||
return full
|
||||
}
|
||||
Reference in new issue
Block a user