fix(auth): OpenAI 端 key 也走 DB,setup 是单一配置入口

修 M2/M3 遗漏:OPENAI_TTS_API_KEY (admin + 合成鉴权) 此前只能从 env
读,setup 前端没收集。装完用户还要回去设 env,体验断,违反
'setup 是单一配置源' 承诺。

改动:
- setting/config.go: LoadRuntimeConfig 顺便把 auth_key 灌到
  setting.Auth.APIKeys。优先级 DB > env(env 仅作 fallback,首次启
  动无 DB 时仍可用,向后兼容)。
- controller/setup.go: validateSetupSettings 加 auth_key 为必填,
  写 DB 后立即 setting.Auth.APIKeys = [auth_key](本进程内立刻生效)。
- controller/settings.go: GET /api/settings 返 auth_key (打码) +
  auth_key_set;新增 SettingsAuthKeyHandler (PUT /api/settings/auth-key),
  改完单独刷新 setting.Auth.APIKeys(避免被自踢,只下次请求生效)。
- router/router.go: 挂 /api/settings/auth-key 路由。
- router/setup.html: 加 OpenAI 鉴权 Key 字段(密码框,带提示);
  表单 default + submit 都加 auth_key。
- router/admin.html: 设置 tab 拆两张卡 — 鉴权 Key(独立保存按钮,
  改完会自踢当前会话)+ 火山 TTS 凭证;vue setup 加 authKeyInput +
  saveAuthKey 函数。
- main.go: 把 LoadRuntimeConfig(st) 提到 LogStartupSummary 之前,
  让启动日志反映真实运行时状态(DB 已加载的 key 数量)而不是
  过时的 env 状态。

