diff --git a/README.md b/README.md index d9c9e50..1193ea8 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,8 @@ make docker-down # 停止并移除 | `OPEN_API_SECRET` | 开放接口签名密钥 | 需修改 | | `OPEN_SIGN_SKEW` | 签名时间戳偏差(秒) | `300` | | `OPEN_API_DEBUG` | 开放接口调试日志 | debug 模式默认开启 | -| `LOG_FILE` | 日志文件路径 | `logs/app.log` | +| `LOG_FILE` | 日志文件路径(按天分割为 `app-YYYYMMDD.log`) | `logs/app.log` | +| `LOG_RETAIN_DAYS` | 日志文件保留天数,到期自动清理 | `30` | | `FULFILLMENT_PROCESSING_TIMEOUT_MINUTES` | 发货中订单自动标记失败的超时分钟数,<=0 关闭 | `30` | | `FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS` | 发货超时巡检间隔秒数 | `60` | diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 87778c2..3fa41e1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -37,6 +37,7 @@ func main() { time.Local = timeutil.Location() // 日志:控制台 + 文件 + applog.SetRetainDays(cfg.LogRetainDays) logFile, err := applog.Setup(cfg.LogFile) if err != nil { log.Fatalf("setup log: %v", err) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 389f7f3..d830223 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -24,8 +24,10 @@ type Config struct { OpenSignSkew int64 // OpenAPIDebug 开放接口详细调试日志 OpenAPIDebug bool - // LogFile 日志文件路径;非空时同时写控制台与文件 + // LogFile 日志文件路径;非空时同时写控制台与文件(按天分割) LogFile string + // LogRetainDays 日志文件保留天数(按天分割后自动清理旧文件) + LogRetainDays int // DeliveryBaseURL 用户侧发货页面基础地址;为空时前端可自行按当前域名拼接。 DeliveryBaseURL string // DeliveryBFFBaseURL 上游发货 BFF 地址。 @@ -66,6 +68,7 @@ func Load() *Config { OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)), OpenAPIDebug: debugOpen, LogFile: getEnv("LOG_FILE", "logs/app.log"), + LogRetainDays: getEnvInt("LOG_RETAIN_DAYS", 30), DeliveryBaseURL: getEnv("DELIVERY_BASE_URL", ""), DeliveryBFFBaseURL: getEnv("DELIVERY_BFF_BASE_URL", "https://www.jxya.top/bff"), DeliveryChannel: getEnv("DELIVERY_CHANNEL", "dlc"), diff --git a/backend/internal/pkg/applog/applog.go b/backend/internal/pkg/applog/applog.go index fcbc289..53e81cb 100644 --- a/backend/internal/pkg/applog/applog.go +++ b/backend/internal/pkg/applog/applog.go @@ -1,32 +1,141 @@ package applog import ( + "fmt" "io" "log" "os" "path/filepath" + "strings" + "sync" "time" "github.com/gin-gonic/gin" "gorm.io/gorm/logger" ) -// Setup 将标准库 log、Gin 请求日志同时写到控制台与文件。 +// logRetainDays 保留最近多少天的日志文件;<=0 不清理。 +var logRetainDays = 30 + +// SetRetainDays 设置日志文件保留天数(可在 main 里按配置覆盖)。 +func SetRetainDays(days int) { + if days > 0 { + logRetainDays = days + } +} + +// dailyFileWriter 按天轮转的日志 writer。 +// 文件命名:<前缀>-20060102<扩展>,如 app-20260803.log;跨天自动切换并清理过期文件。 +type dailyFileWriter struct { + dir string + prefix string + ext string + mu sync.Mutex + current *os.File + currentDate string +} + +func newDailyFileWriter(logFile string) (*dailyFileWriter, error) { + abs, err := filepath.Abs(logFile) + if err != nil { + return nil, err + } + dir := filepath.Dir(abs) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + base := filepath.Base(abs) + ext := filepath.Ext(base) + prefix := strings.TrimSuffix(base, ext) + w := &dailyFileWriter{dir: dir, prefix: prefix, ext: ext} + if err := w.rotateIfNeeded(time.Now()); err != nil { + return nil, err + } + return w, nil +} + +// pathFor 当天日志文件路径。 +func (w *dailyFileWriter) pathFor(t time.Time) string { + return filepath.Join(w.dir, fmt.Sprintf("%s-%s%s", w.prefix, t.Format("20060102"), w.ext)) +} + +func (w *dailyFileWriter) rotateIfNeeded(now time.Time) error { + date := now.Format("20060102") + if w.current != nil && w.currentDate == date { + return nil + } + if w.current != nil { + _ = w.current.Close() + w.current = nil + } + f, err := os.OpenFile(w.pathFor(now), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + w.current = f + w.currentDate = date + go w.cleanup(now) + return nil +} + +// cleanup 删除超出保留天数的历史日志文件。 +func (w *dailyFileWriter) cleanup(now time.Time) { + if logRetainDays <= 0 { + return + } + cutoff := now.AddDate(0, 0, -logRetainDays).Format("20060102") + entries, err := os.ReadDir(w.dir) + if err != nil { + return + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasPrefix(name, w.prefix+"-") || !strings.HasSuffix(name, w.ext) { + continue + } + datePart := strings.TrimSuffix(strings.TrimPrefix(name, w.prefix+"-"), w.ext) + if len(datePart) != 8 || datePart >= cutoff { + continue + } + _ = os.Remove(filepath.Join(w.dir, name)) + } +} + +func (w *dailyFileWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if err := w.rotateIfNeeded(time.Now()); err != nil { + return 0, err + } + return w.current.Write(p) +} + +func (w *dailyFileWriter) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.current != nil { + err := w.current.Close() + w.current = nil + return err + } + return nil +} + +// Setup 将标准库 log、Gin 请求日志同时写到控制台与按天分割的文件。 // logFile 为空则仅控制台。返回文件句柄供进程结束时 Close(可为 nil)。 func Setup(logFile string) (*os.File, error) { writers := []io.Writer{os.Stdout} var f *os.File if logFile != "" { - dir := filepath.Dir(logFile) - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, err - } - var err error - f, err = os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + daily, err := newDailyFileWriter(logFile) if err != nil { return nil, err } - writers = append(writers, f) + f = daily.current + writers = append(writers, daily) } mw := io.MultiWriter(writers...) @@ -39,7 +148,7 @@ func Setup(logFile string) (*os.File, error) { if logFile != "" { abs, _ := filepath.Abs(logFile) - log.Printf("[applog] 日志同时输出到控制台与文件: %s", abs) + log.Printf("[applog] 日志按天分割输出到 %s-YYYYMMDD.log(保留 %d 天)", strings.TrimSuffix(abs, filepath.Ext(abs)), logRetainDays) } return f, nil } diff --git a/backend/internal/pkg/applog/applog_test.go b/backend/internal/pkg/applog/applog_test.go new file mode 100644 index 0000000..5f010e5 --- /dev/null +++ b/backend/internal/pkg/applog/applog_test.go @@ -0,0 +1,47 @@ +package applog + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestDailyFileWriterRotatesAndCleans(t *testing.T) { + dir := t.TempDir() + logFile := filepath.Join(dir, "app.log") + + w, err := newDailyFileWriter(logFile) + if err != nil { + t.Fatalf("new writer: %v", err) + } + defer w.Close() + + // 写入当天日志,文件应按 app-YYYYMMDD.log 命名 + today := time.Now().Format("20060102") + if _, err := w.Write([]byte("line1\n")); err != nil { + t.Fatalf("write: %v", err) + } + todayPath := filepath.Join(dir, "app-"+today+".log") + if _, err := os.Stat(todayPath); err != nil { + t.Fatalf("today log file missing: %v", err) + } + // 旧格式 app.log 不应被创建 + if _, err := os.Stat(logFile); err == nil { + t.Fatalf("old-style app.log should not be created") + } + + // 构造过期文件,cleanup 应删除(保留 30 天,这里用 3 天验证) + old := filepath.Join(dir, "app-20260701.log") + if err := os.WriteFile(old, []byte("old"), 0o644); err != nil { + t.Fatalf("create old file: %v", err) + } + w.cleanup(time.Now()) + if _, err := os.Stat(old); err == nil { + t.Fatalf("old log file should be cleaned") + } + // 当天的文件应保留 + if _, err := os.Stat(todayPath); err != nil { + t.Fatalf("today log file should be kept") + } +} diff --git a/docker-compose.yml b/docker-compose.yml index da2266a..5b22acd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,7 @@ services: - OPEN_SIGN_SKEW=${OPEN_SIGN_SKEW:-300} - OPEN_API_DEBUG=${OPEN_API_DEBUG:-false} - LOG_FILE=${LOG_FILE:-logs/app.log} + - LOG_RETAIN_DAYS=${LOG_RETAIN_DAYS:-30} - DELIVERY_BASE_URL=${DELIVERY_BASE_URL:-} - DELIVERY_BFF_BASE_URL=${DELIVERY_BFF_BASE_URL:-https://www.jxya.top/bff} - DELIVERY_CHANNEL=${DELIVERY_CHANNEL:-dlc}