2026-08-15 14:38:14 +08:00
package controller
2026-05-23 20:32:12 +08:00
import (
2026-08-15 13:30:35 +08:00
"context"
2026-05-23 20:32:12 +08:00
"encoding/json"
"fmt"
"io"
2026-06-26 23:09:27 +08:00
"log"
2026-05-23 20:32:12 +08:00
"net/http"
2026-08-15 13:30:35 +08:00
"runtime"
2026-05-23 20:32:12 +08:00
"strings"
"time"
"github.com/volcano-tts/tts-api/adapter/volcano"
"github.com/volcano-tts/tts-api/common"
"github.com/volcano-tts/tts-api/dto"
2026-08-29 20:44:33 +08:00
"github.com/volcano-tts/tts-api/installer"
2026-08-15 13:30:35 +08:00
"github.com/volcano-tts/tts-api/metrics"
2026-05-23 20:32:12 +08:00
"github.com/volcano-tts/tts-api/middleware"
"github.com/volcano-tts/tts-api/setting"
2026-08-29 22:40:53 +08:00
"github.com/volcano-tts/tts-api/store"
2026-08-15 13:30:35 +08:00
"github.com/volcano-tts/tts-api/telemetry"
2026-08-27 10:18:34 +08:00
"github.com/volcano-tts/tts-api/version"
2026-05-23 20:32:12 +08:00
)
2026-08-15 13:30:35 +08:00
var (
volcanoClient * volcano . HTTPClient
adapterRec volcano . MetricsRecorder = metrics . AdapterRecorder {}
)
2026-05-23 20:32:12 +08:00
func InitController () {
volcanoClient = volcano . NewHTTPClient ()
}
2026-06-26 23:09:27 +08:00
func truncateForLog ( b [] byte , max int ) string {
if len ( b ) > max {
return string ( b [: max ]) + fmt . Sprintf ( "...(truncated, total %d bytes)" , len ( b ))
}
return string ( b )
}
2026-08-15 13:30:35 +08:00
// resolveClientFormat 把 OpenAI 风格的 response_format 映射为最终输出格式;
2026-09-21 10:50:03 +08:00
// 不识别或未指定时回退到 setting.GetTTSOptions().Format。
2026-08-15 13:30:35 +08:00
func resolveClientFormat ( reqFmt string ) string {
switch strings . ToLower ( reqFmt ) {
case "mp3" , "wav" , "opus" , "pcm" , "aac" , "flac" :
if reqFmt == "opus" {
return "ogg_opus"
}
return strings . ToLower ( reqFmt )
}
2026-09-21 10:50:03 +08:00
return setting . GetTTSOptions (). Format
2026-08-15 13:30:35 +08:00
}
// OpenaiTTSHandler 是 /v1/audio/speech 的入口。
2026-05-23 20:32:12 +08:00
func OpenaiTTSHandler ( w http . ResponseWriter , r * http . Request ) {
2026-08-15 13:30:35 +08:00
start := time . Now ()
2026-05-23 20:32:12 +08:00
if r . Method != http . MethodPost {
2026-06-26 23:09:27 +08:00
log . Printf ( "警告: 错误的方法 - 方法=%s 期望=POST 路径=%s 客户端=%s" ,
r . Method , r . URL . Path , middleware . GetClientIP ( r ))
2026-08-15 13:30:35 +08:00
metrics . RequestTotal . Inc ( telemetry . Labels { "status" : "method_not_allowed" , "format" : "" , "speaker" : "" , "model" : "" })
2026-05-23 20:32:12 +08:00
http . Error ( w , "Method not allowed" , http . StatusMethodNotAllowed )
return
}
2026-08-29 20:44:33 +08:00
// 安装模式双保险:即使 InstallGuard 中间件没拦住,这里也 503 + 引导跳转
if installer . GetMode () == installer . ModeSetup {
log . Printf ( "[tts] 安装模式下拒绝 /v1/audio/speech - 客户端=%s" , middleware . GetClientIP ( r ))
w . Header (). Set ( "Content-Type" , "application/json; charset=utf-8" )
w . WriteHeader ( http . StatusServiceUnavailable )
_ , _ = w . Write ([] byte ( `{"error":"not installed","code":"install_required","redirect":"/setup"}` ))
return
}
2026-05-23 20:32:12 +08:00
if ! middleware . ValidateAPIKey ( r ) {
2026-08-15 13:30:35 +08:00
metrics . AuthFailed . Inc ( telemetry . Labels {})
2026-06-26 23:09:27 +08:00
log . Printf ( "警告: API Key 鉴权失败 - 路径=%s 客户端=%s 远端=%s" ,
r . URL . Path , middleware . GetClientIP ( r ), r . RemoteAddr )
2026-05-23 20:32:12 +08:00
middleware . SendJSONError ( w , http . StatusUnauthorized , "Invalid API key provided." , "invalid_request_error" , "invalid_api_key" )
return
}
2026-09-21 10:50:03 +08:00
if err := setting . GetTTSConfigErr (); err != nil {
2026-08-15 13:30:35 +08:00
log . Printf ( "警告: TTS配置未就绪,拒绝请求 - 错误=%v 路径=%s 客户端=%s" ,
2026-09-21 10:50:03 +08:00
err , r . URL . Path , middleware . GetClientIP ( r ))
2026-05-23 20:32:12 +08:00
middleware . SendJSONError ( w , http . StatusServiceUnavailable , "TTS service configuration error. Please check environment variables and restart the service." , "configuration_error" , "service_unavailable" )
return
}
2026-06-23 10:23:44 +08:00
r . Body = http . MaxBytesReader ( w , r . Body , common . MaxRequestBodySize )
2026-05-23 20:32:12 +08:00
body , err := io . ReadAll ( r . Body )
if err != nil {
2026-05-26 10:42:41 +08:00
if strings . Contains ( err . Error (), "request body too large" ) {
2026-06-26 23:09:27 +08:00
log . Printf ( "警告: 请求体过大 - 路径=%s 客户端=%s 限制=%d字节" ,
r . URL . Path , middleware . GetClientIP ( r ), common . MaxRequestBodySize )
2026-06-26 19:12:50 +08:00
http . Error ( w , "Request body too large" , http . StatusRequestEntityTooLarge )
2026-05-26 10:42:41 +08:00
return
}
2026-06-26 23:09:27 +08:00
log . Printf ( "警告: 读取请求体失败 - 路径=%s 客户端=%s 错误=%v" ,
r . URL . Path , middleware . GetClientIP ( r ), err )
2026-05-26 10:42:41 +08:00
http . Error ( w , "Failed to read request body" , http . StatusBadRequest )
2026-05-23 20:32:12 +08:00
return
}
var req dto . OpenAITTSRequest
if err := json . Unmarshal ( body , & req ); err != nil {
2026-06-26 23:09:27 +08:00
log . Printf ( "警告: JSON 解析失败 - 路径=%s 客户端=%s 错误=%v body前200字节=%q" ,
r . URL . Path , middleware . GetClientIP ( r ), err , truncateForLog ( body , 200 ))
2026-05-23 20:32:12 +08:00
http . Error ( w , "Invalid JSON" , http . StatusBadRequest )
return
}
2026-05-24 11:52:57 +08:00
if req . Model != "" {
if len ( req . Model ) > common . MaxModelNameLength {
2026-06-26 23:09:27 +08:00
log . Printf ( "警告: Model 名过长 - 路径=%s 客户端=%s 长度=%d 限制=%d" ,
r . URL . Path , middleware . GetClientIP ( r ), len ( req . Model ), common . MaxModelNameLength )
2026-05-24 11:52:57 +08:00
http . Error ( w , fmt . Sprintf ( "Model name too long (max %d characters)" , common . MaxModelNameLength ), http . StatusBadRequest )
return
}
if strings . ContainsAny ( req . Model , "\x00\n\r\t" ) {
2026-06-26 23:09:27 +08:00
log . Printf ( "警告: Model 名含非法字符 - 路径=%s 客户端=%s model前50字节=%q" ,
r . URL . Path , middleware . GetClientIP ( r ), truncateForLog ([] byte ( req . Model ), 50 ))
2026-05-24 11:52:57 +08:00
http . Error ( w , "Model name contains invalid characters" , http . StatusBadRequest )
return
}
}
2026-05-23 20:32:12 +08:00
if req . Input == "" {
2026-06-26 23:09:27 +08:00
log . Printf ( "警告: input 字段为空 - 路径=%s 客户端=%s" , r . URL . Path , middleware . GetClientIP ( r ))
2026-05-23 20:32:12 +08:00
http . Error ( w , "Input text is required" , http . StatusBadRequest )
return
}
if len ( req . Input ) > common . MaxTextLength {
2026-06-26 23:09:27 +08:00
log . Printf ( "警告: input 文本过长 - 路径=%s 客户端=%s 长度=%d 限制=%d" ,
r . URL . Path , middleware . GetClientIP ( r ), len ( req . Input ), common . MaxTextLength )
2026-05-23 20:32:12 +08:00
http . Error ( w , fmt . Sprintf ( "Input text too long (max %d characters)" , common . MaxTextLength ), http . StatusBadRequest )
return
}
speed := req . Speed
if speed <= 0 {
speed = common . DefaultSpeed
}
if speed < common . MinSpeed {
speed = common . MinSpeed
}
if speed > common . MaxSpeed {
speed = common . MaxSpeed
}
2026-08-15 13:30:35 +08:00
clientFormat := resolveClientFormat ( req . ResponseFormat )
2026-05-23 20:32:12 +08:00
2026-09-21 10:50:03 +08:00
opts := setting . GetTTSOptions ()
2026-08-15 13:30:35 +08:00
opts . Text = req . Input
2026-08-29 22:40:53 +08:00
// M3: voice 路由
2026-08-30 13:09:40 +08:00
// - voice 为空 → 走 LoadRuntimeConfig 解析过的 opts.Speaker (已是真 speaker ID,
// default_speaker 是 voice 名,LoadRuntimeConfig 查 voice 表后替换)
2026-08-29 22:40:53 +08:00
// - voice 非空 → 查 voices 表,替换 opts.Speaker / ResourceID / Model
// - 命中但 enabled=0 → 仍可用(用户显式传 voice 即覆盖 enabled 状态;若想禁用在 admin UI 关掉就行)
// - 未命中 → 400 "unknown voice: <name>"
if req . Voice != "" {
s := GetAdminStore ()
if s == nil {
log . Printf ( "警告: voice=%s 路由但 store 未初始化 - 路径=%s" , req . Voice , r . URL . Path )
middleware . SendJSONError ( w , http . StatusServiceUnavailable ,
"voice routing requires database; not initialized" ,
"configuration_error" , "db_not_ready" )
return
}
v , err := s . VoiceGetByName ( req . Voice )
if err != nil {
if err == store . ErrNotFound {
2026-08-30 19:16:04 +08:00
log . Printf ( "警告: 未知 voice=%q - 路径=%s 客户端=%s" , req . Voice , r . URL . Path , middleware . GetClientIP ( r ))
2026-08-29 22:40:53 +08:00
middleware . SendJSONError ( w , http . StatusBadRequest ,
2026-08-30 19:16:04 +08:00
fmt . Sprintf ( "unknown voice: '%s'" , req . Voice ),
2026-08-29 22:40:53 +08:00
"invalid_request_error" , "unknown_voice" )
return
}
log . Printf ( "警告: voice 查库失败 - 错误=%v voice=%s" , err , req . Voice )
middleware . SendJSONError ( w , http . StatusInternalServerError ,
"voice lookup failed" , "server_error" , "db_read_failed" )
return
}
2026-09-21 10:50:03 +08:00
// 覆盖 opts(API key / UID 保留自 setting.GetTTSOptions 快照)
2026-08-30 19:16:04 +08:00
if ! v . Enabled {
log . Printf ( "警告: voice=%q 已禁用 - 客户端=%s" , req . Voice , middleware . GetClientIP ( r ))
middleware . SendJSONError ( w , http . StatusForbidden ,
fmt . Sprintf ( "voice '%s' is disabled" , req . Voice ),
"invalid_request_error" , "voice_disabled" )
return
}
2026-08-29 22:40:53 +08:00
opts . Speaker = v . Speaker
opts . ResourceID = v . ResourceID
if v . Model != "" {
opts . Model = v . Model
}
log . Printf ( "[tts] voice=%s 命中 (speaker=%s resource=%s model=%s) - 客户端=%s" ,
2026-09-12 14:34:06 +08:00
req . Voice , telemetry . MaskSpeaker ( v . Speaker ), telemetry . MaskResourceID ( v . ResourceID ), v . Model , middleware . GetClientIP ( r ))
2026-08-29 22:40:53 +08:00
}
2026-09-21 10:50:03 +08:00
ctx , cancel := context . WithTimeout ( r . Context (), setting . GetTTSTimeout ())
2026-08-15 13:30:35 +08:00
defer cancel ()
result , err := volcano . Synthesis ( ctx , volcanoClient , opts , req . Input , clientFormat , speed , adapterRec )
duration := time . Since ( start )
finalLabels := telemetry . Labels {
"format" : clientFormat ,
2026-08-29 21:42:57 +08:00
// speaker 是火山复刻音色 ID(用户付费资产),不能直接出现在 /metrics label 里
//(无鉴权可枚举)。用 sha1[:8] 替代:同 speaker 同 label 保留 per-voice 观测,
//但反推不出原值。Admin UI 想要看原名通过 /api/voices 拿 name 字段。
"speaker" : telemetry . SpeakerLabel ( opts . Speaker ),
2026-08-15 13:30:35 +08:00
"model" : opts . Model ,
}
2026-05-23 20:32:12 +08:00
if err != nil {
2026-08-15 13:30:35 +08:00
finalLabels [ "status" ] = classifyStatus ( err )
metrics . RequestTotal . Inc ( finalLabels )
metrics . RequestDuration . Observe ( duration . Seconds (), telemetry . Labels { "status" : finalLabels [ "status" ], "format" : clientFormat })
2026-06-26 23:09:27 +08:00
log . Printf ( "警告: TTS 合成失败 - 路径=%s 客户端=%s 文本长度=%d 耗时=%v 错误=%v" ,
r . URL . Path , middleware . GetClientIP ( r ), len ( req . Input ), duration , err )
2026-08-15 19:03:37 +08:00
middleware . SendJSONError ( w , http . StatusInternalServerError , "TTS synthesis failed." , "server_error" , "synthesis_failed" )
2026-05-23 20:32:12 +08:00
return
}
2026-08-15 13:30:35 +08:00
finalLabels [ "status" ] = "ok"
metrics . RequestTotal . Inc ( finalLabels )
metrics . RequestDuration . Observe ( duration . Seconds (), telemetry . Labels { "status" : "ok" , "format" : clientFormat })
2026-05-23 20:32:12 +08:00
2026-08-15 13:30:35 +08:00
w . Header (). Set ( "Content-Type" , contentTypeFor ( result . Format ))
2026-05-23 20:32:12 +08:00
w . Header (). Set ( "Content-Length" , fmt . Sprintf ( "%d" , len ( result . AudioData )))
w . Header (). Set ( "X-Request-Id" , result . ReqID )
w . WriteHeader ( http . StatusOK )
2026-09-12 15:53:07 +08:00
if n , err := w . Write ( result . AudioData ); err != nil {
// header 已发,无法改 status code;只记日志供排查(常见:客户端中途断开 → broken pipe / connection reset)
log . Printf ( "警告: 响应写入失败 - 路径=%s 客户端=%s 已写=%d/%d 错误=%v" ,
r . URL . Path , middleware . GetClientIP ( r ), n , len ( result . AudioData ), err )
}
2026-05-23 20:32:12 +08:00
}
2026-08-15 13:30:35 +08:00
func classifyStatus ( err error ) string {
if ue , ok := err .( * volcano . UpstreamError ); ok {
switch ue . Stage {
case "request" :
return "request_error"
case "http" :
return fmt . Sprintf ( "http_%d" , ue . Code )
case "stream" :
return "upstream_error"
case "wrap" :
return "wrap_error"
}
}
return "internal_error"
}
func contentTypeFor ( format string ) string {
switch strings . ToLower ( format ) {
case "wav" :
return "audio/wav"
case "mp3" :
return "audio/mpeg"
case "ogg_opus" , "opus" :
return "audio/ogg"
case "pcm" :
return "audio/L16"
case "aac" :
return "audio/aac"
case "flac" :
return "audio/flac"
}
return "application/octet-stream"
}
// HealthHandler 暴露运行期状态;无鉴权。
2026-10-04 00:45:59 +08:00
// HealthzHandler GET /healthz —— 匿名存活探针,**只回 200 与字面量 "ok"**。
//
// 为什么单独做这个:v0.3.0 把详细健康数据(/health)收口到管理鉴权之后,
// 但 K8s liveness/readiness、Docker HEALTHCHECK、负载均衡健康检查默认都不带 Authorization。
// 若把它们继续指向 /health,加鉴权后会一律 401,导致探针失败、Pod 反复重启。
//
// 因此本端点刻意**不返回任何字段**(无版本、无内存、无配置状态、无模式信息),
// 只用于回答"进程还在不在"。运维要细节请走鉴权后的 /health。
func HealthzHandler ( w http . ResponseWriter , r * http . Request ) {
w . Header (). Set ( "Content-Type" , "text/plain; charset=utf-8" )
w . Header (). Set ( "Cache-Control" , "no-store" )
w . WriteHeader ( http . StatusOK )
_ , _ = w . Write ([] byte ( "ok" ))
}
2026-05-23 20:32:12 +08:00
func HealthHandler ( w http . ResponseWriter , r * http . Request ) {
w . Header (). Set ( "Content-Type" , "application/json" )
2026-08-29 20:44:33 +08:00
// 安装模式下 /health 仍然 200,但通过 installed 字段让探针/运维识别
// (Kubernetes readiness probe 可以用 installed=false 决定是否放流量)
mode := installer . GetMode ()
if mode == installer . ModeSetup {
w . WriteHeader ( http . StatusOK ) // 200,因为进程活着,只是还没初始化
2026-09-21 10:50:03 +08:00
} else if setting . GetTTSConfigErr () != nil {
2026-05-23 20:32:12 +08:00
w . WriteHeader ( http . StatusServiceUnavailable )
} else {
w . WriteHeader ( http . StatusOK )
}
2026-08-15 13:30:35 +08:00
env := setting . CheckEnvironmentVariables ()
allRequired := env [ "all_required_vars_set" ].( bool )
2026-05-23 20:32:12 +08:00
status := "ok"
2026-08-29 20:44:33 +08:00
if mode == installer . ModeSetup {
status = "not_installed"
} else if ! allRequired {
2026-05-23 20:32:12 +08:00
status = "configuration_error"
}
2026-08-15 13:30:35 +08:00
resp := dto . HealthResponse {
2026-05-23 20:32:12 +08:00
Status : status ,
Service : "ByteDance TTS to OpenAI API Adapter" ,
2026-08-27 10:18:34 +08:00
Version : version . Version ,
Commit : version . Commit ,
2026-05-23 20:32:12 +08:00
Uptime : fmt . Sprintf ( "%.0f seconds" , time . Since ( startTime ). Seconds ()),
StartTime : startTime . Format ( time . RFC3339 ),
2026-08-15 13:30:35 +08:00
Memory : collectMemorySnapshot (),
2026-05-23 20:32:12 +08:00
ConfigStatus : dto . ConfigStatusResponse {
2026-08-15 13:30:35 +08:00
AllRequiredVarsSet : allRequired ,
2026-09-21 10:50:03 +08:00
ConfigError : setting . GetTTSConfigErr () != nil ,
Error : configErrorMessage ( setting . GetTTSConfigErr ()),
2026-05-23 20:32:12 +08:00
},
2026-08-29 20:44:33 +08:00
Installed : mode == installer . ModeNormal ,
Mode : mode . String (),
2026-05-23 20:32:12 +08:00
}
2026-08-15 13:30:35 +08:00
json . NewEncoder ( w ). Encode ( resp )
2026-05-23 20:32:12 +08:00
}
2026-09-21 10:50:03 +08:00
// configErrorMessage 把运行时配置错误(setting.GetTTSConfigErr())安全地转成可对外暴露的字符串。
2026-08-30 22:43:07 +08:00
// 仅在 normal 模式且有错时调用, error 为 nil 时返 "" (被 omitempty 跳过)。
func configErrorMessage ( err error ) string {
if err == nil {
return ""
}
return err . Error ()
}
2026-05-23 20:32:12 +08:00
var startTime time . Time
2026-08-15 13:30:35 +08:00
func SetStartTime ( t time . Time ) { startTime = t }
func collectMemorySnapshot () map [ string ] interface {} {
var ms runtime . MemStats
runtime . ReadMemStats ( & ms )
return map [ string ] interface {}{
"heap_alloc" : ms . HeapAlloc ,
"heap_inuse" : ms . HeapInuse ,
"goroutines" : runtime . NumGoroutine (),
}
2026-05-23 20:32:12 +08:00
}