Files
Scoreboard/server/internal/httpapi/response.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

274 lines
9.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 (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// apiError 是可直接映射为统一响应结构的业务错误(见 devdocs/05 第 8 节错误码)。
type apiError struct {
status int
code string
message string
detail any
// logDetail 是「只写日志、不进响应体」的错误原文,只有 5xx 用:
// SQLite / 计分引擎的原始错误串里可能带表名列名甚至数据片段,
// 现场排查需要它,前端不需要、也不该拿到。
logDetail string
// origin 是「拦截点」:构造这个错误的 file:line 与函数名,例如
// internal/httpapi/repo_events.go:231 normalizeAvgMode。拦截日志靠它定位到校验代码。
origin string
}
func (e *apiError) Error() string { return e.code + ": " + e.message }
// newAPIError 是所有业务错误的唯一构造入口:在这里捕获拦截点。
// 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(skip),
}
}
func badRequest(message string, detail any) *apiError {
return newAPIError(http.StatusBadRequest, "VALIDATION_FAILED", message, detail)
}
func notFound(message string) *apiError {
return newAPIError(http.StatusNotFound, "VALIDATION_FAILED", message, nil)
}
func conflict(message string) *apiError {
return newAPIError(http.StatusConflict, "CONFLICT", message, nil)
}
func stateInvalid(message string) *apiError {
return newAPIError(http.StatusConflict, "STATE_INVALID", message, nil)
}
// unauthorized 是 401:code 区分「没登录」与「凭据不对」。
func unauthorized(code, message string) *apiError {
return newAPIError(http.StatusUnauthorized, code, message, nil)
}
// forbidden 是 403:角色不足或来源校验不过。
func forbidden(message string) *apiError {
return newAPIError(http.StatusForbidden, "FORBIDDEN", message, nil)
}
// rateLimited 是 429:写操作/登录太频繁,稍后可重试。
func rateLimited(message string) *apiError {
return newAPIError(http.StatusTooManyRequests, "RATE_LIMITED", message, nil)
}
// tooLarge 是 413:正文超过上限,与「格式不对」区分开,前端才知道该怎么处理。
func tooLarge(message string) *apiError {
return newAPIError(http.StatusRequestEntityTooLarge, "VALIDATION_FAILED", message, nil)
}
// serviceUnavailable 是 503:依赖未接入或目录未配置,配置好即可用,重试无用。
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 internalMsg("服务端异常", err)
}
// internalMsg 是 internal 的可自定义文案版本:5xx 的 message 也要让现场看懂发生了什么。
// skip=4:业务代码 → internal/internalMsg → newAPIErrorSkip → callerOrigin,比 newAPIError 多一层。
func internalMsg(message string, err error) *apiError {
api := newAPIErrorSkip(4, http.StatusInternalServerError, "INTERNAL", message, nil)
if err != nil {
api.logDetail = err.Error()
}
return api
}
func notImplemented(message string) *apiError {
return newAPIError(http.StatusNotImplemented, "INTERNAL", message, nil)
}
// handler 是业务处理器:返回 data 时自动包成 {ok:true,data},返回 *apiError 时输出统一错误。
type handler func(w http.ResponseWriter, r *http.Request) (any, error)
// handle 统一把业务结果/错误写成 REST 响应。
func (s *server) handle(fn handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := fn(w, r)
if err != nil {
if errors.Is(err, errHandled) {
return
}
s.writeAPIError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": data})
}
}
// handleVoid 用于无返回体的写操作。
func (s *server) handleVoid(fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
s.writeAPIError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": nil})
}
}
// writeAPIError 把业务错误写成统一响应,并按级别记日志:
// 4xx 是「我们主动拦下的」,WARN 给出原因与拦截点(默认 info 级别下必须可见),DEBUG 补上下文;
// 5xx 是服务端自身异常,用 ERROR 并带上拦截点。
func (s *server) writeAPIError(w http.ResponseWriter, r *http.Request, err error) {
var api *apiError
if !errors.As(err, &api) {
api = internal(err)
}
// 兜底:错误没经构造函数(拿不到 origin)时,沿栈找到返回它的那段业务代码。
if api.origin == "" {
api.origin = originFromStack(3)
}
logIntercepted(r, s.logger, api)
body := map[string]any{
"ok": false,
"error": map[string]any{
"code": api.code,
"message": api.message,
},
}
if api.detail != nil {
body["error"].(map[string]any)["detail"] = api.detail
}
writeJSON(w, api.status, body)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// decodeJSON 解析请求体;空体视为错误,避免把「没传参数」当成合法写操作。
// 不启用 DisallowUnknownFields:前端会在每个写请求体里注入 requestId,
// 服务端按需读取即可,不该因为多一个字段就拒绝整个请求。
func decodeJSON(r *http.Request, dst any) error {
dec := json.NewDecoder(r.Body)
if err := dec.Decode(dst); err != nil {
return badRequest("请求体不是合法 JSON", err.Error())
}
return nil
}
// pageQuery 解析 ?page=&size=,默认第 1 页、每页 50 条,上限 500。
func pageQuery(r *http.Request) (page, size int) {
page = intQuery(r, "page", 1)
size = intQuery(r, "size", 50)
if page < 1 {
page = 1
}
switch {
case size < 1:
size = 50
case size > 500:
size = 500
}
return page, size
}
func intQuery(r *http.Request, key string, def int) int {
raw := strings.TrimSpace(r.URL.Query().Get(key))
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil {
return def
}
return n
}
func int64Query(r *http.Request, key string) (int64, bool) {
raw := strings.TrimSpace(r.URL.Query().Get(key))
if raw == "" {
return 0, false
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0, false
}
return n, true
}
// pathID 解析路由通配符 {id}。
func pathID(r *http.Request, name string) (int64, error) {
raw := r.PathValue(name)
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n <= 0 {
return 0, badRequest("路径参数 "+name+" 非法", raw)
}
return n, nil
}
// pageResult 是分页响应体(见 devdocs/05 第 6 节)。
type pageResult[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
// mapDomainError 把 domain 引擎的哨兵错误映射为 REST 错误码。
func mapDomainError(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, domain.ErrManualAdjustmentDisabled),
errors.Is(err, domain.ErrAdjustmentReasonRequired),
errors.Is(err, domain.ErrCompareRequired),
errors.Is(err, domain.ErrUnknownBasis),
errors.Is(err, domain.ErrUnknownLedgerOp):
return stateInvalid(err.Error())
case errors.Is(err, domain.ErrProfileNotFound):
return badRequest("计分通道引用的积分模板不存在", err.Error())
case errors.Is(err, domain.ErrLedgerAlreadyVoided):
return stateInvalid("该积分流水已作废,不能重复作废")
case errors.Is(err, domain.ErrLedgerNotFound):
return notFound("积分流水不存在")
default:
return internal(err)
}
}