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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user