diff --git a/README.md b/README.md index c8aa727..dfbd58b 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ npm run dev ## 开发态短信与实名 +- 后端日志默认同时输出到控制台和 `backend/logs/app-YYYY-MM-DD.log`,按北京时间自动按天切分;可通过 `LOG_LEVEL=debug|info|warn|error` 调整级别。 - 短信验证码默认使用 mock 适配器,验证码会打印在后端日志中;生产环境可通过 `SMS_PROVIDER=aliyun` 接入阿里云短信。 - 阿里云短信需要配置 `ALIYUN_ACCESS_KEY_ID`、`ALIYUN_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME` 和 `ALIYUN_SMS_LOGIN_TEMPLATE_CODE`,模板变量名默认为 `code`。 - 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。 diff --git a/backend/.env.example b/backend/.env.example index 136b1b4..5991729 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,6 +7,12 @@ REDIS_PASSWORD= REDIS_DB=0 JWT_SECRET=change-me + +LOG_LEVEL=info +LOG_DIR=logs +LOG_ENABLE_CONSOLE=true +LOG_ENABLE_FILE=true + STORAGE_ENDPOINT=http://localhost:9000 STORAGE_BUCKET=hfb-sys STORAGE_ACCESS_KEY_ID=minioadmin diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 717401b..437615b 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -11,6 +11,7 @@ import ( "hfb_sys/backend/internal/config" "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/jobs/ordertimeout" + "hfb_sys/backend/internal/logging" "hfb_sys/backend/internal/router" "go.uber.org/zap" @@ -18,10 +19,7 @@ import ( func main() { cfg := config.Load() - logger, err := zap.NewProduction() - if cfg.AppEnv == "development" { - logger, err = zap.NewDevelopment() - } + logger, err := logging.New(cfg.Log) if err != nil { panic(err) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 22afd5a..5fe8086 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -15,6 +15,7 @@ type Config struct { JWTSecret string Storage StorageConfig SMS SMSConfig + Log LogConfig } type StorageConfig struct { @@ -33,6 +34,13 @@ type SMSConfig struct { AliyunLoginTemplateCode string } +type LogConfig struct { + Level string + Dir string + EnableConsole bool + EnableFile bool +} + func Load() Config { return Config{ AppEnv: getEnv("APP_ENV", "development"), @@ -56,6 +64,12 @@ func Load() Config { AliyunSignName: getEnv("ALIYUN_SMS_SIGN_NAME", ""), AliyunLoginTemplateCode: getEnv("ALIYUN_SMS_LOGIN_TEMPLATE_CODE", ""), }, + Log: LogConfig{ + Level: getEnv("LOG_LEVEL", "info"), + Dir: getEnv("LOG_DIR", "logs"), + EnableConsole: getEnvBool("LOG_ENABLE_CONSOLE", true), + EnableFile: getEnvBool("LOG_ENABLE_FILE", true), + }, } } @@ -78,3 +92,15 @@ func getEnvInt(key string, fallback int) int { } return parsed } + +func getEnvBool(key string, fallback bool) bool { + value := os.Getenv(key) + if value == "" { + return fallback + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return fallback + } + return parsed +} diff --git a/backend/internal/logging/daily_writer.go b/backend/internal/logging/daily_writer.go new file mode 100644 index 0000000..a43f784 --- /dev/null +++ b/backend/internal/logging/daily_writer.go @@ -0,0 +1,71 @@ +package logging + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +type dailyWriter struct { + mu sync.Mutex + dir string + prefix string + location *time.Location + day string + file *os.File +} + +func newDailyWriter(dir string, prefix string, location *time.Location) *dailyWriter { + return &dailyWriter{ + dir: dir, + prefix: prefix, + location: location, + } +} + +func (w *dailyWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + + if err := w.rotateIfNeeded(time.Now().In(w.location)); err != nil { + return 0, err + } + return w.file.Write(p) +} + +func (w *dailyWriter) Sync() error { + w.mu.Lock() + defer w.mu.Unlock() + + if w.file == nil { + return nil + } + return w.file.Sync() +} + +func (w *dailyWriter) rotateIfNeeded(now time.Time) error { + day := now.Format("2006-01-02") + if w.file != nil && w.day == day { + return nil + } + + if err := os.MkdirAll(w.dir, 0o755); err != nil { + return err + } + + if w.file != nil { + _ = w.file.Close() + 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) + if err != nil { + return err + } + w.file = file + w.day = day + return nil +} diff --git a/backend/internal/logging/logger.go b/backend/internal/logging/logger.go new file mode 100644 index 0000000..db113b3 --- /dev/null +++ b/backend/internal/logging/logger.go @@ -0,0 +1,75 @@ +package logging + +import ( + "os" + "strings" + "time" + + "hfb_sys/backend/internal/config" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func New(cfg config.LogConfig) (*zap.Logger, error) { + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + location = time.FixedZone("Asia/Shanghai", 8*60*60) + } + + 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), + zapcore.Lock(os.Stdout), + level, + )) + } + if cfg.EnableFile { + writer := newDailyWriter(cfg.Dir, "app", location) + cores = append(cores, zapcore.NewCore( + zapcore.NewConsoleEncoder(encoderConfig), + writer, + level, + )) + } + if len(cores) == 0 { + cores = append(cores, zapcore.NewCore( + zapcore.NewConsoleEncoder(encoderConfig), + zapcore.Lock(os.Stdout), + level, + )) + } + + return zap.New( + zapcore.NewTee(cores...), + zap.AddCaller(), + zap.AddStacktrace(zapcore.ErrorLevel), + ), nil +} + +func parseLevel(value string) zapcore.Level { + switch strings.ToLower(strings.TrimSpace(value)) { + case "debug": + return zapcore.DebugLevel + case "warn", "warning": + return zapcore.WarnLevel + case "error": + return zapcore.ErrorLevel + default: + return zapcore.InfoLevel + } +}