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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user