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:
AskaEth committed 2026-09-11 14:28:25 +08:00
1 parent 23fee5b818
commit 2c983bcfdf
14 files changed
+1112 -21

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
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)
}
}
@@ -0,0 +1,181 @@
package httpapi
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// createEntry / updateEntry 目前没有挂路由,只能在包内直接调用验证。
func newEntryTestServer(t *testing.T) (*server, *sql.DB) {
t.Helper()
db, err := sql.Open("sqlite",
"file:"+filepath.Join(t.TempDir(), "event.db")+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("打开赛事库: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("迁移赛事库: %v", err)
}
return &server{ev: db, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, db
}
type entryFixture struct {
eventA, eventB int64
catA, catB int64
team int64
}
func seedEntryFixture(t *testing.T, db *sql.DB) entryFixture {
t.Helper()
exec := func(query string, args ...any) int64 {
res, err := db.Exec(query, args...)
if err != nil {
t.Fatalf("执行 SQL 失败: %v\n%s", err, query)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("取 id 失败: %v", err)
}
return id
}
sport := exec(`INSERT INTO sports (code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`)
var f entryFixture
f.eventA = exec(`INSERT INTO events (sport_id, code, name, status) VALUES (?, 'A100', '甲组100米', 'pending')`, sport)
f.eventB = exec(`INSERT INTO events (sport_id, code, name, status) VALUES (?, 'B100', '乙组100米', 'pending')`, sport)
f.catA = exec(`INSERT INTO event_categories (event_id, name, multiplier) VALUES (?, '男子A', 1)`, f.eventA)
f.catB = exec(`INSERT INTO event_categories (event_id, name, multiplier) VALUES (?, '男子B', 1)`, f.eventB)
f.team = exec(`INSERT INTO teams (code, name) VALUES ('T1', '第一队')`)
return f
}
func requireAPIStatus(t *testing.T, err error, status int) *apiError {
t.Helper()
if err == nil {
t.Fatalf("期望错误,得到 nil")
}
var api *apiError
if !errors.As(err, &api) {
t.Fatalf("期望 *apiError,得到 %T: %v", err, err)
}
if api.status != status {
t.Fatalf("期望状态 %d,得到 %d (%s)", status, api.status, api.message)
}
return api
}
func TestNormalizeEntryStatus(t *testing.T) {
for _, tc := range []struct{ raw, want string }{
{"", "registered"},
{"dns", "dns"},
{" scratched ", "scratched"},
} {
got, err := normalizeEntryStatus(tc.raw)
if err != nil || got != tc.want {
t.Fatalf("normalizeEntryStatus(%q) = %q, %v;期望 %q", tc.raw, got, err, tc.want)
}
}
got, err := normalizeEntryStatus("bogus")
if got != "" || err == nil {
t.Fatalf("非法 status 应报错,得到 %q, %v", got, err)
}
api := requireAPIStatus(t, err, http.StatusBadRequest)
if api.code != "VALIDATION_FAILED" {
t.Fatalf("非法 status 的 code 应是 VALIDATION_FAILED,得到 %q", api.code)
}
if !strings.Contains(api.message, "status") {
t.Fatalf("错误文案应带字段名,得到 %q", api.message)
}
if api.detail != "bogus" {
t.Fatalf("detail 应是原始值,得到 %v", api.detail)
}
}
func TestCreateEntryRejectsForeignCategory(t *testing.T) {
s, db := newEntryTestServer(t)
f := seedEntryFixture(t, db)
ctx := context.Background()
_, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catB, TeamID: f.team, Status: "registered"})
api := requireAPIStatus(t, err, http.StatusBadRequest)
if api.message != "categoryId 不属于该小项" {
t.Fatalf("错误文案应是「categoryId 不属于该小项」,得到 %q", api.message)
}
if api.detail != f.catB {
t.Fatalf("detail 应是 categoryId %d,得到 %v", f.catB, api.detail)
}
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM entries`).Scan(&n); err != nil {
t.Fatalf("统计报名项: %v", err)
}
if n != 0 {
t.Fatalf("组别不属于小项时不该落库,entries 有 %d 行", n)
}
created, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "dq"})
if err != nil {
t.Fatalf("同小项组别应创建成功: %v", err)
}
if created.CategoryID != f.catA || created.EventID != f.eventA || created.Status != "dq" {
t.Fatalf("报名项字段不对: %+v", created)
}
}
func TestCreateEntryRejectsUnknownStatus(t *testing.T) {
s, db := newEntryTestServer(t)
f := seedEntryFixture(t, db)
_, err := s.createEntry(context.Background(),
entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "bogus"})
api := requireAPIStatus(t, err, http.StatusBadRequest)
if api.code != "VALIDATION_FAILED" || !strings.Contains(api.message, "status") {
t.Fatalf("非法 status 应 400 VALIDATION_FAILED 且带字段名,得到 %+v", api)
}
}
func TestUpdateEntryCategoryCheckKeepsNotFound(t *testing.T) {
s, db := newEntryTestServer(t)
f := seedEntryFixture(t, db)
ctx := context.Background()
created, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "registered"})
if err != nil {
t.Fatalf("准备报名项失败: %v", err)
}
// 组别必须属于该报名项所在小项(updateEntry 不改 event_id)。
_, err = s.updateEntry(ctx, created.ID, entryDTO{CategoryID: f.catB, TeamID: f.team, Status: "registered"})
api := requireAPIStatus(t, err, http.StatusBadRequest)
if api.message != "categoryId 不属于该小项" || api.detail != f.catB {
t.Fatalf("非法组别应被拦下,得到 %+v", api)
}
updated, err := s.updateEntry(ctx, created.ID, entryDTO{CategoryID: f.catA, TeamID: f.team, Status: "scratched"})
if err != nil {
t.Fatalf("同小项组别应更新成功: %v", err)
}
if updated.CategoryID != f.catA || updated.Status != "scratched" {
t.Fatalf("更新结果不对: %+v", updated)
}
// 报名项不存在仍是 404,不能被组别校验降级成 400。
_, err = s.updateEntry(ctx, created.ID+9999, entryDTO{CategoryID: f.catA, TeamID: f.team, Status: "registered"})
api = requireAPIStatus(t, err, http.StatusNotFound)
if api.message != "报名项不存在" {
t.Fatalf("不存在的报名项应 404「报名项不存在」,得到 %+v", api)
}
}
+294
View File
@@ -0,0 +1,294 @@
package httpapi_test
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
)
// detailOf 把错误响应的 detail 还原成字符串,用来断言「detail 是原始入参」。
func detailOf(t *testing.T, raw json.RawMessage) string {
t.Helper()
var s string
if err := json.Unmarshal(raw, &s); err != nil {
t.Fatalf("detail 应是原始值字符串: %v (%s)", err, raw)
}
return s
}
// 枚举入参非法时必须是 400 VALIDATION_FAILED,且 message 带字段名、detail 带原始值;
// 而不是一路落到 SQLite 的 CHECK 上变成 500 INTERNAL。
func TestEventEnumValidation(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
for i, tc := range []struct{ field, value string }{
{"eventType", "bogus"},
{"resultMode", "bogus"},
{"resultType", "bogus"},
{"compare", "bogus"},
{"status", "bogus"},
} {
status, resp := env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": fmt.Sprintf("E-BAD-%d", i), "name": "非法枚举",
tc.field: tc.value,
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("%s=%q 应 400 VALIDATION_FAILED,得到 %d %+v", tc.field, tc.value, status, resp.Error)
}
if !strings.Contains(resp.Error.Message, tc.field) {
t.Fatalf("%s 的错误文案应带字段名,得到 %q", tc.field, resp.Error.Message)
}
if got := detailOf(t, resp.Error.Detail); got != tc.value {
t.Fatalf("%s 的 detail 应是原始值 %q,得到 %q", tc.field, tc.value, got)
}
}
// 合法值原样落库(含 0016 追加的 individual_team)。
status, resp := env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": "E-OK", "name": "合法枚举",
"eventType": "individual_team", "resultMode": "manual", "resultType": "score",
"compare": "desc", "status": "published",
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("合法枚举应 200,得到 %d %+v", status, resp.Error)
}
created := decodeData[map[string]any](t, resp)
for field, want := range map[string]string{
"eventType": "individual_team", "resultMode": "manual",
"resultType": "score", "compare": "desc", "status": "published",
} {
if created[field] != want {
t.Fatalf("%s 应落库为 %q,得到 %v", field, want, created[field])
}
}
// 空值走建表默认值,不能报错。
status, resp = env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": "E-EMPTY", "name": "空枚举",
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("空枚举应走默认值并 200,得到 %d %+v", status, resp.Error)
}
got := decodeData[map[string]any](t, resp)
for field, want := range map[string]string{
"eventType": "individual", "resultMode": "auto",
"resultType": "none", "compare": "none", "status": "pending",
} {
if got[field] != want {
t.Fatalf("空 %s 应默认 %q,得到 %v", field, want, got[field])
}
}
}
func TestUpdateEventEnumValidation(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
// 非法值在事务前就被拦下,不该碰库。
status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), map[string]any{
"eventType": "bogus",
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("更新非法 eventType 应 400,得到 %d %+v", status, resp.Error)
}
if got := detailOf(t, resp.Error.Detail); got != "bogus" {
t.Fatalf("detail 应是原始值,得到 %q", got)
}
// 空值走默认值。
status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), map[string]any{
"sportId": ids.sport, "code": "M100", "name": "100米跑",
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("更新空枚举应走默认值并 200,得到 %d %+v", status, resp.Error)
}
got := decodeData[map[string]any](t, resp)
if got["eventType"] != "individual" || got["status"] != "pending" || got["resultMode"] != "auto" {
t.Fatalf("更新空枚举应回落默认值,得到 %+v", got)
}
}
func TestProfileEnumValidation(t *testing.T) {
env := newTestEnv(t)
client := recorderClient(t, env)
for i, tc := range []struct{ field, value string }{
{"tieMode", "bogus"},
{"recordBonusMode", "bogus"},
} {
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": fmt.Sprintf("P-BAD-%d", i), "name": "非法模板", tc.field: tc.value,
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("%s=%q 应 400 VALIDATION_FAILED,得到 %d %+v", tc.field, tc.value, status, resp.Error)
}
if !strings.Contains(resp.Error.Message, tc.field) {
t.Fatalf("%s 的错误文案应带字段名,得到 %q", tc.field, resp.Error.Message)
}
if got := detailOf(t, resp.Error.Detail); got != tc.value {
t.Fatalf("%s 的 detail 应是原始值 %q,得到 %q", tc.field, tc.value, got)
}
}
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "P-OK", "name": "合法模板", "tieMode": "average", "recordBonusMode": "sum",
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("合法枚举应 200,得到 %d %+v", status, resp.Error)
}
created := decodeData[map[string]any](t, resp)
if created["tieMode"] != "average" || created["recordBonusMode"] != "sum" {
t.Fatalf("合法枚举应原样落库,得到 %+v", created)
}
profileID := int64(created["id"].(float64))
status, resp = env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "P-EMPTY", "name": "空枚举",
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("空枚举应走默认值并 200,得到 %d %+v", status, resp.Error)
}
got := decodeData[map[string]any](t, resp)
if got["tieMode"] != "skip" || got["recordBonusMode"] != "max" {
t.Fatalf("空枚举应默认 skip / max,得到 %+v", got)
}
status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/scoring-profiles/%d", profileID), map[string]any{
"code": "P-OK", "name": "合法模板", "tieMode": "nope",
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("更新非法 tieMode 应 400,得到 %d %+v", status, resp.Error)
}
}
func TestResultStatusEnumValidation(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{{"entryId": ids.entryA, "status": "bogus"}},
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("非法 results[].status 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "status") {
t.Fatalf("错误文案应带字段名,得到 %q", resp.Error.Message)
}
if got := detailOf(t, resp.Error.Detail); got != "bogus" {
t.Fatalf("detail 应是原始值,得到 %q", got)
}
status, resp = env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{{"entryId": ids.entryA, "status": "dq"}},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("合法 status 应 200,得到 %d %+v", status, resp.Error)
}
items := decodeData[[]map[string]any](t, resp)
if len(items) != 1 || items[0]["status"] != "dq" {
t.Fatalf("合法 status 应落库为 dq,得到 %+v", items)
}
// 空值走建表默认值 ok。
status, resp = env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{{"entryId": ids.entryA}},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("空 status 应走默认值并 200,得到 %d %+v", status, resp.Error)
}
items = decodeData[[]map[string]any](t, resp)
if len(items) != 1 || items[0]["status"] != "ok" {
t.Fatalf("空 status 应默认 ok,得到 %+v", items)
}
}
// 原本就有校验的枚举入参(通道 scope/basis、纪录 level、用户 role)一并钉住,
// 确认它们在这次改动里没有被绕过、也仍然返回 400 而不是撞 CHECK 的 500。
func TestExistingEnumValidationGuards(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
var profileID int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles LIMIT 1`).Scan(&profileID); err != nil {
t.Fatalf("读取积分模板: %v", err)
}
for _, ch := range []map[string]any{
{"scope": "bogus", "basis": "result", "profileId": profileID},
{"scope": "athlete", "basis": "bogus", "profileId": profileID},
} {
status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ids.event),
map[string]any{"channels": []map[string]any{ch}}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("非法通道 %+v 应 400 VALIDATION_FAILED,得到 %d %+v", ch, status, resp.Error)
}
field := "scope"
if ch["basis"] == "bogus" {
field = "basis"
}
if !strings.Contains(resp.Error.Message, field) {
t.Fatalf("错误文案应带 %s,得到 %q", field, resp.Error.Message)
}
}
status, resp := env.do(client, http.MethodPost, "/api/record-breaks", map[string]any{
"eventId": ids.event, "entryId": ids.entryA, "level": "bogus",
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("非法纪录 level 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "level") {
t.Fatalf("错误文案应带 level,得到 %q", resp.Error.Message)
}
}
func TestUserRoleEnumValidation(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass-1", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass-1")
status, resp := env.do(client, http.MethodPost, "/api/users", map[string]any{
"username": "someone", "password": "password-1", "role": "bogus",
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("非法 role 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "role") {
t.Fatalf("错误文案应带 role,得到 %q", resp.Error.Message)
}
}
// 拦截点必须指向 enums.go 里的校验函数,而不是 response.go 的构造函数。
func TestEnumInterceptLogPointsAtEnumsValidator(t *testing.T) {
env, buf := logEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
status, _ := env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": "E-LOG", "name": "非法枚举", "eventType": "bogus",
}, true)
if status != http.StatusBadRequest {
t.Fatalf("非法 eventType 应 400,得到 %d", status)
}
logs := waitLog(t, buf, "请求被拦截")
if !strings.Contains(logs, `"origin":"internal/httpapi/enums.go:`) ||
!strings.Contains(logs, "normalizeEventType") {
t.Fatalf("WARN 行应指出拦截点是 normalizeEventType,得到:\n%s", logs)
}
for _, forbidden := range []string{"response.go", "badRequest", "newAPIError", "/home/"} {
if strings.Contains(logs, forbidden) {
t.Fatalf("拦截点不该出现 %q,得到:\n%s", forbidden, logs)
}
}
}
@@ -0,0 +1,132 @@
package httpapi_test
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// 5xx 的原始错误串(SQLite / 计分引擎的报错)只能进日志,不能进响应体:
// 里面有表名、列名甚至数据片段,前端不需要、也不该拿到。
// 排查能力不能因此丢——ERROR 行必须带上那句原文。
func TestServerErrorKeepsRawReasonOutOfResponse(t *testing.T) {
buf := &bytes.Buffer{}
logger := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
env := newTestEnvWith(t, func(o *httpapi.Options) {
o.Logger = logger
// 未知 action 会让投递层直接报错,是最省事的 500 触发点。
o.Display = &strictDisplaySender{}
})
client := recorderClient(t, env)
raw, err := json.Marshal(map[string]any{"action": "nope"})
if err != nil {
t.Fatalf("序列化请求体: %v", err)
}
status, body := env.doRaw(client, http.MethodPost, "/api/display/command", raw, "application/json", true)
if status != http.StatusInternalServerError {
t.Fatalf("未知大屏指令应 500,得到 %d %s", status, body)
}
if strings.Contains(string(body), "未知的大屏指令") {
t.Fatalf("响应体不该回显内部错误原文,得到 %s", body)
}
var payload struct {
OK bool `json:"ok"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Detail json.RawMessage `json:"detail"`
} `json:"error"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("解析响应体: %v (%s)", err, body)
}
if payload.Error.Code != "INTERNAL" || payload.Error.Message != "服务端异常" {
t.Fatalf("5xx 应回笼统文案,得到 %+v", payload.Error)
}
if len(payload.Error.Detail) != 0 {
t.Fatalf("5xx 的 detail 应为空,得到 %s", payload.Error.Detail)
}
// 原文必须能在日志里找到,否则现场就真的查不下去了。
logs := waitLog(t, buf, `"level":"ERROR"`)
if !strings.Contains(logs, "未知的大屏指令") {
t.Fatalf("ERROR 行应带上原始错误串,得到:\n%s", logs)
}
}
// 改密码时原密码填错返回 400,不是 401:
// 本请求的会话是好的,报 401 会被前端当成「会话失效」,填错一次就被登出。
func TestChangePasswordWrongCurrentIsBadRequest(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass-123", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass-123")
status, body := env.do(client, http.MethodPost, "/api/auth/password",
map[string]any{"currentPassword": "wrong-pass", "newPassword": "new-pass-12345"}, true)
if status != http.StatusBadRequest {
t.Fatalf("原密码不对应 400,得到 %d %+v", status, body.Error)
}
if body.Error == nil || body.Error.Code != "AUTH_FAILED" || body.Error.Message != "原密码不正确" {
t.Fatalf("错误码与文案要保持原样,得到 %+v", body.Error)
}
// 会话没被清掉:紧接着的请求仍然是登录态。
status, me := env.do(client, http.MethodGet, "/api/auth/me", nil, false)
if status != http.StatusOK {
t.Fatalf("填错一次原密码不该把人踢下线,me 得到 %d", status)
}
if got := decodeData[map[string]any](t, me); got["username"] != "admin" {
t.Fatalf("会话应保持为 admin,得到 %+v", got)
}
}
// /api/health 要能看出「HTTP 活着但数据库连不上」。
// 状态码恒为 200:Console 拿状态码当存活信号,回 5xx 会让它在数据库故障时反复重启进程。
func TestHealthReportsDatabaseProbe(t *testing.T) {
env := newTestEnv(t)
client, _ := env.client()
status, body := env.doRaw(client, http.MethodGet, "/api/health", nil, "", false)
if status != http.StatusOK {
t.Fatalf("健康检查应回 200,得到 %d %s", status, body)
}
var healthy struct {
OK bool `json:"ok"`
Version string `json:"version"`
DB string `json:"db"`
}
if err := json.Unmarshal(body, &healthy); err != nil {
t.Fatalf("解析健康检查响应: %v (%s)", err, body)
}
if !healthy.OK || healthy.DB != "ok" || healthy.Version != "test" {
t.Fatalf("正常时应为 ok/db=ok/version=test,得到 %+v", healthy)
}
// 把赛事库关掉:HTTP 还活着,探库必须失败,且状态码仍然是 200。
if err := env.ev.Close(); err != nil {
t.Fatalf("关闭赛事库: %v", err)
}
status, body = env.doRaw(client, http.MethodGet, "/api/health", nil, "", false)
if status != http.StatusOK {
t.Fatalf("数据库故障时状态码仍应是 200(避免 Console 重启循环),得到 %d %s", status, body)
}
var broken struct {
OK bool `json:"ok"`
DB string `json:"db"`
DBError string `json:"dbError"`
}
if err := json.Unmarshal(body, &broken); err != nil {
t.Fatalf("解析健康检查响应: %v (%s)", err, body)
}
if broken.DB != "error" || broken.DBError == "" {
t.Fatalf("探库失败应报 db=error 并说明原因,得到 %+v", broken)
}
}
+4 -2
View File
@@ -103,9 +103,11 @@ func (s *server) changePassword(w http.ResponseWriter, r *http.Request) (any, er
if in.NewPassword == in.CurrentPassword {
return nil, badRequest("新密码不能与原密码相同", nil)
}
// 会话被盗后不能直接改密,必须再验一次原密码
// 会话被盗后不能直接改密,必须再验一次原密码。
// 这里不能用 401:本请求的会话是好的,只是填错了原密码,
// 401 会被前端当成「会话失效」处理(历史上真出现过改完密码登不回去)。
if err := s.auth.VerifyPassword(r.Context(), u.ID, in.CurrentPassword); err != nil {
return nil, unauthorized("AUTH_FAILED", "原密码不正确")
return nil, wrongCredential("原密码不正确")
}
if err := s.auth.SetPassword(r.Context(), u.Username, in.NewPassword, false); err != nil {
return nil, internal(err)
+3 -1
View File
@@ -65,8 +65,10 @@ func logIntercepted(r *http.Request, logger *slog.Logger, api *apiError) {
reqID := middleware.GetRequestID(r.Context())
if api.status >= http.StatusInternalServerError {
// 5xx 的原始错误串(SQLite / 引擎)只落在这里,不进响应体:
// 现场看日志能定位,前端拿不到内部表名列名。
logger.Error("请求失败",
"code", api.code, "err", api.message, "origin", api.origin,
"code", api.code, "err", api.message, "detail", api.logDetail, "origin", api.origin,
"method", r.Method, "path", r.URL.Path, "actor", actor, "requestId", reqID)
return
}
@@ -0,0 +1,87 @@
package httpapi_test
import (
"fmt"
"io"
"log/slog"
"net/http"
"testing"
"golang.org/x/crypto/bcrypt"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// 写限流要真的拦得住(默认 30 次/分钟/用户):
// 之前没有任何 429 用例,限流键名写错、中间件漏挂都测不出来。
// 这里把阈值压到 2,避免为了触发限流跑 31 次写请求。
func TestWriteRateLimitReturns429(t *testing.T) {
env := newTestEnvWith(t, func(o *httpapi.Options) {
o.Auth = auth.New(o.System, auth.Options{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
BcryptCost: bcrypt.MinCost,
WritePerMinute: 2,
})
})
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
for i := 1; i <= 2; i++ {
status, body := env.do(client, http.MethodPost, "/api/teams",
map[string]any{"code": fmt.Sprintf("T%d", i), "name": "测试单位"}, true)
if status != http.StatusOK {
t.Fatalf("第 %d 次写在阈值内应放行,得到 %d %+v", i, status, body.Error)
}
}
status, body := env.do(client, http.MethodPost, "/api/teams",
map[string]any{"code": "T9", "name": "超限单位"}, true)
if status != http.StatusTooManyRequests {
t.Fatalf("超出阈值应 429,得到 %d %+v", status, body.Error)
}
if body.Error == nil || body.Error.Code != "RATE_LIMITED" {
t.Fatalf("429 的错误码应为 RATE_LIMITED,得到 %+v", body.Error)
}
// 读操作不占写配额:限流只管写,不然大屏轮询会把录入员挤掉。
if status, _ := env.do(client, http.MethodGet, "/api/teams", nil, false); status != http.StatusOK {
t.Fatalf("读操作不该被写限流拦住,得到 %d", status)
}
}
// CSRF 的反向用例:已登录用户写了但没带(或带错)X-CSRF-Token,必须 403。
// 只有正例的话,「忘了校验」和「校验通过」看起来一模一样。
func TestCSRFRejectsWriteWithoutToken(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, body := env.do(client, http.MethodPost, "/api/teams",
map[string]any{"code": "C1", "name": "缺 token 的单位"}, false)
if status != http.StatusForbidden {
t.Fatalf("缺 CSRF token 的写请求应 403,得到 %d %+v", status, body.Error)
}
if body.Error == nil || body.Error.Code != "FORBIDDEN" {
t.Fatalf("403 的错误码应为 FORBIDDEN,得到 %+v", body.Error)
}
// 带一个与 Cookie 不一致的 token 同样要拦,且数据不能落库。
status, _ = env.doH(client, http.MethodPost, "/api/teams",
map[string]any{"code": "C2", "name": "错 token 的单位"}, false,
map[string]string{"X-CSRF-Token": "not-the-cookie-value"})
if status != http.StatusForbidden {
t.Fatalf("CSRF token 不匹配应 403,得到 %d", status)
}
status, list := env.do(client, http.MethodGet, "/api/teams", nil, false)
if status != http.StatusOK {
t.Fatalf("读取单位列表失败,得到 %d", status)
}
for _, team := range decodeData[[]map[string]any](t, list) {
if code, _ := team["code"].(string); code == "C1" || code == "C2" {
t.Fatalf("被 CSRF 拦下的请求不该写进库:%+v", team)
}
}
}
+18 -2
View File
@@ -819,6 +819,14 @@ func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO,
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
return profileDTO{}, badRequest("code 与 name 必填", nil)
}
tieMode, err := normalizeTieMode(in.TieMode)
if err != nil {
return profileDTO{}, err
}
recordBonusMode, err := normalizeRecordBonusMode(in.RecordBonusMode)
if err != nil {
return profileDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return profileDTO{}, internal(err)
@@ -828,7 +836,7 @@ func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO,
res, err := tx.ExecContext(ctx,
`INSERT INTO scoring_profiles (code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero)
VALUES (?, ?, ?, ?, ?, ?)`,
in.Code, in.Name, orDefault(in.TieMode, "skip"), orDefault(in.RecordBonusMode, "max"), in.AwardRanks, in.LastPlaceZero)
in.Code, in.Name, tieMode, recordBonusMode, in.AwardRanks, in.LastPlaceZero)
if isUniqueViolation(err) {
return profileDTO{}, conflict("积分模板编码已存在")
}
@@ -849,6 +857,14 @@ func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO,
}
func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (profileDTO, error) {
tieMode, err := normalizeTieMode(in.TieMode)
if err != nil {
return profileDTO{}, err
}
recordBonusMode, err := normalizeRecordBonusMode(in.RecordBonusMode)
if err != nil {
return profileDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return profileDTO{}, internal(err)
@@ -859,7 +875,7 @@ func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (pr
`UPDATE scoring_profiles SET code = ?, name = ?, tie_mode = ?, record_bonus_mode = ?,
award_ranks = ?, last_place_zero = ?, updated_at = datetime('now')
WHERE id = ?`,
in.Code, in.Name, orDefault(in.TieMode, "skip"), orDefault(in.RecordBonusMode, "max"), in.AwardRanks, in.LastPlaceZero, id)
in.Code, in.Name, tieMode, recordBonusMode, in.AwardRanks, in.LastPlaceZero, id)
if isUniqueViolation(err) {
return profileDTO{}, conflict("积分模板编码已存在")
}
+99 -8
View File
@@ -94,6 +94,26 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
if err != nil {
return eventDTO{}, err
}
eventType, err := normalizeEventType(in.EventType)
if err != nil {
return eventDTO{}, err
}
resultMode, err := normalizeResultMode(in.ResultMode)
if err != nil {
return eventDTO{}, err
}
resultType, err := normalizeResultType(in.ResultType)
if err != nil {
return eventDTO{}, err
}
compare, err := normalizeCompare(in.Compare)
if err != nil {
return eventDTO{}, err
}
status, err := normalizeEventStatus(in.Status)
if err != nil {
return eventDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return eventDTO{}, internal(err)
@@ -105,9 +125,8 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.SportID, in.Code, in.Name,
orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"),
orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"),
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"),
eventType, resultMode, resultType, compare,
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), status,
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode)
if isUniqueViolation(err) {
return eventDTO{}, conflict("小项编码已存在")
@@ -137,6 +156,26 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
if err != nil {
return eventDTO{}, err
}
eventType, err := normalizeEventType(in.EventType)
if err != nil {
return eventDTO{}, err
}
resultMode, err := normalizeResultMode(in.ResultMode)
if err != nil {
return eventDTO{}, err
}
resultType, err := normalizeResultType(in.ResultType)
if err != nil {
return eventDTO{}, err
}
compare, err := normalizeCompare(in.Compare)
if err != nil {
return eventDTO{}, err
}
status, err := normalizeEventStatus(in.Status)
if err != nil {
return eventDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return eventDTO{}, internal(err)
@@ -149,9 +188,8 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?, avg_mode = ?
WHERE id = ?`,
in.SportID, in.Code, in.Name,
orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"),
orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"),
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"),
eventType, resultMode, resultType, compare,
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), status,
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode, id)
if isUniqueViolation(err) {
return eventDTO{}, conflict("小项编码已存在")
@@ -485,11 +523,18 @@ func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error)
if in.EventID == 0 || in.CategoryID == 0 || in.TeamID == 0 {
return entryDTO{}, badRequest("eventId、categoryId、teamId 必填", nil)
}
status, err := normalizeEntryStatus(in.Status)
if err != nil {
return entryDTO{}, err
}
if err := s.checkCategoryInEvent(ctx, in.EventID, in.CategoryID); err != nil {
return entryDTO{}, err
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status, remark)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered"), nullStr(in.Remark))
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status, nullStr(in.Remark))
if isUniqueViolation(err) {
return entryDTO{}, conflict("该报名项已存在")
}
@@ -507,11 +552,24 @@ func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error)
}
func (s *server) updateEntry(ctx context.Context, id int64, in entryDTO) (entryDTO, error) {
// 先确认报名项存在:小项不可改,categoryId 要拿它的 event_id 来校验。
// 这条查询同时守住了 404 语义,别让后面的校验把它降级成 400。
eventID, err := s.entryEventID(ctx, id)
if err != nil {
return entryDTO{}, err
}
status, err := normalizeEntryStatus(in.Status)
if err != nil {
return entryDTO{}, err
}
if err := s.checkCategoryInEvent(ctx, eventID, in.CategoryID); err != nil {
return entryDTO{}, err
}
res, err := s.ev.ExecContext(ctx,
`UPDATE entries SET category_id = ?, team_id = ?, athlete_id = ?, member_names = ?,
bib = ?, seed = ?, heat = ?, status = ?, remark = ? WHERE id = ?`,
in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered"), nullStr(in.Remark), id)
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status, nullStr(in.Remark), id)
if isUniqueViolation(err) {
return entryDTO{}, conflict("该报名项已存在")
}
@@ -527,6 +585,39 @@ func (s *server) updateEntry(ctx context.Context, id int64, in entryDTO) (entryD
return s.getEntry(ctx, id)
}
// entryEventID 读报名项所属小项:小项不可改,更新组别时要以它为准做归属校验。
// 找不到时给 404,调用方(更新)原本也靠 requireChanges 给出同样的语义。
func (s *server) entryEventID(ctx context.Context, id int64) (int64, error) {
var eventID int64
err := s.ev.QueryRowContext(ctx, `SELECT event_id FROM entries WHERE id = ?`, id).Scan(&eventID)
if errors.Is(err, sql.ErrNoRows) {
return 0, notFound("报名项不存在")
}
if err != nil {
return 0, internal(err)
}
return eventID, nil
}
// checkCategoryInEvent 拦住「A 小项的报名挂在 B 小项的组别上」。
//
// event_categories 只有 (event_id, name) 唯一约束,entries 也没有 (event_id, category_id)
// 复合外键,所以外键只能保证组别存在、保证不了同属一个小项。组别不存在时放行,
// 交给 INSERT/UPDATE 的外键报错,保持原有「...不存在」的 400 文案不变。
func (s *server) checkCategoryInEvent(ctx context.Context, eventID, categoryID int64) error {
var catEvent int64
err := s.ev.QueryRowContext(ctx, `SELECT event_id FROM event_categories WHERE id = ?`, categoryID).Scan(&catEvent)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil
case err != nil:
return internal(err)
case catEvent != eventID:
return badRequest("categoryId 不属于该小项", categoryID)
}
return nil
}
func (s *server) deleteEntry(ctx context.Context, id int64) error {
// 报名项被成绩引用时不允许删除;业务数据一律软删(status=scratched)。
res, err := s.ev.ExecContext(ctx, `UPDATE entries SET status = 'scratched' WHERE id = ?`, id)
+6 -2
View File
@@ -201,10 +201,14 @@ func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, i
if entryEvent != eventID {
return nil, badRequest("entryId 不属于该小项", it.EntryID)
}
status, err := normalizeResultStatus(it.Status)
if err != nil {
return nil, err
}
// 已发布成绩不能被「存草稿」就地改写:ON CONFLICT 不改 state,
// 若放行会把 rank 清空但流水不动,大屏奖牌与积分立刻对不上。
var state string
err := tx.QueryRowContext(ctx, `SELECT state FROM results WHERE entry_id = ?`, it.EntryID).Scan(&state)
err = tx.QueryRowContext(ctx, `SELECT state FROM results WHERE entry_id = ?`, it.EntryID).Scan(&state)
switch {
case errors.Is(err, sql.ErrNoRows):
// 尚无成绩,正常新建草稿。
@@ -230,7 +234,7 @@ func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, i
recorded_at = excluded.recorded_at,
updated_at = datetime('now')`,
eventID, it.EntryID, nullInt(it.Rank), nullStr(it.ResultText), nullF64(it.ResultValue),
nullStr(it.Unit), it.IsTie, nullF64(it.PointsOverride), orDefault(it.Status, "ok"),
nullStr(it.Unit), it.IsTie, nullF64(it.PointsOverride), status,
nullID(userID), nullStr(it.Note)); err != nil {
return nil, internal(err)
}
+34 -5
View File
@@ -16,6 +16,10 @@ type apiError struct {
code string
message string
detail any
// logDetail 是「只写日志、不进响应体」的错误原文,只有 5xx 用:
// SQLite / 计分引擎的原始错误串里可能带表名列名甚至数据片段,
// 现场排查需要它,前端不需要、也不该拿到。
logDetail string
// origin 是「拦截点」:构造这个错误的 file:line 与函数名,例如
// internal/httpapi/repo_events.go:231 normalizeAvgMode。拦截日志靠它定位到校验代码。
origin string
@@ -24,15 +28,25 @@ type apiError struct {
func (e *apiError) Error() string { return e.code + ": " + e.message }
// newAPIError 是所有业务错误的唯一构造入口:在这里捕获拦截点。
// skip=3 是 callerOrigin → newAPIError → 具体构造函数 → 真正做校验的调用点,
// 少一层就会把 helper 自己记成拦截点,那样日志等于没说。
// 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(3),
origin: callerOrigin(skip),
}
}
@@ -77,13 +91,28 @@ 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 newAPIError(http.StatusInternalServerError, "INTERNAL", "服务端异常", err.Error())
return internalMsg("服务端异常", err)
}
// internalMsg 是 internal 的可自定义文案版本:5xx 的 message 也要让现场看懂发生了什么。
// skip=4:业务代码 → internal/internalMsg → newAPIErrorSkip → callerOrigin,比 newAPIError 多一层。
func internalMsg(message string, err error) *apiError {
return newAPIError(http.StatusInternalServerError, "INTERNAL", message, err.Error())
api := newAPIErrorSkip(4, http.StatusInternalServerError, "INTERNAL", message, nil)
if err != nil {
api.logDetail = err.Error()
}
return api
}
func notImplemented(message string) *apiError {
+41 -1
View File
@@ -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