上一版把 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。
203 lines
8.3 KiB
Go
203 lines
8.3 KiB
Go
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"
|
||
}
|
||
}
|