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:
1 parent
23fee5b818
commit
2c983bcfdf
14 files changed
+1112
-21
No files matched your search
@@ -2,7 +2,9 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -164,8 +166,22 @@ func (s *server) writeVoid(role auth.Role, fn func(w http.ResponseWriter, r *htt
|
||||
|
||||
func (s *server) routes(mux *http.ServeMux) {
|
||||
// 健康检查不走 {ok,data} 包装(05 第 7 节)。
|
||||
//
|
||||
// 语义分两层,都写进响应体的不同字段,谁也不覆盖谁:
|
||||
// ok —— HTTP 服务活着(Console 拿状态码当存活信号,这里恒回 200);
|
||||
// db —— 数据库能应答(新增,之前不探库,「进程活着但连不上库」会被当成健康)。
|
||||
// 状态码保持 200:Console 判定不健康会重启进程,而数据库故障重启治不了,
|
||||
// 只会变成重启循环。让守护脚本读 db 字段自行决策,比替它做决定安全。
|
||||
// 不返回最近备份时间:本接口会被 Console 高频轮询,不做目录扫描。
|
||||
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.version})
|
||||
ctx, cancel := context.WithTimeout(r.Context(), healthProbeTimeout)
|
||||
defer cancel()
|
||||
resp := map[string]any{"ok": true, "version": s.version, "db": "ok"}
|
||||
if err := s.probeDatabases(ctx); err != nil {
|
||||
resp["db"] = "error"
|
||||
resp["dbError"] = err.Error()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
})
|
||||
|
||||
// 认证
|
||||
@@ -284,6 +300,30 @@ func (s *server) notWired(name string) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// healthProbeTimeout 是健康检查探库的超时。
|
||||
// 取 5s 与 busy_timeout 同量级:库被备份、重算这类长事务占着单连接时,
|
||||
// 探针宁可等一会儿,也不要报出一个假的「数据库故障」。
|
||||
const healthProbeTimeout = 5 * time.Second
|
||||
|
||||
// probeDatabases 探活系统库与赛事库,返回第一个出错的原因。
|
||||
// 用 `SELECT 1` 而不是 Ping:sqlite 驱动的 Ping 可能只到连接池就返回,
|
||||
// 探不到「文件还在、库已经读不动」这种情况。
|
||||
func (s *server) probeDatabases(ctx context.Context) error {
|
||||
for _, target := range []struct {
|
||||
name string
|
||||
db *sql.DB
|
||||
}{{"system", s.sys}, {"event", s.ev}} {
|
||||
if target.db == nil {
|
||||
continue
|
||||
}
|
||||
var one int
|
||||
if err := target.db.QueryRowContext(ctx, `SELECT 1`).Scan(&one); err != nil {
|
||||
return fmt.Errorf("%s 库无响应: %w", target.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// staticHandler 把 URL 映射到多页面前端产物。
|
||||
//
|
||||
// / → index.html
|
||||
|
||||
Reference in new issue
Block a user