fix(admin): VoiceInsert 错误按客户端/服务端分流,400/500 不再混淆

原版 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 哨兵模式。
This commit is contained in:
sun
2026-09-21 17:22:45 +08:00
parent 6ed25e8e4b
commit 01bc5ea5bf
2 changed files with 37 additions and 7 deletions
+25 -3
View File
@@ -2,9 +2,11 @@ package controller
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
@@ -191,14 +193,23 @@ func AdminVoiceCreateHandler(w http.ResponseWriter, r *http.Request) {
} }
id, err := s.VoiceInsert(v) id, err := s.VoiceInsert(v)
if err != nil { if err != nil {
switch err { switch {
case store.ErrDuplicate: case err == store.ErrDuplicate:
middleware.SendJSONError(w, http.StatusConflict, middleware.SendJSONError(w, http.StatusConflict,
fmt.Sprintf("voice name %q already exists", v.Name), fmt.Sprintf("voice name %q already exists", v.Name),
"invalid_request_error", "voice_duplicate") "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: default:
// 兜底:DB 连接失败 / Exec 错误 / LastInsertId 失败等都是服务端问题,
// 返 500 + 通用 message,详细 err 走日志(不直接暴露给前端,避免信息泄漏)。
log.Printf("[admin] voice insert: %v", 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 return
} }
@@ -299,3 +310,14 @@ func AdminVoiceToggleHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(updated) _ = 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
}
+12 -4
View File
@@ -36,6 +36,11 @@ var ErrInUse = errors.New("store: voice is referenced by default_speaker")
// ErrNotFound 表示按 id/name 找不到;controller 翻译为 404。 // ErrNotFound 表示按 id/name 找不到;controller 翻译为 404。
var ErrNotFound = errors.New("store: voice not found") 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 注入 + 路径穿越防护。 // voiceNameRe 限制 voice 名为 [a-zA-Z0-9_-]{1,64};SQL 注入 + 路径穿越防护。
var voiceNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) 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。 // VoiceInsert 新增音色;name 冲突返回 ErrDuplicate。
// 空字符串/格式不合法返回 error;不依赖 SQLite 约束作为唯一校验。 // 客户端输入错误(name 格式 / 必填字段缺失)返回 wrap ErrInvalid 的 error;
// 服务端错误(DB 失败等)不被 wrap,controller 用 errors.Is 区分。
// 不依赖 SQLite 约束作为唯一校验。
func (s *Store) VoiceInsert(v Voice) (int64, error) { func (s *Store) VoiceInsert(v Voice) (int64, error) {
v.Name = strings.TrimSpace(v.Name) v.Name = strings.TrimSpace(v.Name)
v.Speaker = strings.TrimSpace(v.Speaker) v.Speaker = strings.TrimSpace(v.Speaker)
@@ -125,13 +132,14 @@ func (s *Store) VoiceInsert(v Voice) (int64, error) {
v.Description = strings.TrimSpace(v.Description) v.Description = strings.TrimSpace(v.Description)
if err := validateVoiceName(v.Name); err != nil { 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 == "" { 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 == "" { 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(` res, err := s.db.Exec(`