Files
Cyrene_v2/internal/services/gateway/audio.go
T
AskaEth 8820b0b316 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。
2026-10-26 13:06:39 +08:00

203 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"encoding/json"
"net/http"
"strconv"
"strings"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
)
// maxAudioRequestBytes 限定 /api/v1/audio/* 请求可携带的 JSON 体大小,
// 使巨大的 base64 音频载荷无法耗尽内存。
const maxAudioRequestBytes = 32 << 20
// transcribeRequest 是 POST /api/v1/audio/transcribe 的请求体。Audio 是 base64
// (或 data: URI)数据块;Format 是其编码("wav"/"mp3"/"ogg")。
type transcribeRequest struct {
Audio string `json:"audio"`
Format string `json:"format"`
DeviceID string `json:"deviceId,omitempty"`
}
// speechRequest 是 POST /api/v1/audio/speech 的请求体。Voice/format 为前向
// 兼容而被接受;合成目前遵循服务端的
// VOICE_TTS_VOICE_ID / VOICE_TTS_FORMAT 配置。Instruction 是可选的
// 每请求情绪/语气覆盖,用于克隆音色(声音复刻);系统音色
// (longxiaochun_v3)忽略它,绝不接收自由形式的指令。
//
// SessionID 是**可选**的会话标识(切片 M35,纯增量):发音人按
// 「该会话绑定的人格」解析(`chat_sessions.persona_id`)。省略它时本口没有会话
// 概念 ⇒ 走 emotionTTS 的 default 分支(没配 default 就不合成)。客户端**绝不**
// 通过它选音色:音色由服务端的 persona 映射决定,请求只声明"这是哪个会话"。
type speechRequest struct {
Text string `json:"text"`
Voice string `json:"voice,omitempty"`
Format string `json:"format,omitempty"`
Instruction string `json:"instruction,omitempty"`
SessionID string `json:"sessionId,omitempty"`
}
// personaVoice 是"会按会话 persona 选发音人"的语音服务的**可选**扩展。只有
// emotionTTS 门面(`*voicetts.Service`)实现它;既有链、stub 服务都不实现 ⇒
// 未接 emotionTTS 时本片不为它额外查库,关闭态与引入之前逐字同行为。
type personaVoice interface {
// PrimaryActive 报告"按 persona 选发音"这一级是否真的在生效。
PrimaryActive() bool
}
// handleTranscribe 对上传的音频执行 STT 并返回识别出的
// 文本。它是原生客户端的 REST 来源,绕过 4KB WebSocket
// 帧上限(该上限会截断 base64 音频块)。
func (s *Server) handleTranscribe(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAudioRequestBytes)
var req transcribeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.auditClaims(r, "audio.transcribe", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if s.voice == nil {
s.auditClaims(r, "audio.transcribe", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "voice_unavailable", "voice service is not configured"))
return
}
audio, err := decodeVoiceAudio(req.Audio, req.Format)
if err != nil {
s.auditClaims(r, "audio.transcribe", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "audio could not be decoded"))
return
}
if len(audio.Data) == 0 {
s.auditClaims(r, "audio.transcribe", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "audio is required"))
return
}
text, rung, err := sttWithProvider(r.Context(), s.voice, audio)
if err != nil {
s.auditClaims(r, "audio.transcribe", "error")
s.log.Warn("voice transcribe", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "stt_failed", "speech transcription failed"))
return
}
s.auditClaims(r, "audio.transcribe", "ok")
out := map[string]any{"text": text}
if sig := providerSignal(rung, strings.TrimSpace(text) == ""); sig != "" {
out["provider"] = sig
}
writeJSON(w, http.StatusOK, out)
}
// handleSpeech 为给定文本合成语音并写回原始音频
// 字节(audio/<format>)—— 二进制响应,不是 JSON 信封。
func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAudioRequestBytes)
var req speechRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.auditClaims(r, "audio.speech", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Text) == "" {
s.auditClaims(r, "audio.speech", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text is required"))
return
}
if s.voice == nil {
s.auditClaims(r, "audio.speech", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "voice_unavailable", "voice service is not configured"))
return
}
ctx := r.Context()
if req.Instruction != "" {
ctx = voice.WithTTSInstruction(ctx, req.Instruction)
}
// 发音人按**本次请求的会话**所绑定的人格解析(切片 M35)。空 sessionId 或
// 会话无绑定 ⇒ personaID 为空 ⇒ 语音面走 default 分支(没有 default 就不合成)。
// 解析只做一次、只服务本次请求,绝不在注入时算一次并缓存成全局。
//
// 🔴 只有"真的会按 persona 选发音"的语音服务才做这次库读取:未启用 emotionTTS
// 时注入的是既有链本身,此时本函数**不额外查库、不改 ctx** —— 关闭态与引入本
// 切片之前逐字同行为(复审要求自证的那一条)。
if pv, ok := s.voice.(personaVoice); ok && pv.PrimaryActive() {
ctx = voicetts.WithPersonaID(ctx, s.envResolver.sessionPersona(ctx, req.SessionID))
}
audi, rung, err := ttsWithProvider(ctx, s.voice, req.Text)
if err != nil {
s.auditClaims(r, "audio.speech", "error")
s.log.Warn("voice speech", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "tts_failed", "speech synthesis failed"))
return
}
if len(audi.Data) == 0 {
s.auditClaims(r, "audio.speech", "error")
w.Header().Set(voiceProviderHeader, providerSignal(rung, true))
writeError(w, apperr.Wrap(apperr.ErrInternal, "tts_empty", "no audio synthesized"))
return
}
s.auditClaims(r, "audio.speech", "ok")
w.Header().Set("Content-Type", audioMime(audi.Format))
w.Header().Set("Content-Length", strconv.Itoa(len(audi.Data)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(audi.Data)
}
// voiceCloneRequest 是 POST /api/v1/audio/voice-clone 的请求体。URL 是公开的
// 参考音频 URL;prefix 是可选音色名前缀(≤10 个字母数字字符)。
// 二者都不记日志,URL 也不持久化(仅 owner + 审计,红线)。
type voiceCloneRequest struct {
URL string `json:"url"`
Prefix string `json:"prefix,omitempty"`
}
// handleVoiceClone 经百炼声音注册 API(声音复刻 create_voice)从公开
// 参考音频 URL 创建自定义 TTS 音色,并返回新的
// voice_id。仅 owner + 带审计;参考 URL 绝不写入审计
// 记录或日志(红线)。
func (s *Server) handleVoiceClone(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAudioRequestBytes)
var req voiceCloneRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.auditClaims(r, "audio.voice-clone", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.URL) == "" {
s.auditClaims(r, "audio.voice-clone", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "url is required"))
return
}
if s.voice == nil {
s.auditClaims(r, "audio.voice-clone", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "voice_unavailable", "voice service is not configured"))
return
}
voiceID, err := s.voice.CloneVoice(r.Context(), req.URL, req.Prefix)
if err != nil {
s.auditClaims(r, "audio.voice-clone", "error")
// 不要记录原因:provider 的错误体可能回显参考 URL。
s.log.Warn("voice clone failed")
writeError(w, apperr.Wrap(apperr.ErrInternal, "voice_clone_failed", "voice clone failed"))
return
}
s.auditClaims(r, "audio.voice-clone", "ok")
writeJSON(w, http.StatusOK, map[string]any{"voiceId": voiceID})
}
// audioMime 把音频格式映射为其用于语音响应的 Content-Type。
func audioMime(format string) string {
switch strings.ToLower(format) {
case "mp3", "mpeg":
return "audio/mpeg"
case "ogg", "oga":
return "audio/ogg"
case "webm":
return "audio/webm"
default:
return "audio/wav"
}
}