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

+22 -6
View File
@@ -9,8 +9,12 @@ package gateway
//
// 四条硬口径:
//
// - **🎤 判据 = `voice` 键存在**(契约明写):非语音消息整键缺席(`omitempty` 的
// 指针 nil),**不是** `{}` 也不是 `null`。客户端绝不从正文里认 `[语音]` 字样。
// - **`voice` 键存在 = 这条消息有语音事实**:非语音消息整键缺席(`omitempty` 的指针
// nil),**不是** `{}` 也不是 `null`。客户端绝不从正文里认 `[语音]` 字样。
// ⚠️ 出站合成降级(source=tts、status=failed + fallbackReason)同样**有键**,但
// 没有可播副本(name/mime/size/url 四键一起缺席)⇒ 客户端按 `status` 分支:只挂一个
// 「本来想用语音」的小标、**不**画播放器,也不打「来自语音」的 🎤(正文是文本,
// 不是转写文本)。契约文档对 🎤 判据的措辞由塔统一收口。
// - **副本四键同生共死**:`name`/`mime`/`size`/`url` 只在**真的有副本**时一起出现
// (`Ref.Validate` 在解码时已保证,本文件再用 `HasCopy` 复核一次),绝不出现
// 「`url` 指着一个不存在的 `name`」。
@@ -29,7 +33,8 @@ package gateway
// ⚠️ 实时帧那条路对**时序**有依赖(已由 M44 的跨层修复保证):`voice` 是**读列**得来的,
// 所以帧必须在 `voice` 列写好之后才发出。渠道桥原来「先 notify、后写列」,导致入站语音的
// **第一帧**读不到 voice;M44 把它改成「INSERT → 写 voice 列 → notify」(见 channelbridge
// 的 persistBatchUsers / persistVoiceFirstNotify),本文件因此无需再区别对待实时帧与读面。
// 的 persistBatchUsers / persistVoiceFirstNotify),出站语音(outbound_voice.go)走的是
// 同一条顺序纪律。本文件因此无需再区别对待实时帧与读面。
import (
"context"
@@ -45,10 +50,12 @@ import (
// `source` / `status` 恒在(属于闭集,见 `voice.Source` / `voice.Status`);其余
// 全部 `omitempty` —— 给不出那一项时它**缺席**,绝不用 `0` 或空串编一个值。
type historyVoice struct {
// Source ∈ asr(服务端识别,含云端)| platform(渠道平台自带转写)。
// Source ∈ asr(服务端识别,含云端)| platform(渠道平台自带转写)|
// tts(我们自己合成的出站语音)。
Source string `json:"source"`
// Status ∈ transcribed(正文即转写文本)| empty(转写成功但没识别出文字)|
// failed(没拿到转写文本,正文是一行中文诚实提示)。闭集三态。
// Status ∈ transcribed(正文即转写文本 / 出站合成成功)| empty(转写成功但没识别出
// 文字)| failed(没拿到转写文本,正文是一行中文诚实提示;出站方向 = 合成降级)。
// 闭集三态。
Status string `json:"status"`
// Name 是音频副本的存储名;无副本时**整键缺席**(与 mime/size/url 同生共死)。
Name string `json:"name,omitempty"`
@@ -58,6 +65,10 @@ type historyVoice struct {
Size int64 `json:"size,omitempty"`
// DurationMs 只有渠道如实给出、或转码产物可确证时才有;读不出来就缺席,绝不估算。
DurationMs int64 `json:"durationMs,omitempty"`
// FallbackReason 只在**出站合成降级**时出现(source=tts、status=failed):说明这条
// 消息为什么没能变成语音(能力未接线 / 预算超时 / 合成为空)。正文此时照发文本,
// 客户端据 status=failed 只挂一个小标,**绝不**把它当成可播气泡。
FallbackReason string `json:"fallbackReason,omitempty"`
// URL 是服务端相对路径 `/api/v1/media/<name>`(唯一派生字段,由 MediaPath 一处拼出)。
URL string `json:"url,omitempty"`
}
@@ -148,6 +159,11 @@ func historyVoiceOf(ref voiceref.Ref) *historyVoice {
return nil
}
out := &historyVoice{Source: string(ref.Source), Status: string(ref.Status)}
// 降级原因只在失败态下发(`Ref.Validate` 已保证它不会挂在其它状态上):客户端据此
// 显示「这条本来想用语音」的小标,正文仍是原文本。
if ref.Status == voiceref.StatusFailed {
out.FallbackReason = ref.FallbackReason
}
if ref.HasCopy() {
// 四键同生共死:有副本时 name/mime/size/url 一起在,没有时一起缺席。
out.Name, out.MIME, out.Size = ref.Name, ref.MIME, ref.Size