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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user