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:
1 parent
9f6243a729
commit
28afa11aeb
13 files changed
+755
-44
No files matched your search
@@ -32,6 +32,9 @@ const (
|
||||
// SourcePlatform 表示渠道平台自带转写(例如微信 iLink 的 `voice_item.text`):
|
||||
// 平台把语音转成文字后送达,我们**不调 ASR**(不重复付费)。
|
||||
SourcePlatform Source = "platform"
|
||||
// SourceTTS 表示正文是我们自己合成的语音(出站方向,design/25 §7.1):成功时
|
||||
// Name 指向那份可播副本,降级时 Status=failed 且正文照发文本、只带降级原因。
|
||||
SourceTTS Source = "tts"
|
||||
)
|
||||
|
||||
// Status 是转写结果的**三态闭集**(design/24 §10.4)。客户端按它分支,**绝不按正文
|
||||
@@ -39,7 +42,8 @@ const (
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// StatusTranscribed 表示正文即转写文本(真转写,或平台自带转写)。
|
||||
// StatusTranscribed 表示正文即转写文本(真转写,或平台自带转写);出站方向表示
|
||||
// **合成成功**(正文即这段语音的文字内容,Name 指向可播副本)。
|
||||
StatusTranscribed Status = "transcribed"
|
||||
// StatusEmpty 表示转写成功但没识别出文字(静音 / 太短 / 全是噪声)——是服务正常
|
||||
// 的空结果,**不是**故障。
|
||||
@@ -85,6 +89,10 @@ type Ref struct {
|
||||
// DurationMs 是时长,**只有渠道如实给出或转码产物可确证时**才 >0;读不出来就是 0
|
||||
// (编码时该键缺席)。**绝不估算**(design/24 §10.4)。
|
||||
DurationMs int64 `json:"durationMs,omitempty"`
|
||||
// FallbackReason 只在**出站合成降级**时非空(Source=tts、Status=failed):说明为什么
|
||||
// 这条消息没能变成语音(能力未接线 / 预算超时 / 合成为空)。入站转写不使用它 ——
|
||||
// 入站的失败原因由正文那行诚实提示承载。
|
||||
FallbackReason string `json:"fallbackReason,omitempty"`
|
||||
}
|
||||
|
||||
// IsVoice 报告这条事实是否代表「这条消息来自一条语音消息」(= 来源与状态都在闭集内)。
|
||||
@@ -110,6 +118,11 @@ func (r Ref) Validate() error {
|
||||
if r.DurationMs < 0 {
|
||||
return fmt.Errorf("%w: negative durationMs %d", ErrBadRef, r.DurationMs)
|
||||
}
|
||||
// 降级原因只属于失败态:成功还带原因会让人以为「其实降级了」,客户端也就无从按
|
||||
// status 单点分支(design/24 §10.4 的「客户端绝不按文案分支」同一条纪律)。
|
||||
if r.FallbackReason != "" && r.Status != StatusFailed {
|
||||
return fmt.Errorf("%w: fallbackReason with status %q", ErrBadRef, r.Status)
|
||||
}
|
||||
switch {
|
||||
case r.Name == "" && (r.MIME != "" || r.Size != 0):
|
||||
return fmt.Errorf("%w: mime/size without name", ErrBadRef)
|
||||
@@ -124,7 +137,7 @@ func (r Ref) Validate() error {
|
||||
// IsZero 报告这条事实是零值(= 这条消息不是语音消息)。
|
||||
func (r Ref) IsZero() bool {
|
||||
return r.Source == "" && r.Status == "" && r.Name == "" && r.MIME == "" &&
|
||||
r.Size == 0 && r.DurationMs == 0
|
||||
r.Size == 0 && r.DurationMs == 0 && r.FallbackReason == ""
|
||||
}
|
||||
|
||||
// Encode 把一条语音事实编码成落库用的 JSON 单对象。零值返回 ""(⇒ 列里空串 ⇒ DTO 里
|
||||
@@ -167,7 +180,9 @@ func Decode(raw string) (Ref, error) {
|
||||
}
|
||||
|
||||
// validSource / validStatus 是闭集判定,只此一处(编码、解码与渲染共用)。
|
||||
func validSource(s Source) bool { return s == SourceASR || s == SourcePlatform }
|
||||
func validSource(s Source) bool {
|
||||
return s == SourceASR || s == SourcePlatform || s == SourceTTS
|
||||
}
|
||||
|
||||
func validStatus(s Status) bool {
|
||||
return s == StatusTranscribed || s == StatusEmpty || s == StatusFailed
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user