package logger import ( "fmt" "log" "os" "path/filepath" "sort" "strings" "time" ) // minKeepFiles — сколько самых свежих файлов всегда сохраняем, даже если // возраст или размер говорят удалить. Защита от полного стирания директории. const minKeepFiles = 2 type Retention struct { maxAgeHours int maxSizeBytes int64 eventLogger *EventLogger stopCh chan struct{} } func NewRetention(maxAgeHours int, maxSizeMB int, eventLogger *EventLogger) *Retention { return &Retention{ maxAgeHours: maxAgeHours, maxSizeBytes: int64(maxSizeMB) * 1024 * 1024, eventLogger: eventLogger, stopCh: make(chan struct{}), } } func (r *Retention) Start() { // Запускаем проверку каждые 15 минут r.StartWithInterval(15 * time.Minute) } func (r *Retention) StartWithInterval(interval time.Duration) { // Запускаем проверку с указанным интервалом ticker := time.NewTicker(interval) go func() { for { select { case <-ticker.C: r.Cleanup() case <-r.stopCh: ticker.Stop() return } } }() // Первая очистка через 1 минуту после запуска time.AfterFunc(1*time.Minute, func() { r.Cleanup() }) } func (r *Retention) Stop() { close(r.stopCh) } func (r *Retention) Cleanup() { // Если оба лимита отключены, ничего не делаем if r.maxAgeHours <= 0 && r.maxSizeBytes <= 0 { return } // Получаем директорию с данными dataDir, err := GetDataLogsDir() if err != nil { if r.eventLogger != nil { r.eventLogger.Event("ОШИБКА_ПОЛУЧЕНИЯ_ДИРЕКТОРИИ") } return } // 1. Удаляем старые файлы (если включено) deletedByAge := 0 var deletedByAgeBytes int64 if r.maxAgeHours > 0 { deletedByAge, deletedByAgeBytes = r.cleanByAge(dataDir) } // 2. Проверяем общий размер и удаляем самые старые если превышен лимит (если включено) deletedBySize := 0 var deletedBySizeBytes int64 if r.maxSizeBytes > 0 { deletedBySize, deletedBySizeBytes = r.cleanBySize(dataDir) } totalDeleted := deletedByAge + deletedBySize totalDeletedBytes := deletedByAgeBytes + deletedBySizeBytes // Логируем ТОЛЬКО если были удаления if totalDeleted > 0 { // Логируем суммарное событие if r.eventLogger != nil { eventMsg := fmt.Sprintf("ОЧИСТКА_ЛОГОВ_УДАЛЕНО_%d_ФАЙЛОВ_%d_MB", totalDeleted, totalDeletedBytes/(1024*1024)) r.eventLogger.Event(eventMsg) } // Пишем в консоль для отладки log.Printf("Очистка логов: удалено %d файлов (%.2f MB) [по возрасту: %d, по размеру: %d]", totalDeleted, float64(totalDeletedBytes)/(1024*1024), deletedByAge, deletedBySize) } } // fileInfo описывает один лог-файл: путь, время из имени (или ModTime как // запасной вариант), реальное время последней записи и размер. type fileInfo struct { path string time time.Time modTime time.Time size int64 } // collectFiles возвращает все gpio-*.bin, отсортированные от старых к новым // (FIFO-порядок), и их суммарный размер в байтах. func (r *Retention) collectFiles(dataDir string) ([]fileInfo, int64) { files, err := filepath.Glob(filepath.Join(dataDir, "gpio-*.bin")) if err != nil { return nil, 0 } var fileList []fileInfo var totalSize int64 for _, file := range files { info, err := os.Stat(file) if err != nil { continue } // Время из имени файла; если распарсить не удалось — берём ModTime. fileTime := r.parseFilenameTime(file) if fileTime.IsZero() { fileTime = info.ModTime() } fileList = append(fileList, fileInfo{ path: file, time: fileTime, modTime: info.ModTime(), size: info.Size(), }) totalSize += info.Size() } // Сортируем по времени (старые первые) — FIFO. sort.Slice(fileList, func(i, j int) bool { return fileList[i].time.Before(fileList[j].time) }) return fileList, totalSize } // cleanByAge удаляет старые файлы по FIFO: с самого старого края, пока файл // старше cutoff И по имени, И по ModTime, и пока не упрёмся в minKeepFiles. // Возвращает количество удалённых файлов и их общий размер в байтах. func (r *Retention) cleanByAge(dataDir string) (int, int64) { cutoff := time.Now().Add(-time.Duration(r.maxAgeHours) * time.Hour) files, _ := r.collectFiles(dataDir) deleted := 0 var deletedBytes int64 for len(files) > minKeepFiles { f := files[0] // Возраст — это цель, а не гильотина. Файл удаляем только если он старше // cutoff и по имени, и по реальному времени записи. Это защищает от // скачка часов (после NTP на устройстве без RTC): свежий по ModTime // файл со «старым» именем не будет снесён. if !f.time.Before(cutoff) || !f.modTime.Before(cutoff) { // Список отсортирован от старых к новым — дальше только моложе. break } if err := os.Remove(f.path); err != nil { break } deleted++ deletedBytes += f.size files = files[1:] } return deleted, deletedBytes } // cleanBySize удаляет самые старые файлы, пока суммарный размер превышает лимит, // сохраняя как минимум minKeepFiles файлов. // Возвращает количество удалённых файлов и их общий размер в байтах. func (r *Retention) cleanBySize(dataDir string) (int, int64) { fileList, totalSize := r.collectFiles(dataDir) deleted := 0 var deletedBytes int64 for totalSize > r.maxSizeBytes && len(fileList) > minKeepFiles { // Удаляем самый старый файл. oldest := fileList[0] if err := os.Remove(oldest.path); err != nil { break } totalSize -= oldest.size deletedBytes += oldest.size deleted++ fileList = fileList[1:] } return deleted, deletedBytes } func (r *Retention) parseFilenameTime(filename string) time.Time { base := filepath.Base(filename) // Формат: gpio-2026-06-09-11.bin parts := strings.Split(strings.TrimSuffix(base, ".bin"), "-") if len(parts) != 5 { return time.Time{} } year, err := time.Parse("2006", parts[1]) if err != nil { return time.Time{} } month, err := time.Parse("01", parts[2]) if err != nil { return time.Time{} } day, err := time.Parse("02", parts[3]) if err != nil { return time.Time{} } hour, err := time.Parse("15", parts[4]) if err != nil { return time.Time{} } return time.Date(year.Year(), month.Month(), day.Day(), hour.Hour(), 0, 0, 0, time.Local) }