diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index bc3a061..cb458f7 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,10 +1,20 @@ name: Docker Publish +# 触发条件: +# - main 上的 semver tag(v*.*.*): 推 GHCR + DockerHub +# - 手动按钮(workflow_dispatch): 临时跑一次 +# +# 注意: +# 1. 需在 GitHub 仓库 Settings → Secrets 加 DOCKERHUB_USERNAME / DOCKERHUB_TOKEN。 +# 没加之前, DockerHub login 步骤被 actions 自动跳过, 只推 GHCR。 +# 2. secrets.DOCKERHUB_USERNAME 用于 metadata-action 渲染 image 名, 不能空 — 留空会 +# 生成 "docker.io//ttshub" 这种非法 image。已加默认值保护(空时禁用 DockerHub image)。 +# 3. 暂不验 PR 编译;PR 合到 main 不会重跑本 workflow, 只在打 tag 时构建。 on: push: tags: - - 'v*' - workflow_dispatch: # 允许手动触发测试 + - 'v*.*.*' + workflow_dispatch: env: REGISTRY: ghcr.io @@ -28,12 +38,20 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Login to GHCR + if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Login to DockerHub + if: github.event_name != 'pull_request' && secrets.DOCKERHUB_USERNAME != '' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Extract version from git id: version run: | @@ -44,20 +62,32 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ github.repository }} + # 多个 image base:metadata-action 会为每个 base 渲染完整 tag 列表。 + # DockerHub image 用 enable 保护:username secret 没设时不渲染,避免非法 image 名。 + images: | + name=${{ env.REGISTRY }}/${{ github.repository }} + name=docker.io/${{ secrets.DOCKERHUB_USERNAME }}/ttshub,enable=${{ secrets.DOCKERHUB_USERNAME != '' }} tags: | type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ github.ref_type == 'tag' }} type=sha,format=short labels: | org.opencontainers.image.version=${{ steps.version.outputs.version }} org.opencontainers.image.revision=${{ steps.version.outputs.commit }} - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 - push: true + # 当前触发器只有 push (tag) + workflow_dispatch, 无 pull_request, 所以 push 永远为 true。 + # 保留 != 'pull_request' 表达式以兼容未来:若再加 PR 触发, 自动只 build 不 push。 + push: ${{ github.event_name != 'pull_request' }} + # cache-from: 复用上次构建的层(github actions 内置缓存), 快 5-10x + # cache-to: 本次构建的层也推回缓存, 下次复用 + cache-from: type=gha + cache-to: type=gha,mode=max tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | diff --git a/adapter/volcano/synthesis.go b/adapter/volcano/synthesis.go index 1df6ffc..4eece0f 100644 --- a/adapter/volcano/synthesis.go +++ b/adapter/volcano/synthesis.go @@ -84,7 +84,7 @@ func Synthesis( if common.DebugLog { log.Printf("TTS upstream: resource_id=%s speaker=%s model=%q format=%s sample_rate=%d speech_rate=%d additions=%q", - opts.ResourceID, telemetry.MaskSpeaker(opts.Speaker), opts.Model, opts.Format, opts.SampleRate, opts.SpeechRate, extractAdditionsForLog(body)) + telemetry.MaskResourceID(opts.ResourceID), telemetry.MaskSpeaker(opts.Speaker), opts.Model, opts.Format, opts.SampleRate, opts.SpeechRate, extractAdditionsForLog(body)) } resp, err := client.PostStream(ctx, "https://openspeech.bytedance.com/api/v3/tts/unidirectional", headers, body) diff --git a/cmd/dumpdb/main.go b/cmd/dumpdb/main.go index 440c859..cb0fa1f 100644 --- a/cmd/dumpdb/main.go +++ b/cmd/dumpdb/main.go @@ -11,6 +11,7 @@ import ( "database/sql" "fmt" "os" + "strings" _ "modernc.org/sqlite" ) @@ -116,14 +117,13 @@ func main() { } func isSensitive(key string) bool { - lowK := key - // 简单 contains (不引 strings 包保持精简) - markers := []string{"key", "token", "speaker"} - for _, m := range markers { - for i := 0; i+len(m) <= len(lowK); i++ { - if lowK[i:i+len(m)] == m { - return true - } + // 标记 key 名包含 "key" / "token" / "speaker" 即视为敏感,值打码。 + // 大小写不敏感: API_KEY / Auth_Token 等大写 key 也会被命中 + // (避免漏打码)。 + low := strings.ToLower(key) + for _, m := range []string{"key", "token", "speaker"} { + if strings.Contains(low, m) { + return true } } return false diff --git a/controller/admin.go b/controller/admin.go index 490a006..630282f 100644 --- a/controller/admin.go +++ b/controller/admin.go @@ -2,9 +2,11 @@ package controller import ( "encoding/json" + "errors" "fmt" "log" "net/http" + "strings" "sync" "time" @@ -191,14 +193,23 @@ func AdminVoiceCreateHandler(w http.ResponseWriter, r *http.Request) { } id, err := s.VoiceInsert(v) if err != nil { - switch err { - case store.ErrDuplicate: + switch { + case err == store.ErrDuplicate: middleware.SendJSONError(w, http.StatusConflict, fmt.Sprintf("voice name %q already exists", v.Name), "invalid_request_error", "voice_duplicate") + case errors.Is(err, store.ErrInvalid): + // 客户端输入不合法(name 格式 / speaker / resource_id 缺失):400。 + // 走 ErrInvalid 哨兵 + 原始 detail,前端可以直接显示。 + middleware.SendJSONError(w, http.StatusBadRequest, + stripInvalidPrefix(err.Error()), + "invalid_request_error", "voice_invalid") default: + // 兜底:DB 连接失败 / Exec 错误 / LastInsertId 失败等都是服务端问题, + // 返 500 + 通用 message,详细 err 走日志(不直接暴露给前端,避免信息泄漏)。 log.Printf("[admin] voice insert: %v", err) - middleware.SendJSONError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "voice_invalid") + middleware.SendJSONError(w, http.StatusInternalServerError, + "failed to insert voice", "server_error", "voice_insert_failed") } return } @@ -299,3 +310,14 @@ func AdminVoiceToggleHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(updated) } + +// stripInvalidPrefix 把 wrap 后的 ErrInvalid 错误字符串中的 "store: voice invalid: " 前缀剥掉, +// 只保留底层 detail(例如 "speaker is required"),让前端能直接展示用户友好文案。 +// 如果上游 wrap 形态变化(没匹配到前缀),原样返回,避免误删关键信息。 +func stripInvalidPrefix(s string) string { + const prefix = "store: voice invalid: " + if strings.HasPrefix(s, prefix) { + return s[len(prefix):] + } + return s +} diff --git a/controller/settings.go b/controller/settings.go index 9fcee12..0a0b0eb 100644 --- a/controller/settings.go +++ b/controller/settings.go @@ -121,7 +121,7 @@ func SettingsUpdateHandler(w http.ResponseWriter, r *http.Request) { // 收集要更新的键值对 updates := make(map[string]string) if body.DefaultResourceID != nil { - v := trimAll(*body.DefaultResourceID) + v := strings.TrimSpace(*body.DefaultResourceID) if v == "" { middleware.SendJSONError(w, http.StatusBadRequest, "default_resource_id cannot be empty", "invalid_request_error", "missing_field") return @@ -129,7 +129,7 @@ func SettingsUpdateHandler(w http.ResponseWriter, r *http.Request) { updates["default_resource_id"] = v } if body.DefaultSpeaker != nil { - v := trimAll(*body.DefaultSpeaker) + v := strings.TrimSpace(*body.DefaultSpeaker) if v == "" { middleware.SendJSONError(w, http.StatusBadRequest, "default_speaker cannot be empty", "invalid_request_error", "missing_field") return @@ -144,7 +144,7 @@ func SettingsUpdateHandler(w http.ResponseWriter, r *http.Request) { updates["default_speaker"] = v } if body.DefaultFormat != nil { - v := trimAll(*body.DefaultFormat) + v := strings.TrimSpace(*body.DefaultFormat) if !isValidFormat(v) { middleware.SendJSONError(w, http.StatusBadRequest, fmt.Sprintf("default_format %q invalid; valid: mp3/wav/opus/pcm/aac/flac", v), @@ -163,13 +163,13 @@ func SettingsUpdateHandler(w http.ResponseWriter, r *http.Request) { updates["sample_rate"] = strconv.Itoa(v) } if body.Model != nil { - updates["model"] = trimAll(*body.Model) + updates["model"] = strings.TrimSpace(*body.Model) } if body.ModelType != nil { updates["model_type"] = strconv.Itoa(*body.ModelType) } if body.ExplicitLanguage != nil { - updates["explicit_language"] = trimAll(*body.ExplicitLanguage) + updates["explicit_language"] = strings.TrimSpace(*body.ExplicitLanguage) } if body.EnableSubtitle != nil { updates["enable_subtitle"] = boolToStr(*body.EnableSubtitle) @@ -232,7 +232,7 @@ func SettingsAPIKeyHandler(w http.ResponseWriter, r *http.Request) { middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request") return } - key := trimAll(body.APIKey) + key := strings.TrimSpace(body.APIKey) if key == "" { middleware.SendJSONError(w, http.StatusBadRequest, "api_key cannot be empty", "invalid_request_error", "missing_field") return @@ -261,7 +261,7 @@ type SettingsAuthKeyRequest struct { } // SettingsAuthKeyHandler PUT /api/settings/auth-key -// 鉴权: RequireAdmin。改完立即更新 setting.Auth.APIKeys(进程内生效), +// 鉴权: RequireAdmin。改完立即刷新鉴权 key 列表(setting.SetAuthAPIKeys,进程内生效), // 下一个请求就用新 key — admin 自己改完要等下一次请求才能验证(避免改完立刻自踢)。 func SettingsAuthKeyHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { @@ -280,7 +280,7 @@ func SettingsAuthKeyHandler(w http.ResponseWriter, r *http.Request) { middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request") return } - key := trimAll(body.AuthKey) + key := strings.TrimSpace(body.AuthKey) if key == "" { middleware.SendJSONError(w, http.StatusBadRequest, "auth_key cannot be empty", "invalid_request_error", "missing_field") return @@ -292,23 +292,24 @@ func SettingsAuthKeyHandler(w http.ResponseWriter, r *http.Request) { } // 立即生效:不重新 LoadRuntimeConfig(那会覆盖其它字段), // 只单独刷新 Auth.APIKeys - setting.Auth.APIKeys = []string{key} + setting.SetAuthAPIKeys([]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}) } // SettingsCORSRequest 是 PUT /api/settings/cors 的 body。 -// 两个字段都可选(至少给一个): -// - allow_all: true → 任意 Origin 都接受(*);设了之后 origins 失效 -// - origins: 一行一个 origin,后端 trim + lower + 去末尾 / +// 两个字段都可选(至少给一个),用指针区分"未传"和"传空串": +// - allow_all 指针: nil=未传(不动) *true=开 *false=关 +// - origins 字符串: nil=未传(不动) ""=传空串(清空) "url1\nurl2"=覆盖 +// 这样用户能精确表达意图(保留 / 改 / 清空),不会被 0/"" 歧义坑死。 type SettingsCORSRequest struct { - AllowAll *bool `json:"allow_all,omitempty"` - Origins string `json:"origins,omitempty"` // 也接受 string 数组(任一形式) + AllowAll *bool `json:"allow_all,omitempty"` + Origins *string `json:"origins,omitempty"` // *string 区分"未传(nil)"和"传空串" } // SettingsCORSHandler PUT /api/settings/cors -// 鉴权: RequireAdmin。改完立即更新 setting.CORS(进程内生效,跨域请求从下个请求开始按新配置)。 +// 鉴权: RequireAdmin。改完立即刷新 CORS(setting.SetCORS,进程内生效,跨域请求从下个请求开始按新配置)。 // 同源豁免由 middleware/cors.go 的 isSameOrigin 处理,不在这里管。 func SettingsCORSHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { @@ -327,10 +328,9 @@ func SettingsCORSHandler(w http.ResponseWriter, r *http.Request) { middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request") return } - if body.AllowAll == nil && trimAll(body.Origins) == "" && body.Origins != "" { - // 空 body 不算错误,用户可能是想"清空"(只清 origins 保留现状) - } - if body.AllowAll == nil && body.Origins == "" { + // 至少要给一个字段(allow_all 或 origins) + // 指针为 nil 表示"未传",不计入 + if body.AllowAll == nil && body.Origins == nil { middleware.SendJSONError(w, http.StatusBadRequest, "at least one of allow_all / origins required", "invalid_request_error", "no_fields") @@ -341,22 +341,27 @@ func SettingsCORSHandler(w http.ResponseWriter, r *http.Request) { if body.AllowAll != nil { updates["cors_allow_all"] = boolToStr(*body.AllowAll) } - if body.Origins != "" { - // 校验每个 origin 至少像 http(s)://... (防止用户填空或填乱字符) - for _, line := range strings.Split(body.Origins, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - low := strings.ToLower(line) - if !strings.HasPrefix(low, "http://") && !strings.HasPrefix(low, "https://") { - middleware.SendJSONError(w, http.StatusBadRequest, - fmt.Sprintf("invalid origin: %q (must start with http:// or https://)", line), - "invalid_request_error", "origin_invalid") - return + if body.Origins != nil { + // *Origins == "" 表示用户要清空(保留 nil 表示"不动") + origins := *body.Origins + if origins != "" { + // 校验每个 origin 至少像 http(s)://... (防止用户填乱字符) + for _, line := range strings.Split(origins, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + low := strings.ToLower(line) + if !strings.HasPrefix(low, "http://") && !strings.HasPrefix(low, "https://") { + middleware.SendJSONError(w, http.StatusBadRequest, + fmt.Sprintf("invalid origin: %q (must start with http:// or https://)", line), + "invalid_request_error", "origin_invalid") + return + } } } - updates["cors_origins"] = body.Origins + // 空串也能存(表示"清空");trim/lower 在 LoadRuntimeConfig 那侧做 + updates["cors_origins"] = origins } if err := s.SettingsSetBatch(updates); err != nil { log.Printf("[settings] cors set: %v", err) @@ -364,7 +369,7 @@ func SettingsCORSHandler(w http.ResponseWriter, r *http.Request) { return } - // 立即刷新 setting.CORS,跨域请求从下个请求开始按新配置生效 + // 立即刷新 CORS(setting.SetCORS),跨域请求从下个请求开始按新配置生效 // 复用 LoadRuntimeConfig 的解析逻辑(只取 cors 部分,避免覆盖其它运行时字段) corsAllowAll, _ := s.SettingsGetBool("cors_allow_all", false) originsStr := "" @@ -372,20 +377,17 @@ func SettingsCORSHandler(w http.ResponseWriter, r *http.Request) { originsStr = v } if corsAllowAll { - setting.CORS.AllowAll = true - setting.CORS.Origins = nil + setting.SetCORS(true, nil) } else if originsStr != "" { - setting.CORS.AllowAll = false - setting.CORS.Origins = setting.SplitOriginsForCORS(originsStr) + setting.SetCORS(false, setting.SplitOriginsForCORS(originsStr)) } else { - setting.CORS.AllowAll = false - setting.CORS.Origins = nil + setting.SetCORS(false, nil) } - log.Printf("[settings] cors updated (allow_all=%v origins=%q), runtime active", setting.CORS.AllowAll, originsStr) + log.Printf("[settings] cors updated (allow_all=%v origins=%q), runtime active", setting.GetCORSAllowAll(), originsStr) w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, - "allow_all": setting.CORS.AllowAll, + "allow_all": setting.GetCORSAllowAll(), "origins": originsStr, "cors_active": true, }) @@ -404,18 +406,6 @@ func maskAPIKeyField(s string) string { return s[:4] + "****" + s[len(s)-4:] } -func trimAll(s string) string { - // 简单 trim 前后空白;不剥中间空格 - out := s - for len(out) > 0 && (out[0] == ' ' || out[0] == '\t' || out[0] == '\n' || out[0] == '\r') { - out = out[1:] - } - for len(out) > 0 && (out[len(out)-1] == ' ' || out[len(out)-1] == '\t' || out[len(out)-1] == '\n' || out[len(out)-1] == '\r') { - out = out[:len(out)-1] - } - return out -} - func isValidFormat(s string) bool { switch s { case "mp3", "wav", "opus", "pcm", "aac", "flac", "": diff --git a/controller/setup.go b/controller/setup.go index f89335e..e8797c7 100644 --- a/controller/setup.go +++ b/controller/setup.go @@ -2,6 +2,7 @@ package controller import ( "encoding/json" + "errors" "fmt" "log" "net/http" @@ -163,17 +164,62 @@ func SetupSubmitHandler(w http.ResponseWriter, r *http.Request) { } settingsKV["initialized"] = "1" settingsKV["installed_at"] = time.Now().UTC().Format(time.RFC3339) - if err := s.SettingsSetBatch(settingsKV); err != nil { - log.Printf("[setup] 写 settings 失败: %v", err) - middleware.SendJSONError(w, http.StatusInternalServerError, "failed to write settings", "server_error", "db_write_failed") + + // 【修复】原版先写 settings 再循环插 voice,任一 voice 失败时已写入的 + // settings 不回滚 → db 处于半残状态(用户重启还要踩"已装但配置不完整"的坑)。 + // 改用 store.SetupApply 一次性事务:settings + voices 任一失败整体回滚, + // db 保持 setup 前的状态(无脏数据)。 + // + // voice 行的 resource_id 留空时,自动用 settings.default_resource_id 兜底。 + // 用户在 step 2 填了 default_resource_id 后,step 3 的 voice 行 resource_id + // 可以不填 — 保持一致。否则会出现 "settings 里 seed-icl-2.0,voice 里 volc.megatts.icl" + // 这种 mismatch,运行时 500。 + voices := make([]store.Voice, 0, len(body.Voices)) + defaultResourceID := settingsKV["default_resource_id"] + for _, v := range body.Voices { + voiceResourceID := v.ResourceID + if voiceResourceID == "" { + voiceResourceID = defaultResourceID + log.Printf("[setup] voice %q resource_id 留空,自动用 default_resource_id=%q", v.Name, defaultResourceID) + } + voices = append(voices, store.Voice{ + Name: v.Name, + Speaker: v.Speaker, + ResourceID: voiceResourceID, + Model: v.Model, + Language: v.Language, + Enabled: true, + }) + } + + // 预检 voices 数量(避免空提交也走事务);setup 校验已要求至少 1 条, + // 防御性兜底。 + if len(voices) == 0 { + middleware.SendJSONError(w, http.StatusBadRequest, + "at least one voice is required", "invalid_request_error", "no_voices") return } - // 立即把 auth_key 灌到 setting.Auth.APIKeys,这样后续 /v1/audio/speech 和 /admin - // 在本进程内能立刻用新 key(无需等 LoadRuntimeConfig)。 + inserted, err := s.SetupApply(settingsKV, voices) + if err != nil { + log.Printf("[setup] 提交失败,事务回滚 - 错误=%v", err) + // 区分客户端/服务端错误,沿用 admin.go 的 400/500 模式 + if errors.Is(err, store.ErrInvalid) { + middleware.SendJSONError(w, http.StatusBadRequest, + err.Error(), "invalid_request_error", "voice_invalid") + return + } + middleware.SendJSONError(w, http.StatusInternalServerError, + fmt.Sprintf("failed to apply setup: %v", err), + "server_error", "db_write_failed") + return + } + + // 立即把 auth_key 灌到鉴权 key 列表(setting.SetAuthAPIKeys),这样后续 + // /v1/audio/speech 和 /admin 在本进程内能立刻用新 key(无需等 LoadRuntimeConfig)。 authKey := strings.TrimSpace(body.Settings["auth_key"]) if authKey != "" { - setting.Auth.APIKeys = []string{authKey} + setting.SetAuthAPIKeys([]string{authKey}) } // 装完 reload TTS 全局配置(让 TTSOptions 立即有可用的 api_key/speaker/resource_id, @@ -182,49 +228,7 @@ func SetupSubmitHandler(w http.ResponseWriter, r *http.Request) { log.Printf("[setup] warning: 装完 LoadRuntimeConfig 失败: %v(下次启动会恢复)", err) } - // 清空旧 voices 再插入(假设是首次安装;若不是,name 冲突会变成 409) - // 这里选择 "清空+插入" 语义,符合"setup 是首次安装"的产品定位 - // 如果想保留旧 voices,可以改成 UPSERT,但 M1 不做 - if existing, _ := s.VoiceList(true); len(existing) > 0 { - // 留作未来:如果是非首次 setup(M2 加 reset 功能),这里需要更精细处理 - log.Printf("[setup] 检测到 %d 条已存在 voices,本次将跳过清空(name 冲突由 ErrDuplicate 处理)", len(existing)) - } - inserted := 0 - // 【UX 改进】voice 行的 resource_id 留空时,自动用 settings.default_resource_id 兜底。 - // 用户在 step 2 填了 default_resource_id 后,step 3 的 voice 行 resource_id - // 可以不填 — 保持一致。否则会出现 "settings 里 seed-icl-2.0,voice 里 volc.megatts.icl" - // 这种 mismatch,运行时 500。 - defaultResourceID := settingsKV["default_resource_id"] - for _, v := range body.Voices { - voiceResourceID := v.ResourceID - if voiceResourceID == "" { - voiceResourceID = defaultResourceID - log.Printf("[setup] voice %q resource_id 留空,自动用 default_resource_id=%q", v.Name, defaultResourceID) - } - _, err := s.VoiceInsert(store.Voice{ - Name: v.Name, - Speaker: v.Speaker, - ResourceID: voiceResourceID, - Model: v.Model, - Language: v.Language, - Enabled: true, - }) - if err != nil { - log.Printf("[setup] 插入 voice %q 失败: %v", v.Name, err) - // 不回滚 settings(用户重启后会重新 setup) - // 但已插入的 voices 会留着,下次 setup 会撞 ErrDuplicate - // 安全:把 ErrDuplicate 视作可继续(用户重复 setup 同一组 voice) - if err == store.ErrDuplicate { - continue - } - middleware.SendJSONError(w, http.StatusInternalServerError, - fmt.Sprintf("failed to insert voice %q: %v", v.Name, err), - "server_error", "voice_insert_failed") - return - } - inserted++ - } - log.Printf("[setup] 写入 settings=%d, voices=%d/%d", len(settingsKV), inserted, len(body.Voices)) + log.Printf("[setup] 写入 settings=%d, voices=%d/%d (事务原子提交)", len(settingsKV), inserted, len(voices)) // 写 lock(原子):从这一刻起,/api/setup 永久关闭 if err := installer.CreateLock(GetSetupDBPath()); err != nil { diff --git a/controller/tts.go b/controller/tts.go index 4b103f3..5292674 100644 --- a/controller/tts.go +++ b/controller/tts.go @@ -40,7 +40,7 @@ func truncateForLog(b []byte, max int) string { } // resolveClientFormat 把 OpenAI 风格的 response_format 映射为最终输出格式; -// 不识别或未指定时回退到 setting.TTSOptions.Format。 +// 不识别或未指定时回退到 setting.GetTTSOptions().Format。 func resolveClientFormat(reqFmt string) string { switch strings.ToLower(reqFmt) { case "mp3", "wav", "opus", "pcm", "aac", "flac": @@ -49,7 +49,7 @@ func resolveClientFormat(reqFmt string) string { } return strings.ToLower(reqFmt) } - return setting.TTSOptions.Format + return setting.GetTTSOptions().Format } // OpenaiTTSHandler 是 /v1/audio/speech 的入口。 @@ -81,9 +81,9 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { return } - if setting.TTSConfigErr != nil { + if err := setting.GetTTSConfigErr(); err != nil { log.Printf("警告: TTS配置未就绪,拒绝请求 - 错误=%v 路径=%s 客户端=%s", - setting.TTSConfigErr, r.URL.Path, middleware.GetClientIP(r)) + err, r.URL.Path, middleware.GetClientIP(r)) middleware.SendJSONError(w, http.StatusServiceUnavailable, "TTS service configuration error. Please check environment variables and restart the service.", "configuration_error", "service_unavailable") return } @@ -151,7 +151,7 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { clientFormat := resolveClientFormat(req.ResponseFormat) - opts := setting.TTSOptions + opts := setting.GetTTSOptions() opts.Text = req.Input // M3: voice 路由 @@ -183,7 +183,7 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { "voice lookup failed", "server_error", "db_read_failed") return } - // 覆盖 opts(API key / UID 保留自 setting.TTSOptions) + // 覆盖 opts(API key / UID 保留自 setting.GetTTSOptions 快照) if !v.Enabled { log.Printf("警告: voice=%q 已禁用 - 客户端=%s", req.Voice, middleware.GetClientIP(r)) middleware.SendJSONError(w, http.StatusForbidden, @@ -197,10 +197,10 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { opts.Model = v.Model } log.Printf("[tts] voice=%s 命中 (speaker=%s resource=%s model=%s) - 客户端=%s", - req.Voice, telemetry.MaskSpeaker(v.Speaker), v.ResourceID, v.Model, middleware.GetClientIP(r)) + req.Voice, telemetry.MaskSpeaker(v.Speaker), telemetry.MaskResourceID(v.ResourceID), v.Model, middleware.GetClientIP(r)) } - ctx, cancel := context.WithTimeout(r.Context(), setting.TTSTimeout) + ctx, cancel := context.WithTimeout(r.Context(), setting.GetTTSTimeout()) defer cancel() result, err := volcano.Synthesis(ctx, volcanoClient, opts, req.Input, clientFormat, speed, adapterRec) @@ -232,7 +232,11 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Length", fmt.Sprintf("%d", len(result.AudioData))) w.Header().Set("X-Request-Id", result.ReqID) w.WriteHeader(http.StatusOK) - w.Write(result.AudioData) + 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) + } } func classifyStatus(err error) string { @@ -278,7 +282,7 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) { mode := installer.GetMode() if mode == installer.ModeSetup { w.WriteHeader(http.StatusOK) // 200,因为进程活着,只是还没初始化 - } else if setting.TTSConfigErr != nil { + } else if setting.GetTTSConfigErr() != nil { w.WriteHeader(http.StatusServiceUnavailable) } else { w.WriteHeader(http.StatusOK) @@ -304,8 +308,8 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) { Memory: collectMemorySnapshot(), ConfigStatus: dto.ConfigStatusResponse{ AllRequiredVarsSet: allRequired, - ConfigError: setting.TTSConfigErr != nil, - Error: configErrorMessage(setting.TTSConfigErr), + ConfigError: setting.GetTTSConfigErr() != nil, + Error: configErrorMessage(setting.GetTTSConfigErr()), }, Installed: mode == installer.ModeNormal, Mode: mode.String(), @@ -313,7 +317,7 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } -// configErrorMessage 把 setting.TTSConfigErr 安全地转成可对外暴露的字符串。 +// configErrorMessage 把运行时配置错误(setting.GetTTSConfigErr())安全地转成可对外暴露的字符串。 // 仅在 normal 模式且有错时调用, error 为 nil 时返 "" (被 omitempty 跳过)。 func configErrorMessage(err error) string { if err == nil { diff --git a/main.go b/main.go index 5612b46..80c4d7f 100644 --- a/main.go +++ b/main.go @@ -77,8 +77,9 @@ func main() { } log.Printf("[main][WARN] TTS 运行时配置加载失败 (setup mode, 需先 /setup): %v", err) } else { + opts := setting.GetTTSOptions() log.Printf("[main] TTS 运行时配置已加载(api_key=***, speaker=%s, resource=%s, format=%s)", - setting.TTSOptions.Speaker, setting.TTSOptions.ResourceID, setting.TTSOptions.Format) + telemetry.MaskSpeaker(opts.Speaker), telemetry.MaskResourceID(opts.ResourceID), opts.Format) } } diff --git a/middleware/admin_auth.go b/middleware/admin_auth.go index 22e20b7..b1395a6 100644 --- a/middleware/admin_auth.go +++ b/middleware/admin_auth.go @@ -15,7 +15,7 @@ import ( // - Authorization 头 Bearer token 在列表中 → 放行 // - 其它 → 401 + JSON {error: 'unauthorized', code: 'admin_auth_failed'} // -// 设计: 与现有 /v1/audio/speech 用的 setting.Auth 共享同一份 keys, +// 设计: 与现有 /v1/audio/speech 用的鉴权 key 列表(setting.GetAuthAPIKeys)共享同一份 keys, // 用户只用管一个 env 变量(OPENAI_TTS_API_KEY)。 func RequireAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -25,7 +25,7 @@ func RequireAdmin(next http.Handler) http.Handler { return } - keys := setting.Auth.APIKeys + keys := setting.GetAuthAPIKeys() if len(keys) == 0 { // 没配 admin key,等同无鉴权 next.ServeHTTP(w, r) diff --git a/middleware/auth.go b/middleware/auth.go index 59dc2e7..f0324d8 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -10,7 +10,8 @@ import ( ) func ValidateAPIKey(r *http.Request) bool { - if len(setting.Auth.APIKeys) == 0 { + keys := setting.GetAuthAPIKeys() + if len(keys) == 0 { return true } @@ -24,7 +25,7 @@ func ValidateAPIKey(r *http.Request) bool { } token := strings.TrimPrefix(authHeader, "Bearer ") - for _, validKey := range setting.Auth.APIKeys { + for _, validKey := range keys { if subtle.ConstantTimeCompare([]byte(token), []byte(validKey)) == 1 { return true } diff --git a/middleware/cors.go b/middleware/cors.go index 8e3dc3c..d9f1ec3 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -29,11 +29,11 @@ func matchOrigin(origin string) (string, bool) { if !isValidOrigin(origin) { return "", false } - if setting.CORS.AllowAll { + if setting.GetCORSAllowAll() { return "*", true } normalized := strings.ToLower(strings.TrimRight(strings.TrimSpace(origin), "/")) - for _, allowed := range setting.CORS.Origins { + for _, allowed := range setting.GetCORSOrigins() { if allowed == normalized { return origin, true } @@ -148,7 +148,7 @@ func CORS(next http.Handler) http.Handler { // Origin 匹配:设置 CORS 响应头 w.Header().Set("Access-Control-Allow-Origin", allowOrigin) - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") w.Header().Set("Access-Control-Expose-Headers", "X-Request-Id") w.Header().Set("Access-Control-Max-Age", corsMaxAgeHeader) diff --git a/middleware/ratelimit.go b/middleware/ratelimit.go index 8cddfc4..9a98c23 100644 --- a/middleware/ratelimit.go +++ b/middleware/ratelimit.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "os" + "sort" "strconv" "strings" "sync" @@ -128,11 +129,32 @@ func (rl *RateLimiter) cleanup() { if len(rl.requests) > common.MaxRateLimiterEntries { log.Printf("警告: 限流器条目数 %d 超过上限 %d,触发强制清理", len(rl.requests), common.MaxRateLimiterEntries) - for k := range rl.requests { + // 【修复】原版用 `for k := range rl.requests` 删,Go map 遍历顺序随机, + // 会随机删掉活跃用户(其条目 timestamp 仍在窗口内),导致该用户下次请求 + // 拿到新配额 — 攻击者可用大量伪造 IP 撑爆 map 触发清理,反而"清洗" + // 自己留的活跃条目,绕过限流。 + // 修复:按"最近一次请求时间(lastTs)"升序排序,删最旧的(最可能已离开/低频), + // 保留最活跃用户,语义符合"限流器只淘汰冷条目"的预期。 + // 排序复杂度 O(n log n),但只在超 10w 条目时触发,代价可接受。 + type entry struct { + key string + lastTs time.Time + } + entries := make([]entry, 0, len(rl.requests)) + for k, v := range rl.requests { + // 走到这里 v 一定非空(cleanup 第一阶段会把空 timestamps 删掉), + // 取最后一个 timestamp 作为"最近活跃时间"。 + lastTs := v[len(v)-1] + entries = append(entries, entry{key: k, lastTs: lastTs}) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].lastTs.Before(entries[j].lastTs) + }) + for _, e := range entries { if len(rl.requests) <= common.MaxRateLimiterEntries/2 { break } - delete(rl.requests, k) + delete(rl.requests, e.key) } } } diff --git a/router/admin-login.html b/router/admin-login.html new file mode 100644 index 0000000..0527368 --- /dev/null +++ b/router/admin-login.html @@ -0,0 +1,56 @@ + + + + + + 管理后台 · 登录 + + + + + +
+
+ +
+
+ + + + + diff --git a/router/admin-settings.html b/router/admin-settings.html new file mode 100644 index 0000000..18ff160 --- /dev/null +++ b/router/admin-settings.html @@ -0,0 +1,270 @@ + + + + + + 火山 TTS · 系统设置 + + + + + +
+
+ + +
+
+
+

