diff --git a/controller/admin.go b/controller/admin.go index 490a006..630282f 100644 --- a/controller/admin.go +++ b/controller/admin.go @@ -2,9 +2,11 @@ package controller import ( "encoding/json" + "errors" "fmt" "log" "net/http" + "strings" "sync" "time" @@ -191,14 +193,23 @@ func AdminVoiceCreateHandler(w http.ResponseWriter, r *http.Request) { } id, err := s.VoiceInsert(v) if err != nil { - switch err { - case store.ErrDuplicate: + 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.StatusBadRequest, err.Error(), "invalid_request_error", "voice_invalid") + middleware.SendJSONError(w, http.StatusInternalServerError, + "failed to insert voice", "server_error", "voice_insert_failed") } return } @@ -299,3 +310,14 @@ func AdminVoiceToggleHandler(w http.ResponseWriter, r *http.Request) { 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 +} diff --git a/store/voices.go b/store/voices.go index 132843c..6d265b8 100644 --- a/store/voices.go +++ b/store/voices.go @@ -36,6 +36,11 @@ var ErrInUse = errors.New("store: voice is referenced by default_speaker") // ErrNotFound 表示按 id/name 找不到;controller 翻译为 404。 var ErrNotFound = errors.New("store: voice not found") +// ErrInvalid 表示客户端输入不合法(name 格式 / 必填字段缺失); +// controller 用 errors.Is(err, ErrInvalid) 翻译为 400。 +// 服务端错误(DB 失败等)不会被 wrap,controller 应翻译为 500。 +var ErrInvalid = errors.New("store: voice invalid") + // voiceNameRe 限制 voice 名为 [a-zA-Z0-9_-]{1,64};SQL 注入 + 路径穿越防护。 var voiceNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) @@ -115,7 +120,9 @@ func (s *Store) GetVoiceForTTS(name string) (speaker, resourceID, model string, } // VoiceInsert 新增音色;name 冲突返回 ErrDuplicate。 -// 空字符串/格式不合法返回 error;不依赖 SQLite 约束作为唯一校验。 +// 客户端输入错误(name 格式 / 必填字段缺失)返回 wrap ErrInvalid 的 error; +// 服务端错误(DB 失败等)不被 wrap,controller 用 errors.Is 区分。 +// 不依赖 SQLite 约束作为唯一校验。 func (s *Store) VoiceInsert(v Voice) (int64, error) { v.Name = strings.TrimSpace(v.Name) v.Speaker = strings.TrimSpace(v.Speaker) @@ -125,13 +132,14 @@ func (s *Store) VoiceInsert(v Voice) (int64, error) { v.Description = strings.TrimSpace(v.Description) if err := validateVoiceName(v.Name); err != nil { - return 0, err + // validateVoiceName 返纯文本;这里 wrap 进 ErrInvalid 让 controller 用 errors.Is 判定。 + return 0, fmt.Errorf("%w: %s", ErrInvalid, err.Error()) } if v.Speaker == "" { - return 0, fmt.Errorf("store: voice insert: speaker is required") + return 0, fmt.Errorf("%w: speaker is required", ErrInvalid) } if v.ResourceID == "" { - return 0, fmt.Errorf("store: voice insert: resource_id is required") + return 0, fmt.Errorf("%w: resource_id is required", ErrInvalid) } res, err := s.db.Exec(`