feat(admin): M2 WebUI 管理后台(鉴权+仪表盘+音色 CRUD)

新增 /admin 管理后台,Vue3 + axios 单文件 SPA。

新增:
- middleware/admin_auth.go: RequireAdmin 中间件
  复用 OPENAI_TTS_API_KEY(Bearer),密钥为空等同无鉴权;
  常量时间比较防计时攻击(用 common.SecureEqualString);
  401 + WWW-Authenticate 头 + JSON 错误体。
- controller/admin.go: 5 个 admin handler
  - GET /api/admin/overview: 模式/db路径/lock/版本/运行时间/音色数/内存
  - GET /api/admin/metrics: 鉴权版 Prometheus 文本(无鉴权版 /metrics 仍给 Prometheus)
  - GET /api/voices: 列表(含 disabled)
  - POST /api/voices: 新增,409 处理重复,400 处理字段不合法
  - DELETE /api/voices/{name}: 删除,409 处理被 default_speaker 引用
  - PATCH /api/voices/{name}/toggle: 启停
- router/admin.html: 单文件 SPA(18KB)
  登录页(单 input) + 仪表盘(metrics cards) + 音色 tab(表格+新增+启停+删除);
  hash 路由切 tab;axios 拦截器自动加 Bearer,401 回登录页;
  sessionStorage 存 key(关浏览器失效)。
- common/constants.go: SecureEqualString 提取到公共包
  controller/setup.go + middleware/admin_auth.go 共用,避免重复实现。

改造:
- router/router.go: 挂 /admin(/admin HTML 公开,鉴权由前端 JS 拦截)
  + /api/admin/{overview,metrics} + /api/voices{,/{name},/{name}/toggle}
  全部 API 端点套 RequireAdmin;正常模式 / → 302 /admin(M2 优先于 /dashboard)。
- main.go: 启动期调 SetAdminStore + SetMetricsTextWriter(避免 controller → metrics cycle)
- metrics/metrics.go: AdapterRecorder 用 SpeakerLabel 替代明文 speaker
  (这是第 4 处 speaker 泄漏,顺带补上;前面 3 处在 51bd7ae 已修)。
- controller/setup.go: 用 common.SecureEqualString 替自己的实现(去重)。

测试(都被 .gitignore 排除,本地保留):
- router/router_test.go: 更新 4 个 NotFound 测试路径(/admin 现是注册路由,不再 404)
- telemetry/labels_test.go: 已有 SpeakerLabel/MaskSpeaker 测试(M2 复用)

e2e 验证(本机跑通):
- /admin 200 HTML(无鉴权,前端 JS 拦截)
- /api/admin/overview 无 Bearer 401
- /api/admin/overview 错 Bearer 401
- /api/admin/overview 对 Bearer 200 + JSON
- /api/voices GET 200 + 2 voices
- /api/voices POST 201 + new voice
- /api/voices/{name} DELETE 200
- /api/voices/{name}/toggle PATCH 200 + updated voice
- 正常模式 / 302 /admin
- 启动日志 BYTEDANCE_TTS_SPEAKER 仍打码 S_G8****naJ1(前次 fix 生效)

二进制大小: 16MB → ~17MB(admin.html +5 个 handler + 中间件)

