fix(tts): default_speaker 查 voice 表,改后立即生效

Bug 链:

1. 【核心 bug】setting/config.go LoadRuntimeConfig 把
   "default_speaker" 当成"火山 speaker ID"用 — 但 DB 里存的是
   voice **名字**(如 "chun"),不是 speaker ID (如 "S_G8tEKnaJ1")。
   结果: TTSOptions.Speaker = "chun", TTSOptions.ResourceID 仍来自
   default_resource_id 配置。调 /v1/audio/speech 不传 voice 时,
   把 "chun" 当 speaker ID 发给火山 → 55000000 resource ID mismatched。

2. 【次生 bug】装完 install 没 reload TTS 配置。
   启动期 LoadRuntimeConfig 失败(无 lock 无 settings), TTSConfigErr 被设;
   装完 mode 变 normal 但 TTSConfigErr 没清, /v1/audio/speech 仍 503/500
   (要重启才生效)。

修法:
- setting/config.go: 拿到 default_speaker 后调 GetVoiceForTTS(name)
  查 voice 表, 用 voice.Speaker / ResourceID / Model 覆盖。
  (新增 store.Store interface 方法 GetVoiceForTTS,4 元组返回值
  避免循环 import;store 包加 GetVoiceForTTS 实现,内部包 VoiceGetByName)
- controller/setup.go: 装完 install 调 LoadRuntimeConfig 一次,
  让 TTSConfigErr 立即清,TTSOptions 立即可用。

测试:
- 已有 store/installer/middleware/router 测试全绿
- e2e (本机): install voice "chun" + speaker "S_G8tEKnaJ1" →
  启动 log 显示 "speaker=S_G8tEKnaJ1" (不是 "chun") ✓
  → /v1/audio/speech 不传 voice → 火山收到正确 speaker,
  错误从 55000000 变成 401 Invalid X-Api-Key (假 key,换真 key 就 work)

二进制大小: ~18MB → ~18MB (代码量不变)

