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:
1 parent
8e707e1e9b
commit
e5e0944d26
32 files changed
+2017
-138
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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+"接口尚未接入"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user