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