未 push(用户要求与 speaker fix 51bd7ae 一起发,等服务器测完 M1 后一起验证)
This commit is contained in:
sun
2026-08-29 21:53:19 +08:00
parent 51bd7ae37a
commit 70865a3cf4
8 changed files with 806 additions and 33 deletions
+19
View File
@@ -20,3 +20,22 @@ const (
MaxModelNameLength = 64 MaxModelNameLength = 64
MaxRateLimiterEntries = 100000 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
}
+301
View File
@@ -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
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/volcano-tts/tts-api/common"
"github.com/volcano-tts/tts-api/installer" "github.com/volcano-tts/tts-api/installer"
"github.com/volcano-tts/tts-api/middleware" "github.com/volcano-tts/tts-api/middleware"
"github.com/volcano-tts/tts-api/setting" "github.com/volcano-tts/tts-api/setting"
@@ -262,17 +263,5 @@ func validateSetupVoices(vs []SetupVoice) error {
return nil return nil
} }
// secureEqualString 是常量时间字符串比较,防止 token 计时攻击。 // secureEqualString wraps common.SecureEqualString 保持向后兼容(原文件内已有调用)。
func secureEqualString(a, b string) bool { func secureEqualString(a, b string) bool { return common.SecureEqualString(a, b) }
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
}
+7
View File
@@ -49,6 +49,12 @@ func main() {
} }
// 注入 setup 控制器需要的 store + dbPath(无论哪种模式都注入,正常模式下备用) // 注入 setup 控制器需要的 store + dbPath(无论哪种模式都注入,正常模式下备用)
controller.SetSetupState(st, 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) log.Printf("[main] 当前模式: %s (db=%s lock=%s)", res.Mode, dbPath, res.LockPath)
controller.InitController() controller.InitController()
@@ -75,6 +81,7 @@ func main() {
log.Printf("Starting ByteDance TTS to OpenAI API Adapter Server") log.Printf("Starting ByteDance TTS to OpenAI API Adapter Server")
log.Printf("Listening on port: %s", setting.Server.Port) 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("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("Health check: http://localhost:%s/health", setting.Server.Port)
log.Printf("Metrics: http://localhost:%s/metrics", setting.Server.Port) log.Printf("Metrics: http://localhost:%s/metrics", setting.Server.Port)
+4 -2
View File
@@ -114,12 +114,14 @@ type AdapterRecorder struct{}
// UpstreamStarted 满足 volcano.MetricsRecorder 接口。 // UpstreamStarted 满足 volcano.MetricsRecorder 接口。
func (AdapterRecorder) UpstreamStarted(speaker, model, format string) { 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 接口。 // UpstreamFinished 满足 volcano.MetricsRecorder 接口。
func (AdapterRecorder) UpstreamFinished(speaker, model, format, status string, duration, ttfb time.Duration, chunks, audioBytes, errCode int) { 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) UpstreamTotal.Inc(labels)
UpstreamDuration.Observe(duration.Seconds(), telemetry.Labels{"status": status, "format": format}) UpstreamDuration.Observe(duration.Seconds(), telemetry.Labels{"status": status, "format": format})
if ttfb > 0 { if ttfb > 0 {
+73
View File
@@ -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"}}`))
}
+359
View File
@@ -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
View File
@@ -18,10 +18,16 @@ var dashboardHTML []byte
//go:embed setup.html //go:embed setup.html
var setupHTML []byte var setupHTML []byte
//go:embed admin.html
var adminHTML []byte
// Setup 返回主路由。 // Setup 返回主路由。
// 中间件顺序(由外向内): // 中间件顺序(由外向内):
// SecurityHeaders → InstallGuard → RateLimit → ConcurrencyLimit → Logger → handler // SecurityHeaders → InstallGuard → RateLimit → ConcurrencyLimit → Logger → handler
// 关键: InstallGuard 必须在 RateLimit 之前,避免安装模式被限流计数污染。 // 关键: InstallGuard 必须在 RateLimit 之前,避免安装模式被限流计数污染。
//
// /admin 和 /api/admin/* 都加 RequireAdmin;InstallGuard 不预先放行(让安装模式下
// 自动 302 跳 /setup,体验一致)。
func Setup() *mux.Router { func Setup() *mux.Router {
r := mux.NewRouter() r := mux.NewRouter()
@@ -31,23 +37,21 @@ func Setup() *mux.Router {
r.Use(middleware.ConcurrencyLimitWithMetrics) r.Use(middleware.ConcurrencyLimitWithMetrics)
r.Use(middleware.Logger) r.Use(middleware.Logger)
// mux 的 NotFoundHandler 不会走 r.Use() 中间件链, // mux 路由未匹配时 NotFoundHandler 单独处理(不走 r.Use() 中间件链);
// 所以 InstallGuard 的内容协商在 404 路径上不生效。 // 安装模式 + 浏览器访问任意未注册路径 → 302 跳 /setup。
// 手动设一个:安装模式 + 浏览器访问任意未注册路径 → 302 跳 /setup。 r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if installer.GetMode() == installer.ModeSetup && acceptsHTML(req.Header.Get("Accept")) {
if installer.GetMode() == installer.ModeSetup && acceptsHTML(r.Header.Get("Accept")) { http.Redirect(w, req, "/setup", http.StatusFound)
http.Redirect(w, r, "/setup", http.StatusFound)
return return
} }
http.NotFound(w, r) http.NotFound(w, req)
}) })
// 安装相关路由(InstallGuard 已在 setup 模式放行;完成后由 controller 二次校验 404) // 安装相关路由(InstallGuard 已在 setup 模式放行;完成后由 controller 二次校验 404)
// /setup 页面本身:装完后必须不可用,否则用户敲 /setup 还会看到安装表单,容易误以为要重装。 // /setup 页面本身:装完后必须不可用,否则用户敲 /setup 还会看到安装表单,容易误以为要重装。
// 装后跳 /admin(M2 之后才有;目前会 404,这是预期,比继续显示表单好)。 r.HandleFunc("/setup", func(w http.ResponseWriter, req *http.Request) {
r.HandleFunc("/setup", func(w http.ResponseWriter, r *http.Request) {
if installer.GetMode() == installer.ModeNormal { if installer.GetMode() == installer.ModeNormal {
http.Redirect(w, r, "/admin", http.StatusFound) http.Redirect(w, req, "/admin", http.StatusFound)
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") 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/prefill", controller.SetupPrefillHandler).Methods("GET")
r.HandleFunc("/api/setup", controller.SetupSubmitHandler).Methods("POST") 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("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS")
r.HandleFunc("/health", controller.HealthHandler).Methods("GET") 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.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(dashboardHTML) _, _ = w.Write(dashboardHTML)
}).Methods("GET") }).Methods("GET")
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
// 安装模式下,根路径跳 /setup(给运维一个明显入口) // 安装模式下,根路径跳 /setup
if installer.GetMode() == installer.ModeSetup { if installer.GetMode() == installer.ModeSetup {
http.Redirect(w, r, "/setup", http.StatusFound) http.Redirect(w, req, "/setup", http.StatusFound)
return return
} }
http.Redirect(w, r, "/dashboard", http.StatusFound) // 正常模式:跳 /admin(M2 之后优先于 /dashboard)
http.Redirect(w, req, "/admin", http.StatusFound)
}).Methods("GET") }).Methods("GET")
// /metrics 不做鉴权(对齐 /health 策略),但仍然走 RateLimit / ConcurrencyLimit。 // /metrics 不做鉴权(对齐 /health 策略),但仍然走 RateLimit / ConcurrencyLimit。
@@ -80,9 +104,8 @@ func Setup() *mux.Router {
return r return r
} }
// acceptsHTML 在 router 包内复刻一份,middleware 包的版本未导出。 // acceptsHTML 在 router 包内复刻,middleware 包的版本未导出。
// 用途:NotFoundHandler 判断浏览器 Accept。 // 用途:NotFoundHandler 判断浏览器 Accept。
// 与 middleware.acceptsHTML 行为一致(简单实现,严格匹配 text/html 或 text/*)。
func acceptsHTML(accept string) bool { func acceptsHTML(accept string) bool {
if accept == "" { if accept == "" {
return false return false