Add UploadLimits service and refactor user and media listing endpoints to use filter records for improved parameter handling. Update audio upload logic to streamline parameter passing and enhance clarity. Adjust ImageEndpoints to use HashSet for allowed extensions, and refine StreamingEndpoints to clarify token handling.
ci / build-backend (push) Successful in 1m29s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 2m1s
ci / sonar (push) Successful in 4m22s

This commit is contained in:
Leonid Pershin
2026-07-26 23:05:37 +03:00
parent 0f0657e523
commit 69d1319564
7 changed files with 108 additions and 58 deletions
@@ -0,0 +1,21 @@
using Microsoft.Extensions.Options;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Common;
/// <summary>
/// Лимиты приёма файла, собранные в одно место. Они живут в двух разных секциях конфигурации
/// (<c>Media</c> и <c>Storage</c>), но проверяются всегда вместе и только на входе загрузки —
/// хендлеру незачем знать про обе секции и тащить два <see cref="IOptions{T}"/> в сигнатуре.
/// </summary>
public sealed class UploadLimits(
IOptions<MediaOptions> media,
IOptions<StorageOptions> storage
)
{
/// <summary>Потолок размера загружаемого файла.</summary>
public long MaxUploadBytes { get; } = media.Value.MaxUploadBytes;
/// <summary>Сколько места должно остаться свободным после приёма файла.</summary>
public long MinFreeSpaceBytes { get; } = storage.Value.MinFreeSpaceBytes;
}
@@ -32,31 +32,21 @@ public static class AdminUserEndpoints
return app; return app;
} }
/// <summary>
/// Все параметры со значениями по умолчанию: обязательный <c>bool</c> в query превращает
/// запрос без него в 400 ещё до хендлера — ошибку в таком виде отладить тяжело.
/// </summary>
private static async Task<IResult> ListUsers( private static async Task<IResult> ListUsers(
[AsParameters] ListUsersFilter filter,
ISender sender, ISender sender,
CancellationToken cancellationToken, CancellationToken cancellationToken
int page = 1,
int pageSize = 20,
string? search = null,
Guid? roleId = null,
bool? isBlocked = null,
string? sort = null,
bool desc = false
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new ListUsersQuery( new ListUsersQuery(
page <= 0 ? 1 : page, filter.Page is > 0 ? filter.Page.Value : 1,
pageSize <= 0 ? 20 : pageSize, filter.PageSize is > 0 ? filter.PageSize.Value : 20,
search, filter.Search,
roleId, filter.RoleId,
isBlocked, filter.IsBlocked,
sort, filter.Sort,
desc filter.Desc ?? false
), ),
cancellationToken cancellationToken
); );
@@ -126,6 +116,22 @@ public static class AdminUserEndpoints
} }
} }
/// <summary>
/// Фильтр и страница списка пользователей. Все поля nullable намеренно: обязательный параметр в query
/// превращает запрос без него в 400 ещё до хендлера — ошибку в таком виде отладить тяжело. Умолчания
/// подставляет сам хендлер, а не объявление: при <c>AsParameters</c> обязательность определяется
/// nullable-типом, а не наличием значения по умолчанию в конструкторе.
/// </summary>
public sealed record ListUsersFilter(
int? Page,
int? PageSize,
string? Search,
Guid? RoleId,
bool? IsBlocked,
string? Sort,
bool? Desc
);
public sealed record CreateUserBody(string UserName, string Password, Guid RoleId); public sealed record CreateUserBody(string UserName, string Password, Guid RoleId);
public sealed record ResetPasswordBody(string NewPassword); public sealed record ResetPasswordBody(string NewPassword);
@@ -66,9 +66,7 @@ public static partial class ChannelEndpoints
} }
private static async Task<IResult> UploadTemplateAudio( private static async Task<IResult> UploadTemplateAudio(
Guid id, [AsParameters] BumperAudioUpload upload,
Guid templateId,
string fileName,
HttpRequest request, HttpRequest request,
IBumperTemplateStorage storage, IBumperTemplateStorage storage,
IAudioProbe probe, IAudioProbe probe,
@@ -76,23 +74,31 @@ public static partial class ChannelEndpoints
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
if (ResolveBumperExtension(fileName, request, BumperFiles.AudioExtensions) is not { } ext) if (
ResolveBumperExtension(upload.FileName, request, BumperFiles.AudioExtensions)
is not { } ext
)
return ChannelErrors.InvalidBumperFile.ToProblem(); return ChannelErrors.InvalidBumperFile.ToProblem();
await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken); await storage.SaveAudioAsync(upload.TemplateId, ext, request.Body, cancellationToken);
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина). // Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
var path = storage.AudioPath(templateId, ext); var path = storage.AudioPath(upload.TemplateId, ext);
var duration = path is null var duration = path is null
? null ? null
: await probe.TryGetDurationAsync(path, cancellationToken); : await probe.TryGetDurationAsync(path, cancellationToken);
var result = await sender.Send( var result = await sender.Send(
new SetBumperTemplateAudioCommand(id, templateId, ext, duration?.TotalSeconds ?? 0), new SetBumperTemplateAudioCommand(
upload.Id,
upload.TemplateId,
ext,
duration?.TotalSeconds ?? 0
),
cancellationToken cancellationToken
); );
if (!result.IsSuccess) if (!result.IsSuccess)
await storage.DeleteAudioAsync(templateId, cancellationToken); await storage.DeleteAudioAsync(upload.TemplateId, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -242,13 +248,12 @@ public static partial class ChannelEndpoints
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl"); return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
} }
private static IResult PreviewSegment( /// <summary>
Guid id, /// Сегмент превью. Канал и блок в маршруте есть, но хендлеру не нужны: каталог превью
Guid templateId, /// адресуется подблоком (см. BumperPreview.AssetId), поэтому в сигнатуре их нет — незаявленные
Guid variantId, /// параметры маршрута просто не связываются.
string file, /// </summary>
MediaPathResolver paths private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths)
)
{ {
if (!SegmentFiles.IsSegmentName(file)) if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound(); return Results.NotFound();
@@ -300,6 +305,13 @@ public sealed record UpdateBumperVariantBody(
int Weight int Weight
); );
/// <summary>
/// Адрес загружаемого звука: канал и блок из маршрута плюс имя исходного файла из query (по нему
/// проверяется расширение). Свёрнуто в один параметр — кроме него хендлеру нужны ещё запрос, два
/// сервиса, диспетчер и токен отмены, и плоским списком сигнатура перестаёт читаться.
/// </summary>
public sealed record BumperAudioUpload(Guid Id, Guid TemplateId, string FileName);
/// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary> /// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary>
internal static class BumperFiles internal static class BumperFiles
{ {
@@ -15,7 +15,9 @@ public static class ImageEndpoints
{ {
private const long MaxBytes = 50L * 1024 * 1024; // 50 МБ private const long MaxBytes = 50L * 1024 * 1024; // 50 МБ
private static readonly IReadOnlySet<string> AllowedExtensions = new HashSet<string>( // Тип поля — HashSet, а не IReadOnlySet: поле приватное, и через интерфейс Contains уходит
// в виртуальный вызов вместо прямого.
private static readonly HashSet<string> AllowedExtensions = new(
StringComparer.OrdinalIgnoreCase StringComparer.OrdinalIgnoreCase
) )
{ {
@@ -51,8 +51,7 @@ public static class MediaEndpoints
IMediaStorage storage, IMediaStorage storage,
IMediaProcessingQueue queue, IMediaProcessingQueue queue,
ISender sender, ISender sender,
IOptions<MediaOptions> mediaOptions, UploadLimits limits,
IOptions<StorageOptions> storageOptions,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
@@ -71,7 +70,7 @@ public static class MediaEndpoints
); );
var contentLength = request.ContentLength ?? 0; var contentLength = request.ContentLength ?? 0;
if (contentLength > mediaOptions.Value.MaxUploadBytes) if (contentLength > limits.MaxUploadBytes)
return Results.Problem( return Results.Problem(
title: MediaErrors.FileTooLarge.Code, title: MediaErrors.FileTooLarge.Code,
detail: MediaErrors.FileTooLarge.Message, detail: MediaErrors.FileTooLarge.Message,
@@ -79,7 +78,7 @@ public static class MediaEndpoints
); );
var free = storage.GetAvailableFreeSpaceBytes(); var free = storage.GetAvailableFreeSpaceBytes();
if (free - contentLength < storageOptions.Value.MinFreeSpaceBytes) if (free - contentLength < limits.MinFreeSpaceBytes)
return Results.Problem( return Results.Problem(
title: MediaErrors.InsufficientStorage.Code, title: MediaErrors.InsufficientStorage.Code,
detail: MediaErrors.InsufficientStorage.Message, detail: MediaErrors.InsufficientStorage.Message,
@@ -106,30 +105,20 @@ public static class MediaEndpoints
); );
} }
/// <summary>
/// Все параметры со значениями по умолчанию: обязательный <c>bool</c> в query заставлял
/// минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые ассеты»,
/// которым сортировка не нужна.
/// </summary>
private static async Task<IResult> List( private static async Task<IResult> List(
[AsParameters] ListMediaFilter filter,
ISender sender, ISender sender,
CancellationToken cancellationToken, CancellationToken cancellationToken
int page = 1,
int pageSize = 20,
MediaAssetStatus[]? status = null,
string? search = null,
string? sort = null,
bool desc = false
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new ListMediaAssetsQuery( new ListMediaAssetsQuery(
page <= 0 ? 1 : page, filter.Page is > 0 ? filter.Page.Value : 1,
pageSize <= 0 ? 20 : pageSize, filter.PageSize is > 0 ? filter.PageSize.Value : 20,
status ?? [], filter.Status ?? [],
search, filter.Search,
sort, filter.Sort,
desc filter.Desc ?? false
), ),
cancellationToken cancellationToken
); );
@@ -204,6 +193,21 @@ public static class MediaEndpoints
} }
} }
/// <summary>
/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
/// <c>AsParameters</c> обязательность определяется nullable-типом, а не значением по умолчанию.
/// </summary>
public sealed record ListMediaFilter(
int? Page,
int? PageSize,
MediaAssetStatus[]? Status,
string? Search,
string? Sort,
bool? Desc
);
public sealed record UploadMediaResponse(Guid Id); public sealed record UploadMediaResponse(Guid Id);
public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId); public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId);
@@ -51,8 +51,12 @@ public static class StreamingEndpoints
new ViewerFeaturesDto(await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken)) new ViewerFeaturesDto(await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken))
); );
/// <summary>
/// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными
/// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на
/// конкретную ленту, поэтому в сигнатуре slug не нужен.
/// </summary>
private static IResult Watch( private static IResult Watch(
string slug,
ICurrentUser currentUser, ICurrentUser currentUser,
StreamTokenService tokens, StreamTokenService tokens,
HttpRequest request, HttpRequest request,
+1
View File
@@ -61,6 +61,7 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
}); });
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<UploadLimits>();
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddInfrastructure(builder.Configuration);