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:
AskaEth committed 2026-10-26 13:06:39 +08:00
1 parent efc25c6a7f
commit 8820b0b316
12 files changed
+333 -69

No files matched your search

+4 -5
View File
@@ -94,11 +94,6 @@ type VoiceTTSTuning struct {
// 保留字段只为在启动日志里明确告诉运维"这一键不再生效,请改用 personas",
// 而不是让它被 json 静默丢弃。新配置**不要**写它。
Character string `json:"character,omitempty"`
// Format 是请求格式,只接受 wav/mp3/ogg。空 = 代码默认 wav。
//
// 注意:上游产物**恒为 WAV**(它按 `response_format` 自报 media_type,但字节是
// WAV)⇒ 真正下发的格式一律由字节嗅探裁决,这一键只影响请求参数。
Format string `json:"format,omitempty"`
// TimeoutSec 是单次合成超时(**秒**)。零/负 = 没配 ⇒ 代码默认 60 秒。
//
// 用秒而不是 Go 时长字面量:这个文件是给人改的,写 `60` 比要求记住 `"60s"`
@@ -106,6 +101,10 @@ type VoiceTTSTuning struct {
TimeoutSec int `json:"timeoutSec,omitempty"`
}
// 本文件**刻意没有** `format` 键:上游只拿 `response_format` 改它自报的 media_type,
// 产物恒 `.wav` 且无转码,我方又只按字节嗅探裁决 ⇒ 那个键不改变任何交付字节,
// 是假开关。请求体里它由 `tts.requestFormat` 固定为 wav。
// EnabledOn 报告这一级是否被显式打开(nil = 没配 ⇒ 不启用)。
func (t VoiceTTSTuning) EnabledOn() bool { return t.Enabled != nil && *t.Enabled }
+14 -2
View File
@@ -29,7 +29,7 @@ func TestLoadVoiceTTSTuning(t *testing.T) {
// ③ 合法(新形状:personas 映射 + default;旧的全局 character 不再参与)
body := `{"enabled":true,"baseUrl":"http://127.0.0.1:9880",
"personas":{"cyrene":"char_84fb88a3","furina":"char_5314489e"},
"default":"char_84fb88a3","format":"wav","timeoutSec":45}`
"default":"char_84fb88a3","timeoutSec":45}`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
@@ -37,7 +37,7 @@ func TestLoadVoiceTTSTuning(t *testing.T) {
if err != nil {
t.Fatalf("合法文件: %v", err)
}
if !tuning.EnabledOn() || tuning.Format != "wav" || tuning.TimeoutSec != 45 {
if !tuning.EnabledOn() || tuning.TimeoutSec != 45 {
t.Fatalf("读取结果不对: %+v", tuning)
}
if len(tuning.Personas) != 2 || tuning.Personas["furina"] != "char_5314489e" {
@@ -47,6 +47,18 @@ func TestLoadVoiceTTSTuning(t *testing.T) {
t.Fatalf("default = %q", tuning.Default)
}
// ③c 旧文件里若还留着 `format`,它**不再有任何效果**(结构体里没有这个键,
// json 当作未知字段忽略):加载仍成功,且交付格式只由字节嗅探决定。
if err := os.WriteFile(path, []byte(`{"enabled":true,"format":"mp3","personas":{"cyrene":"char_84fb88a3"}}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if tuning, err = LoadVoiceTTSTuning(path); err != nil {
t.Fatalf("带废弃 format 键的文件不该解不开: %v", err)
}
if !tuning.EnabledOn() || tuning.Personas["cyrene"] != "char_84fb88a3" {
t.Fatalf("废弃键影响了其余字段: %+v", tuning)
}
// ③b 旧的全局 character 仍能读出来(供启动日志提示"已废弃"),但它**不是**
// personas/default 的一部分 —— 组合根绝不把它当发音人。
if err := os.WriteFile(path, []byte(`{"enabled":true,"character":"char_old"}`), 0o600); err != nil {