fix: 枚举入参校验(500→400)、5xx 错误串不再外泄、健康检查探库、限流键清理

服务端一批「会误导现场」的缺陷,全部补了测试:

- 枚举型入参(events 的 event_type/result_mode/result_type/compare/status、
  scoring_profiles 的 tie_mode/record_bonus_mode、entries.status、results.status)
  过去完全没有校验,非法值一路落到 SQLite 的 CHECK 约束,被统一错误处理当成
  服务端异常报成 500;现在统一在 enums.go 拦成 400 VALIDATION_FAILED,
  message 带字段名与合法取值、detail 带原始输入值。空值仍走原来的默认值。
- 5xx 响应体不再回显内部错误原文:新增 apiError.logDetail,SQLite / 引擎的
  原始错误串只进 ERROR 日志的 detail 字段。此前正好是反的——前端拿得到原文,
  日志里只有「服务端异常」。同时校准 newAPIError / internalMsg 的 skip 层数,
  拦截点不再退化成 response.go 里的构造函数。
- /api/health 新增 db / dbError:用 SELECT 1 探库(Ping 探不到「文件在、库读不动」),
  超时 5s 与 busy_timeout 同量级。状态码仍恒 200 —— Console 拿状态码当存活信号,
  数据库故障时回 5xx 只会变成重启循环,两个事实分两个字段表达。
- 修改密码时原密码错误由 401 改为 400 AUTH_FAILED(新增 wrongCredential):
  401 的语义是「本请求没有有效会话」,而这里会话是好的。
- 限流 hits map 在键数超过阈值时清理窗口内已无记录的键(窗口内活跃的键不动,
  否则堆键数就能冲掉别人的计数)。
- createEntry / updateEntry 拒绝「A 小项的报名挂在 B 小项的组别上」。
  数据库层仍无 (event_id, category_id) 复合外键(重建 entries 会牵动 results /
  score_ledger 的级联,风险高不做),这一层先兜住。
- 补测试:枚举字段逐项正反例、CSRF 反向用例、写限流 429、健康探库(含库故障)、
  5xx 不外泄原文、限流键清理不误删。
This commit is contained in:
AskaEth committed 2026-09-11 14:28:25 +08:00
1 parent 23fee5b818
commit 2c983bcfdf
14 files changed
+1112 -21

No files matched your search

+34 -5
View File
@@ -16,6 +16,10 @@ type apiError struct {
code string
message string
detail any
// logDetail 是「只写日志、不进响应体」的错误原文,只有 5xx 用:
// SQLite / 计分引擎的原始错误串里可能带表名列名甚至数据片段,
// 现场排查需要它,前端不需要、也不该拿到。
logDetail string
// origin 是「拦截点」:构造这个错误的 file:line 与函数名,例如
// internal/httpapi/repo_events.go:231 normalizeAvgMode。拦截日志靠它定位到校验代码。
origin string
@@ -24,15 +28,25 @@ type apiError struct {
func (e *apiError) Error() string { return e.code + ": " + e.message }
// newAPIError 是所有业务错误的唯一构造入口:在这里捕获拦截点。
// skip=3 是 callerOrigin → newAPIError → 具体构造函数 → 真正做校验的调用点,
// 少一层就会把 helper 自己记成拦截点,那样日志等于没说。
// skip 记录的是「runtime 栈里从 Origin 数到真正做校验那段业务代码的层数」,
// 调用链是:业务代码 → 具体构造函数(badRequest 等)→ newAPIError → newAPIErrorSkip
// → callerOrigin → logging.Origin,所以这里从 4 起算。
//
// 往这条链上加封装时**必须同步加一**,否则拦截点会退化成 response.go 里的 helper,
// 现场拿着它找不到任何校验代码(4xx 那串拦截点有测试钉着:TestInterceptLogPointsAtValidator)。
func newAPIError(status int, code, message string, detail any) *apiError {
return newAPIErrorSkip(4, status, code, message, detail)
}
// newAPIErrorSkip 是带显式层数的构造器,供链路层数不同的错误用(目前是 internal / internalMsg,
// 它们比 badRequest 那串多一层:业务代码 → internal → internalMsg → 这里)。
func newAPIErrorSkip(skip int, status int, code, message string, detail any) *apiError {
return &apiError{
status: status,
code: code,
message: message,
detail: detail,
origin: callerOrigin(3),
origin: callerOrigin(skip),
}
}
@@ -77,13 +91,28 @@ func serviceUnavailable(code, message string) *apiError {
return newAPIError(http.StatusServiceUnavailable, code, message, nil)
}
// wrongCredential 是 400:已登录用户做敏感操作时提供的凭据不对(目前只有「修改密码时原密码错」)。
//
// 不能用 401 —— 401 的含义是「这个请求没有有效会话」,而本请求的会话是好的;
// 前端按 401 的既有约定(AUTH_REQUIRED)会把人踢下线,填错一次原密码就被登出,现场不可接受。
// code 仍沿用 AUTH_FAILED,前端的错误码联合类型与文案都不需要改。
func wrongCredential(message string) *apiError {
return newAPIError(http.StatusBadRequest, "AUTH_FAILED", message, nil)
}
// internal 是 500:给现场看的只有 message,原始错误串走 logDetail 只进日志。
func internal(err error) *apiError {
return newAPIError(http.StatusInternalServerError, "INTERNAL", "服务端异常", err.Error())
return internalMsg("服务端异常", err)
}
// internalMsg 是 internal 的可自定义文案版本:5xx 的 message 也要让现场看懂发生了什么。
// skip=4:业务代码 → internal/internalMsg → newAPIErrorSkip → callerOrigin,比 newAPIError 多一层。
func internalMsg(message string, err error) *apiError {
return newAPIError(http.StatusInternalServerError, "INTERNAL", message, err.Error())
api := newAPIErrorSkip(4, http.StatusInternalServerError, "INTERNAL", message, nil)
if err != nil {
api.logDetail = err.Error()
}
return api
}
func notImplemented(message string) *apiError {