diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index bf4ee33..52a390c 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -31,6 +31,9 @@ + + + diff --git a/backend/Tnsu.ApprovalRoute.Api/Auth/HangfireDashboardBasicAuthFilter.cs b/backend/Tnsu.ApprovalRoute.Api/Auth/HangfireDashboardBasicAuthFilter.cs new file mode 100644 index 0000000..98613de --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Api/Auth/HangfireDashboardBasicAuthFilter.cs @@ -0,0 +1,69 @@ +using System.Security.Cryptography; +using System.Text; +using Hangfire.Dashboard; +using Microsoft.Net.Http.Headers; + +namespace Tnsu.ApprovalRoute.Api.Auth; + +/// +/// Простая Basic Auth для дашборда Hangfire — единственный логин/пароль из +/// конфигурации (Hangfire:Dashboard), а не полноценная схема аутентификации: сам +/// дашборд не участвует в остальной авторизации API (это отдельная страница, +/// открываемая прямо в браузере, не по JWT). +/// +/// Пустые логин/пароль (не заданы в конфигурации) означают «дашборд закрыт для +/// всех» — не «открыт для всех»: пустая строка в конфиге не должна случайно снять +/// защиту. +/// +public sealed class HangfireDashboardBasicAuthFilter(string username, string password) : IDashboardAuthorizationFilter +{ + public bool Authorize(DashboardContext context) + { + var httpContext = context.GetHttpContext(); + + if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password) && + TryGetBasicAuthCredentials(httpContext.Request.Headers.Authorization, out var user, out var pass) && + FixedTimeEquals(user, username) && FixedTimeEquals(pass, password)) + { + return true; + } + + httpContext.Response.Headers[HeaderNames.WWWAuthenticate] = "Basic realm=\"Hangfire\""; + httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; + return false; + } + + private static bool TryGetBasicAuthCredentials( + string? authorizationHeader, out string username, out string password) + { + username = string.Empty; + password = string.Empty; + + if (string.IsNullOrEmpty(authorizationHeader) || + !authorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) + return false; + + try + { + var decoded = Encoding.UTF8.GetString( + Convert.FromBase64String(authorizationHeader["Basic ".Length..].Trim())); + var separatorIndex = decoded.IndexOf(':'); + if (separatorIndex < 0) return false; + + username = decoded[..separatorIndex]; + password = decoded[(separatorIndex + 1)..]; + return true; + } + catch (FormatException) + { + // Битый base64 в заголовке — не наша забота, просто не авторизуем. + return false; + } + } + + // Сравнение за постоянное время: длина логина/пароля в Basic Auth и так видна + // по заголовку, но посимвольное сравнение через == утекало бы ЕЩЁ и через + // тайминг совпадения символов — дешёво закрыть и это. + private static bool FixedTimeEquals(string a, string b) => + CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b)); +} diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeListsController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeListsController.cs index bffe626..2e54a01 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeListsController.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeListsController.cs @@ -1,35 +1,45 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tnsu.ApprovalRoute.Api.Common; -using Tnsu.ApprovalRoute.Application.RequestTypeLists; +using Tnsu.ApprovalRoute.Application.ListPartitioning; namespace Tnsu.ApprovalRoute.Api.Controllers; /// -/// Нумерованная серия SharePoint-списков по типу заявок: куда писать сейчас и вся -/// серия для админки. +/// Нумерованная серия партиций SharePoint-списка по типу заявок: куда писать сейчас +/// и вся серия для админки/портала. /// [ApiController] [Authorize] -[Route("api/request-types/{requestTypeId:int}/lists")] +[Route("api/request-types")] [Produces("application/json")] -public sealed class RequestTypeListsController(RequestTypeListService service) : ControllerBase +public sealed class RequestTypeListsController(ListPartitionService service) : ControllerBase { - /// Вся серия списков типа заявок (архив + текущий), от первого к последнему. - [HttpGet] - [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + /// + /// Серии партиций ВСЕХ типов заявок разом — для портала: чтобы искать по + /// заявкам сразу во всех партициях (архив + текущая) любого типа, не нужно + /// запрашивать серию по каждому типу отдельно. + /// + [HttpGet("lists")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task ListAll(CancellationToken ct) => + (await service.ListAllAsync(ct)).ToActionResult(); + + /// Вся серия партиций одного типа заявок (архив + текущая), от первой к последней. + [HttpGet("{requestTypeId:int}/lists")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] public async Task List(int requestTypeId, CancellationToken ct) => (await service.ListAsync(requestTypeId, ct)).ToActionResult(); /// - /// Список, в который нужно писать новые заявки этого типа. + /// Партиция, в которую нужно писать новые заявки этого типа. /// - /// Не чистый GET: если список добрал порог элементов, запрос ставит в очередь - /// фоновую задачу создания следующего (см. RequestTypeListService.GetCurrentAsync). + /// Не чистый GET: если партиция добрала порог элементов, запрос ставит в очередь + /// фоновую задачу создания следующей (см. ListPartitionService.GetCurrentAsync). /// - [HttpGet("current")] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [HttpGet("{requestTypeId:int}/lists/current")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] public async Task Current(int requestTypeId, CancellationToken ct) => diff --git a/backend/Tnsu.ApprovalRoute.Api/Program.cs b/backend/Tnsu.ApprovalRoute.Api/Program.cs index c64e0ff..a37e1b4 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Program.cs @@ -8,11 +8,12 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.OpenApi; using Scalar.AspNetCore; +using Serilog; using Tnsu.ApprovalRoute.Api.Auth; using Tnsu.ApprovalRoute.Api.Filters; using Tnsu.ApprovalRoute.Application; +using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Application.Reminders; -using Tnsu.ApprovalRoute.Application.RequestTypeLists; using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Infrastructure; @@ -20,11 +21,27 @@ const string CorsPolicyName = "Frontend"; var builder = WebApplication.CreateBuilder(args); +// Заменяет собой встроенных провайдеров логирования: все ILogger в приложении +// (включая наши логи в ListPartitioning) идут через Serilog в оба синка ниже. +// ReadFrom.Configuration — чтобы Logging:LogLevel в appsettings продолжал работать +// как обычно, без второй, серилоговской системы настройки уровней рядом с ним. +builder.Host.UseSerilog((context, services, configuration) => configuration + .ReadFrom.Configuration(context.Configuration) + .Enrich.FromLogContext() + .WriteTo.Console() + // Один файл на календарный месяц (rollOnFileSizeLimit не включаем — ротация + // только по времени). retainedFileCountLimit: null — логи не подчищаются + // автоматически, ротацией места на диске занимается инфраструктура хоста. + .WriteTo.File( + "logs/log-.txt", + rollingInterval: RollingInterval.Month, + retainedFileCountLimit: null)); + builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - + options.KnownNetworks.Clear(); options.KnownProxies.Clear(); }); @@ -40,7 +57,7 @@ builder.Services.AddCors(options => builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); builder.Services.AddApprovalRouteApplication(); builder.Services.Configure(builder.Configuration.GetSection("Reminders")); -builder.Services.Configure(builder.Configuration.GetSection("ListProvisioning")); +builder.Services.Configure(builder.Configuration.GetSection("ListPartitioning")); // Hangfire на том же Postgres, что и остальные данные. Свои таблицы он создаёт сам // (схема hangfire) при старте — отдельной EF-миграции для них не нужно. @@ -109,6 +126,19 @@ app.UseCors(CorsPolicyName); app.UseAuthentication(); app.UseAuthorization(); +// Свой Basic Auth, а не схемы из AuthSetup: дашборд Hangfire открывается прямо в +// браузере без похода за токеном Entra, ему нужна отдельная, простая проверка. +// Логин/пароль — в конфигурации (Hangfire:Dashboard), см. appsettings.json. +app.UseHangfireDashboard("/hangfire", new DashboardOptions +{ + Authorization = + [ + new HangfireDashboardBasicAuthFilter( + app.Configuration["Hangfire:Dashboard:Username"] ?? string.Empty, + app.Configuration["Hangfire:Dashboard:Password"] ?? string.Empty) + ] +}); + app.MapControllers(); await app.InitAndRunAsync(); diff --git a/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj b/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj index a3f81e4..ac24261 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj +++ b/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj @@ -14,5 +14,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json index 575395e..ce4c776 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json @@ -1,8 +1,10 @@ { - "Logging": { - "LogLevel": { + "Serilog": { + "MinimumLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Override": { + "Microsoft.AspNetCore": "Warning" + } } }, "ConnectionStrings": { @@ -19,11 +21,17 @@ "https://localhost:4321" ] }, - "ListProvisioning": { + "ListPartitioning": { "ItemCountThreshold": 4500, "StaleLockAfter": "00:30:00", "CheckThresholdOnRead": true }, + "Hangfire": { + "Dashboard": { + "Username": "admin", + "Password": "RzaT2krczbRpE36QAucdCXR" + } + }, "SharePoint": { "TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a", "ClientId": "9232d9e2-9807-4753-808f-07250e5474b0", diff --git a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs index a42a159..0a35929 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs @@ -2,7 +2,7 @@ using System.Reflection; using FluentValidation; using Microsoft.Extensions.DependencyInjection; using Tnsu.ApprovalRoute.Application.Reminders; -using Tnsu.ApprovalRoute.Application.RequestTypeLists; +using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Application.RequestTypes; using Tnsu.ApprovalRoute.Application.TemplateRoutes; using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; @@ -15,10 +15,10 @@ public static class ApplicationServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // Джоба резолвится Hangfire'ом из scope на каждое выполнение. - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/CreateNextRequestTypeListJob.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs similarity index 58% rename from backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/CreateNextRequestTypeListJob.cs rename to backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs index 5319632..a3e35f4 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/CreateNextRequestTypeListJob.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs @@ -6,26 +6,37 @@ using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.SharePoint; -namespace Tnsu.ApprovalRoute.Application.RequestTypeLists; +namespace Tnsu.ApprovalRoute.Application.ListPartitioning; /// -/// Фоновая задача: создать следующий список серии по типу заявок, записать его в -/// реестр и переключить на него запись. +/// Фоновая задача: создать следующую партицию списка по типу заявок, записать её в +/// реестр и переключить на неё запись. +/// +/// Блокировка захватывается ЗДЕСЬ, при каждом запуске (в т.ч. на каждой повторной +/// попытке Hangfire), а не в момент постановки в очередь. Enqueue и фактическое +/// выполнение — разные события во времени: задача может полежать в очереди, быть +/// повторена автоматически после сбоя, в теории — перезапущена вручную из дашборда. +/// Всё это заново становится тем же самым «выполнением» с точки зрения Hangfire, но +/// не с точки зрения блокировки: settled только выполнение может её удержать, и +/// проверять это надо на каждом фактическом входе в код, создающий список в +/// SharePoint, а не один раз на входе в очередь. Иначе, к примеру, зависший на +/// повторе процесс и новый независимый триггер (например, второй HTTP-запрос, +/// поймавший тот же порог до переключения списка) синхронизировались бы неверно. /// /// Повторы разрешены, но их немного: провижинер идемпотентен (переиспользует список /// с целевым именем и дозаполняет колонки), поэтому повтор после частичного сбоя -/// безопасен и не создаёт второй список. Больше трёх попыток смысла нет — если +/// безопасен и не создаёт вторую партицию. Больше трёх попыток смысла нет — если /// SharePoint отвечает ошибкой стабильно, задача должна остаться видимой в Hangfire, /// а не молотить впустую. /// [AutomaticRetry(Attempts = 3)] -public sealed class CreateNextRequestTypeListJob( +public sealed class CreateNextListPartitionJob( ApprovalRouteDbContext db, - RequestTypeProvisioningLock provisioningLock, - RequestTypeListRegistry registry, + ListPartitioningLock provisioningLock, + ListPartitionRegistry registry, ISharePointListProvisioner provisioner, - IOptions options, - ILogger logger) + IOptions options, + ILogger logger) { public async Task RunAsync(int requestTypeId, CancellationToken cancellationToken) { @@ -60,33 +71,36 @@ public sealed class CreateNextRequestTypeListJob( return; } - // Задачу могли поставить раньше, чем реестр успел узнать про текущий - // список (например, ручным запуском) — регистрируем его, иначе серия + // Задачу могли поставить раньше, чем реестр успел узнать про текущую + // партицию (например, ручным запуском) — регистрируем её, иначе серия // осталась бы без своего первого номера. var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken); - var maxNumber = await db.RequestTypeLists + var maxNumber = await db.RequestListPartitions .Where(x => x.RequestTypeId == requestTypeId) .MaxAsync(x => (int?)x.Number, cancellationToken) ?? current.Number; var nextNumber = maxNumber + 1; logger.LogInformation( - "Создаю список №{Number} для типа заявок {RequestTypeId} по структуре {SourceListId}.", + "Создаю партицию №{Number} для типа заявок {RequestTypeId} по структуре {SourceListId}.", nextNumber, requestTypeId, requestType.ListId); + // Партиция всегда создаётся на том же сайте, что и текущая (см. комментарий + // на RequestListPartition о недопустимости разъезда серии по сайтам) — сайт + // берём из RequestType, а не откуда-либо ещё, так что расхождение + // структурно невозможно. var created = await provisioner.CreateNextListPairAsync( requestType.SiteUrl, requestType.ListId, nextNumber, cancellationToken); // Реестр и переключение записи — одной транзакцией: иначе можно остаться - // с записанным в реестр списком, в который никто не пишет, или наоборот. + // с записанной в реестр партицией, в которую никто не пишет, или наоборот. await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); - db.RequestTypeLists.Add(new RequestTypeList + db.RequestListPartitions.Add(new RequestListPartition { RequestTypeId = requestTypeId, Number = nextNumber, - SiteUrl = requestType.SiteUrl, ListId = created.ListId, Title = created.Title, CreatedAt = DateTimeOffset.UtcNow @@ -98,7 +112,7 @@ public sealed class CreateNextRequestTypeListJob( await transaction.CommitAsync(cancellationToken); logger.LogInformation( - "Список №{Number} ({ListId}) создан, запись по типу заявок {RequestTypeId} переключена на него.", + "Партиция №{Number} ({ListId}) создана, запись по типу заявок {RequestTypeId} переключена на неё.", nextNumber, created.ListId, requestTypeId); } finally diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionDto.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionDto.cs new file mode 100644 index 0000000..a67e196 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionDto.cs @@ -0,0 +1,35 @@ +namespace Tnsu.ApprovalRoute.Application.ListPartitioning; + +/// Одна партиция серии — для админки/портала (вся серия) и для ответа «текущий список». +/// +/// true — в эту партицию идёт запись новых заявок; остальные партиции серии — архив только для чтения. +/// +public sealed record RequestListPartitionDto( + int Number, + string SiteUrl, + string ListId, + string? Title, + bool IsCurrent, + DateTimeOffset CreatedAt); + +/// Вся серия партиций одного типа заявок — единица ответа bulk-эндпоинта для портала. +public sealed record RequestTypeListSeriesDto( + int RequestTypeId, + IReadOnlyList Partitions); + +/// Ответ «куда писать новую заявку». +/// +/// Сколько элементов сейчас в списке — по нему видно, насколько он близок к порогу. +/// null, если SharePoint не ответил (тогда условие создания следующей партиции не проверялось). +/// +/// +/// true — этим запросом поставлена в очередь задача создания следующей партиции серии. +/// +public sealed record CurrentListPartitionDto( + int RequestTypeId, + int Number, + string SiteUrl, + string ListId, + string? Title, + int? ItemCount = null, + bool ProvisioningQueued = false); diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListRegistry.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionRegistry.cs similarity index 72% rename from backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListRegistry.cs rename to backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionRegistry.cs index eb83d38..d277ffd 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListRegistry.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionRegistry.cs @@ -4,49 +4,48 @@ using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.SharePoint; -namespace Tnsu.ApprovalRoute.Application.RequestTypeLists; +namespace Tnsu.ApprovalRoute.Application.ListPartitioning; /// -/// Наполнение реестра списков. Вынесено отдельно, потому что нужно и читающему +/// Наполнение реестра партиций. Вынесено отдельно, потому что нужно и читающему /// эндпоинту, и фоновой задаче: реестр появился позже самих списков, и любой из них /// может встретить тип заявок, которого в реестре ещё нет. /// -public sealed class RequestTypeListRegistry( +public sealed class ListPartitionRegistry( ApprovalRouteDbContext db, ISharePointClient sharePoint, - ILogger logger) + ILogger logger) { /// - /// Гарантирует, что текущий список типа заявок есть в реестре, и возвращает его строку. + /// Гарантирует, что текущая партиция типа заявок есть в реестре, и возвращает её строку. /// /// Реестр самозаполняющийся: до этой фичи списки жили только в /// RequestType.ListId, и переключить список руками в админке можно и сейчас. /// Поэтому неизвестный реестру ListId дописывается следующим номером, а не /// считается ошибкой. /// - /// Требует заполненных ListId/SiteUrl — вызывающая сторона это проверяет. + /// Требует заполненного ListId — вызывающая сторона это проверяет. /// - public async Task EnsureCurrentRegisteredAsync( + public async Task EnsureCurrentRegisteredAsync( RequestType requestType, CancellationToken cancellationToken) { - var series = await db.RequestTypeLists + var series = await db.RequestListPartitions .Where(x => x.RequestTypeId == requestType.Id) .ToListAsync(cancellationToken); var existing = series.FirstOrDefault(x => x.ListId == requestType.ListId); if (existing is not null) return existing; - var entity = new RequestTypeList + var entity = new RequestListPartition { RequestTypeId = requestType.Id, Number = series.Count == 0 ? 1 : series.Max(x => x.Number) + 1, - SiteUrl = requestType.SiteUrl!, ListId = requestType.ListId!, Title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken), CreatedAt = DateTimeOffset.UtcNow }; - db.RequestTypeLists.Add(entity); + db.RequestListPartitions.Add(entity); await db.SaveChangesAsync(cancellationToken); logger.LogInformation( @@ -56,8 +55,8 @@ public sealed class RequestTypeListRegistry( return entity; } - // Заголовок нужен только для отображения в админке — из-за его отсутствия - // регистрация списка падать не должна. + // Заголовок нужен только для отображения в админке/портале — из-за его + // отсутствия регистрация партиции падать не должна. private async Task TryGetTitleAsync(string siteUrl, string listId, CancellationToken cancellationToken) { try diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListService.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs similarity index 51% rename from backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListService.cs rename to backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs index 3104733..d284ea8 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs @@ -8,40 +8,40 @@ using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.SharePoint; -namespace Tnsu.ApprovalRoute.Application.RequestTypeLists; +namespace Tnsu.ApprovalRoute.Application.ListPartitioning; /// -/// Реестр нумерованных списков по типу заявок: какой список текущий (куда писать) и -/// вся серия целиком (архив + текущий) для админки. +/// Реестр нумерованных партиций списка по типу заявок: какая партиция текущая (куда +/// писать) и вся серия целиком (архив + текущая) для админки и портала. /// -public sealed class RequestTypeListService( +public sealed class ListPartitionService( ApprovalRouteDbContext db, ISharePointClient sharePoint, - RequestTypeListRegistry registry, + ListPartitionRegistry registry, IBackgroundJobClient backgroundJobs, - IOptions options, - ILogger logger) + IOptions options, + ILogger logger) { /// - /// Список, в который сейчас нужно писать новые заявки. + /// Партиция, в которую сейчас нужно писать новые заявки. /// - /// Побочный эффект: если в списке набрался порог элементов, ставит в очередь - /// задачу создания следующего. Побочный эффект в GET — сознательный компромисс + /// Побочный эффект: если партиция набрала порог элементов, ставит в очередь + /// задачу создания следующей. Побочный эффект в GET — сознательный компромисс /// (так описано в ТЗ): вызывающая сторона всё равно спрашивает адрес перед каждой /// записью, и это единственный момент, когда мы точно знаем, что список живой. /// Повторная постановка безопасна — задача разруливает гонку захватом блокировки. /// - public async Task> GetCurrentAsync( + public async Task> GetCurrentAsync( int requestTypeId, CancellationToken cancellationToken) { var requestType = await db.RequestTypes .FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken); if (requestType is null) - return Result.Fail(new NotFoundError("Тип заявки", requestTypeId)); + return Result.Fail(new NotFoundError("Тип заявки", requestTypeId)); if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl)) - return Result.Fail(new ValidationError( + return Result.Fail(new ValidationError( $"У типа заявки {requestTypeId} не настроен список SharePoint.")); var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken); @@ -49,33 +49,61 @@ public sealed class RequestTypeListService( var itemCount = await TryGetItemCountAsync(requestType.SiteUrl, requestType.ListId, cancellationToken); var queued = await TryEnqueueProvisioningAsync(requestType, current.Number, itemCount, cancellationToken); - return Result.Ok(new CurrentRequestTypeListDto( - requestTypeId, current.Number, current.SiteUrl, current.ListId, current.Title, itemCount, queued)); + return Result.Ok(new CurrentListPartitionDto( + requestTypeId, current.Number, requestType.SiteUrl, current.ListId, current.Title, itemCount, queued)); } - /// Вся серия списков типа заявок, от первого к последнему — для админки. - public async Task>> ListAsync( + /// Вся серия партиций типа заявок, от первой к последней — для админки. + public async Task>> ListAsync( int requestTypeId, CancellationToken cancellationToken) { var requestType = await db.RequestTypes .FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken); if (requestType is null) - return Result.Fail>(new NotFoundError("Тип заявки", requestTypeId)); + return Result.Fail>(new NotFoundError("Тип заявки", requestTypeId)); - // Тип без настроенного списка — пустая серия, а не ошибка: в админке такой - // тип заявок вполне может существовать до привязки к SharePoint. - if (!string.IsNullOrWhiteSpace(requestType.ListId) && !string.IsNullOrWhiteSpace(requestType.SiteUrl)) - await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken); + return Result.Ok(await BuildSeriesAsync(requestType, cancellationToken)); + } - var items = await db.RequestTypeLists.AsNoTracking() - .Where(x => x.RequestTypeId == requestTypeId) - .OrderBy(x => x.Number) - .Select(x => new RequestTypeListDto( - x.Number, x.SiteUrl, x.ListId, x.Title, x.ListId == requestType.ListId, x.CreatedAt)) + /// + /// Серии всех типов заявок разом — для портала: чтобы искать по заявкам сразу во + /// всех партициях (архив + текущая) любого типа, порталу нужен весь список + /// адресов один раз, а не по одному запросу на тип. Типы без настроенного + /// списка SharePoint в ответ не попадают — искать там всё равно нечего. + /// + public async Task>> ListAllAsync(CancellationToken cancellationToken) + { + var requestTypes = await db.RequestTypes + .Where(x => x.ListId != null && x.SiteUrl != null) .ToListAsync(cancellationToken); - return Result.Ok>(items); + var result = new List(requestTypes.Count); + foreach (var requestType in requestTypes) + { + var partitions = await BuildSeriesAsync(requestType, cancellationToken); + result.Add(new RequestTypeListSeriesDto(requestType.Id, partitions)); + } + + return Result.Ok>(result); + } + + private async Task> BuildSeriesAsync( + RequestType requestType, CancellationToken cancellationToken) + { + // Тип без настроенного списка — пустая серия, а не ошибка: в админке такой + // тип заявок вполне может существовать до привязки к SharePoint. + if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl)) + return []; + + await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken); + + return await db.RequestListPartitions.AsNoTracking() + .Where(x => x.RequestTypeId == requestType.Id) + .OrderBy(x => x.Number) + .Select(x => new RequestListPartitionDto( + x.Number, requestType.SiteUrl!, x.ListId, x.Title, x.ListId == requestType.ListId, x.CreatedAt)) + .ToListAsync(cancellationToken); } /// @@ -99,7 +127,7 @@ public sealed class RequestTypeListService( } /// - /// Ставит задачу создания следующего списка, если порог достигнут. Возвращает + /// Ставит задачу создания следующей партиции, если порог достигнут. Возвращает /// true, только если задача действительно поставлена. /// private async Task TryEnqueueProvisioningAsync( @@ -107,23 +135,23 @@ public sealed class RequestTypeListService( { if (itemCount is null || itemCount < options.Value.ItemCountThreshold) return false; - // Следующий список уже создан, а запись на него ещё не переключили — задача - // не нужна (и переключать здесь нельзя: этим владеет провижининг). - var hasNext = await db.RequestTypeLists + // Следующая партиция уже создана, а запись на неё ещё не переключили — + // задача не нужна (и переключать здесь нельзя: этим владеет провижининг). + var hasNext = await db.RequestListPartitions .AnyAsync(x => x.RequestTypeId == requestType.Id && x.Number > currentNumber, cancellationToken); if (hasNext) return false; // Провижининг уже идёт. Проверка неатомарная и это нормально: она экономит // постановку лишней задачи, а настоящую гонку ловит захват блокировки внутри - // самой задачи. - if (requestType.ListProvisioningStartedAt is not null) return false; + // самой задачи (см. CreateNextListPartitionJob). + if (requestType.ListPartitioningStartedAt is not null) return false; - backgroundJobs.Enqueue( + backgroundJobs.Enqueue( job => job.RunAsync(requestType.Id, CancellationToken.None)); logger.LogInformation( "В списке {ListId} типа заявок {RequestTypeId} {ItemCount} элементов (порог {Threshold}) — " + - "поставлена задача создания списка №{NextNumber}.", + "поставлена задача создания партиции №{NextNumber}.", requestType.ListId, requestType.Id, itemCount, options.Value.ItemCountThreshold, currentNumber + 1); return true; diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/ListProvisioningOptions.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitioningOptions.cs similarity index 62% rename from backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/ListProvisioningOptions.cs rename to backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitioningOptions.cs index d2a5c9a..b158e5e 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/ListProvisioningOptions.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitioningOptions.cs @@ -1,30 +1,31 @@ -namespace Tnsu.ApprovalRoute.Application.RequestTypeLists; +namespace Tnsu.ApprovalRoute.Application.ListPartitioning; -/// Настройки нумерованных списков заявок (секция ListProvisioning в конфигурации). -public sealed class ListProvisioningOptions +/// Настройки нумерованных партиций списков заявок (секция ListPartitioning в конфигурации). +public sealed class ListPartitioningOptions { /// /// Порог элементов в списке данных, по достижении которого ставится задача на - /// создание следующей пары списков. + /// создание следующей партиции (пары списков). /// /// 5000 — не предел SharePoint на число элементов (их может быть миллионы), а /// «list view threshold»: запросы, которым приходится просмотреть больше 5000 /// элементов без подходящего индекса, SharePoint отклоняет. Порог намеренно чуть - /// ниже, чтобы задача успела создать следующий список до того, как текущий + /// ниже, чтобы задача успела создать следующую партицию до того, как текущая /// начнёт упираться. /// public int ItemCountThreshold { get; set; } = 4500; /// /// Через сколько блокировка провижининга считается брошенной и может быть - /// перехвачена (процесс упал, не сняв флаг). См. RequestType.ListProvisioningStartedAt. + /// перехвачена (процесс упал, не сняв флаг). См. RequestType.ListPartitioningStartedAt. /// public TimeSpan StaleLockAfter { get; set; } = TimeSpan.FromMinutes(30); /// /// Проверять ли порог в эндпоинте «текущий список». false — эндпоинт только - /// отдаёт список, не обращаясь к SharePoint и никого не ставя в очередь - /// (например, пока провижинер не готов). + /// отдаёт список, не обращаясь к SharePoint за числом элементов и никого не + /// ставя в очередь. Полезно, если SharePoint временно недоступен или нужно + /// приостановить автосоздание партиций, не трогая остальную логику. /// public bool CheckThresholdOnRead { get; set; } = true; } diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListDto.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListDto.cs deleted file mode 100644 index 7a28b51..0000000 --- a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListDto.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace Tnsu.ApprovalRoute.Application.RequestTypeLists; - -/// Один список серии — для админки (вся серия) и для ответа «текущий список». -/// -/// true — в этот список идёт запись новых заявок; остальные серии — архив только для чтения. -/// -public sealed record RequestTypeListDto( - int Number, - string SiteUrl, - string ListId, - string? Title, - bool IsCurrent, - DateTimeOffset CreatedAt); - -/// Ответ «куда писать новую заявку». -/// -/// Сколько элементов сейчас в списке — по нему видно, насколько он близок к порогу. -/// null, если SharePoint не ответил (тогда условие создания следующего не проверялось). -/// -/// -/// true — этим запросом поставлена в очередь задача создания следующего списка серии. -/// -public sealed record CurrentRequestTypeListDto( - int RequestTypeId, - int Number, - string SiteUrl, - string ListId, - string? Title, - int? ItemCount = null, - bool ProvisioningQueued = false); diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestListPartition.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestListPartition.cs new file mode 100644 index 0000000..8ffcb3c --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestListPartition.cs @@ -0,0 +1,47 @@ +namespace Tnsu.ApprovalRoute.Domain.Entities; + +/// +/// Один SharePoint-список данных из нумерованной серии по типу заявок. +/// +/// Списки заявок нумерованные («<Форма> <N> — данные»): когда в текущем +/// списке набирается порог элементов, рядом создаётся следующая пара (партиция), а +/// прежняя остаётся архивом только для чтения. Эта таблица — реестр всей серии: без +/// неё поиск по заявкам типа не знает, по каким спискам искать. +/// +/// Текущий (тот, куда идёт запись) список продолжает лежать в +/// — реестр его дублирует строкой с наибольшим +/// , но источником правды для записи остаётся RequestType. +/// +/// Сайта у партиции нет намеренно: вся серия живёт на одном сайте — том, что указан +/// в на момент создания партиции. Хранить его +/// отдельно на каждой партиции — заводить возможность архивам разъехаться по разным +/// сайтам, а такой сценарий не поддерживается ни провижинером (см. +/// SharePointListProvisioner — всегда пишет на siteUrl запрашивающего типа заявок), +/// ни поиском. Если сайт типа заявок когда-нибудь придётся сменить, старые партиции +/// нужно будет переносить осознанно, а не молча продолжать читать их со старого адреса. +/// +public class RequestListPartition +{ + public int Id { get; set; } + + public int RequestTypeId { get; set; } + public RequestType? RequestType { get; set; } + + /// Номер партиции в серии, с 1. Уникален в пределах типа заявок. + public int Number { get; set; } + + /// + /// Серверный относительный url списка — та же семантика, что у + /// (несмотря на имя, это url, а не GUID). + /// + public string ListId { get; set; } = string.Empty; + + /// + /// Title списка на момент создания — для админки, чтобы показать серию без + /// похода в SharePoint за каждым заголовком. Может разойтись с фактическим, + /// если список переименовали вручную. + /// + public string? Title { get; set; } + + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs index a65a006..a5f4cdf 100644 --- a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs +++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs @@ -26,22 +26,22 @@ public class RequestType public bool? AskOverdueReason { get; set; } /// - /// Момент, когда фоновая задача создания следующего списка взяла эту строку в - /// работу. null — задача не выполняется. + /// Момент, когда фоновая задача создания следующей партиции списка взяла эту + /// строку в работу. null — задача не выполняется. /// /// Одновременно и признак «идёт провижининг», и БЛОКИРОВКА: задача стартует /// только если сумела перевести поле из null в now() одним условным UPDATE - /// (см. RequestTypeProvisioningLock) — тогда две параллельные задачи по одному - /// типу заявок невозможны. + /// (см. ListPartitioningLock) — тогда две параллельные задачи по одному типу + /// заявок невозможны. /// /// Метка времени, а не bool, намеренно: упавший процесс не успевает снять /// флаг, и bool заблокировал бы тип навсегда, а по метке видно, что блокировка /// просрочена, и её можно перехватить. /// - public DateTimeOffset? ListProvisioningStartedAt { get; set; } + public DateTimeOffset? ListPartitioningStartedAt { get; set; } public ICollection Routes { get; set; } = new List(); - /// Нумерованная серия списков этого типа заявок (архив + текущий). - public ICollection Lists { get; set; } = new List(); + /// Нумерованная серия партиций списка этого типа заявок (архив + текущая). + public ICollection ListPartitions { get; set; } = new List(); } diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestTypeList.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestTypeList.cs deleted file mode 100644 index 974f308..0000000 --- a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestTypeList.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace Tnsu.ApprovalRoute.Domain.Entities; - -/// -/// Один SharePoint-список данных из нумерованной серии по типу заявок. -/// -/// Списки заявок нумерованные («<Форма> <N> — данные», см. провижинер -/// request-portal): когда в текущем списке набирается порог элементов, рядом -/// создаётся следующая пара, а прежняя остаётся архивом только для чтения. Эта -/// таблица — реестр всей серии: без неё поиск по заявкам типа не знает, по каким -/// спискам искать, а провижинер — какой номер выдавать следующим. -/// -/// Текущий (тот, куда идёт запись) список продолжает лежать в -/// — реестр его дублирует строкой с наибольшим -/// , но источником правды для записи остаётся RequestType. -/// -public class RequestTypeList -{ - public int Id { get; set; } - - public int RequestTypeId { get; set; } - public RequestType? RequestType { get; set; } - - /// Номер списка в серии, с 1. Уникален в пределах типа заявок. - public int Number { get; set; } - - /// Сайт SharePoint, где живёт список (серия может переезжать между сайтами). - public string SiteUrl { get; set; } = string.Empty; - - /// - /// Серверный относительный url списка — та же семантика, что у - /// (несмотря на имя, это url, а не GUID). - /// - public string ListId { get; set; } = string.Empty; - - /// - /// Title списка на момент создания — для админки, чтобы показать серию без - /// похода в SharePoint за каждым заголовком. Может разойтись с фактическим, - /// если список переименовали вручную. - /// - public string? Title { get; set; } - - public DateTimeOffset CreatedAt { get; set; } -} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs index f187d8c..bf46e61 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -20,7 +20,7 @@ public static class InfrastructureServiceCollectionExtensions opt.UseNpgsql(configuration.GetConnectionString("Postgres")) .UseSnakeCaseNamingConvention()); services.AddAsyncInitializer(); - services.AddScoped(); + services.AddScoped(); services.Configure(configuration.GetSection("SharePoint")); // Провайдер сертификата — синглтон: разобранный .pfx кешируется на всё diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index a0a6f5d..edea59c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -7,7 +7,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence; public class ApprovalRouteDbContext(DbContextOptions options) : DbContext(options) { public DbSet RequestTypes => Set(); - public DbSet RequestTypeLists => Set(); + public DbSet RequestListPartitions => Set(); public DbSet TemplateRoutes => Set(); public DbSet TemplateRouteSteps => Set(); public DbSet Settings => Set(); @@ -34,13 +34,12 @@ public class ApprovalRouteDbContext(DbContextOptions opt e.Property(x => x.Section).HasMaxLength(256).IsRequired(); }); - modelBuilder.Entity(e => + modelBuilder.Entity(e => { e.HasKey(x => x.Id); - e.Property(x => x.SiteUrl).HasMaxLength(512).IsRequired(); e.Property(x => x.ListId).HasMaxLength(512).IsRequired(); e.Property(x => x.Title).HasMaxLength(512); - e.HasOne(x => x.RequestType).WithMany(x => x.Lists).HasForeignKey(x => x.RequestTypeId); + e.HasOne(x => x.RequestType).WithMany(x => x.ListPartitions).HasForeignKey(x => x.RequestTypeId); // Номер уникален в пределах типа заявок — вторая попытка записать тот же // номер (например, при двойном запуске задачи) упадёт на этом индексе, // а не создаст в реестре дубль. diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/RequestTypeProvisioningLock.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ListPartitioningLock.cs similarity index 87% rename from backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/RequestTypeProvisioningLock.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ListPartitioningLock.cs index 47b5ed8..3556cea 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/RequestTypeProvisioningLock.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ListPartitioningLock.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore; namespace Tnsu.ApprovalRoute.Infrastructure.Persistence; /// -/// Блокировка «по типу заявок идёт создание следующего списка» на строке +/// Блокировка «по типу заявок идёт создание следующей партиции списка» на строке /// request_types. /// /// Почему сырой SQL, а не обычный EF: через change tracker это было бы @@ -17,7 +17,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence; /// блокировку было видно в самой строке (админке — что провижининг идёт) и чтобы /// она переживала возврат соединения в пул между шагами задачи. /// -public sealed class RequestTypeProvisioningLock(ApprovalRouteDbContext db) +public sealed class ListPartitioningLock(ApprovalRouteDbContext db) { /// /// Попытаться захватить блокировку. true — захватили (и только тогда можно @@ -33,10 +33,10 @@ public sealed class RequestTypeProvisioningLock(ApprovalRouteDbContext db) var affected = await db.Database.ExecuteSqlInterpolatedAsync( $""" UPDATE request_types - SET list_provisioning_started_at = now() + SET list_partitioning_started_at = now() WHERE id = {requestTypeId} - AND (list_provisioning_started_at IS NULL - OR list_provisioning_started_at < now() - CAST({staleAfter} AS interval)) + AND (list_partitioning_started_at IS NULL + OR list_partitioning_started_at < now() - CAST({staleAfter} AS interval)) """, cancellationToken); @@ -46,6 +46,6 @@ public sealed class RequestTypeProvisioningLock(ApprovalRouteDbContext db) /// Снять блокировку. Идемпотентно: снятая блокировка снимается повторно без ошибки. public Task ReleaseAsync(int requestTypeId, CancellationToken cancellationToken) => db.Database.ExecuteSqlInterpolatedAsync( - $"UPDATE request_types SET list_provisioning_started_at = NULL WHERE id = {requestTypeId}", + $"UPDATE request_types SET list_partitioning_started_at = NULL WHERE id = {requestTypeId}", cancellationToken); } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.Designer.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817102423_AddListPartitioning.Designer.cs similarity index 92% rename from backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.Designer.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817102423_AddListPartitioning.Designer.cs index 15f6299..2995735 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.Designer.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817102423_AddListPartitioning.Designer.cs @@ -12,8 +12,8 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence; namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations { [DbContext(typeof(ApprovalRouteDbContext))] - [Migration("20260817084927_AddRequestTypeListsRegistry")] - partial class AddRequestTypeListsRegistry + [Migration("20260817102423_AddListPartitioning")] + partial class AddListPartitioning { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -25,6 +25,48 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ListId") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("list_id"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_request_list_partitions"); + + b.HasIndex("RequestTypeId", "Number") + .IsUnique() + .HasDatabaseName("ix_request_list_partitions_request_type_id_number"); + + b.ToTable("request_list_partitions", (string)null); + }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => { b.Property("Id") @@ -43,9 +85,9 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(512)") .HasColumnName("list_id"); - b.Property("ListProvisioningStartedAt") + b.Property("ListPartitioningStartedAt") .HasColumnType("timestamp with time zone") - .HasColumnName("list_provisioning_started_at"); + .HasColumnName("list_partitioning_started_at"); b.Property("Name") .IsRequired() @@ -80,54 +122,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.ToTable("request_types", (string)null); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("ListId") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("character varying(512)") - .HasColumnName("list_id"); - - b.Property("Number") - .HasColumnType("integer") - .HasColumnName("number"); - - b.Property("RequestTypeId") - .HasColumnType("integer") - .HasColumnName("request_type_id"); - - b.Property("SiteUrl") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("character varying(512)") - .HasColumnName("site_url"); - - b.Property("Title") - .HasMaxLength(512) - .HasColumnType("character varying(512)") - .HasColumnName("title"); - - b.HasKey("Id") - .HasName("pk_request_type_lists"); - - b.HasIndex("RequestTypeId", "Number") - .IsUnique() - .HasDatabaseName("ix_request_type_lists_request_type_id_number"); - - b.ToTable("request_type_lists", (string)null); - }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { b.Property("Id") @@ -267,14 +261,14 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.ToTable("settings", (string)null); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b => { b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") - .WithMany("Lists") + .WithMany("ListPartitions") .HasForeignKey("RequestTypeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_request_type_lists_request_types_request_type_id"); + .HasConstraintName("fk_request_list_partitions_request_types_request_type_id"); b.Navigation("RequestType"); }); @@ -305,7 +299,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => { - b.Navigation("Lists"); + b.Navigation("ListPartitions"); b.Navigation("Routes"); }); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817102423_AddListPartitioning.cs similarity index 76% rename from backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817102423_AddListPartitioning.cs index a67d3bf..9c2eff6 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817102423_AddListPartitioning.cs @@ -7,35 +7,34 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations { /// - public partial class AddRequestTypeListsRegistry : Migration + public partial class AddListPartitioning : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn( - name: "list_provisioning_started_at", + name: "list_partitioning_started_at", table: "request_types", type: "timestamp with time zone", nullable: true); migrationBuilder.CreateTable( - name: "request_type_lists", + name: "request_list_partitions", columns: table => new { id = table.Column(type: "integer", nullable: false) .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), request_type_id = table.Column(type: "integer", nullable: false), number = table.Column(type: "integer", nullable: false), - site_url = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), list_id = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), title = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), created_at = table.Column(type: "timestamp with time zone", nullable: false) }, constraints: table => { - table.PrimaryKey("pk_request_type_lists", x => x.id); + table.PrimaryKey("pk_request_list_partitions", x => x.id); table.ForeignKey( - name: "fk_request_type_lists_request_types_request_type_id", + name: "fk_request_list_partitions_request_types_request_type_id", column: x => x.request_type_id, principalTable: "request_types", principalColumn: "id", @@ -43,8 +42,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations }); migrationBuilder.CreateIndex( - name: "ix_request_type_lists_request_type_id_number", - table: "request_type_lists", + name: "ix_request_list_partitions_request_type_id_number", + table: "request_list_partitions", columns: new[] { "request_type_id", "number" }, unique: true); } @@ -53,10 +52,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( - name: "request_type_lists"); + name: "request_list_partitions"); migrationBuilder.DropColumn( - name: "list_provisioning_started_at", + name: "list_partitioning_started_at", table: "request_types"); } } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index 69528d5..1e95a34 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -22,6 +22,48 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ListId") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("list_id"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_request_list_partitions"); + + b.HasIndex("RequestTypeId", "Number") + .IsUnique() + .HasDatabaseName("ix_request_list_partitions_request_type_id_number"); + + b.ToTable("request_list_partitions", (string)null); + }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => { b.Property("Id") @@ -40,9 +82,9 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(512)") .HasColumnName("list_id"); - b.Property("ListProvisioningStartedAt") + b.Property("ListPartitioningStartedAt") .HasColumnType("timestamp with time zone") - .HasColumnName("list_provisioning_started_at"); + .HasColumnName("list_partitioning_started_at"); b.Property("Name") .IsRequired() @@ -77,54 +119,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.ToTable("request_types", (string)null); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("ListId") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("character varying(512)") - .HasColumnName("list_id"); - - b.Property("Number") - .HasColumnType("integer") - .HasColumnName("number"); - - b.Property("RequestTypeId") - .HasColumnType("integer") - .HasColumnName("request_type_id"); - - b.Property("SiteUrl") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("character varying(512)") - .HasColumnName("site_url"); - - b.Property("Title") - .HasMaxLength(512) - .HasColumnType("character varying(512)") - .HasColumnName("title"); - - b.HasKey("Id") - .HasName("pk_request_type_lists"); - - b.HasIndex("RequestTypeId", "Number") - .IsUnique() - .HasDatabaseName("ix_request_type_lists_request_type_id_number"); - - b.ToTable("request_type_lists", (string)null); - }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { b.Property("Id") @@ -264,14 +258,14 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.ToTable("settings", (string)null); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b => { b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") - .WithMany("Lists") + .WithMany("ListPartitions") .HasForeignKey("RequestTypeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_request_type_lists_request_types_request_type_id"); + .HasConstraintName("fk_request_list_partitions_request_types_request_type_id"); b.Navigation("RequestType"); }); @@ -302,7 +296,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => { - b.Navigation("Lists"); + b.Navigation("ListPartitions"); b.Navigation("Routes"); }); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeProvisioningLockTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitioningLockTests.cs similarity index 89% rename from backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeProvisioningLockTests.cs rename to backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitioningLockTests.cs index 490e77c..932626f 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeProvisioningLockTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitioningLockTests.cs @@ -9,10 +9,10 @@ namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; /// /// Блокировка провижининга — единственное, что не даёт двум фоновым задачам создать -/// по списку каждая, поэтому проверяется именно на живом Postgres: смысл здесь несёт -/// атомарность условного UPDATE, а не код на C#. +/// по партиции каждая, поэтому проверяется именно на живом Postgres: смысл здесь +/// несёт атомарность условного UPDATE, а не код на C#. /// -public sealed class RequestTypeProvisioningLockTests(PostgresDatabaseFixture fixture) +public sealed class ListPartitioningLockTests(PostgresDatabaseFixture fixture) : IClassFixture { private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(30); @@ -59,7 +59,7 @@ public sealed class RequestTypeProvisioningLockTests(PostgresDatabaseFixture fix // Action using (var scope = _provider.CreateScope()) { - await scope.ServiceProvider.GetRequiredService() + await scope.ServiceProvider.GetRequiredService() .ReleaseAsync(requestTypeId, CancellationToken.None); } @@ -83,7 +83,7 @@ public sealed class RequestTypeProvisioningLockTests(PostgresDatabaseFixture fix { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.ExecuteSqlInterpolatedAsync( - $"UPDATE request_types SET list_provisioning_started_at = now() - interval '1 hour' WHERE id = {requestTypeId}"); + $"UPDATE request_types SET list_partitioning_started_at = now() - interval '1 hour' WHERE id = {requestTypeId}"); } // Assert @@ -97,7 +97,7 @@ public sealed class RequestTypeProvisioningLockTests(PostgresDatabaseFixture fix private async Task TryAcquireAsync(int requestTypeId, TimeSpan? staleAfter = null) { using var scope = _provider.CreateScope(); - return await scope.ServiceProvider.GetRequiredService() + return await scope.ServiceProvider.GetRequiredService() .TryAcquireAsync(requestTypeId, staleAfter ?? StaleAfter, CancellationToken.None); }