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
@@ -22,14 +22,14 @@
|
||||
// 做一个假开关** —— 因此 `Config` 刻意不含 `speed`。同理不含 `emotion`/`tone`:
|
||||
// 那两样上游自己推,我方传什么都是噪音。
|
||||
//
|
||||
// # 一次只暴露三样
|
||||
// # 对外只有两样
|
||||
//
|
||||
// 对调用方只暴露 `text`(必填)、角色(默认 `Cyrene` 的 `char_84fb88a3`)与
|
||||
// `format`(默认 wav)。其余全部是代码默认值或面板文件里的运维旋钮。
|
||||
// 对调用方只暴露 `text`(必填)与角色(由会话 persona 映射而来)。**没有 `format`**:
|
||||
// 上游只拿它改自报的 media_type,产物恒 `.wav` 无转码,我方只按字节嗅探裁决 ⇒
|
||||
// 那个键不改变任何交付字节(假开关)。其余全是面板文件里的运维旋钮或代码默认值。
|
||||
package tts
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -46,30 +46,39 @@ const (
|
||||
// `Config.Personas` / `Config.Default` 显式给出(见 Resolve)。
|
||||
DefaultCharacter = "char_84fb88a3"
|
||||
|
||||
// DefaultFormat 是默认请求格式。服务端产物**恒为 WAV**(它把 .wav 落盘后用
|
||||
// `FileResponse` 回发,`response_format` 只影响它自报的 media_type),因此默认
|
||||
// wav;实际下发的格式仍由**字节嗅探**裁决(见 audio.go),不信任这个字段。
|
||||
DefaultFormat = "wav"
|
||||
|
||||
// DefaultTimeout 是单次合成超时。实测一次中文合成约 4~6 秒(它内部还要先跑一次
|
||||
// LLM 情绪分析 + 素材匹配),故默认留足余量;超时即失败并回落既有链。
|
||||
// DefaultTimeout 是单次合成超时。实测一次中文合成约 4~8 秒(它内部还要先跑一次
|
||||
// LLM 情绪分析 + 素材匹配),故默认留足余量;超时即失败并按既有口径回落。
|
||||
DefaultTimeout = 60 * time.Second
|
||||
|
||||
// defaultMaxAudioBytes 限制读回的音频体积,使异常/畸形响应无法耗尽内存
|
||||
// (32 MiB 远超一句话的语音,实测 5 秒语音约 100~200 KB)。
|
||||
defaultMaxAudioBytes = 32 << 20
|
||||
|
||||
// maxDrainBytes 是错误响应体的**有界**排空上限:只用来得出一个字节数下界,
|
||||
// 读到的东西直接丢弃、绝不进日志(见 upstreamStatus 的隐私红线)。
|
||||
maxDrainBytes = 4096
|
||||
|
||||
// speechPath 是 OpenAI 兼容合成端点(已实测可用:200 / RIFF-WAVE)。
|
||||
speechPath = "/v1/audio/speech"
|
||||
|
||||
// modelName 是上游要求的固定模型名(该服务只认 `emotionTTS`)。
|
||||
modelName = "emotionTTS"
|
||||
|
||||
// requestFormat 是请求体里 `response_format` 的**固定值**。
|
||||
//
|
||||
// 🔴 它刻意**不可配置**:上游只拿它改 `FileResponse` 的 media_type,产物恒为
|
||||
// `.wav` 且**无转码**(`tts_router.py:437-438`),我方又从不读 `Content-Type`
|
||||
// (格式一律由 `sniffAudio` 按字节裁决)⇒ `wav`/`mp3`/`ogg` 交付出去的字节与
|
||||
// 响应头**完全相同**。做成配置键就是一个假开关(主人对假开关零容忍)。
|
||||
requestFormat = "wav"
|
||||
)
|
||||
|
||||
// Config 是 emotionTTS 客户端的运行期旋钮(由面板文件
|
||||
// `$DATA_DIR/config/voice-tts.json` 映射而来;零新 `.env` 键)。
|
||||
//
|
||||
// 🔴 刻意**没有** speed / emotion / tone 三项,理由见包注释(上游硬编码或自推)。
|
||||
// 🔴 刻意**没有** format:上游只拿 `response_format` 改它自报的 media_type,产物
|
||||
// 恒为 `.wav` 无转码,我方又只按字节嗅探裁决 ⇒ 那个键不改变任何交付字节,是假开关。
|
||||
// 🔴 也**没有全局 character**:发音人只能由 `Personas`(按会话角色映射)或
|
||||
// `Default`(仅服务"解析不到 personaId")给出 —— 主人 2026-10-26 的硬要求
|
||||
// 「TTS 的人物选择要绑定后端的角色,不然换了人格还是用老角色的声音」。
|
||||
@@ -87,24 +96,16 @@ type Config struct {
|
||||
// 该出口本身没有会话)时使用的发音人。它**不**服务"配了 Personas 但没有
|
||||
// 这个 persona 的键"那种情况 —— 那是 Resolve 的拒绝分支(主人点名的病害)。
|
||||
Default string
|
||||
// Format 是请求格式(`response_format`);空 = DefaultFormat。只接受
|
||||
// wav/mp3/ogg —— 且**只用于请求**,回发的真实格式一律按字节嗅探。
|
||||
Format string
|
||||
// Timeout 是单次合成超时;<=0 = DefaultTimeout。
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// supportedFormats 是允许写进 Format 的取值(上游 `response_format` 只用于自报
|
||||
// media_type;产物恒为 WAV)。写别的值不是"更灵活",而是让下游拿到一个与字节
|
||||
// 不符的标签 —— 因此这里**拒绝**而不是静默放行。
|
||||
var supportedFormats = map[string]bool{"wav": true, "mp3": true, "ogg": true}
|
||||
|
||||
// Normalized 返回填好默认值、去掉首尾空白的配置。非法 Format 回落到默认值并
|
||||
// 通过 log 记一条 WARN(**不让它停摆** —— 一个写错的格式不该关掉整个语音面)。
|
||||
// Normalized 返回填好默认值、去掉首尾空白的配置。
|
||||
//
|
||||
// 它**不**给 `Personas`/`Default` 补任何默认角色:发音人只有两种合法来源
|
||||
// (显式映射 / 显式 default),代码里的兜底值就是主人担心的病害本身。
|
||||
func (c Config) Normalized(log *slog.Logger) Config {
|
||||
// (显式映射 / 显式 default),代码里的兜底值就是主人担心的病害本身。它也不接受
|
||||
// logger:当前没有任何"需要告警的回落"(格式已不是配置项,见 requestFormat)。
|
||||
func (c Config) Normalized() Config {
|
||||
out := c
|
||||
out.BaseURL = strings.TrimRight(strings.TrimSpace(out.BaseURL), "/")
|
||||
if out.BaseURL == "" {
|
||||
@@ -112,16 +113,6 @@ func (c Config) Normalized(log *slog.Logger) Config {
|
||||
}
|
||||
out.Personas = normalizePersonas(out.Personas)
|
||||
out.Default = strings.TrimSpace(out.Default)
|
||||
out.Format = strings.ToLower(strings.TrimSpace(out.Format))
|
||||
if out.Format == "" {
|
||||
out.Format = DefaultFormat
|
||||
} else if !supportedFormats[out.Format] {
|
||||
if log != nil {
|
||||
log.Warn("unsupported tts format, fell back to default",
|
||||
"format", out.Format, "default", DefaultFormat)
|
||||
}
|
||||
out.Format = DefaultFormat
|
||||
}
|
||||
if out.Timeout <= 0 {
|
||||
out.Timeout = DefaultTimeout
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user