fix(voice): 修 r1 复审四条(正文进日志的隐私泄漏 / format 假开关 / Stats 无消费者 / redactURL 兜底)
上一版把 r1 的三条 finding 静默漏了(我在交付前没读 inbox),本提交逐条补齐:
1. 🔴 隐私:upstreamStatus 不再读也不记响应体
FastAPI 的 422 会把请求体里的 input 整段回显,原来「错误体前 512 字节进 WARN」
等于把她的原话写进日志。现在只记 status / content_type / response_bytes
(字节数优先取 Content-Length,缺失时有界排空取上界,读到即丢)。
新用例 TestSpeechRejectLogOmitsRequestText 用**回显正文**的假上游抓它;
变异(把 body 塞回 WARN)实测打红。
2. format 键彻底删除(假开关)
上游只拿 response_format 改自报 media_type,产物恒 .wav 无转码,我方只按字节
嗅探裁决 ⇒ wav/mp3/ogg 交付字节与响应头完全相同。删 tts.Config.Format 与
VoiceTTSTuning.Format,encodeSpeech 硬编码 requestFormat="wav"(不可配置)。
用例钉住请求体恒为 wav;变异(改成 mp3)打红。旧文件里残留的 format 键被忽略。
3. Stats 接进 degrade 的 WARN(attempts/failures)
否则统计在生产日志里没有消费者。TestServiceDegradeLogsCounters 钉住;
变异(去掉两个字段)打红。
4. redactURL 兜底含 "@" 整体打码
解析失败/无 host 时原来原样返回,user:pass@ 会漏凭据。TestRedactURLCredentials
覆盖合法 URL 去 userinfo、非法含 @ 打码;变异(退回原样返回)打红。
另:关闭态零行为变化的**当前 tip 重跑**——
- handleSpeech 的 persona 解析改为只在语音服务实现 personaVoice 且 PrimaryActive
时才查库;未接 emotionTTS(既有链/stub)时零额外 I/O、ctx 不改、音频字节相同。
用例 TestSpeechEndpointDisabledVoiceDoesNotReadPersona(含对照组证明闸不是恒关);
变异(去掉闸)打红。
- voice.go 在 dev 基线里本就有 env.PersonaID 解析(无人格解析新增 I/O),本次未改。
Config.Normalized 去掉未使用的 logger 参数。go build/vet/test(含 -race)全绿;
真机 live 复测:cyrene 264104 B、furina 221194 B。
This commit is contained in:
1 parent
efc25c6a7f
commit
8820b0b316
12 files changed
+333
-69
No files matched your search
@@ -41,6 +41,14 @@ type speechRequest struct {
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
}
|
||||
|
||||
// personaVoice 是"会按会话 persona 选发音人"的语音服务的**可选**扩展。只有
|
||||
// emotionTTS 门面(`*voicetts.Service`)实现它;既有链、stub 服务都不实现 ⇒
|
||||
// 未接 emotionTTS 时本片不为它额外查库,关闭态与引入之前逐字同行为。
|
||||
type personaVoice interface {
|
||||
// PrimaryActive 报告"按 persona 选发音"这一级是否真的在生效。
|
||||
PrimaryActive() bool
|
||||
}
|
||||
|
||||
// handleTranscribe 对上传的音频执行 STT 并返回识别出的
|
||||
// 文本。它是原生客户端的 REST 来源,绕过 4KB WebSocket
|
||||
// 帧上限(该上限会截断 base64 音频块)。
|
||||
@@ -110,7 +118,13 @@ func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) {
|
||||
// 发音人按**本次请求的会话**所绑定的人格解析(切片 M35)。空 sessionId 或
|
||||
// 会话无绑定 ⇒ personaID 为空 ⇒ 语音面走 default 分支(没有 default 就不合成)。
|
||||
// 解析只做一次、只服务本次请求,绝不在注入时算一次并缓存成全局。
|
||||
ctx = voicetts.WithPersonaID(ctx, s.envResolver.sessionPersona(ctx, req.SessionID))
|
||||
//
|
||||
// 🔴 只有"真的会按 persona 选发音"的语音服务才做这次库读取:未启用 emotionTTS
|
||||
// 时注入的是既有链本身,此时本函数**不额外查库、不改 ctx** —— 关闭态与引入本
|
||||
// 切片之前逐字同行为(复审要求自证的那一条)。
|
||||
if pv, ok := s.voice.(personaVoice); ok && pv.PrimaryActive() {
|
||||
ctx = voicetts.WithPersonaID(ctx, s.envResolver.sessionPersona(ctx, req.SessionID))
|
||||
}
|
||||
audi, rung, err := ttsWithProvider(ctx, s.voice, req.Text)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "audio.speech", "error")
|
||||
|
||||
@@ -229,6 +229,72 @@ func TestVoiceOutputFrameVoiceFollowsTurnPersona(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeechEndpointDisabledVoiceDoesNotReadPersona 是**关闭态零行为变化**的自证
|
||||
// (复审要求在当前 tip 上重跑的那一条,而我这一版动了 `gateway/audio.go`):
|
||||
//
|
||||
// 注入的语音服务不是"按 persona 选发音"的那一类(既有链 / stub)时,语音合成
|
||||
// **一次都不查会话人格** —— 与引入本切片之前逐字同行为;反之(真 emotionTTS 门面)
|
||||
// 才会查,说明这个闸不是"永远不查"。
|
||||
//
|
||||
// 变异:去掉 handleSpeech 里的 `pv.PrimaryActive()` 闸 ⇒ 本用例第一段红。
|
||||
func TestSpeechEndpointDisabledVoiceDoesNotReadPersona(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
stub := &audioStubVoice{ttsAudio: mvoice.Audio{Format: "wav", Data: fakeWAV("chain")}}
|
||||
env.srv.SetVoice(stub)
|
||||
counter := &countingHistory{fakeHistory: &fakeHistory{}}
|
||||
env.srv.envResolver = turnEnvResolver{history: counter, log: env.srv.log}
|
||||
token := env.token(t, auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||||
"text": "你好", "sessionId": "web:sub-1",
|
||||
}, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
if got := counter.Calls(); got != 0 {
|
||||
t.Fatalf("非 persona 语音服务却查了 %d 次会话人格(关闭态行为变了)", got)
|
||||
}
|
||||
if string(body) != string(fakeWAV("chain")) {
|
||||
t.Fatalf("关闭态的音频不是既有链产物: %q", string(body))
|
||||
}
|
||||
|
||||
// 对照:接上真的 emotionTTS 门面后,同一个端点会查一次人格(闸不是恒关)。
|
||||
env2 := newEnv(t, nil, false)
|
||||
env2.srv.SetVoice(emotionTTSService(t, &vocalRecorder{}, voicetts.Config{Personas: personaCharacters}, &silentBase{}))
|
||||
counter2 := &countingHistory{fakeHistory: &fakeHistory{}}
|
||||
env2.srv.envResolver = turnEnvResolver{history: counter2, log: env2.srv.log}
|
||||
if resp, body := doReq(t, http.MethodPost, env2.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||||
"text": "你好", "sessionId": "web:sub-1",
|
||||
}, env.token(t, auth.RoleOwner)); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("对照组 status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
if got := counter2.Calls(); got != 1 {
|
||||
t.Fatalf("启用 emotionTTS 后应查 1 次人格, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// countingHistory 记录 SessionPersona 被查了几次(同时满足 HistoryStore 的其余方法)。
|
||||
type countingHistory struct {
|
||||
*fakeHistory
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
// SessionPersona 计数并回一个固定的 persona。
|
||||
func (h *countingHistory) SessionPersona(context.Context, string) (string, error) {
|
||||
h.mu.Lock()
|
||||
h.calls++
|
||||
h.mu.Unlock()
|
||||
return "furina", nil
|
||||
}
|
||||
|
||||
// Calls 返回查询次数。
|
||||
func (h *countingHistory) Calls() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.calls
|
||||
}
|
||||
|
||||
// asString 安全地取一个 string 值(帧载荷是 map[string]any)。
|
||||
func asString(v any) string {
|
||||
s, _ := v.(string)
|
||||
|
||||
Reference in new issue
Block a user