dcfd24b624
v1.0.3 定位为 bug fix release,收口 v1.0.2 引入的破坏性清理并修复 4 个轻量 bug + 依赖复查 + 版本号治理 + 文档对齐。同时包含此前未提交 的 v1.0.2 全部工作。 v1.0.3 变更: Fixed: - generateJTI 忽略 rand.Read 错误(jwt)— 改为 (string, error) 并传播 - QueryBuilder.Page Count 受残留 Limit 截断(repository)— countDB 加 Limit(-1).Offset(-1) - OSS/本地存储文件名冲突(storage)— 新增 uniqueFilename 加 8 字节随机后缀 - 数据库重试策略对不可恢复错误无效(database)— 新增 isTransientDBError Dependencies: - go mod tidy 补全 postgres 方言传递依赖;安全补丁升级 x/crypto v0.49→v0.53、golang-jwt/jwt/v5 v5.2.1→v5.3.1、gorilla/websocket v1.5.1→v1.5.3 Removed: - Breaking: 清理 v1.0.2 兼容别名(InitMySQL* / driverName) - 死代码 database.DBResolver - 代码中 292 行"评分/理由"自夸注释(#26,23 个文件) Changed: - Breaking: 错误码体系重构 CodeSuccess 1→0、CodeFail 0→1,删除 CodeInvalidParams,加编译期防撞码 - database/mysql.go → manager.go;Logger 拆分三独立 core 修复 Tee 重复写入 - 版本号常量化:app.go 新增 const Version 作为唯一来源,CLI/脚手架模板引用之,不再散落字面量 Security: - CORS 中间件按 W3C 规范修复 Allow-Credentials / Vary: Origin / 非白名单不回显 Added: - console 包显式 level 控制(SetLevel/WithLevel/LevelSilent,atomic.Int32 并发安全) - 新增测试 jwt/repository/storage/database/router/middleware/app - 文档:新增 CHANGELOG.md、Version_v1.0.2_report.md;更新 README/GUIDE 顶部更新日志 - 文档对齐:删除对不存在的 config.DefaultManager() getter 的描述(保持 API 与文档一致) 详见 CHANGELOG.md 与 README.md 更新日志。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
246 lines
6.8 KiB
Go
246 lines
6.8 KiB
Go
package logger
|
||
|
||
import (
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||
|
||
"go.uber.org/zap"
|
||
"go.uber.org/zap/zapcore"
|
||
"gopkg.in/natefinch/lumberjack.v2"
|
||
)
|
||
|
||
var (
|
||
// Logger 全局通用日志实例。Init 之前为 Nop,调用安全。
|
||
Logger = zap.NewNop()
|
||
sugar = Logger.Sugar()
|
||
apiLog = zap.NewNop()
|
||
dbLog = zap.NewNop()
|
||
|
||
// fileWriters 持有所有 lumberjack 实例引用,
|
||
// Close() 调用时显式释放文件句柄。
|
||
// 必要性:lumberjack 不依赖 GC 关闭文件,进程长跑或测试场景下
|
||
// 不显式关闭会持有句柄导致 Windows 上无法删除日志目录。
|
||
fileWriters []*lumberjack.Logger
|
||
)
|
||
|
||
// Init 初始化日志。
|
||
//
|
||
// 三个 logger 的分流策略:
|
||
// - Logger(通用):写 console + logs/app.log
|
||
// - APILog() :写 console + logs/api.log
|
||
// - DBLog() :写 console + logs/database.log
|
||
//
|
||
// 关键修复(v1.0.3):旧实现把 apiCore 和 dbCore 都 Tee 进通用 Logger,
|
||
// 导致每条 logger.Info(...) 都会同时落到 api.log + database.log + console
|
||
// 三份,磁盘占用翻倍且分流形同虚设。新实现通用 Logger 只走独立的 app.log。
|
||
func Init(cfg *config.Config) error {
|
||
if cfg == nil {
|
||
return errors.New("logger: config is nil")
|
||
}
|
||
|
||
// 确保日志目录存在
|
||
if err := os.MkdirAll(cfg.Log.Dir, 0o755); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 日志编码器配置
|
||
encoderConfig := zapcore.EncoderConfig{
|
||
TimeKey: "time",
|
||
LevelKey: "level",
|
||
NameKey: "logger",
|
||
CallerKey: "caller",
|
||
FunctionKey: zapcore.OmitKey,
|
||
MessageKey: "msg",
|
||
StacktraceKey: "stacktrace",
|
||
LineEnding: zapcore.DefaultLineEnding,
|
||
EncodeLevel: zapcore.LowercaseLevelEncoder,
|
||
EncodeTime: zapcore.ISO8601TimeEncoder,
|
||
EncodeDuration: zapcore.SecondsDurationEncoder,
|
||
EncodeCaller: zapcore.ShortCallerEncoder,
|
||
}
|
||
|
||
// 根据运行模式设置日志级别
|
||
level := zapcore.DebugLevel
|
||
if cfg.IsProduction() {
|
||
level = zapcore.InfoLevel
|
||
}
|
||
|
||
jsonEncoder := zapcore.NewJSONEncoder(encoderConfig)
|
||
consoleEncoder := zapcore.NewConsoleEncoder(encoderConfig)
|
||
consoleCore := zapcore.NewCore(consoleEncoder, zapcore.AddSync(os.Stdout), level)
|
||
|
||
// 通用日志独立文件,避免与 api/db 日志重复写入
|
||
appWriter := newRotatingWriter(cfg.Log, "app.log")
|
||
apiWriter := newRotatingWriter(cfg.Log, "api.log")
|
||
dbWriter := newRotatingWriter(cfg.Log, "database.log")
|
||
|
||
appCore := zapcore.NewCore(jsonEncoder, zapcore.AddSync(appWriter), level)
|
||
apiCore := zapcore.NewCore(jsonEncoder, zapcore.AddSync(apiWriter), level)
|
||
dbCore := zapcore.NewCore(jsonEncoder, zapcore.AddSync(dbWriter), level)
|
||
|
||
// 三个 logger 各写自己的文件 + console,互不 Tee
|
||
newLogger := zap.New(
|
||
zapcore.NewTee(appCore, consoleCore),
|
||
zap.AddCaller(), zap.AddCallerSkip(1),
|
||
)
|
||
newAPILog := zap.New(
|
||
zapcore.NewTee(apiCore, consoleCore),
|
||
zap.AddCaller(), zap.AddCallerSkip(1),
|
||
)
|
||
newDBLog := zap.New(
|
||
zapcore.NewTee(dbCore, consoleCore),
|
||
zap.AddCaller(), zap.AddCallerSkip(1),
|
||
)
|
||
|
||
// 全部构造成功后再原子替换全局变量,避免半初始化状态。
|
||
// 同时关闭旧 writer 释放句柄(重复 Init 场景,主要服务于测试)。
|
||
closeFileWriters()
|
||
Logger = newLogger
|
||
sugar = Logger.Sugar()
|
||
apiLog = newAPILog
|
||
dbLog = newDBLog
|
||
fileWriters = []*lumberjack.Logger{appWriter, apiWriter, dbWriter}
|
||
|
||
return nil
|
||
}
|
||
|
||
// newRotatingWriter 创建带 lumberjack 滚动归档的 writer
|
||
func newRotatingWriter(cfg config.LogConfig, filename string) *lumberjack.Logger {
|
||
return &lumberjack.Logger{
|
||
Filename: filepath.Join(cfg.Dir, filename),
|
||
MaxSize: cfg.MaxSize,
|
||
MaxBackups: cfg.MaxBackups,
|
||
MaxAge: cfg.MaxAge,
|
||
Compress: cfg.Compress,
|
||
}
|
||
}
|
||
|
||
// closeFileWriters 关闭并清空当前持有的 lumberjack writer
|
||
func closeFileWriters() {
|
||
for _, w := range fileWriters {
|
||
if w != nil {
|
||
_ = w.Close()
|
||
}
|
||
}
|
||
fileWriters = nil
|
||
}
|
||
|
||
// Sync 同步全部 logger 缓冲到底层 writer。
|
||
//
|
||
// 注意:在 Windows / 部分 *nix 平台上对 stdout/stderr 调用 Sync 会返回
|
||
// "invalid argument" / "inappropriate ioctl for device",属于 zap 已知行为,
|
||
// 这里把这类错误识别并忽略,只返回真实的写入失败。
|
||
func Sync() error {
|
||
var errs []error
|
||
for _, l := range []*zap.Logger{Logger, apiLog, dbLog} {
|
||
if l == nil {
|
||
continue
|
||
}
|
||
if err := l.Sync(); err != nil && !isHarmlessSyncError(err) {
|
||
errs = append(errs, err)
|
||
}
|
||
}
|
||
return errors.Join(errs...)
|
||
}
|
||
|
||
// Close 关闭日志文件句柄,重置全局 logger 为 Nop。
|
||
// 通常由 App.Shutdown 在 Sync 之后调用;测试场景需要清理临时目录时也应调用。
|
||
//
|
||
// 调用后再次写日志不会 panic(fall back to nop logger),但不会写入文件。
|
||
// 如需重新启用,请再次调用 Init。
|
||
func Close() error {
|
||
syncErr := Sync()
|
||
|
||
closeFileWriters()
|
||
|
||
Logger = zap.NewNop()
|
||
sugar = Logger.Sugar()
|
||
apiLog = zap.NewNop()
|
||
dbLog = zap.NewNop()
|
||
|
||
return syncErr
|
||
}
|
||
|
||
// isHarmlessSyncError 识别 stdout/stderr Sync 在不同平台返回的预期错误。
|
||
// 这些错误来自 console core,对真实 writer 无影响,可安全忽略。
|
||
func isHarmlessSyncError(err error) bool {
|
||
if err == nil {
|
||
return true
|
||
}
|
||
msg := err.Error()
|
||
for _, sub := range []string{
|
||
"invalid argument", // Linux stdout
|
||
"inappropriate ioctl for device", // macOS stdout
|
||
"bad file descriptor", // Windows stdout
|
||
} {
|
||
if strings.Contains(msg, sub) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Debug 调试日志
|
||
func Debug(msg string, fields ...zap.Field) {
|
||
Logger.Debug(msg, fields...)
|
||
}
|
||
|
||
// Info 信息日志
|
||
func Info(msg string, fields ...zap.Field) {
|
||
Logger.Info(msg, fields...)
|
||
}
|
||
|
||
// Warn 警告日志
|
||
func Warn(msg string, fields ...zap.Field) {
|
||
Logger.Warn(msg, fields...)
|
||
}
|
||
|
||
// Error 错误日志
|
||
func Error(msg string, fields ...zap.Field) {
|
||
Logger.Error(msg, fields...)
|
||
}
|
||
|
||
// Fatal 致命错误日志(仅供应用层使用,框架内部禁止调用)
|
||
func Fatal(msg string, fields ...zap.Field) {
|
||
Logger.Fatal(msg, fields...)
|
||
}
|
||
|
||
// Debugf 格式化调试日志
|
||
func Debugf(template string, args ...any) {
|
||
sugar.Debugf(template, args...)
|
||
}
|
||
|
||
// Infof 格式化信息日志
|
||
func Infof(template string, args ...any) {
|
||
sugar.Infof(template, args...)
|
||
}
|
||
|
||
// Warnf 格式化警告日志
|
||
func Warnf(template string, args ...any) {
|
||
sugar.Warnf(template, args...)
|
||
}
|
||
|
||
// Errorf 格式化错误日志
|
||
func Errorf(template string, args ...any) {
|
||
sugar.Errorf(template, args...)
|
||
}
|
||
|
||
// Fatalf 格式化致命错误日志(仅供应用层使用,框架内部禁止调用)
|
||
func Fatalf(template string, args ...any) {
|
||
sugar.Fatalf(template, args...)
|
||
}
|
||
|
||
// APILog 返回 API 专用日志器(写 logs/api.log + console)
|
||
func APILog() *zap.Logger {
|
||
return apiLog
|
||
}
|
||
|
||
// DBLog 返回数据库专用日志器(写 logs/database.log + console)
|
||
func DBLog() *zap.Logger {
|
||
return dbLog
|
||
}
|