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

+43
View File
@@ -31,6 +31,14 @@ func TestEncodeDecodeRoundTrip(t *testing.T) {
Source: SourceASR, Status: StatusEmpty, Name: "0123456789abcdef0123456789abcdef.mp3",
MIME: "audio/mpeg", Size: 4321,
}},
{"出站合成成功 + 可播副本", Ref{
Source: SourceTTS, Status: StatusTranscribed,
Name: "fedcba9876543210fedcba9876543210.ogg", MIME: "audio/ogg",
Size: 8192, DurationMs: 1500,
}},
{"出站合成降级(正文照发文本)", Ref{
Source: SourceTTS, Status: StatusFailed, FallbackReason: "budget_exceeded",
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -119,6 +127,11 @@ func TestEncodeRejectsBrokenRef(t *testing.T) {
{"有 name 无 mime", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", Size: 12}},
{"有 name 无 size", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", MIME: "audio/ogg"}},
{"时长为负", Ref{Source: SourceASR, Status: StatusTranscribed, DurationMs: -1}},
{"降级原因挂在成功态", Ref{
Source: SourceTTS, Status: StatusTranscribed,
Name: "x.ogg", MIME: "audio/ogg", Size: 12, FallbackReason: "budget_exceeded",
}},
{"只有降级原因", Ref{FallbackReason: "budget_exceeded"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -149,6 +162,7 @@ func TestDecodeRejectsGarbage(t *testing.T) {
{"闭集外的来源", `{"source":"cloud","status":"transcribed"}`},
{"闭集外的状态", `{"source":"asr","status":"done"}`},
{"副本字段不同生共死", `{"source":"asr","status":"transcribed","size":9}`},
{"降级原因挂在成功态", `{"source":"tts","status":"transcribed","fallbackReason":"x"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -164,6 +178,33 @@ func TestDecodeRejectsGarbage(t *testing.T) {
}
}
// TestOutboundTTSJSONShape 把**出站**两种落库形状钉死(design/25 §7.1):
// 成功 = source/status/name/mime/size(+ 可选 durationMs);降级 = source/status +
// fallbackReason,**没有 name/url**(正文照发文本,客户端据此只挂一个小标)。
func TestOutboundTTSJSONShape(t *testing.T) {
t.Parallel()
raw, err := Encode(Ref{
Source: SourceTTS, Status: StatusTranscribed,
Name: "fedcba9876543210fedcba9876543210.ogg", MIME: "audio/ogg", Size: 8192,
})
if err != nil {
t.Fatalf("Encode(成功): %v", err)
}
want := `{"source":"tts","status":"transcribed","name":"fedcba9876543210fedcba9876543210.ogg",` +
`"mime":"audio/ogg","size":8192}`
if raw != want {
t.Fatalf("出站成功 JSON = %s\n want %s", raw, want)
}
raw, err = Encode(Ref{Source: SourceTTS, Status: StatusFailed, FallbackReason: "budget_exceeded"})
if err != nil {
t.Fatalf("Encode(降级): %v", err)
}
if want := `{"source":"tts","status":"failed","fallbackReason":"budget_exceeded"}`; raw != want {
t.Fatalf("出站降级 JSON = %s\n want %s", raw, want)
}
}
// TestRefIsVoiceAndHasCopy 钉住两个判据的边界:
// IsVoice = 来源与状态都在闭集内(= 这条消息来自一条语音);
// HasCopy = 有 name(name/mime/size 同生共死,因此 name 是代表)。
@@ -178,6 +219,8 @@ func TestRefIsVoiceAndHasCopy(t *testing.T) {
{"零值(不是语音消息)", Ref{}, false, false},
{"平台转写无副本", Ref{Source: SourcePlatform, Status: StatusTranscribed}, true, false},
{"有副本", Ref{Source: SourceASR, Status: StatusFailed, Name: "n.wav", MIME: "audio/wav", Size: 4}, true, true},
{"出站合成成功", Ref{Source: SourceTTS, Status: StatusTranscribed, Name: "n.ogg", MIME: "audio/ogg", Size: 4}, true, true},
{"出站降级(算语音消息,但无可播副本)", Ref{Source: SourceTTS, Status: StatusFailed, FallbackReason: "no_capability"}, true, false},
{"只有来源(半截,不算语音)", Ref{Source: SourceASR}, false, false},
}
for _, tc := range cases {