fix: 枚举入参校验(500→400)、5xx 错误串不再外泄、健康检查探库、限流键清理
服务端一批「会误导现场」的缺陷,全部补了测试: - 枚举型入参(events 的 event_type/result_mode/result_type/compare/status、 scoring_profiles 的 tie_mode/record_bonus_mode、entries.status、results.status) 过去完全没有校验,非法值一路落到 SQLite 的 CHECK 约束,被统一错误处理当成 服务端异常报成 500;现在统一在 enums.go 拦成 400 VALIDATION_FAILED, message 带字段名与合法取值、detail 带原始输入值。空值仍走原来的默认值。 - 5xx 响应体不再回显内部错误原文:新增 apiError.logDetail,SQLite / 引擎的 原始错误串只进 ERROR 日志的 detail 字段。此前正好是反的——前端拿得到原文, 日志里只有「服务端异常」。同时校准 newAPIError / internalMsg 的 skip 层数, 拦截点不再退化成 response.go 里的构造函数。 - /api/health 新增 db / dbError:用 SELECT 1 探库(Ping 探不到「文件在、库读不动」), 超时 5s 与 busy_timeout 同量级。状态码仍恒 200 —— Console 拿状态码当存活信号, 数据库故障时回 5xx 只会变成重启循环,两个事实分两个字段表达。 - 修改密码时原密码错误由 401 改为 400 AUTH_FAILED(新增 wrongCredential): 401 的语义是「本请求没有有效会话」,而这里会话是好的。 - 限流 hits map 在键数超过阈值时清理窗口内已无记录的键(窗口内活跃的键不动, 否则堆键数就能冲掉别人的计数)。 - createEntry / updateEntry 拒绝「A 小项的报名挂在 B 小项的组别上」。 数据库层仍无 (event_id, category_id) 复合外键(重建 entries 会牵动 results / score_ledger 的级联,风险高不做),这一层先兜住。 - 补测试:枚举字段逐项正反例、CSRF 反向用例、写限流 429、健康探库(含库故障)、 5xx 不外泄原文、限流键清理不误删。
This commit is contained in:
1 parent
23fee5b818
commit
2c983bcfdf
14 files changed
+1112
-21
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("积分模板编码已存在")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -164,8 +166,22 @@ func (s *server) writeVoid(role auth.Role, fn func(w http.ResponseWriter, r *htt
|
||||
|
||||
func (s *server) routes(mux *http.ServeMux) {
|
||||
// 健康检查不走 {ok,data} 包装(05 第 7 节)。
|
||||
//
|
||||
// 语义分两层,都写进响应体的不同字段,谁也不覆盖谁:
|
||||
// ok —— HTTP 服务活着(Console 拿状态码当存活信号,这里恒回 200);
|
||||
// db —— 数据库能应答(新增,之前不探库,「进程活着但连不上库」会被当成健康)。
|
||||
// 状态码保持 200:Console 判定不健康会重启进程,而数据库故障重启治不了,
|
||||
// 只会变成重启循环。让守护脚本读 db 字段自行决策,比替它做决定安全。
|
||||
// 不返回最近备份时间:本接口会被 Console 高频轮询,不做目录扫描。
|
||||
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.version})
|
||||
ctx, cancel := context.WithTimeout(r.Context(), healthProbeTimeout)
|
||||
defer cancel()
|
||||
resp := map[string]any{"ok": true, "version": s.version, "db": "ok"}
|
||||
if err := s.probeDatabases(ctx); err != nil {
|
||||
resp["db"] = "error"
|
||||
resp["dbError"] = err.Error()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
})
|
||||
|
||||
// 认证
|
||||
@@ -284,6 +300,30 @@ func (s *server) notWired(name string) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// healthProbeTimeout 是健康检查探库的超时。
|
||||
// 取 5s 与 busy_timeout 同量级:库被备份、重算这类长事务占着单连接时,
|
||||
// 探针宁可等一会儿,也不要报出一个假的「数据库故障」。
|
||||
const healthProbeTimeout = 5 * time.Second
|
||||
|
||||
// probeDatabases 探活系统库与赛事库,返回第一个出错的原因。
|
||||
// 用 `SELECT 1` 而不是 Ping:sqlite 驱动的 Ping 可能只到连接池就返回,
|
||||
// 探不到「文件还在、库已经读不动」这种情况。
|
||||
func (s *server) probeDatabases(ctx context.Context) error {
|
||||
for _, target := range []struct {
|
||||
name string
|
||||
db *sql.DB
|
||||
}{{"system", s.sys}, {"event", s.ev}} {
|
||||
if target.db == nil {
|
||||
continue
|
||||
}
|
||||
var one int
|
||||
if err := target.db.QueryRowContext(ctx, `SELECT 1`).Scan(&one); err != nil {
|
||||
return fmt.Errorf("%s 库无响应: %w", target.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// staticHandler 把 URL 映射到多页面前端产物。
|
||||
//
|
||||
// / → index.html
|
||||
|
||||
Reference in new issue
Block a user