Files
luotianyi 9900695855 feat(logfile): 日志按日轮转与 gzip 压缩([server].log_file)
- 新增 internal/logfile:按日轮转 + gzip 归档,io.Writer 形态接在 slog 之后
- [server].log_file:留空写 <data_dir>/logs/dsfree2api.log,"-" 仅终端;LOG_FILE env
2026-10-08 00:19:40 +08:00

171 lines
3.7 KiB
Go

// Package logfile writes logs to a daily file and compresses the previous
// day's file on rotation. It is an io.Writer meant to sit behind an slog
// handler, next to the stderr writer.
package logfile
import (
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const dayLayout = "2006-01-02"
// Writer appends to path and rotates once per day: the retired file becomes
// "<base>-YYYY-MM-DD.log.gz" next to it.
type Writer struct {
mu sync.Mutex
dir string
base string
path string
file *os.File
day string
closed bool
now func() time.Time
wg sync.WaitGroup
}
// New opens (or creates) path and immediately rotates a stale file left over
// from an earlier day.
func New(path string) (*Writer, error) {
path = strings.TrimSpace(path)
if path == "" {
return nil, fmt.Errorf("log file path is empty")
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
w := &Writer{dir: dir, base: baseName(path), path: path, now: time.Now}
if st, err := os.Stat(path); err == nil {
day := st.ModTime().Format(dayLayout)
if day != w.now().Format(dayLayout) && st.Size() > 0 {
w.day = day
if err := w.rotateLocked(); err != nil {
return nil, err
}
return w, nil
}
}
return w, w.openLocked()
}
// Write appends p, rotating first when the local day changed.
func (w *Writer) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return 0, os.ErrClosed
}
if w.file == nil {
if err := w.openLocked(); err != nil {
return 0, err
}
}
if day := w.now().Format(dayLayout); day != w.day {
if err := w.rotateLocked(); err != nil {
return 0, err
}
}
return w.file.Write(p)
}
// Close flushes and waits for any in-flight compression.
func (w *Writer) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return nil
}
w.closed = true
var err error
if w.file != nil {
err = w.file.Close()
w.file = nil
}
w.wg.Wait()
return err
}
func (w *Writer) openLocked() error {
f, err := os.OpenFile(w.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
w.file = f
w.day = w.now().Format(dayLayout)
return nil
}
// rotateLocked retires the current file — renaming it first so the async
// compression can never read a freshly opened file — and starts a new one.
func (w *Writer) rotateLocked() error {
if w.file != nil {
_ = w.file.Close()
w.file = nil
}
old, day := w.path, w.day
if st, err := os.Stat(old); err == nil && st.Size() > 0 {
tmp := old + ".rotating"
if err := os.Rename(old, tmp); err == nil {
gz := filepath.Join(w.dir, fmt.Sprintf("%s-%s.log.gz", w.base, day))
w.wg.Add(1)
go func() {
defer w.wg.Done()
compressFile(tmp, gz)
}()
}
} else {
_ = os.Remove(old)
}
return w.openLocked()
}
// compressFile gzips src into dst and removes src on success; on any failure
// the raw .rotating file is left in place so no log data is ever lost.
// NOTE: the source handle is closed before removal — Windows refuses to
// delete a file that is still open.
func compressFile(src, dst string) {
in, err := os.Open(src)
if err != nil {
return
}
out, err := os.Create(dst)
if err != nil {
_ = in.Close()
return
}
gz := gzip.NewWriter(out)
_, copyErr := io.Copy(gz, in)
_ = in.Close()
if copyErr != nil {
gz.Close()
out.Close()
_ = os.Remove(dst)
return
}
if err := gz.Close(); err != nil {
out.Close()
_ = os.Remove(dst)
return
}
if err := out.Close(); err != nil {
_ = os.Remove(dst)
return
}
_ = os.Remove(src)
}
func baseName(path string) string {
base := filepath.Base(path)
if ext := filepath.Ext(base); ext != "" {
base = strings.TrimSuffix(base, ext)
}
return base
}