Smail/backend/services/runtimelog/archive.go
smail bb4ac4d5f5
Some checks are pending
Security Checks / security (push) Waiting to run
过滤无价值日志并实现最近24小时倒序分页查询
2026-09-09 01:06:45 +08:00

238 lines
6.1 KiB
Go

package runtimelog
import (
"bufio"
"compress/gzip"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
"strings"
"time"
"git.pcux.cn/xuans/Smail/utils/logpolicy"
)
func fileDay(name string) (time.Time, bool) {
base := strings.TrimSuffix(name, ".gz")
if !strings.HasPrefix(base, prefix) || !strings.HasSuffix(base, ".log") {
return time.Time{}, false
}
value := strings.TrimSuffix(strings.TrimPrefix(base, prefix), ".log")
day, err := time.Parse("2006-01-02", value)
return day, err == nil && (name == prefix+value+".log" || name == prefix+value+".log.gz")
}
func lineTime(line []byte) (time.Time, error) {
if len(line) < 3 || line[0] != '[' {
return time.Time{}, ErrInvalid
}
end := strings.IndexByte(string(line), ']')
if end < 0 {
return time.Time{}, ErrInvalid
}
at, err := time.Parse(time.RFC3339Nano, string(line[1:end]))
if err != nil {
return time.Time{}, ErrInvalid
}
return at, nil
}
// scan 严格解析每条记录及压缩校验和,损坏归档不得触发删除源文件。
func (w *Writer) scan(ctx context.Context, name string, consume func([]byte, time.Time) error) error {
return w.scanSize(ctx, name, -1, consume)
}
func (w *Writer) scanSize(ctx context.Context, name string, size int64, consume func([]byte, time.Time) error) error {
if err := ctx.Err(); err != nil {
return err
}
if err := w.regular(name, false); err != nil {
return err
}
file, err := w.root.Open(name)
if err != nil {
return err
}
defer file.Close()
var reader io.Reader = file
if size >= 0 {
reader = io.LimitReader(reader, size)
}
if strings.HasSuffix(name, ".gz") {
archive, err := gzip.NewReader(reader)
if err != nil {
return err
}
defer archive.Close()
reader = archive
}
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 4096), maxLineBytes)
for scanner.Scan() {
if err := ctx.Err(); err != nil {
return err
}
at, err := lineTime(scanner.Bytes())
if err != nil {
return err
}
if err := consume(scanner.Bytes(), at); err != nil {
return err
}
}
return scanner.Err()
}
func (w *Writer) Maintain(ctx context.Context, now time.Time) error {
if now.IsZero() {
return ErrInvalid
}
w.archiveMu.Lock()
defer w.archiveMu.Unlock()
if w.closed {
return nil
}
cutoff := logpolicy.Cutoff(now)
w.mu.Lock()
openDay, _ := time.Parse("2006-01-02", w.day)
// 仅关闭本轮要处理的历史句柄,当天写入不与历史文件扫描共用长时间锁。
if w.file != nil && (!openDay.After(cutoff) || !openDay.Add(24*time.Hour).After(now.Add(-logpolicy.CompressionAge))) {
err := errors.Join(w.file.Sync(), w.file.Close())
w.file = nil
if err != nil {
w.mu.Unlock()
return err
}
}
w.mu.Unlock()
directory, err := w.root.Open(".")
if err != nil {
return err
}
entries, err := directory.ReadDir(-1)
closeErr := directory.Close()
if err != nil || closeErr != nil {
return errors.Join(err, closeErr)
}
var failures []error
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return err
}
day, ok := fileDay(entry.Name())
if !ok {
continue
}
compress := !strings.HasSuffix(entry.Name(), ".gz") && !day.Add(24*time.Hour).After(now.Add(-logpolicy.CompressionAge))
if !day.After(cutoff) || compress {
if err := w.transform(ctx, entry.Name(), day, cutoff, compress); err != nil {
failures = append(failures, err)
}
}
}
return errors.Join(failures...)
}
// transform 流式过滤边界日;额外内存不随一天的日志量增长。
func (w *Writer) transform(ctx context.Context, source string, day, cutoff time.Time, compress bool) error {
target := source
if compress {
target += ".gz"
}
var token [16]byte
if _, err := rand.Read(token[:]); err != nil {
return err
}
temporary := ".pending-" + hex.EncodeToString(token[:]) + ".log"
if strings.HasSuffix(target, ".gz") {
temporary += ".gz"
}
file, err := w.root.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
defer func() { _ = file.Close(); _ = w.root.Remove(temporary) }()
var output io.Writer = file
var archive *gzip.Writer
if strings.HasSuffix(target, ".gz") {
archive = gzip.NewWriter(file)
output = archive
}
hash := sha256.New()
output = io.MultiWriter(output, hash)
kept, removed := 0, 0
err = w.scan(ctx, source, func(line []byte, at time.Time) error {
// 文件名和记录日期不一致时保留现场,不按文件名误删。
if at.UTC().Format("2006-01-02") != day.Format("2006-01-02") {
return ErrInvalid
}
if at.Before(cutoff) {
removed++
return nil
}
kept++
_, err := output.Write(append(append([]byte(nil), line...), '\n'))
return err
})
if archive != nil {
err = errors.Join(err, archive.Close())
}
if err = errors.Join(err, file.Sync(), file.Close()); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
if removed == 0 && !compress {
return nil
}
if kept == 0 {
if err := w.regular(source, false); err != nil {
return err
}
return w.root.Remove(source)
}
verified := sha256.New()
if err := w.scan(ctx, temporary, func(line []byte, _ time.Time) error {
_, err := verified.Write(append(append([]byte(nil), line...), '\n'))
return err
}); err != nil {
return err
}
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), hex.EncodeToString(verified.Sum(nil))) {
return ErrInvalid
}
if source != target {
if _, err := w.root.Lstat(target); err == nil {
// 压缩成功但删除前中断时,只有目标与本轮内容完全一致才允许补做删除。
existing := sha256.New()
if err := w.scan(ctx, target, func(line []byte, _ time.Time) error {
_, err := existing.Write(append(append([]byte(nil), line...), '\n'))
return err
}); err != nil {
return err
}
if hex.EncodeToString(existing.Sum(nil)) != hex.EncodeToString(hash.Sum(nil)) {
return ErrInvalid
}
return w.root.Remove(source)
} else if !os.IsNotExist(err) {
return err
}
}
if err := w.regular(target, true); err != nil {
return err
}
if err := w.root.Rename(temporary, target); err != nil {
return err
}
if source != target {
return w.root.Remove(source)
}
return nil
}