feat(channelbridge): 出站语音结论落 voice 列并挂出站 Media(M58)

- voice.Ref 增 SourceTTS 与 FallbackReason,Validate/IsZero/validSource 同步
- 出站成功:INSERT -> 补写 voice 列 -> notify(首帧即带 voice)
- 出站降级:source=tts + status=failed + fallbackReason,正文原样
- 首条气泡挂出站 Media(新开条目,不复制 target.Media)
- gateway historyVoice 投影 fallbackReason
- 新增 outbound_voice.go 与整链 e2e 测试(含变异打红验证时序)
This commit is contained in:
AskaEth committed 2026-10-27 22:08:38 +08:00
1 parent 9f6243a729
commit 28afa11aeb
13 files changed
+755 -44

No files matched your search

@@ -116,6 +116,9 @@ func (b *Bridge) ingestInboundVoiceCopy(ctx context.Context, msg adapter.Unified
// 若先广播后写列,这条语音消息的**第一帧**就永远缺 `voice`,客户端只能等重连或下一次 REST
// 拉取才升级出播放器(安卓 2026-10-26 回执报的正是这个)。
//
// 入站与出站**共用**它:两边都是「结论已经在手里、列还没写」的同一形状(入站是转写结论,
// 出站是合成结论),首帧纪律因此只有一处实现。
//
// 只换顺序、不换代价:仍是 INSERT + 一次 UPDATE(与 design/24 §10.5 的第二选项同一手法,
// 与 document 结论的两段式同族)。非语音消息(ref 零值)时 persistMessageVoice 直接返回,
// 因此本包装与 notifyPersisted 逐字等价 —— 普通消息的时序一个字节都没变。
@@ -126,9 +129,9 @@ func (b *Bridge) persistVoiceFirstNotify(ref voiceref.Ref) func(history.Message)
}
}
// persistMessageVoice 把这条语音的来源事实与副本引用写进刚落库的用户消息 `voice` 列
// (design/24 §10.2 第 5 步)。它由 runInboundTurn 在用户消息落库之后调用,因此
// 「引用 + 三态」最终是同一行里的一份事实,客户端不必自己拼。
// persistMessageVoice 把这条语音的来源事实与副本引用写进刚落库的消息 `voice` 列
// (design/24 §10.2 第 5 步;出站方向同列同形)。它由落库那条路在消息写进去之后调用,
// 因此「引用 + 三态」最终是同一行里的一份事实,客户端不必自己拼。
//
// 三个提前返回都是**正常**情形,不刷日志:这条消息不是语音(零值)、没有落库
// (msg.ID 为空 ⇒ 存储未接或写库失败)、编码后为空串。
@@ -152,7 +155,7 @@ func (b *Bridge) persistMessageVoice(msg history.Message, ref voiceref.Ref) {
if err != nil {
// 编码失败只可能是坏数据(闭集破坏 / 副本字段不同生共死):**绝不**写半截,
// 但也绝不静默 —— 消息本身已经落库,少的是这条语音标记。
b.voiceLogger().Error("inbound voice ref not persisted: encode failed",
b.voiceLogger().Error("voice ref not persisted: encode failed",
"message", msg.ID, "source", ref.Source, "status", ref.Status, "error", err)
return
}
@@ -163,13 +166,13 @@ func (b *Bridge) persistMessageVoice(msg history.Message, ref voiceref.Ref) {
if !ok {
// 历史存储没有 voice 列写入视图(迁移 0020 只加列,不替调用方接写路径):
// 如实报告「没写成」,绝不假装写了。
b.voiceLogger().Warn("inbound voice ref not persisted",
b.voiceLogger().Warn("voice ref not persisted",
"reason", "history store has no voice column path", "message", msg.ID,
"source", ref.Source, "status", ref.Status)
return
}
if err := writer.UpdateMessageVoice(context.Background(), msg.ID, raw); err != nil {
b.voiceLogger().Warn("inbound voice ref update",
b.voiceLogger().Warn("voice ref update",
"err", err, "message", msg.ID, "source", ref.Source, "status", ref.Status)
}
}