重构日志与可观测性体系
新增单行文本编码器与结构化 GORM 日志,统一错误记录与请求日志策略,收紧日志文件权限并修复按天切分与压缩,支付回调参数脱敏,生产强制阿里云短信,RequestID 校验防注入,日志文案中文化。
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
package logging
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type requestIDContextKey struct{}
|
||||
type adminIDContextKey struct{}
|
||||
@@ -41,3 +45,19 @@ func AdminIDFromContext(ctx context.Context) uint64 {
|
||||
adminID, _ := ctx.Value(adminIDContextKey{}).(uint64)
|
||||
return adminID
|
||||
}
|
||||
|
||||
// FromContext 返回自动携带请求和管理员上下文的日志器。
|
||||
func FromContext(ctx context.Context) *zap.Logger {
|
||||
logger := zap.L()
|
||||
fields := make([]zap.Field, 0, 2)
|
||||
if requestID := RequestIDFromContext(ctx); requestID != "" {
|
||||
fields = append(fields, zap.String("request_id", requestID))
|
||||
}
|
||||
if adminID := AdminIDFromContext(ctx); adminID != 0 {
|
||||
fields = append(fields, zap.Uint64("admin_id", adminID))
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return logger
|
||||
}
|
||||
return logger.With(fields...)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package logging
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -21,6 +22,12 @@ type dailyWriter struct {
|
||||
file *os.File
|
||||
}
|
||||
|
||||
func (w *dailyWriter) Open() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.rotateIfNeeded(time.Now().In(w.location))
|
||||
}
|
||||
|
||||
func newDailyWriter(dir string, prefix string, location *time.Location, retainDays int) *dailyWriter {
|
||||
if retainDays < 0 {
|
||||
retainDays = 0
|
||||
@@ -59,51 +66,79 @@ func (w *dailyWriter) rotateIfNeeded(now time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(w.dir, 0o755); err != nil {
|
||||
if err := os.MkdirAll(w.dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(w.dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prevDay := w.day
|
||||
if w.file != nil {
|
||||
_ = w.file.Close()
|
||||
if err := w.file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
w.file = nil
|
||||
}
|
||||
|
||||
path := filepath.Join(w.dir, fmt.Sprintf("%s-%s.log", w.prefix, day))
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.Chmod(0o600); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
w.file = file
|
||||
w.day = day
|
||||
|
||||
// 异步压缩昨日日志并清理过期文件,避免阻塞业务写路径
|
||||
if prevDay != "" && prevDay != day {
|
||||
go w.maintain(prevDay, now)
|
||||
} else {
|
||||
// 进程启动或首写:尝试压缩昨天 + 清理过期
|
||||
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
|
||||
go w.maintain(yesterday, now)
|
||||
}
|
||||
// 异步补压缩全部历史日志并清理过期文件,避免漏掉停机期间的日期。
|
||||
go w.maintain(now)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *dailyWriter) maintain(prevDay string, now time.Time) {
|
||||
if prevDay != "" {
|
||||
_ = w.compressDay(prevDay)
|
||||
func (w *dailyWriter) maintain(now time.Time) {
|
||||
if err := w.compressHistorical(now); err != nil {
|
||||
writeMaintenanceError(err)
|
||||
}
|
||||
if w.retainDays > 0 {
|
||||
_ = w.purgeOlderThan(now)
|
||||
if err := w.purgeOlderThan(now); err != nil {
|
||||
writeMaintenanceError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *dailyWriter) compressHistorical(now time.Time) error {
|
||||
entries, err := os.ReadDir(w.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
today := now.In(w.location).Format("2006-01-02")
|
||||
for _, entry := range entries {
|
||||
day, ok := w.dayFromFilename(entry.Name(), ".log")
|
||||
if !ok || day >= today {
|
||||
continue
|
||||
}
|
||||
if err := w.compressDay(day); err != nil {
|
||||
return fmt.Errorf("压缩 %s 日志失败: %w", day, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *dailyWriter) compressDay(day string) error {
|
||||
srcPath := filepath.Join(w.dir, fmt.Sprintf("%s-%s.log", w.prefix, day))
|
||||
dstPath := srcPath + ".gz"
|
||||
if _, err := os.Stat(dstPath); err == nil {
|
||||
// 已压缩则删除明文(若仍存在)
|
||||
_ = os.Remove(srcPath)
|
||||
return nil
|
||||
if err := validateGzip(dstPath); err == nil {
|
||||
if err := os.Remove(srcPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
src, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
@@ -114,32 +149,58 @@ func (w *dailyWriter) compressDay(day string) error {
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
dst, err := os.CreateTemp(w.dir, "."+filepath.Base(dstPath)+".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := dst.Name()
|
||||
defer func() { _ = os.Remove(tmpPath) }()
|
||||
if err := dst.Chmod(0o600); err != nil {
|
||||
_ = dst.Close()
|
||||
return err
|
||||
}
|
||||
gz := gzip.NewWriter(dst)
|
||||
if _, err := io.Copy(gz, src); err != nil {
|
||||
_ = gz.Close()
|
||||
_ = dst.Close()
|
||||
_ = os.Remove(dstPath)
|
||||
return err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
_ = dst.Close()
|
||||
_ = os.Remove(dstPath)
|
||||
return err
|
||||
}
|
||||
if err := dst.Sync(); err != nil {
|
||||
_ = dst.Close()
|
||||
return err
|
||||
}
|
||||
if err := dst.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(srcPath)
|
||||
}
|
||||
|
||||
func validateGzip(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
reader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(io.Discard, reader)
|
||||
closeErr := reader.Close()
|
||||
return errors.Join(copyErr, closeErr)
|
||||
}
|
||||
|
||||
func (w *dailyWriter) purgeOlderThan(now time.Time) error {
|
||||
// 按「日历天」比较:保留最近 retainDays 天(含当天),更早的 .log / .log.gz 删除。
|
||||
today := time.Date(now.In(w.location).Year(), now.In(w.location).Month(), now.In(w.location).Day(), 0, 0, 0, 0, w.location)
|
||||
cutoff := today.AddDate(0, 0, -w.retainDays) // day < cutoff 才删除;retainDays=2 且今天 12 号 → 删除 10 号之前
|
||||
cutoff := today.AddDate(0, 0, -(w.retainDays - 1))
|
||||
entries, err := os.ReadDir(w.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -150,10 +211,10 @@ func (w *dailyWriter) purgeOlderThan(now time.Time) error {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
if !strings.HasPrefix(name, prefix) ||
|
||||
(!strings.HasSuffix(name, ".log") && !strings.HasSuffix(name, ".log.gz")) {
|
||||
continue
|
||||
}
|
||||
// app-2026-07-11.log 或 app-2026-07-11.log.gz
|
||||
rest := strings.TrimPrefix(name, prefix)
|
||||
rest = strings.TrimSuffix(rest, ".gz")
|
||||
rest = strings.TrimSuffix(rest, ".log")
|
||||
@@ -162,8 +223,26 @@ func (w *dailyWriter) purgeOlderThan(now time.Time) error {
|
||||
continue
|
||||
}
|
||||
if day.Before(cutoff) {
|
||||
_ = os.Remove(filepath.Join(w.dir, name))
|
||||
if err := os.Remove(filepath.Join(w.dir, name)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *dailyWriter) dayFromFilename(name string, suffix string) (string, bool) {
|
||||
prefix := w.prefix + "-"
|
||||
if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, suffix) {
|
||||
return "", false
|
||||
}
|
||||
day := strings.TrimSuffix(strings.TrimPrefix(name, prefix), suffix)
|
||||
if _, err := time.ParseInLocation("2006-01-02", day, w.location); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return day, true
|
||||
}
|
||||
|
||||
func writeMaintenanceError(err error) {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%s | ERROR | 日志维护失败 | error=%q\n", time.Now().Format("2006-01-02 15:04:05.000"), err.Error())
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestCompressDayAndPurge(t *testing.T) {
|
||||
t.Fatalf("gzip missing: %v", err)
|
||||
}
|
||||
|
||||
// 以 7-12 为「现在」,保留 2 天 → cutoff=7-10,删除 7-10 之前(仅 7-01)
|
||||
// 以 7-12 为「现在」,保留 2 天(7-11、7-12),删除 7-11 之前。
|
||||
now := time.Date(2026, 7, 12, 12, 0, 0, 0, loc)
|
||||
if err := w.purgeOlderThan(now); err != nil {
|
||||
t.Fatalf("purgeOlderThan: %v", err)
|
||||
@@ -40,15 +40,38 @@ func TestCompressDayAndPurge(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-01.log")); !os.IsNotExist(err) {
|
||||
t.Fatal("expired log should be purged")
|
||||
}
|
||||
// 7-10 / 7-11 仍应存在(cutoff 当天起保留)
|
||||
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-10.log.gz")); err != nil {
|
||||
t.Fatalf("recent gzip should remain: %v", err)
|
||||
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-10.log.gz")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expired gzip should be purged, err=%v", err)
|
||||
}
|
||||
// 7-11 仍应存在(cutoff 当天起保留)
|
||||
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-11.log")); err != nil {
|
||||
t.Fatalf("recent plain should remain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompressDayReplacesCorruptedGzip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := newDailyWriter(dir, "app", time.UTC, 0)
|
||||
sourcePath := filepath.Join(dir, "app-2026-07-11.log")
|
||||
gzipPath := sourcePath + ".gz"
|
||||
if err := os.WriteFile(sourcePath, []byte("important log\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(gzipPath, []byte("broken"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := w.compressDay("2026-07-11"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateGzip(gzipPath); err != nil {
|
||||
t.Fatalf("gzip should be valid: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(sourcePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("source should be removed after successful compression, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompressDayIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
loc := time.UTC
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -18,47 +19,41 @@ func New(cfg config.LogConfig) (*zap.Logger, error) {
|
||||
}
|
||||
|
||||
level := zap.NewAtomicLevelAt(parseLevel(cfg.Level))
|
||||
encoderConfig := zap.NewProductionEncoderConfig()
|
||||
encoderConfig.TimeKey = "time"
|
||||
encoderConfig.LevelKey = "level"
|
||||
encoderConfig.MessageKey = "message"
|
||||
encoderConfig.CallerKey = "caller"
|
||||
encoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
|
||||
enc.AppendString(t.In(location).Format("2006-01-02 15:04:05.000 -0700"))
|
||||
}
|
||||
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
|
||||
encoderConfig.EncodeDuration = zapcore.StringDurationEncoder
|
||||
encoderConfig.EncodeCaller = zapcore.ShortCallerEncoder
|
||||
|
||||
cores := make([]zapcore.Core, 0, 2)
|
||||
if cfg.EnableConsole {
|
||||
cores = append(cores, zapcore.NewCore(
|
||||
zapcore.NewConsoleEncoder(encoderConfig),
|
||||
newTextEncoder(location),
|
||||
zapcore.Lock(os.Stdout),
|
||||
level,
|
||||
))
|
||||
}
|
||||
if cfg.EnableFile {
|
||||
writer := newDailyWriter(cfg.Dir, "app", location, cfg.RetainDays)
|
||||
if err := writer.Open(); err != nil {
|
||||
return nil, fmt.Errorf("初始化日志文件失败: %w", err)
|
||||
}
|
||||
cores = append(cores, zapcore.NewCore(
|
||||
zapcore.NewJSONEncoder(encoderConfig),
|
||||
newTextEncoder(location),
|
||||
writer,
|
||||
level,
|
||||
))
|
||||
}
|
||||
if len(cores) == 0 {
|
||||
cores = append(cores, zapcore.NewCore(
|
||||
zapcore.NewConsoleEncoder(encoderConfig),
|
||||
newTextEncoder(location),
|
||||
zapcore.Lock(os.Stdout),
|
||||
level,
|
||||
))
|
||||
}
|
||||
|
||||
return zap.New(
|
||||
logger := zap.New(
|
||||
zapcore.NewTee(cores...),
|
||||
zap.AddCaller(),
|
||||
zap.AddStacktrace(zapcore.PanicLevel),
|
||||
), nil
|
||||
zap.ErrorOutput(zapcore.Lock(os.Stderr)),
|
||||
)
|
||||
zap.ReplaceGlobals(logger)
|
||||
return logger, nil
|
||||
}
|
||||
|
||||
func parseLevel(value string) zapcore.Level {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap/buffer"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
var textBufferPool = buffer.NewPool()
|
||||
|
||||
// textEncoder 把结构化字段输出为便于直接阅读和 grep 的单行文本。
|
||||
type textEncoder struct {
|
||||
*zapcore.MapObjectEncoder
|
||||
location *time.Location
|
||||
}
|
||||
|
||||
func newTextEncoder(location *time.Location) zapcore.Encoder {
|
||||
return &textEncoder{
|
||||
MapObjectEncoder: zapcore.NewMapObjectEncoder(),
|
||||
location: location,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *textEncoder) Clone() zapcore.Encoder {
|
||||
cloned := &textEncoder{
|
||||
MapObjectEncoder: zapcore.NewMapObjectEncoder(),
|
||||
location: e.location,
|
||||
}
|
||||
for key, value := range e.Fields {
|
||||
cloned.Fields[key] = value
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func (e *textEncoder) EncodeEntry(entry zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) {
|
||||
ctx := e.Clone().(*textEncoder)
|
||||
for i := range fields {
|
||||
fields[i].AddTo(ctx)
|
||||
}
|
||||
|
||||
buf := textBufferPool.Get()
|
||||
buf.AppendString(entry.Time.In(e.location).Format("2006-01-02 15:04:05.000"))
|
||||
buf.AppendString(" | ")
|
||||
buf.AppendString(fmt.Sprintf("%-5s", strings.ToUpper(entry.Level.String())))
|
||||
buf.AppendString(" | ")
|
||||
buf.AppendString(cleanText(entry.Message))
|
||||
|
||||
if entry.Level >= zapcore.WarnLevel && entry.Caller.Defined {
|
||||
buf.AppendString(" | caller=")
|
||||
buf.AppendString(entry.Caller.TrimmedPath())
|
||||
}
|
||||
|
||||
keys := orderedKeys(ctx.Fields)
|
||||
for _, key := range keys {
|
||||
value := ctx.Fields[key]
|
||||
if isEmptyValue(value) {
|
||||
continue
|
||||
}
|
||||
buf.AppendString(" | ")
|
||||
buf.AppendString(key)
|
||||
buf.AppendByte('=')
|
||||
buf.AppendString(formatValue(value))
|
||||
}
|
||||
if entry.Stack != "" {
|
||||
buf.AppendString(" | stack=")
|
||||
buf.AppendString(strconv.Quote(entry.Stack))
|
||||
}
|
||||
buf.AppendByte('\n')
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func orderedKeys(fields map[string]any) []string {
|
||||
priority := []string{
|
||||
"module", "request_id", "method", "route", "path", "status", "code",
|
||||
"duration_ms", "rows", "user_id", "admin_id", "client_ip", "error",
|
||||
}
|
||||
keys := make([]string, 0, len(fields))
|
||||
seen := make(map[string]bool, len(fields))
|
||||
for _, key := range priority {
|
||||
if _, ok := fields[key]; ok {
|
||||
keys = append(keys, key)
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
rest := make([]string, 0, len(fields)-len(keys))
|
||||
for key := range fields {
|
||||
if !seen[key] {
|
||||
rest = append(rest, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(rest)
|
||||
return append(keys, rest...)
|
||||
}
|
||||
|
||||
func formatValue(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if typed != "" && !strings.ContainsAny(typed, " \t\r\n|=\"") {
|
||||
return typed
|
||||
}
|
||||
return strconv.Quote(typed)
|
||||
case []byte:
|
||||
return strconv.Quote(string(typed))
|
||||
case time.Time:
|
||||
return typed.Format(time.RFC3339)
|
||||
case time.Duration:
|
||||
return typed.String()
|
||||
default:
|
||||
return cleanText(fmt.Sprint(typed))
|
||||
}
|
||||
}
|
||||
|
||||
func cleanText(value string) string {
|
||||
return strings.NewReplacer("\r", `\r`, "\n", `\n`, "\t", `\t`).Replace(value)
|
||||
}
|
||||
|
||||
func isEmptyValue(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
text, ok := value.(string)
|
||||
return ok && strings.TrimSpace(text) == ""
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
func TestTextEncoderProducesReadableSingleLine(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
core := zapcore.NewCore(newTextEncoder(time.UTC), zapcore.AddSync(&output), zapcore.DebugLevel)
|
||||
logger := zap.New(core)
|
||||
logger.Info("服务启动",
|
||||
zap.String("request_id", "req-1"),
|
||||
zap.String("empty", ""),
|
||||
zap.String("detail", "line1\nline2"),
|
||||
)
|
||||
|
||||
got := output.String()
|
||||
if strings.ContainsAny(got, "{}") {
|
||||
t.Fatalf("output should not use JSON object syntax: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "INFO | 服务启动 | request_id=req-1") {
|
||||
t.Fatalf("unexpected text output: %s", got)
|
||||
}
|
||||
if strings.Contains(got, "empty=") {
|
||||
t.Fatalf("empty fields should be omitted: %s", got)
|
||||
}
|
||||
if strings.Count(got, "\n") != 1 || !strings.Contains(got, `detail="line1\nline2"`) {
|
||||
t.Fatalf("output should remain one physical line: %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user