feat: 分级拦截日志 + 两类日志文件;成绩录入防呆/解析/宽度修复

## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 internal/logging:拦截点捕获(file:line + 函数名,相对路径)、两类文件轮转、
  按级别/类别分流。httpapi 与 middleware 各自薄封装,无循环依赖。
- 两类目录(数据目录下):
  operations/     放行类(INFO「操作已放行」+ 常规运行日志,按 log.level)
  interceptions/  拦截类(WARN 中文原因 + 拦截点 + method/path/actor/role/requestId、
                  DEBUG 上下文、ERROR 服务端异常)—— **固定 debug 采集**,
                  否则默认 info 下"每次拦截的调试日志"根本看不见
- 命名 operations-YYYYMMDD[.N].log,按天+按大小轮转,每类保留 keepFiles 份;写盘失败
  降级只写控制台且只 Warn 一次。
- 所有业务错误统一走 newAPIError 捕获拦截点(11 处内联 &apiError{} 已收进构造函数,
  防止绕过);中间件层拦截(401/403/CSRF/未改初始密码/429)也留痕,响应体与状态码一字未变。
- config.yaml 的 log 段新增 dir / maxSizeMB / keepFiles(留空即默认)。
- 服务端包数 12 → 13(新增 internal/logging)。

## 成绩录入(用户实机踩坑)
- 真因一:成绩格 placeholder 是光秃秃的 `1:06.33`,而全仓库没有任何 ::placeholder 样式,
  示例文本与真实输入长得一样 → 用户把灰字当成已填内容,那 13 格其实是空的。
- 真因二:2 条报错是秒位 ≥ 60(1:70)或全角冒号(1:06);1:06 与 1:06.00 在解析器里
  本来就等价(都 = 66),格式不是问题。
- 真因三:成绩格没有宽度定义(旁边名次 72px、状态 110px 都有),被挤到第 4 位数字看不见。

修复:parseSecondsDetail 区分 empty/seconds-overflow/unrecognized;新增 entryGuards.ts
纯函数(录入页与审核页共用),报错**打出那一格的实际内容**并说明原因;解析器新认全角
冒号/全角数字/1分06秒 等写法;成绩格 min-width 112px(按 fontTools 实测文本宽度定);
示例文本改 `例 1:06.33` 等并给录入表格内的 ::placeholder 加淡色斜体;新增
onBeforeRouteLeave 离开守卫(原来切菜单会静默丢未保存成绩)。

测试:服务端 13 包全绿(372 个测试函数);前端 vue-tsc 通过、205 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q76,部署指南新增第 10 节「服务端日志与拦截复盘」)。
This commit is contained in:
AskaEth committed 2026-09-11 01:59:58 +08:00
1 parent 8e707e1e9b
commit e5e0944d26
32 files changed
+2017 -138

No files matched your search

