Files
AskaEth a918819878
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels,voice): IM 各渠道语音消息可被读到(全渠道云端 ASR + 临时熔断)
采集面:mediafetch 新增 inline_voice.go(音频白名单,与 documents.ExtAllowed 互不污染;
复用既有来源白名单 / 逐跳校验 / 边读边限 / 超时);四个适配器零改动。
消费面:channelbridge 在流水线最前端把「语音字节 → 文本」归一并写回 msg.Content;
inbound.go 的丢空判据放宽为「无文本且无可用媒体」。
微信优先平台自带转写(Content 非空 ⇒ 不调 ASR,不重复付费)。
降级(主人定的两层):新增 voice/outcome.go 的 STTOutcome 识破静默档位;
errors.go 分类鉴权失败/欠费/限流(CircuitWorthy);voice_breaker.go 临时熔断 + 退避(TTL 恢复);
无论是否熔断,消息面都落一行中文诚实提示(绝不静默、绝不留空消息)。
独立短超时:VOICE_STT_TIMEOUT_SEC(默认 20s,同步 .env.example)。
kook 不再把 CDN 地址写进 Content(地址仍在 Media[].URL)。
设计先行:design/06 §1.1/§1.2、design/08 §4.1、design/24 §1.3/§4.2、开源对照清单。
变异 5 条(静默档位不带 Cause / Transcribed 误判 / 熔断恒放行 / ChatText 退化 / 平台文字判据取反)。
诚实边界:实施时官方音频规格尚未抓到 ⇒ MaxVoiceBytes 自定 5 MiB(保守)、采样率仍 16 kHz;
官方规格随后已抓到(10 MB / 5 min;格式含 amr、无 silk),对齐留待下次改动。
2026-10-05 08:32:37 +08:00

