Files
AskaEth e5e0944d26 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 节「服务端日志与拦截复盘」)。
2026-09-11 01:59:58 +08:00

253 lines
9.9 KiB
Go

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)
}
}