+5 -7
View File
@@ -60,8 +60,7 @@ func isBuiltinFontID(id string) bool {
func (s *server) fontsDir() (string, error) {
dir := strings.TrimSpace(s.projectDir)
if dir == "" {
return "", &apiError{status: http.StatusServiceUnavailable, code: "STATE_INVALID",
message: "当前未配置赛事项目目录,无法管理字体"}
return "", serviceUnavailable("STATE_INVALID", "当前未配置赛事项目目录,无法管理字体")
}
return filepath.Join(dir, "uploads", "fonts"), nil
}
@@ -247,8 +246,7 @@ func (s *server) uploadFontHandler(w http.ResponseWriter, r *http.Request) (any,
// fontTooLarge 返回 413:字体超过上限时必须给出状态码,前端才知道是「太大」而不是「格式不对」。
func fontTooLarge() *apiError {
return &apiError{status: http.StatusRequestEntityTooLarge, code: "VALIDATION_FAILED",
message: "字体文件超过 20 MiB 上限"}
return tooLarge("字体文件超过 20 MiB 上限")
}
// deleteFontHandler 删除上传字体;仍被 display.font_title / display.font_body 引用时拒绝。
@@ -296,17 +294,17 @@ func (s *server) serveFont(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
ctype, ok := fontContentType(name)
if !ok {
writeAPIError(w, s.logger, notFound("字体不存在"))
s.writeAPIError(w, r, notFound("字体不存在"))
return
}
dir, err := s.fontsDir()
if err != nil {
writeAPIError(w, s.logger, notFound("字体不存在"))
s.writeAPIError(w, r, notFound("字体不存在"))
return
}
file := filepath.Join(dir, filepath.Base(name))
if info, err := os.Stat(file); err != nil || info.IsDir() {
writeAPIError(w, s.logger, notFound("字体不存在"))
s.writeAPIError(w, r, notFound("字体不存在"))
return
}
w.Header().Set("Content-Type", ctype)
+22 -24
View File
@@ -41,9 +41,9 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) {
u, token, err := s.auth.Authenticate(r.Context(), in.Username, in.Password, ip, r.UserAgent())
switch {
case err == auth.ErrRateLimited:
return nil, &apiError{status: http.StatusTooManyRequests, code: "RATE_LIMITED", message: "登录尝试过于频繁,请稍后再试"}
return nil, rateLimited("登录尝试过于频繁,请稍后再试")
case err == auth.ErrInvalidCredentials, err == auth.ErrUserInactive:
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_FAILED", message: err.Error()}
return nil, unauthorized("AUTH_FAILED", err.Error())
case err != nil:
return nil, internal(err)
}
@@ -87,7 +87,7 @@ type changePasswordInput struct {
func (s *server) changePassword(w http.ResponseWriter, r *http.Request) (any, error) {
u := middleware.User(r.Context())
if u.Role == auth.RoleGuest {
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_REQUIRED", message: "请先登录"}
return nil, unauthorized("AUTH_REQUIRED", "请先登录")
}
var in changePasswordInput
@@ -105,7 +105,7 @@ func (s *server) changePassword(w http.ResponseWriter, r *http.Request) (any, er
}
// 会话被盗后不能直接改密,必须再验一次原密码
if err := s.auth.VerifyPassword(r.Context(), u.ID, in.CurrentPassword); err != nil {
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_FAILED", message: "原密码不正确"}
return nil, unauthorized("AUTH_FAILED", "原密码不正确")
}
if err := s.auth.SetPassword(r.Context(), u.Username, in.NewPassword, false); err != nil {
return nil, internal(err)
@@ -168,8 +168,7 @@ func (s *server) listTeamsHandler(w http.ResponseWriter, r *http.Request) (any,
includeDeleted := isQueryTrue(r, "includeDeleted")
// 已彻底删除的单位不进任何列表,只有 admin 能用显式开关把它们翻出来排查。
if includeDeleted && !middleware.User(r.Context()).Role.Allows(auth.RoleAdmin) {
return nil, &apiError{status: http.StatusForbidden, code: "FORBIDDEN",
message: "只有管理员可以查看已彻底删除的单位"}
return nil, forbidden("只有管理员可以查看已彻底删除的单位")
}
return s.listTeams(r.Context(), includeDeleted)
}
@@ -819,34 +818,34 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
athleteName := cell("athlete_name")
if eventCode == "" || teamCode == "" {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:event_code / team_code 必填", line))
result.Errors = append(result.Errors, s.interceptRow(line, "event_code / team_code 必填"))
continue
}
// status 先归一:填表人写 DNS、退赛这类值不会撞 CHECK,认不出的填法在这里给出行级提示。
status, err := normalizeImportStatus(cell("status"))
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
continue
}
eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:小项 %s 不存在", line, eventCode))
result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 不存在", eventCode))
continue
}
teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:单位 %s 不存在", line, teamCode))
result.Errors = append(result.Errors, s.interceptRow(line, "单位 %s 不存在", teamCode))
continue
}
// 编码查单位时不排除已隐藏的(编码有唯一索引,排除会撞索引或重复建档),
// 命中就复活他:这一行马上要给他建报名,人却看不见就说不通了。
if err := s.reviveTeam(ctx, teamID); err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
continue
}
var categoryID int64
@@ -854,14 +853,14 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:组别 %s 不存在", line, categoryName))
result.Errors = append(result.Errors, s.interceptRow(line, "组别 %s 不存在", categoryName))
continue
}
} else {
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? ORDER BY sort_order, id LIMIT 1`, eventID)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:小项 %s 没有组别", line, eventCode))
result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 没有组别", eventCode))
continue
}
}
@@ -874,19 +873,19 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
// 证件号与号码布二选一即可,都没有就无法区分同名的两个人。
if idCard == "" && bib == "" {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:填写了 athlete_name 就必须填 id_card 或 bib", line))
result.Errors = append(result.Errors, s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib"))
continue
}
age, ok := parseImportAge(cell("age"))
if !ok {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:age 需要是 0-120 的整数", line))
result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数"))
continue
}
if idCard != "" {
if seenIDCards[idCard] {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:证件号 %s 在报名表内重复出现", line, idCard))
result.Errors = append(result.Errors, s.interceptRow(line, "证件号 %s 在报名表内重复出现", idCard))
continue
}
seenIDCards[idCard] = true
@@ -894,7 +893,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
if bib != "" {
if seenBibs[bib] {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:号码布 %s 在报名表内重复出现", line, bib))
result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
continue
}
seenBibs[bib] = true
@@ -912,7 +911,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
}, decisions)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
continue
}
if matched.Conflict != nil {
@@ -937,7 +936,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
switch {
case err != nil:
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
case created:
result.Created++
default:
@@ -1015,8 +1014,7 @@ type uploadResult struct {
func (s *server) uploadsDir() (string, error) {
dir := strings.TrimSpace(s.projectDir)
if dir == "" {
return "", &apiError{status: http.StatusServiceUnavailable, code: "STATE_INVALID",
message: "当前未配置赛事项目目录,无法上传图片"}
return "", serviceUnavailable("STATE_INVALID", "当前未配置赛事项目目录,无法上传图片")
}
return filepath.Join(dir, "uploads"), nil
}
@@ -1092,18 +1090,18 @@ func (s *server) serveUpload(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
ctype, ok := uploadContentType(name)
if !ok {
writeAPIError(w, s.logger, notFound("图片不存在"))
s.writeAPIError(w, r, notFound("图片不存在"))
return
}
dir, err := s.uploadsDir()
if err != nil {
writeAPIError(w, s.logger, notFound("图片不存在"))
s.writeAPIError(w, r, notFound("图片不存在"))
return
}
// 名字已过白名单校验,这里再用 Base 兜一层,确保只会读到 uploads 内的文件。
file := filepath.Join(dir, filepath.Base(name))
if info, err := os.Stat(file); err != nil || info.IsDir() {
writeAPIError(w, s.logger, notFound("图片不存在"))
s.writeAPIError(w, r, notFound("图片不存在"))
return
}
w.Header().Set("Content-Type", ctype)
+3 -3
View File
@@ -36,12 +36,12 @@ func (s *server) shutdownHandler(w http.ResponseWriter, r *http.Request) (any, e
if !ok {
// 没带任何凭据的访客按「需登录」处理,其余一律 403,避免探测。
if middleware.User(r.Context()).Role == auth.RoleGuest && r.Header.Get(shutdownTokenHeader) == "" {
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_REQUIRED", message: "请先登录"}
return nil, unauthorized("AUTH_REQUIRED", "请先登录")
}
return nil, &apiError{status: http.StatusForbidden, code: "FORBIDDEN", message: "无权关闭服务"}
return nil, forbidden("无权关闭服务")
}
if s.shutdown == nil {
return nil, &apiError{status: http.StatusServiceUnavailable, code: "INTERNAL", message: "服务端未接入优雅关闭"}
return nil, serviceUnavailable("INTERNAL", "服务端未接入优雅关闭")
}
s.audit(r.Context(), "system.shutdown", "system", 0, nil, map[string]any{"auth": method})
@@ -0,0 +1,203 @@
package httpapi_test
import (
"bytes"
"log/slog"
"net/http"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// logEnv 把服务端日志抓进内存缓冲:日志分级与拦截点只有真的抓到才能断言。
// 用 DEBUG 级别建 handler,WARN/INFO/ERROR 与默认级别下的可见性差异由用例分别断言。
func logEnv(t *testing.T) (*testEnv, *bytes.Buffer) {
t.Helper()
buf := &bytes.Buffer{}
logger := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Logger = logger })
return env, buf
}
// waitLog 等到日志里出现 want 之后返回整段日志。
// 一部分日志(审计 INFO、拦截 WARN)在响应写完之后才落,不能立刻就读缓冲。
func waitLog(t *testing.T, buf *bytes.Buffer, want string) string {
t.Helper()
for i := 0; i < 200; i++ {
if got := buf.String(); strings.Contains(got, want) {
return got
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("等不到日志片段 %q,当前日志:\n%s", want, buf.String())
return ""
}
// recorderClient 建号并登录,返回 recorder 客户端。
func recorderClient(t *testing.T, env *testEnv) *http.Client {
t.Helper()
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
return client
}
// 拦截点必须指向真正做校验的那段代码,而不是 response.go 里的构造函数。
func TestInterceptLogPointsAtValidator(t *testing.T) {
env, buf := logEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
// avgMode 校验发生在 repo_events.go 的 normalizeAvgMode。
status, env2 := env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": "E-BAD", "name": "非法小项", "avgMode": "bogus",
}, true)
if status != http.StatusBadRequest || env2.Error == nil {
t.Fatalf("非法 avgMode 应 400,得到 %d %+v", status, env2.Error)
}
logs := waitLog(t, buf, "请求被拦截")
if !strings.Contains(logs, `"origin":"internal/httpapi/repo_events.go:`) ||
!strings.Contains(logs, "normalizeAvgMode") {
t.Fatalf("WARN 行应指出拦截点是 normalizeAvgMode,得到:\n%s", logs)
}
for _, forbidden := range []string{"response.go", "badRequest", "newAPIError", "/home/"} {
if strings.Contains(logs, forbidden) {
t.Fatalf("拦截点不该出现 %q(应指向校验代码、且不带开发机路径),得到:\n%s", forbidden, logs)
}
}
}
// 分级:4xx 有 WARN 与 DEBUG、没有 ERROR;5xx 用 ERROR;成功的写操作只有 INFO 放行。
func TestInterceptLogLevels(t *testing.T) {
t.Run("4xx", func(t *testing.T) {
env, buf := logEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": "E-BAD", "name": "非法小项", "avgMode": "bogus",
}, true)
logs := waitLog(t, buf, `"level":"WARN"`)
// WARN 是默认 info 级别下可见的那行:为什么被拦 + 拦截点都要在里面。
if !strings.Contains(logs, `"reason":"avgMode 只能是 valid / all"`) {
t.Fatalf("WARN 行应写明被拦原因,得到:\n%s", logs)
}
if !strings.Contains(logs, `"origin":"internal/httpapi/repo_events.go:`) {
t.Fatalf("WARN 行应带拦截点,得到:\n%s", logs)
}
logs = waitLog(t, buf, `"level":"DEBUG"`)
if !strings.Contains(logs, `"msg":"拦截上下文"`) || !strings.Contains(logs, `"detail":"bogus"`) {
t.Fatalf("DEBUG 行应带完整上下文与 detail,得到:\n%s", logs)
}
if strings.Contains(logs, `"level":"ERROR"`) {
t.Fatalf("4xx 不该出现 ERROR,得到:\n%s", logs)
}
})
t.Run("5xx", func(t *testing.T) {
sender := &strictDisplaySender{}
buf := &bytes.Buffer{}
logger := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
env := newTestEnvWith(t, func(o *httpapi.Options) {
o.Logger = logger
o.Display = sender
})
client := recorderClient(t, env)
status, _ := env.do(client, http.MethodPost, "/api/display/command",
map[string]any{"action": "nope"}, true)
if status != http.StatusInternalServerError {
t.Fatalf("未知指令应 500,得到 %d", status)
}
logs := waitLog(t, buf, `"level":"ERROR"`)
if !strings.Contains(logs, `"origin":"internal/httpapi/handlers_results.go:`) ||
!strings.Contains(logs, "displayCommandHandler") {
t.Fatalf("ERROR 行应带上拦截点,得到:\n%s", logs)
}
if strings.Contains(logs, `"level":"WARN"`) {
t.Fatalf("5xx 不该同时按 WARN 记,得到:\n%s", logs)
}
})
t.Run("放行", func(t *testing.T) {
env, buf := logEnv(t)
client := recorderClient(t, env)
status, env2 := env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": "Z9", "name": "第九分公司",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("新建单位应 200,得到 %d %+v", status, env2.Error)
}
logs := waitLog(t, buf, "操作已放行")
for _, want := range []string{`"level":"INFO"`, `"action":"team.create"`, `"entity":"team"`, `"actor":"recorder#`} {
if !strings.Contains(logs, want) {
t.Fatalf("放行日志应含 %s,得到:\n%s", want, logs)
}
}
// 一次审计条目一行 INFO:登录 + 新建单位共 2 行,别在同一次请求里重复刷。
if n := strings.Count(logs, "操作已放行"); n != 2 {
t.Fatalf("放行日志应恰好 2 行(登录 + 新建单位),得到 %d 行:\n%s", n, logs)
}
if strings.Contains(logs, `"level":"WARN"`) || strings.Contains(logs, `"level":"ERROR"`) {
t.Fatalf("成功请求不该有 WARN/ERROR,得到:\n%s", logs)
}
})
}
// 日志不能整段记请求体:带证件号的新建人员请求(成功与 409 两条路径)都不该把证件号打出来。
func TestInterceptLogKeepsBodyOut(t *testing.T) {
env, buf := logEnv(t)
client := recorderClient(t, env)
const idCard = "110101199001019999"
env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "张三", "gender": "男", "teamId": 1, "idCard": idCard, "active": true,
}, true)
// 同一个证件号再来一次:409 的 WARN 也不该带出证件号。
env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "张三", "gender": "男", "teamId": 1, "idCard": idCard, "active": true,
}, true)
logs := waitLog(t, buf, "操作已放行")
if !strings.Contains(logs, "请求被拦截") {
t.Fatalf("第二次建档应被拦截并记 WARN,得到:\n%s", logs)
}
if strings.Contains(logs, idCard) {
t.Fatalf("日志里出现了证件号原文,说明请求体被打了进去:\n%s", logs)
}
}
// 导入的行级拦截每一条都要有一行 DEBUG,带行号与拦截点。
func TestImportRowInterceptLogs(t *testing.T) {
env, buf := logEnv(t)
env.seed(t)
client := recorderClient(t, env)
body := importHeader + "\n" +
"M100,男子,A,张三,110101199001011234,男,,,,已报到,\n" +
"M100,男子,A,李四,110101199001011235,男,,,,,\n"
status, resp := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[map[string]any](t, resp)
if result["skipped"] != float64(1) {
t.Fatalf("非法 status 的那行应被跳过: %+v", result)
}
logs := waitLog(t, buf, "导入行被拦截")
if !strings.Contains(logs, `"row":2`) || !strings.Contains(logs, "无法识别") {
t.Fatalf("行级 DEBUG 应带行号与原因,得到:\n%s", logs)
}
if !strings.Contains(logs, "internal/httpapi/handlers_config.go:") ||
!strings.Contains(logs, "importEntriesWith") {
t.Fatalf("行级 DEBUG 应带拦截点,得到:\n%s", logs)
}
if n := strings.Count(logs, "导入行被拦截"); n != 1 {
t.Fatalf("一条行级错误应只记一行 DEBUG,得到 %d 行:\n%s", n, logs)
}
}
+164
View File
@@ -0,0 +1,164 @@
package httpapi_test
import (
"bytes"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
)
// fileLogEnv 把服务端日志按「放行 / 拦截」两类真的写进临时目录,返回两个目录路径。
// 到这里为止的用例都只看内存缓冲,这一份是「真的读文件」的验收:分流、级别、落盘都要落地。
func fileLogEnv(t *testing.T) (*testEnv, string, string) {
t.Helper()
dir := t.TempDir()
handler, closer := logging.New(logging.Options{
Dir: dir,
Level: slog.LevelInfo,
MaxSizeMB: 20,
KeepFiles: 10,
Console: &bytes.Buffer{},
})
t.Cleanup(func() { _ = closer.Close() })
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Logger = slog.New(handler) })
return env, filepath.Join(dir, logging.DirOperations), filepath.Join(dir, logging.DirInterceptions)
}
// readCategory 读某类日志今天的文件;文件还没建出来时返回空串。
func readCategory(t *testing.T, dir, prefix string) string {
t.Helper()
path := filepath.Join(dir, prefix+"-"+time.Now().Format("20060102")+".log")
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ""
}
if err != nil {
t.Fatalf("读日志 %s: %v", path, err)
}
return string(raw)
}
// waitCategory 等到某类日志出现目标片段(审计 INFO 在响应写完之后才落)。
func waitCategory(t *testing.T, dir, prefix, want string) string {
t.Helper()
for i := 0; i < 200; i++ {
got := readCategory(t, dir, prefix)
if strings.Contains(got, want) {
return got
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("等不到 %s/%s 里出现 %q,当前内容:\n%s", dir, prefix, want, readCategory(t, dir, prefix))
return ""
}
// 中间件层的拦截(403/401 这类发生在 handler 之前的拦截)必须落进 interceptions/,
// 带中文原因与指向中间件的拦截点;同时验证两类文件互不串门、5xx 只进拦截类。
func TestMiddlewareInterceptsLandInInterceptionFile(t *testing.T) {
env, opsDir, interDir := fileLogEnv(t)
env.seed(t)
// recorder 访问 admin-only 的只读接口 → RequireRole 403。
env.createUser("recorder", "recorder-pass", "recorder")
recorder, _ := env.client()
env.login(recorder, "recorder", "recorder-pass")
if status, _ := env.do(recorder, http.MethodGet, "/api/audit-logs", nil, false); status != http.StatusForbidden {
t.Fatalf("recorder 读审计应 403,得到 %d", status)
}
// 已登录但不带 CSRF 头的写请求 → CSRF 中间件 403。
if status, _ := env.do(recorder, http.MethodPost, "/api/teams",
map[string]any{"code": "X", "name": "越权单位"}, false); status != http.StatusForbidden {
t.Fatalf("缺 CSRF 的写请求应 403,得到 %d", status)
}
// 必须改密的账号写操作 → MustChangePassword 403。
lockedID := env.createUser("locked", "locked-pass", "recorder")
if _, err := env.sys.Exec(`UPDATE users SET must_change_password = 1 WHERE id = ?`, lockedID); err != nil {
t.Fatalf("设置 must_change_password: %v", err)
}
locked, _ := env.client()
env.login(locked, "locked", "locked-pass")
if status, _ := env.do(locked, http.MethodPost, "/api/teams",
map[string]any{"code": "Y", "name": "越权单位"}, true); status != http.StatusForbidden {
t.Fatalf("改密前写操作应 403,得到 %d", status)
}
// 未登录的写请求 → RequireWrite 401。
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodPost, "/api/teams",
map[string]any{"code": "Z", "name": "未登录"}, false); status != http.StatusUnauthorized {
t.Fatalf("guest 写请求应 401,得到 %d", status)
}
inter := waitCategory(t, interDir, logging.DirInterceptions, "RequireRole")
for _, want := range []string{
"请求被拦截",
"无权访问该资源",
"请先登录",
"CSRF 校验失败,请刷新页面重试",
"请先修改初始密码",
"internal/middleware/middleware.go:",
} {
if !strings.Contains(inter, want) {
t.Fatalf("interceptions 应含 %q,得到:\n%s", want, inter)
}
}
// 四个中间件各留下一条 WARN,拦截点都在 middleware.go、都不是 response.go。
for _, fn := range []string{"RequireRole", "RequireWrite", "CSRF", "MustChangePassword"} {
if !strings.Contains(inter, fn) {
t.Fatalf("interceptions 应含拦截点函数 %s,得到:\n%s", fn, inter)
}
}
if strings.Contains(inter, "response.go") {
t.Fatalf("中间件拦截的拦截点不该落在 response.go,得到:\n%s", inter)
}
// 放行类里不能出现这些拦截行。
ops := readCategory(t, opsDir, logging.DirOperations)
for _, unwanted := range []string{"请求被拦截", "无权访问该资源", "CSRF 校验失败"} {
if strings.Contains(ops, unwanted) {
t.Fatalf("operations 不该含拦截日志 %q,得到:\n%s", unwanted, ops)
}
}
// 放行类里应当有登录这条 INFO(证明两类文件各自都有内容)。
if !strings.Contains(waitCategory(t, opsDir, logging.DirOperations, "操作已放行"), `"action":"auth.login"`) {
t.Fatalf("operations 应含放行日志,得到:\n%s", readCategory(t, opsDir, logging.DirOperations))
}
}
// 5xx 的归属:只进 interceptions/,不进 operations/。
func TestServerErrorGoesToInterceptionFileOnly(t *testing.T) {
dir := t.TempDir()
sender := &strictDisplaySender{}
handler, closer := logging.New(logging.Options{Dir: dir, Level: slog.LevelInfo, Console: &bytes.Buffer{}})
defer func() { _ = closer.Close() }()
env := newTestEnvWith(t, func(o *httpapi.Options) {
o.Logger = slog.New(handler)
o.Display = sender
})
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
if status, _ := env.do(client, http.MethodPost, "/api/display/command",
map[string]any{"action": "nope"}, true); status != http.StatusInternalServerError {
t.Fatalf("未知指令应 500,得到 %d", status)
}
inter := waitCategory(t, filepath.Join(dir, logging.DirInterceptions), logging.DirInterceptions, "请求失败")
if !strings.Contains(inter, "displayCommandHandler") {
t.Fatalf("ERROR 行应带拦截点,得到:\n%s", inter)
}
ops := readCategory(t, filepath.Join(dir, logging.DirOperations), logging.DirOperations)
if strings.Contains(ops, "请求失败") {
t.Fatalf("5xx 不该进 operations,得到:\n%s", ops)
}
}
+91
View File
@@ -0,0 +1,91 @@
package httpapi
import (
"fmt"
"log/slog"
"net/http"
"runtime"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// 本文件把「拦截点」(哪一行、哪个函数拦下的)记进拦截日志。
// 主动拦截的报错必须自带拦截点:默认级别是 info,现场只看得见 WARN 那一行,
// 只给一句中文提示的话,排查时仍然不知道是哪段校验拦的。
// 捕获实现放在 internal/logging,与 middleware 共用一份,避免两边各写一套。
// callerOrigin 捕获调用点;skip 从 callerOrigin 自己算起(见 newAPIError 的说明),
// 这里 +1 是把自己这一层算进 logging.Origin 的帧数。
func callerOrigin(skip int) string {
return logging.Origin(skip + 1)
}
// originFromStack 兜底:错误不是经构造函数造的(例如把底层错误原样返回出来)时,
// 沿栈找到第一个不在 response.go / 本文件里的帧,就是返回这个错误的那段业务代码。
func originFromStack(skip int) string {
pcs := make([]uintptr, 16)
n := runtime.Callers(skip, pcs)
frames := runtime.CallersFrames(pcs[:n])
for {
frame, more := frames.Next()
if frame.File != "" && !strings.HasSuffix(frame.File, "response.go") &&
!strings.HasSuffix(frame.File, "origin.go") && !strings.Contains(frame.Function, "/internal/logging.") {
return logging.FormatOrigin(frame.File, frame.Line, frame.Function)
}
if !more {
return "unknown"
}
}
}
// interceptRow 生成给前端的行级错误文案,并顺手留一条 DEBUG 拦截日志(拦进 interceptions/)。
// 导入的行级错误原本只进响应体,服务端不留痕的话,现场「我明明填对了它却说格式不对」没法复盘。
func (s *server) interceptRow(row int, format string, args ...any) string {
reason := fmt.Sprintf(format, args...)
s.logger.Debug("导入行被拦截",
"row", row,
"reason", reason,
"origin", callerOrigin(2),
logging.ChannelKey, logging.ChannelInterception,
)
return fmt.Sprintf("第 %d 行:%s", row, reason)
}
// logIntercepted 记录一次被拦截的请求。
// WARN 是默认级别(info)下现场唯一看得见的一行,所以「为什么被拦」与「拦截点」都放在它身上;
// DEBUG 只补上下文(code/detail/ip/角色),不能只靠它。带类别标记的行会进 interceptions/。
// 这里绝不整段记请求体:里面有证件号这类个人信息,`detail` 与响应同源,属既有泄露面。
func logIntercepted(r *http.Request, logger *slog.Logger, api *apiError) {
u := middleware.User(r.Context())
actor := actorLabel(u)
role := u.Role.String()
reqID := middleware.GetRequestID(r.Context())
if api.status >= http.StatusInternalServerError {
logger.Error("请求失败",
"code", api.code, "err", api.message, "origin", api.origin,
"method", r.Method, "path", r.URL.Path, "actor", actor, "requestId", reqID)
return
}
logger.Warn("请求被拦截",
"reason", api.message, "origin", api.origin, "code", api.code,
"method", r.Method, "path", r.URL.Path,
"actor", actor, "role", role, "requestId", reqID,
logging.ChannelKey, logging.ChannelInterception)
logger.Debug("拦截上下文",
"code", api.code, "detail", api.detail, "origin", api.origin,
"method", r.Method, "path", r.URL.Path, "ip", middleware.ClientIP(r),
"actor", actor, "role", role, "requestId", reqID,
logging.ChannelKey, logging.ChannelInterception)
}
// actorLabel 是日志里的操作人:未登录记 guest,登录后记「用户名#id」,比裸 id 好认。
func actorLabel(u *auth.User) string {
if u == nil || u.ID == 0 {
return "guest"
}
return fmt.Sprintf("%s#%d", u.Username, u.ID)
}
+63 -12
View File
@@ -3,7 +3,6 @@ package httpapi
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
@@ -17,32 +16,78 @@ type apiError struct {
code string
message string
detail any
// origin 是「拦截点」:构造这个错误的 file:line 与函数名,例如
// internal/httpapi/repo_events.go:231 normalizeAvgMode。拦截日志靠它定位到校验代码。
origin string
}
func (e *apiError) Error() string { return e.code + ": " + e.message }
// newAPIError 是所有业务错误的唯一构造入口:在这里捕获拦截点。
// skip=3 是 callerOrigin → newAPIError → 具体构造函数 → 真正做校验的调用点,
// 少一层就会把 helper 自己记成拦截点,那样日志等于没说。
func newAPIError(status int, code, message string, detail any) *apiError {
return &apiError{
status: status,
code: code,
message: message,
detail: detail,
origin: callerOrigin(3),
}
}
func badRequest(message string, detail any) *apiError {
return &apiError{status: http.StatusBadRequest, code: "VALIDATION_FAILED", message: message, detail: detail}
return newAPIError(http.StatusBadRequest, "VALIDATION_FAILED", message, detail)
}
func notFound(message string) *apiError {
return &apiError{status: http.StatusNotFound, code: "VALIDATION_FAILED", message: message}
return newAPIError(http.StatusNotFound, "VALIDATION_FAILED", message, nil)
}
func conflict(message string) *apiError {
return &apiError{status: http.StatusConflict, code: "CONFLICT", message: message}
return newAPIError(http.StatusConflict, "CONFLICT", message, nil)
}
func stateInvalid(message string) *apiError {
return &apiError{status: http.StatusConflict, code: "STATE_INVALID", message: message}
return newAPIError(http.StatusConflict, "STATE_INVALID", message, nil)
}
// unauthorized 是 401:code 区分「没登录」与「凭据不对」。
func unauthorized(code, message string) *apiError {
return newAPIError(http.StatusUnauthorized, code, message, nil)
}
// forbidden 是 403:角色不足或来源校验不过。
func forbidden(message string) *apiError {
return newAPIError(http.StatusForbidden, "FORBIDDEN", message, nil)
}
// rateLimited 是 429:写操作/登录太频繁,稍后可重试。
func rateLimited(message string) *apiError {
return newAPIError(http.StatusTooManyRequests, "RATE_LIMITED", message, nil)
}
// tooLarge 是 413:正文超过上限,与「格式不对」区分开,前端才知道该怎么处理。
func tooLarge(message string) *apiError {
return newAPIError(http.StatusRequestEntityTooLarge, "VALIDATION_FAILED", message, nil)
}
// serviceUnavailable 是 503:依赖未接入或目录未配置,配置好即可用,重试无用。
func serviceUnavailable(code, message string) *apiError {
return newAPIError(http.StatusServiceUnavailable, code, message, nil)
}
func internal(err error) *apiError {
return &apiError{status: http.StatusInternalServerError, code: "INTERNAL", message: "服务端异常", detail: err.Error()}
return newAPIError(http.StatusInternalServerError, "INTERNAL", "服务端异常", err.Error())
}
// internalMsg 是 internal 的可自定义文案版本:5xx 的 message 也要让现场看懂发生了什么。
func internalMsg(message string, err error) *apiError {
return newAPIError(http.StatusInternalServerError, "INTERNAL", message, err.Error())
}
func notImplemented(message string) *apiError {
return &apiError{status: http.StatusNotImplemented, code: "INTERNAL", message: message}
return newAPIError(http.StatusNotImplemented, "INTERNAL", message, nil)
}
// handler 是业务处理器:返回 data 时自动包成 {ok:true,data},返回 *apiError 时输出统一错误。
@@ -56,7 +101,7 @@ func (s *server) handle(fn handler) http.HandlerFunc {
if errors.Is(err, errHandled) {
return
}
writeAPIError(w, s.logger, err)
s.writeAPIError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": data})
@@ -67,21 +112,27 @@ func (s *server) handle(fn handler) http.HandlerFunc {
func (s *server) handleVoid(fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
writeAPIError(w, s.logger, err)
s.writeAPIError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": nil})
}
}
func writeAPIError(w http.ResponseWriter, logger *slog.Logger, err error) {
// writeAPIError 把业务错误写成统一响应,并按级别记日志:
// 4xx 是「我们主动拦下的」,WARN 给出原因与拦截点(默认 info 级别下必须可见),DEBUG 补上下文;
// 5xx 是服务端自身异常,用 ERROR 并带上拦截点。
func (s *server) writeAPIError(w http.ResponseWriter, r *http.Request, err error) {
var api *apiError
if !errors.As(err, &api) {
api = internal(err)
}
if api.status >= http.StatusInternalServerError {
logger.Error("请求失败", "code", api.code, "err", api.message)
// 兜底:错误没经构造函数(拿不到 origin)时,沿栈找到返回它的那段业务代码。
if api.origin == "" {
api.origin = originFromStack(3)
}
logIntercepted(r, s.logger, api)
body := map[string]any{
"ok": false,
"error": map[string]any{
+1 -6
View File
@@ -139,12 +139,7 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error
// 规则已经落库、重算整体回滚,现场处于「新规则 + 旧积分」;
// 必须显式告知调用方,不能报成功让操作员以为重算已完成。
s.logger.Error("规则导入后重算失败", "err", err)
return nil, &apiError{
status: http.StatusInternalServerError,
code: "INTERNAL",
message: "规则已导入,但积分重算失败,成绩与积分尚未同步;请重试导入",
detail: err.Error(),
}
return nil, internalMsg("规则已导入,但积分重算失败,成绩与积分尚未同步;请重试导入", err)
}
s.audit(r.Context(), "rules.import", "rules", 0, nil, res)
return rulesImportResult{
+8 -8
View File
@@ -126,9 +126,9 @@ func New(opts Options) http.Handler {
var h http.Handler = mux
h = s.broadcastChanges(h)
h = middleware.NewAuditor(opts.Event, logger).Middleware(h)
h = middleware.RateLimitWrite(svc)(h)
h = middleware.CSRF(h)
h = middleware.MustChangePassword(h)
h = middleware.RateLimitWrite(svc, logger)(h)
h = middleware.CSRF(logger, h)
h = middleware.MustChangePassword(logger, h)
h = middleware.Authenticate(svc, logger)(h)
h = middleware.Log(logger)(h)
h = middleware.Recover(logger)(h)
@@ -149,17 +149,17 @@ func (s *server) public(fn handler) http.HandlerFunc { return s.handle(fn) }
// read 注册「guest 也不可读」的接口,权限不足一律 403。
func (s *server) read(role auth.Role, fn handler) http.HandlerFunc {
return middleware.RequireRole(role)(s.handle(fn)).ServeHTTP
return middleware.RequireRole(s.logger, role)(s.handle(fn)).ServeHTTP
}
// write 注册写接口:未登录 401,角色不足 403;成功后按 X-Request-Id 支持幂等回放。
func (s *server) write(role auth.Role, fn handler) http.HandlerFunc {
return middleware.RequireWrite(role)(s.cached(s.handle(fn))).ServeHTTP
return middleware.RequireWrite(s.logger, role)(s.cached(s.handle(fn))).ServeHTTP
}
// writeVoid 注册无返回体的写接口。
func (s *server) writeVoid(role auth.Role, fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return middleware.RequireWrite(role)(s.cached(s.handleVoid(fn))).ServeHTTP
return middleware.RequireWrite(s.logger, role)(s.cached(s.handleVoid(fn))).ServeHTTP
}
func (s *server) routes(mux *http.ServeMux) {
@@ -269,14 +269,14 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("/api/export/", s.notWired("导出"))
mux.HandleFunc("/api/rules/", s.notWired("规则导入导出"))
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, s.logger, notFound("接口不存在"))
s.writeAPIError(w, r, notFound("接口不存在"))
})
}
// notWired 为待接线的接口返回 501,避免请求被静态资源兜底成 HTML。
func (s *server) notWired(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, s.logger, notImplemented(name+"接口尚未接入"))
s.writeAPIError(w, r, notImplemented(name+"接口尚未接入"))
}
}