|
|
|
|
@@ -0,0 +1,262 @@
|
|
|
|
|
package com.storycove.service;
|
|
|
|
|
|
|
|
|
|
import com.storycove.repository.StoryRepository;
|
|
|
|
|
import org.slf4j.Logger;
|
|
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
|
|
|
import org.springframework.core.io.Resource;
|
|
|
|
|
import org.springframework.scheduling.annotation.Scheduled;
|
|
|
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
|
|
|
|
|
import java.io.IOException;
|
|
|
|
|
import java.nio.file.Files;
|
|
|
|
|
import java.nio.file.Path;
|
|
|
|
|
import java.nio.file.Paths;
|
|
|
|
|
import java.time.LocalDateTime;
|
|
|
|
|
import java.time.format.DateTimeFormatter;
|
|
|
|
|
import java.util.Comparator;
|
|
|
|
|
import java.util.List;
|
|
|
|
|
import java.util.stream.Collectors;
|
|
|
|
|
import java.util.stream.Stream;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Service for automatic daily backups.
|
|
|
|
|
* Runs at 4 AM daily and creates a backup if content has changed since last backup.
|
|
|
|
|
* Keeps maximum of 5 backups, rotating old ones out.
|
|
|
|
|
*/
|
|
|
|
|
@Service
|
|
|
|
|
public class AutomaticBackupService {
|
|
|
|
|
|
|
|
|
|
private static final Logger logger = LoggerFactory.getLogger(AutomaticBackupService.class);
|
|
|
|
|
private static final int MAX_BACKUPS = 5;
|
|
|
|
|
private static final DateTimeFormatter FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");
|
|
|
|
|
|
|
|
|
|
@Value("${storycove.automatic-backup.dir:/app/automatic-backups}")
|
|
|
|
|
private String automaticBackupDir;
|
|
|
|
|
|
|
|
|
|
@Autowired
|
|
|
|
|
private StoryRepository storyRepository;
|
|
|
|
|
|
|
|
|
|
@Autowired
|
|
|
|
|
private DatabaseManagementService databaseManagementService;
|
|
|
|
|
|
|
|
|
|
@Autowired
|
|
|
|
|
private LibraryService libraryService;
|
|
|
|
|
|
|
|
|
|
private LocalDateTime lastBackupCheck = null;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scheduled job that runs daily at 4 AM.
|
|
|
|
|
* Creates a backup if content has changed since last backup.
|
|
|
|
|
*/
|
|
|
|
|
@Scheduled(cron = "0 0 4 * * ?")
|
|
|
|
|
public void performAutomaticBackup() {
|
|
|
|
|
logger.info("========================================");
|
|
|
|
|
logger.info("Starting automatic backup check at 4 AM");
|
|
|
|
|
logger.info("========================================");
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Get current library ID (or default)
|
|
|
|
|
String libraryId = libraryService.getCurrentLibraryId();
|
|
|
|
|
if (libraryId == null) {
|
|
|
|
|
libraryId = "default";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info("Checking for content changes in library: {}", libraryId);
|
|
|
|
|
|
|
|
|
|
// Check if content has changed since last backup
|
|
|
|
|
if (!hasContentChanged()) {
|
|
|
|
|
logger.info("No content changes detected since last backup. Skipping backup.");
|
|
|
|
|
logger.info("========================================");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info("Content changes detected! Creating automatic backup...");
|
|
|
|
|
|
|
|
|
|
// Create backup directory for this library
|
|
|
|
|
Path backupPath = Paths.get(automaticBackupDir, libraryId);
|
|
|
|
|
Files.createDirectories(backupPath);
|
|
|
|
|
|
|
|
|
|
// Create the backup
|
|
|
|
|
String timestamp = LocalDateTime.now().format(FILENAME_FORMATTER);
|
|
|
|
|
String filename = String.format("auto_backup_%s.zip", timestamp);
|
|
|
|
|
Path backupFile = backupPath.resolve(filename);
|
|
|
|
|
|
|
|
|
|
logger.info("Creating complete backup to: {}", backupFile);
|
|
|
|
|
|
|
|
|
|
Resource backup = databaseManagementService.createCompleteBackup();
|
|
|
|
|
|
|
|
|
|
// Write backup to file
|
|
|
|
|
try (var inputStream = backup.getInputStream();
|
|
|
|
|
var outputStream = Files.newOutputStream(backupFile)) {
|
|
|
|
|
inputStream.transferTo(outputStream);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
long fileSize = Files.size(backupFile);
|
|
|
|
|
logger.info("✅ Automatic backup created successfully");
|
|
|
|
|
logger.info(" File: {}", backupFile.getFileName());
|
|
|
|
|
logger.info(" Size: {} MB", fileSize / 1024 / 1024);
|
|
|
|
|
|
|
|
|
|
// Rotate old backups (keep only MAX_BACKUPS)
|
|
|
|
|
rotateBackups(backupPath);
|
|
|
|
|
|
|
|
|
|
// Update last backup check time
|
|
|
|
|
lastBackupCheck = LocalDateTime.now();
|
|
|
|
|
|
|
|
|
|
logger.info("========================================");
|
|
|
|
|
logger.info("Automatic backup completed successfully");
|
|
|
|
|
logger.info("========================================");
|
|
|
|
|
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
logger.error("❌ Automatic backup failed", e);
|
|
|
|
|
logger.info("========================================");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if content has changed since last backup.
|
|
|
|
|
* Looks for stories created or updated after the last backup time.
|
|
|
|
|
*/
|
|
|
|
|
private boolean hasContentChanged() {
|
|
|
|
|
try {
|
|
|
|
|
if (lastBackupCheck == null) {
|
|
|
|
|
// First run - check if there are any stories at all
|
|
|
|
|
long storyCount = storyRepository.count();
|
|
|
|
|
logger.info("First backup check - found {} stories", storyCount);
|
|
|
|
|
return storyCount > 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for stories created or updated since last backup
|
|
|
|
|
long changedCount = storyRepository.countStoriesModifiedAfter(lastBackupCheck);
|
|
|
|
|
logger.info("Found {} stories modified since last backup ({})", changedCount, lastBackupCheck);
|
|
|
|
|
return changedCount > 0;
|
|
|
|
|
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
logger.error("Error checking for content changes", e);
|
|
|
|
|
// On error, create backup to be safe
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Rotate backups - keep only MAX_BACKUPS most recent backups.
|
|
|
|
|
* Deletes older backups.
|
|
|
|
|
*/
|
|
|
|
|
private void rotateBackups(Path backupPath) throws IOException {
|
|
|
|
|
logger.info("Checking for old backups to rotate...");
|
|
|
|
|
|
|
|
|
|
// Find all backup files in the directory
|
|
|
|
|
List<Path> backupFiles;
|
|
|
|
|
try (Stream<Path> stream = Files.list(backupPath)) {
|
|
|
|
|
backupFiles = stream
|
|
|
|
|
.filter(Files::isRegularFile)
|
|
|
|
|
.filter(p -> p.getFileName().toString().startsWith("auto_backup_"))
|
|
|
|
|
.filter(p -> p.getFileName().toString().endsWith(".zip"))
|
|
|
|
|
.sorted(Comparator.comparing((Path p) -> {
|
|
|
|
|
try {
|
|
|
|
|
return Files.getLastModifiedTime(p);
|
|
|
|
|
} catch (IOException e) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}).reversed()) // Most recent first
|
|
|
|
|
.collect(Collectors.toList());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info("Found {} automatic backups", backupFiles.size());
|
|
|
|
|
|
|
|
|
|
// Delete old backups if we exceed MAX_BACKUPS
|
|
|
|
|
if (backupFiles.size() > MAX_BACKUPS) {
|
|
|
|
|
List<Path> toDelete = backupFiles.subList(MAX_BACKUPS, backupFiles.size());
|
|
|
|
|
logger.info("Deleting {} old backups to maintain maximum of {}", toDelete.size(), MAX_BACKUPS);
|
|
|
|
|
|
|
|
|
|
for (Path oldBackup : toDelete) {
|
|
|
|
|
try {
|
|
|
|
|
Files.delete(oldBackup);
|
|
|
|
|
logger.info(" Deleted old backup: {}", oldBackup.getFileName());
|
|
|
|
|
} catch (IOException e) {
|
|
|
|
|
logger.warn("Failed to delete old backup: {}", oldBackup, e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
logger.info("Backup count within limit ({}), no rotation needed", MAX_BACKUPS);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Manual trigger for testing - creates backup immediately if content changed.
|
|
|
|
|
*/
|
|
|
|
|
public void triggerManualBackup() {
|
|
|
|
|
logger.info("Manual automatic backup triggered");
|
|
|
|
|
performAutomaticBackup();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get list of automatic backups for the current library.
|
|
|
|
|
*/
|
|
|
|
|
public List<BackupInfo> listAutomaticBackups() throws IOException {
|
|
|
|
|
String libraryId = libraryService.getCurrentLibraryId();
|
|
|
|
|
if (libraryId == null) {
|
|
|
|
|
libraryId = "default";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Path backupPath = Paths.get(automaticBackupDir, libraryId);
|
|
|
|
|
if (!Files.exists(backupPath)) {
|
|
|
|
|
return List.of();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try (Stream<Path> stream = Files.list(backupPath)) {
|
|
|
|
|
return stream
|
|
|
|
|
.filter(Files::isRegularFile)
|
|
|
|
|
.filter(p -> p.getFileName().toString().startsWith("auto_backup_"))
|
|
|
|
|
.filter(p -> p.getFileName().toString().endsWith(".zip"))
|
|
|
|
|
.sorted(Comparator.comparing((Path p) -> {
|
|
|
|
|
try {
|
|
|
|
|
return Files.getLastModifiedTime(p);
|
|
|
|
|
} catch (IOException e) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}).reversed())
|
|
|
|
|
.map(p -> {
|
|
|
|
|
try {
|
|
|
|
|
return new BackupInfo(
|
|
|
|
|
p.getFileName().toString(),
|
|
|
|
|
Files.size(p),
|
|
|
|
|
Files.getLastModifiedTime(p).toInstant().toString()
|
|
|
|
|
);
|
|
|
|
|
} catch (IOException e) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.filter(info -> info != null)
|
|
|
|
|
.collect(Collectors.toList());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Simple backup info class.
|
|
|
|
|
*/
|
|
|
|
|
public static class BackupInfo {
|
|
|
|
|
private final String filename;
|
|
|
|
|
private final long sizeBytes;
|
|
|
|
|
private final String createdAt;
|
|
|
|
|
|
|
|
|
|
public BackupInfo(String filename, long sizeBytes, String createdAt) {
|
|
|
|
|
this.filename = filename;
|
|
|
|
|
this.sizeBytes = sizeBytes;
|
|
|
|
|
this.createdAt = createdAt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public String getFilename() {
|
|
|
|
|
return filename;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public long getSizeBytes() {
|
|
|
|
|
return sizeBytes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public String getCreatedAt() {
|
|
|
|
|
return createdAt;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|