package channelbridge // 本文件锁定**入站语音副本的落盘与 `voice` 列回填**(design/24 §10.2 第 4/5 步; // design/25 §7.1 第 ④ 步;切片 CODE-VOICE)。钉住四件事: // // 1. 可播格式(ogg/wav/mp3…)**原样**落副本,一个字节不改、**不碰转码**; // 2. 不可播格式(QQ 的 AMR)在落盘**之前**转码(复用 internal/capability/transcode), // 落的是可播副本,存储名带的是**目标扩展名**(不是渠道原文件名); // 3. 转码不可用(未接线 / 没这条路 / 越限 / 解码失败…)⇒ **不落副本、不打假 url**, // 只留一条带**分类**的 WARN —— 而**消息本身一个字都不丢**(正文仍是转写文本)。 // 4. `voice` 列的 JSON 与 design/24 §10.4 的落库形状逐字一致:非语音消息空串、 // 无副本时 name/mime/size 三个键**一起缺席**(同生共死)。 // // 为什么这些断言必须存在:副本这一路最容易「静默坏掉」——落了一份播不了的字节、 // 或者给了一个指向不存在文件的 url,客户端只会看到一个转不完的圈,而日志里什么都没有。 import ( "context" "errors" "log/slog" "strings" "sync" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" mmvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice" ) // voiceHistory 是记录型历史存储(在 documentHistory 之上)再加**本片新增的 `voice` 列 // 写入视图**(design/24 §10.9-4)。真存储上线后由 `history.Store` 实现同一签名。 type voiceHistory struct { *documentHistory voiceMu sync.Mutex voices map[string]string } func (h *voiceHistory) UpdateMessageVoice(_ context.Context, id, voice string) error { h.voiceMu.Lock() defer h.voiceMu.Unlock() if h.voices == nil { h.voices = make(map[string]string) } h.voices[id] = voice return nil } // voiceColumnOf 返回 id 所指消息当前的 `voice` 列(被回写过就是回写值)。 func (h *voiceHistory) voiceColumnOf(id string) string { h.voiceMu.Lock() updated, ok := h.voices[id] h.voiceMu.Unlock() if ok { return updated } return "" } // lastUser 返回最近落库的那条用户消息(一个回合只有一条用户消息)。 func (h *voiceHistory) lastUser(t *testing.T) history.Message { t.Helper() var out history.Message found := false for _, m := range h.records() { if m.Role == history.RoleUser { out, found = m, true } } if !found { t.Fatal("本回合没有落库任何用户消息") } return out } // fakeAudioStore 是音频落盘面的替身:它记录每次保存的**提议名与字节**(好让「转码产物 // 才是落盘字节」这类纪律可断言),并按真存储的口径给出内容寻址名与下发 MIME。 type fakeAudioStore struct { mu sync.Mutex calls []audioSaveCall err error // incomplete 为 true 时返回一份**残缺**的引用(有 name 没 mime),用来钉住 // 「同生共死被破坏时不给副本」。 incomplete bool } type audioSaveCall struct { name string data []byte } func (s *fakeAudioStore) SaveAudio(_ context.Context, origName string, data []byte) (documents.Ref, error) { s.mu.Lock() defer s.mu.Unlock() s.calls = append(s.calls, audioSaveCall{name: origName, data: append([]byte(nil), data...)}) if s.err != nil { return documents.Ref{}, s.err } if s.incomplete { return documents.Ref{Name: "aabbccddeeff00112233445566778899.ogg"}, nil } ext := documents.ExtOf(origName) return documents.Ref{ Name: contentAddressedName + ext, MIME: "audio/" + strings.TrimPrefix(ext, "."), Size: int64(len(data)), }, nil } func (s *fakeAudioStore) callCount() int { s.mu.Lock() defer s.mu.Unlock() return len(s.calls) } func (s *fakeAudioStore) lastCall(t *testing.T) audioSaveCall { t.Helper() s.mu.Lock() defer s.mu.Unlock() if len(s.calls) == 0 { t.Fatal("音频存储从未被调用") } return s.calls[len(s.calls)-1] } // contentAddressedName 是一份合法的存储主名(32 位小写十六进制),与真存储的口径一致。 const contentAddressedName = "0123456789abcdef0123456789abcdef" // fakeVoiceTranscoder 是转码替身:按 routes 回答能力门,按 out/err 回答转换结果。 type fakeVoiceTranscoder struct { routes map[string]bool out transcode.Result err error mu sync.Mutex calls int } func newFakeVoiceTranscoder() *fakeVoiceTranscoder { return &fakeVoiceTranscoder{routes: map[string]bool{"amr>wav": true}} } func (f *fakeVoiceTranscoder) Transcode(_ context.Context, req transcode.Request) (transcode.Result, error) { f.mu.Lock() f.calls++ f.mu.Unlock() if f.err != nil { return transcode.Result{}, f.err } res := f.out if res.Format == "" { res.Format = req.Target } return res, nil } func (f *fakeVoiceTranscoder) Supports(src, dst transcode.Format) bool { return f.routes[transcode.FormatName(src)+">"+transcode.FormatName(dst)] } func (f *fakeVoiceTranscoder) Formats() []transcode.Format { return []transcode.Format{transcode.FormatAMR, transcode.FormatWAV} } func (f *fakeVoiceTranscoder) callCount() int { f.mu.Lock() defer f.mu.Unlock() return f.calls } // voiceCopyEnv 是本片用例的装配:真流水线(`process`)+ 假 STT + 可注入的转码器与音频 // 存储 + 记录型历史(含 voice 列视图)。 type voiceCopyEnv struct { bridge *Bridge hist *voiceHistory store *fakeAudioStore tr *fakeVoiceTranscoder logs *safeBuffer } func newVoiceCopyEnv(t *testing.T, stt VoiceSTT, tr *fakeVoiceTranscoder, store *fakeAudioStore) *voiceCopyEnv { t.Helper() logs := &safeBuffer{} log := slog.New(slog.NewTextHandler(logs, nil)) ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(&stubModelAdapter{}, nil)) if err != nil { t.Fatalf("构造 agent: %v", err) } runner := loop.New(loop.Options{Agent: ag, MaxRounds: 4, Log: log}) hist := &voiceHistory{documentHistory: &documentHistory{captureHistory: &captureHistory{}}} opts := Options{ Log: log, Loop: runner, Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}, Owner: testOwnerProfile("telegram", "100"), History: hist, VoiceSTT: stt, } // 传 nil 与传具体值必须区别对待:`VoiceTranscoder` 是接口,「没接线」正是本片要 // 覆盖的一条路径(转码不可用 ⇒ 不落副本),装了 nil 指针会让那条判据失效。 if tr != nil { opts.VoiceTranscoder = tr } if store != nil { opts.VoiceAudioStore = store } b, err := New(opts) if err != nil { t.Fatalf("New: %v", err) } b.Add(&fakeAdapter{name: "telegram"}) return &voiceCopyEnv{bridge: b, hist: hist, store: store, tr: tr, logs: logs} } // voiceRefOf 返回本回合用户消息的 `voice` 事实(列空 ⇒ 零值,即「不是语音消息」)。 func (e *voiceCopyEnv) voiceRefOf(t *testing.T) voiceref.Ref { t.Helper() msg := e.hist.lastUser(t) raw := e.hist.voiceColumnOf(msg.ID) ref, err := voiceref.Decode(raw) if err != nil { t.Fatalf("解码 voice 列 %q: %v", raw, err) } return ref } // contentOf 返回本回合落库的用户消息正文。 func (e *voiceCopyEnv) contentOf(t *testing.T) string { t.Helper() return e.hist.lastUser(t).Content } // userContentIn 返回记录型历史里最后一条用户消息的正文(没有则判失败)。 func userContentIn(t *testing.T, hist *captureHistory) string { t.Helper() content := "" found := false for _, m := range hist.records() { if m.Role == history.RoleUser { content, found = m.Content, true } } if !found { t.Fatal("没有落库任何用户消息") } return content } // transcodingSTT 是一个「转写成功」的假 STT,供不需要关心转写细节的用例使用。 func transcodingSTT(text string) *fakeVoiceSTT { return &fakeVoiceSTT{reply: mmvoice.STTOutcome{Text: text, Provider: "cloud"}} } // TestInboundVoiceCopyStoredAsIsForPlayableFormat 覆盖设计口径①(design/25 §7.1): // 源格式可播 ⇒ **原样落副本**,一个字节不改,且**一次转码都不发生**(可播还转一圈等于 // 白烧 CPU、把音质往下拽)。同时钉住落库 JSON 的逐字形状。 func TestInboundVoiceCopyStoredAsIsForPlayableFormat(t *testing.T) { t.Parallel() store := &fakeAudioStore{} tr := newFakeVoiceTranscoder() env := newVoiceCopyEnv(t, transcodingSTT("你好"), tr, store) env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA"))) if store.callCount() != 1 { t.Fatalf("音频存储调用次数 = %d, want 1", store.callCount()) } call := store.lastCall(t) if call.name != "voice.ogg" { t.Fatalf("存储提议名 = %q, want voice.ogg(扩展名决定按哪种格式裁决)", call.name) } if string(call.data) != "OGGDATA" { t.Fatalf("落盘字节 = %q, want 原样(可播格式不转码)", call.data) } if tr.callCount() != 0 { t.Fatalf("转码调用次数 = %d, want 0(可播格式绝不转码)", tr.callCount()) } ref := env.voiceRefOf(t) if ref.Source != voiceref.SourceASR || ref.Status != voiceref.StatusTranscribed { t.Fatalf("voice 事实 = %+v, want asr/transcribed", ref) } if ref.Name != contentAddressedName+".ogg" || ref.MIME != "audio/ogg" || ref.Size != 7 { t.Fatalf("副本事实 = %+v, want 服务端存储名 + audio/ogg + 7 字节", ref) } if log := env.logs.String(); !strings.Contains(log, "inbound voice copy stored") || !strings.Contains(log, "source=services/channelbridge/voice") { t.Fatalf("落副本日志缺少子功能来源:%q", log) } } // TestInboundVoiceCopyTranscodesAMRBeforeStoring 覆盖设计口径②(design/24 §10.14-2 的 // 核实结论:QQ 的 AMR 浏览器播不了 ⇒ 必须转码):落的是**转码产物**,存储名带的是**目标** // 扩展名(`.wav`),且时长取转码产物可确证的值。 func TestInboundVoiceCopyTranscodesAMRBeforeStoring(t *testing.T) { t.Parallel() store := &fakeAudioStore{} tr := newFakeVoiceTranscoder() tr.out = transcode.Result{Data: []byte("RIFFwavdata"), Format: transcode.FormatWAV, DurationMs: 3200} env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), tr, store) env.bridge.process(context.Background(), voiceMessage("v.amr", "audio/amr", []byte("#!AMR\nraw"))) if tr.callCount() != 1 { t.Fatalf("转码调用次数 = %d, want 1(AMR 不可播)", tr.callCount()) } call := store.lastCall(t) if call.name != "voice.wav" { t.Fatalf("存储提议名 = %q, want voice.wav(转码产物的扩展名,不是渠道原文件名)", call.name) } if string(call.data) != "RIFFwavdata" { t.Fatalf("落盘字节 = %q, want 转码产物", call.data) } ref := env.voiceRefOf(t) if ref.Name != contentAddressedName+".wav" || ref.DurationMs != 3200 { t.Fatalf("副本事实 = %+v, want .wav + durationMs=3200(转码产物可确证)", ref) } if log := env.logs.String(); !strings.Contains(log, "transcoded=true") { t.Fatalf("日志没有标出转码事实:%q", log) } } // TestInboundVoiceCopyNotStoredWhenTranscodeUnavailable 覆盖设计口径③(design/25 §5.4): // **不可播且转码不可用** ⇒ 不落副本、不打假 url,只留一条带**分类**的 WARN;而**消息本身 // 一个字都不丢**(正文仍是转写文本)—— 这是本片「转码失败要可分类且不丢消息」的验收。 func TestInboundVoiceCopyNotStoredWhenTranscodeUnavailable(t *testing.T) { t.Parallel() store := &fakeAudioStore{} // 组合根没把转码能力交进来(VoiceTranscoder 为 nil)——今天生产装配就是这个状态。 env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), nil, store) env.bridge.process(context.Background(), voiceMessage("v.amr", "audio/amr", []byte("#!AMR\nraw"))) if store.callCount() != 0 { t.Fatalf("音频存储调用次数 = %d, want 0(不可播就不落,绝不给播不了的 url)", store.callCount()) } ref := env.voiceRefOf(t) if !ref.IsVoice() || ref.HasCopy() { t.Fatalf("voice 事实 = %+v, want 「是语音但无副本」", ref) } if ref.Source != voiceref.SourceASR || ref.Status != voiceref.StatusTranscribed { t.Fatalf("voice 事实 = %+v, want asr/transcribed(正文确实是转写文本)", ref) } if got := env.contentOf(t); got != "语音内容" { t.Fatalf("落库正文 = %q, want 转写文本(消息绝不因为转码不可用而丢)", got) } log := env.logs.String() if !strings.Contains(log, "category=transcode_not_wired") || !strings.Contains(log, "copy not stored") { t.Fatalf("缺少带分类的诚实日志:%q", log) } } // TestInboundVoiceCopyNotStoredWithoutBytesButMarkStillPersisted 覆盖微信那条既有裁决 // (design/24 §10.14-1):平台自带转写 ⇒ 连字节都不取 ⇒ **没有可回放副本**,但 🎤 标记 // (`source:"platform"`)必须照落 —— 客户端据此显示「这是一条语音」,只是不显示播放控件。 func TestInboundVoiceCopyNotStoredWithoutBytesButMarkStillPersisted(t *testing.T) { t.Parallel() store := &fakeAudioStore{} env := newVoiceCopyEnv(t, &fakeVoiceSTT{}, newFakeVoiceTranscoder(), store) msg := voiceMessage("v.silk", "audio/silk", nil) msg.Content = "平台自带转写" // 微信 iLink 的 voice_item.text env.bridge.process(context.Background(), msg) if store.callCount() != 0 { t.Fatalf("音频存储调用次数 = %d, want 0(没有字节就没有副本)", store.callCount()) } ref := env.voiceRefOf(t) if ref.Source != voiceref.SourcePlatform || ref.Status != voiceref.StatusTranscribed { t.Fatalf("voice 事实 = %+v, want platform/transcribed", ref) } if ref.HasCopy() { t.Fatalf("voice 事实 = %+v, want 无副本(name/mime/size 全缺席)", ref) } } // TestInboundVoiceCopyNotStoredWhenSaveFails 钉住「存储侧失败」的诚实处理:正文与标记 // 都在,只是没有副本 —— 绝不因为落副本失败就把消息当成失败。 func TestInboundVoiceCopyNotStoredWhenSaveFails(t *testing.T) { t.Parallel() store := &fakeAudioStore{err: errors.New("磁盘满了")} env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), newFakeVoiceTranscoder(), store) env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA"))) ref := env.voiceRefOf(t) if !ref.IsVoice() || ref.HasCopy() { t.Fatalf("voice 事实 = %+v, want 「是语音但无副本」", ref) } if got := env.contentOf(t); got != "语音内容" { t.Fatalf("落库正文 = %q, want 转写文本", got) } if log := env.logs.String(); !strings.Contains(log, "inbound voice copy not stored") { t.Fatalf("缺少落副本失败的 WARN:%q", log) } }