Files
Volcano-Engine-TTS-UI/controller/admin.go
T
sun 70865a3cf4 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 后一起验证)
2026-08-29 21:53:19 +08:00

302 lines
10 KiB
Go

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)
}