// Package logger 提供统一的日志初始化功能 // // 使用示例: // // logger.InitLog(logrus.DebugLevel, true) // logger.SetProjectPrefix("D:/Projects/MyProject/") // logrus.Debug("调试信息") // logrus.Infof("信息: %s", "test") package logger import ( "archive/zip" "io" "os" "path/filepath" "runtime" "strconv" "strings" "sync" "time" "github.com/lestrrat-go/file-rotatelogs" "github.com/mattn/go-colorable" "github.com/sirupsen/logrus" ) var ( // 项目根目录前缀,用于简化日志中的调用文件路径 // 可以通过 SetProjectPrefix 自定义设置 projectPrefix string projectPrefixOnce sync.Once ) // SetProjectPrefix 设置项目路径前缀,用于简化日志中的文件路径显示 // 例如:SetProjectPrefix("D:/Projects/ZoSmartCybercafe/service-server-new/") func SetProjectPrefix(prefix string) { projectPrefix = prefix } // getProjectPrefix 获取项目路径前缀(懒加载) func getProjectPrefix() string { projectPrefixOnce.Do(func() { // 默认前缀,如果没有通过 SetProjectPrefix 设置,则使用空字符串 if projectPrefix == "" { projectPrefix = "" } }) return projectPrefix } // fileLogHook 写入按天轮转的日志文件的 Hook type fileLogHook struct { writer io.Writer formatter logrus.Formatter levels []logrus.Level // 此 Hook 生效的日志级别 } // NewFileLogHook 创建按天轮转的日志 Hook // 参数: // - logDir: 日志目录 // - filename: 日志文件名模板(如 "app-%Y-%m-%d.log") // - levels: 此 Hook 生效的日志级别,如果为空则对所有级别生效 func NewFileLogHook(logDir string, filename string, levels []logrus.Level) (*fileLogHook, error) { // 创建日志目录 if err := os.MkdirAll(logDir, 0755); err != nil { return nil, err } // 创建按天轮转的日志文件 logPath := filepath.Join(logDir, filename) rl, err := rotatelogs.New( logPath, rotatelogs.WithMaxAge(-1), // 不自动删除旧日志 rotatelogs.WithRotationTime(24*time.Hour), // 24小时轮转一次 ) if err != nil { return nil, err } // 文件日志使用的 formatter(不带颜色) formatter := &logrus.TextFormatter{ TimestampFormat: "2006-01-02 15:04:05", FullTimestamp: true, DisableColors: true, // 文件中不使用颜色 DisableLevelTruncation: true, // 不缩略 INFO -> INF DisableSorting: true, CallerPrettyfier: func(f *runtime.Frame) (string, string) { file := f.File prefix := getProjectPrefix() if prefix != "" && strings.HasPrefix(file, prefix) { file = file[len(prefix):] } // 返回 "文件:行号",file= 字段内不要前导空格 return "", file + ":" + strconv.Itoa(f.Line) }, } // 如果没有指定级别,则对所有级别生效 if len(levels) == 0 { levels = logrus.AllLevels } return &fileLogHook{ writer: rl, formatter: formatter, levels: levels, }, nil } // Fire 每次日志记录时调用,写入到文件 func (hook *fileLogHook) Fire(entry *logrus.Entry) error { line, err := hook.formatter.Format(entry) if err != nil { return err } _, err = hook.writer.Write(line) return err } // Levels 返回此 Hook 生效的日志级别 func (hook *fileLogHook) Levels() []logrus.Level { return hook.levels } // InitLog 初始化 logrus 日志配置 // 参数: // - level: 日志级别,例如 logrus.DebugLevel, logrus.InfoLevel // - reportCaller: 是否显示调用者信息(文件名和行号) // - logDir: 日志文件目录,如果为空则不输出到文件 // // 注意:初始化后,在其他文件中直接使用 logrus.Debug、logrus.Info 等函数, // 不要通过 logger.Debug 等方式调用,否则会导致调用位置信息不准确。 // // 日志文件: // - app-YYYY-MM-DD.log: 所有级别的日志 // - error-YYYY-MM-DD.log: 只记录 Error 及以上级别的日志 func InitLog(level logrus.Level, reportCaller bool, logDir ...string) { logrus.SetOutput(colorable.NewColorableStdout()) logrus.SetLevel(level) logrus.SetReportCaller(reportCaller) logrus.SetFormatter(&logrus.TextFormatter{ TimestampFormat: "2006-01-02 15:04:05", FullTimestamp: true, ForceColors: true, // 强制输出颜色,配合 colorable 在 Windows 上显示 DisableLevelTruncation: true, // 不缩略 INFO -> INF DisableSorting: true, CallerPrettyfier: func(f *runtime.Frame) (string, string) { file := f.File prefix := getProjectPrefix() if prefix != "" && strings.HasPrefix(file, prefix) { file = file[len(prefix):] } // 返回 "文件:行号",file= 字段内不要前导空格 return "", " " + file + ":" + strconv.Itoa(f.Line) }, }) // 如果指定了日志目录,则添加文件输出 if len(logDir) > 0 && logDir[0] != "" { // 创建主日志 Hook(记录所有级别) mainHook, err := NewFileLogHook(logDir[0], "app-%Y-%m-%d.log", logrus.AllLevels) if err == nil { logrus.AddHook(mainHook) } else { logrus.Warnf("创建主日志文件失败: %v", err) } // 创建错误日志 Hook(只记录 Error 及以上级别) errorHook, err := NewFileLogHook(logDir[0], "error-%Y-%m-%d.log", []logrus.Level{ logrus.ErrorLevel, logrus.FatalLevel, logrus.PanicLevel, }) if err == nil { logrus.AddHook(errorHook) } else { logrus.Warnf("创建错误日志文件失败: %v", err) } // 启动后台历史日志压缩协程(每日 0:00-0:30 为不压缩窗口) StartLogCompressor(logDir[0], time.Hour) } } // 历史日志压缩相关常量 const ( // compressSkipStartHour / compressSkipEndMin 定义每日不压缩窗口:0:00 - 0:30 // 该窗口通常与日志轮转时间重合,期间不执行压缩,避免与正在写入/轮转的日志冲突 compressSkipStartHour = 0 compressSkipEndMin = 30 ) // inCompressSkipWindow 判断当前时间是否处于每日不压缩窗口(0:00 - 0:30) func inCompressSkipWindow(now time.Time) bool { h, m, _ := now.Clock() return h == compressSkipStartHour && m < compressSkipEndMin } // StartLogCompressor 在后台启动历史日志压缩协程,按 interval 周期扫描并压缩历史日志。 // 每日 0:00-0:30 为不压缩窗口,期间直接跳过。 // 参数: // - logDir: 日志目录 // - interval: 扫描周期,<=0 时默认 1 小时 func StartLogCompressor(logDir string, interval time.Duration) { if logDir == "" { return } if interval <= 0 { interval = time.Hour } go func() { // 启动时先执行一次,立即清理已有历史日志 _ = CompressHistoryLogs(logDir) ticker := time.NewTicker(interval) defer ticker.Stop() for range ticker.C { _ = CompressHistoryLogs(logDir) } }() } // CompressHistoryLogs 将历史日志(非当天、且尚未压缩的 .log 文件)逐个压缩为同名的 .zip 文件, // 压缩成功后删除原始日志文件以完成清理。每日 0:00-0:30 为不压缩窗口,期间直接跳过。 // 参数 logDir 为日志目录。 func CompressHistoryLogs(logDir string) error { // 不压缩窗口内直接跳过 if inCompressSkipWindow(time.Now()) { return nil } entries, err := os.ReadDir(logDir) if err != nil { return err } // 当天的日期串,用于跳过正在写入的当天日志 today := time.Now().Format("2006-01-02") for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() // 仅处理 .log 文件 if !strings.HasSuffix(name, ".log") { continue } // 跳过当天的日志(正在写入,不压缩) if strings.Contains(name, today) { continue } logPath := filepath.Join(logDir, name) // 文件名不变,仅追加 .zip 后缀(如 app-2026-07-16.log -> app-2026-07-16.log.zip) zipPath := logPath + ".zip" // 若已存在同名 zip,说明此前已压缩成功,直接清理原日志避免重复压缩 if _, err := os.Stat(zipPath); err == nil { if rmErr := os.Remove(logPath); rmErr != nil { logrus.Warnf("清理已压缩的历史日志失败 %s: %v", name, rmErr) } continue } if err := zipSingleFile(logPath, zipPath); err != nil { logrus.Warnf("压缩历史日志失败 %s: %v", name, err) continue } // 压缩成功后清理原始日志文件 if err := os.Remove(logPath); err != nil { logrus.Warnf("删除历史日志失败 %s: %v", name, err) } else { logrus.Infof("历史日志已压缩并清理: %s -> %s", name, filepath.Base(zipPath)) } } return nil } // zipSingleFile 将单个文件 src 压缩写入 dst(zip 包内仅包含原始文件名,文件名保持不变) func zipSingleFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() zw := zip.NewWriter(out) w, err := zw.Create(filepath.Base(src)) if err != nil { _ = zw.Close() return err } if _, err := io.Copy(w, in); err != nil { _ = zw.Close() return err } return zw.Close() }