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

+123
View File
@@ -0,0 +1,123 @@
package tts
// 本文件锁定**日志侧**的两条口径(复审 r1 的 P2-2 / nit,都是"CI 抓不到"的那类):
//
// 1. 🔴 WARN / 错误链里**绝不能出现她的正文**。上游(FastAPI)的 422 会把请求体
// 里的 `input` 整段回显,所以「把错误体前 512 字节打进日志」= 把她的原话写进
// 日志。这里用一台**回显正文**的假上游 + 一个接日志缓冲区的 logger 来抓它。
// 2. `redactURL` 的兜底路径:地址里含 `@`(userinfo)时必须整体打码,哪怕 URL
// 本身不合法 —— 否则 `user:pass@` 会把凭据原样写进日志。
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// captureLogger 返回一个把日志写进内存缓冲的 logger(断言用)。
func captureLogger() (*slog.Logger, *bytes.Buffer) {
var buf bytes.Buffer
return slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), &buf
}
// TestSpeechRejectLogOmitsRequestText 是隐私红线的用例:上游回 422 并**回显请求体**
// (FastAPI 的真实行为),日志里因此**一个正文词都不许出现**,只许有元数据。
//
// 变异:把错误体重新塞回 WARN(`"detail", string(snippet)`)⇒ 本用例必红。
func TestSpeechRejectLogOmitsRequestText(t *testing.T) {
log, buf := captureLogger()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnprocessableEntity)
// 这就是复审在真服务上抓到的形状:input 被整段回显。
_, _ = w.Write([]byte(`{"detail":[{"type":"missing","loc":["body","voice"],` +
`"msg":"Field required","input":{"model":"emotionTTS","input":"` + chineseSpeech + `"}}]}`))
}))
t.Cleanup(srv.Close)
client := New(Config{Enabled: true, BaseURL: srv.URL}, WithLogger(log))
_, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech)
if !errors.Is(err, ErrUpstream) {
t.Fatalf("err = %v, want ErrUpstream", err)
}
logs := buf.String()
// ① 日志里不得出现正文的任何一段(整句、半句、单个关键词都算泄漏)。
for _, leak := range []string{chineseSpeech, "苏梦", "心情特别好", "你好"} {
if strings.Contains(logs, leak) {
t.Fatalf("日志里出现了请求正文(%q)—— 隐私红线:\n%s", leak, logs)
}
}
// ② 也不得出现上游错误体的结构键(那正是"把 body 打出来"的痕迹)。
if strings.Contains(logs, "detail") || strings.Contains(logs, "loc") {
t.Fatalf("日志里出现了上游错误体内容:\n%s", logs)
}
// ③ 该有的元数据必须在:状态码 + 响应字节数(排查靠它们,不靠正文)。
if !strings.Contains(logs, "status=422") {
t.Fatalf("日志缺状态码:\n%s", logs)
}
if !strings.Contains(logs, "response_bytes=") {
t.Fatalf("日志缺响应字节数:\n%s", logs)
}
// ④ 错误串本身也不许带正文(调用方可能把它写日志)。
if strings.Contains(err.Error(), "苏梦") || strings.Contains(err.Error(), chineseSpeech) {
t.Fatalf("错误串里出现了正文: %v", err)
}
}
// TestSpeechRejectLogUsesContentLength 钉住"字节数优先取 Content-Length":
// 有长度时不必读响应体就能报出大小(不为数字节把整段读完)。
func TestSpeechRejectLogUsesContentLength(t *testing.T) {
log, buf := captureLogger()
body := `{"detail":"nope"}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", "17")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
client := New(Config{Enabled: true, BaseURL: srv.URL}, WithLogger(log))
if _, err := client.Speech(context.Background(), DefaultCharacter, "你好"); !errors.Is(err, ErrUpstream) {
t.Fatalf("err = %v, want ErrUpstream", err)
}
logs := buf.String()
if !strings.Contains(logs, "response_bytes=17") {
t.Fatalf("没有采用 Content-Length: 17:\n%s", logs)
}
if strings.Contains(logs, "nope") {
t.Fatalf("日志里出现了响应体内容:\n%s", logs)
}
}
// TestRedactURLCredentials 钉住 `redactURL` 的凭据兜底(复审 r1 的 nit)。
func TestRedactURLCredentials(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"普通地址不动", "http://127.0.0.1:9880", "http://127.0.0.1:9880"},
{"合法 URL 的 userinfo 被丢掉", "http://user:pass@127.0.0.1:9880", "http://127.0.0.1:9880"},
{"非法 URL 含 @ ⇒ 整体打码", "http://user:pass@", "<redacted>"},
{"无 scheme 含 @ ⇒ 整体打码", "user:pass@host", "<redacted>"},
{"空串原样", "", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := redactURL(c.in)
if got != c.want {
t.Fatalf("redactURL(%q) = %q, want %q", c.in, got, c.want)
}
if strings.Contains(got, "pass") {
t.Fatalf("凭据漏进可打日志的地址: %q", got)
}
})
}
}