From 2c983bcfdf09f67db4f623b884c3ed82d860679d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 11 Sep 2026 14:28:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=9E=9A=E4=B8=BE=E5=85=A5=E5=8F=82?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C(500=E2=86=92400)=E3=80=815xx=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E4=B8=B2=E4=B8=8D=E5=86=8D=E5=A4=96=E6=B3=84=E3=80=81?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E6=8E=A2=E5=BA=93=E3=80=81?= =?UTF-8?q?=E9=99=90=E6=B5=81=E9=94=AE=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端一批「会误导现场」的缺陷,全部补了测试: - 枚举型入参(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 不外泄原文、限流键清理不误删。 --- server/internal/auth/ratelimit.go | 26 ++ server/internal/auth/ratelimit_sweep_test.go | 60 ++++ server/internal/httpapi/enums.go | 127 ++++++++ .../internal/httpapi/enums_internal_test.go | 181 +++++++++++ server/internal/httpapi/enums_test.go | 294 ++++++++++++++++++ server/internal/httpapi/error_surface_test.go | 132 ++++++++ server/internal/httpapi/handlers_config.go | 6 +- server/internal/httpapi/origin.go | 4 +- server/internal/httpapi/ratelimit_api_test.go | 87 ++++++ server/internal/httpapi/repo.go | 20 +- server/internal/httpapi/repo_events.go | 107 ++++++- server/internal/httpapi/repo_results.go | 8 +- server/internal/httpapi/response.go | 39 ++- server/internal/httpapi/server.go | 42 ++- 14 files changed, 1112 insertions(+), 21 deletions(-) create mode 100644 server/internal/auth/ratelimit_sweep_test.go create mode 100644 server/internal/httpapi/enums.go create mode 100644 server/internal/httpapi/enums_internal_test.go create mode 100644 server/internal/httpapi/enums_test.go create mode 100644 server/internal/httpapi/error_surface_test.go create mode 100644 server/internal/httpapi/ratelimit_api_test.go diff --git a/server/internal/auth/ratelimit.go b/server/internal/auth/ratelimit.go index 2331714..c159850 100644 --- a/server/internal/auth/ratelimit.go +++ b/server/internal/auth/ratelimit.go @@ -23,6 +23,13 @@ func NewLimiter(limit int, window time.Duration, now func() time.Time) *Limiter return &Limiter{hits: make(map[string][]time.Time), limit: limit, window: window, now: now} } +// sweepThreshold 是触发全表清理的键数量。 +// +// hits 只在「这个键被再次访问」时才修剪自己的时间片,所以一个只来一次的 +// 随机 IP(或伪造 X-Forwarded-For 的扫描器)留下的空键会一直躺在 map 里: +// 单机内网虽然打不满,但这个 map 没有上界本身就是隐患,超过阈值就顺手清一遍。 +const sweepThreshold = 1024 + // Allow 记录一次访问并报告是否放行。 func (l *Limiter) Allow(key string) bool { if l == nil || l.limit <= 0 { @@ -45,9 +52,28 @@ func (l *Limiter) Allow(key string) bool { return false } l.hits[key] = append(kept, now) + if len(l.hits) > sweepThreshold { + l.sweepLocked(cutoff) + } return true } +// sweepLocked 删掉窗口内已经没有任何记录的键。调用方必须已持有 l.mu。 +func (l *Limiter) sweepLocked(cutoff time.Time) { + for key, times := range l.hits { + live := false + for _, t := range times { + if t.After(cutoff) { + live = true + break + } + } + if !live { + delete(l.hits, key) + } + } +} + // Reset 清空某个键的计数,例如登录成功后不再累计失败次数。 func (l *Limiter) Reset(key string) { if l == nil { diff --git a/server/internal/auth/ratelimit_sweep_test.go b/server/internal/auth/ratelimit_sweep_test.go new file mode 100644 index 0000000..f35ff00 --- /dev/null +++ b/server/internal/auth/ratelimit_sweep_test.go @@ -0,0 +1,60 @@ +package auth + +import ( + "fmt" + "testing" + "time" +) + +// newSweepTestLimiter 造一个带可控时钟的限流器:窗口 1 分钟、限 1 次。 +// 只来一次的键(随机 IP、伪造 X-Forwarded-For 的扫描器)不会自己过期, +// 必须靠 Allow 里的阈值清理把它们扫掉。 +func newSweepTestLimiter(clock *time.Time) *Limiter { + return NewLimiter(1, time.Minute, func() time.Time { return *clock }) +} + +// 键数超过阈值后,窗口内已经没有记录的键要被清掉,内存不能无界增长。 +func TestLimiterSweepsExpiredKeys(t *testing.T) { + clock := time.Date(2026, 9, 11, 10, 0, 0, 0, time.UTC) + l := newSweepTestLimiter(&clock) + + for i := 0; i < sweepThreshold+1; i++ { + l.Allow(fmt.Sprintf("10.0.0.%d", i)) + } + if got := len(l.hits); got <= sweepThreshold { + t.Fatalf("阈值内的键不该被清,期望超过 %d,得到 %d", sweepThreshold, got) + } + + // 时间推过一个窗口,所有旧键都过期;这一次 Allow 会把键数推过阈值,触发清理。 + clock = clock.Add(2 * time.Minute) + if !l.Allow("fresh") { + t.Fatalf("新键应被放行") + } + + if got := len(l.hits); got != 1 { + t.Fatalf("过期键应被清光,只剩 fresh,得到 %d 个", got) + } + if _, ok := l.hits["fresh"]; !ok { + t.Fatalf("清理不该误删刚写入的键") + } +} + +// 清理只针对「窗口内没有任何记录」的键:窗口内仍然活跃的键一个都不能丢, +// 否则攻击者只要堆够键数就能把别人的限流计数冲掉。 +func TestLimiterSweepKeepsLiveKeys(t *testing.T) { + clock := time.Date(2026, 9, 11, 10, 0, 0, 0, time.UTC) + l := newSweepTestLimiter(&clock) + + total := sweepThreshold + 1 + for i := 0; i < total; i++ { + l.Allow(fmt.Sprintf("10.0.1.%d", i)) + } + if got := len(l.hits); got != total { + t.Fatalf("窗口内的键应全部保留,期望 %d,得到 %d", total, got) + } + + // 窗口内的键再访问一次仍然受限,说明清理没有顺手把计数抹掉。 + if l.Allow("10.0.1.0") { + t.Fatalf("同一键在同一窗口内第二次应被拒") + } +} diff --git a/server/internal/httpapi/enums.go b/server/internal/httpapi/enums.go new file mode 100644 index 0000000..43be06e --- /dev/null +++ b/server/internal/httpapi/enums.go @@ -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) + } +} diff --git a/server/internal/httpapi/enums_internal_test.go b/server/internal/httpapi/enums_internal_test.go new file mode 100644 index 0000000..ef76804 --- /dev/null +++ b/server/internal/httpapi/enums_internal_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/enums_test.go b/server/internal/httpapi/enums_test.go new file mode 100644 index 0000000..a50950f --- /dev/null +++ b/server/internal/httpapi/enums_test.go @@ -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) + } + } +} diff --git a/server/internal/httpapi/error_surface_test.go b/server/internal/httpapi/error_surface_test.go new file mode 100644 index 0000000..4f3ca62 --- /dev/null +++ b/server/internal/httpapi/error_surface_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index 207dce6..8e1663a 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -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) diff --git a/server/internal/httpapi/origin.go b/server/internal/httpapi/origin.go index 04beaa3..c77bd0d 100644 --- a/server/internal/httpapi/origin.go +++ b/server/internal/httpapi/origin.go @@ -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 } diff --git a/server/internal/httpapi/ratelimit_api_test.go b/server/internal/httpapi/ratelimit_api_test.go new file mode 100644 index 0000000..2146220 --- /dev/null +++ b/server/internal/httpapi/ratelimit_api_test.go @@ -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) + } + } +} diff --git a/server/internal/httpapi/repo.go b/server/internal/httpapi/repo.go index b1bbe30..a150e7d 100644 --- a/server/internal/httpapi/repo.go +++ b/server/internal/httpapi/repo.go @@ -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("积分模板编码已存在") } diff --git a/server/internal/httpapi/repo_events.go b/server/internal/httpapi/repo_events.go index 98d5235..c60b738 100644 --- a/server/internal/httpapi/repo_events.go +++ b/server/internal/httpapi/repo_events.go @@ -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) diff --git a/server/internal/httpapi/repo_results.go b/server/internal/httpapi/repo_results.go index c5aa5f2..2260243 100644 --- a/server/internal/httpapi/repo_results.go +++ b/server/internal/httpapi/repo_results.go @@ -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) } diff --git a/server/internal/httpapi/response.go b/server/internal/httpapi/response.go index 52602d3..1cb960e 100644 --- a/server/internal/httpapi/response.go +++ b/server/internal/httpapi/response.go @@ -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 { diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index e9d0b86..994aff2 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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