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

+37 -10
View File
@@ -64,9 +64,10 @@ type speechPayload struct {
Model string `json:"model"`
// Input 是要合成的文本(UTF-8;见文件头的红线)。
Input string `json:"input"`
// Voice 是角色 id 或名字(`char_84fb88a3` / `Cyrene` 都认)。
// Voice 是角色 id(由会话 persona 映射而来,每次请求现算)。
Voice string `json:"voice"`
// ResponseFormat 是请求格式;产物恒为 WAV,真实格式按字节嗅探。
// ResponseFormat 恒为 `wav`(`requestFormat`):上游产物无转码、我方只按字节
// 嗅探裁决,这个字段不可配置(见 requestFormat 的注释)。
ResponseFormat string `json:"response_format"`
}
@@ -110,7 +111,7 @@ func New(cfg Config, opts ...Option) *Client {
for _, o := range opts {
o(c)
}
c.cfg = cfg.Normalized(c.log)
c.cfg = cfg.Normalized()
if c.http == nil {
c.http = &http.Client{Timeout: c.cfg.Timeout}
}
@@ -163,7 +164,7 @@ func (c *Client) Speech(ctx context.Context, character, text string) (Audio, err
// speak 是 Speech 的主体:编码 → 发送 → 判状态 → 嗅探。失败原因已分类。
func (c *Client) speak(ctx context.Context, character, text string) (Audio, error) {
body, err := encodeSpeech(text, character, c.cfg.Format)
body, err := encodeSpeech(text, character)
if err != nil {
return Audio{}, err
}
@@ -193,12 +194,12 @@ func (c *Client) speak(ctx context.Context, character, text string) (Audio, erro
// 🔴 全仓仅此一处构造请求体:`json.Encoder` 天然产出 UTF-8,且这里显式关掉 HTML
// 转义,使正文里的 `<`/`>`/`&` 不会被改写成 `\u003c` 之类(内容逐字不变,下游
// 日志与音色匹配都依赖原文)。**绝不**改成字符串拼接或 GBK 转码(见文件头)。
func encodeSpeech(text, character, format string) ([]byte, error) {
func encodeSpeech(text, character string) ([]byte, error) {
payload := speechPayload{
Model: modelName,
Input: text,
Voice: character,
ResponseFormat: format,
ResponseFormat: requestFormat,
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
@@ -225,11 +226,30 @@ func (c *Client) unreachable(ctx context.Context, endpoint string, err error) er
return fmt.Errorf("%w: %s: %w", ErrUnreachable, reason, err)
}
// upstreamStatus 把非 200 收敛成 ErrUpstream,并在日志里留下状态码与一小段
// 响应片段(上游的错误体是它自己的 `{"detail":...}`;片段只用于排查,不含我方正文)。
// upstreamStatus 把非 200 收敛成 ErrUpstream,并只记**元数据**(状态码、Content-Type
// 与响应字节数)—— 上游的错误体**一个字节都不进日志**。
//
// 🔴 这条是隐私红线,不是风格问题:FastAPI 的 422 会把请求体里的 `input` **整段回显**,
// 实测报文形如
//
// {"detail":[{"type":"missing","loc":["body","voice"],…,
// "input":{"input":"你好,我是苏梦,我有点难过。"}}]}
//
// 于是"把错误体前 512 字节打进 WARN"就等于**把她说的原话写进日志**。错误链里同样
// 只有状态码(`TestSpeechRejectLogOmitsRequestText` 钉这一条,变异可打红)。
//
// 字节数优先取 `Content-Length`;缺失时只做**有界**排空(上限 maxDrainBytes)得出
// 一个下界,**不为数字节把整段读完**,读到的东西也绝不进日志。
func (c *Client) upstreamStatus(res *http.Response) error {
snippet, _ := io.ReadAll(io.LimitReader(res.Body, 512))
c.warn("voice tts rejected", "status", res.StatusCode, "detail", string(snippet))
size := res.ContentLength
if size < 0 {
drained, _ := io.Copy(io.Discard, io.LimitReader(res.Body, maxDrainBytes))
size = drained
}
c.warn("voice tts rejected",
"status", res.StatusCode,
"content_type", res.Header.Get("Content-Type"),
"response_bytes", size)
return fmt.Errorf("%w: status %d", ErrUpstream, res.StatusCode)
}
@@ -273,9 +293,16 @@ type Audio struct {
// redactURL 把 baseURL 收敛成**可打日志**的形态:只保留 scheme 与 host,
// 丢掉 userinfo/query(若运维在地址里塞了凭据,别把它写进日志)。
//
// 解析失败或没有 host 时**不能**原样返回:`http://user:pass@` 这种不合法地址会
// 把凭据整个写进日志。因此兜底判据是"含 `@` 就整体打码"(`@` 是 userinfo 的
// 分隔符,出现在地址里只可能是凭据)。
func redactURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
if strings.Contains(raw, "@") {
return "<redacted>"
}
return raw
}
return u.Scheme + "://" + u.Host