feat(settings): M3 全局设置 + voice 动态路由 + env 收敛

M3 完成:env 业务配置全进 DB,WebUI 可改,改完立即生效(无需重启)。

新增:
- controller/settings.go: 3 个 handler
  - GET /api/settings: 读所有设置,api_key 永远打码
  - PUT /api/settings: 部分更新(白名单字段);default_speaker 改时校验
    音色必须在 voices 表里(避免引用不存在);改完调 LoadRuntimeConfig 即时生效
  - PUT /api/settings/api-key: 独立端点,只改 api_key

改造:
- setting/config.go: 用 LoadRuntimeConfig(store) 替代 InitTTSConfig
  - 不再读 BYTEDANCE_TTS_* env,完全从 store.Settings 拿
  - 必填项缺失返 error,设 TTSConfigErr(/v1/audio/speech 返 503)
  - 定义 Store 接口(避免 setting → store 循环 import)
  - 字段映射: BYTEDANCE_TTS_API_KEY → api_key, BYTEDANCE_TTS_RESOURCE_ID
    → default_resource_id, ... 全部一一对应
- controller/tts.go: voice 路由
  - voice 为空 → 用 setting.TTSOptions.Speaker (即 store.default_speaker)
  - voice 非空 → 查 voices 表,替换 opts.Speaker / ResourceID / Model
  - 命中但 enabled=0 → 仍可用(显式 voice 覆盖)
  - 未命中 → 400 unknown_voice
  - voice 路由日志: "[tts] voice=xxx 命中 (speaker=... resource=... model=...)"
- router/admin.html: 加 #settings tab (24.78KB, 仍 <30KB 预算)
  - API key 区:打码显示 + 独立更新按钮
  - 路由/格式区:default_resource_id / default_speaker (下拉源 /api/voices) /
    default_format / sample_rate / model
  - "保存" 按钮 PUT /api/settings + 立即 reload (settingsOk 提示)
- router/router.go: 挂 /api/settings[/{api-key}] 三路由
- main.go: Detect 之后调 LoadRuntimeConfig,失败也允许进入
  (env 阶段无 db,LogStartupSummary 仍显示 BYTEDANCE_TTS_* 必填项用于诊断)

测试(都被 .gitignore 排除,本地保留):
- 已有 store/installer/middleware/router/telemetry 测试保持全绿
- e2e 验证(本机跑通 9 场景):
  - install 写入 3 voices + 7 settings
  - 重启后 main.go log: "TTS 运行时配置已加载(api_key=***, speaker=default-voice, resource=volc.megatts.icl, format=wav)" — 确认走 DB 不读 env
  - GET /api/settings 返所有字段
  - voice=clone-voice-1 命中 S_clone_1 + seed-tts-2.0-expressive
  - voice=clone-voice-2 命中 S_clone_2 + volc.megatts.icl-2
  - voice=unknown 返 400 unknown_voice
  - PUT /api/settings 改 default_speaker 即时生效
  - PUT /api/settings/api-key 改 key 即时生效
  - PUT /api/settings default_speaker=ghost 返 400 default_speaker_missing

注意: e2e 里的 TTS 500 是因为我用假 key 调上游,路由逻辑本身正确。
换真火山 API key 即可 work。

二进制大小: ~17MB → ~18MB (+1MB)

