Files
Scoreboard/server/internal/logging/logging.go
T
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

152 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}