原版 controller/admin.go VoiceInsert 错误处理的 default 分支把所有非
ErrDuplicate 错误(含 DB 连接失败等真服务端错误)都当 400 + voice_invalid
返回。问题:
- 客户端拿到 400 不会重试,但实际是服务端问题
- 4xx 通常被监控忽略,DB 故障等不到告警
- err.Error() 透传前端,可能泄漏 DB schema / SQL 细节
修复:加 store.ErrInvalid 哨兵,把客户端校验错误(name 格式 / speaker /
resource_id 缺失)统一 wrap 进 ErrInvalid,controller 改用 errors.Is 三向分流:
- ErrDuplicate → 409 voice_duplicate
- ErrInvalid → 400 voice_invalid(剥掉 store 前缀,返用户友好 detail)
- 其它(DB 失败 / LastInsertId 失败)→ 500 voice_insert_failed
(详细 err 走 log.Printf,不外抛,避免信息泄漏)
新增 stripInvalidPrefix helper 处理 'store: voice invalid: ' 前缀剥离,
保留底层 detail(例如 'speaker is required')给前端展示。
VoiceUpdate 保持不动(本任务范围只到 VoiceInsert);后续若发现类似问题
可复用同一 ErrInvalid 哨兵模式。
324 lines
11 KiB
Go
324 lines
11 KiB
Go
package controller
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"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 {
|
|
case err == store.ErrDuplicate:
|
|
middleware.SendJSONError(w, http.StatusConflict,
|
|
fmt.Sprintf("voice name %q already exists", v.Name),
|
|
"invalid_request_error", "voice_duplicate")
|
|
case errors.Is(err, store.ErrInvalid):
|
|
// 客户端输入不合法(name 格式 / speaker / resource_id 缺失):400。
|
|
// 走 ErrInvalid 哨兵 + 原始 detail,前端可以直接显示。
|
|
middleware.SendJSONError(w, http.StatusBadRequest,
|
|
stripInvalidPrefix(err.Error()),
|
|
"invalid_request_error", "voice_invalid")
|
|
default:
|
|
// 兜底:DB 连接失败 / Exec 错误 / LastInsertId 失败等都是服务端问题,
|
|
// 返 500 + 通用 message,详细 err 走日志(不直接暴露给前端,避免信息泄漏)。
|
|
log.Printf("[admin] voice insert: %v", err)
|
|
middleware.SendJSONError(w, http.StatusInternalServerError,
|
|
"failed to insert voice", "server_error", "voice_insert_failed")
|
|
}
|
|
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)
|
|
}
|
|
|
|
// stripInvalidPrefix 把 wrap 后的 ErrInvalid 错误字符串中的 "store: voice invalid: " 前缀剥掉,
|
|
// 只保留底层 detail(例如 "speaker is required"),让前端能直接展示用户友好文案。
|
|
// 如果上游 wrap 形态变化(没匹配到前缀),原样返回,避免误删关键信息。
|
|
func stripInvalidPrefix(s string) string {
|
|
const prefix = "store: voice invalid: "
|
|
if strings.HasPrefix(s, prefix) {
|
|
return s[len(prefix):]
|
|
}
|
|
return s
|
|
}
|