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

94 lines
3.9 KiB
Go

package httpapi
import (
"fmt"
"log/slog"
"net/http"
"runtime"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// 本文件把「拦截点」(哪一行、哪个函数拦下的)记进拦截日志。
// 主动拦截的报错必须自带拦截点:默认级别是 info,现场只看得见 WARN 那一行,
// 只给一句中文提示的话,排查时仍然不知道是哪段校验拦的。
// 捕获实现放在 internal/logging,与 middleware 共用一份,避免两边各写一套。
// callerOrigin 捕获调用点;skip 从 callerOrigin 自己算起(见 newAPIError 的说明),
// 这里 +1 是把自己这一层算进 logging.Origin 的帧数。
func callerOrigin(skip int) string {
return logging.Origin(skip + 1)
}
// originFromStack 兜底:错误不是经构造函数造的(例如把底层错误原样返回出来)时,
// 沿栈找到第一个不在 response.go / 本文件里的帧,就是返回这个错误的那段业务代码。
func originFromStack(skip int) string {
pcs := make([]uintptr, 16)
n := runtime.Callers(skip, pcs)
frames := runtime.CallersFrames(pcs[:n])
for {
frame, more := frames.Next()
if frame.File != "" && !strings.HasSuffix(frame.File, "response.go") &&
!strings.HasSuffix(frame.File, "origin.go") && !strings.Contains(frame.Function, "/internal/logging.") {
return logging.FormatOrigin(frame.File, frame.Line, frame.Function)
}
if !more {
return "unknown"
}
}
}
// interceptRow 生成给前端的行级错误文案,并顺手留一条 DEBUG 拦截日志(拦进 interceptions/)。
// 导入的行级错误原本只进响应体,服务端不留痕的话,现场「我明明填对了它却说格式不对」没法复盘。
func (s *server) interceptRow(row int, format string, args ...any) string {
reason := fmt.Sprintf(format, args...)
s.logger.Debug("导入行被拦截",
"row", row,
"reason", reason,
"origin", callerOrigin(2),
logging.ChannelKey, logging.ChannelInterception,
)
return fmt.Sprintf("第 %d 行:%s", row, reason)
}
// logIntercepted 记录一次被拦截的请求。
// WARN 是默认级别(info)下现场唯一看得见的一行,所以「为什么被拦」与「拦截点」都放在它身上;
// DEBUG 只补上下文(code/detail/ip/角色),不能只靠它。带类别标记的行会进 interceptions/。
// 这里绝不整段记请求体:里面有证件号这类个人信息,`detail` 与响应同源,属既有泄露面。
func logIntercepted(r *http.Request, logger *slog.Logger, api *apiError) {
u := middleware.User(r.Context())
actor := actorLabel(u)
role := u.Role.String()
reqID := middleware.GetRequestID(r.Context())
if api.status >= http.StatusInternalServerError {
// 5xx 的原始错误串(SQLite / 引擎)只落在这里,不进响应体:
// 现场看日志能定位,前端拿不到内部表名列名。
logger.Error("请求失败",
"code", api.code, "err", api.message, "detail", api.logDetail, "origin", api.origin,
"method", r.Method, "path", r.URL.Path, "actor", actor, "requestId", reqID)
return
}
logger.Warn("请求被拦截",
"reason", api.message, "origin", api.origin, "code", api.code,
"method", r.Method, "path", r.URL.Path,
"actor", actor, "role", role, "requestId", reqID,
logging.ChannelKey, logging.ChannelInterception)
logger.Debug("拦截上下文",
"code", api.code, "detail", api.detail, "origin", api.origin,
"method", r.Method, "path", r.URL.Path, "ip", middleware.ClientIP(r),
"actor", actor, "role", role, "requestId", reqID,
logging.ChannelKey, logging.ChannelInterception)
}
// actorLabel 是日志里的操作人:未登录记 guest,登录后记「用户名#id」,比裸 id 好认。
func actorLabel(u *auth.User) string {
if u == nil || u.ID == 0 {
return "guest"
}
return fmt.Sprintf("%s#%d", u.Username, u.ID)
}