未 push(等用户当前 M3 测完一起或单独)
This commit is contained in:
sun
2026-08-29 23:18:51 +08:00
parent 9db038dd05
commit a4ea53bfa0
7 changed files with 137 additions and 17 deletions
+48
View File
@@ -17,6 +17,8 @@ import (
type SettingsResponse struct { type SettingsResponse struct {
APIKey string `json:"api_key"` // 打码形式,例如 S_G8****naJ1 APIKey string `json:"api_key"` // 打码形式,例如 S_G8****naJ1
APIKeySet bool `json:"api_key_set"` // 是否已设置(用于前端判断要不要提示必填) APIKeySet bool `json:"api_key_set"` // 是否已设置(用于前端判断要不要提示必填)
AuthKey string `json:"auth_key"` // 鉴权 key 打码(客户端访问 + admin 登录用)
AuthKeySet bool `json:"auth_key_set"`
DefaultResourceID string `json:"default_resource_id"` DefaultResourceID string `json:"default_resource_id"`
DefaultSpeaker string `json:"default_speaker"` DefaultSpeaker string `json:"default_speaker"`
DefaultFormat string `json:"default_format"` DefaultFormat string `json:"default_format"`
@@ -50,6 +52,8 @@ func SettingsGetHandler(w http.ResponseWriter, r *http.Request) {
resp := SettingsResponse{ resp := SettingsResponse{
APIKey: maskAPIKeyField(all["api_key"]), APIKey: maskAPIKeyField(all["api_key"]),
APIKeySet: all["api_key"] != "", APIKeySet: all["api_key"] != "",
AuthKey: maskAPIKeyField(all["auth_key"]),
AuthKeySet: all["auth_key"] != "",
DefaultResourceID: all["default_resource_id"], DefaultResourceID: all["default_resource_id"],
DefaultSpeaker: all["default_speaker"], DefaultSpeaker: all["default_speaker"],
DefaultFormat: all["default_format"], DefaultFormat: all["default_format"],
@@ -243,6 +247,50 @@ func SettingsAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
} }
// SettingsAuthKeyRequest 是 PUT /api/settings/auth-key 的 body。
// auth_key 是 admin 鉴权和 /v1/audio/speech 鉴权用的 key(火山上游 key 是 api_key,这是两套)。
type SettingsAuthKeyRequest struct {
AuthKey string `json:"auth_key"`
}
// SettingsAuthKeyHandler PUT /api/settings/auth-key
// 鉴权: RequireAdmin。改完立即更新 setting.Auth.APIKeys(进程内生效),
// 下一个请求就用新 key — admin 自己改完要等下一次请求才能验证(避免改完立刻自踢)。
func SettingsAuthKeyHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
s := GetAdminStore()
if s == nil {
middleware.SendJSONError(w, http.StatusServiceUnavailable, "database not ready", "configuration_error", "db_not_ready")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<10)
var body SettingsAuthKeyRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request")
return
}
key := trimAll(body.AuthKey)
if key == "" {
middleware.SendJSONError(w, http.StatusBadRequest, "auth_key cannot be empty", "invalid_request_error", "missing_field")
return
}
if err := s.SettingsSet("auth_key", key); err != nil {
log.Printf("[settings] auth-key set: %v", err)
middleware.SendJSONError(w, http.StatusInternalServerError, "write auth_key failed", "server_error", "db_write_failed")
return
}
// 立即生效:不重新 LoadRuntimeConfig(那会覆盖其它字段),
// 只单独刷新 Auth.APIKeys
setting.Auth.APIKeys = []string{key}
log.Printf("[settings] auth_key updated, runtime active (next request uses new key)")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// maskAPIKeyField 复用 setting 包的打码风格(前 4 后 4 中间 ****)。 // maskAPIKeyField 复用 setting 包的打码风格(前 4 后 4 中间 ****)。
// 单独导出版本避免从 setting 包拉整个 APIKeyMask 之类的工具(那个是 unexported)。 // 单独导出版本避免从 setting 包拉整个 APIKeyMask 之类的工具(那个是 unexported)。
func maskAPIKeyField(s string) string { func maskAPIKeyField(s string) string {
+10 -1
View File
@@ -169,6 +169,13 @@ func SetupSubmitHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// 立即把 auth_key 灌到 setting.Auth.APIKeys,这样后续 /v1/audio/speech 和 /admin
// 在本进程内能立刻用新 key(无需等 LoadRuntimeConfig)。
authKey := strings.TrimSpace(body.Settings["auth_key"])
if authKey != "" {
setting.Auth.APIKeys = []string{authKey}
}
// 清空旧 voices 再插入(假设是首次安装;若不是,name 冲突会变成 409) // 清空旧 voices 再插入(假设是首次安装;若不是,name 冲突会变成 409)
// 这里选择 "清空+插入" 语义,符合"setup 是首次安装"的产品定位 // 这里选择 "清空+插入" 语义,符合"setup 是首次安装"的产品定位
// 如果想保留旧 voices,可以改成 UPSERT,但 M1 不做 // 如果想保留旧 voices,可以改成 UPSERT,但 M1 不做
@@ -224,8 +231,10 @@ func SetupSubmitHandler(w http.ResponseWriter, r *http.Request) {
} }
// validateSetupSettings 校验必填项。 // validateSetupSettings 校验必填项。
// auth_key (鉴权) 也是必填 — 让 setup 成为"单一配置入口",
// 用户装完不用再回去设 OPENAI_TTS_API_KEY env。
func validateSetupSettings(m map[string]string) error { func validateSetupSettings(m map[string]string) error {
required := []string{"api_key", "default_resource_id", "default_speaker"} required := []string{"api_key", "auth_key", "default_resource_id", "default_speaker"}
var missing []string var missing []string
for _, k := range required { for _, k := range required {
if strings.TrimSpace(m[k]) == "" { if strings.TrimSpace(m[k]) == "" {
+11 -6
View File
@@ -29,11 +29,11 @@ func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetFlags(log.LstdFlags | log.Lshortfile)
log.SetPrefix("[TTS-Server] ") log.SetPrefix("[TTS-Server] ")
// 1) 加载引导环境变量(PORT / OPENAI_TTS_API_KEY / TTS_ADMIN_KEY 等) // 1) 加载引导环境变量(PORT / TTS_ADMIN_KEY / OPENAI_TTS_API_KEY 兜底)
// 注: Auth.APIKeys 在 InitAllConfigs 里先读 env,后被 LoadRuntimeConfig 覆盖为 DB 值。
setting.InitAllConfigs() setting.InitAllConfigs()
metrics.Init() metrics.Init()
middleware.InitRateLimiter() middleware.InitRateLimiter()
setting.LogStartupSummary()
// 2) 启动期关键步骤:打开/建库 → 检测 lock → 判定模式 // 2) 启动期关键步骤:打开/建库 → 检测 lock → 判定模式
dbPath := ttsDBPath() dbPath := ttsDBPath()
@@ -47,23 +47,28 @@ func main() {
if res.Corrupted { if res.Corrupted {
log.Printf("[main] 注意: 启动时检测到 db 损坏并已自愈回退(备份=%s)", res.BackupTo) log.Printf("[main] 注意: 启动时检测到 db 损坏并已自愈回退(备份=%s)", res.BackupTo)
} }
// 注入 setup 控制器需要的 store + dbPath(无论哪种模式都注入,正常模式下备用)
// 3) 注入 setup/admin 控制器需要的句柄(M1+M2)
controller.SetSetupState(st, dbPath) controller.SetSetupState(st, dbPath)
// 注入 admin 控制器需要的 store + 指标文本写入器(M2)
controller.SetAdminStore(st) controller.SetAdminStore(st)
controller.SetMetricsTextWriter(func(w http.ResponseWriter) error { controller.SetMetricsTextWriter(func(w http.ResponseWriter) error {
metrics.Meter.Handler().ServeHTTP(w, &http.Request{}) metrics.Meter.Handler().ServeHTTP(w, &http.Request{})
return nil return nil
}) })
// M3: 从 store 加载运行时 TTS 配置(替代原来的 env-based InitTTSConfig)
// 4) M3: 从 store 加载运行时 TTS 配置(替代原来的 env-based InitTTSConfig)
// 必须在 LogStartupSummary 之前,这样日志显示的是真实状态(API key 已从 DB 加载,不再读 env)
if st != nil { if st != nil {
if err := setting.LoadRuntimeConfig(st); err != nil { if err := setting.LoadRuntimeConfig(st); err != nil {
log.Printf("[main] TTS 运行时配置加载失败:%v(将保持 install mode 或返 503)", err) log.Printf("[main] TTS 运行时配置加载失败:%v", err)
} else { } else {
log.Printf("[main] TTS 运行时配置已加载(api_key=***, speaker=%s, resource=%s, format=%s)", log.Printf("[main] TTS 运行时配置已加载(api_key=***, speaker=%s, resource=%s, format=%s)",
setting.TTSOptions.Speaker, setting.TTSOptions.ResourceID, setting.TTSOptions.Format) setting.TTSOptions.Speaker, setting.TTSOptions.ResourceID, setting.TTSOptions.Format)
} }
} }
// 5) 启动摘要日志(此时 Auth.APIKeys 已是 DB 值,日志反映真实状态)
setting.LogStartupSummary()
log.Printf("[main] 当前模式: %s (db=%s lock=%s)", res.Mode, dbPath, res.LockPath) log.Printf("[main] 当前模式: %s (db=%s lock=%s)", res.Mode, dbPath, res.LockPath)
controller.InitController() controller.InitController()
+35 -4
View File
@@ -228,8 +228,21 @@
<!-- Settings --> <!-- Settings -->
<div v-if="tab==='settings'"> <div v-if="tab==='settings'">
<div class="card"> <div class="card">
<div class="card-title">火山 TTS 凭证</div> <div class="card-title">鉴权 Key (登录 + 客户端调用)</div>
<div class="card-desc" style="color: var(--text-dim); font-size: 12px; margin-bottom: 16px;">改完点保存,立即生效,无需重启服务。</div> <div class="card-desc" style="color: var(--text-dim); font-size: 12px; margin-bottom: 16px;">客户端用这个 Key 调 <code>/v1/audio/speech</code>,也是登录 <code>/admin</code> 的密码。改完下一次请求立即生效(改完会被当前会话自踢,需重新登录)。</div>
<div class="field">
<label>当前 Key (打码: <span style="color: var(--accent); font-family: monospace;">{{ settings.auth_key || '(未设置)' }}</span>)</label>
<div style="display: flex; gap: 8px;">
<input type="password" v-model="authKeyInput" :placeholder="settings.auth_key_set ? '不改留空' : '输入新 Key'" style="flex: 1;">
<button class="btn btn-primary" @click="saveAuthKey" :disabled="!authKeyInput">更新 Key</button>
</div>
<div class="hint">独立端点 /api/settings/auth-key。改了之后,你的浏览器 401 自动跳回登录页,用新 Key 重登。</div>
</div>
</div>
<div class="card">
<div class="card-title">火山 TTS 上游凭证</div>
<div class="card-desc" style="color: var(--text-dim); font-size: 12px; margin-bottom: 16px;">调火山 v3 上游用的凭证。改完点保存,立即生效,无需重启服务。</div>
<div class="field"> <div class="field">
<label>API Key (打码显示: <span style="color: var(--accent); font-family: monospace;">{{ settings.api_key || '(未设置)' }}</span>)</label> <label>API Key (打码显示: <span style="color: var(--accent); font-family: monospace;">{{ settings.api_key || '(未设置)' }}</span>)</label>
@@ -331,6 +344,7 @@
const settings = ref({}); const settings = ref({});
const settingsForm = ref({ default_resource_id: '', default_speaker: '', default_format: 'mp3', sample_rate: 24000, model: '' }); const settingsForm = ref({ default_resource_id: '', default_speaker: '', default_format: 'mp3', sample_rate: 24000, model: '' });
const apiKeyInput = ref(''); const apiKeyInput = ref('');
const authKeyInput = ref('');
const savingSettings = ref(false); const savingSettings = ref(false);
const settingsErr = ref(''); const settingsErr = ref('');
const settingsOk = ref(false); const settingsOk = ref(false);
@@ -422,6 +436,23 @@
settingsErr.value = e.response?.data?.error?.message || e.message; settingsErr.value = e.response?.data?.error?.message || e.message;
} }
}; };
const saveAuthKey = async () => {
settingsErr.value = ''; settingsOk.value = false;
if (!authKeyInput.value) { settingsErr.value = 'Auth Key 不能为空'; return; }
try {
await http.put('/settings/auth-key', { auth_key: authKeyInput.value });
authKeyInput.value = '';
// 改完自己会 401,清掉 session key 让用户重新登录
setTimeout(() => {
sessionStorage.removeItem('ttsAdminKey');
apiKey.value = '';
}, 200);
settingsOk.value = true;
setTimeout(() => settingsOk.value = false, 3000);
} catch (e) {
settingsErr.value = e.response?.data?.error?.message || e.message;
}
};
const resetSettingsForm = () => { loadSettings(); }; const resetSettingsForm = () => { loadSettings(); };
const openAdd = () => { const openAdd = () => {
@@ -466,8 +497,8 @@
return { apiKey, keyInput, loginErr, login, logout, tab, overview, voices, actionErr, return { apiKey, keyInput, loginErr, login, logout, tab, overview, voices, actionErr,
showAdd, form, addErr, adding, openAdd, submitAdd, toggle, remove, showAdd, form, addErr, adding, openAdd, submitAdd, toggle, remove,
settings, settingsForm, apiKeyInput, savingSettings, settingsErr, settingsOk, settings, settingsForm, apiKeyInput, authKeyInput, savingSettings, settingsErr, settingsOk,
loadSettings, saveSettings, saveApiKey, resetSettingsForm, loadSettings, saveSettings, saveApiKey, saveAuthKey, resetSettingsForm,
formatUptime, shortPath, reloadAll }; formatUptime, shortPath, reloadAll };
}, },
}).mount('#app'); }).mount('#app');
+1
View File
@@ -84,6 +84,7 @@ func Setup() *mux.Router {
r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsGetHandler))).Methods("GET") r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsGetHandler))).Methods("GET")
r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsUpdateHandler))).Methods("PUT") r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsUpdateHandler))).Methods("PUT")
r.Handle("/api/settings/api-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAPIKeyHandler))).Methods("PUT") r.Handle("/api/settings/api-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAPIKeyHandler))).Methods("PUT")
r.Handle("/api/settings/auth-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAuthKeyHandler))).Methods("PUT")
// 业务路由 // 业务路由
r.HandleFunc("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS") r.HandleFunc("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS")
+13 -5
View File
@@ -96,11 +96,17 @@
<div v-if="errorMsg" class="banner">⚠ {{ errorMsg }}</div> <div v-if="errorMsg" class="banner">⚠ {{ errorMsg }}</div>
<div class="card"> <div class="card">
<div class="card-title">火山引擎 TTS 凭证</div> <div class="card-title">OpenAI 鉴权 + 火山引擎 TTS 凭证</div>
<div class="card-desc">从火山引擎控制台获取,API Key 仅在此页面输入,不会回显。</div> <div class="card-desc">从控制台/面板获取。两个 Key 仅在此页面输入,不会回显,装完不用再碰 env。</div>
<div class="field"> <div class="field">
<label>API Key <span class="req">*</span></label> <label>OpenAI 鉴权 Key <span class="req">*</span></label>
<input type="password" v-model="form.auth_key" placeholder="自定,例如 my-secret-key-2024" autocomplete="off">
<div class="hint">客户端用这个 Key 调 <code>/v1/audio/speech</code>,也是登录 <code>/admin</code> 的密码。装完可在 <code>/admin → 设置</code> 改。</div>
</div>
<div class="field">
<label>火山 TTS API Key <span class="req">*</span></label>
<input type="password" v-model="form.api_key" placeholder="例如: 5b4d7c2a-..." autocomplete="off"> <input type="password" v-model="form.api_key" placeholder="例如: 5b4d7c2a-..." autocomplete="off">
<div class="hint">调火山 v3 上游用的凭证,从 <a href="https://console.volcengine.com/" target="_blank">火山控制台</a> 获取。</div>
</div> </div>
<div class="field-row"> <div class="field-row">
<div class="field"> <div class="field">
@@ -166,6 +172,7 @@
createApp({ createApp({
setup() { setup() {
const form = ref({ const form = ref({
auth_key: '',
api_key: '', api_key: '',
default_resource_id: 'volc.megatts.default', default_resource_id: 'volc.megatts.default',
default_speaker: '', default_speaker: '',
@@ -200,8 +207,8 @@
const submit = async () => { const submit = async () => {
errorMsg.value = ''; errorMsg.value = '';
if (!form.value.api_key || !form.value.default_resource_id || !form.value.default_speaker || !form.value.token) { if (!form.value.auth_key || !form.value.api_key || !form.value.default_resource_id || !form.value.default_speaker || !form.value.token) {
errorMsg.value = '请填写所有必填项(API Key / 资源 ID / 默认音色 / Token)'; errorMsg.value = '请填写所有必填项(OpenAI Key / 火山 API Key / 资源 ID / 默认音色 / Token)';
return; return;
} }
if (!form.value.voices.length || form.value.voices.some(v => !v.name || !v.speaker || !v.resource_id)) { if (!form.value.voices.length || form.value.voices.some(v => !v.name || !v.speaker || !v.resource_id)) {
@@ -213,6 +220,7 @@
const r = await axios.post('/api/setup', { const r = await axios.post('/api/setup', {
token: form.value.token, token: form.value.token,
settings: { settings: {
auth_key: form.value.auth_key,
api_key: form.value.api_key, api_key: form.value.api_key,
default_resource_id: form.value.default_resource_id, default_resource_id: form.value.default_resource_id,
default_speaker: form.value.default_speaker, default_speaker: form.value.default_speaker,
+19 -1
View File
@@ -127,7 +127,7 @@ func normalizeOrigin(origin string) string {
return strings.ToLower(origin) return strings.ToLower(origin)
} }
// LoadRuntimeConfig 从 store 加载 TTS 全局配置到 TTSOptions / TTSTimeout 内存。 // LoadRuntimeConfig 从 store 加载 TTS 全局配置到 TTSOptions / TTSTimeout / Auth.APIKeys 内存。
// 启动期(master 模式)调一次,或 PUT /api/settings 后调一次(改完立即生效)。 // 启动期(master 模式)调一次,或 PUT /api/settings 后调一次(改完立即生效)。
// //
// 与原 InitTTSConfig 的区别: // 与原 InitTTSConfig 的区别:
@@ -148,6 +148,11 @@ func normalizeOrigin(origin string) string {
// BYTEDANCE_TTS_EXPLICIT_LANGUAGE → explicit_language // BYTEDANCE_TTS_EXPLICIT_LANGUAGE → explicit_language
// BYTEDANCE_TTS_ENABLE_SUBTITLE → enable_subtitle (默认 false) // BYTEDANCE_TTS_ENABLE_SUBTITLE → enable_subtitle (默认 false)
// BYTEDANCE_TTS_TIMEOUT → TTSTimeout (默认 30s) // BYTEDANCE_TTS_TIMEOUT → TTSTimeout (默认 30s)
//
// 鉴权 key(auth_key)优先级:DB > env OPENAI_TTS_API_KEY
// - 首次启动(无 DB 数据):用 env,保证向后兼容
// - 已 install:用 DB,env 不再读
// - DB 没 auth_key 但 env 有(env fallback):仍用 env
func LoadRuntimeConfig(s Store) error { func LoadRuntimeConfig(s Store) error {
all, err := s.SettingsGetAll() all, err := s.SettingsGetAll()
if err != nil { if err != nil {
@@ -217,6 +222,19 @@ func LoadRuntimeConfig(s Store) error {
EnableSubtitle: enableSubtitle, EnableSubtitle: enableSubtitle,
Additions: adds, Additions: adds,
} }
// 鉴权 key:DB > env(向后兼容)
authKey := all["auth_key"]
if authKey == "" {
authKey = os.Getenv("OPENAI_TTS_API_KEY")
}
// 用临时 slice 避免和 InitAuthConfig 抢同一个 Auth.APIKeys 底层
if authKey != "" {
Auth.APIKeys = []string{authKey}
} else {
Auth.APIKeys = nil
}
TTSConfigErr = nil TTSConfigErr = nil
return nil return nil
} }