Initial commit: EngineeringSync v1.0.0
Vollständige Implementierung des EngineeringSync-Middleware-Tools: - Windows Service (Kestrel :5050) mit FileSystemWatcher + SignalR - WPF Tray-App mit PendingChanges- und Projektverwaltungs-Fenster - Setup-Wizard (8-Schritte-Installer) - SQLite/EF Core Datenschicht (WAL-Modus) - SHA-256-basiertes Debouncing (2s Fenster) - Backup-System mit konfigurierbarer Aufbewahrung Bugfixes & Verbesserungen: - BUG-1: AppDbContext OnConfiguring invertierte Bedingung behoben - BUG-2: Event-Handler-Leak in TrayApp (Fenster-Singleton-Pattern) - BUG-3: ProjectConfigChanged SignalR-Signal in allen CRUD-Endpoints - BUG-5: Rename-Sync löscht alte Datei im Simulations-Ordner - BUG-6: Doppeltes Dispose von SignalR verhindert - BUG-7: Registry-Deinstallation nur EngineeringSync-Eintrag entfernt - S1: Path-Traversal-Schutz via SafeCombine() im SyncManager - E1: FSW Buffer 64KB + automatischer Re-Scan bei Overflow - E2: Retry-Logik (3x) für gesperrte Dateien mit exponentiellem Backoff - E4: Channel.Writer.TryComplete() beim Shutdown - C2: HubMethodNames-Konstanten statt Magic Strings - E3: Pagination in Changes-API (page/pageSize Query-Parameter) - A1: Fire-and-Forget mit try/catch + Logging Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
52
EngineeringSync.Service/Api/ChangesApi.cs
Normal file
52
EngineeringSync.Service/Api/ChangesApi.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using EngineeringSync.Domain.Entities;
|
||||
using EngineeringSync.Infrastructure;
|
||||
using EngineeringSync.Service.Models;
|
||||
using EngineeringSync.Service.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EngineeringSync.Service.Api;
|
||||
|
||||
public static class ChangesApi
|
||||
{
|
||||
public static IEndpointRouteBuilder MapChangesApi(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/changes/{projectId:guid}", async (Guid projectId, IDbContextFactory<AppDbContext> dbFactory,
|
||||
int page = 0, int pageSize = 100) =>
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
var changes = await db.PendingChanges
|
||||
.Where(c => c.ProjectId == projectId && c.Status == ChangeStatus.Pending)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.Skip(page * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
return Results.Ok(changes);
|
||||
});
|
||||
|
||||
app.MapGet("/api/changes/{projectId:guid}/history", async (Guid projectId,
|
||||
IDbContextFactory<AppDbContext> dbFactory) =>
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
var changes = await db.PendingChanges
|
||||
.Where(c => c.ProjectId == projectId && c.Status != ChangeStatus.Pending)
|
||||
.OrderByDescending(c => c.SyncedAt ?? c.CreatedAt)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
return Results.Ok(changes);
|
||||
});
|
||||
|
||||
app.MapPost("/api/sync", async (SyncRequest req, SyncManager syncManager) =>
|
||||
{
|
||||
var result = await syncManager.SyncAsync(req.ChangeIds);
|
||||
return Results.Ok(result);
|
||||
});
|
||||
|
||||
app.MapPost("/api/ignore", async (IgnoreRequest req, SyncManager syncManager) =>
|
||||
{
|
||||
await syncManager.IgnoreAsync(req.ChangeIds);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
173
EngineeringSync.Service/Api/ProjectsApi.cs
Normal file
173
EngineeringSync.Service/Api/ProjectsApi.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using EngineeringSync.Domain.Constants;
|
||||
using EngineeringSync.Domain.Entities;
|
||||
using EngineeringSync.Infrastructure;
|
||||
using EngineeringSync.Service.Hubs;
|
||||
using EngineeringSync.Service.Models;
|
||||
using EngineeringSync.Service.Services;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EngineeringSync.Service.Api;
|
||||
|
||||
public static class ProjectsApi
|
||||
{
|
||||
public static IEndpointRouteBuilder MapProjectsApi(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/projects");
|
||||
|
||||
group.MapGet("/", async (IDbContextFactory<AppDbContext> dbFactory) =>
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
var projects = await db.Projects.OrderBy(p => p.Name).ToListAsync();
|
||||
return Results.Ok(projects);
|
||||
});
|
||||
|
||||
group.MapGet("/{id:guid}", async (Guid id, IDbContextFactory<AppDbContext> dbFactory) =>
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
var project = await db.Projects.FindAsync(id);
|
||||
return project is null ? Results.NotFound() : Results.Ok(project);
|
||||
});
|
||||
|
||||
group.MapPost("/", async (CreateProjectRequest req, IDbContextFactory<AppDbContext> dbFactory,
|
||||
WatcherService watcher, IHubContext<NotificationHub> hubContext, ILoggerFactory loggerFactory) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("ProjectsApi");
|
||||
if (!Directory.Exists(req.EngineeringPath))
|
||||
return Results.BadRequest($"Engineering-Pfad existiert nicht: {req.EngineeringPath}");
|
||||
if (!Directory.Exists(req.SimulationPath))
|
||||
return Results.BadRequest($"Simulations-Pfad existiert nicht: {req.SimulationPath}");
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
var project = new ProjectConfig
|
||||
{
|
||||
Name = req.Name,
|
||||
EngineeringPath = req.EngineeringPath,
|
||||
SimulationPath = req.SimulationPath,
|
||||
FileExtensions = req.FileExtensions,
|
||||
IsActive = req.IsActive,
|
||||
BackupEnabled = req.BackupEnabled,
|
||||
BackupPath = req.BackupPath,
|
||||
MaxBackupsPerFile = req.MaxBackupsPerFile
|
||||
};
|
||||
db.Projects.Add(project);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await watcher.StartWatchingAsync(project);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await watcher.ScanExistingFilesAsync(project); }
|
||||
catch (Exception ex) { logger.LogError(ex, "Fehler beim initialen Scan für Projekt {Id}", project.Id); }
|
||||
});
|
||||
|
||||
await hubContext.Clients.All.SendAsync(HubMethodNames.ProjectConfigChanged, CancellationToken.None);
|
||||
|
||||
return Results.Created($"/api/projects/{project.Id}", project);
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async (Guid id, UpdateProjectRequest req,
|
||||
IDbContextFactory<AppDbContext> dbFactory, WatcherService watcher, IHubContext<NotificationHub> hubContext,
|
||||
ILoggerFactory loggerFactory) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("ProjectsApi");
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
var project = await db.Projects.FindAsync(id);
|
||||
if (project is null) return Results.NotFound();
|
||||
|
||||
if (!Directory.Exists(req.EngineeringPath))
|
||||
return Results.BadRequest($"Engineering-Pfad existiert nicht: {req.EngineeringPath}");
|
||||
if (!Directory.Exists(req.SimulationPath))
|
||||
return Results.BadRequest($"Simulations-Pfad existiert nicht: {req.SimulationPath}");
|
||||
|
||||
project.Name = req.Name;
|
||||
project.EngineeringPath = req.EngineeringPath;
|
||||
project.SimulationPath = req.SimulationPath;
|
||||
project.FileExtensions = req.FileExtensions;
|
||||
project.IsActive = req.IsActive;
|
||||
project.BackupEnabled = req.BackupEnabled;
|
||||
project.BackupPath = req.BackupPath;
|
||||
project.MaxBackupsPerFile = req.MaxBackupsPerFile;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Watcher neu starten (stoppt automatisch den alten)
|
||||
await watcher.StartWatchingAsync(project);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await watcher.ScanExistingFilesAsync(project); }
|
||||
catch (Exception ex) { logger.LogError(ex, "Fehler beim initialen Scan für Projekt {Id}", project.Id); }
|
||||
});
|
||||
|
||||
await hubContext.Clients.All.SendAsync(HubMethodNames.ProjectConfigChanged, CancellationToken.None);
|
||||
|
||||
return Results.Ok(project);
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async (Guid id, IDbContextFactory<AppDbContext> dbFactory,
|
||||
WatcherService watcher, IHubContext<NotificationHub> hubContext) =>
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
var project = await db.Projects.FindAsync(id);
|
||||
if (project is null) return Results.NotFound();
|
||||
|
||||
await watcher.StopWatchingAsync(id);
|
||||
db.Projects.Remove(project);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await hubContext.Clients.All.SendAsync(HubMethodNames.ProjectConfigChanged, CancellationToken.None);
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
group.MapGet("/scan", async (string engineeringPath, string simulationPath, string fileExtensions) =>
|
||||
{
|
||||
if (!Directory.Exists(engineeringPath))
|
||||
return Results.BadRequest($"Engineering-Pfad existiert nicht: {engineeringPath}");
|
||||
if (!Directory.Exists(simulationPath))
|
||||
return Results.BadRequest($"Simulations-Pfad existiert nicht: {simulationPath}");
|
||||
|
||||
var extensions = string.IsNullOrWhiteSpace(fileExtensions) || fileExtensions == "*"
|
||||
? new HashSet<string>()
|
||||
: fileExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var scanAll = extensions.Count == 0;
|
||||
var engFiles = scanAll
|
||||
? Directory.EnumerateFiles(engineeringPath, "*", SearchOption.AllDirectories).ToList()
|
||||
: Directory.EnumerateFiles(engineeringPath, "*", SearchOption.AllDirectories)
|
||||
.Where(f => extensions.Contains(Path.GetExtension(f)))
|
||||
.ToList();
|
||||
|
||||
var results = new List<ScanResultEntry>();
|
||||
foreach (var engFile in engFiles)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(engineeringPath, engFile);
|
||||
var simFile = Path.Combine(simulationPath, relativePath);
|
||||
|
||||
var engInfo = new FileInfo(engFile);
|
||||
var engHash = await FileHasher.ComputeAsync(engFile);
|
||||
|
||||
var existsInSim = File.Exists(simFile);
|
||||
var needsSync = !existsInSim;
|
||||
if (existsInSim)
|
||||
{
|
||||
var simHash = await FileHasher.ComputeAsync(simFile);
|
||||
needsSync = engHash != simHash;
|
||||
}
|
||||
|
||||
if (needsSync)
|
||||
{
|
||||
results.Add(new ScanResultEntry(
|
||||
relativePath,
|
||||
existsInSim ? "Modified" : "Created",
|
||||
engInfo.Length,
|
||||
engInfo.LastWriteTimeUtc
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Ok(results);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
23
EngineeringSync.Service/EngineeringSync.Service.csproj
Normal file
23
EngineeringSync.Service/EngineeringSync.Service.csproj
Normal file
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-EngineeringSync.Service-6b7e2096-9433-4768-9912-b8f8fa4ad393</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\EngineeringSync.Domain\EngineeringSync.Domain.csproj" />
|
||||
<ProjectReference Include="..\EngineeringSync.Infrastructure\EngineeringSync.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
9
EngineeringSync.Service/Hubs/NotificationHub.cs
Normal file
9
EngineeringSync.Service/Hubs/NotificationHub.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace EngineeringSync.Service.Hubs;
|
||||
|
||||
public class NotificationHub : Hub
|
||||
{
|
||||
// Clients rufen hier keine Server-Methoden auf.
|
||||
// Der Server pusht via IHubContext<NotificationHub>.
|
||||
}
|
||||
39
EngineeringSync.Service/Models/ApiModels.cs
Normal file
39
EngineeringSync.Service/Models/ApiModels.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
namespace EngineeringSync.Service.Models;
|
||||
|
||||
public record CreateProjectRequest(
|
||||
string Name,
|
||||
string EngineeringPath,
|
||||
string SimulationPath,
|
||||
string FileExtensions,
|
||||
bool IsActive = true,
|
||||
bool BackupEnabled = true,
|
||||
string? BackupPath = null,
|
||||
int MaxBackupsPerFile = 0
|
||||
);
|
||||
|
||||
public record UpdateProjectRequest(
|
||||
string Name,
|
||||
string EngineeringPath,
|
||||
string SimulationPath,
|
||||
string FileExtensions,
|
||||
bool IsActive,
|
||||
bool BackupEnabled = true,
|
||||
string? BackupPath = null,
|
||||
int MaxBackupsPerFile = 0
|
||||
);
|
||||
|
||||
public record SyncRequest(List<Guid> ChangeIds);
|
||||
public record IgnoreRequest(List<Guid> ChangeIds);
|
||||
|
||||
public record ScanRequest(
|
||||
string EngineeringPath,
|
||||
string SimulationPath,
|
||||
string FileExtensions
|
||||
);
|
||||
|
||||
public record ScanResultEntry(
|
||||
string RelativePath,
|
||||
string ChangeType,
|
||||
long Size,
|
||||
DateTime LastModified
|
||||
);
|
||||
16
EngineeringSync.Service/Models/FileEvent.cs
Normal file
16
EngineeringSync.Service/Models/FileEvent.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace EngineeringSync.Service.Models;
|
||||
|
||||
public record FileEvent(
|
||||
Guid ProjectId,
|
||||
string FullPath,
|
||||
string RelativePath,
|
||||
FileEventType EventType,
|
||||
string? OldRelativePath = null
|
||||
);
|
||||
|
||||
public enum FileEventType
|
||||
{
|
||||
CreatedOrChanged,
|
||||
Renamed,
|
||||
Deleted
|
||||
}
|
||||
151
EngineeringSync.Service/Program.cs
Normal file
151
EngineeringSync.Service/Program.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
using System.Text.Json;
|
||||
using EngineeringSync.Infrastructure;
|
||||
using EngineeringSync.Service.Api;
|
||||
using EngineeringSync.Service.Hubs;
|
||||
using EngineeringSync.Service.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Host.UseWindowsService();
|
||||
|
||||
// Datenbankpfad: neben der .exe oder im AppData-Ordner (dev: aktuelles Verzeichnis)
|
||||
var dbPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"EngineeringSync", "engineeringsync.db");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
||||
|
||||
builder.Services.AddInfrastructure(dbPath);
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddSingleton<WatcherService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<WatcherService>());
|
||||
builder.Services.AddSingleton<SyncManager>();
|
||||
|
||||
// Kestrel nur auf localhost binden (kein öffentlicher Port)
|
||||
builder.WebHost.UseUrls("http://localhost:5050");
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Datenbankmigrationen beim Start
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
// WAL-Modus aktivieren (muss nach dem Öffnen der DB gesetzt werden)
|
||||
await db.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;");
|
||||
|
||||
// First-Run-Config laden (falls vorhanden)
|
||||
var watcher = scope.ServiceProvider.GetRequiredService<WatcherService>();
|
||||
await ProcessFirstRunConfigAsync(db, watcher);
|
||||
}
|
||||
|
||||
async Task ProcessFirstRunConfigAsync(AppDbContext db, WatcherService watcher)
|
||||
{
|
||||
var configPath = Path.Combine(AppContext.BaseDirectory, "firstrun-config.json");
|
||||
if (!File.Exists(configPath))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(configPath);
|
||||
var root = JsonDocument.Parse(json);
|
||||
var firstRunElement = root.RootElement.GetProperty("FirstRun");
|
||||
|
||||
var projectName = firstRunElement.GetProperty("ProjectName").GetString() ?? "Imported Project";
|
||||
var engineeringPath = firstRunElement.GetProperty("EngineeringPath").GetString() ?? "";
|
||||
var simulationPath = firstRunElement.GetProperty("SimulationPath").GetString() ?? "";
|
||||
var fileExtensionsRaw = firstRunElement.GetProperty("FileExtensions").GetString() ?? "";
|
||||
var watchAllFiles = firstRunElement.TryGetProperty("WatchAllFiles", out var watchAllElement)
|
||||
? watchAllElement.GetBoolean()
|
||||
: false;
|
||||
|
||||
// Wenn WatchAllFiles=true oder FileExtensions="*", dann leerer String (alle Dateien)
|
||||
var fileExtensions = (watchAllFiles || fileExtensionsRaw == "*") ? "" : fileExtensionsRaw;
|
||||
|
||||
// Backup-Einstellungen (optional, mit Standardwerten für Rückwärtskompatibilität)
|
||||
var backupEnabled = true;
|
||||
string? backupPath = null;
|
||||
var maxBackupsPerFile = 0;
|
||||
|
||||
if (root.RootElement.TryGetProperty("Backup", out var backupElement))
|
||||
{
|
||||
if (backupElement.TryGetProperty("BackupEnabled", out var be))
|
||||
backupEnabled = be.GetBoolean();
|
||||
if (backupElement.TryGetProperty("BackupPath", out var bp) &&
|
||||
bp.ValueKind != JsonValueKind.Null)
|
||||
backupPath = bp.GetString();
|
||||
if (backupElement.TryGetProperty("MaxBackupsPerFile", out var mb))
|
||||
maxBackupsPerFile = mb.GetInt32();
|
||||
}
|
||||
|
||||
// Nur erstellen, wenn die Verzeichnisse existieren
|
||||
if (!Directory.Exists(engineeringPath) || !Directory.Exists(simulationPath))
|
||||
{
|
||||
System.Console.WriteLine(
|
||||
$"[FirstRunConfig] WARNUNG: Engineering- oder Simulations-Pfad existiert nicht. " +
|
||||
$"Engineering={engineeringPath}, Simulation={simulationPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfen: Existiert bereits ein Projekt mit diesem Namen?
|
||||
var existingProject = await db.Projects
|
||||
.FirstOrDefaultAsync(p => p.Name == projectName);
|
||||
|
||||
if (existingProject != null)
|
||||
{
|
||||
System.Console.WriteLine(
|
||||
$"[FirstRunConfig] Projekt '{projectName}' existiert bereits. Überspringe Import.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Neue ProjectConfig erstellen
|
||||
var project = new EngineeringSync.Domain.Entities.ProjectConfig
|
||||
{
|
||||
Name = projectName,
|
||||
EngineeringPath = engineeringPath,
|
||||
SimulationPath = simulationPath,
|
||||
FileExtensions = fileExtensions,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
BackupEnabled = backupEnabled,
|
||||
BackupPath = backupPath,
|
||||
MaxBackupsPerFile = maxBackupsPerFile
|
||||
};
|
||||
|
||||
db.Projects.Add(project);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
System.Console.WriteLine(
|
||||
$"[FirstRunConfig] Projekt '{projectName}' wurde importiert und aktiviert. " +
|
||||
$"FileExtensions='{fileExtensions}'");
|
||||
|
||||
// Initialer Scan: Unterschiede zwischen Engineering- und Simulations-Ordner erkennen
|
||||
try { await watcher.ScanExistingFilesAsync(project); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Console.WriteLine(
|
||||
$"[FirstRunConfig] FEHLER beim initialen Scan für '{projectName}': {ex.Message}");
|
||||
}
|
||||
|
||||
// Konfigurationsdatei umbenennen, damit sie beim nächsten Start nicht wieder verarbeitet wird
|
||||
// (WatcherService.ExecuteAsync lädt das Projekt automatisch aus der DB beim Host-Start)
|
||||
var processedPath = configPath + ".processed";
|
||||
if (File.Exists(processedPath))
|
||||
File.Delete(processedPath);
|
||||
File.Move(configPath, processedPath);
|
||||
|
||||
System.Console.WriteLine($"[FirstRunConfig] Konfigurationsdatei verarbeitet: {configPath} → {processedPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Console.WriteLine(
|
||||
$"[FirstRunConfig] FEHLER beim Verarbeiten der Erstkonfiguration: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
app.MapHub<NotificationHub>("/notifications");
|
||||
app.MapProjectsApi();
|
||||
app.MapChangesApi();
|
||||
|
||||
app.Run();
|
||||
12
EngineeringSync.Service/Properties/launchSettings.json
Normal file
12
EngineeringSync.Service/Properties/launchSettings.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"EngineeringSync.Service": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
EngineeringSync.Service/Services/FileHasher.cs
Normal file
14
EngineeringSync.Service/Services/FileHasher.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace EngineeringSync.Service.Services;
|
||||
|
||||
public static class FileHasher
|
||||
{
|
||||
public static async Task<string> ComputeAsync(string filePath, CancellationToken ct = default)
|
||||
{
|
||||
await using var stream = new FileStream(filePath,
|
||||
FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 4096, true);
|
||||
var hash = await SHA256.HashDataAsync(stream, ct);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
}
|
||||
143
EngineeringSync.Service/Services/SyncManager.cs
Normal file
143
EngineeringSync.Service/Services/SyncManager.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using EngineeringSync.Domain.Entities;
|
||||
using EngineeringSync.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EngineeringSync.Service.Services;
|
||||
|
||||
public class SyncManager(IDbContextFactory<AppDbContext> dbFactory, ILogger<SyncManager> logger)
|
||||
{
|
||||
public async Task<SyncResult> SyncAsync(IEnumerable<Guid> changeIds, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
var ids = changeIds.ToList();
|
||||
var changes = await db.PendingChanges
|
||||
.Include(c => c.Project)
|
||||
.Where(c => ids.Contains(c.Id) && c.Status == ChangeStatus.Pending)
|
||||
.ToListAsync(ct);
|
||||
|
||||
int success = 0, failed = 0;
|
||||
foreach (var change in changes)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessChangeAsync(change, ct);
|
||||
change.Status = ChangeStatus.Synced;
|
||||
change.SyncedAt = DateTime.UtcNow;
|
||||
success++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Fehler beim Sync von {Path}", change.RelativePath);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new SyncResult(success, failed);
|
||||
}
|
||||
|
||||
public async Task IgnoreAsync(IEnumerable<Guid> changeIds, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
var ids = changeIds.ToList();
|
||||
await db.PendingChanges
|
||||
.Where(c => ids.Contains(c.Id) && c.Status == ChangeStatus.Pending)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(c => c.Status, ChangeStatus.Ignored), ct);
|
||||
}
|
||||
|
||||
private async Task ProcessChangeAsync(PendingChange change, CancellationToken ct)
|
||||
{
|
||||
var project = change.Project;
|
||||
var sourcePath = SafeCombine(project.EngineeringPath, change.RelativePath);
|
||||
var targetPath = SafeCombine(project.SimulationPath, change.RelativePath);
|
||||
|
||||
if (change.ChangeType == ChangeType.Deleted)
|
||||
{
|
||||
if (File.Exists(targetPath))
|
||||
{
|
||||
BackupFile(targetPath, project);
|
||||
File.Delete(targetPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (change.ChangeType == ChangeType.Renamed)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(change.OldRelativePath))
|
||||
{
|
||||
var oldTargetPath = SafeCombine(project.SimulationPath, change.OldRelativePath);
|
||||
if (File.Exists(oldTargetPath))
|
||||
File.Delete(oldTargetPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!File.Exists(sourcePath))
|
||||
throw new FileNotFoundException($"Quelldatei nicht gefunden: {sourcePath}");
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||
|
||||
if (File.Exists(targetPath))
|
||||
BackupFile(targetPath, project);
|
||||
|
||||
await Task.Run(() => File.Copy(sourcePath, targetPath, overwrite: true), ct);
|
||||
}
|
||||
|
||||
private void BackupFile(string targetPath, ProjectConfig project)
|
||||
{
|
||||
if (!project.BackupEnabled)
|
||||
return;
|
||||
|
||||
var backupDir = project.BackupPath ?? Path.GetDirectoryName(targetPath)!;
|
||||
Directory.CreateDirectory(backupDir);
|
||||
|
||||
var nameWithoutExt = Path.GetFileNameWithoutExtension(targetPath);
|
||||
var ext = Path.GetExtension(targetPath);
|
||||
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
||||
var backupPath = Path.Combine(backupDir, $"{nameWithoutExt}_{timestamp}{ext}.bak");
|
||||
|
||||
try
|
||||
{
|
||||
File.Move(targetPath, backupPath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
File.Copy(targetPath, backupPath, overwrite: false);
|
||||
File.Delete(targetPath);
|
||||
}
|
||||
|
||||
if (project.MaxBackupsPerFile > 0)
|
||||
CleanupOldBackups(backupDir, nameWithoutExt, ext, project.MaxBackupsPerFile);
|
||||
}
|
||||
|
||||
private void CleanupOldBackups(string backupDir, string baseName, string ext, int max)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pattern = $"{baseName}_*{ext}.bak";
|
||||
var backups = Directory.GetFiles(backupDir, pattern)
|
||||
.OrderBy(f => Path.GetFileName(f))
|
||||
.ToList();
|
||||
|
||||
while (backups.Count > max)
|
||||
{
|
||||
File.Delete(backups[0]);
|
||||
backups.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Cleanup alter Backups fehlgeschlagen in {Dir}", backupDir);
|
||||
}
|
||||
}
|
||||
|
||||
private static string SafeCombine(string basePath, string relativePath)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(Path.Combine(basePath, relativePath));
|
||||
var normalizedBase = Path.GetFullPath(basePath);
|
||||
if (!fullPath.StartsWith(normalizedBase + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) &&
|
||||
!fullPath.Equals(normalizedBase, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"Path traversal detected: {relativePath}");
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
public record SyncResult(int Success, int Failed);
|
||||
434
EngineeringSync.Service/Services/WatcherService.cs
Normal file
434
EngineeringSync.Service/Services/WatcherService.cs
Normal file
@@ -0,0 +1,434 @@
|
||||
using System.Threading.Channels;
|
||||
using EngineeringSync.Domain.Constants;
|
||||
using EngineeringSync.Domain.Entities;
|
||||
using EngineeringSync.Infrastructure;
|
||||
using EngineeringSync.Service.Hubs;
|
||||
using EngineeringSync.Service.Models;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EngineeringSync.Service.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet FileSystemWatcher-Instanzen für alle aktiven Projekte.
|
||||
/// Nutzt ein Channel als Puffer zwischen dem schnellen FSW-Event-Thread
|
||||
/// und dem langsamen DB-Schreib-Thread (Debouncing).
|
||||
/// </summary>
|
||||
public sealed class WatcherService(
|
||||
IDbContextFactory<AppDbContext> dbFactory,
|
||||
IHubContext<NotificationHub> hub,
|
||||
ILogger<WatcherService> logger) : BackgroundService
|
||||
{
|
||||
// Unbounded Channel: FSW-Events kommen schnell, Verarbeitung ist langsam
|
||||
private readonly Channel<FileEvent> _channel =
|
||||
Channel.CreateUnbounded<FileEvent>(new UnboundedChannelOptions { SingleReader = true });
|
||||
|
||||
// Watcher pro Projekt-ID – wird dynamisch verwaltet
|
||||
private readonly Dictionary<Guid, FileSystemWatcher[]> _watchers = [];
|
||||
private readonly SemaphoreSlim _watcherLock = new(1, 1);
|
||||
private readonly ILogger<WatcherService> _logger = logger;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Alle aktiven Projekte beim Start laden
|
||||
List<ProjectConfig> projects;
|
||||
await using (var db = await dbFactory.CreateDbContextAsync(stoppingToken))
|
||||
{
|
||||
projects = await db.Projects.Where(p => p.IsActive).ToListAsync(stoppingToken);
|
||||
foreach (var project in projects)
|
||||
await StartWatchingAsync(project);
|
||||
}
|
||||
|
||||
// Initialer Scan: Engineering- vs. Simulations-Ordner vergleichen
|
||||
foreach (var project in projects)
|
||||
{
|
||||
try { await ScanExistingFilesAsync(project, stoppingToken); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Initialer Scan fehlgeschlagen für '{Name}'", project.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// Channel-Consumer läuft, bis der Service gestoppt wird
|
||||
await ConsumeChannelAsync(stoppingToken);
|
||||
}
|
||||
|
||||
/// <summary>Startet Watcher für ein Projekt. Idempotent – stoppt alten Watcher zuerst.</summary>
|
||||
public async Task StartWatchingAsync(ProjectConfig project)
|
||||
{
|
||||
await _watcherLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
StopWatchingInternal(project.Id);
|
||||
if (!project.IsActive || !Directory.Exists(project.EngineeringPath))
|
||||
return;
|
||||
|
||||
var extensions = project.GetExtensions().ToArray();
|
||||
|
||||
// Pro Dateiendung einen eigenen Watcher (FSW unterstützt nur ein Filter-Pattern)
|
||||
var watchers = extensions.Select(ext => CreateWatcher(project, ext)).ToArray();
|
||||
_watchers[project.Id] = watchers;
|
||||
_logger.LogInformation("Watcher gestartet für Projekt '{Name}' ({Count} Extensions)",
|
||||
project.Name, watchers.Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_watcherLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialer Scan: Vergleicht Engineering- und Simulations-Ordner.
|
||||
/// Erkennt Dateien, die im Engineering-Ordner vorhanden sind, aber im
|
||||
/// Simulations-Ordner fehlen oder sich unterscheiden.
|
||||
/// </summary>
|
||||
public async Task ScanExistingFilesAsync(ProjectConfig project, CancellationToken ct = default)
|
||||
{
|
||||
if (!Directory.Exists(project.EngineeringPath)) return;
|
||||
|
||||
var extensions = project.GetExtensions().ToList();
|
||||
|
||||
// Wenn keine Erweiterungen angegeben oder "*" → alle Dateien scannen
|
||||
var scanAllFiles = extensions.Count == 0 || (extensions.Count == 1 && extensions[0] == "*");
|
||||
|
||||
var engFiles = scanAllFiles
|
||||
? Directory.EnumerateFiles(project.EngineeringPath, "*", SearchOption.AllDirectories).ToList()
|
||||
: Directory.EnumerateFiles(project.EngineeringPath, "*", SearchOption.AllDirectories)
|
||||
.Where(f => extensions.Contains(Path.GetExtension(f), StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (engFiles.Count == 0) return;
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
var newChanges = 0;
|
||||
|
||||
foreach (var engFile in engFiles)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(project.EngineeringPath, engFile);
|
||||
var simFile = Path.Combine(project.SimulationPath, relativePath);
|
||||
|
||||
var engHash = await FileHasher.ComputeAsync(engFile, ct);
|
||||
var engInfo = new FileInfo(engFile);
|
||||
|
||||
// FileRevision anlegen/aktualisieren
|
||||
var revision = await db.FileRevisions
|
||||
.FirstOrDefaultAsync(r => r.ProjectId == project.Id && r.RelativePath == relativePath, ct);
|
||||
|
||||
if (revision is null)
|
||||
{
|
||||
db.FileRevisions.Add(new FileRevision
|
||||
{
|
||||
ProjectId = project.Id,
|
||||
RelativePath = relativePath,
|
||||
FileHash = engHash,
|
||||
Size = engInfo.Length,
|
||||
LastModified = engInfo.LastWriteTimeUtc
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
revision.FileHash = engHash;
|
||||
revision.Size = engInfo.Length;
|
||||
revision.LastModified = engInfo.LastWriteTimeUtc;
|
||||
}
|
||||
|
||||
// Prüfen: Existiert die Datei im Simulations-Ordner und ist sie identisch?
|
||||
bool needsSync;
|
||||
if (!File.Exists(simFile))
|
||||
{
|
||||
needsSync = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var simHash = await FileHasher.ComputeAsync(simFile, ct);
|
||||
needsSync = engHash != simHash;
|
||||
}
|
||||
|
||||
if (!needsSync) continue;
|
||||
|
||||
// Nur anlegen, wenn nicht bereits ein offener PendingChange existiert
|
||||
var alreadyPending = await db.PendingChanges
|
||||
.AnyAsync(c => c.ProjectId == project.Id
|
||||
&& c.RelativePath == relativePath
|
||||
&& c.Status == ChangeStatus.Pending, ct);
|
||||
if (alreadyPending) continue;
|
||||
|
||||
db.PendingChanges.Add(new PendingChange
|
||||
{
|
||||
ProjectId = project.Id,
|
||||
RelativePath = relativePath,
|
||||
ChangeType = File.Exists(simFile) ? ChangeType.Modified : ChangeType.Created
|
||||
});
|
||||
newChanges++;
|
||||
}
|
||||
|
||||
if (newChanges > 0)
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var totalPending = await db.PendingChanges
|
||||
.CountAsync(c => c.ProjectId == project.Id && c.Status == ChangeStatus.Pending, ct);
|
||||
|
||||
await hub.Clients.All.SendAsync(HubMethodNames.ReceiveChangeNotification,
|
||||
project.Id, project.Name, totalPending, ct);
|
||||
|
||||
_logger.LogInformation("Initialer Scan für '{Name}': {Count} Unterschied(e) erkannt",
|
||||
project.Name, newChanges);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Initialer Scan für '{Name}': Ordner sind synchron", project.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stoppt und entfernt Watcher für ein Projekt.</summary>
|
||||
public async Task StopWatchingAsync(Guid projectId)
|
||||
{
|
||||
await _watcherLock.WaitAsync();
|
||||
try { StopWatchingInternal(projectId); }
|
||||
finally { _watcherLock.Release(); }
|
||||
}
|
||||
|
||||
private void StopWatchingInternal(Guid projectId)
|
||||
{
|
||||
if (!_watchers.Remove(projectId, out var old)) return;
|
||||
foreach (var w in old) { w.EnableRaisingEvents = false; w.Dispose(); }
|
||||
}
|
||||
|
||||
private FileSystemWatcher CreateWatcher(ProjectConfig project, string extension)
|
||||
{
|
||||
var watcher = new FileSystemWatcher(project.EngineeringPath)
|
||||
{
|
||||
Filter = extension == "*" ? "*.*" : $"*{extension}",
|
||||
IncludeSubdirectories = true,
|
||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size,
|
||||
InternalBufferSize = 65536 // 64KB – verhindert Buffer-Overflow bei vielen gleichzeitigen Events
|
||||
};
|
||||
|
||||
// Lokale Kopie für Lambda-Capture
|
||||
var projectId = project.Id;
|
||||
var basePath = project.EngineeringPath;
|
||||
|
||||
watcher.Created += (_, e) => EnqueueEvent(projectId, basePath, e.FullPath, FileEventType.CreatedOrChanged);
|
||||
watcher.Changed += (_, e) => EnqueueEvent(projectId, basePath, e.FullPath, FileEventType.CreatedOrChanged);
|
||||
watcher.Deleted += (_, e) => EnqueueEvent(projectId, basePath, e.FullPath, FileEventType.Deleted);
|
||||
watcher.Renamed += (_, e) => EnqueueRenamedEvent(projectId, basePath, e);
|
||||
watcher.Error += (_, args) =>
|
||||
{
|
||||
_logger.LogWarning(args.GetException(), "FSW Buffer-Overflow für Projekt {Id} – starte Re-Scan", projectId);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await ScanExistingFilesAsync(project);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Re-Scan nach Buffer-Overflow fehlgeschlagen für Projekt {Id}", projectId);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
watcher.EnableRaisingEvents = true;
|
||||
return watcher;
|
||||
}
|
||||
|
||||
private void EnqueueEvent(Guid projectId, string basePath, string fullPath, FileEventType type)
|
||||
{
|
||||
var rel = Path.GetRelativePath(basePath, fullPath);
|
||||
_channel.Writer.TryWrite(new FileEvent(projectId, fullPath, rel, type));
|
||||
}
|
||||
|
||||
private void EnqueueRenamedEvent(Guid projectId, string basePath, RenamedEventArgs e)
|
||||
{
|
||||
var rel = Path.GetRelativePath(basePath, e.FullPath);
|
||||
var oldRel = Path.GetRelativePath(basePath, e.OldFullPath);
|
||||
_channel.Writer.TryWrite(new FileEvent(projectId, e.FullPath, rel, FileEventType.Renamed, oldRel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Debouncing-Consumer: Gruppiert Events nach (ProjectId, RelativePath) innerhalb
|
||||
/// eines 2000ms-Fensters. Verhindert, dass eine Datei 10× verarbeitet wird.
|
||||
/// </summary>
|
||||
private async Task ConsumeChannelAsync(CancellationToken ct)
|
||||
{
|
||||
// Dictionary: Key=(ProjectId,RelativePath) → letztes Event
|
||||
var pending = new Dictionary<(Guid, string), FileEvent>();
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Warte auf erstes Event (blockierend)
|
||||
if (!await _channel.Reader.WaitToReadAsync(ct)) break;
|
||||
|
||||
// Sammle alle sofort verfügbaren Events (nicht blockierend)
|
||||
while (_channel.Reader.TryRead(out var evt))
|
||||
pending[(evt.ProjectId, evt.RelativePath)] = evt; // neuester gewinnt
|
||||
|
||||
// 2s warten – kommen in dieser Zeit weitere Events, werden sie im nächsten Batch verarbeitet
|
||||
await Task.Delay(2000, ct);
|
||||
|
||||
// Noch mehr eingelaufene Events sammeln
|
||||
while (_channel.Reader.TryRead(out var evt))
|
||||
pending[(evt.ProjectId, evt.RelativePath)] = evt;
|
||||
|
||||
if (pending.Count == 0) continue;
|
||||
|
||||
var batch = pending.Values.ToList();
|
||||
pending.Clear();
|
||||
|
||||
foreach (var evt in batch)
|
||||
{
|
||||
try { await ProcessEventAsync(evt, ct); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fehler beim Verarbeiten von {Path}", evt.RelativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessEventAsync(FileEvent evt, CancellationToken ct)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var project = await db.Projects.FindAsync([evt.ProjectId], ct);
|
||||
if (project is null) return;
|
||||
|
||||
// Existierende Revision lesen
|
||||
var revision = await db.FileRevisions
|
||||
.FirstOrDefaultAsync(r => r.ProjectId == evt.ProjectId && r.RelativePath == evt.RelativePath, ct);
|
||||
|
||||
if (evt.EventType == FileEventType.Deleted)
|
||||
{
|
||||
await HandleDeleteAsync(db, project, revision, evt, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!File.Exists(evt.FullPath)) return; // Race condition: Datei schon weg
|
||||
|
||||
// Hash berechnen mit Retry-Logik für gesperrte Dateien
|
||||
string newHash;
|
||||
try
|
||||
{
|
||||
newHash = await ComputeHashWithRetryAsync(evt.FullPath, ct);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Hash-Berechnung für {Path} fehlgeschlagen nach Retrys – überspringe Event", evt.RelativePath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (revision is not null && revision.FileHash == newHash) return; // Keine echte Änderung
|
||||
|
||||
var info = new FileInfo(evt.FullPath);
|
||||
|
||||
// FileRevision anlegen oder aktualisieren
|
||||
if (revision is null)
|
||||
{
|
||||
db.FileRevisions.Add(new FileRevision
|
||||
{
|
||||
ProjectId = evt.ProjectId,
|
||||
RelativePath = evt.RelativePath,
|
||||
FileHash = newHash,
|
||||
Size = info.Length,
|
||||
LastModified = info.LastWriteTimeUtc
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
revision.FileHash = newHash;
|
||||
revision.Size = info.Length;
|
||||
revision.LastModified = info.LastWriteTimeUtc;
|
||||
if (evt.EventType == FileEventType.Renamed)
|
||||
revision.RelativePath = evt.RelativePath;
|
||||
}
|
||||
|
||||
// PendingChange schreiben
|
||||
var changeType = evt.EventType switch
|
||||
{
|
||||
FileEventType.Renamed => ChangeType.Renamed,
|
||||
_ when revision is null => ChangeType.Created,
|
||||
_ => ChangeType.Modified
|
||||
};
|
||||
|
||||
db.PendingChanges.Add(new PendingChange
|
||||
{
|
||||
ProjectId = evt.ProjectId,
|
||||
RelativePath = evt.RelativePath,
|
||||
ChangeType = changeType,
|
||||
OldRelativePath = evt.OldRelativePath
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Alle SignalR-Clients benachrichtigen
|
||||
var count = await db.PendingChanges
|
||||
.CountAsync(c => c.ProjectId == evt.ProjectId && c.Status == ChangeStatus.Pending, ct);
|
||||
|
||||
await hub.Clients.All.SendAsync(HubMethodNames.ReceiveChangeNotification,
|
||||
evt.ProjectId, project.Name, count, ct);
|
||||
|
||||
_logger.LogInformation("[{Type}] {Path} in Projekt '{Name}'",
|
||||
changeType, evt.RelativePath, project.Name);
|
||||
}
|
||||
|
||||
private async Task HandleDeleteAsync(AppDbContext db, ProjectConfig project,
|
||||
FileRevision? revision, FileEvent evt, CancellationToken ct)
|
||||
{
|
||||
if (revision is not null)
|
||||
db.FileRevisions.Remove(revision);
|
||||
|
||||
db.PendingChanges.Add(new PendingChange
|
||||
{
|
||||
ProjectId = evt.ProjectId,
|
||||
RelativePath = evt.RelativePath,
|
||||
ChangeType = ChangeType.Deleted
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var count = await db.PendingChanges
|
||||
.CountAsync(c => c.ProjectId == evt.ProjectId && c.Status == ChangeStatus.Pending, ct);
|
||||
|
||||
await hub.Clients.All.SendAsync(HubMethodNames.ReceiveChangeNotification,
|
||||
evt.ProjectId, project.Name, count, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Berechnet den Hash einer Datei mit Retry-Logik.
|
||||
/// Versucht bis zu 3 Mal, mit exponentieller Backoff-Wartezeit.
|
||||
/// Wirft IOException, wenn alle Versuche scheitern.
|
||||
/// </summary>
|
||||
private async Task<string> ComputeHashWithRetryAsync(string fullPath, CancellationToken ct)
|
||||
{
|
||||
const int maxAttempts = 3;
|
||||
for (int attempt = 0; attempt < maxAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await FileHasher.ComputeAsync(fullPath, ct);
|
||||
}
|
||||
catch (IOException) when (attempt < maxAttempts - 1)
|
||||
{
|
||||
var delaySeconds = 2 * (attempt + 1);
|
||||
_logger.LogDebug("Hash-Berechnung für {Path} fehlgeschlagen (Versuch {Attempt}), warte {Seconds}s...",
|
||||
fullPath, attempt + 1, delaySeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct);
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn alle Versuche fehlschlagen, IOException werfen
|
||||
throw new IOException($"Hash-Berechnung für {fullPath} fehlgeschlagen nach {maxAttempts} Versuchen");
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
// Channel-Writer schließen, um ConsumeChannelAsync zum Beenden zu bringen
|
||||
_channel.Writer.TryComplete();
|
||||
|
||||
foreach (var watchers in _watchers.Values)
|
||||
foreach (var w in watchers) w.Dispose();
|
||||
_watcherLock.Dispose();
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
16
EngineeringSync.Service/Worker.cs
Normal file
16
EngineeringSync.Service/Worker.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace EngineeringSync.Service;
|
||||
|
||||
public class Worker(ILogger<Worker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
|
||||
}
|
||||
await Task.Delay(1000, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
8
EngineeringSync.Service/appsettings.Development.json
Normal file
8
EngineeringSync.Service/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
EngineeringSync.Service/appsettings.json
Normal file
8
EngineeringSync.Service/appsettings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user