系统设置

+

鉴权、上游凭证、默认路由与跨域

+
+
+ +
+ ⚠ +
+
跨域 CORS 未配置
+
同源可用,跨域请求会被 403。在下方「跨域 CORS」卡片中配置允许的来源。
+
+
+ +
+
+
+
+
鉴权 Key
+
客户端调用 /v1/audio/speech 与登录 /admin 共用
+
+ 敏感 +
+
+ +
{{ settings.auth_key || '(未设置)' }}
+
+
+ +
+ + +
+
改了之后,你的浏览器 401 自动跳回登录页,用新 Key 重登。
+
+
+ +
+
+
+
火山 TTS 上游凭证
+
调火山 v3 上游用的凭证与默认路由
+
+
+
+ +
{{ settings.api_key || '(未设置)' }}
+
+
+ +
+ + +
+
独立端点 PUT /api/settings/api-key,与其他字段分开传输
+
+
+
+
+ + +
+
+ + +
必须是「音色」tab 里存在的 name
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+
+
跨域 CORS
+
控制哪些前端域名能跨域调 /v1/audio/speech
+
+ + + {{ settings.cors_configured ? '已配置' : '未配置' }} + +
+
+ +
勾上后下面白名单失效。仅测试用
+
+
+ + +
一行一个。当前: {{ corsCurrentLabel }}
+
+
+ +
+
+
+
+
+
+ + + + + diff --git a/router/admin-shell.js b/router/admin-shell.js new file mode 100644 index 0000000..648f46c --- /dev/null +++ b/router/admin-shell.js @@ -0,0 +1,170 @@ +/* admin 共享脚本 — 多个 admin-* 页面共用 + 加载方式: + 暴露到 window.admin: { http, getKey, setKey, clearKey, toast, dismissToast, mountShell, + formatUptime, shortPath, formatBytes, formatStart, logout } + 用法: + const { http, toast, mountShell } = window.admin; + const { apiKey, overview } = mountShell({ activeNav: 'voices' }); +*/ +(function () { + const KEY_STORAGE = 'ttsAdminKey'; + + // === Key 持久化(sessionStorage,跨页面同标签页共享)=== + const getKey = () => sessionStorage.getItem(KEY_STORAGE) || ''; + const setKey = (k) => { if (k) sessionStorage.setItem(KEY_STORAGE, k); else sessionStorage.removeItem(KEY_STORAGE); }; + const clearKey = () => sessionStorage.removeItem(KEY_STORAGE); + + // === HTTP client(401 自动清 key,业务层 redirect 到登录)=== + const http = axios.create({ baseURL: '/api' }); + http.interceptors.request.use(c => { + const k = getKey(); + if (k) c.headers.Authorization = 'Bearer ' + k; + return c; + }); + http.interceptors.response.use(r => r, err => { + if (err.response && err.response.status === 401) { + clearKey(); + } + return Promise.reject(err); + }); + + // === Toasts(全局单例)=== + let toasts = []; + let toastSeq = 0; + let toastStackEl = null; + const ensureToastStack = () => { + if (toastStackEl) return toastStackEl; + toastStackEl = document.createElement('div'); + toastStackEl.className = 'toast-stack'; + document.body.appendChild(toastStackEl); + return toastStackEl; + }; + const renderToasts = () => { + const stack = ensureToastStack(); + stack.innerHTML = toasts.map(t => ` +
+ ${t.type === 'ok' ? '✓' : t.type === 'err' ? '!' : '⚠'} + ${escapeHTML(t.msg)} + +
+ `).join(''); + stack.querySelectorAll('.toast-close').forEach(btn => { + btn.addEventListener('click', () => dismissToast(Number(btn.dataset.id))); + }); + }; + const toast = (type, msg, ttl) => { + const id = ++toastSeq; + toasts.push({ id, type, msg }); + renderToasts(); + if (ttl !== 0) setTimeout(() => dismissToast(id), ttl || 3200); + }; + const dismissToast = (id) => { + toasts = toasts.filter(t => t.id !== id); + renderToasts(); + }; + + // === HTML escape(toast msg 兜底)=== + const escapeHTML = (s) => String(s).replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[c])); + + // === format helpers === + const formatUptime = (s) => { + if (!s) return '—'; + const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60); + return h > 0 ? `${h}h ${m}m` : `${m}m`; + }; + const shortPath = (p) => p ? p.split(/[\\/]/).pop() : ''; + const formatBytes = (n) => { + if (n === null || n === undefined) return '—'; + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + return (n / 1024 / 1024).toFixed(2) + ' MB'; + }; + const formatStart = (iso) => { + if (!iso) return '—'; + try { + const d = new Date(iso); + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + } catch (e) { return '—'; } + }; + + // === Sidebar 渲染(每个 admin-* 页面共用)=== + // 占位符 由 shell 填入。 + // options: { activeNav: 'dashboard' | 'voices' | 'settings', overview, voiceCount, corsConfigured, onReload, onLogout } + const mountShell = (options = {}) => { + const slot = document.getElementById('admin-sidebar'); + if (!slot) return {}; + const activeNav = options.activeNav || ''; + const ov = options.overview || {}; + const voiceCount = options.voiceCount != null ? options.voiceCount : ''; + const corsConfigured = !!options.corsConfigured; + slot.outerHTML = ` +`; + const btnReload = document.getElementById('btn-reload'); + const btnLogout = document.getElementById('btn-logout'); + if (btnReload) btnReload.addEventListener('click', () => { + if (typeof options.onReload === 'function') options.onReload(); + else location.reload(); + }); + if (btnLogout) btnLogout.addEventListener('click', () => { + clearKey(); + if (typeof options.onLogout === 'function') options.onLogout(); + else location.href = '/admin/login'; + }); + return {}; + }; + + // === Login helper(login 页用)=== + const login = async (key) => { + setKey(key); + try { + await http.get('/admin/overview'); + return true; + } catch (e) { + clearKey(); + throw e; + } + }; + + // 暴露 + window.admin = { + http, getKey, setKey, clearKey, + toast, dismissToast, + mountShell, login, + formatUptime, shortPath, formatBytes, formatStart, + escapeHTML, + }; +})(); diff --git a/router/admin-voices.html b/router/admin-voices.html new file mode 100644 index 0000000..3663181 --- /dev/null +++ b/router/admin-voices.html @@ -0,0 +1,260 @@ + + + + + + 火山 TTS · 音色管理 + + + + + +
+
+ + +
+
+
+

音色管理

+

管理火山 TTS 音色及其启用状态

+
+
+ +
+
+ +
+
+ +
+ + + +
+
+
+ {{ filteredVoices.length }} / {{ voices.length }} + 共 {{ voices.length }} 条 +
+
+ +
+ ⚠ +
+
操作失败
+
{{ actionErr }}
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
名称Speaker资源 IDModel状态操作
+
{{ v.name }}
+
{{ v.description }}
+
{{ v.speaker }}{{ v.resource_id }}{{ v.model || '—' }} + + + {{ v.enabled ? '启用' : '停用' }} + + + + +
+ +
+
♪
+
还没有音色
+
点击右上角「+ 新增音色」开始添加第一个火山 TTS 音色
+ +
+ +
+
⌕
+
没有匹配的音色
+
尝试调整搜索词或筛选条件
+ +
+
+
+
+ + + + + + + +
+ + + + + diff --git a/router/admin.css b/router/admin.css new file mode 100644 index 0000000..d23f84e --- /dev/null +++ b/router/admin.css @@ -0,0 +1,415 @@ +/* admin 共享样式 — 多个 admin-* 页面共用 + 加载方式: +*/ + +:root { + --bg: #0a0e1a; + --bg-sidebar: #0c111e; + --bg-card: #131826; + --bg-elev: #1a2138; + --bg-hover: #1f2740; + --border: #232b42; + --border-strong: #2e3754; + --text: #e4e7ee; + --text-dim: #94a3b8; + --text-mute: #64748b; + --accent: #00d4ff; + --accent-hover: #33ddff; + --accent-2: #7b2ff7; + --accent-soft: rgba(0, 212, 255, 0.10); + --accent-glow: rgba(0, 212, 255, 0.35); + --success: #22c55e; + --success-soft: rgba(34, 197, 94, 0.12); + --warning: #f59e0b; + --warning-soft: rgba(245, 158, 11, 0.12); + --danger: #ef4444; + --danger-soft: rgba(239, 68, 68, 0.12); + --radius: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --shadow-sm: 0 1px 2px rgba(0,0,0,0.4); + --shadow: 0 4px 16px rgba(0,0,0,0.25); + --shadow-lg: 0 20px 50px rgba(0,0,0,0.5); + --sidebar-w: 240px; +} +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { height: 100%; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + background-image: + radial-gradient(ellipse 70% 50% at 20% 0%, rgba(0,212,255,0.05), transparent), + radial-gradient(ellipse 50% 40% at 100% 100%, rgba(123,47,247,0.04), transparent); + background-attachment: fixed; +} +#app { min-height: 100vh; } + +/* === Login === */ +.login-screen { + min-height: 100vh; + display: flex; align-items: center; justify-content: center; + padding: 24px; position: relative; +} +.login-screen::before { + content: ''; position: absolute; inset: 0; + background: + radial-gradient(circle at 30% 20%, rgba(0,212,255,0.12), transparent 40%), + radial-gradient(circle at 70% 80%, rgba(123,47,247,0.10), transparent 40%); + pointer-events: none; +} +.login-card { + position: relative; + width: 100%; max-width: 400px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + padding: 36px 32px; + box-shadow: var(--shadow-lg); +} +.login-logo { + width: 52px; height: 52px; + border-radius: 14px; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 18px; color: #fff; + margin: 0 auto 20px; + box-shadow: 0 6px 24px var(--accent-glow); +} +.login-card h1 { font-size: 22px; font-weight: 600; text-align: center; margin-bottom: 6px; } +.login-card .sub { color: var(--text-dim); font-size: 13px; text-align: center; margin-bottom: 24px; } +.login-input { + width: 100%; padding: 12px 14px; margin-bottom: 14px; + background: var(--bg-elev); border: 1px solid var(--border); + color: var(--text); border-radius: var(--radius); font-size: 14px; + font-family: inherit; transition: border-color 0.15s, box-shadow 0.15s; +} +.login-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.login-err { + margin-top: 12px; padding: 10px 12px; + background: var(--danger-soft); border: 1px solid rgba(239,68,68,0.3); + color: var(--danger); border-radius: var(--radius); font-size: 12px; +} + +/* === App shell === */ +.app-shell { display: flex; min-height: 100vh; } +.sidebar { + width: var(--sidebar-w); + background: var(--bg-sidebar); + border-right: 1px solid var(--border); + display: flex; flex-direction: column; + position: sticky; top: 0; height: 100vh; + flex-shrink: 0; +} +.brand { display: flex; align-items: center; gap: 12px; padding: 20px 18px; border-bottom: 1px solid var(--border); } +.brand-logo { + width: 36px; height: 36px; border-radius: 10px; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 13px; color: #fff; + box-shadow: 0 2px 12px var(--accent-glow); +} +.brand-name { font-size: 14px; font-weight: 600; } +.brand-version { font-size: 11px; color: var(--text-mute); margin-top: 2px; font-family: ui-monospace, monospace; } + +.nav { flex: 1; padding: 16px 12px; overflow-y: auto; } +.nav-group { margin-bottom: 20px; } +.nav-label { + font-size: 10px; font-weight: 600; color: var(--text-mute); + text-transform: uppercase; letter-spacing: 0.8px; + padding: 0 10px 8px; +} +.nav-item { + display: flex; align-items: center; gap: 10px; + padding: 9px 10px; border-radius: var(--radius); + color: var(--text-dim); font-size: 13px; + cursor: pointer; user-select: none; + margin-bottom: 2px; + transition: background 0.12s, color 0.12s; + position: relative; + text-decoration: none; +} +.nav-item:hover { background: var(--bg-hover); color: var(--text); } +.nav-item.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 500; +} +.nav-item.active::before { + content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%); + width: 3px; height: 16px; background: var(--accent); border-radius: 0 2px 2px 0; +} +.nav-icon { font-size: 16px; width: 18px; text-align: center; flex-shrink: 0; } +.nav-badge { + margin-left: auto; padding: 1px 7px; border-radius: 10px; + background: var(--bg-hover); font-size: 11px; font-weight: 500; + color: var(--text-dim); +} +.nav-item.active .nav-badge { background: rgba(0,212,255,0.2); color: var(--accent); } +.nav-dot { + margin-left: auto; width: 6px; height: 6px; border-radius: 50%; + background: var(--warning); box-shadow: 0 0 0 3px rgba(245,158,11,0.2); +} + +.sidebar-foot { + display: flex; gap: 4px; padding: 12px; + border-top: 1px solid var(--border); +} +.btn-icon { + flex: 1; padding: 8px; background: transparent; + border: 1px solid transparent; border-radius: var(--radius); + color: var(--text-dim); cursor: pointer; font-size: 13px; + font-family: inherit; + transition: all 0.12s; +} +.btn-icon:hover { background: var(--bg-hover); color: var(--text); border-color: var(--border); } + +/* === Main === */ +.main { flex: 1; min-width: 0; padding: 28px 32px; max-width: 1200px; } +.page-head { + display: flex; justify-content: space-between; align-items: flex-end; + margin-bottom: 24px; gap: 16px; flex-wrap: wrap; +} +.page-title { font-size: 22px; font-weight: 600; letter-spacing: -0.01em; } +.page-sub { color: var(--text-dim); font-size: 13px; margin-top: 4px; } +.page-actions { display: flex; gap: 8px; align-items: center; } + +/* === Buttons === */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + padding: 8px 14px; background: var(--bg-elev); border: 1px solid var(--border); + color: var(--text); border-radius: var(--radius); cursor: pointer; + font-size: 13px; font-weight: 500; font-family: inherit; + transition: all 0.12s; + white-space: nowrap; +} +.btn:hover:not(:disabled) { border-color: var(--border-strong); background: var(--bg-hover); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-primary { background: var(--accent); color: #00121a; border-color: var(--accent); } +.btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); } +.btn-danger { color: var(--danger); border-color: var(--border); } +.btn-danger:hover:not(:disabled) { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); } +.btn-danger-ghost { color: var(--text-mute); border-color: transparent; background: transparent; padding: 5px 10px; } +.btn-danger-ghost:hover:not(:disabled) { background: var(--danger-soft); color: var(--danger); border-color: transparent; } +.btn-sm { padding: 6px 10px; font-size: 12px; } +.btn-lg { padding: 11px 16px; font-size: 14px; } + +/* === Card === */ +.card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 20px; margin-bottom: 16px; +} +.card-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; gap: 12px; } +.card-title { font-size: 15px; font-weight: 600; } +.card-sub { font-size: 12px; color: var(--text-dim); margin-top: 2px; } + +/* === Stat grid === */ +.stat-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; margin-bottom: 20px; +} +.stat-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 18px 20px; + transition: border-color 0.15s; + position: relative; overflow: hidden; +} +.stat-card::before { + content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px; + background: linear-gradient(90deg, transparent, var(--border-strong), transparent); +} +.stat-card:hover { border-color: var(--border-strong); } +.stat-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } +.stat-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500; } +.stat-value { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; } +.stat-value .stat-unit { font-size: 14px; color: var(--text-mute); font-weight: 500; margin-left: 4px; } +.stat-foot { font-size: 11px; color: var(--text-dim); margin-top: 6px; } + +.pill { + display: inline-flex; align-items: center; gap: 5px; + padding: 2px 8px; border-radius: 999px; + font-size: 11px; font-weight: 500; + background: var(--bg-elev); color: var(--text-dim); + border: 1px solid var(--border); +} +.pill-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; } +.pill-success { background: var(--success-soft); color: var(--success); border-color: rgba(34,197,94,0.3); } +.pill-accent { background: var(--accent-soft); color: var(--accent); border-color: rgba(0,212,255,0.3); } +.pill-warning { background: var(--warning-soft); color: var(--warning); border-color: rgba(245,158,11,0.3); } +.pill-mute { background: var(--bg-elev); color: var(--text-mute); } + +/* === KV list === */ +.kv { display: grid; grid-template-columns: 1fr 1fr; gap: 0; } +.kv-row { display: contents; } +.kv-row > dt { padding: 10px 16px 10px 0; font-size: 12px; color: var(--text-dim); border-bottom: 1px solid var(--border); } +.kv-row > dd { padding: 10px 0; font-size: 13px; border-bottom: 1px solid var(--border); word-break: break-all; } +.kv-row:last-child > dt, .kv-row:last-child > dd { border-bottom: none; } + +/* === Toolbar === */ +.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; } +.toolbar-left { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; } +.toolbar-right { font-size: 12px; color: var(--text-dim); } + +.search { position: relative; flex: 0 0 280px; max-width: 100%; } +.search input { + width: 100%; padding: 8px 12px 8px 32px; + background: var(--bg-card); border: 1px solid var(--border); + color: var(--text); border-radius: var(--radius); font-size: 13px; + font-family: inherit; transition: border-color 0.15s; +} +.search input::placeholder { color: var(--text-mute); } +.search input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-mute); font-size: 14px; pointer-events: none; } + +.seg { display: inline-flex; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 2px; } +.seg-btn { + padding: 5px 12px; background: transparent; border: none; + color: var(--text-dim); cursor: pointer; font-size: 12px; font-family: inherit; + border-radius: 6px; transition: all 0.12s; +} +.seg-btn:hover { color: var(--text); } +.seg-btn.active { background: var(--bg-elev); color: var(--accent); } + +/* === Data table === */ +.data-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; } +.data-table { width: 100%; border-collapse: collapse; } +.data-table th { + text-align: left; padding: 12px 16px; font-size: 11px; font-weight: 600; + color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; + background: var(--bg-elev); border-bottom: 1px solid var(--border); + white-space: nowrap; +} +.data-table td { + padding: 14px 16px; font-size: 13px; + border-bottom: 1px solid var(--border); vertical-align: middle; +} +.data-table tbody tr { transition: background 0.1s; } +.data-table tbody tr:hover { background: rgba(255,255,255,0.02); } +.data-table tbody tr:last-child td { border-bottom: none; } +.th-actions { text-align: right; width: 1%; white-space: nowrap; } +.data-table td.th-actions { text-align: right; } +.cell-name { font-weight: 500; } +.cell-sub { font-size: 11px; color: var(--text-mute); margin-top: 2px; } + +.empty { padding: 64px 24px; text-align: center; color: var(--text-dim); } +.empty-icon { font-size: 36px; opacity: 0.3; margin-bottom: 12px; } +.empty-title { font-size: 15px; font-weight: 500; color: var(--text); margin-bottom: 6px; } +.empty-desc { font-size: 13px; margin-bottom: 16px; } + +/* === Switch === */ +.switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; vertical-align: middle; } +.switch input { opacity: 0; width: 0; height: 0; } +.switch-track { + position: absolute; inset: 0; + background: var(--bg-elev); border: 1px solid var(--border); + border-radius: 999px; transition: all 0.18s; +} +.switch-track::before { + content: ''; position: absolute; top: 2px; left: 2px; + width: 14px; height: 14px; border-radius: 50%; + background: var(--text-dim); transition: all 0.18s; +} +.switch input:checked + .switch-track { background: var(--accent); border-color: var(--accent); } +.switch input:checked + .switch-track::before { transform: translateX(16px); background: #00121a; } + +/* === Forms === */ +.field { margin-bottom: 14px; } +.field label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px; font-weight: 500; } +.field label .req { color: var(--danger); margin-left: 2px; } +.field input, .field select, .field textarea, .input-static, .textarea { + width: 100%; padding: 9px 12px; + background: var(--bg-elev); border: 1px solid var(--border); + color: var(--text); border-radius: var(--radius); font-size: 13px; + font-family: inherit; transition: border-color 0.15s, box-shadow 0.15s; +} +.field input:focus, .field select:focus, .field textarea:focus, .textarea:focus { + outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); +} +.field select { + appearance: none; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; background-position: right 10px center; padding-right: 30px; +} +.input-static { font-family: ui-monospace, SFMono-Regular, monospace; color: var(--accent); padding: 8px 12px; } +.textarea { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 12px; line-height: 1.6; resize: vertical; } +.input-group { display: flex; gap: 8px; } +.input-group input { flex: 1; } +.hint { font-size: 11px; color: var(--text-mute); margin-top: 5px; line-height: 1.5; } +.check { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; color: var(--text); } +.check input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; } +.divider { height: 1px; background: var(--border); margin: 20px 0; } +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +.grid-2 .field { margin-bottom: 0; } +.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border); } +code { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.9em; background: var(--bg-elev); padding: 1px 5px; border-radius: 4px; color: var(--accent); } +.mono { font-family: ui-monospace, SFMono-Regular, monospace; } + +/* === Settings grid === */ +.settings-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 16px; align-items: start; } +.settings-grid .card { margin-bottom: 0; } + +/* === Alert / Banner === */ +.alert { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: var(--radius); margin-bottom: 16px; font-size: 13px; } +.alert-icon { font-size: 16px; flex-shrink: 0; } +.alert-body { flex: 1; } +.alert-title { font-weight: 500; } +.alert-desc { font-size: 12px; color: var(--text-dim); margin-top: 2px; } +.alert-warning { background: var(--warning-soft); border: 1px solid rgba(245,158,11,0.3); color: var(--warning); } +.alert-warning .alert-title { color: var(--warning); } +.alert-danger { background: var(--danger-soft); border: 1px solid rgba(239,68,68,0.3); color: var(--danger); } + +/* === Toasts === */ +.toast-stack { position: fixed; top: 16px; right: 16px; z-index: 9999; display: flex; flex-direction: column; gap: 8px; pointer-events: none; } +.toast { + display: flex; align-items: center; gap: 10px; + padding: 10px 14px; min-width: 240px; max-width: 380px; + background: var(--bg-card); border: 1px solid var(--border-strong); + border-radius: var(--radius); box-shadow: var(--shadow-lg); + font-size: 13px; pointer-events: auto; + border-left-width: 3px; +} +.toast-ok { border-left-color: var(--success); } +.toast-err { border-left-color: var(--danger); } +.toast-warn { border-left-color: var(--warning); } +.toast-icon { font-size: 14px; font-weight: 700; } +.toast-ok .toast-icon { color: var(--success); } +.toast-err .toast-icon { color: var(--danger); } +.toast-warn .toast-icon { color: var(--warning); } +.toast-msg { flex: 1; } +.toast-close { background: none; border: none; color: var(--text-mute); cursor: pointer; font-size: 16px; padding: 0 4px; font-family: inherit; } +.toast-close:hover { color: var(--text); } +.toast-enter-active, .toast-leave-active { transition: all 0.22s ease; } +.toast-enter-from { opacity: 0; transform: translateX(20px); } +.toast-leave-to { opacity: 0; transform: translateX(20px); } + +/* === Modal === */ +.modal-backdrop { + position: fixed; inset: 0; z-index: 100; + background: rgba(5, 8, 16, 0.7); backdrop-filter: blur(4px); + display: flex; align-items: center; justify-content: center; + padding: 24px; +} +.modal { + background: var(--bg-card); border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); + width: 100%; max-width: 540px; max-height: 90vh; overflow: auto; + display: flex; flex-direction: column; +} +.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid var(--border); } +.modal-head h2 { font-size: 16px; font-weight: 600; } +.modal-body { padding: 20px 24px; } +.modal-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 16px 24px; border-top: 1px solid var(--border); background: var(--bg-elev); border-bottom-left-radius: var(--radius-lg); border-bottom-right-radius: var(--radius-lg); } +.modal-enter-active, .modal-leave-active { transition: opacity 0.2s; } +.modal-enter-active .modal, .modal-leave-active .modal { transition: transform 0.2s, opacity 0.2s; } +.modal-enter-from, .modal-leave-to { opacity: 0; } +.modal-enter-from .modal, .modal-leave-to .modal { transform: scale(0.95) translateY(-10px); opacity: 0; } + +/* === Utility === */ +.text-dim { color: var(--text-dim); } +.text-mute { color: var(--text-mute); } +.text-accent { color: var(--accent); } +.text-success { color: var(--success); } +.text-danger { color: var(--danger); } diff --git a/router/admin.html b/router/admin.html index 3cf0d02..f8c146b 100644 --- a/router/admin.html +++ b/router/admin.html @@ -3,1071 +3,102 @@ - 火山 TTS · 管理后台 + 火山 TTS · 仪表盘 - +
- - - -
- {{ t.type === 'ok' ? '✓' : t.type === 'err' ? '!' : '⚠' }} - {{ t.msg }} - -
-
- - -
- -
- - -
- +
+
-
- ⚠ -
-
跨域 CORS 未配置
-
同源可用,跨域请求会被 403。在「设置」中配置允许的来源。
-
- -
-
-

{{ pageTitle }}

-

{{ pageSub }}

-
-
- +

仪表盘

+

运行状态、版本与资源总览

- -
-
-
-
- 运行模式 - - - {{ overview.installed ? '已就绪' : '待配置' }} - -
-
{{ overview.mode || '—' }}
+
+
+
+ 运行模式 + + + {{ overview.installed ? '已就绪' : '待配置' }} +
-
-
音色
-
{{ overview.voice_count ?? 0 }}条
-
- {{ overview.voice_enabled_count ?? 0 }} 启用 · - {{ (overview.voice_count ?? 0) - (overview.voice_enabled_count ?? 0) }} 停用 -
-
-
-
运行时间
-
{{ formatUptime(overview.uptime_seconds) }}
-
{{ formatStart(overview.start_time) }}
-
-
-
数据库
-
{{ shortPath(overview.db_path) || '—' }}
-
{{ overview.db_path || '' }}
+
{{ overview.mode || '—' }}
+
+
+
音色
+
{{ overview.voice_count ?? 0 }}条
+
+ {{ overview.voice_enabled_count ?? 0 }} 启用 · + {{ (overview.voice_count ?? 0) - (overview.voice_enabled_count ?? 0) }} 停用
- -
-
-
-
系统信息
-
运行版本与资源
-
-
-
-
Commit
{{ overview.commit || 'dev' }}
-
版本
{{ overview.version || '—' }}
-
运行模式
{{ overview.mode || '—' }}
-
数据库路径
{{ overview.db_path || '—' }}
-
锁文件
{{ overview.lock_path || '—' }}
-
-
内存
-
- goroutines {{ overview.memory?.goroutines ?? '—' }} · heap {{ formatBytes(overview.memory?.heap_alloc) }} -
-
-
+
+
运行时间
+
{{ formatUptime(overview.uptime_seconds) }}
+
{{ formatStart(overview.start_time) }}
-
- - -
-
-
- -
- - - -
-
-
- {{ filteredVoices.length }} / {{ voices.length }} - 共 {{ voices.length }} 条 -
-
- -
- ⚠ -
-
操作失败
-
{{ actionErr }}
-
-
- -
- - - - - - - - - - - - - - - - - - - - - -
名称Speaker资源 IDModel状态操作
-
{{ v.name }}
-
{{ v.description }}
-
{{ v.speaker }}{{ v.resource_id }}{{ v.model || '—' }} - - - {{ v.enabled ? '启用' : '停用' }} - - - - -
- -
-
♪
-
还没有音色
-
点击右上角「+ 新增音色」开始添加第一个火山 TTS 音色
- -
- -
-
⌕
-
没有匹配的音色
-
尝试调整搜索词或筛选条件
- -
-
-
- - -
-
-
-
-
-
鉴权 Key
-
客户端调用 /v1/audio/speech 与登录 /admin 共用
-
- 敏感 -
-
- -
{{ settings.auth_key || '(未设置)' }}
-
-
- -
- - -
-
改了之后,你的浏览器 401 自动跳回登录页,用新 Key 重登。
-
-
- -
-
-
-
火山 TTS 上游凭证
-
调火山 v3 上游用的凭证与默认路由
-
-
-
- -
{{ settings.api_key || '(未设置)' }}
-
-
- -
- - -
-
独立端点 PUT /api/settings/api-key,与其他字段分开传输
-
-
-
-
- - -
-
- - -
必须是「音色」tab 里存在的 name
-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- -
-
-
-
跨域 CORS
-
控制哪些前端域名能跨域调 /v1/audio/speech
-
- - - {{ settings.cors_configured ? '已配置' : '未配置' }} - -
-
- -
勾上后下面白名单失效。仅测试用
-
-
- - -
一行一个。当前: {{ corsCurrentLabel }}
-
-
- -
-
-
-
-
-
- - - - -
+ diff --git a/router/router.go b/router/router.go index c82781d..f86a7b6 100644 --- a/router/router.go +++ b/router/router.go @@ -21,6 +21,21 @@ var setupHTML []byte //go:embed admin.html var adminHTML []byte +//go:embed admin-login.html +var adminLoginHTML []byte + +//go:embed admin-voices.html +var adminVoicesHTML []byte + +//go:embed admin-settings.html +var adminSettingsHTML []byte + +//go:embed admin.css +var adminCSS []byte + +//go:embed admin-shell.js +var adminShellJS []byte + // Setup 返回主路由。 // 中间件顺序(由外向内): // SecurityHeaders → InstallGuard → RateLimit → ConcurrencyLimit → Logger → handler @@ -64,9 +79,21 @@ func Setup() *mux.Router { // /admin 管理后台(M2);HTML 本身公开,鉴权由前端 JS 拦截 // (sessionStorage 没 key 就显示登录页;有 key 调 /api/admin/overview 触发 401 跳登录) // API 端点(/api/admin/* /api/voices*)才需要 RequireAdmin。 - r.HandleFunc("/admin", func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write(adminHTML) + // + // 拆分后 admin.html / admin-login.html / admin-voices.html / admin-settings.html + // 各自独立,URL 路由切换;共享 admin.css + admin-shell.js 由 adminStatic 提供。 + r.HandleFunc("/admin", serveAdmin(adminHTML)).Methods("GET") + r.HandleFunc("/admin/login", serveAdmin(adminLoginHTML)).Methods("GET") + r.HandleFunc("/admin/voices", serveAdmin(adminVoicesHTML)).Methods("GET") + r.HandleFunc("/admin/settings", serveAdmin(adminSettingsHTML)).Methods("GET") + // admin 静态资源 + r.HandleFunc("/admin/admin.css", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + _, _ = w.Write(adminCSS) + }).Methods("GET") + r.HandleFunc("/admin/admin-shell.js", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + _, _ = w.Write(adminShellJS) }).Methods("GET") // /api/admin/overview (鉴权) @@ -132,3 +159,12 @@ func acceptsHTML(accept string) bool { } return false } + +// serveAdmin 返回一个把 embed 的 HTML bytes 以 text/html 写出的 handler。 +// 抽出来只为让 /admin/voices 等多条路由的注册保持单行,避免重复 4 段。 +func serveAdmin(body []byte) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(body) + } +} diff --git a/setting/config.go b/setting/config.go index 189326a..748fee8 100644 --- a/setting/config.go +++ b/setting/config.go @@ -8,6 +8,7 @@ import ( "os" "strconv" "strings" + "sync" "time" "github.com/volcano-tts/tts-api/adapter/volcano" @@ -16,30 +17,129 @@ import ( ) // 全部环境变量读取的单一入口:其它包不允许直接 os.Getenv,只读这里的全局 Config。 - -// TTSOptions 是火山 v3 TTS 调用的完整参数集合,启动期由 LoadRuntimeConfig 从 store 填充。 -// 业务侧(controller)直接读取并传入 volcano.Synthesis。 +// +// 并发模型: +// - TTSOptions / TTSTimeout / TTSConfigErr / Auth.APIKeys / CORS 是运行期可被 +// LoadRuntimeConfig(由 PUT /api/settings 触发)整体替换的"运行时配置快照"。 +// struct 整体赋值不是原子的,若 TTS 请求正在读,可能拿到半写状态。 +// - 解决: 用 ttsMu(RWMutex)统一保护这些字段;读路径用 Get* 拿快照(RLock), +// 写路径用 Set* 整体替换(Lock)。读多写少,RWMutex 读不互斥,不会显著拖慢热路径。 +// - Server.Port / TrustedProxyHops / SetupToken 仅启动期写、运行期读,无并发修改, +// 保持原样不加锁。 var ( - TTSOptions volcano.Options - TTSConfigErr error - // TTSTimeout 单次合成请求的超时;controller 用来派生 context。 - TTSTimeout time.Duration = common.DefaultTimeout + ttsMu sync.RWMutex + ttsOptions volcano.Options + ttsTimeout time.Duration = common.DefaultTimeout + ttsConfigErr error + authAPIKeys []string + // corsAllowAll / corsOrigins 拆成两个独立字段,各自在 RLock 下读取, + // 避免 CORSConfig 整体读时被 Lock 阻塞热路径。 + corsAllowAll bool + corsOrigins []string ) -// AuthConfig OpenAI 兼容接口的客户端 API Key 鉴权配置。 -type AuthConfig struct { - APIKeys []string +// GetTTSOptions 读 TTSOptions 快照(值类型,使用方可以放心使用,不会被并发写破坏)。 +// 提供给业务侧(controller)的统一读取入口;LoadRuntimeConfig 写入时用 SetTTSOptions 整体替换。 +// 业务侧不要缓存这个值跨 goroutine 使用(应该每次调用时重新拿)。 +func GetTTSOptions() volcano.Options { + ttsMu.RLock() + defer ttsMu.RUnlock() + return ttsOptions } -var Auth AuthConfig - -// CORSConfig 跨域白名单配置。 -type CORSConfig struct { - Origins []string - AllowAll bool +// SetTTSOptions 整体替换 TTSOptions。LoadRuntimeConfig 写路径专用。 +func SetTTSOptions(o volcano.Options) { + ttsMu.Lock() + defer ttsMu.Unlock() + ttsOptions = o } -var CORS CORSConfig +// GetTTSTimeout 读当前超时;controller 用它派生 context。 +func GetTTSTimeout() time.Duration { + ttsMu.RLock() + defer ttsMu.RUnlock() + return ttsTimeout +} + +// SetTTSTimeout 整体替换超时。 +func SetTTSTimeout(d time.Duration) { + ttsMu.Lock() + defer ttsMu.Unlock() + ttsTimeout = d +} + +// GetTTSConfigErr 读运行时配置错误;nil 表示就绪。 +func GetTTSConfigErr() error { + ttsMu.RLock() + defer ttsMu.RUnlock() + return ttsConfigErr +} + +// SetTTSConfigErr 设运行时配置错误;nil 表示清错。 +func SetTTSConfigErr(err error) { + ttsMu.Lock() + defer ttsMu.Unlock() + ttsConfigErr = err +} + +// GetAuthAPIKeys 读鉴权 key 列表;返回拷贝防止业务侧持有底层 slice 后被并发写破坏。 +// 外部不应直接读 Auth.APIKeys,统一走 Get*。 +func GetAuthAPIKeys() []string { + ttsMu.RLock() + defer ttsMu.RUnlock() + if len(authAPIKeys) == 0 { + return nil + } + out := make([]string, len(authAPIKeys)) + copy(out, authAPIKeys) + return out +} + +// SetAuthAPIKeys 整体替换鉴权 key 列表;入参被复制以防外部后续修改影响内部状态。 +func SetAuthAPIKeys(keys []string) { + ttsMu.Lock() + defer ttsMu.Unlock() + if len(keys) == 0 { + authAPIKeys = nil + return + } + out := make([]string, len(keys)) + copy(out, keys) + authAPIKeys = out +} + +// GetCORSAllowAll 读 CORS 是否放行所有来源。 +func GetCORSAllowAll() bool { + ttsMu.RLock() + defer ttsMu.RUnlock() + return corsAllowAll +} + +// GetCORSOrigins 读 CORS 白名单;返回拷贝防止业务侧持有后被并发写破坏。 +func GetCORSOrigins() []string { + ttsMu.RLock() + defer ttsMu.RUnlock() + if len(corsOrigins) == 0 { + return nil + } + out := make([]string, len(corsOrigins)) + copy(out, corsOrigins) + return out +} + +// SetCORS 整体替换 CORS 配置;LoadRuntimeConfig 和 PUT /api/settings/cors 写路径专用。 +func SetCORS(allowAll bool, origins []string) { + ttsMu.Lock() + defer ttsMu.Unlock() + corsAllowAll = allowAll + if len(origins) == 0 { + corsOrigins = nil + return + } + out := make([]string, len(origins)) + copy(out, origins) + corsOrigins = out +} // ServerConfig HTTP 服务监听配置。 type ServerConfig struct { @@ -52,6 +152,8 @@ var Server ServerConfig // 表示当前 XFF 解析模式:0=启发式,N>0=精确 N 跳。 // setting.LogStartupSummary 读这个字段以展示运行期配置, // 不直接调用 middleware(避免循环 import)。 +// +// 【并发】仅启动期被 InitRateLimiter 写一次,运行期只读,无并发问题,不加锁。 var TrustedProxyHops int // SetupToken 是安装模式下的初始化凭证。 @@ -60,6 +162,8 @@ var TrustedProxyHops int // 打印到日志(/api/setup 提交时必须带这个 token) // // 安装完成后,/api/setup 端点永久关闭,SetupToken 失去意义但保留在内存。 +// +// 【并发】仅 setup 阶段使用,运行期不会再写,无并发问题,不加锁。 var SetupToken string // SetupTokenSource 标记 SetupToken 的来源,便于日志区分。 @@ -87,7 +191,7 @@ func InitServerConfig() { func InitAuthConfig() { raw := os.Getenv("OPENAI_TTS_API_KEY") if raw == "" { - Auth.APIKeys = nil + SetAuthAPIKeys(nil) return } parts := strings.Split(raw, ",") @@ -98,27 +202,27 @@ func InitAuthConfig() { keys = append(keys, k) } } - Auth.APIKeys = keys + SetAuthAPIKeys(keys) } func InitCORSConfig() { raw := os.Getenv("ALLOWED_ORIGINS") - CORS.Origins = nil - CORS.AllowAll = false - if raw == "" { - return - } - for _, p := range strings.Split(raw, ",") { - o := strings.TrimSpace(p) - if o == "" { - continue + allowAll := false + var origins []string + if raw != "" { + for _, p := range strings.Split(raw, ",") { + o := strings.TrimSpace(p) + if o == "" { + continue + } + if o == "*" { + allowAll = true + continue + } + origins = append(origins, normalizeOrigin(o)) } - if o == "*" { - CORS.AllowAll = true - continue - } - CORS.Origins = append(CORS.Origins, normalizeOrigin(o)) } + SetCORS(allowAll, origins) } func normalizeOrigin(origin string) string { @@ -129,7 +233,7 @@ func normalizeOrigin(origin string) string { // SplitOriginsForCORS 解析逗号/换行/空格分隔的 origins 列表, // 全部小写、trim 末尾 / 后面统一比较。导出供 controller 复用 -// (PUT /api/settings/cors 写完立即刷新 setting.CORS 用)。 +// (PUT /api/settings/cors 写完立即刷新 CORS 用)。 func SplitOriginsForCORS(s string) []string { return splitAndLowerOrigins(s) } @@ -160,6 +264,9 @@ func splitAndLowerOrigins(s string) []string { // - 失败时 TTSConfigErr 被设置,/v1/audio/speech 路由会返 503 // - 成功时清空 TTSConfigErr // +// 【并发】所有写都走 Set* 系列函数,在 ttsMu.Lock 下完成;运行中的 TTS 请求 +// 用 Get* 拿快照不会读到半写状态。 +// // 字段映射(原 env → store key): // BYTEDANCE_TTS_API_KEY → api_key // BYTEDANCE_TTS_RESOURCE_ID → default_resource_id @@ -180,8 +287,9 @@ func splitAndLowerOrigins(s string) []string { func LoadRuntimeConfig(s Store) error { all, err := s.SettingsGetAll() if err != nil { - TTSConfigErr = fmt.Errorf("read settings failed: %w", err) - return TTSConfigErr + wrapped := fmt.Errorf("read settings failed: %w", err) + SetTTSConfigErr(wrapped) + return wrapped } apiKey := all["api_key"] @@ -198,8 +306,9 @@ func LoadRuntimeConfig(s Store) error { missing = append(missing, "default_speaker") } if len(missing) > 0 { - TTSConfigErr = fmt.Errorf("missing required settings: %v", missing) - return TTSConfigErr + wrapped := fmt.Errorf("missing required settings: %v", missing) + SetTTSConfigErr(wrapped) + return wrapped } // 【BUG 修复 · 第二轮】default_speaker 是 voice **名字**(如 "chun"), @@ -248,14 +357,15 @@ func LoadRuntimeConfig(s Store) error { } } - TTSTimeout = common.DefaultTimeout + timeout := common.DefaultTimeout if v, err := s.SettingsGetDuration("timeout", common.DefaultTimeout); err == nil { - TTSTimeout = v + timeout = v } else { - TTSTimeout = common.DefaultTimeout + timeout = common.DefaultTimeout } + SetTTSTimeout(timeout) - TTSOptions = volcano.Options{ + SetTTSOptions(volcano.Options{ APIKey: apiKey, ResourceID: resourceId, UID: "uid", @@ -268,18 +378,17 @@ func LoadRuntimeConfig(s Store) error { LoudnessRate: 0, EnableSubtitle: enableSubtitle, 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} + SetAuthAPIKeys([]string{authKey}) } else { - Auth.APIKeys = nil + SetAuthAPIKeys(nil) } // CORS 配置:DB > env @@ -298,17 +407,14 @@ func LoadRuntimeConfig(s Store) error { originsStr = os.Getenv("ALLOWED_ORIGINS") } if corsAllowAll { - CORS.AllowAll = true - CORS.Origins = nil + SetCORS(true, nil) } else if originsStr != "" { - CORS.AllowAll = false - CORS.Origins = SplitOriginsForCORS(originsStr) + SetCORS(false, SplitOriginsForCORS(originsStr)) } else { - CORS.AllowAll = false - CORS.Origins = nil + SetCORS(false, nil) } - TTSConfigErr = nil + SetTTSConfigErr(nil) return nil } @@ -388,10 +494,16 @@ func InitSetupToken() { // CheckEnvironmentVariables 返回 /health 用的环境变量状态快照。 func CheckEnvironmentVariables() map[string]interface{} { + // 一次性拿所有需要的快照,缩短锁占用窗口;后续只读本地变量。 + opts := GetTTSOptions() + authKeys := GetAuthAPIKeys() + allowAll := GetCORSAllowAll() + origins := GetCORSOrigins() + required := map[string]bool{ - "BYTEDANCE_TTS_API_KEY": TTSOptions.APIKey != "", - "BYTEDANCE_TTS_RESOURCE_ID": TTSOptions.ResourceID != "", - "BYTEDANCE_TTS_SPEAKER": TTSOptions.Speaker != "", + "BYTEDANCE_TTS_API_KEY": opts.APIKey != "", + "BYTEDANCE_TTS_RESOURCE_ID": opts.ResourceID != "", + "BYTEDANCE_TTS_SPEAKER": opts.Speaker != "", } missing := []string{} for k, ok := range required { @@ -400,12 +512,12 @@ func CheckEnvironmentVariables() map[string]interface{} { } } optional := map[string]bool{ - "BYTEDANCE_TTS_MODEL": TTSOptions.Model != "", - "BYTEDANCE_TTS_FORMAT": TTSOptions.Format != "mp3", - "BYTEDANCE_TTS_SAMPLE_RATE": TTSOptions.SampleRate != 24000, - "BYTEDANCE_TTS_EXPLICIT_LANGUAGE": TTSOptions.Additions != nil && TTSOptions.Additions.ExplicitLanguage != "", - "OPENAI_TTS_API_KEY": len(Auth.APIKeys) > 0, - "ALLOWED_ORIGINS": CORS.AllowAll || len(CORS.Origins) > 0, + "BYTEDANCE_TTS_MODEL": opts.Model != "", + "BYTEDANCE_TTS_FORMAT": opts.Format != "mp3", + "BYTEDANCE_TTS_SAMPLE_RATE": opts.SampleRate != 24000, + "BYTEDANCE_TTS_EXPLICIT_LANGUAGE": opts.Additions != nil && opts.Additions.ExplicitLanguage != "", + "OPENAI_TTS_API_KEY": len(authKeys) > 0, + "ALLOWED_ORIGINS": allowAll || len(origins) > 0, "PORT": Server.Port != common.DefaultPort, } return map[string]interface{}{ @@ -421,18 +533,21 @@ func LogStartupSummary() { log.Printf("=== 环境配置汇总 ===") log.Printf("服务端口: %s", Server.Port) - if len(Auth.APIKeys) == 0 { + authKeys := GetAuthAPIKeys() + if len(authKeys) == 0 { log.Printf("OPENAI_TTS_API_KEY: 未设置(所有请求无需鉴权)") } else { - log.Printf("OPENAI_TTS_API_KEY: 已设置 %d 个有效密钥", len(Auth.APIKeys)) + log.Printf("OPENAI_TTS_API_KEY: 已设置 %d 个有效密钥", len(authKeys)) } - if CORS.AllowAll { + allowAll := GetCORSAllowAll() + origins := GetCORSOrigins() + if allowAll { log.Printf("ALLOWED_ORIGINS: *(允许所有跨域;不可与鉴权共用)") - } else if len(CORS.Origins) == 0 { + } else if len(origins) == 0 { log.Printf("ALLOWED_ORIGINS: 未设置(跨域请求将被拒绝)") } else { - log.Printf("ALLOWED_ORIGINS: 已配置 %d 个允许的跨域来源白名单", len(CORS.Origins)) + log.Printf("ALLOWED_ORIGINS: 已配置 %d 个允许的跨域来源白名单", len(origins)) } if h := TrustedProxyHops; h == 0 { @@ -441,6 +556,9 @@ func LogStartupSummary() { log.Printf("TRUSTED_PROXY_HOPS: 精确模式,信任 %d 跳反代", h) } + // 一次性拿 TTSOptions 快照;后续只读本地变量,避免在多个 log.Printf 调用之间 + // 被 LoadRuntimeConfig 整体替换导致打印出不连贯的数据。 + opts := GetTTSOptions() log.Printf("火山 TTS 必填项状态:") type ttsCheck struct { name string @@ -448,10 +566,10 @@ func LogStartupSummary() { ok bool } checks := []ttsCheck{ - {"BYTEDANCE_TTS_API_KEY", maskAPIKey(TTSOptions.APIKey), TTSOptions.APIKey != ""}, - {"BYTEDANCE_TTS_RESOURCE_ID", TTSOptions.ResourceID, TTSOptions.ResourceID != ""}, + {"BYTEDANCE_TTS_API_KEY", maskAPIKey(opts.APIKey), opts.APIKey != ""}, + {"BYTEDANCE_TTS_RESOURCE_ID", telemetry.MaskResourceID(opts.ResourceID), opts.ResourceID != ""}, // speaker 是火山复刻音色 ID(用户付费资产),日志里打码,避免明文落盘 - {"BYTEDANCE_TTS_SPEAKER", telemetry.MaskSpeaker(TTSOptions.Speaker), TTSOptions.Speaker != ""}, + {"BYTEDANCE_TTS_SPEAKER", telemetry.MaskSpeaker(opts.Speaker), opts.Speaker != ""}, } missingCount := 0 for _, c := range checks { @@ -467,7 +585,7 @@ func LogStartupSummary() { log.Printf(" %s %s: %s", mark, c.name, val) } - if TTSConfigErr != nil { + if err := GetTTSConfigErr(); err != nil { log.Printf("火山 TTS 整体: 初始化失败(%d 个必填项缺失),/v1/audio/speech 路由将全部返回 500", missingCount) } else { log.Printf("火山 TTS 整体: 初始化成功") diff --git a/store/setup.go b/store/setup.go new file mode 100644 index 0000000..33168a7 --- /dev/null +++ b/store/setup.go @@ -0,0 +1,132 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "strings" +) + +// SetupApply 原子提交:settings 批量写入 + voices 全部插入,任一失败整体回滚, +// 保证 db 不会留下半残状态(原 controller 直接调 SettingsSetBatch + 循环 +// VoiceInsert 时,voice 第 3 条失败 → settings 已写、voice 1/2 已落、voice 4 +// 没了,db 处于"装了一半"的脏状态,只能靠重启救)。 +// +// 行为契约: +// - settingsKV 全部写入(settings 白名单校验复用 SettingsSetBatch 逻辑); +// - voices 逐条插入;ErrInvalid 校验错误 → 整体回滚,errors.Is(err, ErrInvalid) 仍可用; +// - voices 中已存在的 name 命中 ErrDuplicate → 静默跳过(不计 inserted,事务继续), +// 兼容"重复 setup 同一组 voice"场景; +// - 其它 voice 错误 → 整体回滚; +// - 全部成功 → tx.Commit,返回 inserted count(不含被 ErrDuplicate 跳过的)。 +// +// 不动 lock 文件:lock 由 controller 层(installer.CreateLock)管理, +// 失败/成功都不应影响 db 事务(事务外)。 +func (s *Store) SetupApply(settingsKV map[string]string, voices []Voice) (inserted int, err error) { + tx, err := s.db.Begin() + if err != nil { + return 0, fmt.Errorf("store: setup apply begin: %w", err) + } + // defer Rollback:Commit 成功时 Rollback 返 sql.ErrTxDone,无害。 + defer func() { + _ = tx.Rollback() + }() + + // 1) 写 settings(同事务) + if len(settingsKV) > 0 { + if err := settingsSetBatchTx(tx, settingsKV); err != nil { + return 0, err + } + } + + // 2) 逐条插 voice;ErrDuplicate 跳过,ErrInvalid/其它整体回滚 + for i, v := range voices { + // trim 各字段,跟 VoiceInsert 保持一致(防止 controller 已经 trim 过但 + // 未来调用方不 trim 时行为不一致) + v.Name = strings.TrimSpace(v.Name) + v.Speaker = strings.TrimSpace(v.Speaker) + v.ResourceID = strings.TrimSpace(v.ResourceID) + v.Model = strings.TrimSpace(v.Model) + v.Language = strings.TrimSpace(v.Language) + v.Description = strings.TrimSpace(v.Description) + // Enabled 走 setup 语义:用户主动配置时保留(允许 admin 预设 disabled); + // 但 controller.SetupSubmitHandler 走的是用户首次安装,统一 enabled=true。 + // 这里不强制覆盖,保持原值(等同 VoiceInsert 行为)。 + + id, err := voiceInsertTx(tx, v) + if err != nil { + if errors.Is(err, ErrDuplicate) { + // 已存在,跳过(不计 inserted) + continue + } + // ErrInvalid / DB 错误等:整体回滚,把原始 error 透传(已 wrap ErrInvalid) + return 0, fmt.Errorf("store: setup apply voice[%d] %q: %w", i, v.Name, err) + } + _ = id // id 当前用不到,后续如果 controller 需要可加返回值 + inserted++ + } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("store: setup apply commit: %w", err) + } + return inserted, nil +} + +// settingsSetBatchTx 在已有 tx 上写 settings;逻辑跟 SettingsSetBatch 一致 +// 但用 tx 代替 s.db。失败时**不**回滚(交给 caller 决定);caller 拿 err 后 +// defer Rollback 兜底。 +func settingsSetBatchTx(tx *sql.Tx, kv map[string]string) error { + if len(kv) == 0 { + return nil + } + stmt, err := tx.Prepare(` + INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`) + if err != nil { + return fmt.Errorf("store: settings setbatch prepare: %w", err) + } + defer stmt.Close() + + for k, v := range kv { + if k == "" { + return fmt.Errorf("store: settings setbatch: empty key") + } + if !isAllowedSettingsKey(k) { + return fmt.Errorf("store: settings setbatch: key %q not in whitelist", k) + } + if _, err := stmt.Exec(k, v); err != nil { + return fmt.Errorf("store: settings setbatch exec %q: %w", k, err) + } + } + return nil +} + +// voiceInsertTx 在已有 tx 上插 voice;跟 VoiceInsert 逻辑一致。 +// 校验(name 格式 / speaker / resource_id)用 ErrInvalid wrap; +// 唯一冲突返 ErrDuplicate;其它错误返 wrap 的 db error。 +func voiceInsertTx(tx *sql.Tx, v Voice) (int64, error) { + if err := validateVoiceName(v.Name); err != nil { + return 0, fmt.Errorf("%w: %s", ErrInvalid, err.Error()) + } + if v.Speaker == "" { + return 0, fmt.Errorf("%w: speaker is required", ErrInvalid) + } + if v.ResourceID == "" { + return 0, fmt.Errorf("%w: resource_id is required", ErrInvalid) + } + res, err := tx.Exec(` + INSERT INTO voices (name, speaker, resource_id, model, language, description, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`, + v.Name, v.Speaker, v.ResourceID, v.Model, v.Language, v.Description, boolToInt(v.Enabled)) + if err != nil { + if isUniqueViolation(err) { + return 0, ErrDuplicate + } + return 0, fmt.Errorf("store: voice insert: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("store: voice insert lastid: %w", err) + } + return id, nil +} diff --git a/store/voices.go b/store/voices.go index 152fa05..6d265b8 100644 --- a/store/voices.go +++ b/store/voices.go @@ -36,6 +36,11 @@ var ErrInUse = errors.New("store: voice is referenced by default_speaker") // ErrNotFound 表示按 id/name 找不到;controller 翻译为 404。 var ErrNotFound = errors.New("store: voice not found") +// ErrInvalid 表示客户端输入不合法(name 格式 / 必填字段缺失); +// controller 用 errors.Is(err, ErrInvalid) 翻译为 400。 +// 服务端错误(DB 失败等)不会被 wrap,controller 应翻译为 500。 +var ErrInvalid = errors.New("store: voice invalid") + // voiceNameRe 限制 voice 名为 [a-zA-Z0-9_-]{1,64};SQL 注入 + 路径穿越防护。 var voiceNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) @@ -115,7 +120,9 @@ func (s *Store) GetVoiceForTTS(name string) (speaker, resourceID, model string, } // VoiceInsert 新增音色;name 冲突返回 ErrDuplicate。 -// 空字符串/格式不合法返回 error;不依赖 SQLite 约束作为唯一校验。 +// 客户端输入错误(name 格式 / 必填字段缺失)返回 wrap ErrInvalid 的 error; +// 服务端错误(DB 失败等)不被 wrap,controller 用 errors.Is 区分。 +// 不依赖 SQLite 约束作为唯一校验。 func (s *Store) VoiceInsert(v Voice) (int64, error) { v.Name = strings.TrimSpace(v.Name) v.Speaker = strings.TrimSpace(v.Speaker) @@ -125,13 +132,14 @@ func (s *Store) VoiceInsert(v Voice) (int64, error) { v.Description = strings.TrimSpace(v.Description) if err := validateVoiceName(v.Name); err != nil { - return 0, err + // validateVoiceName 返纯文本;这里 wrap 进 ErrInvalid 让 controller 用 errors.Is 判定。 + return 0, fmt.Errorf("%w: %s", ErrInvalid, err.Error()) } if v.Speaker == "" { - return 0, fmt.Errorf("store: voice insert: speaker is required") + return 0, fmt.Errorf("%w: speaker is required", ErrInvalid) } if v.ResourceID == "" { - return 0, fmt.Errorf("store: voice insert: resource_id is required") + return 0, fmt.Errorf("%w: resource_id is required", ErrInvalid) } res, err := s.db.Exec(` @@ -153,6 +161,12 @@ func (s *Store) VoiceInsert(v Voice) (int64, error) { // VoiceUpdate 整行替换;name 仍需保持唯一。 // 不允许把 name 改成空/不合法。 +// +// 同步 default_speaker: +// - 改 name 前,先查旧记录 +// - 若 settings.default_speaker == 旧 name,把它改成新 name +// - 整个 voice UPDATE + settings UPDATE 在同一事务里, +// 失败回滚,避免"声音改了但 default_speaker 还指向旧名"导致火山查不到 func (s *Store) VoiceUpdate(v Voice) error { v.Name = strings.TrimSpace(v.Name) v.Speaker = strings.TrimSpace(v.Speaker) @@ -171,7 +185,47 @@ func (s *Store) VoiceUpdate(v Voice) error { return fmt.Errorf("store: voice update: resource_id is required") } - res, err := s.db.Exec(` + // 整段事务: 读旧名 → 同步 settings → UPDATE voice + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("store: voice update begin: %w", err) + } + defer func() { + // commit 成功时 Rollback 返回 sql.ErrTxDone,无害 + _ = tx.Rollback() + }() + + // 1. 读旧名(同事务,避免并发改) + var oldName string + if err := tx.QueryRow(`SELECT name FROM voices WHERE id = ?`, v.ID).Scan(&oldName); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return fmt.Errorf("store: voice update read old name id=%d: %w", v.ID, err) + } + + // 2. 若 name 变了 + 是默认音色 → 同步 default_speaker + if oldName != v.Name { + var defVal string + err := tx.QueryRow(`SELECT value FROM settings WHERE key = 'default_speaker'`).Scan(&defVal) + switch { + case errors.Is(err, sql.ErrNoRows): + // 没设 default_speaker,无事可做 + case err != nil: + return fmt.Errorf("store: voice update read default_speaker: %w", err) + case defVal == oldName: + // 同步改名为新名 + if _, err := tx.Exec(` + INSERT INTO settings (key, value, updated_at) VALUES ('default_speaker', ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + v.Name); err != nil { + return fmt.Errorf("store: voice update sync default_speaker: %w", err) + } + } + } + + // 3. UPDATE voice + res, err := tx.Exec(` UPDATE voices SET name=?, speaker=?, resource_id=?, model=?, language=?, description=?, enabled=?, updated_at=datetime('now') WHERE id = ?`, v.Name, v.Speaker, v.ResourceID, v.Model, v.Language, v.Description, boolToInt(v.Enabled), v.ID) @@ -185,6 +239,11 @@ func (s *Store) VoiceUpdate(v Voice) error { if n == 0 { return ErrNotFound } + + // 4. 提交 + if err := tx.Commit(); err != nil { + return fmt.Errorf("store: voice update commit: %w", err) + } return nil } diff --git a/telemetry/labels.go b/telemetry/labels.go index 7dfaa29..a45604a 100644 --- a/telemetry/labels.go +++ b/telemetry/labels.go @@ -41,14 +41,29 @@ func SpeakerLabel(s string) string { // - 其它 → 前 4 + **** + 后 4 (保留前缀便于肉眼区分 "S_xx 开头" vs "BV001_...") // 例子: "S_G8tEKnaJ1" → "S_G8****naJ1" func MaskSpeaker(s string) string { + return maskWithAffix(s, "(未设置)") +} + +// MaskResourceID 把火山 TTS 资源 ID 部分打码用于日志输出。 +// 资源 ID 同样属于用户付费/敏感资产(指向 V3 复刻项目),与 speaker 走同一规则。 +// - 空 → "(未设置)" +// - 长度 ≤ 4 → 全打码 +// - 其它 → 前 4 + **** + 后 4 +// 例子: "volc.megatts.icl" → "volc****.icl"; "seed-icl-2.0" → "seed****2.0" +func MaskResourceID(s string) string { + return maskWithAffix(s, "(未设置)") +} + +// maskWithAffix 共用的"前 4 + **** + 后 4"打码逻辑,空串返回 emptyLabel。 +func maskWithAffix(s, emptyLabel string) string { if s == "" { - return "(未设置)" + return emptyLabel } if len(s) <= 4 { return strings.Repeat("*", len(s)) } // 找前 4 字符中第一个非 [A-Za-z0-9_] 字符做截断,避免截到奇怪位置 - // (虽然火山 ID 实际都是 S_xxx 字母数字组合,这里保险) + // (虽然火山 ID 实际都是字母数字组合,这里保险) prefix := s[:4] suffix := s[len(s)-4:] return prefix + "****" + suffix