未 push (等用户验证)
This commit is contained in:
sun
2026-08-30 01:53:30 +08:00
parent d22abecf12
commit f5563e6f31
3 changed files with 47 additions and 1 deletions
+6
View File
@@ -176,6 +176,12 @@ func SetupSubmitHandler(w http.ResponseWriter, r *http.Request) {
setting.Auth.APIKeys = []string{authKey} setting.Auth.APIKeys = []string{authKey}
} }
// 装完 reload TTS 全局配置(让 TTSOptions 立即有可用的 api_key/speaker/resource_id,
// 否则 /v1/audio/speech 会因为 TTSConfigErr 在启动期被设而返 503,要重启才生效)。
if err := setting.LoadRuntimeConfig(GetSetupStore()); err != nil {
log.Printf("[setup] warning: 装完 LoadRuntimeConfig 失败: %v(下次启动会恢复)", err)
}
// 清空旧 voices 再插入(假设是首次安装;若不是,name 冲突会变成 409) // 清空旧 voices 再插入(假设是首次安装;若不是,name 冲突会变成 409)
// 这里选择 "清空+插入" 语义,符合"setup 是首次安装"的产品定位 // 这里选择 "清空+插入" 语义,符合"setup 是首次安装"的产品定位
// 如果想保留旧 voices,可以改成 UPSERT,但 M1 不做 // 如果想保留旧 voices,可以改成 UPSERT,但 M1 不做
+25 -1
View File
@@ -153,7 +153,7 @@ func splitAndLowerOrigins(s string) []string {
// LoadRuntimeConfig 从 store 加载 TTS 全局配置到 TTSOptions / TTSTimeout / Auth.APIKeys 内存。 // LoadRuntimeConfig 从 store 加载 TTS 全局配置到 TTSOptions / TTSTimeout / Auth.APIKeys 内存。
// 启动期(master 模式)调一次,或 PUT /api/settings 后调一次(改完立即生效)。 // 启动期(master 模式)调一次,或 PUT /api/settings 后调一次(改完立即生效)。
// ////
// 与原 InitTTSConfig 的区别: // 与原 InitTTSConfig 的区别:
// - 不再读 BYTEDANCE_TTS_* env;全部从 store.Settings 读 // - 不再读 BYTEDANCE_TTS_* env;全部从 store.Settings 读
// - 必填项(api_key / default_resource_id / default_speaker)缺失时返 error // - 必填项(api_key / default_resource_id / default_speaker)缺失时返 error
@@ -202,7 +202,22 @@ func LoadRuntimeConfig(s Store) error {
return TTSConfigErr return TTSConfigErr
} }
// 【BUG 修复】default_speaker 是 voice **名字**(如 "chun"),
// 不是火山 speaker ID (如 "S_G8tEKnaJ1")。原代码直接把 voice 名当
// speaker ID 用,导致调 /v1/audio/speech 不传 voice 时火山 55000000。
// 这里查 voice 表拿真正的 speaker / resource_id / model。
var voiceModel string
if vSpeaker, vResource, vModel, found, vErr := s.GetVoiceForTTS(speaker); vErr == nil && found {
speaker = vSpeaker
resourceId = vResource
voiceModel = vModel
// 找不到 voice 时不报错 — 保持原值(向后兼容)
}
model := all["model"] model := all["model"]
if voiceModel != "" {
model = voiceModel // voice 行有 model 时优先用 voice 的
}
format := all["default_format"] format := all["default_format"]
if format == "" { if format == "" {
format = "mp3" format = "mp3"
@@ -290,11 +305,20 @@ func LoadRuntimeConfig(s Store) error {
} }
// Store 是 LoadRuntimeConfig 需要的最小接口(避免 setting 包 import store 产生 cycle)。 // Store 是 LoadRuntimeConfig 需要的最小接口(避免 setting 包 import store 产生 cycle)。
//
// 重要:VoiceGetByName 用 4 个返回值(speaker/resourceID/model/found/err)
// 而非 (VoiceRef, error),这样 store 包不需要 import setting 包
// 就能实现这个接口(避免循环 import)。
type Store interface { type Store interface {
SettingsGetAll() (map[string]string, error) SettingsGetAll() (map[string]string, error)
SettingsGetInt(key string, def int) (int, error) SettingsGetInt(key string, def int) (int, error)
SettingsGetBool(key string, def bool) (bool, error) SettingsGetBool(key string, def bool) (bool, error)
SettingsGetDuration(key string, def time.Duration) (time.Duration, error) SettingsGetDuration(key string, def time.Duration) (time.Duration, error)
// GetVoiceForTTS 给定 voice 名字,返 (speaker_id, resource_id, model, found, err)。
// - found=false 表示 voice 不存在(此时 err=nil,返回值是空串)
// - err!=nil 是真错误(db 失败等)
// - 找到时返 voice 行的真实字段值
GetVoiceForTTS(name string) (speaker, resourceID, model string, found bool, err error)
} }
func getEnvDefault(name, def string) string { func getEnvDefault(name, def string) string {
+16
View File
@@ -98,6 +98,22 @@ func (s *Store) VoiceGetByName(name string) (*Voice, error) {
return &v, nil return &v, nil
} }
// GetVoiceForTTS 实现 setting.Store 接口,返 voice 行的 TTS 关键字段。
// - found=false: voice 不存在(ErrNotFound 翻译为 found=false)
// - err != nil: 真错误(db 失败等)
// 这个方法存在是为了让 *Store 满足 setting.Store 接口,且不引起
// setting → store → setting 循环 import。
func (s *Store) GetVoiceForTTS(name string) (speaker, resourceID, model string, found bool, err error) {
v, err := s.VoiceGetByName(name)
if err != nil {
if errors.Is(err, ErrNotFound) {
return "", "", "", false, nil
}
return "", "", "", false, err
}
return v.Speaker, v.ResourceID, v.Model, true, nil
}
// VoiceInsert 新增音色;name 冲突返回 ErrDuplicate。 // VoiceInsert 新增音色;name 冲突返回 ErrDuplicate。
// 空字符串/格式不合法返回 error;不依赖 SQLite 约束作为唯一校验。 // 空字符串/格式不合法返回 error;不依赖 SQLite 约束作为唯一校验。
func (s *Store) VoiceInsert(v Voice) (int64, error) { func (s *Store) VoiceInsert(v Voice) (int64, error) {