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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user