服务端一批「会误导现场」的缺陷,全部补了测试: - 枚举型入参(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 不外泄原文、限流键清理不误删。
128 lines
4.3 KiB
Go
128 lines
4.3 KiB
Go
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)
|
||
}
|
||
}
|