feat: Implementazione completa sistema schedulazione con intervalli personalizzati
- Aggiunto supporto schedulazione con intervalli flessibili (secondi/minuti/ore/giorni/settimane/mesi) - Esteso modello ProfileSchedule con campi IntervalValue e IntervalUnit - Ottimizzato ScheduledJobService per controlli ogni 30s con esecuzione parallela - Implementata interfaccia UI completa con anteprima real-time in italiano - Aggiunta migrazione database AddIntervalSchedulingFields - Implementati metodi calcolo NextExecutionTime per intervalli - Aggiunta gestione tracking anti-duplicati e cleanup automatico - Creata documentazione completa (6 file, 2500+ righe) Modifiche tecniche: - ProfileSchedule.cs: Nuovi campi e metodi CalculateNextInterval/GetScheduleDescription - ScheduledJobService.cs: Ridotto check interval a 30s, aggiunto parallel processing - ProfileScheduleService.cs: Supporto calcolo intervalli in UpdateNextExecutionTimeAsync - Scheduling.razor: Aggiunta sezione UI per configurazione intervalli - Scheduling.razor.cs: Implementato GetIntervalPreview() e gestione stato campi
This commit is contained in:
@@ -0,0 +1,733 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CredentialManager.Data;
|
||||
using CredentialManager.Models;
|
||||
using CredentialManager.Services;
|
||||
using Data_Coupler.Models;
|
||||
|
||||
namespace Data_Coupler.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interfaccia per il servizio di backup e ripristino
|
||||
/// </summary>
|
||||
public interface IBackupService
|
||||
{
|
||||
/// <summary>
|
||||
/// Esporta tutti i dati del sistema in un file di backup
|
||||
/// </summary>
|
||||
Task<BackupOperationResult> ExportBackupAsync(BackupOptions options);
|
||||
|
||||
/// <summary>
|
||||
/// Importa i dati da un file di backup
|
||||
/// </summary>
|
||||
Task<BackupOperationResult> ImportBackupAsync(string backupFilePath, RestoreOptions options);
|
||||
|
||||
/// <summary>
|
||||
/// Importa i dati da contenuto JSON
|
||||
/// </summary>
|
||||
Task<BackupOperationResult> ImportBackupFromJsonAsync(string jsonContent, RestoreOptions options);
|
||||
|
||||
/// <summary>
|
||||
/// Valida un file di backup
|
||||
/// </summary>
|
||||
Task<BackupOperationResult> ValidateBackupAsync(string backupFilePath);
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene le informazioni su un backup senza importarlo
|
||||
/// </summary>
|
||||
Task<SystemBackupData?> GetBackupInfoAsync(string backupFilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Servizio per la gestione dei backup e ripristini del sistema
|
||||
/// </summary>
|
||||
public class BackupService : IBackupService
|
||||
{
|
||||
private readonly CredentialDbContext _context;
|
||||
private readonly IDataCouplerProfileService _profileService;
|
||||
private readonly ICredentialService _credentialService;
|
||||
private readonly IKeyAssociationService _keyAssociationService;
|
||||
private readonly ILogger<BackupService> _logger;
|
||||
|
||||
public BackupService(
|
||||
CredentialDbContext context,
|
||||
IDataCouplerProfileService profileService,
|
||||
ICredentialService credentialService,
|
||||
IKeyAssociationService keyAssociationService,
|
||||
ILogger<BackupService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_profileService = profileService;
|
||||
_credentialService = credentialService;
|
||||
_keyAssociationService = keyAssociationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Esporta tutti i dati del sistema
|
||||
/// </summary>
|
||||
public async Task<BackupOperationResult> ExportBackupAsync(BackupOptions options)
|
||||
{
|
||||
var result = new BackupOperationResult();
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Avvio backup con opzioni: {@Options}", options);
|
||||
|
||||
var backupData = new SystemBackupData
|
||||
{
|
||||
Metadata = new BackupMetadata
|
||||
{
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = options.CreatedBy,
|
||||
Description = options.Description,
|
||||
ApplicationVersion = "1.0.0"
|
||||
}
|
||||
};
|
||||
|
||||
// Export Profiles
|
||||
if (options.IncludeProfiles)
|
||||
{
|
||||
_logger.LogDebug("Esportazione profili in corso...");
|
||||
backupData.Profiles = await ExportProfilesAsync(options.IncludeOnlyActiveRecords);
|
||||
result.ProcessedCounts.Profiles = backupData.Profiles.Count;
|
||||
_logger.LogDebug("Esportati {Count} profili", backupData.Profiles.Count);
|
||||
}
|
||||
|
||||
// Export Credentials (senza dati sensibili)
|
||||
if (options.IncludeCredentials)
|
||||
{
|
||||
_logger.LogDebug("Esportazione credenziali in corso...");
|
||||
backupData.Credentials = await ExportCredentialsAsync(options.IncludeOnlyActiveRecords);
|
||||
result.ProcessedCounts.Credentials = backupData.Credentials.Count;
|
||||
result.Warnings.Add("Le credenziali esportate non includono password, API keys o token per motivi di sicurezza");
|
||||
_logger.LogDebug("Esportate {Count} credenziali", backupData.Credentials.Count);
|
||||
}
|
||||
|
||||
// Export Key Associations
|
||||
if (options.IncludeKeyAssociations)
|
||||
{
|
||||
_logger.LogDebug("Esportazione associazioni chiavi in corso...");
|
||||
backupData.KeyAssociations = await ExportKeyAssociationsAsync(options.IncludeOnlyActiveRecords);
|
||||
result.ProcessedCounts.KeyAssociations = backupData.KeyAssociations.Count;
|
||||
_logger.LogDebug("Esportate {Count} associazioni", backupData.KeyAssociations.Count);
|
||||
}
|
||||
|
||||
// Export Profile Schedules
|
||||
if (options.IncludeProfileSchedules)
|
||||
{
|
||||
_logger.LogDebug("Esportazione schedule profili in corso...");
|
||||
backupData.ProfileSchedules = await ExportProfileSchedulesAsync(options.IncludeOnlyActiveRecords);
|
||||
result.ProcessedCounts.ProfileSchedules = backupData.ProfileSchedules.Count;
|
||||
_logger.LogDebug("Esportate {Count} schedule", backupData.ProfileSchedules.Count);
|
||||
}
|
||||
|
||||
// Aggiorna metadata con conteggi
|
||||
backupData.Metadata.RecordCounts = result.ProcessedCounts;
|
||||
|
||||
// Serializza e salva
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(backupData, jsonOptions);
|
||||
var fileName = $"data_coupler_backup_{DateTime.Now:yyyyMMdd_HHmmss}.json";
|
||||
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
||||
var backupFolder = Path.Combine(documentsPath, "DataCoupler", "Backups");
|
||||
Directory.CreateDirectory(backupFolder);
|
||||
|
||||
var filePath = Path.Combine(backupFolder, fileName);
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
result.FilePath = filePath;
|
||||
result.Success = true;
|
||||
result.Message = $"Backup completato con successo. File salvato: {filePath}";
|
||||
|
||||
_logger.LogInformation("Backup completato: {FilePath}, Record totali: {Total}",
|
||||
filePath,
|
||||
result.ProcessedCounts.Profiles + result.ProcessedCounts.Credentials +
|
||||
result.ProcessedCounts.KeyAssociations + result.ProcessedCounts.ProfileSchedules);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Success = false;
|
||||
result.Message = $"Errore durante il backup: {ex.Message}";
|
||||
result.Errors.Add(ex.ToString());
|
||||
_logger.LogError(ex, "Errore durante l'esportazione del backup");
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
result.Duration = stopwatch.Elapsed;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Importa dati da file backup
|
||||
/// </summary>
|
||||
public async Task<BackupOperationResult> ImportBackupAsync(string backupFilePath, RestoreOptions options)
|
||||
{
|
||||
if (!File.Exists(backupFilePath))
|
||||
{
|
||||
return new BackupOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "File di backup non trovato"
|
||||
};
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(backupFilePath);
|
||||
return await ImportBackupFromJsonAsync(json, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Importa dati da contenuto JSON
|
||||
/// </summary>
|
||||
public async Task<BackupOperationResult> ImportBackupFromJsonAsync(string jsonContent, RestoreOptions options)
|
||||
{
|
||||
var result = new BackupOperationResult();
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Avvio import backup con opzioni: {@Options}", options);
|
||||
|
||||
// Parse JSON
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
var backupData = JsonSerializer.Deserialize<SystemBackupData>(jsonContent, jsonOptions);
|
||||
if (backupData == null)
|
||||
{
|
||||
throw new InvalidOperationException("Impossibile deserializzare i dati del backup");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Backup da importare: {Metadata}", backupData.Metadata);
|
||||
|
||||
// Crea backup automatico prima del restore se richiesto
|
||||
if (options.CreateBackupBeforeRestore)
|
||||
{
|
||||
_logger.LogInformation("Creazione backup di sicurezza pre-restore...");
|
||||
var preRestoreBackup = await ExportBackupAsync(new BackupOptions
|
||||
{
|
||||
Description = "Backup automatico pre-restore",
|
||||
CreatedBy = options.ImportedBy
|
||||
});
|
||||
|
||||
if (preRestoreBackup.Success)
|
||||
{
|
||||
result.Warnings.Add($"Backup di sicurezza creato: {preRestoreBackup.FilePath}");
|
||||
}
|
||||
}
|
||||
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
// Import Profiles
|
||||
if (options.RestoreProfiles && backupData.Profiles.Any())
|
||||
{
|
||||
_logger.LogDebug("Importazione profili in corso...");
|
||||
var importedProfiles = await ImportProfilesAsync(backupData.Profiles, options);
|
||||
result.ProcessedCounts.Profiles = importedProfiles;
|
||||
_logger.LogDebug("Importati {Count} profili", importedProfiles);
|
||||
}
|
||||
|
||||
// Import Credentials (solo metadati, nessun dato sensibile)
|
||||
if (options.RestoreCredentials && backupData.Credentials.Any())
|
||||
{
|
||||
_logger.LogDebug("Importazione credenziali in corso...");
|
||||
var importedCredentials = await ImportCredentialsAsync(backupData.Credentials, options);
|
||||
result.ProcessedCounts.Credentials = importedCredentials;
|
||||
result.Warnings.Add("Le credenziali importate richiedono configurazione manuale di password/API keys");
|
||||
_logger.LogDebug("Importate {Count} credenziali", importedCredentials);
|
||||
}
|
||||
|
||||
// Import Key Associations
|
||||
if (options.RestoreKeyAssociations && backupData.KeyAssociations.Any())
|
||||
{
|
||||
_logger.LogDebug("Importazione associazioni chiavi in corso...");
|
||||
var importedAssociations = await ImportKeyAssociationsAsync(backupData.KeyAssociations, options);
|
||||
result.ProcessedCounts.KeyAssociations = importedAssociations;
|
||||
_logger.LogDebug("Importate {Count} associazioni", importedAssociations);
|
||||
}
|
||||
|
||||
// Import Profile Schedules
|
||||
if (options.RestoreProfileSchedules && backupData.ProfileSchedules.Any())
|
||||
{
|
||||
_logger.LogDebug("Importazione schedule profili in corso...");
|
||||
var importedSchedules = await ImportProfileSchedulesAsync(backupData.ProfileSchedules, options);
|
||||
result.ProcessedCounts.ProfileSchedules = importedSchedules;
|
||||
_logger.LogDebug("Importate {Count} schedule", importedSchedules);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync();
|
||||
|
||||
result.Success = true;
|
||||
result.Message = "Import completato con successo";
|
||||
|
||||
_logger.LogInformation("Import completato con successo. Record totali: {Total}",
|
||||
result.ProcessedCounts.Profiles + result.ProcessedCounts.Credentials +
|
||||
result.ProcessedCounts.KeyAssociations + result.ProcessedCounts.ProfileSchedules);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Success = false;
|
||||
result.Message = $"Errore durante l'import: {ex.Message}";
|
||||
result.Errors.Add(ex.ToString());
|
||||
_logger.LogError(ex, "Errore durante l'importazione del backup");
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
result.Duration = stopwatch.Elapsed;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Valida un file di backup
|
||||
/// </summary>
|
||||
public async Task<BackupOperationResult> ValidateBackupAsync(string backupFilePath)
|
||||
{
|
||||
var result = new BackupOperationResult();
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(backupFilePath))
|
||||
{
|
||||
result.Errors.Add("File non trovato");
|
||||
return result;
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(backupFilePath);
|
||||
var backupData = JsonSerializer.Deserialize<SystemBackupData>(json);
|
||||
|
||||
if (backupData == null)
|
||||
{
|
||||
result.Errors.Add("Formato backup non valido");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Validazioni
|
||||
if (backupData.Metadata == null)
|
||||
{
|
||||
result.Errors.Add("Metadata mancanti");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(backupData.Metadata?.Version))
|
||||
{
|
||||
result.Warnings.Add("Versione backup non specificata");
|
||||
}
|
||||
|
||||
result.ProcessedCounts.Profiles = backupData.Profiles?.Count ?? 0;
|
||||
result.ProcessedCounts.Credentials = backupData.Credentials?.Count ?? 0;
|
||||
result.ProcessedCounts.KeyAssociations = backupData.KeyAssociations?.Count ?? 0;
|
||||
result.ProcessedCounts.ProfileSchedules = backupData.ProfileSchedules?.Count ?? 0;
|
||||
|
||||
result.Success = result.Errors.Count == 0;
|
||||
result.Message = result.Success ? "Backup valido" : "Backup contiene errori";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Success = false;
|
||||
result.Message = $"Errore validazione: {ex.Message}";
|
||||
result.Errors.Add(ex.ToString());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene informazioni backup senza importare
|
||||
/// </summary>
|
||||
public async Task<SystemBackupData?> GetBackupInfoAsync(string backupFilePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(backupFilePath))
|
||||
return null;
|
||||
|
||||
var json = await File.ReadAllTextAsync(backupFilePath);
|
||||
return JsonSerializer.Deserialize<SystemBackupData>(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Errore lettura info backup da {FilePath}", backupFilePath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#region Private Export Methods
|
||||
|
||||
private async Task<List<DataCouplerProfileBackup>> ExportProfilesAsync(bool onlyActive)
|
||||
{
|
||||
var query = _context.DataCouplerProfiles
|
||||
.Include(p => p.SourceCredential)
|
||||
.Include(p => p.DestinationCredential)
|
||||
.AsQueryable();
|
||||
|
||||
if (onlyActive)
|
||||
query = query.Where(p => p.IsActive);
|
||||
|
||||
var profiles = await query.ToListAsync();
|
||||
|
||||
return profiles.Select(p => new DataCouplerProfileBackup
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Description = p.Description,
|
||||
SourceType = p.SourceType,
|
||||
SourceCredentialName = p.SourceCredential?.Name,
|
||||
SourceDatabaseName = p.SourceDatabaseName,
|
||||
SourceSchema = p.SourceSchema,
|
||||
SourceTable = p.SourceTable,
|
||||
SourceCustomQuery = p.SourceCustomQuery,
|
||||
SourceFilePath = p.SourceFilePath,
|
||||
DestinationType = p.DestinationType,
|
||||
DestinationCredentialName = p.DestinationCredential?.Name,
|
||||
DestinationSchema = p.DestinationSchema,
|
||||
DestinationTable = p.DestinationTable,
|
||||
DestinationEndpoint = p.DestinationEndpoint,
|
||||
FieldMappings = !string.IsNullOrEmpty(p.FieldMappingJson) ?
|
||||
System.Text.Json.JsonSerializer.Deserialize<List<FieldMappingDto>>(p.FieldMappingJson) ?? new List<FieldMappingDto>() :
|
||||
new List<FieldMappingDto>(),
|
||||
SourceKeyField = p.SourceKeyField,
|
||||
UseRecordAssociations = p.UseRecordAssociations,
|
||||
CreatedBy = p.CreatedBy,
|
||||
CreatedAt = p.CreatedAt,
|
||||
LastUsedAt = p.LastUsedAt,
|
||||
IsActive = p.IsActive
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<CredentialBackup>> ExportCredentialsAsync(bool onlyActive)
|
||||
{
|
||||
var query = _context.Credentials.AsQueryable();
|
||||
|
||||
if (onlyActive)
|
||||
query = query.Where(c => c.IsActive);
|
||||
|
||||
var credentials = await query.ToListAsync();
|
||||
|
||||
return credentials.Select(c => new CredentialBackup
|
||||
{
|
||||
Id = c.Id,
|
||||
Name = c.Name,
|
||||
Type = c.Type,
|
||||
DatabaseType = c.DatabaseType,
|
||||
Host = c.Host,
|
||||
Port = c.Port,
|
||||
DatabaseName = c.DatabaseName,
|
||||
Username = c.Username,
|
||||
// Password, API Keys e Token NON inclusi per sicurezza
|
||||
CommandTimeout = c.CommandTimeout,
|
||||
TimeoutSeconds = c.TimeoutSeconds,
|
||||
IgnoreSslErrors = c.IgnoreSslErrors,
|
||||
RestServiceType = c.RestServiceType,
|
||||
Headers = c.Headers,
|
||||
AdditionalParameters = c.AdditionalParameters,
|
||||
CreatedAt = c.CreatedAt,
|
||||
UpdatedAt = c.UpdatedAt,
|
||||
CreatedBy = c.CreatedBy,
|
||||
IsActive = c.IsActive
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<KeyAssociationBackup>> ExportKeyAssociationsAsync(bool onlyActive)
|
||||
{
|
||||
var query = _context.KeyAssociations.AsQueryable();
|
||||
|
||||
if (onlyActive)
|
||||
query = query.Where(ka => ka.IsActive);
|
||||
|
||||
var associations = await query.ToListAsync();
|
||||
|
||||
return associations.Select(ka => new KeyAssociationBackup
|
||||
{
|
||||
Id = ka.Id,
|
||||
KeyValue = ka.KeyValue,
|
||||
SourceKeyField = ka.SourceKeyField,
|
||||
DestinationKeyField = ka.DestinationKeyField,
|
||||
DestinationEntity = ka.DestinationEntity,
|
||||
DestinationId = ka.DestinationId,
|
||||
RestCredentialName = ka.RestCredentialName,
|
||||
CreatedAt = ka.CreatedAt,
|
||||
UpdatedAt = ka.UpdatedAt,
|
||||
LastVerifiedAt = ka.LastVerifiedAt,
|
||||
IsActive = ka.IsActive,
|
||||
DataHash = ka.Data_Hash,
|
||||
SourcesInfo = ka.SourcesInfo,
|
||||
AdditionalInfo = ka.AdditionalInfo
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private Task<List<ProfileScheduleBackup>> ExportProfileSchedulesAsync(bool onlyActive)
|
||||
{
|
||||
// Nota: Assumendo che esista una tabella ProfileSchedules
|
||||
// Se non esiste, questo metodo restituirà una lista vuota
|
||||
var schedules = new List<ProfileScheduleBackup>();
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Implementare quando la tabella ProfileSchedules sarà disponibile
|
||||
// var query = _context.ProfileSchedules.AsQueryable();
|
||||
// if (onlyActive)
|
||||
// query = query.Where(ps => ps.IsActive);
|
||||
// var profileSchedules = await query.ToListAsync();
|
||||
// schedules = profileSchedules.Select(ps => new ProfileScheduleBackup { ... }).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Tabella ProfileSchedules non disponibile, saltando export");
|
||||
}
|
||||
|
||||
return Task.FromResult(schedules);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Import Methods
|
||||
|
||||
private async Task<int> ImportProfilesAsync(List<DataCouplerProfileBackup> profiles, RestoreOptions options)
|
||||
{
|
||||
int imported = 0;
|
||||
|
||||
foreach (var profileBackup in profiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _context.DataCouplerProfiles
|
||||
.FirstOrDefaultAsync(p => p.Name == profileBackup.Name);
|
||||
|
||||
if (existing != null && !options.OverwriteExisting)
|
||||
{
|
||||
_logger.LogDebug("Profilo {Name} già esistente, saltando", profileBackup.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var profile = new DataCouplerProfile
|
||||
{
|
||||
Name = profileBackup.Name,
|
||||
Description = profileBackup.Description,
|
||||
SourceType = profileBackup.SourceType,
|
||||
SourceDatabaseName = profileBackup.SourceDatabaseName,
|
||||
SourceSchema = profileBackup.SourceSchema,
|
||||
SourceTable = profileBackup.SourceTable,
|
||||
SourceCustomQuery = profileBackup.SourceCustomQuery,
|
||||
SourceFilePath = profileBackup.SourceFilePath,
|
||||
DestinationType = profileBackup.DestinationType,
|
||||
DestinationSchema = profileBackup.DestinationSchema,
|
||||
DestinationTable = profileBackup.DestinationTable,
|
||||
DestinationEndpoint = profileBackup.DestinationEndpoint,
|
||||
FieldMappingJson = profileBackup.FieldMappings != null ?
|
||||
System.Text.Json.JsonSerializer.Serialize(profileBackup.FieldMappings) :
|
||||
string.Empty,
|
||||
SourceKeyField = profileBackup.SourceKeyField,
|
||||
UseRecordAssociations = profileBackup.UseRecordAssociations,
|
||||
CreatedBy = options.ImportedBy ?? profileBackup.CreatedBy,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = profileBackup.IsActive
|
||||
};
|
||||
|
||||
// Risolvi credential IDs per nome se esistenti
|
||||
if (!string.IsNullOrEmpty(profileBackup.SourceCredentialName))
|
||||
{
|
||||
var sourceCred = await _context.Credentials
|
||||
.FirstOrDefaultAsync(c => c.Name == profileBackup.SourceCredentialName);
|
||||
profile.SourceCredentialId = sourceCred?.Id;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(profileBackup.DestinationCredentialName))
|
||||
{
|
||||
var destCred = await _context.Credentials
|
||||
.FirstOrDefaultAsync(c => c.Name == profileBackup.DestinationCredentialName);
|
||||
profile.DestinationCredentialId = destCred?.Id;
|
||||
}
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
// Update existing
|
||||
_context.Entry(existing).CurrentValues.SetValues(profile);
|
||||
existing.Id = profileBackup.Id; // Mantieni ID originale se sovrascriviamo
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new
|
||||
_context.DataCouplerProfiles.Add(profile);
|
||||
}
|
||||
|
||||
imported++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Errore importazione profilo {Name}", profileBackup.Name);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return imported;
|
||||
}
|
||||
|
||||
private async Task<int> ImportCredentialsAsync(List<CredentialBackup> credentials, RestoreOptions options)
|
||||
{
|
||||
int imported = 0;
|
||||
|
||||
foreach (var credBackup in credentials)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _context.Credentials
|
||||
.FirstOrDefaultAsync(c => c.Name == credBackup.Name);
|
||||
|
||||
if (existing != null && !options.OverwriteExisting)
|
||||
{
|
||||
_logger.LogDebug("Credenziale {Name} già esistente, saltando", credBackup.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var credential = new CredentialEntity
|
||||
{
|
||||
Name = credBackup.Name,
|
||||
Type = credBackup.Type,
|
||||
DatabaseType = credBackup.DatabaseType,
|
||||
Host = credBackup.Host,
|
||||
Port = credBackup.Port,
|
||||
DatabaseName = credBackup.DatabaseName,
|
||||
Username = credBackup.Username,
|
||||
// Password, API Keys e Token dovranno essere riconfigurati manualmente
|
||||
CommandTimeout = credBackup.CommandTimeout,
|
||||
TimeoutSeconds = credBackup.TimeoutSeconds,
|
||||
IgnoreSslErrors = credBackup.IgnoreSslErrors,
|
||||
RestServiceType = credBackup.RestServiceType,
|
||||
Headers = credBackup.Headers,
|
||||
AdditionalParameters = credBackup.AdditionalParameters,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = options.ImportedBy ?? credBackup.CreatedBy,
|
||||
IsActive = credBackup.IsActive
|
||||
};
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
// Update existing (preserva password esistenti)
|
||||
var oldPassword = existing.EncryptedPassword;
|
||||
var oldApiKey = existing.EncryptedApiKey;
|
||||
var oldAuthToken = existing.EncryptedAuthToken;
|
||||
|
||||
_context.Entry(existing).CurrentValues.SetValues(credential);
|
||||
existing.Id = credBackup.Id;
|
||||
existing.EncryptedPassword = oldPassword;
|
||||
existing.EncryptedApiKey = oldApiKey;
|
||||
existing.EncryptedAuthToken = oldAuthToken;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new
|
||||
_context.Credentials.Add(credential);
|
||||
}
|
||||
|
||||
imported++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Errore importazione credenziale {Name}", credBackup.Name);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return imported;
|
||||
}
|
||||
|
||||
private async Task<int> ImportKeyAssociationsAsync(List<KeyAssociationBackup> associations, RestoreOptions options)
|
||||
{
|
||||
int imported = 0;
|
||||
|
||||
foreach (var assocBackup in associations)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _context.KeyAssociations
|
||||
.FirstOrDefaultAsync(ka => ka.KeyValue == assocBackup.KeyValue &&
|
||||
ka.DestinationEntity == assocBackup.DestinationEntity &&
|
||||
ka.RestCredentialName == assocBackup.RestCredentialName);
|
||||
|
||||
if (existing != null && !options.OverwriteExisting)
|
||||
{
|
||||
_logger.LogDebug("Associazione {Key}-{Entity} già esistente, saltando",
|
||||
assocBackup.KeyValue, assocBackup.DestinationEntity);
|
||||
continue;
|
||||
}
|
||||
|
||||
var association = new KeyAssociation
|
||||
{
|
||||
KeyValue = assocBackup.KeyValue,
|
||||
SourceKeyField = assocBackup.SourceKeyField,
|
||||
DestinationKeyField = assocBackup.DestinationKeyField,
|
||||
DestinationEntity = assocBackup.DestinationEntity,
|
||||
DestinationId = assocBackup.DestinationId,
|
||||
RestCredentialName = assocBackup.RestCredentialName,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = assocBackup.IsActive,
|
||||
Data_Hash = assocBackup.DataHash,
|
||||
SourcesInfo = assocBackup.SourcesInfo,
|
||||
AdditionalInfo = assocBackup.AdditionalInfo
|
||||
};
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
// Update existing
|
||||
_context.Entry(existing).CurrentValues.SetValues(association);
|
||||
existing.Id = assocBackup.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new
|
||||
_context.KeyAssociations.Add(association);
|
||||
}
|
||||
|
||||
imported++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Errore importazione associazione {Key}-{Entity}",
|
||||
assocBackup.KeyValue, assocBackup.DestinationEntity);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return imported;
|
||||
}
|
||||
|
||||
private Task<int> ImportProfileSchedulesAsync(List<ProfileScheduleBackup> schedules, RestoreOptions options)
|
||||
{
|
||||
int imported = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Implementare quando la tabella ProfileSchedules sarà disponibile
|
||||
_logger.LogInformation("Import ProfileSchedules saltato - tabella non ancora implementata");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Errore importazione schedule profili");
|
||||
}
|
||||
|
||||
return Task.FromResult(imported);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using CredentialManager.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Data_Coupler.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Servizio per l'esecuzione automatica dei trasferimenti dati basati sui profili
|
||||
/// </summary>
|
||||
public interface IDataTransferService
|
||||
{
|
||||
Task<DataTransferResult> ExecuteProfileAsync(DataCouplerProfile profile, string? sourceDatabaseOverride = null, string? destinationDatabaseOverride = null);
|
||||
}
|
||||
|
||||
public class DataTransferResult
|
||||
{
|
||||
public bool IsSuccess { get; set; }
|
||||
public int RecordsProcessed { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTime StartTime { get; set; }
|
||||
public DateTime EndTime { get; set; }
|
||||
public List<string> ErrorDetails { get; set; } = new();
|
||||
public Dictionary<string, object> AdditionalInfo { get; set; } = new();
|
||||
|
||||
public TimeSpan Duration => EndTime - StartTime;
|
||||
}
|
||||
|
||||
public class DataTransferService : IDataTransferService
|
||||
{
|
||||
private readonly IScheduledProfileExecutionService _scheduledExecutionService;
|
||||
private readonly ILogger<DataTransferService> _logger;
|
||||
|
||||
public DataTransferService(
|
||||
IScheduledProfileExecutionService scheduledExecutionService,
|
||||
ILogger<DataTransferService> logger)
|
||||
{
|
||||
_scheduledExecutionService = scheduledExecutionService ?? throw new ArgumentNullException(nameof(scheduledExecutionService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<DataTransferResult> ExecuteProfileAsync(DataCouplerProfile profile, string? sourceDatabaseOverride = null, string? destinationDatabaseOverride = null)
|
||||
{
|
||||
var result = new DataTransferResult
|
||||
{
|
||||
StartTime = DateTime.Now // Usa l'ora locale per coerenza con le schedulazioni
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Iniziando esecuzione profilo {ProfileName} (ID: {ProfileId})",
|
||||
profile.Name, profile.Id);
|
||||
|
||||
// Validazione del profilo
|
||||
var validationResult = ValidateProfile(profile);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
result.IsSuccess = false;
|
||||
result.ErrorMessage = validationResult.ErrorMessage;
|
||||
result.EndTime = DateTime.Now; // Usa l'ora locale per coerenza
|
||||
return result;
|
||||
}
|
||||
|
||||
// Controlla se il profilo ha file come sorgente e blocca l'esecuzione
|
||||
if (profile.SourceType?.ToLower() == "file")
|
||||
{
|
||||
result.IsSuccess = false;
|
||||
result.ErrorMessage = "I profili con file come sorgente non sono supportati nelle schedulazioni per motivi di sicurezza.";
|
||||
result.EndTime = DateTime.Now; // Usa l'ora locale per coerenza
|
||||
_logger.LogWarning("Tentativo di esecuzione di profilo con file come sorgente bloccato: {ProfileName}", profile.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Applica override del database se specificati
|
||||
var profileToExecute = await ApplyDatabaseOverrides(profile, sourceDatabaseOverride, destinationDatabaseOverride);
|
||||
|
||||
// Utilizza il servizio esistente per l'esecuzione
|
||||
var executionResult = await _scheduledExecutionService.ExecuteProfileAsync(profileToExecute.Id);
|
||||
|
||||
result.IsSuccess = executionResult.Success;
|
||||
result.RecordsProcessed = executionResult.RecordsProcessed;
|
||||
result.ErrorMessage = executionResult.Success ? null : executionResult.Message;
|
||||
result.EndTime = DateTime.Now; // Usa l'ora locale per coerenza
|
||||
|
||||
if (executionResult.Success)
|
||||
{
|
||||
result.AdditionalInfo["ExecutionDuration"] = executionResult.Duration.ToString();
|
||||
_logger.LogInformation("Profilo {ProfileName} eseguito con successo. " +
|
||||
"Record processati: {RecordsProcessed}, Durata: {Duration}ms",
|
||||
profile.Name, result.RecordsProcessed, result.Duration.TotalMilliseconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.ErrorDetails.Add(executionResult.Message);
|
||||
_logger.LogError("Errore nell'esecuzione del profilo {ProfileName}: {ErrorMessage}",
|
||||
profile.Name, executionResult.Message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore durante l'esecuzione del profilo {ProfileName} (ID: {ProfileId})",
|
||||
profile.Name, profile.Id);
|
||||
|
||||
result.IsSuccess = false;
|
||||
result.ErrorMessage = ex.Message;
|
||||
result.ErrorDetails.Add($"Exception: {ex.GetType().Name} - {ex.Message}");
|
||||
if (ex.InnerException != null)
|
||||
{
|
||||
result.ErrorDetails.Add($"Inner Exception: {ex.InnerException.Message}");
|
||||
}
|
||||
result.EndTime = DateTime.Now; // Usa l'ora locale per coerenza
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DataCouplerProfile> ApplyDatabaseOverrides(DataCouplerProfile originalProfile,
|
||||
string? sourceDatabaseOverride, string? destinationDatabaseOverride)
|
||||
{
|
||||
// Se non ci sono override, restituisce il profilo originale
|
||||
if (string.IsNullOrEmpty(sourceDatabaseOverride) && string.IsNullOrEmpty(destinationDatabaseOverride))
|
||||
{
|
||||
return originalProfile;
|
||||
}
|
||||
|
||||
// Crea una copia del profilo con gli override applicati
|
||||
// In un'implementazione reale, potresti voler creare una copia più sofisticata
|
||||
// o utilizzare un metodo di clonazione appropriato
|
||||
var profileCopy = new DataCouplerProfile
|
||||
{
|
||||
Id = originalProfile.Id,
|
||||
Name = originalProfile.Name,
|
||||
Description = originalProfile.Description,
|
||||
SourceType = originalProfile.SourceType,
|
||||
SourceCredentialId = originalProfile.SourceCredentialId,
|
||||
SourceDatabaseName = originalProfile.SourceDatabaseName,
|
||||
SourceTable = originalProfile.SourceTable,
|
||||
SourceSchema = originalProfile.SourceSchema,
|
||||
SourceCustomQuery = originalProfile.SourceCustomQuery,
|
||||
SourceFilePath = originalProfile.SourceFilePath,
|
||||
DestinationType = originalProfile.DestinationType,
|
||||
DestinationCredentialId = originalProfile.DestinationCredentialId,
|
||||
DestinationTable = originalProfile.DestinationTable,
|
||||
DestinationSchema = originalProfile.DestinationSchema,
|
||||
DestinationEndpoint = originalProfile.DestinationEndpoint,
|
||||
FieldMappingJson = originalProfile.FieldMappingJson,
|
||||
SourceKeyField = originalProfile.SourceKeyField,
|
||||
UseRecordAssociations = originalProfile.UseRecordAssociations,
|
||||
IsActive = originalProfile.IsActive,
|
||||
CreatedAt = originalProfile.CreatedAt,
|
||||
LastUsedAt = originalProfile.LastUsedAt,
|
||||
CreatedBy = originalProfile.CreatedBy
|
||||
};
|
||||
|
||||
// TODO: Implementare l'applicazione degli override del database
|
||||
// Questo richiederebbe di modificare temporaneamente la stringa di connessione
|
||||
// delle credenziali per puntare al database specificato
|
||||
|
||||
_logger.LogInformation("Applicazione override database - Source: {SourceDB}, Destination: {DestDB}",
|
||||
sourceDatabaseOverride ?? "none", destinationDatabaseOverride ?? "none");
|
||||
|
||||
return await Task.FromResult(profileCopy);
|
||||
}
|
||||
|
||||
private (bool IsValid, string? ErrorMessage) ValidateProfile(DataCouplerProfile profile)
|
||||
{
|
||||
if (profile == null)
|
||||
return (false, "Profilo non specificato");
|
||||
|
||||
if (string.IsNullOrEmpty(profile.Name))
|
||||
return (false, "Nome profilo non specificato");
|
||||
|
||||
if (string.IsNullOrEmpty(profile.SourceType))
|
||||
return (false, "Tipo sorgente non specificato");
|
||||
|
||||
if (string.IsNullOrEmpty(profile.DestinationType))
|
||||
return (false, "Tipo destinazione non specificato");
|
||||
|
||||
if (!profile.SourceCredentialId.HasValue)
|
||||
return (false, "Credenziale sorgente non specificata");
|
||||
|
||||
if (!profile.DestinationCredentialId.HasValue)
|
||||
return (false, "Credenziale destinazione non specificata");
|
||||
|
||||
// Validazioni specifiche per tipo sorgente
|
||||
if (profile.SourceType == "database")
|
||||
{
|
||||
if (string.IsNullOrEmpty(profile.SourceTable) && string.IsNullOrEmpty(profile.SourceCustomQuery))
|
||||
return (false, "Tabella sorgente o query personalizzata deve essere specificata");
|
||||
}
|
||||
else if (profile.SourceType == "file")
|
||||
{
|
||||
if (string.IsNullOrEmpty(profile.SourceFilePath))
|
||||
return (false, "Percorso file sorgente non specificato");
|
||||
}
|
||||
|
||||
// Validazioni specifiche per tipo destinazione
|
||||
if (profile.DestinationType == "database")
|
||||
{
|
||||
if (string.IsNullOrEmpty(profile.DestinationTable))
|
||||
return (false, "Tabella destinazione non specificata");
|
||||
}
|
||||
else if (profile.DestinationType == "rest")
|
||||
{
|
||||
if (string.IsNullOrEmpty(profile.DestinationEndpoint))
|
||||
return (false, "Endpoint REST destinazione non specificato");
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Data_Coupler.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Servizio utility per la gestione di date, orari e formattazione
|
||||
/// Utilizza il formato 24h per coerenza con il sistema
|
||||
/// </summary>
|
||||
public static class DateTimeHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Formato orario 24h standard utilizzato in tutto il sistema
|
||||
/// </summary>
|
||||
public const string TimeFormat24H = "HH:mm";
|
||||
|
||||
/// <summary>
|
||||
/// Formato data/ora 24h completo utilizzato per il logging e la visualizzazione
|
||||
/// </summary>
|
||||
public const string DateTimeFormat24H = "dd/MM/yyyy HH:mm:ss";
|
||||
|
||||
/// <summary>
|
||||
/// Formato data/ora 24h per i log dettagliati
|
||||
/// </summary>
|
||||
public const string DetailedDateTimeFormat24H = "dd/MM/yyyy HH:mm:ss.fff";
|
||||
|
||||
/// <summary>
|
||||
/// Cultura italiana per la formattazione (formato 24h di default)
|
||||
/// </summary>
|
||||
public static readonly CultureInfo ItalianCulture = new("it-IT");
|
||||
|
||||
/// <summary>
|
||||
/// Converte un TimeSpan in stringa formato 24h (HH:mm)
|
||||
/// </summary>
|
||||
public static string FormatTime24H(TimeSpan time)
|
||||
{
|
||||
return time.ToString(TimeFormat24H);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converte un DateTime in stringa formato 24h (dd/MM/yyyy HH:mm:ss)
|
||||
/// </summary>
|
||||
public static string FormatDateTime24H(DateTime dateTime)
|
||||
{
|
||||
return dateTime.ToString(DateTimeFormat24H, ItalianCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converte un DateTime in stringa formato 24h dettagliato con millisecondi
|
||||
/// </summary>
|
||||
public static string FormatDateTimeDetailed24H(DateTime dateTime)
|
||||
{
|
||||
return dateTime.ToString(DetailedDateTimeFormat24H, ItalianCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prova a parsare una stringa orario in formato 24h
|
||||
/// </summary>
|
||||
public static bool TryParseTime24H(string? timeString, out TimeSpan time)
|
||||
{
|
||||
time = default;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(timeString))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TimeSpan.TryParseExact(timeString.Trim(), TimeFormat24H, ItalianCulture, out time);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prova a parsare una stringa data/ora in formato 24h
|
||||
/// </summary>
|
||||
public static bool TryParseDateTime24H(string? dateTimeString, out DateTime dateTime)
|
||||
{
|
||||
dateTime = default;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dateTimeString))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return DateTime.TryParseExact(dateTimeString.Trim(), DateTimeFormat24H, ItalianCulture, DateTimeStyles.None, out dateTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene l'ora corrente locale formattata in 24h
|
||||
/// </summary>
|
||||
public static string GetCurrentTime24H()
|
||||
{
|
||||
return FormatTime24H(DateTime.Now.TimeOfDay);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene la data/ora corrente locale formattata in 24h
|
||||
/// </summary>
|
||||
public static string GetCurrentDateTime24H()
|
||||
{
|
||||
return FormatDateTime24H(DateTime.Now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Valida se una stringa rappresenta un orario valido nel formato 24h
|
||||
/// </summary>
|
||||
public static bool IsValidTime24H(string? timeString)
|
||||
{
|
||||
return TryParseTime24H(timeString, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Valida se una stringa rappresenta una data/ora valida nel formato 24h
|
||||
/// </summary>
|
||||
public static bool IsValidDateTime24H(string? dateTimeString)
|
||||
{
|
||||
return TryParseDateTime24H(dateTimeString, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converte un orario dal formato 12h al formato 24h se necessario
|
||||
/// </summary>
|
||||
public static string? ConvertTo24H(string? timeString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(timeString))
|
||||
{
|
||||
return timeString;
|
||||
}
|
||||
|
||||
// Se è già in formato 24h, restituisci così com'è
|
||||
if (TryParseTime24H(timeString, out var time24))
|
||||
{
|
||||
return FormatTime24H(time24);
|
||||
}
|
||||
|
||||
// Prova a parsare dal formato 12h
|
||||
if (DateTime.TryParse(timeString.Trim(), ItalianCulture, DateTimeStyles.None, out var parsed))
|
||||
{
|
||||
return FormatTime24H(parsed.TimeOfDay);
|
||||
}
|
||||
|
||||
return null; // Formato non riconosciuto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calcola il tempo rimanente fino al prossimo orario schedulato
|
||||
/// </summary>
|
||||
public static TimeSpan TimeUntilNextSchedule(TimeSpan scheduledTime, DateTime? referenceTime = null)
|
||||
{
|
||||
var now = referenceTime ?? DateTime.Now;
|
||||
var currentTime = now.TimeOfDay;
|
||||
|
||||
// Se l'orario programmato è già passato oggi, programma per domani
|
||||
if (currentTime > scheduledTime)
|
||||
{
|
||||
var tomorrow = now.Date.AddDays(1);
|
||||
var nextExecution = tomorrow.Add(scheduledTime);
|
||||
return nextExecution - now;
|
||||
}
|
||||
else
|
||||
{
|
||||
var today = now.Date;
|
||||
var nextExecution = today.Add(scheduledTime);
|
||||
return nextExecution - now;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene il nome del giorno della settimana in italiano
|
||||
/// </summary>
|
||||
public static string GetDayOfWeekName(DayOfWeek dayOfWeek)
|
||||
{
|
||||
return dayOfWeek switch
|
||||
{
|
||||
DayOfWeek.Sunday => "Domenica",
|
||||
DayOfWeek.Monday => "Lunedì",
|
||||
DayOfWeek.Tuesday => "Martedì",
|
||||
DayOfWeek.Wednesday => "Mercoledì",
|
||||
DayOfWeek.Thursday => "Giovedì",
|
||||
DayOfWeek.Friday => "Venerdì",
|
||||
DayOfWeek.Saturday => "Sabato",
|
||||
_ => dayOfWeek.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene il nome del giorno della settimana in italiano tramite indice (0=Domenica)
|
||||
/// </summary>
|
||||
public static string GetDayOfWeekName(int dayOfWeekIndex)
|
||||
{
|
||||
if (dayOfWeekIndex < 0 || dayOfWeekIndex > 6)
|
||||
{
|
||||
return "Non valido";
|
||||
}
|
||||
|
||||
return GetDayOfWeekName((DayOfWeek)dayOfWeekIndex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Data_Coupler.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Risultato dell'esecuzione di un profilo schedulato
|
||||
/// </summary>
|
||||
public class ProfileExecutionResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public int RecordsProcessed { get; set; }
|
||||
public DateTime ExecutionTime { get; set; }
|
||||
public TimeSpan Duration { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interfaccia per l'esecuzione di profili schedulati
|
||||
/// </summary>
|
||||
public interface IScheduledProfileExecutionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Esegue un profilo Data Coupler specificato dall'ID
|
||||
/// </summary>
|
||||
Task<ProfileExecutionResult> ExecuteProfileAsync(int profileId);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
using CredentialManager.Models;
|
||||
using CredentialManager.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Data_Coupler.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Servizio background che gestisce l'esecuzione automatica dei profili schedulati
|
||||
/// Controlla ogni minuto se ci sono profili da eseguire secondo la schedulazione impostata
|
||||
/// </summary>
|
||||
public class ScheduledExecutionBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<ScheduledExecutionBackgroundService> _logger;
|
||||
private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1); // Controlla ogni minuto
|
||||
|
||||
public ScheduledExecutionBackgroundService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<ScheduledExecutionBackgroundService> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Servizio di schedulazione automatica avviato. Controllo ogni {CheckInterval} minuti.",
|
||||
_checkInterval.TotalMinutes);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CheckAndExecuteScheduledProfiles();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore durante il controllo delle schedulazioni");
|
||||
}
|
||||
|
||||
// Attendi il prossimo controllo
|
||||
await Task.Delay(_checkInterval, stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Servizio di schedulazione automatica arrestato.");
|
||||
}
|
||||
|
||||
private async Task CheckAndExecuteScheduledProfiles()
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var scheduleService = scope.ServiceProvider.GetRequiredService<IProfileScheduleService>();
|
||||
var executionService = scope.ServiceProvider.GetRequiredService<IScheduledProfileExecutionService>();
|
||||
var profileService = scope.ServiceProvider.GetRequiredService<IDataCouplerProfileService>();
|
||||
|
||||
try
|
||||
{
|
||||
// Ottieni tutte le schedulazioni attive
|
||||
var activeSchedules = await scheduleService.GetActiveSchedulesAsync();
|
||||
var currentTime = DateTime.Now; // Usa l'ora locale per il confronto con le schedulazioni
|
||||
|
||||
_logger.LogDebug("Controllo schedulazioni: {ScheduleCount} schedulazioni attive alle {CurrentTime}",
|
||||
activeSchedules.Count, DateTimeHelper.FormatDateTime24H(currentTime));
|
||||
|
||||
foreach (var schedule in activeSchedules)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ShouldExecuteSchedule(schedule, currentTime))
|
||||
{
|
||||
_logger.LogInformation("Esecuzione schedulata per profilo: {ProfileName} (Schedule: {ScheduleName})",
|
||||
schedule.Profile?.Name ?? "N/A", schedule.Name);
|
||||
|
||||
// Esegui il profilo
|
||||
var result = await executionService.ExecuteProfileAsync(schedule.ProfileId);
|
||||
|
||||
// Aggiorna la schedulazione
|
||||
await UpdateScheduleAfterExecution(scheduleService, schedule, currentTime, result.Success);
|
||||
|
||||
// Log del risultato
|
||||
if (result.Success)
|
||||
{
|
||||
_logger.LogInformation("Esecuzione schedulata completata con successo per {ProfileName}. " +
|
||||
"Record processati: {RecordsProcessed}, Durata: {Duration}ms",
|
||||
schedule.Profile?.Name ?? "N/A", result.RecordsProcessed, result.Duration.TotalMilliseconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Esecuzione schedulata fallita per {ProfileName}: {ErrorMessage}",
|
||||
schedule.Profile?.Name ?? "N/A", result.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore durante l'esecuzione della schedulazione {ScheduleName} per il profilo {ProfileName}",
|
||||
schedule.Name, schedule.Profile?.Name ?? "N/A");
|
||||
|
||||
// Aggiorna la schedulazione anche in caso di errore
|
||||
try
|
||||
{
|
||||
await UpdateScheduleAfterExecution(scheduleService, schedule, currentTime, false);
|
||||
}
|
||||
catch (Exception updateEx)
|
||||
{
|
||||
_logger.LogError(updateEx, "Errore durante l'aggiornamento della schedulazione {ScheduleName} dopo l'errore",
|
||||
schedule.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore durante il recupero delle schedulazioni attive");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determina se una schedulazione deve essere eseguita in base all'orario corrente
|
||||
/// </summary>
|
||||
private bool ShouldExecuteSchedule(ProfileSchedule schedule, DateTime currentTime)
|
||||
{
|
||||
if (!schedule.IsEnabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Controllo per evitare esecuzioni multiple nella stessa finestra temporale (tolleranza di 1 minuto)
|
||||
if (schedule.LastExecutionTime.HasValue)
|
||||
{
|
||||
var timeSinceLastExecution = currentTime - schedule.LastExecutionTime.Value;
|
||||
if (timeSinceLastExecution.TotalMinutes < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return schedule.ScheduleType.ToLower() switch
|
||||
{
|
||||
"once" => ShouldExecuteOnceSchedule(schedule, currentTime),
|
||||
"daily" => ShouldExecuteDailySchedule(schedule, currentTime),
|
||||
"weekly" => ShouldExecuteWeeklySchedule(schedule, currentTime),
|
||||
"monthly" => ShouldExecuteMonthlySchedule(schedule, currentTime),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private bool ShouldExecuteOnceSchedule(ProfileSchedule schedule, DateTime currentTime)
|
||||
{
|
||||
if (!schedule.ScheduledDateTime.HasValue)
|
||||
{
|
||||
_logger.LogWarning("Schedulazione 'once' senza data/ora specificata: {ScheduleName}", schedule.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
var scheduledTime = schedule.ScheduledDateTime.Value;
|
||||
|
||||
// Esegui se l'orario programmato è passato e non è mai stato eseguito
|
||||
return currentTime >= scheduledTime && !schedule.LastExecutionTime.HasValue;
|
||||
}
|
||||
|
||||
private bool ShouldExecuteDailySchedule(ProfileSchedule schedule, DateTime currentTime)
|
||||
{
|
||||
if (string.IsNullOrEmpty(schedule.DailyTime))
|
||||
{
|
||||
_logger.LogWarning("Schedulazione 'daily' senza orario specificato: {ScheduleName}", schedule.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DateTimeHelper.TryParseTime24H(schedule.DailyTime, out var scheduledTime))
|
||||
{
|
||||
_logger.LogWarning("Formato orario non valido per schedulazione daily {ScheduleName}: {DailyTime} (formato richiesto: HH:mm)",
|
||||
schedule.Name, schedule.DailyTime);
|
||||
return false;
|
||||
}
|
||||
|
||||
var currentTimeOfDay = currentTime.TimeOfDay;
|
||||
var scheduledTimeOfDay = scheduledTime;
|
||||
|
||||
// Verifica se siamo nell'orario giusto (con tolleranza di ±1 minuto)
|
||||
var timeDifference = Math.Abs((currentTimeOfDay - scheduledTimeOfDay).TotalMinutes);
|
||||
|
||||
if (timeDifference > 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Se non è mai stato eseguito, esegui
|
||||
if (!schedule.LastExecutionTime.HasValue)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Se è stato eseguito, verifica che sia passato almeno un giorno
|
||||
var lastExecutionDate = schedule.LastExecutionTime.Value.Date;
|
||||
var currentDate = currentTime.Date;
|
||||
|
||||
return currentDate > lastExecutionDate;
|
||||
}
|
||||
|
||||
private bool ShouldExecuteWeeklySchedule(ProfileSchedule schedule, DateTime currentTime)
|
||||
{
|
||||
if (!schedule.DayOfWeek.HasValue || string.IsNullOrEmpty(schedule.DailyTime))
|
||||
{
|
||||
_logger.LogWarning("Schedulazione 'weekly' senza giorno della settimana o orario specificato: {ScheduleName}",
|
||||
schedule.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DateTimeHelper.TryParseTime24H(schedule.DailyTime, out var scheduledTime))
|
||||
{
|
||||
_logger.LogWarning("Formato orario non valido per schedulazione weekly {ScheduleName}: {DailyTime} (formato richiesto: HH:mm)",
|
||||
schedule.Name, schedule.DailyTime);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verifica il giorno della settimana (0=Domenica, 1=Lunedì, etc.)
|
||||
var currentDayOfWeek = (int)currentTime.DayOfWeek;
|
||||
if (currentDayOfWeek != schedule.DayOfWeek.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verifica l'orario (con tolleranza di ±1 minuto)
|
||||
var currentTimeOfDay = currentTime.TimeOfDay;
|
||||
var timeDifference = Math.Abs((currentTimeOfDay - scheduledTime).TotalMinutes);
|
||||
|
||||
if (timeDifference > 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Se non è mai stato eseguito, esegui
|
||||
if (!schedule.LastExecutionTime.HasValue)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Se è stato eseguito, verifica che sia passata almeno una settimana
|
||||
var daysSinceLastExecution = (currentTime.Date - schedule.LastExecutionTime.Value.Date).TotalDays;
|
||||
|
||||
return daysSinceLastExecution >= 7;
|
||||
}
|
||||
|
||||
private bool ShouldExecuteMonthlySchedule(ProfileSchedule schedule, DateTime currentTime)
|
||||
{
|
||||
if (!schedule.DayOfMonth.HasValue || string.IsNullOrEmpty(schedule.DailyTime))
|
||||
{
|
||||
_logger.LogWarning("Schedulazione 'monthly' senza giorno del mese o orario specificato: {ScheduleName}",
|
||||
schedule.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DateTimeHelper.TryParseTime24H(schedule.DailyTime, out var scheduledTime))
|
||||
{
|
||||
_logger.LogWarning("Formato orario non valido per schedulazione monthly {ScheduleName}: {DailyTime} (formato richiesto: HH:mm)",
|
||||
schedule.Name, schedule.DailyTime);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verifica il giorno del mese
|
||||
var currentDayOfMonth = currentTime.Day;
|
||||
var scheduledDayOfMonth = schedule.DayOfMonth.Value;
|
||||
|
||||
// Gestione per mesi con meno giorni (es. 31 in febbraio diventa ultimo giorno del mese)
|
||||
var daysInCurrentMonth = DateTime.DaysInMonth(currentTime.Year, currentTime.Month);
|
||||
var effectiveScheduledDay = Math.Min(scheduledDayOfMonth, daysInCurrentMonth);
|
||||
|
||||
if (currentDayOfMonth != effectiveScheduledDay)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verifica l'orario (con tolleranza di ±1 minuto)
|
||||
var currentTimeOfDay = currentTime.TimeOfDay;
|
||||
var timeDifference = Math.Abs((currentTimeOfDay - scheduledTime).TotalMinutes);
|
||||
|
||||
if (timeDifference > 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Se non è mai stato eseguito, esegui
|
||||
if (!schedule.LastExecutionTime.HasValue)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Se è stato eseguito, verifica che sia passato almeno un mese
|
||||
var lastExecutionDate = schedule.LastExecutionTime.Value;
|
||||
var monthsSinceLastExecution = ((currentTime.Year - lastExecutionDate.Year) * 12) + currentTime.Month - lastExecutionDate.Month;
|
||||
|
||||
return monthsSinceLastExecution >= 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna la schedulazione dopo l'esecuzione
|
||||
/// </summary>
|
||||
private async Task UpdateScheduleAfterExecution(IProfileScheduleService scheduleService,
|
||||
ProfileSchedule schedule, DateTime executionTime, bool wasSuccessful)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Aggiorna i dati della schedulazione
|
||||
schedule.LastExecutionTime = executionTime;
|
||||
schedule.ExecutionCount++;
|
||||
|
||||
// Calcola il prossimo tempo di esecuzione
|
||||
schedule.NextExecutionTime = CalculateNextExecutionTime(schedule, executionTime);
|
||||
|
||||
// Per schedulazioni "once", disabilita dopo l'esecuzione
|
||||
if (schedule.ScheduleType.ToLower() == "once")
|
||||
{
|
||||
schedule.IsEnabled = false;
|
||||
_logger.LogInformation("Schedulazione 'once' {ScheduleName} disabilitata dopo l'esecuzione", schedule.Name);
|
||||
}
|
||||
|
||||
await scheduleService.UpdateScheduleAsync(schedule);
|
||||
|
||||
_logger.LogDebug("Schedulazione {ScheduleName} aggiornata. Prossima esecuzione: {NextExecution}",
|
||||
schedule.Name, schedule.NextExecutionTime.HasValue ? DateTimeHelper.FormatDateTime24H(schedule.NextExecutionTime.Value) : "N/A");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore durante l'aggiornamento della schedulazione {ScheduleName}", schedule.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calcola il prossimo tempo di esecuzione per una schedulazione
|
||||
/// </summary>
|
||||
private DateTime? CalculateNextExecutionTime(ProfileSchedule schedule, DateTime lastExecution)
|
||||
{
|
||||
if (!schedule.IsEnabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return schedule.ScheduleType.ToLower() switch
|
||||
{
|
||||
"once" => null, // Schedulazione singola non ha prossima esecuzione
|
||||
"daily" => CalculateNextDailyExecution(schedule, lastExecution),
|
||||
"weekly" => CalculateNextWeeklyExecution(schedule, lastExecution),
|
||||
"monthly" => CalculateNextMonthlyExecution(schedule, lastExecution),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private DateTime? CalculateNextDailyExecution(ProfileSchedule schedule, DateTime lastExecution)
|
||||
{
|
||||
if (string.IsNullOrEmpty(schedule.DailyTime) || !DateTimeHelper.TryParseTime24H(schedule.DailyTime, out var scheduledTime))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var nextDate = lastExecution.Date.AddDays(1);
|
||||
return nextDate.Add(scheduledTime);
|
||||
}
|
||||
|
||||
private DateTime? CalculateNextWeeklyExecution(ProfileSchedule schedule, DateTime lastExecution)
|
||||
{
|
||||
if (!schedule.DayOfWeek.HasValue || string.IsNullOrEmpty(schedule.DailyTime) ||
|
||||
!DateTimeHelper.TryParseTime24H(schedule.DailyTime, out var scheduledTime))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var nextDate = lastExecution.Date.AddDays(7);
|
||||
return nextDate.Add(scheduledTime);
|
||||
}
|
||||
|
||||
private DateTime? CalculateNextMonthlyExecution(ProfileSchedule schedule, DateTime lastExecution)
|
||||
{
|
||||
if (!schedule.DayOfMonth.HasValue || string.IsNullOrEmpty(schedule.DailyTime) ||
|
||||
!DateTimeHelper.TryParseTime24H(schedule.DailyTime, out var scheduledTime))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var nextMonth = lastExecution.AddMonths(1);
|
||||
var daysInNextMonth = DateTime.DaysInMonth(nextMonth.Year, nextMonth.Month);
|
||||
var effectiveDay = Math.Min(schedule.DayOfMonth.Value, daysInNextMonth);
|
||||
|
||||
var nextDate = new DateTime(nextMonth.Year, nextMonth.Month, effectiveDay);
|
||||
return nextDate.Add(scheduledTime);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user