From 2a7130c37f86efcc407851594e3e29472ac5abdb Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 17 Aug 2026 14:14:45 +0500 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=B6=D0=BE=D0=B1=D0=B0=20=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Directory.Packages.props | 11 + .../Controllers/RequestTypeListsController.cs | 37 ++ backend/Tnsu.ApprovalRoute.Api/Program.cs | 17 + .../Tnsu.ApprovalRoute.Api.csproj | 2 + .../Tnsu.ApprovalRoute.Api/appsettings.json | 5 + .../ApplicationServiceCollectionExtensions.cs | 5 + .../CreateNextRequestTypeListJob.cs | 111 ++++++ .../ListProvisioningOptions.cs | 30 ++ .../RequestTypeLists/RequestTypeListDto.cs | 30 ++ .../RequestTypeListRegistry.cs | 73 ++++ .../RequestTypeListService.cs | 131 +++++++ .../Tnsu.ApprovalRoute.Application.csproj | 1 + .../Entities/RequestType.cs | 18 + .../Entities/RequestTypeList.cs | 43 +++ ...frastructureServiceCollectionExtensions.cs | 2 + .../Persistence/ApprovalRouteDbContext.cs | 14 + ...27_AddRequestTypeListsRegistry.Designer.cs | 320 ++++++++++++++++++ ...60817084927_AddRequestTypeListsRegistry.cs | 63 ++++ .../ApprovalRouteDbContextModelSnapshot.cs | 66 ++++ .../RequestTypeProvisioningLock.cs | 51 +++ .../SharePoint/ISharePointListProvisioner.cs | 27 ++ .../SharePoint/ListTitleConvention.cs | 54 +++ .../SharePoint/SharePointClient.cs | 176 +++++++++- .../SharePoint/SharePointListProvisioner.cs | 131 +++++++ .../RequestTypeProvisioningLockTests.cs | 120 +++++++ 25 files changed, 1533 insertions(+), 5 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeListsController.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/CreateNextRequestTypeListJob.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/ListProvisioningOptions.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListDto.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListRegistry.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListService.cs create mode 100644 backend/Tnsu.ApprovalRoute.Domain/Entities/RequestTypeList.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.Designer.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/RequestTypeProvisioningLock.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ISharePointListProvisioner.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ListTitleConvention.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeProvisioningLockTests.cs diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 0ee4fae..bf4ee33 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -1,6 +1,12 @@ true + + true @@ -9,6 +15,9 @@ + + + @@ -18,6 +27,8 @@ + + diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeListsController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeListsController.cs new file mode 100644 index 0000000..bffe626 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeListsController.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tnsu.ApprovalRoute.Api.Common; +using Tnsu.ApprovalRoute.Application.RequestTypeLists; + +namespace Tnsu.ApprovalRoute.Api.Controllers; + +/// +/// Нумерованная серия SharePoint-списков по типу заявок: куда писать сейчас и вся +/// серия для админки. +/// +[ApiController] +[Authorize] +[Route("api/request-types/{requestTypeId:int}/lists")] +[Produces("application/json")] +public sealed class RequestTypeListsController(RequestTypeListService service) : ControllerBase +{ + /// Вся серия списков типа заявок (архив + текущий), от первого к последнему. + [HttpGet] + [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). + /// + [HttpGet("current")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] + public async Task Current(int requestTypeId, CancellationToken ct) => + (await service.GetCurrentAsync(requestTypeId, ct)).ToActionResult(); +} diff --git a/backend/Tnsu.ApprovalRoute.Api/Program.cs b/backend/Tnsu.ApprovalRoute.Api/Program.cs index 178f2f3..c64e0ff 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Program.cs @@ -1,5 +1,7 @@ using System.Text.Json.Serialization; using Extensions.Hosting.AsyncInitialization; +using Hangfire; +using Hangfire.PostgreSql; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.DependencyInjection; @@ -10,6 +12,7 @@ using Tnsu.ApprovalRoute.Api.Auth; using Tnsu.ApprovalRoute.Api.Filters; using Tnsu.ApprovalRoute.Application; using Tnsu.ApprovalRoute.Application.Reminders; +using Tnsu.ApprovalRoute.Application.RequestTypeLists; using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Infrastructure; @@ -37,6 +40,20 @@ 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")); + +// Hangfire на том же Postgres, что и остальные данные. Свои таблицы он создаёт сам +// (схема hangfire) при старте — отдельной EF-миграции для них не нужно. +builder.Services.AddHangfire(configuration => configuration + .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UsePostgreSqlStorage(postgres => + postgres.UseNpgsqlConnection(builder.Configuration.GetConnectionString("Postgres")))); + +// Воркер живёт в процессе API: задач мало (создание списка — редкое событие), и +// отдельный хост под них был бы лишней инфраструктурой. +builder.Services.AddHangfireServer(); builder.Services.AddApprovalRouteAuth(builder.Configuration); builder.Services.AddScoped(); builder.Services.AddOpenApi(options => diff --git a/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj b/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj index da2a380..a3f81e4 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj +++ b/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj @@ -5,6 +5,8 @@ + + diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json index e01d0f1..575395e 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json @@ -19,6 +19,11 @@ "https://localhost:4321" ] }, + "ListProvisioning": { + "ItemCountThreshold": 4500, + "StaleLockAfter": "00:30:00", + "CheckThresholdOnRead": true + }, "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 3e5732c..a42a159 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs @@ -2,6 +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.RequestTypes; using Tnsu.ApprovalRoute.Application.TemplateRoutes; using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; @@ -14,6 +15,10 @@ public static class ApplicationServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // Джоба резолвится Hangfire'ом из scope на каждое выполнение. + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/CreateNextRequestTypeListJob.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/CreateNextRequestTypeListJob.cs new file mode 100644 index 0000000..5319632 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/CreateNextRequestTypeListJob.cs @@ -0,0 +1,111 @@ +using Hangfire; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +namespace Tnsu.ApprovalRoute.Application.RequestTypeLists; + +/// +/// Фоновая задача: создать следующий список серии по типу заявок, записать его в +/// реестр и переключить на него запись. +/// +/// Повторы разрешены, но их немного: провижинер идемпотентен (переиспользует список +/// с целевым именем и дозаполняет колонки), поэтому повтор после частичного сбоя +/// безопасен и не создаёт второй список. Больше трёх попыток смысла нет — если +/// SharePoint отвечает ошибкой стабильно, задача должна остаться видимой в Hangfire, +/// а не молотить впустую. +/// +[AutomaticRetry(Attempts = 3)] +public sealed class CreateNextRequestTypeListJob( + ApprovalRouteDbContext db, + RequestTypeProvisioningLock provisioningLock, + RequestTypeListRegistry registry, + ISharePointListProvisioner provisioner, + IOptions options, + ILogger logger) +{ + public async Task RunAsync(int requestTypeId, CancellationToken cancellationToken) + { + // Единственная настоящая защита от параллельного запуска: атомарный захват + // флага в строке request_types. Проверка перед постановкой в очередь — + // только чтобы не плодить лишние задачи; гонку разруливает именно здесь. + if (!await provisioningLock.TryAcquireAsync( + requestTypeId, options.Value.StaleLockAfter, cancellationToken)) + { + logger.LogInformation( + "Провижининг списка для типа заявок {RequestTypeId} уже идёт — задача пропущена.", + requestTypeId); + return; + } + + try + { + var requestType = await db.RequestTypes + .FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken); + + if (requestType is null) + { + logger.LogWarning("Тип заявок {RequestTypeId} не найден — провижининг отменён.", requestTypeId); + return; + } + + if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl)) + { + logger.LogWarning( + "У типа заявок {RequestTypeId} не настроен список SharePoint — провижининг отменён.", + requestTypeId); + return; + } + + // Задачу могли поставить раньше, чем реестр успел узнать про текущий + // список (например, ручным запуском) — регистрируем его, иначе серия + // осталась бы без своего первого номера. + var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken); + + var maxNumber = await db.RequestTypeLists + .Where(x => x.RequestTypeId == requestTypeId) + .MaxAsync(x => (int?)x.Number, cancellationToken) ?? current.Number; + + var nextNumber = maxNumber + 1; + + logger.LogInformation( + "Создаю список №{Number} для типа заявок {RequestTypeId} по структуре {SourceListId}.", + nextNumber, requestTypeId, requestType.ListId); + + 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 + { + RequestTypeId = requestTypeId, + Number = nextNumber, + SiteUrl = requestType.SiteUrl, + ListId = created.ListId, + Title = created.Title, + CreatedAt = DateTimeOffset.UtcNow + }); + + requestType.ListId = created.ListId; + + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + logger.LogInformation( + "Список №{Number} ({ListId}) создан, запись по типу заявок {RequestTypeId} переключена на него.", + nextNumber, created.ListId, requestTypeId); + } + finally + { + // Снимаем блокировку и на успехе, и на исключении: иначе упавшая задача + // держала бы тип заблокированным до истечения StaleLockAfter. + await provisioningLock.ReleaseAsync(requestTypeId, CancellationToken.None); + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/ListProvisioningOptions.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/ListProvisioningOptions.cs new file mode 100644 index 0000000..d2a5c9a --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/ListProvisioningOptions.cs @@ -0,0 +1,30 @@ +namespace Tnsu.ApprovalRoute.Application.RequestTypeLists; + +/// Настройки нумерованных списков заявок (секция ListProvisioning в конфигурации). +public sealed class ListProvisioningOptions +{ + /// + /// Порог элементов в списке данных, по достижении которого ставится задача на + /// создание следующей пары списков. + /// + /// 5000 — не предел SharePoint на число элементов (их может быть миллионы), а + /// «list view threshold»: запросы, которым приходится просмотреть больше 5000 + /// элементов без подходящего индекса, SharePoint отклоняет. Порог намеренно чуть + /// ниже, чтобы задача успела создать следующий список до того, как текущий + /// начнёт упираться. + /// + public int ItemCountThreshold { get; set; } = 4500; + + /// + /// Через сколько блокировка провижининга считается брошенной и может быть + /// перехвачена (процесс упал, не сняв флаг). См. RequestType.ListProvisioningStartedAt. + /// + public TimeSpan StaleLockAfter { get; set; } = TimeSpan.FromMinutes(30); + + /// + /// Проверять ли порог в эндпоинте «текущий список». false — эндпоинт только + /// отдаёт список, не обращаясь к 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 new file mode 100644 index 0000000..7a28b51 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListDto.cs @@ -0,0 +1,30 @@ +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.Application/RequestTypeLists/RequestTypeListRegistry.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListRegistry.cs new file mode 100644 index 0000000..eb83d38 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListRegistry.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +namespace Tnsu.ApprovalRoute.Application.RequestTypeLists; + +/// +/// Наполнение реестра списков. Вынесено отдельно, потому что нужно и читающему +/// эндпоинту, и фоновой задаче: реестр появился позже самих списков, и любой из них +/// может встретить тип заявок, которого в реестре ещё нет. +/// +public sealed class RequestTypeListRegistry( + ApprovalRouteDbContext db, + ISharePointClient sharePoint, + ILogger logger) +{ + /// + /// Гарантирует, что текущий список типа заявок есть в реестре, и возвращает его строку. + /// + /// Реестр самозаполняющийся: до этой фичи списки жили только в + /// RequestType.ListId, и переключить список руками в админке можно и сейчас. + /// Поэтому неизвестный реестру ListId дописывается следующим номером, а не + /// считается ошибкой. + /// + /// Требует заполненных ListId/SiteUrl — вызывающая сторона это проверяет. + /// + public async Task EnsureCurrentRegisteredAsync( + RequestType requestType, CancellationToken cancellationToken) + { + var series = await db.RequestTypeLists + .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 + { + 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); + await db.SaveChangesAsync(cancellationToken); + + logger.LogInformation( + "Список {ListId} типа заявок {RequestTypeId} добавлен в реестр под номером {Number}.", + entity.ListId, requestType.Id, entity.Number); + + return entity; + } + + // Заголовок нужен только для отображения в админке — из-за его отсутствия + // регистрация списка падать не должна. + private async Task TryGetTitleAsync(string siteUrl, string listId, CancellationToken cancellationToken) + { + try + { + return await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Не удалось прочитать заголовок списка {ListId}.", listId); + return null; + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListService.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListService.cs new file mode 100644 index 0000000..3104733 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListService.cs @@ -0,0 +1,131 @@ +using FluentResults; +using Hangfire; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +namespace Tnsu.ApprovalRoute.Application.RequestTypeLists; + +/// +/// Реестр нумерованных списков по типу заявок: какой список текущий (куда писать) и +/// вся серия целиком (архив + текущий) для админки. +/// +public sealed class RequestTypeListService( + ApprovalRouteDbContext db, + ISharePointClient sharePoint, + RequestTypeListRegistry registry, + IBackgroundJobClient backgroundJobs, + IOptions options, + ILogger logger) +{ + /// + /// Список, в который сейчас нужно писать новые заявки. + /// + /// Побочный эффект: если в списке набрался порог элементов, ставит в очередь + /// задачу создания следующего. Побочный эффект в GET — сознательный компромисс + /// (так описано в ТЗ): вызывающая сторона всё равно спрашивает адрес перед каждой + /// записью, и это единственный момент, когда мы точно знаем, что список живой. + /// Повторная постановка безопасна — задача разруливает гонку захватом блокировки. + /// + 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)); + + if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl)) + return Result.Fail(new ValidationError( + $"У типа заявки {requestTypeId} не настроен список SharePoint.")); + + var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken); + + 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)); + } + + /// Вся серия списков типа заявок, от первого к последнему — для админки. + 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)); + + // Тип без настроенного списка — пустая серия, а не ошибка: в админке такой + // тип заявок вполне может существовать до привязки к SharePoint. + if (!string.IsNullOrWhiteSpace(requestType.ListId) && !string.IsNullOrWhiteSpace(requestType.SiteUrl)) + await registry.EnsureCurrentRegisteredAsync(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)) + .ToListAsync(cancellationToken); + + return Result.Ok>(items); + } + + /// + /// Число элементов в списке. Ошибку SharePoint глушим: эндпоинт обязан отдать + /// адрес списка даже когда SharePoint недоступен, иначе портал не сможет + /// принять заявку из-за сбоя необязательной проверки порога. + /// + private async Task TryGetItemCountAsync(string siteUrl, string listId, CancellationToken cancellationToken) + { + if (!options.Value.CheckThresholdOnRead) return null; + + try + { + return await sharePoint.GetItemCountAsync(siteUrl, listId, cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Не удалось получить число элементов списка {ListId} — порог не проверен.", listId); + return null; + } + } + + /// + /// Ставит задачу создания следующего списка, если порог достигнут. Возвращает + /// true, только если задача действительно поставлена. + /// + private async Task TryEnqueueProvisioningAsync( + RequestType requestType, int currentNumber, int? itemCount, CancellationToken cancellationToken) + { + if (itemCount is null || itemCount < options.Value.ItemCountThreshold) return false; + + // Следующий список уже создан, а запись на него ещё не переключили — задача + // не нужна (и переключать здесь нельзя: этим владеет провижининг). + var hasNext = await db.RequestTypeLists + .AnyAsync(x => x.RequestTypeId == requestType.Id && x.Number > currentNumber, cancellationToken); + if (hasNext) return false; + + // Провижининг уже идёт. Проверка неатомарная и это нормально: она экономит + // постановку лишней задачи, а настоящую гонку ловит захват блокировки внутри + // самой задачи. + if (requestType.ListProvisioningStartedAt is not null) return false; + + backgroundJobs.Enqueue( + job => job.RunAsync(requestType.Id, CancellationToken.None)); + + logger.LogInformation( + "В списке {ListId} типа заявок {RequestTypeId} {ItemCount} элементов (порог {Threshold}) — " + + "поставлена задача создания списка №{NextNumber}.", + requestType.ListId, requestType.Id, itemCount, options.Value.ItemCountThreshold, currentNumber + 1); + + return true; + } +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj b/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj index a10ee5e..bc84cb2 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj +++ b/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj @@ -7,6 +7,7 @@ + diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs index 70db213..a65a006 100644 --- a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs +++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs @@ -25,5 +25,23 @@ public class RequestType /// public bool? AskOverdueReason { get; set; } + /// + /// Момент, когда фоновая задача создания следующего списка взяла эту строку в + /// работу. null — задача не выполняется. + /// + /// Одновременно и признак «идёт провижининг», и БЛОКИРОВКА: задача стартует + /// только если сумела перевести поле из null в now() одним условным UPDATE + /// (см. RequestTypeProvisioningLock) — тогда две параллельные задачи по одному + /// типу заявок невозможны. + /// + /// Метка времени, а не bool, намеренно: упавший процесс не успевает снять + /// флаг, и bool заблокировал бы тип навсегда, а по метке видно, что блокировка + /// просрочена, и её можно перехватить. + /// + public DateTimeOffset? ListProvisioningStartedAt { get; set; } + public ICollection Routes { get; set; } = new List(); + + /// Нумерованная серия списков этого типа заявок (архив + текущий). + public ICollection Lists { get; set; } = new List(); } diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestTypeList.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestTypeList.cs new file mode 100644 index 0000000..974f308 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestTypeList.cs @@ -0,0 +1,43 @@ +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 9b1ba29..f187d8c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -20,12 +20,14 @@ public static class InfrastructureServiceCollectionExtensions opt.UseNpgsql(configuration.GetConnectionString("Postgres")) .UseSnakeCaseNamingConvention()); services.AddAsyncInitializer(); + services.AddScoped(); services.Configure(configuration.GetSection("SharePoint")); // Провайдер сертификата — синглтон: разобранный .pfx кешируется на всё // время жизни процесса (см. DbCertificateProvider). services.AddSingleton(); services.AddHttpClient(); + services.AddScoped(); services.Configure(configuration.GetSection("TnsuPortal")); services.AddHttpClient(); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index 3bf9ee4..a0a6f5d 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -7,6 +7,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence; public class ApprovalRouteDbContext(DbContextOptions options) : DbContext(options) { public DbSet RequestTypes => Set(); + public DbSet RequestTypeLists => Set(); public DbSet TemplateRoutes => Set(); public DbSet TemplateRouteSteps => Set(); public DbSet Settings => Set(); @@ -33,6 +34,19 @@ public class ApprovalRouteDbContext(DbContextOptions opt e.Property(x => x.Section).HasMaxLength(256).IsRequired(); }); + 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.HasIndex(x => new { x.RequestTypeId, x.Number }).IsUnique(); + }); + modelBuilder.Entity(e => { e.HasKey(x => x.Id); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.Designer.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.Designer.cs new file mode 100644 index 0000000..15f6299 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.Designer.cs @@ -0,0 +1,320 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +#nullable disable + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApprovalRouteDbContext))] + [Migration("20260817084927_AddRequestTypeListsRegistry")] + partial class AddRequestTypeListsRegistry + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AskOverdueReason") + .HasColumnType("boolean") + .HasColumnName("ask_overdue_reason"); + + b.Property("ListId") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("list_id"); + + b.Property("ListProvisioningStartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("list_provisioning_started_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("PageUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("page_url"); + + b.Property("Section") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("section"); + + b.Property("SiteUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("site_url"); + + b.Property("SlaHours") + .HasColumnType("integer") + .HasColumnName("sla_hours"); + + b.HasKey("Id") + .HasName("pk_request_types"); + + 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") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ByProject") + .HasColumnType("boolean") + .HasColumnName("by_project"); + + b.Property("CompanyId") + .HasColumnType("uuid") + .HasColumnName("company_id"); + + b.Property("DepartmentId") + .HasColumnType("uuid") + .HasColumnName("department_id"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.Property("RequiresControl") + .HasColumnType("boolean") + .HasColumnName("requires_control"); + + b.HasKey("Id") + .HasName("pk_template_routes"); + + b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject") + .IsUnique() + .HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by"); + + b.ToTable("template_routes", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowReassignToOtherApprovers") + .HasColumnType("boolean") + .HasColumnName("allow_reassign_to_other_approvers"); + + b.Property("AskOverdueReason") + .HasColumnType("boolean") + .HasColumnName("ask_overdue_reason"); + + b.Property("ExecutorEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("executor_email"); + + b.Property("FieldName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("field_name"); + + b.Property("HeadType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("head_type"); + + b.Property("Operation") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation"); + + b.Property("OrderNo") + .HasColumnType("integer") + .HasColumnName("order_no"); + + b.Property("Position") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("position"); + + b.Property("ProjectRole") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("project_role"); + + b.Property("RouteId") + .HasColumnType("integer") + .HasColumnName("route_id"); + + b.Property("SlaHours") + .HasColumnType("integer") + .HasColumnName("sla_hours"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Value") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_template_route_steps"); + + b.HasIndex("RouteId", "OrderNo") + .HasDatabaseName("ix_template_route_steps_route_id_order_no"); + + b.ToTable("template_route_steps", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b => + { + b.Property("OptionName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("option_name"); + + b.Property("FileData") + .HasColumnType("bytea") + .HasColumnName("file_data"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("OptionName") + .HasName("pk_settings"); + + b.ToTable("settings", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") + .WithMany("Lists") + .HasForeignKey("RequestTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_request_type_lists_request_types_request_type_id"); + + b.Navigation("RequestType"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") + .WithMany("Routes") + .HasForeignKey("RequestTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_routes_request_types_request_type_id"); + + b.Navigation("RequestType"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route") + .WithMany("Steps") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_route_steps_template_routes_route_id"); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => + { + b.Navigation("Lists"); + + b.Navigation("Routes"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.cs new file mode 100644 index 0000000..a67d3bf --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260817084927_AddRequestTypeListsRegistry.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRequestTypeListsRegistry : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "list_provisioning_started_at", + table: "request_types", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.CreateTable( + name: "request_type_lists", + 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.ForeignKey( + name: "fk_request_type_lists_request_types_request_type_id", + column: x => x.request_type_id, + principalTable: "request_types", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_request_type_lists_request_type_id_number", + table: "request_type_lists", + columns: new[] { "request_type_id", "number" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "request_type_lists"); + + migrationBuilder.DropColumn( + name: "list_provisioning_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 a3209ad..69528d5 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -40,6 +40,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(512)") .HasColumnName("list_id"); + b.Property("ListProvisioningStartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("list_provisioning_started_at"); + b.Property("Name") .IsRequired() .HasMaxLength(256) @@ -73,6 +77,54 @@ 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") @@ -212,6 +264,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.ToTable("settings", (string)null); }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") + .WithMany("Lists") + .HasForeignKey("RequestTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_request_type_lists_request_types_request_type_id"); + + b.Navigation("RequestType"); + }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") @@ -238,6 +302,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => { + b.Navigation("Lists"); + b.Navigation("Routes"); }); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/RequestTypeProvisioningLock.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/RequestTypeProvisioningLock.cs new file mode 100644 index 0000000..47b5ed8 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/RequestTypeProvisioningLock.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence; + +/// +/// Блокировка «по типу заявок идёт создание следующего списка» на строке +/// request_types. +/// +/// Почему сырой SQL, а не обычный EF: через change tracker это было бы +/// read-modify-write (прочитали флаг → проверили → записали), и две задачи, +/// прочитавшие null одновременно, обе решили бы, что блокировка свободна. +/// Здесь проверка и захват — ОДИН условный UPDATE, атомарный на уровне строки: +/// Postgres берёт row lock на время оператора, поэтому ровно одна из +/// конкурирующих задач увидит affected rows = 1, остальные — 0. +/// +/// Никакого pg_advisory_lock: он живёт в рамках сессии, а нам нужно, чтобы +/// блокировку было видно в самой строке (админке — что провижининг идёт) и чтобы +/// она переживала возврат соединения в пул между шагами задачи. +/// +public sealed class RequestTypeProvisioningLock(ApprovalRouteDbContext db) +{ + /// + /// Попытаться захватить блокировку. true — захватили (и только тогда можно + /// провижинить), false — держит кто-то другой либо типа заявок нет. + /// + /// Блокировка старше staleAfter считается брошенной (процесс упал, не сняв + /// флаг) и перехватывается. + /// + public async Task TryAcquireAsync(int requestTypeId, TimeSpan staleAfter, CancellationToken cancellationToken) + { + // Имена таблицы/колонки — snake_case из UseSnakeCaseNamingConvention + // (см. InfrastructureServiceCollectionExtensions). + var affected = await db.Database.ExecuteSqlInterpolatedAsync( + $""" + UPDATE request_types + SET list_provisioning_started_at = now() + WHERE id = {requestTypeId} + AND (list_provisioning_started_at IS NULL + OR list_provisioning_started_at < now() - CAST({staleAfter} AS interval)) + """, + cancellationToken); + + return affected == 1; + } + + /// Снять блокировку. Идемпотентно: снятая блокировка снимается повторно без ошибки. + public Task ReleaseAsync(int requestTypeId, CancellationToken cancellationToken) => + db.Database.ExecuteSqlInterpolatedAsync( + $"UPDATE request_types SET list_provisioning_started_at = NULL WHERE id = {requestTypeId}", + cancellationToken); +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ISharePointListProvisioner.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ISharePointListProvisioner.cs new file mode 100644 index 0000000..6e44bf1 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ISharePointListProvisioner.cs @@ -0,0 +1,27 @@ +namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +/// Созданный список: серверный относительный url (он же ListId) и Title. +public sealed record ProvisionedList(string ListId, string Title); + +/// +/// Создание следующей пары списков серии в SharePoint («<Форма> <N> — данные» +/// + «<Форма> <N> — согласование») дублированием текущей пары. +/// +/// Интерфейс живёт в Infrastructure, а не в Application, потому что Application уже +/// ссылается на Infrastructure (так же устроен ) — +/// обратная ссылка замкнула бы проекты в кольцо. +/// +public interface ISharePointListProvisioner +{ + /// + /// Создать пару списков с номером , скопировав структуру + /// со списка . Возвращает созданный список ДАННЫХ + /// (список согласования создаётся тем же вызовом, но наружу не отдаётся — его + /// находят по конвенции имени). + /// + Task CreateNextListPairAsync( + string siteUrl, + string sourceDataListId, + int number, + CancellationToken cancellationToken); +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ListTitleConvention.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ListTitleConvention.cs new file mode 100644 index 0000000..8e3ee3e --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/ListTitleConvention.cs @@ -0,0 +1,54 @@ +using System.Text.RegularExpressions; + +namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +/// +/// Конвенция имён списков заявок: «<Форма> <N> — данные» и парный ему +/// «<Форма> <N> — согласование». +/// +/// Третья копия одного и того же соглашения — оно же в провижинере request-portal +/// (provisioning/Provision-Lists.ps1, Get-NumberedListTitle) и в самом портале +/// (shared/listTitles.ts). Держать в одном месте не получается: три разных runtime. +/// Меняя здесь, менять и там. +/// +/// Номер стоит ПЕРЕД суффиксом, потому что имя списка согласования и портал, и +/// админка выводят из имени списка данных регексом, привязанным к концу строки. +/// +internal static class ListTitleConvention +{ + private const string DataSuffix = "данные"; + private const string ApprovalSuffix = "согласование"; + + private static readonly Regex TitlePattern = new( + @"^(?.+?)\s*—\s*(?данные|согласование)\s*$", + RegexOptions.Compiled); + + private static readonly Regex TrailingNumber = new(@"\s+\d+$", RegexOptions.Compiled); + + /// + /// Имя того же списка с номером . Уже имеющийся номер + /// заменяется, а не дописывается вторым. + /// + public static string WithNumber(string title, int number) + { + var match = TitlePattern.Match(title); + if (!match.Success) + throw new InvalidOperationException( + $"Имя списка «{title}» не по конвенции «<Форма> — данные|согласование» — номер поставить некуда."); + + var baseName = TrailingNumber.Replace(match.Groups["base"].Value, string.Empty); + return $"{baseName} {number} — {match.Groups["suffix"].Value}"; + } + + /// + /// Имя парного списка согласования по имени списка данных (номер сохраняется). + /// null — имя не по конвенции либо это не список данных. + /// + public static string? ToApprovalTitle(string dataListTitle) + { + var match = TitlePattern.Match(dataListTitle); + if (!match.Success || match.Groups["suffix"].Value != DataSuffix) return null; + + return $"{match.Groups["base"].Value} — {ApprovalSuffix}"; + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs index 510a7d0..ce5738f 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs @@ -7,11 +7,45 @@ using Microsoft.Extensions.Options; namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; +/// Список SharePoint: Id нужен для операций над колонками, url — как ссылка на список. +public sealed record SharePointListInfo(Guid Id, string Title, string ServerRelativeUrl, int ItemCount); + +/// Колонка списка в виде её SchemaXml — в таком виде её можно создать в другом списке. +public sealed record SharePointFieldSchema(string InternalName, string SchemaXml); + public interface ISharePointClient { + /// Список по серверному относительному url. null — списка нет. + Task GetListByUrlAsync( + string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken); + + /// Список по заголовку. null — списка нет. + Task GetListByTitleAsync( + string siteUrl, string listTitle, CancellationToken cancellationToken); + + /// + /// Пользовательские колонки списка — те, что нужно повторить в дубле. Системные + /// (унаследованные от базового типа, скрытые, только для чтения) отфильтрованы: + /// они появляются в новом списке сами. + /// + Task> GetCustomFieldsAsync( + string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken); + + /// Создать пустой список (GenericList с версионированием) и вернуть его. + Task CreateListAsync(string siteUrl, string title, CancellationToken cancellationToken); + + /// Создать колонку в списке из её SchemaXml. + Task AddFieldFromXmlAsync(string siteUrl, Guid listId, string schemaXml, CancellationToken cancellationToken); + /// Заголовок списка по его серверному относительному URL (см. RequestType.ListId). Task GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken); + /// + /// Число элементов в списке (ItemCount самого списка, без выгрузки элементов) — + /// по нему решается, не пора ли заводить следующий список серии. + /// + Task GetItemCountAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken); + /// Элементы списка данных, найденного по серверному относительному URL. Task> GetItemsByListUrlAsync( string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken); @@ -50,6 +84,105 @@ public sealed class SharePointClient( return json.TryGetProperty("Title", out var title) ? title.GetString() : null; } + public Task GetListByUrlAsync( + string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) => + GetListInfoAsync( + siteUrl, + $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')", + cancellationToken); + + public Task GetListByTitleAsync( + string siteUrl, string listTitle, CancellationToken cancellationToken) => + GetListInfoAsync( + siteUrl, + $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')", + cancellationToken); + + private async Task GetListInfoAsync( + string siteUrl, string listUrl, CancellationToken cancellationToken) + { + var url = $"{listUrl}?$select=Id,Title,ItemCount,RootFolder/ServerRelativeUrl&$expand=RootFolder"; + var json = await TryGetAsync(siteUrl, url, cancellationToken); + if (json is null) return null; + + var value = json.Value; + var serverRelativeUrl = value.TryGetProperty("RootFolder", out var rootFolder) + && rootFolder.TryGetProperty("ServerRelativeUrl", out var relative) + ? relative.GetString() + : null; + + if (serverRelativeUrl is null) return null; + + return new SharePointListInfo( + value.GetProperty("Id").GetGuid(), + value.GetProperty("Title").GetString() ?? string.Empty, + serverRelativeUrl, + value.TryGetProperty("ItemCount", out var count) && count.TryGetInt32(out var items) ? items : 0); + } + + public async Task> GetCustomFieldsAsync( + string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) + { + // FromBaseType отсеивает колонки базового типа (Title, Author, Created …) — + // они есть в любом новом списке. Hidden/ReadOnlyField — служебные, их создать + // всё равно нельзя. Фильтр в OData, а не в C#, чтобы не тащить лишнее по сети. + var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/fields" + + "?$select=InternalName,SchemaXml,FromBaseType,Hidden,ReadOnlyField" + + "&$filter=Hidden eq false and ReadOnlyField eq false and FromBaseType eq false"; + + var json = await GetAsync(siteUrl, url, cancellationToken); + var rows = json.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array + ? value.EnumerateArray() + : json.EnumerateArray(); + + var fields = new List(); + foreach (var row in rows) + { + var internalName = row.TryGetProperty("InternalName", out var name) ? name.GetString() : null; + var schemaXml = row.TryGetProperty("SchemaXml", out var schema) ? schema.GetString() : null; + if (internalName is null || schemaXml is null) continue; + + fields.Add(new SharePointFieldSchema(internalName, schemaXml)); + } + + return fields; + } + + public async Task CreateListAsync( + string siteUrl, string title, CancellationToken cancellationToken) + { + // BaseTemplate 100 — GenericList, как -Template GenericList у провижинера. + await PostAsync( + siteUrl, + $"{siteUrl.TrimEnd('/')}/_api/web/lists", + JsonSerializer.Serialize(new { Title = title, BaseTemplate = 100, EnableVersioning = true }), + cancellationToken); + + // Ответ POST'а не отдаёт RootFolder, а нам нужен серверный относительный url — + // дочитываем список отдельным запросом. + return await GetListByTitleAsync(siteUrl, title, cancellationToken) + ?? throw new InvalidOperationException($"Список «{title}» создан, но не читается обратно."); + } + + public Task AddFieldFromXmlAsync( + string siteUrl, Guid listId, string schemaXml, CancellationToken cancellationToken) => + PostAsync( + siteUrl, + $"{siteUrl.TrimEnd('/')}/_api/web/lists(guid'{listId}')/fields/createfieldasxml", + // Options = 8 (AddFieldInternalNameHint): SharePoint обязан сохранить + // InternalName из SchemaXml, а не сгенерировать свой. К колонкам обращается + // код портала именно по внутреннему имени, так что это не косметика. + JsonSerializer.Serialize(new { parameters = new { SchemaXml = schemaXml, Options = 8 } }), + cancellationToken); + + public async Task GetItemCountAsync( + string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) + { + var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')?$select=ItemCount"; + var json = await GetAsync(siteUrl, url, cancellationToken); + return json.TryGetProperty("ItemCount", out var count) && count.TryGetInt32(out var value) ? value : null; + } + public Task> GetItemsByListUrlAsync( string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) => GetItemsAsync( @@ -79,9 +212,26 @@ public sealed class SharePointClient( : []; } - private async Task GetAsync(string siteUrl, string url, CancellationToken cancellationToken) + private async Task GetAsync(string siteUrl, string url, CancellationToken cancellationToken) => + await SendAsync(HttpMethod.Get, siteUrl, url, body: null, allowNotFound: false, cancellationToken) + ?? throw new InvalidOperationException($"SharePoint не вернул тело на {url}."); + + /// GET, для которого «нет такого списка» — не ошибка, а null. + private Task TryGetAsync(string siteUrl, string url, CancellationToken cancellationToken) => + SendAsync(HttpMethod.Get, siteUrl, url, body: null, allowNotFound: true, cancellationToken); + + private Task PostAsync(string siteUrl, string url, string json, CancellationToken cancellationToken) => + SendAsync(HttpMethod.Post, siteUrl, url, json, allowNotFound: false, cancellationToken); + + private async Task SendAsync( + HttpMethod method, + string siteUrl, + string url, + string? body, + bool allowNotFound, + CancellationToken cancellationToken) { - using var request = new HttpRequestMessage(HttpMethod.Get, url); + using var request = new HttpRequestMessage(method, url); request.Headers.Authorization = new AuthenticationHeaderValue( "Bearer", await GetTokenAsync(siteUrl, cancellationToken)); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json") @@ -89,14 +239,30 @@ public sealed class SharePointClient( Parameters = { new NameValueHeaderValue("odata", "nometadata") } }); + if (body is not null) + { + request.Content = new StringContent(body); + // odata=nometadata и на запись: тогда в теле не нужен блок __metadata с + // типом сущности — SharePoint выводит тип из самого эндпоинта. + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") + { + Parameters = { new NameValueHeaderValue("odata", "nometadata") } + }; + } + using var response = await httpClient.SendAsync(request, cancellationToken); - var body = await response.Content.ReadAsStringAsync(cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + if (allowNotFound && response.StatusCode == System.Net.HttpStatusCode.NotFound) + return null; if (!response.IsSuccessStatusCode) throw new InvalidOperationException( - $"SharePoint ответил {(int)response.StatusCode} на {url}: {body}"); + $"SharePoint ответил {(int)response.StatusCode} на {method} {url}: {responseBody}"); - using var document = JsonDocument.Parse(body); + if (string.IsNullOrWhiteSpace(responseBody)) return null; + + using var document = JsonDocument.Parse(responseBody); return document.RootElement.Clone(); } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs new file mode 100644 index 0000000..e2111fa --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs @@ -0,0 +1,131 @@ +using Microsoft.Extensions.Logging; + +namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +/// +/// Создаёт следующую пару списков ДУБЛИРОВАНИЕМ текущей: берёт структуру живого +/// списка (его пользовательские колонки) и повторяет её в новом списке с +/// номером N+1. Манифесты provisioning.json request-portal при этом не читаются — +/// источник структуры сам SharePoint, поэтому дубль не может разойтись с тем, что +/// реально в списке. +/// +/// Идемпотентен: список с целевым именем переиспользуется, уже существующие колонки +/// пропускаются. Повторный запуск после частичного сбоя дозаполняет недостающее, а +/// не создаёт второй список. +/// +public sealed class SharePointListProvisioner( + ISharePointClient sharePoint, + ILogger logger) : ISharePointListProvisioner +{ + public async Task CreateNextListPairAsync( + string siteUrl, + string sourceDataListId, + int number, + CancellationToken cancellationToken) + { + var sourceData = await sharePoint.GetListByUrlAsync(siteUrl, sourceDataListId, cancellationToken) + ?? throw new InvalidOperationException( + $"Исходный список {sourceDataListId} не найден на {siteUrl} — дублировать нечего."); + + var newData = await CloneAsync( + siteUrl, + sourceData, + ListTitleConvention.WithNumber(sourceData.Title, number), + retarget: null, + cancellationToken); + + await CloneApprovalPairAsync(siteUrl, sourceData, newData, number, cancellationToken); + + return new ProvisionedList(newData.ServerRelativeUrl, newData.Title); + } + + /// + /// Дублирует парный список согласования. Его не на что сослаться, кроме имени: + /// в БД хранится ссылка только на список данных, а согласование ищется по + /// конвенции — так же, как это делают портал и админка. + /// + private async Task CloneApprovalPairAsync( + string siteUrl, + SharePointListInfo sourceData, + SharePointListInfo newData, + int number, + CancellationToken cancellationToken) + { + var sourceApprovalTitle = ListTitleConvention.ToApprovalTitle(sourceData.Title); + if (sourceApprovalTitle is null) + { + logger.LogWarning( + "Список «{Title}» не по конвенции «… — данные» — парный список согласования не дублируется.", + sourceData.Title); + return; + } + + var sourceApproval = await sharePoint.GetListByTitleAsync(siteUrl, sourceApprovalTitle, cancellationToken); + if (sourceApproval is null) + { + logger.LogWarning( + "Список согласования «{Title}» не найден — дублируется только список данных.", + sourceApprovalTitle); + return; + } + + await CloneAsync( + siteUrl, + sourceApproval, + ListTitleConvention.WithNumber(sourceApproval.Title, number), + // Ticket списка согласования смотрит на список данных — в дубле он должен + // смотреть на НОВЫЙ список данных, иначе строки маршрута новых заявок + // будут ссылаться в старый список. + retarget: (sourceData.Id, newData.Id), + cancellationToken); + } + + private async Task CloneAsync( + string siteUrl, + SharePointListInfo source, + string newTitle, + (Guid From, Guid To)? retarget, + CancellationToken cancellationToken) + { + var existing = await sharePoint.GetListByTitleAsync(siteUrl, newTitle, cancellationToken); + var target = existing ?? await sharePoint.CreateListAsync(siteUrl, newTitle, cancellationToken); + + logger.LogInformation( + existing is null ? "Создан список «{Title}»." : "Список «{Title}» уже существует — дозаполняю колонки.", + newTitle); + + var sourceFields = await sharePoint.GetCustomFieldsAsync(siteUrl, source.ServerRelativeUrl, cancellationToken); + var targetFields = await sharePoint.GetCustomFieldsAsync(siteUrl, target.ServerRelativeUrl, cancellationToken); + var alreadyThere = targetFields.Select(x => x.InternalName).ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var field in sourceFields) + { + if (alreadyThere.Contains(field.InternalName)) continue; + + var schemaXml = retarget is null + ? field.SchemaXml + : RetargetLookup(field.SchemaXml, retarget.Value.From, retarget.Value.To); + + await sharePoint.AddFieldFromXmlAsync(siteUrl, target.Id, schemaXml, cancellationToken); + logger.LogInformation(" колонка {InternalName} → «{Title}»", field.InternalName, newTitle); + } + + return target; + } + + /// + /// Перенацеливает Lookup на новый список данных. + /// + /// Целевой список Lookup хранит в SchemaXml GUID'ом (атрибут List), поэтому + /// подменяем именно GUID. Подменяем ТОЛЬКО ссылки на исходный список данных: + /// кросс-формные lookup'ы (например, «организация рабочего места» ссылается на + /// список «приема на работу») должны и дальше смотреть на список ЧУЖОЙ формы. + /// + private static string RetargetLookup(string schemaXml, Guid from, Guid to) + { + var fromText = from.ToString("D"); + return schemaXml.Contains(fromText, StringComparison.OrdinalIgnoreCase) + ? schemaXml.Replace(fromText, to.ToString("D"), StringComparison.OrdinalIgnoreCase) + : schemaXml; + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeProvisioningLockTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeProvisioningLockTests.cs new file mode 100644 index 0000000..490e77c --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeProvisioningLockTests.cs @@ -0,0 +1,120 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +/// +/// Блокировка провижининга — единственное, что не даёт двум фоновым задачам создать +/// по списку каждая, поэтому проверяется именно на живом Postgres: смысл здесь несёт +/// атомарность условного UPDATE, а не код на C#. +/// +public sealed class RequestTypeProvisioningLockTests(PostgresDatabaseFixture fixture) + : IClassFixture +{ + private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(30); + + private readonly ServiceProvider _provider = TestServiceProviderFactory.Build(fixture.ConnectionString); + + [Fact(DisplayName = "Повторный захват занятой блокировки не удаётся")] + public async Task TryAcquire_WhenAlreadyHeld_ReturnsFalse() + { + // Arrange + var requestTypeId = await CreateRequestTypeAsync(); + + // Action + var first = await TryAcquireAsync(requestTypeId); + var second = await TryAcquireAsync(requestTypeId); + + // Assert + first.ShouldBeTrue(); + second.ShouldBeFalse(); + } + + [Fact(DisplayName = "При одновременном захвате блокировку получает ровно одна задача")] + public async Task TryAcquire_Concurrently_OnlyOneWins() + { + // Arrange + var requestTypeId = await CreateRequestTypeAsync(); + + // Action — каждый захват из своего scope, то есть своим соединением к БД: + // именно так это и происходит у параллельных задач Hangfire. + var results = await Task.WhenAll( + Enumerable.Range(0, 8).Select(_ => TryAcquireAsync(requestTypeId))); + + // Assert + results.Count(x => x).ShouldBe(1); + } + + [Fact(DisplayName = "После снятия блокировку можно захватить снова")] + public async Task TryAcquire_AfterRelease_Succeeds() + { + // Arrange + var requestTypeId = await CreateRequestTypeAsync(); + (await TryAcquireAsync(requestTypeId)).ShouldBeTrue(); + + // Action + using (var scope = _provider.CreateScope()) + { + await scope.ServiceProvider.GetRequiredService() + .ReleaseAsync(requestTypeId, CancellationToken.None); + } + + // Assert + (await TryAcquireAsync(requestTypeId)).ShouldBeTrue(); + } + + [Fact(DisplayName = "Просроченная блокировка перехватывается, свежая — нет")] + public async Task TryAcquire_StaleLock_IsTakenOver() + { + // Arrange + var requestTypeId = await CreateRequestTypeAsync(); + (await TryAcquireAsync(requestTypeId)).ShouldBeTrue(); + + // Свежую блокировку с тем же порогом перехватить нельзя. + (await TryAcquireAsync(requestTypeId, TimeSpan.FromMinutes(10))).ShouldBeFalse(); + + // Action — отодвигаем метку в прошлое: так выглядит блокировка, которую + // оставил упавший, не снявший её процесс. + using (var scope = _provider.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.ExecuteSqlInterpolatedAsync( + $"UPDATE request_types SET list_provisioning_started_at = now() - interval '1 hour' WHERE id = {requestTypeId}"); + } + + // Assert + (await TryAcquireAsync(requestTypeId, TimeSpan.FromMinutes(10))).ShouldBeTrue(); + } + + [Fact(DisplayName = "Захват блокировки для несуществующего типа заявки не удаётся")] + public async Task TryAcquire_UnknownRequestType_ReturnsFalse() => + (await TryAcquireAsync(999999)).ShouldBeFalse(); + + private async Task TryAcquireAsync(int requestTypeId, TimeSpan? staleAfter = null) + { + using var scope = _provider.CreateScope(); + return await scope.ServiceProvider.GetRequiredService() + .TryAcquireAsync(requestTypeId, staleAfter ?? StaleAfter, CancellationToken.None); + } + + private async Task CreateRequestTypeAsync() + { + using var scope = _provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var entity = new RequestType + { + Name = $"Тип {Guid.NewGuid():N}", + PageUrl = "/test", + Section = "General" + }; + db.RequestTypes.Add(entity); + await db.SaveChangesAsync(); + + return entity.Id; + } +}