init: 通用库抽离——httpx/logger/wsc/wscclient/jwtx(合并分叉副本); 顶层包布局; module path git.zeroonesoft.cn/golib/zogo

This commit is contained in:
w11
2026-09-19 19:04:01 +08:00
commit f0f5421262
17 changed files with 2336 additions and 0 deletions
+308
View File
@@ -0,0 +1,308 @@
// 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()
}