Files
Scoreboard/server/internal/httpapi/enums.go
T
AskaEth 2c983bcfdf 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 不外泄原文、限流键清理不误删。
2026-09-11 14:28:25 +08:00

128 lines
4.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import "strings"
// 枚举型入参的取值校验集中在本文件。
//
// 这些列在迁移里都写了 CHECK (col IN (...)),但 CHECK 是最后一道闸:非法值一路落到
// SQLite 才被拒,报出来的是原始约束错误,被统一错误处理当成服务端异常,前端看到
// 「服务端异常」而不是「这个值不合法」。必须在这一层拦下来,才能给出 400 与字段名。
//
// 每个函数都直接调用 badRequest:拦截点靠它捕获调用栈,中间再包一层错误构造 helper
// 会让拦截点退化成 response.go 里的构造函数(有测试钉着,见 intercept_log_test.go)。
// 空值一律给建表默认值,与原先 orDefault(...) 的默认值保持一致;返回值做 TrimSpace,
// 避免「前后带空格」这种近失值又落回 CHECK。
// normalizeEventType 校验 events.event_type。
// 取值来自 0004 的 CHECK,0016 用 writable_schema 追加了 individual_team。
func normalizeEventType(v string) (string, error) {
switch strings.TrimSpace(v) {
case "":
return "individual", nil
case "individual", "team", "match", "individual_team":
return strings.TrimSpace(v), nil
default:
return "", badRequest("eventType 只能是 individual / team / match / individual_team", v)
}
}
// normalizeResultMode 校验 events.result_mode(0004 的 CHECK)。
func normalizeResultMode(v string) (string, error) {
switch strings.TrimSpace(v) {
case "":
return "auto", nil
case "auto", "manual":
return strings.TrimSpace(v), nil
default:
return "", badRequest("resultMode 只能是 auto / manual", v)
}
}
// normalizeResultType 校验 events.result_type(0004 的 CHECK)。
func normalizeResultType(v string) (string, error) {
switch strings.TrimSpace(v) {
case "":
return "none", nil
case "time", "distance", "count", "score", "none":
return strings.TrimSpace(v), nil
default:
return "", badRequest("resultType 只能是 time / distance / count / score / none", v)
}
}
// normalizeCompare 校验 events.compare(0004 的 CHECK)。
func normalizeCompare(v string) (string, error) {
switch strings.TrimSpace(v) {
case "":
return "none", nil
case "asc", "desc", "none":
return strings.TrimSpace(v), nil
default:
return "", badRequest("compare 只能是 asc / desc / none", v)
}
}
// normalizeEventStatus 校验 events.status(0004 的 CHECK)。
// 与 entries.status / results.status 同名但取值不同,故单独一个函数。
func normalizeEventStatus(v string) (string, error) {
switch strings.TrimSpace(v) {
case "":
return "pending", nil
case "pending", "ongoing", "finished", "published":
return strings.TrimSpace(v), nil
default:
return "", badRequest("status 只能是 pending / ongoing / finished / published", v)
}
}
// normalizeTieMode 校验 scoring_profiles.tie_mode(0003 的 CHECK)。
func normalizeTieMode(v string) (string, error) {
switch strings.TrimSpace(v) {
case "":
return "skip", nil
case "skip", "average", "higher":
return strings.TrimSpace(v), nil
default:
return "", badRequest("tieMode 只能是 skip / average / higher", v)
}
}
// normalizeRecordBonusMode 校验 scoring_profiles.record_bonus_mode(0003 的 CHECK)。
func normalizeRecordBonusMode(v string) (string, error) {
switch strings.TrimSpace(v) {
case "":
return "max", nil
case "max", "sum":
return strings.TrimSpace(v), nil
default:
return "", badRequest("recordBonusMode 只能是 max / sum", v)
}
}
// normalizeEntryStatus 校验 JSON 接口传入的 entries.status(0005 的 CHECK)。
//
// 只走报名表 CSV/XLSX 导入那条链路时用不到本函数:导入有 normalizeImportStatus,
// 它认中文别名并给出行级提示;本函数只服务于 JSON 入参,不认别名。
func normalizeEntryStatus(v string) (string, error) {
switch strings.TrimSpace(v) {
case "":
return "registered", nil
case "registered", "scratched", "dq", "dns":
return strings.TrimSpace(v), nil
default:
return "", badRequest("status 只能是 registered / scratched / dq / dns", v)
}
}
// normalizeResultStatus 校验 results.status(0005 的 CHECK)。
func normalizeResultStatus(v string) (string, error) {
switch strings.TrimSpace(v) {
case "":
return "ok", nil
case "ok", "dq", "dns", "dnf", "withdrawn":
return strings.TrimSpace(v), nil
default:
return "", badRequest("status 只能是 ok / dq / dns / dnf / withdrawn", v)
}
}