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

+24 -1
View File
@@ -5,9 +5,12 @@ import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
)
// Config 是 config.yaml 的映射。
@@ -37,6 +40,13 @@ type Server struct {
// Log 是日志配置。
type Log struct {
Level string `yaml:"level"`
// Dir 是日志根目录,其下分 operations/(放行类)与 interceptions/(拦截类);
// 留空时用 <dataDir>/logs。
Dir string `yaml:"dir"`
// MaxSizeMB 是单个日志文件的大小上限(MB);<=0 用 logging.DefaultMaxSizeMB。
MaxSizeMB int `yaml:"maxSizeMB"`
// KeepFiles 是每个类别保留的份数(含当前文件);<=0 用 logging.DefaultKeepFiles。
KeepFiles int `yaml:"keepFiles"`
}
// Default 返回一份可直接使用的默认配置。
@@ -45,10 +55,23 @@ func Default() *Config {
Server: Server{Host: "0.0.0.0", Port: 19540},
DataDir: "data",
Backup: Backup{IntervalMinutes: 5, Keep: 20},
Log: Log{Level: "info"},
Log: Log{
Level: "info",
MaxSizeMB: logging.DefaultMaxSizeMB,
KeepFiles: logging.DefaultKeepFiles,
},
}
}
// DirPath 返回日志根目录:配了 log.dir 就用它,否则落在数据目录下的 logs/。
// 相对路径保持相对(与 dataDir 的处理一致),现场怎么填就怎么用。
func (l Log) DirPath(dataDir string) string {
if dir := strings.TrimSpace(l.Dir); dir != "" {
return dir
}
return filepath.Join(dataDir, "logs")
}
// Load 读取配置文件;文件不存在时返回默认配置。
func Load(path string) (*Config, error) {
cfg := Default()
+30
View File
@@ -63,3 +63,33 @@ func TestApplyDataDir(t *testing.T) {
})
}
}
// 日志配置:默认值开箱即用,配了 log.dir 就按它走,不配就落在 <dataDir>/logs。
func TestLogConfigDefaultsAndDirPath(t *testing.T) {
def := Default().Log
if def.Level != "info" {
t.Fatalf("默认级别应为 info,得到 %q", def.Level)
}
if def.MaxSizeMB <= 0 || def.KeepFiles <= 0 {
t.Fatalf("大小上限与保留份数都要有默认值,得到 %+v", def)
}
if got := def.DirPath("/data/sb"); got != filepath.Join("/data/sb", "logs") {
t.Fatalf("未配 dir 时应落在数据目录下的 logs,得到 %q", got)
}
path := filepath.Join(t.TempDir(), "config.yaml")
raw := "log:\n level: warn\n dir: /var/log/scoreboard\n maxSizeMB: 5\n keepFiles: 3\n"
if err := os.WriteFile(path, []byte(raw), 0o644); err != nil {
t.Fatalf("写入配置: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("加载配置: %v", err)
}
if cfg.Log.Level != "warn" || cfg.Log.MaxSizeMB != 5 || cfg.Log.KeepFiles != 3 {
t.Fatalf("log 段解析错误: %+v", cfg.Log)
}
if got := cfg.Log.DirPath("/data/sb"); got != "/var/log/scoreboard" {
t.Fatalf("配了 dir 就该用它,得到 %q", got)
}
}
+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+"接口尚未接入"))
}
}
+151
View File
@@ -0,0 +1,151 @@
package logging
import (
"context"
"io"
"log/slog"
"os"
"path/filepath"
"sync"
)
// 默认值:现场不配 config.yaml 的 log 段也能开箱即用,且不会把盘写满。
const (
// DirOperations 与 DirInterceptions 是两类日志的子目录名。
DirOperations = "operations"
DirInterceptions = "interceptions"
DefaultMaxSizeMB = 20
DefaultKeepFiles = 10
operationsPrefix = "operations"
interceptionPrefix = "interceptions"
)
// Options 是日志装配参数。
type Options struct {
// Dir 是日志根目录,其下分 operations/ 与 interceptions/ 两个子目录。
Dir string
// Level 是放行类(与控制台)的采集级别,来自 config.yaml 的 log.level。
Level slog.Level
// MaxSizeMB 是单个文件的大小上限(MB),<=0 用默认值。
MaxSizeMB int
// KeepFiles 是每个类别保留的份数(含当前文件),<=0 用默认值。
KeepFiles int
// Console 是控制台输出目标,留空用 os.Stdout(保持原有控制台行为)。
Console io.Writer
}
// New 组装日志处理器:控制台保持现状(全量、按 Level 过滤),
// 文件按「放行 / 拦截」两类分流——拦截类固定按 DEBUG 采集,不看 log.level,
// 因为默认级别是 info,跟着过滤就看不到现场要的那份详细拦截日志了。
// 返回的 io.Closer 负责关文件,进程退出前调用。
func New(opts Options) (slog.Handler, io.Closer) {
console := opts.Console
if console == nil {
console = os.Stdout
}
maxBytes := int64(opts.MaxSizeMB) * 1024 * 1024
if maxBytes <= 0 {
maxBytes = int64(DefaultMaxSizeMB) * 1024 * 1024
}
keep := opts.KeepFiles
if keep <= 0 {
keep = DefaultKeepFiles
}
dir := opts.Dir
// 降级告警只发一次:控制台那条链路是现成的,且不会绕回文件处理器造成递归。
consoleLogger := slog.New(slog.NewTextHandler(console, &slog.HandlerOptions{Level: opts.Level}))
var warnOnce sync.Once
warn := func(err error) {
warnOnce.Do(func() {
consoleLogger.Warn("日志文件不可写,已降级为只写控制台", "dir", dir, "err", err)
})
}
ops := newRotator(filepath.Join(dir, DirOperations), operationsPrefix, maxBytes, keep, warn)
inter := newRotator(filepath.Join(dir, DirInterceptions), interceptionPrefix, maxBytes, keep, warn)
// 放行类跟着 log.level 过滤;拦截类固定 Debug 采集,不跟着过滤(现场要的就是那份详细日志)。
return &handler{
console: slog.NewTextHandler(console, &slog.HandlerOptions{Level: opts.Level}),
ops: slog.NewJSONHandler(ops, &slog.HandlerOptions{Level: opts.Level}),
inter: slog.NewJSONHandler(inter, &slog.HandlerOptions{Level: slog.LevelDebug}),
}, closerFunc(func() error { return closerAll(ops, inter) })
}
type handler struct {
console slog.Handler
ops slog.Handler
inter slog.Handler
}
func (h *handler) Enabled(ctx context.Context, level slog.Level) bool {
return h.console.Enabled(ctx, level) || h.ops.Enabled(ctx, level) || h.inter.Enabled(ctx, level)
}
func (h *handler) Handle(ctx context.Context, rec slog.Record) error {
// 控制台照旧:所有类别都打,按配置级别过滤。
if h.console.Enabled(ctx, rec.Level) {
if err := h.console.Handle(ctx, rec); err != nil {
return err
}
}
// 文件按类别分流:拦截类固定 Debug 采集,放行类按配置级别。
if isInterception(rec) {
if h.inter.Enabled(ctx, rec.Level) {
return h.inter.Handle(ctx, rec)
}
return nil
}
if h.ops.Enabled(ctx, rec.Level) {
return h.ops.Handle(ctx, rec)
}
return nil
}
func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &handler{
console: h.console.WithAttrs(attrs),
ops: h.ops.WithAttrs(attrs),
inter: h.inter.WithAttrs(attrs),
}
}
func (h *handler) WithGroup(name string) slog.Handler {
return &handler{
console: h.console.WithGroup(name),
ops: h.ops.WithGroup(name),
inter: h.inter.WithGroup(name),
}
}
// isInterception 判断一条日志是不是「我们主动拦截 / 服务端异常」:
// 拦截类日志带类别标记;ERROR 级别的异常即使没标记也归拦截类,它们是排查用的。
func isInterception(rec slog.Record) bool {
if rec.Level >= slog.LevelError {
return true
}
marked := false
rec.Attrs(func(a slog.Attr) bool {
if a.Key == ChannelKey && a.Value.String() == ChannelInterception {
marked = true
return false
}
return true
})
return marked
}
type closerFunc func() error
func (f closerFunc) Close() error { return f() }
func closerAll(cs ...io.Closer) error {
var first error
for _, c := range cs {
if err := c.Close(); err != nil && first == nil {
first = err
}
}
return first
}
+252
View File
@@ -0,0 +1,252 @@
package logging
import (
"bytes"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// newTestLogging 装配一套写进临时目录的日志,返回 logger 与关闭函数。
func newTestLogging(t *testing.T, dir string, level slog.Level, maxSizeMB, keepFiles int) (*slog.Logger, *bytes.Buffer, func()) {
t.Helper()
console := &bytes.Buffer{}
handler, closer := New(Options{
Dir: dir,
Level: level,
MaxSizeMB: maxSizeMB,
KeepFiles: keepFiles,
Console: console,
})
t.Cleanup(func() { _ = closer.Close() })
return slog.New(handler), console, func() { _ = closer.Close() }
}
// readLog 读某类日志今天的文件内容;文件不存在返回空串。
func readLog(t *testing.T, dir, prefix string) string {
t.Helper()
path := filepath.Join(dir, prefix, 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)
}
// 两类分流:放行类进 operations/,拦截类(WARN/上下文/异常)进 interceptions/,互不串门。
func TestRoutesOperationsAndInterceptions(t *testing.T) {
dir := t.TempDir()
logger, console, closeLogs := newTestLogging(t, dir, slog.LevelInfo, 20, 10)
logger.Info("操作已放行", "action", "team.create", "actor", "recorder#1")
logger.Warn("启动告警") // 没带标记的 WARN 属于常规运行日志
logger.Debug("http", "path", "/api/teams") // 默认 info 级别下不落盘
logger.Warn("请求被拦截", "reason", "无权访问该资源", "origin", "internal/middleware/middleware.go:126 RequireRole",
ChannelKey, ChannelInterception)
logger.Debug("拦截上下文", "code", "FORBIDDEN", "origin", "internal/middleware/middleware.go:126 RequireRole",
ChannelKey, ChannelInterception)
logger.Error("请求失败", "code", "INTERNAL", "origin", "internal/httpapi/handlers_results.go:238 displayCommandHandler")
closeLogs()
ops := readLog(t, dir, DirOperations)
inter := readLog(t, dir, DirInterceptions)
for _, want := range []string{"操作已放行", "启动告警", "team.create"} {
if !strings.Contains(ops, want) {
t.Fatalf("operations 应含 %q,得到:\n%s", want, ops)
}
}
for _, unwanted := range []string{"请求被拦截", "拦截上下文", "请求失败", `"msg":"http"`} {
if strings.Contains(ops, unwanted) {
t.Fatalf("operations 不该含 %q,得到:\n%s", unwanted, ops)
}
}
for _, want := range []string{"请求被拦截", "拦截上下文", "请求失败", "RequireRole", "无权访问该资源"} {
if !strings.Contains(inter, want) {
t.Fatalf("interceptions 应含 %q,得到:\n%s", want, inter)
}
}
for _, unwanted := range []string{"操作已放行", "启动告警", `"msg":"http"`} {
if strings.Contains(inter, unwanted) {
t.Fatalf("interceptions 不该含 %q,得到:\n%s", unwanted, inter)
}
}
// 控制台保持现状:全量按级别输出。
if !strings.Contains(console.String(), "操作已放行") || !strings.Contains(console.String(), "请求被拦截") {
t.Fatalf("控制台应同时看到两类日志,得到:\n%s", console.String())
}
}
// 拦截类固定按 DEBUG 采集:把级别压到 error,拦截的 WARN 与上下文 DEBUG 仍要落盘。
func TestInterceptionsIgnoreConfiguredLevel(t *testing.T) {
dir := t.TempDir()
logger, _, closeLogs := newTestLogging(t, dir, slog.LevelError, 20, 10)
logger.Info("操作已放行", "action", "team.create")
logger.Warn("启动告警")
logger.Warn("请求被拦截", "reason", "请先登录", "origin", "internal/middleware/middleware.go:140 RequireWrite",
ChannelKey, ChannelInterception)
logger.Debug("拦截上下文", "code", "AUTH_REQUIRED", ChannelKey, ChannelInterception)
closeLogs()
ops := readLog(t, dir, DirOperations)
inter := readLog(t, dir, DirInterceptions)
if strings.Contains(ops, "操作已放行") || strings.Contains(ops, "启动告警") {
t.Fatalf("级别为 error 时 operations 不该有 INFO/WARN,得到:\n%s", ops)
}
if !strings.Contains(inter, "请求被拦截") || !strings.Contains(inter, "拦截上下文") {
t.Fatalf("拦截类不受 log.level 影响,应仍有 WARN 与 DEBUG,得到:\n%s", inter)
}
}
// 轮转与保留:超过大小上限续写 .1/.2,超出保留份数的最旧文件被清理。
func TestRotatorRotatesAndPrunes(t *testing.T) {
dir := t.TempDir()
w := newRotator(dir, "operations", 120, 3, nil)
defer func() { _ = w.Close() }()
// 每行约 30 字节,写 12 行 → 至少 3 个文件,保留 3 份后仍不该无限涨。
line := []byte("0123456789012345678901234567\n")
for i := 0; i < 12; i++ {
if _, err := w.Write(line); err != nil {
t.Fatalf("写日志: %v", err)
}
}
files := w.list()
names := make([]string, 0, len(files))
for _, f := range files {
names = append(names, f.name)
}
if len(files) != 3 {
t.Fatalf("应轮转出 3 个文件(保留 3 份),得到 %v", names)
}
day := time.Now().Format("20060102")
want := []string{"operations-" + day + ".log", "operations-" + day + ".1.log", "operations-" + day + ".2.log"}
if strings.Join(names, ",") != strings.Join(want, ",") {
t.Fatalf("文件名应为 %v,得到 %v", want, names)
}
// 每个文件都不超过上限(最后一个是当前写入文件,可能刚好装下最后几行)。
for _, f := range files {
info, err := os.Stat(filepath.Join(dir, f.name))
if err != nil {
t.Fatalf("stat %s: %v", f.name, err)
}
if info.Size() > 120 {
t.Fatalf("%s 超过大小上限:%d 字节", f.name, info.Size())
}
}
}
// 序号按数字排序:.10 比 .2 新,清理时不能把新文件当旧的删掉。
func TestRotatorPruneKeepsNewestByIndex(t *testing.T) {
dir := t.TempDir()
day := time.Now().Format("20060102")
for _, name := range []string{
"operations-" + day + ".2.log",
"operations-" + day + ".10.log",
"operations-" + day + ".1.log",
} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
t.Fatalf("造日志文件: %v", err)
}
}
w := newRotator(dir, "operations", 1024, 2, nil)
w.prune()
left := []string{}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
left = append(left, e.Name())
}
if len(left) != 2 {
t.Fatalf("应保留 2 份,得到 %v", left)
}
// 最旧的是 .1,必须被删掉。
if strings.Contains(strings.Join(left, ","), ".1.log") {
t.Fatalf("最旧的 .1 应被清理,得到 %v", left)
}
}
// 写盘失败只降级、不拖垮服务:控制台仍输出,且告警只发一次。
func TestDegradesToConsoleWhenDirUnwritable(t *testing.T) {
// 用一个普通文件顶替日志目录:MkdirAll 必然失败,且对 root 也成立。
blocked := filepath.Join(t.TempDir(), "logs")
if err := os.WriteFile(blocked, []byte("not a dir"), 0o644); err != nil {
t.Fatalf("造占位文件: %v", err)
}
logger, console, closeLogs := newTestLogging(t, blocked, slog.LevelInfo, 20, 10)
defer closeLogs()
for i := 0; i < 5; i++ {
logger.Warn("请求被拦截", "reason", "无权访问该资源", ChannelKey, ChannelInterception)
}
if got := strings.Count(console.String(), "日志文件不可写,已降级为只写控制台"); got != 1 {
t.Fatalf("降级告警应只发一次,得到 %d 次:\n%s", got, console.String())
}
if got := strings.Count(console.String(), "请求被拦截"); got != 5 {
t.Fatalf("降级后拦截日志仍要打控制台,得到 %d 行:\n%s", got, console.String())
}
// 进程照常:没有任何 panic / 阻塞,logger 调用全部返回。
logger.Info("操作已放行")
}
// 日期变化时开新文件(用可注入的时钟直接调 prepare,避免真等到第二天)。
func TestRotatorStartsNewFileOnNewDay(t *testing.T) {
dir := t.TempDir()
w := newRotator(dir, "operations", 1024, 10, nil)
defer func() { _ = w.Close() }()
if _, err := w.Write([]byte("day1\n")); err != nil {
t.Fatalf("写日志: %v", err)
}
tomorrow := time.Now().Add(24 * time.Hour)
if err := w.prepare(6, tomorrow); err != nil {
t.Fatalf("跨天 prepare: %v", err)
}
if _, err := w.Write([]byte("day2\n")); err != nil {
t.Fatalf("写日志: %v", err)
}
for _, day := range []string{time.Now().Format("20060102"), tomorrow.Format("20060102")} {
path := filepath.Join(dir, "operations-"+day+".log")
if _, err := os.Stat(path); err != nil {
t.Fatalf("跨天后应存在 %s: %v", path, err)
}
}
}
// 拦截点里的函数名要短而准:内联后的闭包名会拖一长串调用链,必须收敛。
func TestOriginFuncNameIsShort(t *testing.T) {
cases := map[string]string{
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware.RequireRole.func1": "RequireRole.func1",
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi.(*server).write.(*server).write.RequireWrite.func1.func4": "RequireWrite.func1",
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi.(*server).createEvent": "(*server).createEvent",
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi.normalizeAvgMode": "normalizeAvgMode",
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi.(*server).importEntriesWith.func1": "(*server).importEntriesWith.func1",
"main.main": "main",
}
for in, want := range cases {
if got := shortFuncName(in); got != want {
t.Errorf("shortFuncName(%q) = %q,期望 %q", in, got, want)
}
}
// 模块内的路径裁成「internal/...」相对路径,不带开发机目录。
if sourceRoot != "" {
if got := FormatOrigin(sourceRoot+"/internal/httpapi/repo.go", 12, "p.f"); got != "internal/httpapi/repo.go:12 f" {
t.Errorf("FormatOrigin 应裁成相对路径,得到 %q", got)
}
}
// 裁不动的路径只留文件名,同样不泄漏绝对路径。
if got := FormatOrigin("/somewhere/else/repo.go", 12, "p.f"); got != "repo.go:12 f" {
t.Errorf("裁不动时应只留文件名,得到 %q", got)
}
}
+96
View File
@@ -0,0 +1,96 @@
// Package logging 负责服务端日志的落盘:按「放行 / 拦截」两类分流到不同目录,
// 各自按天 + 按大小轮转并保留固定份数,写盘失败时降级为只写控制台。
//
// 单独成包是为了让 httpapi 与 middleware 共用同一份「拦截点」捕获与类别标记,
// 又不会互相依赖(两边都只依赖本包)。
package logging
import (
"fmt"
"path/filepath"
"runtime"
"strings"
)
// 类别标记:日志按「是不是我们主动拦截的」分流,而不是只看级别——
// DEBUG 里既有常规访问日志,也有拦截上下文,只按级别分不开。
const (
// ChannelKey 是类别标记的属性名;拦截类日志都带上它。
ChannelKey = "log.channel"
// ChannelInterception 是拦截类的取值。
ChannelInterception = "interception"
)
// sourceRoot 是本包所在模块的根目录(…/server);用它把编译期路径裁成
// 「internal/httpapi/xxx.go」这样的仓库内相对路径,日志里不出现开发机的目录结构。
// 用 -trimpath 构建时 file 本来就是相对路径,裁剪会自然退化成原样。
var sourceRoot = func() string {
_, file, _, ok := runtime.Caller(0)
if !ok {
return ""
}
// 本文件位于 <root>/internal/logging/origin.go,向上三级即 <root>。
return filepath.Dir(filepath.Dir(filepath.Dir(file)))
}()
// relativeSourcePath 把编译期文件路径裁成仓库内相对路径;裁不动时只留文件名。
func relativeSourcePath(file string) string {
if sourceRoot != "" {
if rel := strings.TrimPrefix(file, sourceRoot+string(filepath.Separator)); rel != file {
return rel
}
}
return filepath.Base(file)
}
// shortFuncName 尽量给出「(*server).createEvent」「RequireWrite」这样的短名。
// 编译器会把闭包按内联链写成 (*server).write.(*server).write.RequireWrite.func1.func4,
// 直接取最后一段会得到一串噪音,这里只保留「有意义的那个函数名 + 第一个闭包序号」。
func shortFuncName(full string) string {
if i := strings.LastIndex(full, "/"); i >= 0 {
full = full[i+1:]
}
rest := full
if i := strings.Index(rest, "."); i >= 0 {
rest = rest[i+1:]
}
parts := strings.Split(rest, ".")
if len(parts) == 1 {
return rest
}
// 跳过尾部的闭包序号,落回真正定义它的那个函数。
owner := len(parts) - 1
for owner > 0 && strings.HasPrefix(parts[owner], "func") {
owner--
}
name := parts[owner]
// 闭包序号只留第一个:够区分同一个函数里的多个闭包。
if owner+1 < len(parts) && strings.HasPrefix(parts[owner+1], "func") {
name += "." + parts[owner+1]
}
// 方法带上接收者,「(*server).createEvent」比「createEvent」好找。
if owner > 0 && strings.HasPrefix(parts[owner-1], "(*") {
name = parts[owner-1] + "." + name
}
return name
}
// FormatOrigin 拼出「internal/middleware/middleware.go:123 RequireRole」。
func FormatOrigin(file string, line int, fn string) string {
return fmt.Sprintf("%s:%d %s", relativeSourcePath(file), line, shortFuncName(fn))
}
// Origin 捕获调用点(skip 从本函数自己那层算起),产出「文件:行 函数名」。
// 日志里必须能指出是哪段代码拦下的,否则现场只有一句中文提示,定位不到校验代码。
func Origin(skip int) string {
pc, file, line, ok := runtime.Caller(skip)
if !ok {
return "unknown"
}
fn := "unknown"
if f := runtime.FuncForPC(pc); f != nil {
fn = f.Name()
}
return FormatOrigin(file, line, fn)
}
+206
View File
@@ -0,0 +1,206 @@
package logging
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// rotator 是「按天 + 按大小」轮转的文件写入器,并按份数清理旧文件。
//
// 现场要跑一整天,日志既不能无限涨到把盘写满,也不能因为写不进去就影响业务:
// 一旦写盘失败就永久降级(只通知一次),后续日志由控制台那条链路兜住。
type rotator struct {
dir string
prefix string
maxBytes int64
keep int
// onError 只在第一次写盘失败时回调一次:每条日志都刷告警会把控制台淹掉。
onError func(error)
mu sync.Mutex
file *os.File
size int64
curName string
curDay string
curIdx int
broken bool
}
func newRotator(dir, prefix string, maxBytes int64, keep int, onError func(error)) *rotator {
return &rotator{dir: dir, prefix: prefix, maxBytes: maxBytes, keep: keep, onError: onError}
}
// Write 永不返回错误:日志写不进去是运维问题,不能变成业务请求失败。
func (w *rotator) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.broken {
return len(p), nil
}
if err := w.prepare(len(p), time.Now()); err != nil {
w.degrade(err)
return len(p), nil
}
n, err := w.file.Write(p)
w.size += int64(n)
if err != nil {
w.degrade(err)
}
return len(p), nil
}
// Close 关掉当前文件;进程退出前调用,保证缓冲里的内容不会丢。
func (w *rotator) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
func (w *rotator) degrade(err error) {
w.broken = true
if w.file != nil {
_ = w.file.Close()
w.file = nil
}
if w.onError != nil {
w.onError(err)
}
}
// prepare 保证当前打开的是「今天该写、且还装得下」的那个文件。
func (w *rotator) prepare(need int, now time.Time) error {
day := now.Format("20060102")
if w.file == nil || day != w.curDay {
if err := w.closeLocked(); err != nil {
return err
}
w.curDay = day
w.curIdx = 0
return w.openLocked()
}
// 当前文件已经装了东西且放不下这一条时才轮转:空文件永远装得下,
// 否则超长单条会把每个新文件立刻撑爆、无限轮转。
if w.size > 0 && w.size+int64(need) > w.maxBytes {
if err := w.closeLocked(); err != nil {
return err
}
w.curIdx++
return w.openLocked()
}
return nil
}
func (w *rotator) closeLocked() error {
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
func (w *rotator) openLocked() error {
if err := os.MkdirAll(w.dir, 0o755); err != nil {
return err
}
name := w.fileName(w.curDay, w.curIdx)
f, err := os.OpenFile(filepath.Join(w.dir, name), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return err
}
w.file = f
w.size = info.Size()
w.curName = name
w.prune()
return nil
}
// fileName 是「<类别>-<YYYYMMDD>.log」,同一天的第 N 个续写文件加序号:
// operations-20260911.log / operations-20260911.1.log / .2.log…
func (w *rotator) fileName(day string, idx int) string {
if idx <= 0 {
return fmt.Sprintf("%s-%s.log", w.prefix, day)
}
return fmt.Sprintf("%s-%s.%d.log", w.prefix, day, idx)
}
// prune 清掉超出保留份数的最旧文件;调用方必须持有锁。
// 轮转与开新文件时都会走一遍,保留策略才有执行点(只在启动时清会一直涨)。
func (w *rotator) prune() {
if w.keep <= 0 {
return
}
files := w.list()
if len(files) <= w.keep {
return
}
for _, f := range files[:len(files)-w.keep] {
if f.name == w.curName {
continue
}
_ = os.Remove(filepath.Join(w.dir, f.name))
}
}
type logFile struct {
name string
day string
index int
}
// list 列出本类别的日志文件,按「日期 + 序号」从旧到新排序。
// 只认自己写出来的命名,别人的文件一律不碰。
func (w *rotator) list() []logFile {
entries, err := os.ReadDir(w.dir)
if err != nil {
return nil
}
prefix := w.prefix + "-"
out := make([]logFile, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".log") {
continue
}
rest := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".log")
day, idxText, _ := strings.Cut(rest, ".")
if len(day) != 8 {
continue
}
idx := 0
if idxText != "" {
n, err := strconv.Atoi(idxText)
if err != nil {
continue
}
idx = n
}
out = append(out, logFile{name: name, day: day, index: idx})
}
sort.Slice(out, func(i, j int) bool {
if out[i].day != out[j].day {
return out[i].day < out[j].day
}
return out[i].index < out[j].index
})
return out
}
+23
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net/http"
@@ -88,7 +89,29 @@ func (a *Auditor) flush(ctx context.Context, r *http.Request, entries []AuditEnt
if err != nil {
// 审计失败不能吞掉业务结果,但必须留下痕迹。
a.log.Error("写入审计失败", "err", err, "action", e.Action, "requestId", requestID)
continue
}
// 落库成功即「这次操作被放行」:默认 info 级别下这是唯一的放行痕迹,
// 与拦截的 WARN 成对,现场才能对上「谁在什么时候做了什么」。
a.log.Info("操作已放行",
"action", e.Action,
"entity", e.EntityType,
"entityId", e.EntityID,
"actor", auditActorLabel(u, e.ActorID),
"requestId", requestID)
}
}
// auditActorLabel 是放行日志里的操作人:登录请求的身份来自条目自带的 ActorID,
// 其余来自会话;两边都拿不到就是 guest。记「用户名#id」比裸 id 好认。
func auditActorLabel(u *auth.User, actorID int64) string {
switch {
case actorID != 0:
return fmt.Sprintf("user#%d", actorID)
case u != nil && u.ID != 0:
return fmt.Sprintf("%s#%d", u.Username, u.ID)
default:
return "guest"
}
}
+53 -12
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
@@ -16,6 +17,7 @@ import (
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
)
// HeaderRequestID 是客户端可复用的幂等/追踪请求头。
@@ -123,11 +125,12 @@ func Authenticate(svc *auth.Service, logger *slog.Logger) func(http.Handler) htt
// RequireRole 要求至少 need 权限;未登录或权限不足一律 403。
// 用于「guest 也不该看」的只读接口(系统配置),以便与未登录写操作区分。
func RequireRole(need auth.Role) func(http.Handler) http.Handler {
func RequireRole(logger *slog.Logger, need auth.Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !User(r.Context()).Role.Allows(need) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "无权访问该资源", map[string]any{"required": need.String()})
intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "无权访问该资源",
map[string]any{"required": need.String()})
return
}
next.ServeHTTP(w, r)
@@ -136,16 +139,17 @@ func RequireRole(need auth.Role) func(http.Handler) http.Handler {
}
// RequireWrite 要求至少 need 权限的写操作;guest 返回 401(需登录),角色不足返回 403。
func RequireWrite(need auth.Role) func(http.Handler) http.Handler {
func RequireWrite(logger *slog.Logger, need auth.Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := User(r.Context())
if u.Role == auth.RoleGuest {
writeError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "请先登录", nil)
intercept(logger, w, r, http.StatusUnauthorized, "AUTH_REQUIRED", "请先登录", nil)
return
}
if !u.Role.Allows(need) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "无权执行该操作", map[string]any{"required": need.String()})
intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "无权执行该操作",
map[string]any{"required": need.String()})
return
}
next.ServeHTTP(w, r)
@@ -155,7 +159,7 @@ func RequireWrite(need auth.Role) func(http.Handler) http.Handler {
// MustChangePassword 阻止「必须修改密码」的账号执行写操作。
// 只放行改密与登出:否则用户会被永久锁在无法改密的死循环里(见 devdocs/08 第 1 节)。
func MustChangePassword(next http.Handler) http.Handler {
func MustChangePassword(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := User(r.Context())
if u.Role == auth.RoleGuest || !u.MustChangePassword {
@@ -172,12 +176,12 @@ func MustChangePassword(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
writeError(w, http.StatusForbidden, "AUTH_REQUIRED", "请先修改初始密码", nil)
intercept(logger, w, r, http.StatusForbidden, "AUTH_REQUIRED", "请先修改初始密码", nil)
})
}
// CSRF 校验双提交 Cookie:已登录的写请求必须带 X-CSRF-Token 且与 csrf_token Cookie 一致。
func CSRF(next http.Handler) http.Handler {
func CSRF(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
@@ -187,7 +191,7 @@ func CSRF(next http.Handler) http.Handler {
// 登录是 guest 请求,拿不到 CSRF Token;跨站表单仍能把受害者登进攻击者的账号,
// 因此对带 Origin/Referer 的登录请求单独做同源校验。
if r.Method == http.MethodPost && r.URL.Path == "/api/auth/login" && !sameOrigin(r) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "登录请求来源校验失败", nil)
intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "登录请求来源校验失败,请刷新页面重试", nil)
return
}
if User(r.Context()).Role == auth.RoleGuest {
@@ -200,7 +204,7 @@ func CSRF(next http.Handler) http.Handler {
cookie = c.Value
}
if !auth.ValidCSRF(r.Header.Get("X-CSRF-Token"), cookie) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "CSRF 校验失败", nil)
intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "CSRF 校验失败,请刷新页面重试", nil)
return
}
next.ServeHTTP(w, r)
@@ -225,7 +229,7 @@ func sameOrigin(r *http.Request) bool {
}
// RateLimitWrite 对已登录用户的写操作限流(默认 30 次/分钟/用户)。
func RateLimitWrite(svc *auth.Service) func(http.Handler) http.Handler {
func RateLimitWrite(svc *auth.Service, logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
@@ -235,7 +239,7 @@ func RateLimitWrite(svc *auth.Service) func(http.Handler) http.Handler {
}
u := User(r.Context())
if u.Role != auth.RoleGuest && !svc.AllowWrite(u.ID) {
writeError(w, http.StatusTooManyRequests, "RATE_LIMITED", "操作过于频繁,请稍后再试", nil)
intercept(logger, w, r, http.StatusTooManyRequests, "RATE_LIMITED", "操作过于频繁,请稍后再试", nil)
return
}
next.ServeHTTP(w, r)
@@ -283,6 +287,43 @@ func (w *statusWriter) Flush() {
}
// writeError 输出统一错误结构;中间件不依赖 httpapi,避免包循环。
// intercept 记录一次中间件层的拦截,再写出统一响应。
// 这几个中间件都在 handler 之前就返回响应,不经 httpapi 的 writeAPIError——
// 不在这里记的话,这些拦截完全没有痕迹。收成一个函数是为了「只要写了响应就一定有日志」。
// 与 httpapi 的拦截日志同一形状:WARN 给原因与拦截点,DEBUG 补上下文,两类都进 interceptions/。
func intercept(logger *slog.Logger, w http.ResponseWriter, r *http.Request, status int, code, message string, detail any) {
if logger == nil {
logger = slog.Default()
}
u := User(r.Context())
actor := actorLabel(u)
role := u.Role.String()
reqID := GetRequestID(r.Context())
// skip=2:0 是本包 Origin 自己的帧,1 是 intercept,2 才是做校验的那个中间件函数。
origin := logging.Origin(2)
logger.Warn("请求被拦截",
"reason", message, "origin", origin, "code", code,
"method", r.Method, "path", r.URL.Path,
"actor", actor, "role", role, "requestId", reqID, "ip", ClientIP(r),
logging.ChannelKey, logging.ChannelInterception)
logger.Debug("拦截上下文",
"code", code, "detail", detail, "origin", origin,
"method", r.Method, "path", r.URL.Path, "ip", ClientIP(r),
"actor", actor, "role", role, "requestId", reqID,
logging.ChannelKey, logging.ChannelInterception)
writeError(w, status, code, message, detail)
}
// 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)
}
func writeError(w http.ResponseWriter, status int, code, message string, detail any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
@@ -51,7 +51,7 @@ func TestRequestIDKeepsNormalHeader(t *testing.T) {
// 登录是 guest 请求,CSRF Token 校验被跳过,靠同源校验挡住 login CSRF(P2-6)。
func TestCSRFBlocksCrossOriginLogin(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
h := CSRF(next)
h := CSRF(nil, next)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
req.Host = "scoreboard.local"