Add maintenance management features: implement maintenance endpoints in the API, enhance media upload handling to skip duplicate files, and update frontend routes and translations for maintenance operations.

This commit is contained in:
Leonid Pershin
2026-07-24 19:52:56 +03:00
parent 6e7db4a6a9
commit 4202c51a5b
17 changed files with 411 additions and 5 deletions
@@ -0,0 +1,46 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Maintenance.ClearAllMedia;
using TeleWave.Application.Maintenance.DeleteAllShows;
using TeleWave.Application.Maintenance.DeleteShowMedia;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class MaintenanceEndpoints
{
public static IEndpointRouteBuilder MapMaintenanceEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/maintenance")
.WithTags("Admin.Maintenance")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("/clear-media", ClearMedia).Produces<int>();
admin.MapPost("/clear-shows", ClearShows).Produces<int>();
admin.MapPost("/shows/{showId:guid}/clear-media", ClearShowMedia).Produces<int>();
return app;
}
private static async Task<IResult> ClearMedia(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ClearAllMediaCommand(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ClearShows(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new DeleteAllShowsCommand(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ClearShowMedia(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteShowMediaCommand(showId), cancellationToken);
return result.ToHttpResult();
}
}
+1
View File
@@ -116,6 +116,7 @@ app.MapMediaEndpoints();
app.MapShowEndpoints();
app.MapChannelEndpoints();
app.MapStreamingEndpoints();
app.MapMaintenanceEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Maintenance.ClearAllMedia;
/// <summary>Удаляет ВСЕ медиа-ассеты (строки + файлы) и связанное расписание. Опасная операция.</summary>
public sealed record ClearAllMediaCommand : ICommand<Result<int>>;
@@ -0,0 +1,29 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Maintenance.ClearAllMedia;
public sealed class ClearAllMediaCommandHandler(IAppDbContext dbContext, IMediaStorage storage)
: ICommandHandler<ClearAllMediaCommand, Result<int>>
{
public async Task<Result<int>> Handle(
ClearAllMediaCommand command,
CancellationToken cancellationToken
)
{
var assets = await dbContext.MediaAssets
.Select(a => new { a.Id, a.OriginalExtension })
.ToListAsync(cancellationToken);
foreach (var asset in assets)
storage.DeleteAssetArtifacts(asset.Id, asset.OriginalExtension);
// Расписание ссылается на удаляемые ассеты — чистим его тоже.
await dbContext.ScheduleEntries.ExecuteDeleteAsync(cancellationToken);
var deleted = await dbContext.MediaAssets.ExecuteDeleteAsync(cancellationToken);
return Result.Success(deleted);
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Maintenance.DeleteAllShows;
/// <summary>Удаляет ВСЕ шоу (с сериями через каскад). Медиа-ассеты остаются в библиотеке.</summary>
public sealed record DeleteAllShowsCommand : ICommand<Result<int>>;
@@ -0,0 +1,20 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Maintenance.DeleteAllShows;
public sealed class DeleteAllShowsCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteAllShowsCommand, Result<int>>
{
public async Task<Result<int>> Handle(
DeleteAllShowsCommand command,
CancellationToken cancellationToken
)
{
// Серии удаляются каскадом (FK Show → ShowEpisode: OnDelete Cascade).
var deleted = await dbContext.Shows.ExecuteDeleteAsync(cancellationToken);
return Result.Success(deleted);
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Maintenance.DeleteShowMedia;
/// <summary>Удаляет медиа-ассеты (файлы + строки) всех серий указанного шоу и очищает его серии.</summary>
public sealed record DeleteShowMediaCommand(Guid ShowId) : ICommand<Result<int>>;
@@ -0,0 +1,47 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Library;
namespace TeleWave.Application.Maintenance.DeleteShowMedia;
public sealed class DeleteShowMediaCommandHandler(IAppDbContext dbContext, IMediaStorage storage)
: ICommandHandler<DeleteShowMediaCommand, Result<int>>
{
public async Task<Result<int>> Handle(
DeleteShowMediaCommand command,
CancellationToken cancellationToken
)
{
var show = await dbContext.Shows
.Include(s => s.Episodes)
.FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken);
if (show is null)
return Result.Failure<int>(ShowErrors.NotFound);
var episodes = show.Episodes.ToList();
var assetIds = episodes.Select(e => e.MediaAssetId).Distinct().ToList();
var assets = await dbContext.MediaAssets
.Where(a => assetIds.Contains(a.Id))
.Select(a => new { a.Id, a.OriginalExtension })
.ToListAsync(cancellationToken);
foreach (var asset in assets)
storage.DeleteAssetArtifacts(asset.Id, asset.OriginalExtension);
await dbContext.ScheduleEntries
.Where(e => assetIds.Contains(e.MediaAssetId))
.ExecuteDeleteAsync(cancellationToken);
await dbContext.MediaAssets
.Where(a => assetIds.Contains(a.Id))
.ExecuteDeleteAsync(cancellationToken);
// Серии шоу теперь указывают на удалённые ассеты — убираем их (сохранится через UnitOfWork).
foreach (var episode in episodes)
show.RemoveEpisode(episode.Id);
return Result.Success(assets.Count);
}
}
@@ -33,4 +33,9 @@ public static class MediaErrors
"Media.SourceNotFound",
"Исходный файл не найден в хранилище."
);
public static readonly Error DuplicateFileName = Error.Conflict(
"Media.DuplicateFileName",
"Файл с таким именем уже загружен."
);
}
@@ -1,4 +1,5 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Media;
@@ -15,6 +16,16 @@ public sealed class RegisterMediaAssetCommandHandler(
CancellationToken cancellationToken
)
{
// Дедуп по имени: тот же файл уже в библиотеке (кроме проваленных — их разрешаем перезалить).
var duplicate = await dbContext.MediaAssets.AnyAsync(
x =>
x.OriginalFileName == command.OriginalFileName
&& x.Status != MediaAssetStatus.Failed,
cancellationToken
);
if (duplicate)
return Result.Failure<Guid>(MediaErrors.DuplicateFileName);
var extension = Path.GetExtension(command.OriginalFileName).ToLowerInvariant();
var asset = MediaAsset.Register(command.OriginalFileName, extension, command.Source);