feat: BackupService real pg_dump, OrganisationService region stats, SystemConfigService overrides

- BackupService: DB-persisted metadata (BackupRecord/BackupConfig entities + V16 Flyway migration),
  real pg_dump execution via ProcessBuilder, soft-delete on deleteBackup, pg_restore manual guidance
- OrganisationService: repartitionRegion now queries Adresse entities (was Map.of() stub)
- SystemConfigService: in-memory config overrides via AtomicReference (no DB dependency)
- SystemMetricsService: null-guard on MemoryMXBean in getSystemStatus() (fixes test NPE)
- Souscription workflow: SouscriptionService, SouscriptionResource, FormuleAbonnementRepository,
  V11 Flyway migration, admin REST clients
- Flyway V8-V15: notes membres, types référence, type orga constraint, seed roles,
  première connexion, Wave checkout URL, Wave telephone column length fix
- .gitignore: added uploads/ and .claude/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dahoud
2026-04-04 16:14:30 +00:00
parent 9c66909eff
commit e00a9301d8
98 changed files with 5571 additions and 636 deletions

View File

@@ -5,111 +5,89 @@ import dev.lions.unionflow.server.api.dto.backup.request.RestoreBackupRequest;
import dev.lions.unionflow.server.api.dto.backup.request.UpdateBackupConfigRequest;
import dev.lions.unionflow.server.api.dto.backup.response.BackupConfigResponse;
import dev.lions.unionflow.server.api.dto.backup.response.BackupResponse;
import dev.lions.unionflow.server.entity.BackupConfig;
import dev.lions.unionflow.server.entity.BackupRecord;
import dev.lions.unionflow.server.repository.BackupConfigRepository;
import dev.lions.unionflow.server.repository.BackupRecordRepository;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Service de gestion des sauvegardes système
* Service de gestion des sauvegardes système.
* Persiste les métadonnées en base et exécute pg_dump pour les sauvegardes DB.
*/
@Slf4j
@ApplicationScoped
public class BackupService {
private static final Pattern JDBC_PATTERN =
Pattern.compile("jdbc:postgresql://([^:/]+):(\\d+)/([^?]+)");
@Inject
SecurityIdentity securityIdentity;
@Inject
BackupRecordRepository backupRecordRepository;
@Inject
BackupConfigRepository backupConfigRepository;
@ConfigProperty(name = "unionflow.backup.directory", defaultValue = "/tmp/unionflow-backups")
String backupDirectory;
@ConfigProperty(name = "quarkus.datasource.jdbc.url")
String jdbcUrl;
@ConfigProperty(name = "quarkus.datasource.username")
String dbUsername;
@ConfigProperty(name = "quarkus.datasource.password")
String dbPassword;
// ==================== API PUBLIQUE ====================
/**
* Lister toutes les sauvegardes disponibles
* Lister toutes les sauvegardes disponibles.
*/
public List<BackupResponse> getAllBackups() {
log.debug("Récupération de toutes les sauvegardes");
// Dans une vraie implémentation, on lirait depuis le système de fichiers ou DB
// Pour l'instant, on retourne des données de test
List<BackupResponse> backups = new ArrayList<>();
backups.add(BackupResponse.builder()
.id(UUID.randomUUID())
.name("Sauvegarde automatique")
.description("Sauvegarde quotidienne programmée")
.type("AUTO")
.sizeBytes(2_300_000_000L) // 2.3 GB
.sizeFormatted("2.3 GB")
.status("COMPLETED")
.createdAt(LocalDateTime.now().minusHours(2))
.completedAt(LocalDateTime.now().minusHours(2).plusMinutes(45))
.createdBy("system")
.includesDatabase(true)
.includesFiles(true)
.includesConfiguration(true)
.filePath("/backups/auto-2024-12-15-02-00.zip")
.build()
);
backups.add(BackupResponse.builder()
.id(UUID.randomUUID())
.name("Sauvegarde manuelle")
.description("Sauvegarde avant mise à jour")
.type("MANUAL")
.sizeBytes(2_100_000_000L) // 2.1 GB
.sizeFormatted("2.1 GB")
.status("COMPLETED")
.createdAt(LocalDateTime.now().minusDays(1).withHour(14).withMinute(30))
.completedAt(LocalDateTime.now().minusDays(1).withHour(14).withMinute(55))
.createdBy("admin@unionflow.test")
.includesDatabase(true)
.includesFiles(false)
.includesConfiguration(true)
.filePath("/backups/manual-2024-12-14-14-30.zip")
.build()
);
backups.add(BackupResponse.builder()
.id(UUID.randomUUID())
.name("Sauvegarde automatique")
.description("Sauvegarde quotidienne programmée")
.type("AUTO")
.sizeBytes(2_200_000_000L) // 2.2 GB
.sizeFormatted("2.2 GB")
.status("COMPLETED")
.createdAt(LocalDateTime.now().minusDays(1).withHour(2).withMinute(0))
.completedAt(LocalDateTime.now().minusDays(1).withHour(2).withMinute(43))
.createdBy("system")
.includesDatabase(true)
.includesFiles(true)
.includesConfiguration(true)
.filePath("/backups/auto-2024-12-14-02-00.zip")
.build()
);
return backups;
return backupRecordRepository.findAllOrderedByDate()
.stream()
.map(this::mapToResponse)
.collect(Collectors.toList());
}
/**
* Récupérer une sauvegarde par ID
* Récupérer une sauvegarde par ID.
*/
public BackupResponse getBackupById(UUID id) {
log.debug("Récupération de la sauvegarde: {}", id);
// Dans une vraie implémentation, on chercherait dans la DB
return getAllBackups().stream()
.filter(b -> b.getId().equals(id))
.findFirst()
return backupRecordRepository.findByIdOptional(id)
.map(this::mapToResponse)
.orElseThrow(() -> new RuntimeException("Sauvegarde non trouvée: " + id));
}
/**
* Créer une nouvelle sauvegarde
* Créer une nouvelle sauvegarde et exécuter pg_dump si la DB est incluse.
*/
@Transactional
public BackupResponse createBackup(CreateBackupRequest request) {
log.info("Création d'une nouvelle sauvegarde: {}", request.getName());
@@ -117,178 +95,308 @@ public class BackupService {
? securityIdentity.getPrincipal().getName()
: "system";
// Dans une vraie implémentation, on lancerait le processus de backup
// Pour l'instant, on simule la création
BackupResponse backup = BackupResponse.builder()
.id(UUID.randomUUID())
boolean includesDb = request.getIncludeDatabase() == null || request.getIncludeDatabase();
boolean includesFiles = Boolean.TRUE.equals(request.getIncludeFiles());
boolean includesConf = request.getIncludeConfiguration() == null || request.getIncludeConfiguration();
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm"));
String type = request.getType() != null ? request.getType() : "MANUAL";
String filePath = backupDirectory + "/" + type.toLowerCase() + "-" + timestamp + ".dump";
BackupRecord record = BackupRecord.builder()
.name(request.getName())
.description(request.getDescription())
.type(request.getType() != null ? request.getType() : "MANUAL")
.sizeBytes(2_000_000_000L + ThreadLocalRandom.current().nextLong(500_000_000L))
.sizeFormatted("2.0 GB")
.type(type)
.status("IN_PROGRESS")
.createdAt(LocalDateTime.now())
.createdBy(createdBy)
.includesDatabase(request.getIncludeDatabase() != null ? request.getIncludeDatabase() : true)
.includesFiles(request.getIncludeFiles() != null ? request.getIncludeFiles() : true)
.includesConfiguration(request.getIncludeConfiguration() != null ? request.getIncludeConfiguration() : true)
.filePath("/backups/manual-" + LocalDateTime.now().toString().replace(":", "-") + ".zip")
.includesDatabase(includesDb)
.includesFiles(includesFiles)
.includesConfiguration(includesConf)
.filePath(filePath)
.build();
// TODO: Lancer le processus de backup en asynchrone
log.info("Sauvegarde créée avec succès: {}", backup.getId());
backupRecordRepository.persist(record);
return backup;
// Execute backup synchronously (admin-triggered operation)
long sizeBytes = 0L;
String errorMessage = null;
boolean success = true;
try {
if (includesDb) {
Files.createDirectories(Paths.get(backupDirectory));
success = executePgDump(filePath);
if (success) {
File backupFile = new File(filePath);
sizeBytes = backupFile.exists() ? backupFile.length() : 0L;
} else {
errorMessage = "pg_dump failed — check server logs";
}
}
} catch (IOException e) {
log.error("Erreur lors de la création du répertoire de sauvegarde", e);
success = false;
errorMessage = "Cannot create backup directory: " + e.getMessage();
}
record.setStatus(success ? "COMPLETED" : "FAILED");
record.setSizeBytes(sizeBytes);
record.setCompletedAt(LocalDateTime.now());
record.setErrorMessage(errorMessage);
log.info("Sauvegarde {} avec statut {}: {}", record.getId(), record.getStatus(), filePath);
return mapToResponse(record);
}
/**
* Restaurer une sauvegarde
* Restaurer une sauvegarde (crée un point de restauration au préalable si demandé).
*/
@Transactional
public void restoreBackup(RestoreBackupRequest request) {
log.info("Restauration de la sauvegarde: {}", request.getBackupId());
// Vérifier que la sauvegarde existe
BackupResponse backup = getBackupById(request.getBackupId());
BackupRecord backup = backupRecordRepository.findByIdOptional(request.getBackupId())
.orElseThrow(() -> new RuntimeException("Sauvegarde non trouvée: " + request.getBackupId()));
if (!"COMPLETED".equals(backup.getStatus())) {
throw new RuntimeException("La sauvegarde doit être complétée pour être restaurée");
}
// Créer un point de restauration si demandé
if (Boolean.TRUE.equals(request.getCreateRestorePoint())) {
log.info("Création d'un point de restauration avant la restauration");
CreateBackupRequest restorePoint = CreateBackupRequest.builder()
createBackup(CreateBackupRequest.builder()
.name("Point de restauration")
.description("Avant restauration de: " + backup.getName())
.type("RESTORE_POINT")
.includeDatabase(true)
.includeFiles(true)
.includeFiles(false)
.includeConfiguration(true)
.build();
createBackup(restorePoint);
.build());
}
// Dans une vraie implémentation, on restaurerait les données
// Pour l'instant, on log juste l'action
log.info("Restauration en cours...");
log.info("- Database: {}", request.getRestoreDatabase());
log.info("- Files: {}", request.getRestoreFiles());
log.info("- Configuration: {}", request.getRestoreConfiguration());
// TODO: Implémenter la logique de restauration réelle
log.info("Restauration complétée avec succès");
// pg_restore requires the application to be stopped — log the command instead
log.warn("Restauration initiée pour le fichier: {}", backup.getFilePath());
log.warn("Exécutez manuellement : pg_restore -h <host> -U <user> -d <dbname> -Fc {}", backup.getFilePath());
log.info("- Database: {}, Files: {}, Configuration: {}",
request.getRestoreDatabase(), request.getRestoreFiles(), request.getRestoreConfiguration());
}
/**
* Supprimer une sauvegarde
* Supprimer une sauvegarde (fichier physique + soft-delete du record).
*/
@Transactional
public void deleteBackup(UUID id) {
log.info("Suppression de la sauvegarde: {}", id);
// Vérifier que la sauvegarde existe
BackupResponse backup = getBackupById(id);
BackupRecord backup = backupRecordRepository.findByIdOptional(id)
.orElseThrow(() -> new RuntimeException("Sauvegarde non trouvée: " + id));
// Dans une vraie implémentation, on supprimerait le fichier
log.info("Fichier supprimé: {}", backup.getFilePath());
// Supprimer le fichier physique si présent
if (backup.getFilePath() != null) {
File file = new File(backup.getFilePath());
if (file.exists() && file.isFile()) {
boolean deleted = file.delete();
if (deleted) {
log.info("Fichier physique supprimé: {}", backup.getFilePath());
} else {
log.warn("Impossible de supprimer le fichier: {}", backup.getFilePath());
}
}
}
// TODO: Supprimer le fichier physique et l'entrée en DB
log.info("Sauvegarde supprimée avec succès");
// Soft delete via BaseEntity.actif
backup.setActif(false);
log.info("Sauvegarde {} supprimée (soft delete)", id);
}
/**
* Récupérer la configuration des sauvegardes automatiques
* Récupérer la configuration des sauvegardes automatiques.
*/
@Transactional
public BackupConfigResponse getBackupConfig() {
log.debug("Récupération de la configuration des sauvegardes");
// Dans une vraie implémentation, on lirait depuis la DB
return BackupConfigResponse.builder()
.autoBackupEnabled(true)
.frequency("DAILY")
.retention("30 jours")
.retentionDays(30)
.backupTime("02:00")
.includeDatabase(true)
.includeFiles(true)
.includeConfiguration(true)
.lastBackup(LocalDateTime.now().minusHours(2))
.nextScheduledBackup(LocalDateTime.now().plusDays(1).withHour(2).withMinute(0))
.totalBackups(15)
.totalSizeBytes(35_000_000_000L) // 35 GB
.totalSizeFormatted("35 GB")
.build();
BackupConfig config = backupConfigRepository.getConfig()
.orElseGet(this::createDefaultBackupConfig);
return buildConfigResponse(config);
}
/**
* Mettre à jour la configuration des sauvegardes automatiques
* Mettre à jour la configuration des sauvegardes automatiques.
*/
@Transactional
public BackupConfigResponse updateBackupConfig(UpdateBackupConfigRequest request) {
log.info("Mise à jour de la configuration des sauvegardes");
// Dans une vraie implémentation, on persisterait en DB
// Pour l'instant, on retourne juste la config avec les nouvelles valeurs
BackupConfig config = backupConfigRepository.getConfig()
.orElseGet(this::createDefaultBackupConfig);
// TODO: Persister la configuration en DB
if (request.getAutoBackupEnabled() != null) config.setAutoBackupEnabled(request.getAutoBackupEnabled());
if (request.getFrequency() != null) config.setFrequency(request.getFrequency());
if (request.getRetentionDays() != null) config.setRetentionDays(request.getRetentionDays());
if (request.getBackupTime() != null) config.setBackupTime(request.getBackupTime());
if (request.getIncludeDatabase() != null) config.setIncludeDatabase(request.getIncludeDatabase());
if (request.getIncludeFiles() != null) config.setIncludeFiles(request.getIncludeFiles());
if (request.getIncludeConfiguration() != null) config.setIncludeConfiguration(request.getIncludeConfiguration());
return BackupConfigResponse.builder()
.autoBackupEnabled(request.getAutoBackupEnabled() != null ? request.getAutoBackupEnabled() : true)
.frequency(request.getFrequency() != null ? request.getFrequency() : "DAILY")
.retention(request.getRetention() != null ? request.getRetention() : "30 jours")
.retentionDays(request.getRetentionDays() != null ? request.getRetentionDays() : 30)
.backupTime(request.getBackupTime() != null ? request.getBackupTime() : "02:00")
.includeDatabase(request.getIncludeDatabase() != null ? request.getIncludeDatabase() : true)
.includeFiles(request.getIncludeFiles() != null ? request.getIncludeFiles() : true)
.includeConfiguration(request.getIncludeConfiguration() != null ? request.getIncludeConfiguration() : true)
.lastBackup(LocalDateTime.now().minusHours(2))
.nextScheduledBackup(calculateNextBackup(request.getFrequency(), request.getBackupTime()))
.totalBackups(15)
.totalSizeBytes(35_000_000_000L)
.totalSizeFormatted("35 GB")
.build();
return buildConfigResponse(config);
}
/**
* Créer un point de restauration
* Créer un point de restauration avant une opération critique.
*/
@Transactional
public BackupResponse createRestorePoint() {
log.info("Création d'un point de restauration");
CreateBackupRequest request = CreateBackupRequest.builder()
return createBackup(CreateBackupRequest.builder()
.name("Point de restauration")
.description("Point de restauration créé le " + LocalDateTime.now())
.type("RESTORE_POINT")
.includeDatabase(true)
.includeFiles(true)
.includeFiles(false)
.includeConfiguration(true)
.build();
return createBackup(request);
.build());
}
// ==================== MÉTHODES PRIVÉES ====================
/**
* Calculer la prochaine date de sauvegarde programmée
* Exécute pg_dump vers le fichier spécifié.
* @return true si pg_dump s'est terminé avec le code 0
*/
private boolean executePgDump(String filePath) {
Matcher m = JDBC_PATTERN.matcher(jdbcUrl);
if (!m.find()) {
log.error("Impossible de parser l'URL JDBC: {}", jdbcUrl);
return false;
}
String dbHost = m.group(1);
String dbPort = m.group(2);
String dbName = m.group(3);
try {
ProcessBuilder pb = new ProcessBuilder(
"pg_dump",
"-h", dbHost,
"-p", dbPort,
"-U", dbUsername,
"-Fc", // custom format (compressed)
"-f", filePath,
dbName
);
pb.environment().put("PGPASSWORD", dbPassword);
pb.redirectErrorStream(true);
log.info("Lancement de pg_dump: {} → {}", dbName, filePath);
Process process = pb.start();
String output = new String(process.getInputStream().readAllBytes());
int exitCode = process.waitFor();
if (exitCode != 0) {
log.error("pg_dump a échoué (code {}): {}", exitCode, output);
return false;
}
log.info("pg_dump terminé avec succès pour {}", dbName);
return true;
} catch (IOException e) {
log.error("pg_dump introuvable ou erreur d'exécution — vérifiez que pg_dump est dans le PATH", e);
return false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("pg_dump interrompu", e);
return false;
}
}
private BackupConfig createDefaultBackupConfig() {
BackupConfig config = BackupConfig.builder()
.autoBackupEnabled(true)
.frequency("DAILY")
.retentionDays(30)
.backupTime("02:00")
.includeDatabase(true)
.includeFiles(false)
.includeConfiguration(true)
.backupDirectory(backupDirectory)
.build();
backupConfigRepository.persist(config);
return config;
}
private BackupConfigResponse buildConfigResponse(BackupConfig config) {
long totalBackups = backupRecordRepository.count("status = ?1 AND actif = ?2", "COMPLETED", true);
long totalSizeBytes = backupRecordRepository
.find("status = ?1 AND actif = ?2 AND sizeBytes IS NOT NULL", "COMPLETED", true)
.list()
.stream()
.mapToLong(r -> r.getSizeBytes() != null ? r.getSizeBytes() : 0L)
.sum();
LocalDateTime lastBackup = backupRecordRepository
.find("status = ?1 AND actif = ?2 ORDER BY completedAt DESC", "COMPLETED", true)
.firstResultOptional()
.map(BackupRecord::getCompletedAt)
.orElse(null);
return BackupConfigResponse.builder()
.autoBackupEnabled(config.getAutoBackupEnabled())
.frequency(config.getFrequency())
.retention(config.getRetentionDays() + " jours")
.retentionDays(config.getRetentionDays())
.backupTime(config.getBackupTime())
.includeDatabase(config.getIncludeDatabase())
.includeFiles(config.getIncludeFiles())
.includeConfiguration(config.getIncludeConfiguration())
.lastBackup(lastBackup)
.nextScheduledBackup(calculateNextBackup(config.getFrequency(), config.getBackupTime()))
.totalBackups((int) totalBackups)
.totalSizeBytes(totalSizeBytes)
.totalSizeFormatted(formatBytes(totalSizeBytes))
.build();
}
private BackupResponse mapToResponse(BackupRecord record) {
return BackupResponse.builder()
.id(record.getId())
.name(record.getName())
.description(record.getDescription())
.type(record.getType())
.sizeBytes(record.getSizeBytes() != null ? record.getSizeBytes() : 0L)
.sizeFormatted(formatBytes(record.getSizeBytes() != null ? record.getSizeBytes() : 0L))
.status(record.getStatus())
.createdAt(record.getDateCreation())
.completedAt(record.getCompletedAt())
.createdBy(record.getCreatedBy())
.includesDatabase(record.getIncludesDatabase())
.includesFiles(record.getIncludesFiles())
.includesConfiguration(record.getIncludesConfiguration())
.filePath(record.getFilePath())
.build();
}
private LocalDateTime calculateNextBackup(String frequency, String backupTime) {
LocalTime time = backupTime != null ? LocalTime.parse(backupTime) : LocalTime.of(2, 0);
LocalDateTime next = LocalDateTime.now().with(time);
if (frequency == null) frequency = "DAILY";
switch (frequency) {
case "HOURLY":
return next.plusHours(1);
case "DAILY":
if (next.isBefore(LocalDateTime.now())) {
next = next.plusDays(1);
}
return next;
return LocalDateTime.now().plusHours(1);
case "WEEKLY":
if (next.isBefore(LocalDateTime.now())) {
next = next.plusWeeks(1);
}
if (next.isBefore(LocalDateTime.now())) next = next.plusWeeks(1);
return next;
default:
default: // DAILY
if (next.isBefore(LocalDateTime.now())) next = next.plusDays(1);
return next;
}
}
private String formatBytes(long bytes) {
if (bytes <= 0) return "0 B";
if (bytes < 1024) return bytes + " B";
int exp = (int) (Math.log(bytes) / Math.log(1024));
char pre = "KMGTPE".charAt(exp - 1);
return String.format("%.1f %sB", bytes / Math.pow(1024, exp), pre);
}
}