Compare commits
2
Commits
dbaee35400
...
70865a3cf4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70865a3cf4 | ||
|
|
51bd7ae37a |
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/volcano-tts/tts-api/common"
|
||||
"github.com/volcano-tts/tts-api/dto"
|
||||
"github.com/volcano-tts/tts-api/telemetry"
|
||||
)
|
||||
|
||||
// MetricsRecorder 是适配器向上报告埋点的接口。
|
||||
@@ -83,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, opts.Speaker, opts.Model, opts.Format, opts.SampleRate, opts.SpeechRate, extractAdditionsForLog(body))
|
||||
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)
|
||||
@@ -144,7 +145,7 @@ func Synthesis(
|
||||
mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "ok", duration, parsed.FirstChunk, parsed.Chunks, len(finalData), 0)
|
||||
|
||||
log.Printf("TTS 合成成功 - 音色=%s 格式=%s 文本=%d字 音频=%d字节 分片=%d 耗时=%v",
|
||||
opts.Speaker, clientFormat, len(text), len(finalData), parsed.Chunks, duration)
|
||||
telemetry.MaskSpeaker(opts.Speaker), clientFormat, len(text), len(finalData), parsed.Chunks, duration)
|
||||
|
||||
return &dto.SynthesisResult{
|
||||
AudioData: finalData,
|
||||
|
||||
@@ -20,3 +20,22 @@ const (
|
||||
MaxModelNameLength = 64
|
||||
MaxRateLimiterEntries = 100000
|
||||
)
|
||||
|
||||
// SecureEqualString 是常量时间字符串比较,防 token 计时攻击。
|
||||
// 长度先比对(避免短串早返回时泄漏长度信息),再遍历每个字节做 XOR 累加;
|
||||
// diff 为 0 才返 true。用于 Bearer token、setup token 等敏感比较场景。
|
||||
func SecureEqualString(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
// 先比对长度(避免短串早返回时泄漏长度信息)
|
||||
// 但仍要遍历一遍避免优化器消除分支
|
||||
if len(a) > 0 {
|
||||
_ = a[0]
|
||||
}
|
||||
return false
|
||||
}
|
||||
var diff byte
|
||||
for i := 0; i < len(a); i++ {
|
||||
diff |= a[i] ^ b[i]
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/volcano-tts/tts-api/installer"
|
||||
"github.com/volcano-tts/tts-api/middleware"
|
||||
"github.com/volcano-tts/tts-api/store"
|
||||
"github.com/volcano-tts/tts-api/version"
|
||||
)
|
||||
|
||||
// SetAdminStore 注入 admin 控制器需要的 store;main 启动期调一次。
|
||||
// store 可能在自愈回退后为 nil,GetAdminStore 返 nil 时 controller 应返 503。
|
||||
var adminStore *store.Store
|
||||
|
||||
// SetAdminStore 在 main 启动期调,设置 admin 用的 store 句柄。
|
||||
func SetAdminStore(s *store.Store) { adminStore = s }
|
||||
|
||||
// GetAdminStore admin 控制器用,获取已注入的 store;nil 表示服务在 setup 模式。
|
||||
func GetAdminStore() *store.Store { return adminStore }
|
||||
|
||||
// metricsTextWriter 是 admin 端点写 Prometheus 文本的回调,
|
||||
// 由 main 启动期注入(避免 controller → metrics → controller 循环)。
|
||||
type metricsTextWriter func(w http.ResponseWriter) error
|
||||
|
||||
var (
|
||||
metricsTextWriterMu sync.RWMutex
|
||||
metricsTextWriterFn metricsTextWriter
|
||||
)
|
||||
|
||||
// SetMetricsTextWriter 注入 Prometheus 文本写入函数;main 启动期调一次。
|
||||
func SetMetricsTextWriter(fn metricsTextWriter) {
|
||||
metricsTextWriterMu.Lock()
|
||||
metricsTextWriterFn = fn
|
||||
metricsTextWriterMu.Unlock()
|
||||
}
|
||||
|
||||
// AdminOverviewResponse 是 GET /api/admin/overview 的响应体。
|
||||
type AdminOverviewResponse struct {
|
||||
Mode string `json:"mode"`
|
||||
Installed bool `json:"installed"`
|
||||
DBPath string `json:"db_path"`
|
||||
LockPath string `json:"lock_path"`
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
StartTime string `json:"start_time"`
|
||||
VoiceCount int `json:"voice_count"`
|
||||
VoiceEnabledCount int `json:"voice_enabled_count"`
|
||||
Memory map[string]interface{} `json:"memory"`
|
||||
}
|
||||
|
||||
// AdminOverviewHandler GET /api/admin/overview
|
||||
// 鉴权: RequireAdmin;store 为 nil 时仍可服务,但 voice 字段为 0。
|
||||
func AdminOverviewHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
resp := AdminOverviewResponse{
|
||||
Mode: installer.GetMode().String(),
|
||||
Installed: installer.GetMode() == installer.ModeNormal,
|
||||
Version: version.Version,
|
||||
Commit: version.Commit,
|
||||
StartTime: startTime.Format(time.RFC3339),
|
||||
UptimeSeconds: int64(time.Since(startTime).Seconds()),
|
||||
Memory: collectMemorySnapshot(),
|
||||
}
|
||||
|
||||
if s := GetAdminStore(); s != nil {
|
||||
if p, err := s.Path(); err == nil {
|
||||
resp.DBPath = p
|
||||
}
|
||||
resp.LockPath = installer.LockPath(resp.DBPath)
|
||||
if n, err := s.VoiceCount(); err == nil {
|
||||
resp.VoiceCount = n
|
||||
}
|
||||
if n, err := s.VoiceCountEnabled(); err == nil {
|
||||
resp.VoiceEnabledCount = n
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
log.Printf("[admin] overview encode failed: %v", err)
|
||||
middleware.SendJSONError(w, http.StatusInternalServerError, "encode failed", "server_error", "encode_failed")
|
||||
}
|
||||
}
|
||||
|
||||
// AdminMetricsHandler GET /api/admin/metrics
|
||||
// 鉴权: RequireAdmin;返 Prometheus 文本。
|
||||
func AdminMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
metricsTextWriterMu.RLock()
|
||||
fn := metricsTextWriterFn
|
||||
metricsTextWriterMu.RUnlock()
|
||||
if fn == nil {
|
||||
// 启动期没注入,返 503 + 提示(不应该发生)
|
||||
middleware.SendJSONError(w, http.StatusServiceUnavailable,
|
||||
"metrics writer not initialized", "configuration_error", "metrics_not_ready")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
if err := fn(w); err != nil {
|
||||
log.Printf("[admin] metrics write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminVoicesListResponse 是 GET /api/voices 的响应。
|
||||
type AdminVoicesListResponse struct {
|
||||
Voices []store.Voice `json:"voices"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// AdminVoicesListHandler GET /api/voices
|
||||
// 鉴权: RequireAdmin;返所有 voice(包含 disabled)。
|
||||
func AdminVoicesListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
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
|
||||
}
|
||||
vs, err := s.VoiceList(true)
|
||||
if err != nil {
|
||||
log.Printf("[admin] voice list: %v", err)
|
||||
middleware.SendJSONError(w, http.StatusInternalServerError, "list voices failed", "server_error", "db_read_failed")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(AdminVoicesListResponse{Voices: vs, Total: len(vs)})
|
||||
}
|
||||
|
||||
// AdminVoiceCreateRequest 是 POST /api/voices 的 body。
|
||||
type AdminVoiceCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Speaker string `json:"speaker"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
Model string `json:"model"`
|
||||
Language string `json:"language"`
|
||||
Description string `json:"description"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// AdminVoiceCreateHandler POST /api/voices
|
||||
// 鉴权: RequireAdmin;store nil 时 503。
|
||||
func AdminVoiceCreateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
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<<16)
|
||||
var body AdminVoiceCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
enabled := true
|
||||
if body.Enabled != nil {
|
||||
enabled = *body.Enabled
|
||||
}
|
||||
|
||||
v := store.Voice{
|
||||
Name: body.Name,
|
||||
Speaker: body.Speaker,
|
||||
ResourceID: body.ResourceID,
|
||||
Model: body.Model,
|
||||
Language: body.Language,
|
||||
Description: body.Description,
|
||||
Enabled: enabled,
|
||||
}
|
||||
id, err := s.VoiceInsert(v)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case store.ErrDuplicate:
|
||||
middleware.SendJSONError(w, http.StatusConflict,
|
||||
fmt.Sprintf("voice name %q already exists", v.Name),
|
||||
"invalid_request_error", "voice_duplicate")
|
||||
default:
|
||||
log.Printf("[admin] voice insert: %v", err)
|
||||
middleware.SendJSONError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "voice_invalid")
|
||||
}
|
||||
return
|
||||
}
|
||||
created, _ := s.VoiceGet(id)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(created)
|
||||
}
|
||||
|
||||
// AdminVoiceDeleteHandler DELETE /api/voices/{name}
|
||||
func AdminVoiceDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
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
|
||||
}
|
||||
name := mux.Vars(r)["name"]
|
||||
if name == "" {
|
||||
middleware.SendJSONError(w, http.StatusBadRequest, "missing voice name", "invalid_request_error", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
v, err := s.VoiceGetByName(name)
|
||||
if err != nil {
|
||||
if err == store.ErrNotFound {
|
||||
http.Error(w, "voice not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("[admin] voice lookup: %v", err)
|
||||
middleware.SendJSONError(w, http.StatusInternalServerError, "lookup failed", "server_error", "db_read_failed")
|
||||
return
|
||||
}
|
||||
if err := s.VoiceDelete(v.ID); err != nil {
|
||||
switch err {
|
||||
case store.ErrInUse:
|
||||
middleware.SendJSONError(w, http.StatusConflict,
|
||||
fmt.Sprintf("voice %q is referenced by default_speaker; remove the default first", name),
|
||||
"invalid_request_error", "voice_in_use")
|
||||
case store.ErrNotFound:
|
||||
http.Error(w, "voice not found", http.StatusNotFound)
|
||||
default:
|
||||
log.Printf("[admin] voice delete: %v", err)
|
||||
middleware.SendJSONError(w, http.StatusInternalServerError, "delete failed", "server_error", "db_write_failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "deleted": name})
|
||||
}
|
||||
|
||||
// AdminVoiceToggleRequest 是 PATCH /api/voices/{name}/toggle 的 body。
|
||||
type AdminVoiceToggleRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// AdminVoiceToggleHandler PATCH /api/voices/{name}/toggle
|
||||
func AdminVoiceToggleHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPatch {
|
||||
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
|
||||
}
|
||||
name := mux.Vars(r)["name"]
|
||||
if name == "" {
|
||||
middleware.SendJSONError(w, http.StatusBadRequest, "missing voice name", "invalid_request_error", "bad_request")
|
||||
return
|
||||
}
|
||||
v, err := s.VoiceGetByName(name)
|
||||
if err != nil {
|
||||
if err == store.ErrNotFound {
|
||||
http.Error(w, "voice not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("[admin] voice lookup: %v", err)
|
||||
middleware.SendJSONError(w, http.StatusInternalServerError, "lookup failed", "server_error", "db_read_failed")
|
||||
return
|
||||
}
|
||||
var body AdminVoiceToggleRequest
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<10)
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request")
|
||||
return
|
||||
}
|
||||
if err := s.VoiceToggleEnabled(v.ID, body.Enabled); err != nil {
|
||||
log.Printf("[admin] voice toggle: %v", err)
|
||||
middleware.SendJSONError(w, http.StatusInternalServerError, "toggle failed", "server_error", "db_write_failed")
|
||||
return
|
||||
}
|
||||
updated, _ := s.VoiceGet(v.ID)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(updated)
|
||||
}
|
||||
+3
-14
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/volcano-tts/tts-api/common"
|
||||
"github.com/volcano-tts/tts-api/installer"
|
||||
"github.com/volcano-tts/tts-api/middleware"
|
||||
"github.com/volcano-tts/tts-api/setting"
|
||||
@@ -262,17 +263,5 @@ func validateSetupVoices(vs []SetupVoice) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// secureEqualString 是常量时间字符串比较,防止 token 计时攻击。
|
||||
func secureEqualString(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
// 先比对长度(避免短串早返回时泄漏长度信息)
|
||||
// 但仍要遍历一遍避免优化器消除分支
|
||||
_ = a[0]
|
||||
return false
|
||||
}
|
||||
var diff byte
|
||||
for i := 0; i < len(a); i++ {
|
||||
diff |= a[i] ^ b[i]
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
// secureEqualString wraps common.SecureEqualString 保持向后兼容(原文件内已有调用)。
|
||||
func secureEqualString(a, b string) bool { return common.SecureEqualString(a, b) }
|
||||
|
||||
+4
-1
@@ -161,7 +161,10 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
finalLabels := telemetry.Labels{
|
||||
"format": clientFormat,
|
||||
"speaker": opts.Speaker,
|
||||
// speaker 是火山复刻音色 ID(用户付费资产),不能直接出现在 /metrics label 里
|
||||
//(无鉴权可枚举)。用 sha1[:8] 替代:同 speaker 同 label 保留 per-voice 观测,
|
||||
//但反推不出原值。Admin UI 想要看原名通过 /api/voices 拿 name 字段。
|
||||
"speaker": telemetry.SpeakerLabel(opts.Speaker),
|
||||
"model": opts.Model,
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -49,6 +49,12 @@ func main() {
|
||||
}
|
||||
// 注入 setup 控制器需要的 store + dbPath(无论哪种模式都注入,正常模式下备用)
|
||||
controller.SetSetupState(st, dbPath)
|
||||
// 注入 admin 控制器需要的 store + 指标文本写入器(M2)
|
||||
controller.SetAdminStore(st)
|
||||
controller.SetMetricsTextWriter(func(w http.ResponseWriter) error {
|
||||
metrics.Meter.Handler().ServeHTTP(w, &http.Request{})
|
||||
return nil
|
||||
})
|
||||
log.Printf("[main] 当前模式: %s (db=%s lock=%s)", res.Mode, dbPath, res.LockPath)
|
||||
|
||||
controller.InitController()
|
||||
@@ -75,6 +81,7 @@ func main() {
|
||||
log.Printf("Starting ByteDance TTS to OpenAI API Adapter Server")
|
||||
log.Printf("Listening on port: %s", setting.Server.Port)
|
||||
log.Printf("OpenAI TTS endpoint: http://localhost:%s/v1/audio/speech", setting.Server.Port)
|
||||
log.Printf("Admin WebUI: http://localhost:%s/admin", setting.Server.Port)
|
||||
}
|
||||
log.Printf("Health check: http://localhost:%s/health", setting.Server.Port)
|
||||
log.Printf("Metrics: http://localhost:%s/metrics", setting.Server.Port)
|
||||
|
||||
+4
-2
@@ -114,12 +114,14 @@ type AdapterRecorder struct{}
|
||||
|
||||
// UpstreamStarted 满足 volcano.MetricsRecorder 接口。
|
||||
func (AdapterRecorder) UpstreamStarted(speaker, model, format string) {
|
||||
UpstreamTotal.Inc(telemetry.Labels{"status": "started", "format": format, "model": model, "speaker": speaker})
|
||||
// speaker 用 sha1[:8] 替代,保护火山复刻音色 ID
|
||||
// (无鉴权 /metrics 端点可枚举,这是 P0 隐私问题)
|
||||
UpstreamTotal.Inc(telemetry.Labels{"status": "started", "format": format, "model": model, "speaker": telemetry.SpeakerLabel(speaker)})
|
||||
}
|
||||
|
||||
// UpstreamFinished 满足 volcano.MetricsRecorder 接口。
|
||||
func (AdapterRecorder) UpstreamFinished(speaker, model, format, status string, duration, ttfb time.Duration, chunks, audioBytes, errCode int) {
|
||||
labels := telemetry.Labels{"status": status, "format": format, "model": model, "speaker": speaker}
|
||||
labels := telemetry.Labels{"status": status, "format": format, "model": model, "speaker": telemetry.SpeakerLabel(speaker)}
|
||||
UpstreamTotal.Inc(labels)
|
||||
UpstreamDuration.Observe(duration.Seconds(), telemetry.Labels{"status": status, "format": format})
|
||||
if ttfb > 0 {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/volcano-tts/tts-api/common"
|
||||
"github.com/volcano-tts/tts-api/setting"
|
||||
)
|
||||
|
||||
// RequireAdmin 是 /admin 路由的鉴权中间件,复用 OPENAI_TTS_API_KEY。
|
||||
// 行为:
|
||||
// - Auth.APIKeys 为空 → 所有请求放行(等同无鉴权)
|
||||
// - Authorization 头 Bearer token 在列表中 → 放行
|
||||
// - 其它 → 401 + JSON {error: 'unauthorized', code: 'admin_auth_failed'}
|
||||
//
|
||||
// 设计: 与现有 /v1/audio/speech 用的 setting.Auth 共享同一份 keys,
|
||||
// 用户只用管一个 env 变量(OPENAI_TTS_API_KEY)。
|
||||
func RequireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 预检: 跨域/OPTIONS 直接放行(让浏览器能发 preflight)
|
||||
if r.Method == http.MethodOptions {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
keys := setting.Auth.APIKeys
|
||||
if len(keys) == 0 {
|
||||
// 没配 admin key,等同无鉴权
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
auth := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(auth, prefix) {
|
||||
denyAdmin(w, r)
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(auth[len(prefix):])
|
||||
if !inAPIKeyList(token, keys) {
|
||||
denyAdmin(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// inAPIKeyList 用常量时间比较,防 token 计时攻击。
|
||||
// 单个 key 也走同一条路径,无差别处理。
|
||||
func inAPIKeyList(token string, keys []string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
match := false
|
||||
for _, k := range keys {
|
||||
if common.SecureEqualString(token, k) {
|
||||
match = true
|
||||
// 不 break,继续遍历,保持时间恒定
|
||||
}
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
// denyAdmin 写 401 + JSON 错误体,记录客户端 IP。
|
||||
func denyAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[admin_auth] 鉴权失败 - 路径=%s 客户端=%s", r.URL.Path, GetClientIP(r))
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="tts-admin"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"admin_auth_failed","message":"unauthorized","type":"authentication_error"}}`))
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>火山 TTS · 管理后台</title>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0f1a;
|
||||
--bg-card: #131826;
|
||||
--bg-elev: #1a2138;
|
||||
--border: #232b42;
|
||||
--text: #e4e7ee;
|
||||
--text-dim: #8a92a6;
|
||||
--text-mute: #5a6275;
|
||||
--accent: #00d4ff;
|
||||
--accent-2: #7b2ff7;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg); color: var(--text);
|
||||
min-height: 100vh; padding: 24px; font-size: 14px;
|
||||
background-image:
|
||||
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0,212,255,0.08), transparent),
|
||||
radial-gradient(ellipse 60% 40% at 80% 100%, rgba(123,47,247,0.06), transparent);
|
||||
}
|
||||
#app { max-width: 1100px; margin: 0 auto; }
|
||||
|
||||
.login {
|
||||
max-width: 420px; margin: 100px auto; padding: 32px;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.login h1 { font-size: 20px; margin-bottom: 8px; }
|
||||
.login .sub { color: var(--text-dim); font-size: 12px; margin-bottom: 24px; }
|
||||
.login input {
|
||||
width: 100%; padding: 10px 12px; margin-bottom: 12px;
|
||||
background: var(--bg-elev); border: 1px solid var(--border);
|
||||
color: var(--text); border-radius: 8px; font-size: 13px;
|
||||
}
|
||||
.login input:focus { outline: none; border-color: var(--accent); }
|
||||
.login button {
|
||||
width: 100%; padding: 10px; background: var(--accent); color: #00121a;
|
||||
border: none; border-radius: 8px; font-weight: 500; cursor: pointer;
|
||||
}
|
||||
.login button:disabled { background: var(--text-mute); cursor: not-allowed; }
|
||||
.login .err { color: var(--danger); font-size: 12px; margin-top: 8px; }
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 24px; flex-wrap: wrap; gap: 16px;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.logo {
|
||||
width: 40px; height: 40px; 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: 16px; color: #fff;
|
||||
}
|
||||
.header h1 { font-size: 18px; }
|
||||
.header .sub { font-size: 11px; color: var(--text-dim); }
|
||||
|
||||
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); }
|
||||
.tab {
|
||||
padding: 8px 16px; cursor: pointer; color: var(--text-dim);
|
||||
font-size: 13px; border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px; transition: all 0.2s;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
.btn {
|
||||
background: var(--bg-elev); border: 1px solid var(--border); color: var(--text);
|
||||
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px;
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.btn:hover { border-color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); color: #00121a; border-color: var(--accent); font-weight: 500; }
|
||||
.btn-primary:hover { background: #33ddff; }
|
||||
.btn-danger { color: var(--danger); border-color: var(--border); }
|
||||
.btn-danger:hover { border-color: var(--danger); }
|
||||
|
||||
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; padding: 18px; margin-bottom: 12px; }
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.stat-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.stat-value { font-size: 24px; font-weight: 700; margin-top: 4px; }
|
||||
.stat-value.accent { color: var(--accent); }
|
||||
.stat-value.success { color: var(--success); }
|
||||
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th, .table td {
|
||||
text-align: left; padding: 8px 10px; font-size: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.table th { color: var(--text-dim); font-weight: 500; text-transform: uppercase; font-size: 10px; letter-spacing: 0.5px; }
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
.table .name { font-family: "SF Mono", Monaco, monospace; color: var(--accent); }
|
||||
.table .mono { font-family: "SF Mono", Monaco, monospace; color: var(--text-dim); }
|
||||
.empty { color: var(--text-mute); text-align: center; padding: 32px; font-size: 12px; }
|
||||
|
||||
/* switch */
|
||||
.switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider { position: absolute; inset: 0; background: var(--bg-elev); border-radius: 20px; transition: 0.2s; border: 1px solid var(--border); }
|
||||
.slider::before { content: ""; position: absolute; left: 2px; top: 2px; width: 14px; height: 14px; background: var(--text-dim); border-radius: 50%; transition: 0.2s; }
|
||||
.switch input:checked + .slider { background: var(--success); border-color: var(--success); }
|
||||
.switch input:checked + .slider::before { transform: translateX(16px); background: #fff; }
|
||||
|
||||
/* modal */
|
||||
.modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; padding: 24px; width: 480px; max-width: 90vw; }
|
||||
.modal h2 { font-size: 16px; margin-bottom: 16px; }
|
||||
.modal label { display: block; font-size: 11px; color: var(--text-dim); margin-top: 12px; margin-bottom: 4px; }
|
||||
.modal input, .modal select, .modal textarea {
|
||||
width: 100%; padding: 8px 10px; background: var(--bg-elev); border: 1px solid var(--border);
|
||||
color: var(--text); border-radius: 6px; font-size: 12px; font-family: inherit;
|
||||
}
|
||||
.modal .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
||||
.err { color: var(--danger); font-size: 12px; margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Login -->
|
||||
<div v-if="!apiKey" class="login">
|
||||
<h1>火山 TTS · 管理后台</h1>
|
||||
<div class="sub">输入 OPENAI_TTS_API_KEY 登录</div>
|
||||
<input v-model="keyInput" type="password" placeholder="API Key" @keyup.enter="login" autofocus>
|
||||
<button @click="login" :disabled="!keyInput">登录</button>
|
||||
<div v-if="loginErr" class="err">{{ loginErr }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Main -->
|
||||
<div v-else>
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<div class="logo">TTS</div>
|
||||
<div>
|
||||
<h1>管理后台</h1>
|
||||
<div class="sub">火山 TTS 聚合平台 · 模式: {{ overview.mode }} · v{{ overview.version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; align-items: center;">
|
||||
<button class="btn" @click="reloadAll">刷新</button>
|
||||
<button class="btn" @click="logout">登出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab" :class="{active: tab==='dashboard'}" @click="tab='dashboard'">仪表盘</div>
|
||||
<div class="tab" :class="{active: tab==='voices'}" @click="tab='voices'">音色</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div v-if="tab==='dashboard'">
|
||||
<div class="stats">
|
||||
<div class="card">
|
||||
<div class="stat-label">模式</div>
|
||||
<div class="stat-value" :class="overview.installed ? 'success' : 'accent'">{{ overview.mode }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="stat-label">音色数</div>
|
||||
<div class="stat-value accent">{{ overview.voice_count }}</div>
|
||||
<div style="font-size: 11px; color: var(--text-mute); margin-top: 4px;">{{ overview.voice_enabled_count }} 启用</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="stat-label">运行时间</div>
|
||||
<div class="stat-value">{{ formatUptime(overview.uptime_seconds) }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="stat-label">数据库</div>
|
||||
<div class="stat-value" style="font-size: 13px; word-break: break-all;">{{ shortPath(overview.db_path) || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div style="font-size: 12px; color: var(--text-dim); margin-bottom: 6px;">Commit</div>
|
||||
<div class="mono" style="font-size: 13px;">{{ overview.commit || 'dev' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voices -->
|
||||
<div v-if="tab==='voices'">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<div style="color: var(--text-dim); font-size: 12px;">共 {{ voices.length }} 条</div>
|
||||
<button class="btn btn-primary" @click="openAdd">+ 新增音色</button>
|
||||
</div>
|
||||
<div class="card" style="padding: 0;">
|
||||
<table class="table" v-if="voices.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>Speaker</th>
|
||||
<th>资源 ID</th>
|
||||
<th>Model</th>
|
||||
<th>启用</th>
|
||||
<th style="width: 100px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="v in voices" :key="v.name">
|
||||
<td class="name">{{ v.name }}</td>
|
||||
<td class="mono">{{ v.speaker }}</td>
|
||||
<td class="mono">{{ v.resource_id }}</td>
|
||||
<td class="mono">{{ v.model || '—' }}</td>
|
||||
<td>
|
||||
<label class="switch">
|
||||
<input type="checkbox" :checked="v.enabled" @change="toggle(v)">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-danger" @click="remove(v)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">还没有音色。点击右上角"新增音色"开始添加。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="actionErr" class="err" style="margin-top: 16px;">⚠ {{ actionErr }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Add voice modal -->
|
||||
<div v-if="showAdd" class="modal-bg" @click.self="showAdd=false">
|
||||
<div class="modal">
|
||||
<h2>新增音色</h2>
|
||||
<label>对外名 (voice) *</label>
|
||||
<input v-model="form.name" placeholder="qian">
|
||||
<label>Speaker (火山音色 ID) *</label>
|
||||
<input v-model="form.speaker" placeholder="S_xxx">
|
||||
<label>资源 ID *</label>
|
||||
<input v-model="form.resource_id" placeholder="volc.megatts.icl">
|
||||
<label>Model</label>
|
||||
<input v-model="form.model" placeholder="seed-tts-2.0-standard">
|
||||
<label>Language</label>
|
||||
<input v-model="form.language" placeholder="zh">
|
||||
<label>Description</label>
|
||||
<input v-model="form.description" placeholder="可选">
|
||||
<div v-if="addErr" class="err">{{ addErr }}</div>
|
||||
<div class="actions">
|
||||
<button class="btn" @click="showAdd=false">取消</button>
|
||||
<button class="btn btn-primary" @click="submitAdd" :disabled="adding">{{ adding ? '提交中...' : '提交' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, computed, onMounted, watch } = Vue;
|
||||
createApp({
|
||||
setup() {
|
||||
const apiKey = ref(sessionStorage.getItem('ttsAdminKey') || '');
|
||||
const keyInput = ref('');
|
||||
const loginErr = ref('');
|
||||
const tab = ref(location.hash.slice(1) || 'dashboard');
|
||||
const overview = ref({});
|
||||
const voices = ref([]);
|
||||
const actionErr = ref('');
|
||||
const showAdd = ref(false);
|
||||
const adding = ref(false);
|
||||
const addErr = ref('');
|
||||
const form = ref({ name: '', speaker: '', resource_id: 'volc.megatts.icl', model: 'seed-tts-2.0-standard', language: '', description: '' });
|
||||
|
||||
const http = axios.create({ baseURL: '/api' });
|
||||
http.interceptors.request.use(c => {
|
||||
if (apiKey.value) c.headers.Authorization = 'Bearer ' + apiKey.value;
|
||||
return c;
|
||||
});
|
||||
http.interceptors.response.use(r => r, err => {
|
||||
if (err.response && err.response.status === 401) {
|
||||
sessionStorage.removeItem('ttsAdminKey');
|
||||
apiKey.value = '';
|
||||
}
|
||||
return Promise.reject(err);
|
||||
});
|
||||
|
||||
watch(tab, v => { location.hash = v; });
|
||||
window.addEventListener('hashchange', () => { tab.value = location.hash.slice(1) || 'dashboard'; });
|
||||
|
||||
const login = async () => {
|
||||
loginErr.value = '';
|
||||
try {
|
||||
apiKey.value = keyInput.value;
|
||||
await http.get('/admin/overview');
|
||||
sessionStorage.setItem('ttsAdminKey', keyInput.value);
|
||||
} catch (e) {
|
||||
apiKey.value = '';
|
||||
loginErr.value = '鉴权失败: ' + (e.response?.data?.error?.message || e.message);
|
||||
}
|
||||
};
|
||||
const logout = () => { sessionStorage.removeItem('ttsAdminKey'); apiKey.value = ''; };
|
||||
|
||||
const loadOverview = async () => {
|
||||
try { overview.value = (await http.get('/admin/overview')).data; }
|
||||
catch (e) { actionErr.value = '加载概览失败: ' + e.message; }
|
||||
};
|
||||
const loadVoices = async () => {
|
||||
try {
|
||||
const r = await http.get('/voices');
|
||||
voices.value = r.data.voices || [];
|
||||
} catch (e) { actionErr.value = '加载音色失败: ' + e.message; }
|
||||
};
|
||||
const reloadAll = () => { loadOverview(); loadVoices(); };
|
||||
|
||||
const openAdd = () => {
|
||||
form.value = { name: '', speaker: '', resource_id: 'volc.megatts.icl', model: 'seed-tts-2.0-standard', language: '', description: '' };
|
||||
addErr.value = '';
|
||||
showAdd.value = true;
|
||||
};
|
||||
const submitAdd = async () => {
|
||||
addErr.value = '';
|
||||
if (!form.value.name || !form.value.speaker || !form.value.resource_id) {
|
||||
addErr.value = '请填写名称、Speaker、资源 ID';
|
||||
return;
|
||||
}
|
||||
adding.value = true;
|
||||
try {
|
||||
await http.post('/voices', form.value);
|
||||
showAdd.value = false;
|
||||
await loadVoices();
|
||||
await loadOverview();
|
||||
} catch (e) {
|
||||
addErr.value = e.response?.data?.error?.message || e.message;
|
||||
} finally { adding.value = false; }
|
||||
};
|
||||
const toggle = async (v) => {
|
||||
try { await http.patch(`/voices/${v.name}/toggle`, { enabled: !v.enabled }); await loadVoices(); }
|
||||
catch (e) { actionErr.value = '切换失败: ' + (e.response?.data?.error?.message || e.message); }
|
||||
};
|
||||
const remove = async (v) => {
|
||||
if (!confirm(`确定删除音色 "${v.name}" 吗?`)) return;
|
||||
try { await http.delete(`/voices/${v.name}`); await loadVoices(); await loadOverview(); }
|
||||
catch (e) { actionErr.value = '删除失败: ' + (e.response?.data?.error?.message || e.message); }
|
||||
};
|
||||
|
||||
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() : '';
|
||||
|
||||
onMounted(() => { if (apiKey.value) reloadAll(); });
|
||||
|
||||
return { apiKey, keyInput, loginErr, login, logout, tab, overview, voices, actionErr,
|
||||
showAdd, form, addErr, adding, openAdd, submitAdd, toggle, remove,
|
||||
formatUptime, shortPath, reloadAll };
|
||||
},
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+40
-17
@@ -18,10 +18,16 @@ var dashboardHTML []byte
|
||||
//go:embed setup.html
|
||||
var setupHTML []byte
|
||||
|
||||
//go:embed admin.html
|
||||
var adminHTML []byte
|
||||
|
||||
// Setup 返回主路由。
|
||||
// 中间件顺序(由外向内):
|
||||
// SecurityHeaders → InstallGuard → RateLimit → ConcurrencyLimit → Logger → handler
|
||||
// 关键: InstallGuard 必须在 RateLimit 之前,避免安装模式被限流计数污染。
|
||||
//
|
||||
// /admin 和 /api/admin/* 都加 RequireAdmin;InstallGuard 不预先放行(让安装模式下
|
||||
// 自动 302 跳 /setup,体验一致)。
|
||||
func Setup() *mux.Router {
|
||||
r := mux.NewRouter()
|
||||
|
||||
@@ -31,23 +37,21 @@ func Setup() *mux.Router {
|
||||
r.Use(middleware.ConcurrencyLimitWithMetrics)
|
||||
r.Use(middleware.Logger)
|
||||
|
||||
// mux 的 NotFoundHandler 不会走 r.Use() 中间件链,
|
||||
// 所以 InstallGuard 的内容协商在 404 路径上不生效。
|
||||
// 手动设一个:安装模式 + 浏览器访问任意未注册路径 → 302 跳 /setup。
|
||||
r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if installer.GetMode() == installer.ModeSetup && acceptsHTML(r.Header.Get("Accept")) {
|
||||
http.Redirect(w, r, "/setup", http.StatusFound)
|
||||
// mux 路由未匹配时 NotFoundHandler 单独处理(不走 r.Use() 中间件链);
|
||||
// 安装模式 + 浏览器访问任意未注册路径 → 302 跳 /setup。
|
||||
r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if installer.GetMode() == installer.ModeSetup && acceptsHTML(req.Header.Get("Accept")) {
|
||||
http.Redirect(w, req, "/setup", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
http.NotFound(w, req)
|
||||
})
|
||||
|
||||
// 安装相关路由(InstallGuard 已在 setup 模式放行;完成后由 controller 二次校验 404)
|
||||
// /setup 页面本身:装完后必须不可用,否则用户敲 /setup 还会看到安装表单,容易误以为要重装。
|
||||
// 装后跳 /admin(M2 之后才有;目前会 404,这是预期,比继续显示表单好)。
|
||||
r.HandleFunc("/setup", func(w http.ResponseWriter, r *http.Request) {
|
||||
r.HandleFunc("/setup", func(w http.ResponseWriter, req *http.Request) {
|
||||
if installer.GetMode() == installer.ModeNormal {
|
||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||
http.Redirect(w, req, "/admin", http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -57,20 +61,40 @@ func Setup() *mux.Router {
|
||||
r.HandleFunc("/api/setup/prefill", controller.SetupPrefillHandler).Methods("GET")
|
||||
r.HandleFunc("/api/setup", controller.SetupSubmitHandler).Methods("POST")
|
||||
|
||||
// /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)
|
||||
}).Methods("GET")
|
||||
|
||||
// /api/admin/overview (鉴权)
|
||||
r.Handle("/api/admin/overview", middleware.RequireAdmin(http.HandlerFunc(controller.AdminOverviewHandler))).Methods("GET")
|
||||
// /api/admin/metrics (鉴权);返 Prometheus 文本
|
||||
r.Handle("/api/admin/metrics", middleware.RequireAdmin(http.HandlerFunc(controller.AdminMetricsHandler))).Methods("GET")
|
||||
|
||||
// /api/voices 音色 CRUD (鉴权)
|
||||
r.Handle("/api/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoicesListHandler))).Methods("GET")
|
||||
r.Handle("/api/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceCreateHandler))).Methods("POST")
|
||||
r.Handle("/api/voices/{name}", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceDeleteHandler))).Methods("DELETE")
|
||||
r.Handle("/api/voices/{name}/toggle", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceToggleHandler))).Methods("PATCH")
|
||||
|
||||
// 业务路由
|
||||
r.HandleFunc("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS")
|
||||
r.HandleFunc("/health", controller.HealthHandler).Methods("GET")
|
||||
r.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) {
|
||||
r.HandleFunc("/dashboard", func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(dashboardHTML)
|
||||
}).Methods("GET")
|
||||
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// 安装模式下,根路径跳 /setup(给运维一个明显入口)
|
||||
r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
|
||||
// 安装模式下,根路径跳 /setup
|
||||
if installer.GetMode() == installer.ModeSetup {
|
||||
http.Redirect(w, r, "/setup", http.StatusFound)
|
||||
http.Redirect(w, req, "/setup", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
// 正常模式:跳 /admin(M2 之后优先于 /dashboard)
|
||||
http.Redirect(w, req, "/admin", http.StatusFound)
|
||||
}).Methods("GET")
|
||||
|
||||
// /metrics 不做鉴权(对齐 /health 策略),但仍然走 RateLimit / ConcurrencyLimit。
|
||||
@@ -80,9 +104,8 @@ func Setup() *mux.Router {
|
||||
return r
|
||||
}
|
||||
|
||||
// acceptsHTML 在 router 包内复刻一份,middleware 包的版本未导出。
|
||||
// acceptsHTML 在 router 包内复刻,middleware 包的版本未导出。
|
||||
// 用途:NotFoundHandler 判断浏览器 Accept。
|
||||
// 与 middleware.acceptsHTML 行为一致(简单实现,严格匹配 text/html 或 text/*)。
|
||||
func acceptsHTML(accept string) bool {
|
||||
if accept == "" {
|
||||
return false
|
||||
|
||||
+3
-1
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/volcano-tts/tts-api/adapter/volcano"
|
||||
"github.com/volcano-tts/tts-api/common"
|
||||
"github.com/volcano-tts/tts-api/telemetry"
|
||||
)
|
||||
|
||||
// 全部环境变量读取的单一入口:其它包不允许直接 os.Getenv,只读这里的全局 Config。
|
||||
@@ -318,7 +319,8 @@ func LogStartupSummary() {
|
||||
checks := []ttsCheck{
|
||||
{"BYTEDANCE_TTS_API_KEY", maskAPIKey(TTSOptions.APIKey), TTSOptions.APIKey != ""},
|
||||
{"BYTEDANCE_TTS_RESOURCE_ID", TTSOptions.ResourceID, TTSOptions.ResourceID != ""},
|
||||
{"BYTEDANCE_TTS_SPEAKER", TTSOptions.Speaker, TTSOptions.Speaker != ""},
|
||||
// speaker 是火山复刻音色 ID(用户付费资产),日志里打码,避免明文落盘
|
||||
{"BYTEDANCE_TTS_SPEAKER", telemetry.MaskSpeaker(TTSOptions.Speaker), TTSOptions.Speaker != ""},
|
||||
}
|
||||
missingCount := 0
|
||||
for _, c := range checks {
|
||||
|
||||
+42
-1
@@ -8,11 +8,52 @@
|
||||
// - Meter 是高层入口,NoopMeter 用于测试。
|
||||
package telemetry
|
||||
|
||||
import "sort"
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Labels 是指标附加的标签集合。Value 在序列化时会按 Prometheus 规范转义。
|
||||
type Labels map[string]string
|
||||
|
||||
// SpeakerLabel 把 speaker ID 转成不可逆的稳定短哈希,作为指标 label。
|
||||
// 目的:保护火山复刻音色资产(speaker ID 是用户付费 / 隐私敏感);
|
||||
// 同时仍能按 voice 聚合观测(同 speaker → 同 label)。
|
||||
//
|
||||
// 算法: sha1(s)[:8] = 32 bits 空间;典型 <100 个 voice 场景无碰撞风险。
|
||||
// 空串返回 "unknown",避免 /metrics label 出现空值 (Prometheus 禁止空 label)。
|
||||
//
|
||||
// 注意: 这是**不可逆**哈希,不是加密;不可用于需要还原原始 speaker 的场景。
|
||||
// Admin UI 想要看原名时,通过 /api/voices 拿 name 字段对照。
|
||||
func SpeakerLabel(s string) string {
|
||||
if s == "" {
|
||||
return "unknown"
|
||||
}
|
||||
sum := sha1.Sum([]byte(s))
|
||||
return hex.EncodeToString(sum[:])[:8]
|
||||
}
|
||||
|
||||
// MaskSpeaker 把 speaker ID 部分打码用于日志输出。
|
||||
// - 空 → "***"
|
||||
// - 长度 ≤ 4 → 全打码
|
||||
// - 其它 → 前 4 + **** + 后 4 (保留前缀便于肉眼区分 "S_xx 开头" vs "BV001_...")
|
||||
// 例子: "S_G8tEKnaJ1" → "S_G8****naJ1"
|
||||
func MaskSpeaker(s string) string {
|
||||
if s == "" {
|
||||
return "(未设置)"
|
||||
}
|
||||
if len(s) <= 4 {
|
||||
return strings.Repeat("*", len(s))
|
||||
}
|
||||
// 找前 4 字符中第一个非 [A-Za-z0-9_] 字符做截断,避免截到奇怪位置
|
||||
// (虽然火山 ID 实际都是 S_xxx 字母数字组合,这里保险)
|
||||
prefix := s[:4]
|
||||
suffix := s[len(s)-4:]
|
||||
return prefix + "****" + suffix
|
||||
}
|
||||
|
||||
// labelKey 计算一组标签的稳定 key,用于在内部 map 中唯一定位 child。
|
||||
// 缺失或多余的 label 一律视为空串,以保证 child 数量与 label 名集合一致。
|
||||
func labelKey(names []string, labels Labels) string {
|
||||
|
||||
Reference in New Issue
Block a user