161 lines
5.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package voice
// 本文件锁定**一次链上 STT 的诚实报告**(切片 IM-ASR):`STTOutcome` 必须把静默档位
// 掩盖的真实原因带出来,`Transcribed()` 必须把「真转写(文本可能为空)」与「静默降级/
// 未配置」分开,`CircuitWorthy` 只对「再试也是白试」的类别为真。
//
// 这些断言直接对应桥接器的熔断与诚实提示:判错了就会出现「每条语音都撞一次付费墙」
// 或「一条被拒的语音关掉整个转写面」。
import (
"errors"
"fmt"
"testing"
)
func TestSTTOutcomeReportsRealTranscription(t *testing.T) {
t.Parallel()
chain := NewChain(&stubProvider{name: "cloud", stt: &stubSTT{text: "你好"}})
out := chain.STTOutcome(t.Context(), Audio{Format: "ogg", Data: []byte("a")})
if !out.Transcribed() {
t.Fatalf("Transcribed() = false, want true(provider=%q cause=%v)", out.Provider, out.Cause)
}
if out.Text != "你好" || out.Provider != "cloud" || out.Cause != nil {
t.Fatalf("outcome = %+v, want 你好/cloud/nil", out)
}
}
// TestSTTOutcomeEmptyTextFromRealProviderIsNotFailure 钉住一个容易搞反的区分:云端
// 转写成功但**没识别出文字**(静音/噪声)不是故障 —— 它绝不熔断、也不该说「服务挂了」。
func TestSTTOutcomeEmptyTextFromRealProviderIsNotFailure(t *testing.T) {
t.Parallel()
chain := NewChain(&stubProvider{name: "cloud", stt: &stubSTT{text: ""}})
out := chain.STTOutcome(t.Context(), Audio{Format: "ogg", Data: []byte("a")})
if !out.Transcribed() {
t.Fatalf("Transcribed() = false, want true(空文本但服务正常): %+v", out)
}
if out.Provider != "cloud" || out.Cause != nil {
t.Fatalf("outcome = %+v, want cloud/nil", out)
}
if CircuitWorthy(out.Cause) {
t.Fatal("空文本被判成「再试也是白试」,这会误开熔断")
}
}
// TestSTTOutcomeRevealsSilentDegradation 是本切片的核心断言:静默档位返回「空文本 +
// nil 错」,因此只看文本会把「没有可用的识别后端」当成功 —— `Cause` 必须把它揭穿。
func TestSTTOutcomeRevealsSilentDegradation(t *testing.T) {
t.Parallel()
chain := NewChain(
&stubProvider{name: "cloud", stt: &stubSTT{err: ErrAuthFailed}},
SilentProvider(),
)
out := chain.STTOutcome(t.Context(), Audio{Format: "ogg", Data: []byte("a")})
if out.Transcribed() {
t.Fatalf("静默降级被判成真转写:%+v", out)
}
if out.Provider != SilentProviderName {
t.Fatalf("provider = %q, want %q", out.Provider, SilentProviderName)
}
if !errors.Is(out.Cause, ErrAuthFailed) {
t.Fatalf("cause = %v, want ErrAuthFailed(被静默档位掩盖的真实原因必须带出来)", out.Cause)
}
if !CircuitWorthy(out.Cause) {
t.Fatal("鉴权失败必须可熔断(否则每条语音都撞一次付费墙)")
}
}
// TestSTTOutcomeWithoutAnyStepReportsNotConfigured 覆盖「链上压根没有云端档位」:
// 静默档位成功但没有前车之鉴 ⇒ 归为「未配置」,同样可熔断。
func TestSTTOutcomeWithoutAnyStepReportsNotConfigured(t *testing.T) {
t.Parallel()
out := NewChain(SilentProvider()).STTOutcome(t.Context(), Audio{Format: "ogg"})
if out.Transcribed() || !errors.Is(out.Cause, ErrNotConfigured) {
t.Fatalf("outcome = %+v, want 未配置(不可当真转写)", out)
}
if !CircuitWorthy(out.Cause) {
t.Fatal("未配置必须可熔断(否则每条语音都白跑一趟)")
}
}
func TestSTTOutcomeEmptyChain(t *testing.T) {
t.Parallel()
out := NewChain().STTOutcome(t.Context(), Audio{Format: "ogg"})
if out.Transcribed() || !errors.Is(out.Cause, ErrProviderFailed) {
t.Fatalf("outcome = %+v, want ErrProviderFailed", out)
}
}
// TestSTTOutcomeKeepsProviderOrder 钉住它仍走既有的降级顺序(不因为新增报告面而改变
// 「先云端、后本地」的语义)。
func TestSTTOutcomeKeepsProviderOrder(t *testing.T) {
t.Parallel()
chain := NewChain(
&stubProvider{name: "cloud", stt: &stubSTT{err: ErrRateLimited}},
&stubProvider{name: "local", stt: &stubSTT{text: "本地转写"}},
SilentProvider(),
)
out := chain.STTOutcome(t.Context(), Audio{Format: "ogg"})
if !out.Transcribed() || out.Text != "本地转写" || out.Provider != "local" {
t.Fatalf("outcome = %+v, want 落到 local", out)
}
}
// TestCircuitWorthyClassification 钉住分类口径:只有「账户/配额/权限层面」的失败为真;
// 按内容发生的失败(格式不支持、解码失败)为假 —— 后者若熔断,一条被拒的语音会让
// 后续语音全部降级。
func TestCircuitWorthyClassification(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"未配置", ErrNotConfigured, true},
{"鉴权失败", ErrAuthFailed, true},
{"欠费/额度", ErrQuotaExceeded, true},
{"限流", ErrRateLimited, true},
{"包装后的鉴权失败", fmt.Errorf("voice: 上游: %w", ErrAuthFailed), true},
{"普通上游失败", ErrProviderFailed, false},
{"格式不支持", fmt.Errorf("voice: %w: unsupported format", ErrProviderFailed), false},
{"未知错误", errors.New("boom"), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := CircuitWorthy(tc.err); got != tc.want {
t.Fatalf("CircuitWorthy(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
// TestSTTOutcomeErrorStringsAreClassified 钉住鉴权/限流/欠费的错误串里同时带着
// 分类哨兵与上游细节(既能让调用方分类,也让日志能说清是哪一次失败)。
func TestSTTOutcomeErrorStringsAreClassified(t *testing.T) {
t.Parallel()
for name, err := range map[string]error{
"auth": ErrAuthFailed,
"quota": ErrQuotaExceeded,
"rate": ErrRateLimited,
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
if !errors.Is(err, ErrProviderFailed) {
t.Fatalf("分类哨兵 %v 必须以 ErrProviderFailed 为底(既有 errors.Is 判定不得变化)", err)
}
})
}
}