未 push(等用户测试 M1+M2+speaker fix 全部稳定后,跟 51bd7ae+70865a3 一起或单独 push)
This commit is contained in:
sun
2026-08-29 22:40:53 +08:00
parent 70865a3cf4
commit 9db038dd05
6 changed files with 524 additions and 39 deletions
+69 -38
View File
@@ -17,7 +17,7 @@ import (
// 全部环境变量读取的单一入口:其它包不允许直接 os.Getenv,只读这里的全局 Config。
// TTSOptions 是火山 v3 TTS 调用的完整参数集合,启动期由 InitTTSConfig 填充。
// TTSOptions 是火山 v3 TTS 调用的完整参数集合,启动期由 LoadRuntimeConfig 从 store 填充。
// 业务侧(controller)直接读取并传入 volcano.Synthesis。
var (
TTSOptions volcano.Options
@@ -74,7 +74,7 @@ func InitAllConfigs() {
InitAuthConfig()
InitCORSConfig()
InitSetupToken()
TTSConfigErr = InitTTSConfig()
// InitTTSConfig 不再这里调 — 改为启动期从 store 加载(LoadRuntimeConfig)。
}
func InitServerConfig() {
@@ -127,33 +127,62 @@ func normalizeOrigin(origin string) string {
return strings.ToLower(origin)
}
// InitTTSConfig 读取火山 TTS 必填和可选配置,填充 TTSOptions 与 TTSTimeout。
// 必填项缺失时返回 error,/v1/audio/speech 路由会拒绝请求。
func InitTTSConfig() error {
apiKey := os.Getenv("BYTEDANCE_TTS_API_KEY")
resourceId := os.Getenv("BYTEDANCE_TTS_RESOURCE_ID")
speaker := os.Getenv("BYTEDANCE_TTS_SPEAKER")
missing := []string{}
if apiKey == "" {
missing = append(missing, "BYTEDANCE_TTS_API_KEY")
}
if resourceId == "" {
missing = append(missing, "BYTEDANCE_TTS_RESOURCE_ID")
}
if speaker == "" {
missing = append(missing, "BYTEDANCE_TTS_SPEAKER")
}
if len(missing) > 0 {
return fmt.Errorf("缺少必需的环境变量: %v", missing)
// LoadRuntimeConfig 从 store 加载 TTS 全局配置到 TTSOptions / TTSTimeout 内存。
// 启动期(master 模式)调一次,或 PUT /api/settings 后调一次(改完立即生效)。
//
// 与原 InitTTSConfig 的区别:
// - 不再读 BYTEDANCE_TTS_* env;全部从 store.Settings 读
// - 必填项(api_key / default_resource_id / default_speaker)缺失时返 error
// - 失败时 TTSConfigErr 被设置,/v1/audio/speech 路由会返 503
// - 成功时清空 TTSConfigErr
//
// 字段映射(原 env → store key):
// BYTEDANCE_TTS_API_KEY → api_key
// BYTEDANCE_TTS_RESOURCE_ID → default_resource_id
// BYTEDANCE_TTS_SPEAKER → default_speaker
// BYTEDANCE_TTS_MODEL → model
// BYTEDANCE_TTS_FORMAT → default_format (默认 mp3)
// BYTEDANCE_TTS_SAMPLE_RATE → sample_rate (默认 24000)
// BYTEDANCE_TTS_BIT_RATE → bit_rate (默认 0)
// BYTEDANCE_TTS_MODEL_TYPE → model_type (默认 0)
// BYTEDANCE_TTS_EXPLICIT_LANGUAGE → explicit_language
// BYTEDANCE_TTS_ENABLE_SUBTITLE → enable_subtitle (默认 false)
// BYTEDANCE_TTS_TIMEOUT → TTSTimeout (默认 30s)
func LoadRuntimeConfig(s Store) error {
all, err := s.SettingsGetAll()
if err != nil {
TTSConfigErr = fmt.Errorf("read settings failed: %w", err)
return TTSConfigErr
}
model := os.Getenv("BYTEDANCE_TTS_MODEL")
format := getEnvDefault("BYTEDANCE_TTS_FORMAT", "mp3")
sampleRate := getEnvInt("BYTEDANCE_TTS_SAMPLE_RATE", 24000)
bitRate := getEnvInt("BYTEDANCE_TTS_BIT_RATE", 0)
modelType := getEnvInt("BYTEDANCE_TTS_MODEL_TYPE", 0)
explicitLanguage := os.Getenv("BYTEDANCE_TTS_EXPLICIT_LANGUAGE")
enableSubtitle := getEnvBool("BYTEDANCE_TTS_ENABLE_SUBTITLE", false)
apiKey := all["api_key"]
resourceId := all["default_resource_id"]
speaker := all["default_speaker"]
missing := []string{}
if apiKey == "" {
missing = append(missing, "api_key")
}
if resourceId == "" {
missing = append(missing, "default_resource_id")
}
if speaker == "" {
missing = append(missing, "default_speaker")
}
if len(missing) > 0 {
TTSConfigErr = fmt.Errorf("missing required settings: %v", missing)
return TTSConfigErr
}
model := all["model"]
format := all["default_format"]
if format == "" {
format = "mp3"
}
sampleRate, _ := s.SettingsGetInt("sample_rate", 24000)
bitRate, _ := s.SettingsGetInt("bit_rate", 0)
modelType, _ := s.SettingsGetInt("model_type", 0)
explicitLanguage := all["explicit_language"]
enableSubtitle, _ := s.SettingsGetBool("enable_subtitle", false)
var adds *volcano.Additions
if modelType != 0 || explicitLanguage != "" {
@@ -168,17 +197,10 @@ func InitTTSConfig() error {
}
TTSTimeout = common.DefaultTimeout
if ts := os.Getenv("BYTEDANCE_TTS_TIMEOUT"); ts != "" {
if d, err := time.ParseDuration(ts); err == nil {
TTSTimeout = d
} else {
log.Printf("无效的超时设置 %q,使用默认值 %v", ts, TTSTimeout)
}
}
common.DebugLog = getEnvBool("BYTEDANCE_TTS_DEBUG", false)
if common.DebugLog {
log.Println("调试日志已启用 BYTEDANCE_TTS_DEBUG")
if v, err := s.SettingsGetDuration("timeout", common.DefaultTimeout); err == nil {
TTSTimeout = v
} else {
TTSTimeout = common.DefaultTimeout
}
TTSOptions = volcano.Options{
@@ -195,9 +217,18 @@ func InitTTSConfig() error {
EnableSubtitle: enableSubtitle,
Additions: adds,
}
TTSConfigErr = nil
return nil
}
// Store 是 LoadRuntimeConfig 需要的最小接口(避免 setting 包 import store 产生 cycle)。
type Store interface {
SettingsGetAll() (map[string]string, error)
SettingsGetInt(key string, def int) (int, error)
SettingsGetBool(key string, def bool) (bool, error)
SettingsGetDuration(key string, def time.Duration) (time.Duration, error)
}
func getEnvDefault(name, def string) string {
if v := os.Getenv(name); v != "" {
return v