Enhance user and media management with sorting and statistics: add sorting options to user and media listing endpoints, implement media statistics retrieval, and update frontend components for sorting and displaying media processing times. Refactor related query handlers and API types to support new features.
This commit is contained in:
@@ -40,6 +40,8 @@ public static class AdminUserEndpoints
|
||||
string? search,
|
||||
Guid? roleId,
|
||||
bool? isBlocked,
|
||||
string? sort,
|
||||
bool desc,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
@@ -50,7 +52,9 @@ public static class AdminUserEndpoints
|
||||
pageSize <= 0 ? 20 : pageSize,
|
||||
search,
|
||||
roleId,
|
||||
isBlocked
|
||||
isBlocked,
|
||||
sort,
|
||||
desc
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ using TeleWave.Application.Media.Delete;
|
||||
using TeleWave.Application.Media.GetMedia;
|
||||
using TeleWave.Application.Media.ListMedia;
|
||||
using TeleWave.Application.Media.Register;
|
||||
using TeleWave.Application.Media.Stats;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
using TeleWave.Infrastructure.Media;
|
||||
@@ -24,6 +25,7 @@ public static class MediaEndpoints
|
||||
|
||||
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
|
||||
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
|
||||
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
|
||||
admin.MapGet("/{id:guid}", Get).Produces<MediaAssetDto>();
|
||||
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
@@ -100,6 +102,8 @@ public static class MediaEndpoints
|
||||
int pageSize,
|
||||
MediaAssetStatus[]? status,
|
||||
string? search,
|
||||
string? sort,
|
||||
bool desc,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
@@ -109,13 +113,21 @@ public static class MediaEndpoints
|
||||
page <= 0 ? 1 : page,
|
||||
pageSize <= 0 ? 20 : pageSize,
|
||||
status ?? [],
|
||||
search
|
||||
search,
|
||||
sort,
|
||||
desc
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Get(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
|
||||
@@ -9,5 +9,7 @@ public sealed record ListUsersQuery(
|
||||
int PageSize,
|
||||
string? Search,
|
||||
Guid? RoleId,
|
||||
bool? IsBlocked
|
||||
bool? IsBlocked,
|
||||
string? Sort = null,
|
||||
bool Desc = false
|
||||
) : IQuery<PagedList<UserSummaryDto>>;
|
||||
|
||||
@@ -17,6 +17,8 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService)
|
||||
query.Search,
|
||||
query.RoleId,
|
||||
query.IsBlocked,
|
||||
query.Sort,
|
||||
query.Desc,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ public interface IIdentityService
|
||||
string? search,
|
||||
Guid? roleId,
|
||||
bool? isBlocked,
|
||||
string? sort,
|
||||
bool desc,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
|
||||
@@ -8,5 +8,7 @@ public sealed record ListMediaAssetsQuery(
|
||||
int Page,
|
||||
int PageSize,
|
||||
IReadOnlyList<MediaAssetStatus> Statuses,
|
||||
string? Search
|
||||
string? Search,
|
||||
string? Sort = null,
|
||||
bool Desc = false
|
||||
) : IQuery<PagedList<MediaAssetDto>>;
|
||||
|
||||
@@ -30,9 +30,33 @@ public sealed class ListMediaAssetsQueryHandler(IAppDbContext dbContext)
|
||||
|
||||
var total = await q.CountAsync(cancellationToken);
|
||||
|
||||
// Сортировка по выбранному столбцу (в SQL, до пагинации); вторичный ключ — Id для стабильного
|
||||
// порядка при равенстве значений (иначе страницы «дрожат»).
|
||||
var ordered = (query.Sort?.ToLowerInvariant()) switch
|
||||
{
|
||||
"name" => query.Desc
|
||||
? q.OrderByDescending(x => x.OriginalFileName)
|
||||
: q.OrderBy(x => x.OriginalFileName),
|
||||
"status" => query.Desc ? q.OrderByDescending(x => x.Status) : q.OrderBy(x => x.Status),
|
||||
"duration" => query.Desc
|
||||
? q.OrderByDescending(x => x.Duration)
|
||||
: q.OrderBy(x => x.Duration),
|
||||
"resolution" => query.Desc
|
||||
? q.OrderByDescending(x => x.Width)
|
||||
: q.OrderBy(x => x.Width),
|
||||
"processing" => query.Desc
|
||||
? q.OrderByDescending(x => x.ProcessingDuration)
|
||||
: q.OrderBy(x => x.ProcessingDuration),
|
||||
"created" => query.Desc
|
||||
? q.OrderByDescending(x => x.CreatedAt)
|
||||
: q.OrderBy(x => x.CreatedAt),
|
||||
_ => q.OrderByDescending(x => x.CreatedAt),
|
||||
};
|
||||
|
||||
// Маппинг в памяти: MediaAssetDto.From обращается к TimeSpan.TotalSeconds, который EF в SQL
|
||||
// не переводит. Страница ограничена pageSize, поэтому материализация сущностей безопасна.
|
||||
var entities = await q.OrderByDescending(x => x.CreatedAt)
|
||||
var entities = await ordered
|
||||
.ThenBy(x => x.Id)
|
||||
.Skip((query.Page - 1) * query.PageSize)
|
||||
.Take(query.PageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed record MediaAssetDto(
|
||||
string? VideoCodec,
|
||||
string? AudioCodec,
|
||||
string? ErrorMessage,
|
||||
double? ProcessingSeconds,
|
||||
DateTimeOffset CreatedAt
|
||||
)
|
||||
{
|
||||
@@ -30,6 +31,7 @@ public sealed record MediaAssetDto(
|
||||
asset.VideoCodec,
|
||||
asset.AudioCodec,
|
||||
asset.ErrorMessage,
|
||||
asset.ProcessingDuration?.TotalSeconds,
|
||||
asset.CreatedAt
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Media.Stats;
|
||||
|
||||
/// <summary>Сводка по обработке медиа: сколько сейчас в очереди/в обработке и среднее время обработки.</summary>
|
||||
public sealed record GetMediaStatsQuery : IQuery<MediaStatsDto>;
|
||||
|
||||
/// <param name="Queued">Ассетов в статусе Pending (ждут обработки) сейчас.</param>
|
||||
/// <param name="Processing">Ассетов в статусе Processing (обрабатываются) сейчас.</param>
|
||||
/// <param name="AverageProcessingSeconds">Среднее время обработки по недавним завершённым (Ready), сек; null — если нет данных.</param>
|
||||
public sealed record MediaStatsDto(int Queued, int Processing, double? AverageProcessingSeconds);
|
||||
@@ -0,0 +1,40 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Media.Stats;
|
||||
|
||||
public sealed class GetMediaStatsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetMediaStatsQuery, MediaStatsDto>
|
||||
{
|
||||
/// <summary>По скольким последним завершённым ассетам усредняем время обработки.</summary>
|
||||
private const int AverageSample = 500;
|
||||
|
||||
public async Task<MediaStatsDto> Handle(
|
||||
GetMediaStatsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
// Сгенерированные (ТВ-заставки) в статистику библиотеки не входят.
|
||||
var assets = dbContext.MediaAssets.AsNoTracking().Where(x => x.Source != MediaSource.Generated);
|
||||
|
||||
var queued = await assets.CountAsync(x => x.Status == MediaAssetStatus.Pending, cancellationToken);
|
||||
var processing = await assets.CountAsync(
|
||||
x => x.Status == MediaAssetStatus.Processing,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
// Среднее — по недавним завершённым (ограничение выборки, чтобы не тянуть всю историю). EF не
|
||||
// усредняет TimeSpan в SQL, поэтому берём длительности последних N и усредняем в памяти.
|
||||
var recent = await assets
|
||||
.Where(x => x.Status == MediaAssetStatus.Ready && x.ProcessingDuration != null)
|
||||
.OrderByDescending(x => x.UpdatedAt)
|
||||
.Select(x => x.ProcessingDuration!.Value)
|
||||
.Take(AverageSample)
|
||||
.ToListAsync(cancellationToken);
|
||||
double? average = recent.Count > 0 ? recent.Average(d => d.TotalSeconds) : null;
|
||||
|
||||
return new MediaStatsDto(queued, processing, average);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,12 @@ public class MediaAsset
|
||||
/// <summary>Текст ошибки, если <see cref="Status"/> == <see cref="MediaAssetStatus.Failed"/>.</summary>
|
||||
public string? ErrorMessage { get; private set; }
|
||||
|
||||
/// <summary>Когда началась обработка (переход в Processing) — для расчёта её длительности.</summary>
|
||||
public DateTimeOffset? ProcessingStartedAt { get; private set; }
|
||||
|
||||
/// <summary>Сколько заняла обработка завершённого ассета (Ready) = момент готовности − старт обработки.</summary>
|
||||
public TimeSpan? ProcessingDuration { get; private set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
public DateTimeOffset UpdatedAt { get; private set; }
|
||||
|
||||
@@ -79,6 +85,8 @@ public class MediaAsset
|
||||
{
|
||||
Status = MediaAssetStatus.Processing;
|
||||
ErrorMessage = null;
|
||||
ProcessingStartedAt = DateTimeOffset.UtcNow;
|
||||
ProcessingDuration = null;
|
||||
Touch();
|
||||
}
|
||||
|
||||
@@ -103,6 +111,11 @@ public class MediaAsset
|
||||
AudioCodec = audioCodec;
|
||||
RelativePath = relativePath;
|
||||
ErrorMessage = null;
|
||||
if (ProcessingStartedAt is { } startedAt)
|
||||
{
|
||||
var elapsed = DateTimeOffset.UtcNow - startedAt;
|
||||
ProcessingDuration = elapsed > TimeSpan.Zero ? elapsed : TimeSpan.Zero;
|
||||
}
|
||||
Touch();
|
||||
}
|
||||
|
||||
@@ -118,6 +131,7 @@ public class MediaAsset
|
||||
{
|
||||
Status = MediaAssetStatus.Pending;
|
||||
ErrorMessage = null;
|
||||
ProcessingStartedAt = null;
|
||||
Touch();
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +198,8 @@ internal sealed class IdentityService(
|
||||
string? search,
|
||||
Guid? roleId,
|
||||
bool? isBlocked,
|
||||
string? sort,
|
||||
bool desc,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
@@ -225,8 +227,25 @@ internal sealed class IdentityService(
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.user.CreatedAt)
|
||||
var ordered = (sort?.ToLowerInvariant()) switch
|
||||
{
|
||||
"username" => desc
|
||||
? query.OrderByDescending(x => x.user.UserName)
|
||||
: query.OrderBy(x => x.user.UserName),
|
||||
"role" => desc
|
||||
? query.OrderByDescending(x => x.RoleName)
|
||||
: query.OrderBy(x => x.RoleName),
|
||||
"blocked" => desc
|
||||
? query.OrderByDescending(x => x.user.IsBlocked)
|
||||
: query.OrderBy(x => x.user.IsBlocked),
|
||||
"created" => desc
|
||||
? query.OrderByDescending(x => x.user.CreatedAt)
|
||||
: query.OrderBy(x => x.user.CreatedAt),
|
||||
_ => query.OrderByDescending(x => x.user.CreatedAt),
|
||||
};
|
||||
|
||||
var items = await ordered
|
||||
.ThenBy(x => x.user.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new UserSummaryDto(
|
||||
|
||||
Generated
+1023
File diff suppressed because it is too large
Load Diff
+39
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeleWave.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MediaProcessingTiming : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<TimeSpan>(
|
||||
name: "ProcessingDuration",
|
||||
table: "MediaAssets",
|
||||
type: "interval",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "ProcessingStartedAt",
|
||||
table: "MediaAssets",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProcessingDuration",
|
||||
table: "MediaAssets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProcessingStartedAt",
|
||||
table: "MediaAssets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -703,6 +703,12 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<TimeSpan?>("ProcessingDuration")
|
||||
.HasColumnType("interval");
|
||||
|
||||
b.Property<DateTimeOffset?>("ProcessingStartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("RelativePath")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
Reference in New Issue
Block a user