From 2a7130c37f86efcc407851594e3e29472ac5abdb Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 17 Aug 2026 14:14:45 +0500 Subject: [PATCH 01/16] =?UTF-8?q?=D0=B4=D0=B6=D0=BE=D0=B1=D0=B0=20=D0=B5?= =?UTF-8?q?=D1=81=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; + } +} -- 2.34.1 From b92897254db83014033d91c23cde9a989f7b6562 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 17 Aug 2026 15:33:54 +0500 Subject: [PATCH 02/16] =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Directory.Packages.props | 3 + .../Auth/HangfireDashboardBasicAuthFilter.cs | 69 ++++++++++++ .../Controllers/RequestTypeListsController.cs | 36 +++--- backend/Tnsu.ApprovalRoute.Api/Program.cs | 36 +++++- .../Tnsu.ApprovalRoute.Api.csproj | 3 + .../Tnsu.ApprovalRoute.Api/appsettings.json | 16 ++- .../ApplicationServiceCollectionExtensions.cs | 8 +- .../CreateNextListPartitionJob.cs} | 48 +++++--- .../ListPartitioning/ListPartitionDto.cs | 35 ++++++ .../ListPartitionRegistry.cs} | 25 ++--- .../ListPartitionService.cs} | 100 +++++++++++------ .../ListPartitioningOptions.cs} | 17 +-- .../RequestTypeLists/RequestTypeListDto.cs | 30 ----- .../Entities/RequestListPartition.cs | 47 ++++++++ .../Entities/RequestType.cs | 14 +-- .../Entities/RequestTypeList.cs | 43 ------- ...frastructureServiceCollectionExtensions.cs | 2 +- .../Persistence/ApprovalRouteDbContext.cs | 7 +- ...sioningLock.cs => ListPartitioningLock.cs} | 12 +- ...817102423_AddListPartitioning.Designer.cs} | 106 +++++++++--------- ... => 20260817102423_AddListPartitioning.cs} | 19 ++-- .../ApprovalRouteDbContextModelSnapshot.cs | 102 ++++++++--------- ...kTests.cs => ListPartitioningLockTests.cs} | 12 +- 23 files changed, 475 insertions(+), 315 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Api/Auth/HangfireDashboardBasicAuthFilter.cs rename backend/Tnsu.ApprovalRoute.Application/{RequestTypeLists/CreateNextRequestTypeListJob.cs => ListPartitioning/CreateNextListPartitionJob.cs} (58%) create mode 100644 backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionDto.cs rename backend/Tnsu.ApprovalRoute.Application/{RequestTypeLists/RequestTypeListRegistry.cs => ListPartitioning/ListPartitionRegistry.cs} (72%) rename backend/Tnsu.ApprovalRoute.Application/{RequestTypeLists/RequestTypeListService.cs => ListPartitioning/ListPartitionService.cs} (51%) rename backend/Tnsu.ApprovalRoute.Application/{RequestTypeLists/ListProvisioningOptions.cs => ListPartitioning/ListPartitioningOptions.cs} (62%) delete mode 100644 backend/Tnsu.ApprovalRoute.Application/RequestTypeLists/RequestTypeListDto.cs create mode 100644 backend/Tnsu.ApprovalRoute.Domain/Entities/RequestListPartition.cs delete mode 100644 backend/Tnsu.ApprovalRoute.Domain/Entities/RequestTypeList.cs rename backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/{RequestTypeProvisioningLock.cs => ListPartitioningLock.cs} (87%) rename backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260817084927_AddRequestTypeListsRegistry.Designer.cs => 20260817102423_AddListPartitioning.Designer.cs} (92%) rename backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260817084927_AddRequestTypeListsRegistry.cs => 20260817102423_AddListPartitioning.cs} (76%) rename backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/{RequestTypeProvisioningLockTests.cs => ListPartitioningLockTests.cs} (89%) 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); } -- 2.34.1 From a6281dea554009518ec45d0c19b10c8e4c699f7d Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 17 Aug 2026 15:38:15 +0500 Subject: [PATCH 03/16] =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreateNextListPartitionJob.cs | 41 ++++++++++++++----- .../SharePoint/SharePointListProvisioner.cs | 36 ++++++++++++++-- 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs index a3e35f4..c3a4711 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Hangfire; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -83,15 +84,34 @@ public sealed class CreateNextListPartitionJob( var nextNumber = maxNumber + 1; logger.LogInformation( - "Создаю партицию №{Number} для типа заявок {RequestTypeId} по структуре {SourceListId}.", - nextNumber, requestTypeId, requestType.ListId); + "Создаю партицию №{Number} для типа заявок {RequestTypeId} ({RequestTypeName}) " + + "по структуре {SourceListId}.", + nextNumber, requestTypeId, requestType.Name, requestType.ListId); - // Партиция всегда создаётся на том же сайте, что и текущая (см. комментарий - // на RequestListPartition о недопустимости разъезда серии по сайтам) — сайт - // берём из RequestType, а не откуда-либо ещё, так что расхождение - // структурно невозможно. - var created = await provisioner.CreateNextListPairAsync( - requestType.SiteUrl, requestType.ListId, nextNumber, cancellationToken); + var stopwatch = Stopwatch.StartNew(); + ProvisionedList created; + try + { + // Партиция всегда создаётся на том же сайте, что и текущая (см. + // комментарий на RequestListPartition о недопустимости разъезда серии + // по сайтам) — сайт берём из RequestType, а не откуда-либо ещё, так что + // расхождение структурно невозможно. + created = await provisioner.CreateNextListPairAsync( + requestType.SiteUrl, requestType.ListId, nextNumber, cancellationToken); + } + catch (Exception ex) + { + // Логируем с контекстом (номер партиции, тип заявок) здесь, а не + // полагаемся только на то, что исключение долетит до лога Hangfire: + // там оно будет без этих подробностей, вперемешку с логами других задач. + // Дальше даём ему пойти в Hangfire как обычно — так сработает + // AutomaticRetry(Attempts = 3). + logger.LogError( + ex, + "Провижининг партиции №{Number} для типа заявок {RequestTypeId} не удался за {ElapsedMs} мс.", + nextNumber, requestTypeId, stopwatch.ElapsedMilliseconds); + throw; + } // Реестр и переключение записи — одной транзакцией: иначе можно остаться // с записанной в реестр партицией, в которую никто не пишет, или наоборот. @@ -112,8 +132,9 @@ public sealed class CreateNextListPartitionJob( await transaction.CommitAsync(cancellationToken); logger.LogInformation( - "Партиция №{Number} ({ListId}) создана, запись по типу заявок {RequestTypeId} переключена на неё.", - nextNumber, created.ListId, requestTypeId); + "Партиция №{Number} ({ListId}) создана за {ElapsedMs} мс, запись по типу заявок " + + "{RequestTypeId} переключена на неё.", + nextNumber, created.ListId, stopwatch.ElapsedMilliseconds, requestTypeId); } finally { diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs index e2111fa..9021951 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Microsoft.Extensions.Logging; namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; @@ -23,6 +24,11 @@ public sealed class SharePointListProvisioner( int number, CancellationToken cancellationToken) { + logger.LogInformation( + "Дублирую пару списков №{Number} на {SiteUrl} по образцу {SourceListId}.", + number, siteUrl, sourceDataListId); + var stopwatch = Stopwatch.StartNew(); + var sourceData = await sharePoint.GetListByUrlAsync(siteUrl, sourceDataListId, cancellationToken) ?? throw new InvalidOperationException( $"Исходный список {sourceDataListId} не найден на {siteUrl} — дублировать нечего."); @@ -31,11 +37,16 @@ public sealed class SharePointListProvisioner( siteUrl, sourceData, ListTitleConvention.WithNumber(sourceData.Title, number), + role: "данные", retarget: null, cancellationToken); await CloneApprovalPairAsync(siteUrl, sourceData, newData, number, cancellationToken); + logger.LogInformation( + "Пара списков №{Number} готова за {ElapsedMs} мс: данные «{Title}» ({ListId}).", + number, stopwatch.ElapsedMilliseconds, newData.Title, newData.ServerRelativeUrl); + return new ProvisionedList(newData.ServerRelativeUrl, newData.Title); } @@ -73,6 +84,7 @@ public sealed class SharePointListProvisioner( siteUrl, sourceApproval, ListTitleConvention.WithNumber(sourceApproval.Title, number), + role: "согласование", // Ticket списка согласования смотрит на список данных — в дубле он должен // смотреть на НОВЫЙ список данных, иначе строки маршрута новых заявок // будут ссылаться в старый список. @@ -80,10 +92,15 @@ public sealed class SharePointListProvisioner( cancellationToken); } + /// + /// role — только для логов («данные»/«согласование»): по нему в логе видно, какую + /// половину пары обрабатывает конкретный вызов, не заглядывая в стек вызовов. + /// private async Task CloneAsync( string siteUrl, SharePointListInfo source, string newTitle, + string role, (Guid From, Guid To)? retarget, CancellationToken cancellationToken) { @@ -91,13 +108,16 @@ public sealed class SharePointListProvisioner( var target = existing ?? await sharePoint.CreateListAsync(siteUrl, newTitle, cancellationToken); logger.LogInformation( - existing is null ? "Создан список «{Title}»." : "Список «{Title}» уже существует — дозаполняю колонки.", - newTitle); + existing is null + ? "Список {Role} «{Title}» создан." + : "Список {Role} «{Title}» уже существует — дозаполняю недостающие колонки.", + role, 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); + var copied = 0; foreach (var field in sourceFields) { if (alreadyThere.Contains(field.InternalName)) continue; @@ -107,9 +127,19 @@ public sealed class SharePointListProvisioner( : RetargetLookup(field.SchemaXml, retarget.Value.From, retarget.Value.To); await sharePoint.AddFieldFromXmlAsync(siteUrl, target.Id, schemaXml, cancellationToken); - logger.LogInformation(" колонка {InternalName} → «{Title}»", field.InternalName, newTitle); + copied++; + + // По каждой колонке — только на Debug: при штатной первой партии их + // десятки на список, и на Information это был бы шум. Итог всё равно + // виден в сводке ниже. + logger.LogDebug( + "Список {Role} «{Title}»: скопирована колонка {InternalName}.", role, newTitle, field.InternalName); } + logger.LogInformation( + "Список {Role} «{Title}»: скопировано колонок {Copied}, уже было {AlreadyThere}.", + role, newTitle, copied, sourceFields.Count - copied); + return target; } -- 2.34.1 From 8df1b86e1ba081f6e6edc06844ee296530187baf Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 17 Aug 2026 16:05:51 +0500 Subject: [PATCH 04/16] =?UTF-8?q?=D1=84=D1=80=D0=BE=D0=BD=D1=82=20=D0=B0?= =?UTF-8?q?=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B8=20=D0=B2=D1=80=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/screens/DashboardScreen.tsx | 11 +++++++++++ .../screens/ManageRequestsScreen.module.scss | 15 +++++++++++++++ .../components/screens/ManageRequestsScreen.tsx | 7 +++++++ .../src/webparts/approvalAdmin/models/index.ts | 1 + .../approvalAdmin/models/requestManage.ts | 7 +++++++ .../approvalAdmin/models/requestTypeLists.ts | 16 ++++++++++++++++ .../src/webparts/approvalAdmin/services/api.ts | 10 +++++++++- .../services/requestManageService.ts | 13 +++++++++++-- frontend/src/webparts/approvalAdmin/texts.ts | 2 ++ 9 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 frontend/src/webparts/approvalAdmin/models/requestTypeLists.ts diff --git a/frontend/src/webparts/approvalAdmin/components/screens/DashboardScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/DashboardScreen.tsx index dd12dd5..34848e5 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/DashboardScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/DashboardScreen.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; import styles from './DashboardScreen.module.scss'; import { ApprovalRouteApi } from '../../services/api'; +import { RequestTypeListSeries } from '../../models'; import { ScreenKey } from '../app/types'; import { texts } from '../../texts'; import { useRequestTypes } from '../../hooks/useRequestTypes'; +import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll'; import { useTemplateRoutes } from '../../hooks/useTemplateRoutes'; import { AsyncList } from '../../hooks/useAsyncList'; import { CardGrid, NavCard, StatCard, StatRow } from '../ui'; @@ -20,15 +22,24 @@ function statValue(list: AsyncList): React.ReactNode { return list.items.length; } +// Партиций много на серию (архив + текущая) — считаем суммарно по всем типам +// заявок, а не количество самих серий (это и так есть в requestTypesStat). +function partitionsCount(list: AsyncList): React.ReactNode { + if (list.isLoading || list.error) return texts.common.empty; + return list.items.reduce((sum, series) => sum + series.partitions.length, 0); +} + export const DashboardScreen: React.FC = ({ api, onNavigate }) => { const requestTypes = useRequestTypes(api); const routes = useTemplateRoutes(api); + const listPartitions = useRequestTypeListsAll(api); return (
+
{texts.dashboard.sections}
diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss index a3aa861..b1c987e 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss @@ -32,6 +32,21 @@ margin-top: 6px; } +// Ссылка на физический список-партицию — второстепенная деталь под заголовком +// заявки, поэтому приглушённая, а не акцентная (как обычная ссылка $blue-600). +.listLink { + display: inline-block; + margin-top: 4px; + font-size: 12px; + color: $gray-500; + text-decoration: none; + + &:hover { + color: $blue-600; + text-decoration: underline; + } +} + // Колонка с номером шага — узкая, содержимое по центру (важно при rowSpan на // параллельный блок: иначе номер прижимался бы к верхней строке блока). .stepCol { diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx index 757bc97..e14cb84 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx @@ -560,6 +560,13 @@ export const ManageRequestsScreen: React.FC = ({ api {matrix.request.status || texts.common.empty}
+ {/* Список — партиция (см. ТЗ по архивации SharePoint-списков): по мере + роста заявки одного типа переезжают на новый список, и без этой + ссылки не всегда очевидно, какой именно список сейчас открыт. */} + + {texts.manageRequests.listLinkLabel(matrix.dataListTitle)} + + {readOnly && {texts.manageRequests.fullyClosedNotice}} {!matrix.approvalListTitle && {texts.manageRequests.noApprovalList}} diff --git a/frontend/src/webparts/approvalAdmin/models/index.ts b/frontend/src/webparts/approvalAdmin/models/index.ts index 6a3c328..c800bf9 100644 --- a/frontend/src/webparts/approvalAdmin/models/index.ts +++ b/frontend/src/webparts/approvalAdmin/models/index.ts @@ -2,6 +2,7 @@ export * from './enums'; export * from './dictionaries'; export * from './requestTypes'; +export * from './requestTypeLists'; export * from './templateRoutes'; export * from './templateRouteSteps'; export * from './resolve'; diff --git a/frontend/src/webparts/approvalAdmin/models/requestManage.ts b/frontend/src/webparts/approvalAdmin/models/requestManage.ts index c02fec6..19f9078 100644 --- a/frontend/src/webparts/approvalAdmin/models/requestManage.ts +++ b/frontend/src/webparts/approvalAdmin/models/requestManage.ts @@ -127,6 +127,13 @@ export interface ManagedRequestMatrix { siteUrl: string; /** Title списка «… — данные» (для тела запроса к потоку оповещения, см. RequestManageService.notifyRow). */ dataListTitle: string; + /** + * Абсолютный URL списка данных — заявки нумерованные (списки-партиции, см. + * ТЗ по архивации), и по мере роста текущий список меняется. Ссылка нужна, + * чтобы администратор видел, в каком физическом списке лежит открытая + * заявка, и мог перейти туда напрямую, не выясняя это вручную. + */ + dataListUrl: string; /** Title списка «… — согласование» (вычислен из Title списка данных). */ approvalListTitle: string; /** Обе фазы вместе, упорядочены по StepOrder. */ diff --git a/frontend/src/webparts/approvalAdmin/models/requestTypeLists.ts b/frontend/src/webparts/approvalAdmin/models/requestTypeLists.ts new file mode 100644 index 0000000..9462388 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/models/requestTypeLists.ts @@ -0,0 +1,16 @@ +// DTO эндпоинта api/request-types/lists (Application/ListPartitioning/*.cs) — +// нумерованная серия SharePoint-списков (партиций) по типу заявки: архив + текущий. +export interface RequestListPartition { + number: number; + siteUrl: string; + listId: string; + // eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable JSON-контракт бэкенда (Title? в RequestListPartition) + title: string | null; + isCurrent: boolean; + createdAt: string; +} + +export interface RequestTypeListSeries { + requestTypeId: number; + partitions: RequestListPartition[]; +} diff --git a/frontend/src/webparts/approvalAdmin/services/api.ts b/frontend/src/webparts/approvalAdmin/services/api.ts index 42a254f..adc80bc 100644 --- a/frontend/src/webparts/approvalAdmin/services/api.ts +++ b/frontend/src/webparts/approvalAdmin/services/api.ts @@ -7,6 +7,7 @@ import { PatchTemplateRouteInput, PatchTemplateRouteStepInput, RequestType, + RequestTypeListSeries, ResolveTemplateRouteQuery, ResolveTemplateRouteResult, TemplateRoute, @@ -39,7 +40,14 @@ export class ApprovalRouteApi { this.http.patch(`/api/request-types/${id}`, body), remove: (id: number): Promise => - this.http.delete(`/api/request-types/${id}`) + this.http.delete(`/api/request-types/${id}`), + + // Серии партиций СРАЗУ по всем типам заявок (см. + // ListPartitionService.ListAllAsync) — для дашборда (счётчик партиций) и + // портала, которому нужен весь список адресов разом, а не по одному запросу + // на тип. + listsAll: (): Promise => + this.http.get('/api/request-types/lists') }; public readonly templateRoutes = { diff --git a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts index cbdd7e8..47f6b42 100644 --- a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts +++ b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts @@ -207,8 +207,17 @@ export class RequestManageService { this.listTitles(sp, requestType.listId) ]); + const dataListUrl = listAbsUrl(requestType.siteUrl, requestType.listId); + if (!approvalListTitle) { - return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, approvalListTitle: '', rows: [] }; + return { + request: toSummary(requestRow), + siteUrl: requestType.siteUrl, + dataListTitle, + dataListUrl, + approvalListTitle: '', + rows: [] + }; } const approvalList = sp.web.lists.getByTitle(approvalListTitle); @@ -255,7 +264,7 @@ export class RequestManageService { }; }); - return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, approvalListTitle, rows }; + return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows }; } /** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */ diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index 6dc7f2b..fa8ab54 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -89,6 +89,7 @@ export const texts = { dashboard: { requestTypesStat: 'Типов заявок', routesStat: 'Маршрутов согласования', + listPartitionsStat: 'Партиций списков SharePoint', sections: 'Разделы', requestTypesCard: { title: 'Типы заявок', @@ -385,6 +386,7 @@ export const texts = { requestDisabledHint: 'Сначала выберите тип заявки.', noApprovalList: 'У этого типа заявки список согласования не найден (Title списка данных не оканчивается на «— данные»).', requestStatusLabel: 'Статус заявки', + listLinkLabel: (listTitle: string): string => `Список: ${listTitle}`, fullyClosedNotice: 'Заявка полностью закрыта — маршрут доступен только для просмотра.', approvalPhaseTitle: 'Согласование', executionPhaseTitle: 'Исполнение', -- 2.34.1 From fa788fa6c0968c2fee0191d3f405e1ff5827ef74 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 17 Aug 2026 16:46:25 +0500 Subject: [PATCH 05/16] =?UTF-8?q?=D0=BC=D0=B5=D0=BB=D0=BA=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++- .../Tnsu.ApprovalRoute.Api/appsettings.json | 4 +++- .../approvalAdmin/ApprovalAdminWebPart.ts | 22 ++++++++++++++----- .../components/screens/DashboardScreen.tsx | 6 +++++ .../hooks/useRequestTypeListsAll.ts | 10 +++++++++ frontend/src/webparts/approvalAdmin/texts.ts | 4 ++++ 6 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useRequestTypeListsAll.ts diff --git a/.gitignore b/.gitignore index 4f8ee7c..b449280 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ frontend/.idea/ workspace.xml **/bin/ **/obj/ -.DS_Store \ No newline at end of file +.DS_Store +logs/ \ No newline at end of file diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json index ce4c776..647b8ab 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json @@ -3,7 +3,9 @@ "MinimumLevel": { "Default": "Information", "Override": { - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "Hangfire": "Warning" } } }, diff --git a/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts b/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts index 554cd1a..16a8e9b 100644 --- a/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts +++ b/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts @@ -1,7 +1,7 @@ import * as React from 'react'; import * as ReactDom from 'react-dom'; import { Version } from '@microsoft/sp-core-library'; -import { type IPropertyPaneConfiguration, PropertyPaneTextField } from '@microsoft/sp-property-pane'; +import { type IPropertyPaneConfiguration, PropertyPaneDropdown, PropertyPaneTextField } from '@microsoft/sp-property-pane'; import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base'; import ApprovalAdmin from './components/ApprovalAdmin'; @@ -13,6 +13,13 @@ export interface IApprovalAdminWebPartProps { portalBaseUrl: string; } +// Единственные два адреса, которыми реально пользуются (прод и локальный backend, +// см. backend/Tnsu.ApprovalRoute.Api/Properties/launchSettings.json) — селект +// вместо текстового поля, чтобы переключаться одним кликом, а не перепечатывать +// url и рисковать опечаткой в проде. +const API_BASE_URL_PROD = 'https://api.tnsu.kz/approval-route'; +const API_BASE_URL_LOCAL = 'http://localhost:5080'; + export default class ApprovalAdminWebPart extends BaseClientSideWebPart { public render(): void { @@ -28,9 +35,8 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart { // Прод-адрес по умолчанию: пакуем именно под прод (heft package-solution --production). - // Для локальной разработки переопределите значение в панели свойств веб-части - // (например, на http://localhost:5080 — порт из backend/.../launchSettings.json). - this.properties.apiBaseUrl = this.properties.apiBaseUrl || 'https://api.tnsu.kz/approval-route'; + // Для локальной разработки переключите в панели свойств веб-части на «Локально». + this.properties.apiBaseUrl = this.properties.apiBaseUrl || API_BASE_URL_PROD; this.properties.aadResourceId = this.properties.aadResourceId || 'api://9232d9e2-9807-4753-808f-07250e5474b0'; this.properties.portalBaseUrl = this.properties.portalBaseUrl || 'https://api.tnsu.kz'; return Promise.resolve(); @@ -55,9 +61,13 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart = ({ api, onNaviga description={texts.dashboard.resolveCard.description} onClick={() => onNavigate('resolve')} /> + onNavigate('manageRequests')} + /> ); diff --git a/frontend/src/webparts/approvalAdmin/hooks/useRequestTypeListsAll.ts b/frontend/src/webparts/approvalAdmin/hooks/useRequestTypeListsAll.ts new file mode 100644 index 0000000..87b63a5 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/useRequestTypeListsAll.ts @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { RequestTypeListSeries } from '../models'; +import { ApprovalRouteApi } from '../services/api'; +import { AsyncList, useAsyncList } from './useAsyncList'; + +/** Серии партиций списков сразу по всем типам заявок — сейчас нужен только дашборду (счётчик партиций). */ +export function useRequestTypeListsAll(api: ApprovalRouteApi): AsyncList { + const load = React.useCallback(() => api.requestTypes.listsAll(), [api]); + return useAsyncList(load); +} diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index fa8ab54..2b76a51 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -102,6 +102,10 @@ export const texts = { resolveCard: { title: 'Проверка маршрута', description: 'Как разрешится маршрут для конкретного пользователя' + }, + manageRequestsCard: { + title: 'Редактирование заявок', + description: 'Правка маршрута согласования конкретной заявки' } }, -- 2.34.1 From fd79b9624d1bec08967e57e36c0301bde2148048 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 17 Aug 2026 16:54:31 +0500 Subject: [PATCH 06/16] =?UTF-8?q?=D1=81=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Tnsu.ApprovalRoute.Api/appsettings.json | 2 +- frontend/config/serve.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json index 647b8ab..a043fd3 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json @@ -20,7 +20,7 @@ "Cors": { "AllowedOrigins": [ "https://tnsukz.sharepoint.com", - "https://localhost:4321" + "https://localhost:4322" ] }, "ListPartitioning": { diff --git a/frontend/config/serve.json b/frontend/config/serve.json index 115bd26..bc1f191 100644 --- a/frontend/config/serve.json +++ b/frontend/config/serve.json @@ -1,6 +1,6 @@ { "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json", - "port": 4321, + "port": 4322, "https": true, "initialPage": "https://tnsukz.sharepoint.com/_layouts/workbench.aspx" } -- 2.34.1 From e4dcf5cb6712e02b671030d264f4f1e4cc398ad3 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 17 Aug 2026 17:22:28 +0500 Subject: [PATCH 07/16] =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=D1=82=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=B7=D0=B0=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Tnsu.ApprovalRoute.Api/appsettings.json | 2 +- frontend/config/serve.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json index a043fd3..647b8ab 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json @@ -20,7 +20,7 @@ "Cors": { "AllowedOrigins": [ "https://tnsukz.sharepoint.com", - "https://localhost:4322" + "https://localhost:4321" ] }, "ListPartitioning": { diff --git a/frontend/config/serve.json b/frontend/config/serve.json index bc1f191..115bd26 100644 --- a/frontend/config/serve.json +++ b/frontend/config/serve.json @@ -1,6 +1,6 @@ { "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json", - "port": 4322, + "port": 4321, "https": true, "initialPage": "https://tnsukz.sharepoint.com/_layouts/workbench.aspx" } -- 2.34.1 From d8152982d7a599b9fd6a1d50b4796deb90b99a2b Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 18 Aug 2026 11:13:07 +0500 Subject: [PATCH 08/16] =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Tnsu.ApprovalRoute.Api/Program.cs | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Api/Program.cs b/backend/Tnsu.ApprovalRoute.Api/Program.cs index a37e1b4..759a947 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Program.cs @@ -21,21 +21,14 @@ 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)); + retainedFileCountLimit: 6)); builder.Services.Configure(options => { @@ -59,8 +52,6 @@ builder.Services.AddApprovalRouteApplication(); builder.Services.Configure(builder.Configuration.GetSection("Reminders")); builder.Services.Configure(builder.Configuration.GetSection("ListPartitioning")); -// Hangfire на том же Postgres, что и остальные данные. Свои таблицы он создаёт сам -// (схема hangfire) при старте — отдельной EF-миграции для них не нужно. builder.Services.AddHangfire(configuration => configuration .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) .UseSimpleAssemblyNameTypeSerializer() @@ -68,8 +59,6 @@ builder.Services.AddHangfire(configuration => configuration .UsePostgreSqlStorage(postgres => postgres.UseNpgsqlConnection(builder.Configuration.GetConnectionString("Postgres")))); -// Воркер живёт в процессе API: задач мало (создание списка — редкое событие), и -// отдельный хост под них был бы лишней инфраструктурой. builder.Services.AddHangfireServer(); builder.Services.AddApprovalRouteAuth(builder.Configuration); builder.Services.AddScoped(); @@ -126,9 +115,6 @@ app.UseCors(CorsPolicyName); app.UseAuthentication(); app.UseAuthorization(); -// Свой Basic Auth, а не схемы из AuthSetup: дашборд Hangfire открывается прямо в -// браузере без похода за токеном Entra, ему нужна отдельная, простая проверка. -// Логин/пароль — в конфигурации (Hangfire:Dashboard), см. appsettings.json. app.UseHangfireDashboard("/hangfire", new DashboardOptions { Authorization = -- 2.34.1 From 77a101506d34b8dbfb4af44598cd3c3f13bd813d Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 18 Aug 2026 14:52:06 +0500 Subject: [PATCH 09/16] =?UTF-8?q?=D1=80=D0=B5=D0=BC=D0=B0=D0=B9=D0=BD?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Reminders/ReminderService.cs | 55 ++++++++++++++++++- .../ReminderServiceTests.cs | 2 + 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index f29ff7c..7829f26 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -3,6 +3,7 @@ using FluentResults; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.SharePoint; @@ -20,6 +21,7 @@ namespace Tnsu.ApprovalRoute.Application.Reminders; public sealed class ReminderService( ApprovalRouteDbContext db, ISharePointClient sharePoint, + ListPartitionRegistry partitionRegistry, IOptions options, ILogger logger) { @@ -57,14 +59,63 @@ public sealed class ReminderService( return Result.Ok>(reminders); } + /// + /// Обходит ВСЕ партиции типа заявки (архив + текущая) — заявка, уехавшая в + /// архив, продолжает ждать чьего-то решения точно так же, как в текущем + /// списке, и не должна пропадать из напоминаний только из-за того, что для + /// новых заявок завели следующий список. + /// + /// EnsureCurrentRegisteredAsync — на случай, если этот тип заявок ни разу не + /// читали через ListPartitionService (сам реестр партиций мог ещё не знать + /// про текущий список): без неё серия была бы пустой и напоминания молча не + /// строились бы даже по текущему списку. + /// private async Task> GetOverdueForTypeAsync( RequestType type, DateTimeOffset now, CancellationToken cancellationToken) + { + await partitionRegistry.EnsureCurrentRegisteredAsync(type, cancellationToken); + + var partitions = await db.RequestListPartitions.AsNoTracking() + .Where(x => x.RequestTypeId == type.Id) + .OrderBy(x => x.Number) + .ToListAsync(cancellationToken); + + var reminders = new List(); + foreach (var partition in partitions) + { + try + { + reminders.AddRange(await GetOverdueForPartitionAsync(type, partition, now, cancellationToken)); + } + catch (Exception e) + { + // Недоступная партиция (например, архивный список переименовали + // или удалили вручную) не должна обнулять напоминания по + // остальным партициям того же типа. + logger.LogError( + e, "Напоминания по списку {ListId} (партиция №{Number}) типа «{Type}» не собраны", + partition.ListId, partition.Number, type.Name); + } + } + + return reminders; + } + + private async Task> GetOverdueForPartitionAsync( + RequestType type, RequestListPartition partition, DateTimeOffset now, CancellationToken cancellationToken) { var siteUrl = type.SiteUrl!; - var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, type.ListId!, cancellationToken); + + // Читаем Title ЖИВЬЁМ, а не из partition.Title (закешированного на момент + // создания партиции) — тот же принцип, что был здесь и раньше для + // единственного списка: заголовок могли переименовать вручную, и кеш + // тогда соврёт, а по url список найдётся всегда. + var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, partition.ListId, cancellationToken); if (string.IsNullOrWhiteSpace(dataListTitle)) { - logger.LogWarning("Список данных типа «{Type}» не найден по url {Url}", type.Name, type.ListId); + logger.LogWarning( + "Список данных партиции №{Number} типа «{Type}» не найден по url {Url}", + partition.Number, type.Name, partition.ListId); return []; } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs index 9d60089..a476e9f 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using Shouldly; +using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Application.Reminders; using Tnsu.ApprovalRoute.Application.RequestTypes; using Tnsu.ApprovalRoute.Infrastructure.Persistence; @@ -95,6 +96,7 @@ public sealed class ReminderServiceTests : IClassFixture(), sharePoint.Object, + _services.GetRequiredService(), Options.Create(new RemindersOptions()), NullLogger.Instance); } -- 2.34.1 From 7b5158aa11fa5b7c7e5fb001d5ea327ae478b5d6 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 18 Aug 2026 15:23:15 +0500 Subject: [PATCH 10/16] System.Net.Http.HttpClient --- backend/Tnsu.ApprovalRoute.Api/appsettings.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json index 647b8ab..387270e 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json @@ -5,7 +5,8 @@ "Override": { "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning", - "Hangfire": "Warning" + "Hangfire": "Warning", + "System.Net.Http.HttpClient": "Warning" } } }, @@ -24,7 +25,7 @@ ] }, "ListPartitioning": { - "ItemCountThreshold": 4500, + "ItemCountThreshold": 2, "StaleLockAfter": "00:30:00", "CheckThresholdOnRead": true }, -- 2.34.1 From 1dff7c711221f38f67261462675b082b2fa16bd0 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 18 Aug 2026 16:07:11 +0500 Subject: [PATCH 11/16] =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B1=D0=BB=D0=B5?= =?UTF-8?q?=D0=BC=D0=B0=20=D1=81=20=D0=BD=D0=B5=20=D0=BD=D0=B0=D1=85=D0=BE?= =?UTF-8?q?=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D0=B1=D1=86=D0=B0=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SharePoint/SharePointClient.cs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs index ce5738f..e41187b 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs @@ -123,12 +123,23 @@ public sealed class SharePointClient( public async Task> GetCustomFieldsAsync( string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) { - // FromBaseType отсеивает колонки базового типа (Title, Author, Created …) — - // они есть в любом новом списке. Hidden/ReadOnlyField — служебные, их создать - // всё равно нельзя. Фильтр в OData, а не в C#, чтобы не тащить лишнее по сети. + // Ни FromBaseType, ни CanBeDeleted не годятся как признак «колонку явно + // добавили в список»: у переиспользуемых стандартных site-колонок SharePoint + // (например, «Department» — встроенная текстовая колонка из схемы v3, + // подключаемая через DelayActivateTemplateBinding) ОБА флага стоят как у + // настоящего системного поля, хотя колонка реально добавлена в список и в + // неё пишут данные. Из-за этого такие колонки тихо выпадали из копирования — + // без ошибки, просто без предупреждения. + // + // Поэтому здесь фильтруем только Hidden/ReadOnlyField (их создать всё равно + // нельзя), а отделять настоящие базовые поля (Title, ID, ContentType …) от + // явно добавленных — задача вызывающего кода: он сравнивает с полями + // ТОЛЬКО ЧТО СОЗДАННОГО пустого списка того же шаблона (см. CloneAsync, + // alreadyThere) — то, что есть на обоих, действительно базовое; то, что + // есть только на источнике, добавлено явно, независимо от флагов SharePoint. 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"; + "?$select=InternalName,SchemaXml,Hidden,ReadOnlyField" + + "&$filter=Hidden eq false and ReadOnlyField eq false"; var json = await GetAsync(siteUrl, url, cancellationToken); var rows = json.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array -- 2.34.1 From 58c27b2aa58bc0eded6c016f3b1a4a33dc43ff7c Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 19 Aug 2026 09:44:31 +0500 Subject: [PATCH 12/16] =?UTF-8?q?=D0=BF=D1=80=D0=B8=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreateNextListPartitionJob.cs | 35 ++--- .../ListPartitioning/ListPartitionRegistry.cs | 75 ++++++++-- .../ListPartitioning/ListPartitionService.cs | 21 ++- .../Reminders/ReminderService.cs | 35 ++--- .../ListPartitionRegistryTests.cs | 137 ++++++++++++++++++ 5 files changed, 239 insertions(+), 64 deletions(-) create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitionRegistryTests.cs diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs index c3a4711..9c6d1dc 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs @@ -3,7 +3,6 @@ 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; @@ -72,16 +71,13 @@ public sealed class CreateNextListPartitionJob( return; } - // Задачу могли поставить раньше, чем реестр успел узнать про текущую - // партицию (например, ручным запуском) — регистрируем её, иначе серия - // осталась бы без своего первого номера. - var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken); + // Партиция, на которую сейчас указывает RequestType.ListId, прямо сейчас + // перестаёт быть текущей — записываем её в архив (request_list_partitions + // хранит только архив, см. ListPartitionRegistry). До этого момента она + // нигде отдельной строкой не значилась. + var archivedCurrent = await registry.ArchiveCurrentAsync(requestType, cancellationToken); - var maxNumber = await db.RequestListPartitions - .Where(x => x.RequestTypeId == requestTypeId) - .MaxAsync(x => (int?)x.Number, cancellationToken) ?? current.Number; - - var nextNumber = maxNumber + 1; + var nextNumber = archivedCurrent.Number + 1; logger.LogInformation( "Создаю партицию №{Number} для типа заявок {RequestTypeId} ({RequestTypeName}) " + @@ -113,23 +109,12 @@ public sealed class CreateNextListPartitionJob( throw; } - // Реестр и переключение записи — одной транзакцией: иначе можно остаться - // с записанной в реестр партицией, в которую никто не пишет, или наоборот. - await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); - - db.RequestListPartitions.Add(new RequestListPartition - { - RequestTypeId = requestTypeId, - Number = nextNumber, - ListId = created.ListId, - Title = created.Title, - CreatedAt = DateTimeOffset.UtcNow - }); - + // Новая партиция строкой в request_list_partitions пока не появляется — + // она получит свою запись только когда САМА станет архивом, следующим + // вызовом этой же задачи (см. doc-комментарий класса ListPartitionRegistry). + // Здесь переключаем только указатель. requestType.ListId = created.ListId; - await db.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); logger.LogInformation( "Партиция №{Number} ({ListId}) создана за {ElapsedMs} мс, запись по типу заявок " + diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionRegistry.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionRegistry.cs index d277ffd..d7578b6 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionRegistry.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionRegistry.cs @@ -8,8 +8,14 @@ namespace Tnsu.ApprovalRoute.Application.ListPartitioning; /// /// Наполнение реестра партиций. Вынесено отдельно, потому что нужно и читающему -/// эндпоинту, и фоновой задаче: реестр появился позже самих списков, и любой из них -/// может встретить тип заявок, которого в реестре ещё нет. +/// эндпоинту, и фоновой задаче. +/// +/// request_list_partitions хранит ТОЛЬКО архив — партиции, которые уже перестали +/// быть текущими. Текущая партиция нигде отдельной строкой не пишется: она всегда +/// синтезируется на лету из RequestType.ListId/SiteUrl (см. BuildCurrentAsync). +/// Иначе один и тот же список оказывался бы записан дважды — и в RequestType.ListId, +/// и строкой в этой таблице — сразу же, при первом же чтении, до того как партиция +/// реально стала архивом. /// public sealed class ListPartitionRegistry( ApprovalRouteDbContext db, @@ -17,16 +23,61 @@ public sealed class ListPartitionRegistry( ILogger logger) { /// - /// Гарантирует, что текущая партиция типа заявок есть в реестре, и возвращает её строку. + /// Партиции, которые УЖЕ архив — всё, что реестр знает о серии, кроме текущей. /// - /// Реестр самозаполняющийся: до этой фичи списки жили только в - /// RequestType.ListId, и переключить список руками в админке можно и сейчас. - /// Поэтому неизвестный реестру ListId дописывается следующим номером, а не - /// считается ошибкой. - /// - /// Требует заполненного ListId — вызывающая сторона это проверяет. + /// Фильтр по ListId != RequestType.ListId — не оптимизация, а самовосстановление + /// на случай узкого окна в ArchiveCurrentAsync (см. её комментарий): если старая + /// партиция уже успела записаться архивом, а переключение указателя на новую ещё + /// не прошло (SharePoint упал между этими двумя шагами), без фильтра она была бы + /// видна СРАЗУ в обоих качествах — и как архив, и как текущая (см. + /// BuildCurrentAsync). Фильтр держит текущую ровно в одном месте, даже в этом + /// переходном состоянии. /// - public async Task EnsureCurrentRegisteredAsync( + public async Task> GetArchivedAsync( + RequestType requestType, CancellationToken cancellationToken) => + await db.RequestListPartitions.AsNoTracking() + .Where(x => x.RequestTypeId == requestType.Id && x.ListId != requestType.ListId) + .OrderBy(x => x.Number) + .ToListAsync(cancellationToken); + + /// + /// Синтезирует запись о ТЕКУЩЕЙ партиции — без обращения к request_list_partitions + /// на запись: строка появится там только когда эта партиция реально станет + /// архивом (см. ArchiveCurrentAsync). Номер — следующий за последним архивным + /// (или 1, если архива ещё нет); Title читается REST'ом каждый раз, а не + /// кешируется — кешировать было бы негде без строки в БД. + /// + /// archived — уже загруженный список архивных партиций этого типа (см. + /// GetArchivedAsync), передаётся, а не читается заново, чтобы вызывающая + /// сторона (которой archived обычно уже нужен для остальной серии) не делала + /// один и тот же запрос дважды. + /// + public async Task BuildCurrentAsync( + RequestType requestType, IReadOnlyList archived, CancellationToken cancellationToken) + { + var number = archived.Count == 0 ? 1 : archived.Max(x => x.Number) + 1; + var title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken); + + return new RequestListPartitionDto( + number, requestType.SiteUrl!, requestType.ListId!, title, IsCurrent: true, DateTimeOffset.UtcNow); + } + + /// + /// Записывает ТЕКУЩУЮ партицию как архивную строку — вызывать строго в момент, + /// когда она перестаёт быть текущей (единственный вызывающий — + /// CreateNextListPartitionJob, прямо перед тем как переключить + /// RequestType.ListId на следующую партицию). + /// + /// Читает свой собственный, НЕфильтрованный список существующих партиций — + /// намеренно не переиспользует GetArchivedAsync(): та фильтрует по + /// ListId != RequestType.ListId, и ровно между этим вызовом и переключением + /// указателя (см. CreateNextListPartitionJob) только что записанная строка + /// на мгновение совпадает с текущим ListId — фильтр GetArchivedAsync её бы не + /// увидел, идемпотентность сломалась бы, и повтор Hangfire упал бы на + /// уникальном индексе (request_type_id, number) вместо того, чтобы найти уже + /// записанное и вернуть его. + /// + public async Task ArchiveCurrentAsync( RequestType requestType, CancellationToken cancellationToken) { var series = await db.RequestListPartitions @@ -49,14 +100,14 @@ public sealed class ListPartitionRegistry( await db.SaveChangesAsync(cancellationToken); logger.LogInformation( - "Список {ListId} типа заявок {RequestTypeId} добавлен в реестр под номером {Number}.", + "Список {ListId} типа заявок {RequestTypeId} заархивирован под номером {Number}.", entity.ListId, requestType.Id, entity.Number); return entity; } // Заголовок нужен только для отображения в админке/портале — из-за его - // отсутствия регистрация партиции падать не должна. + // отсутствия чтение партиции падать не должно. private async Task TryGetTitleAsync(string siteUrl, string listId, CancellationToken cancellationToken) { try diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs index d284ea8..b0567c0 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs @@ -44,7 +44,11 @@ public sealed class ListPartitionService( return Result.Fail(new ValidationError( $"У типа заявки {requestTypeId} не настроен список SharePoint.")); - var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken); + // Синтезируется на лету, не пишется в request_list_partitions — см. + // ListPartitionRegistry.BuildCurrentAsync: строка появится там только когда + // партиция реально станет архивом. + var archived = await registry.GetArchivedAsync(requestType, cancellationToken); + var current = await registry.BuildCurrentAsync(requestType, archived, cancellationToken); var itemCount = await TryGetItemCountAsync(requestType.SiteUrl, requestType.ListId, cancellationToken); var queued = await TryEnqueueProvisioningAsync(requestType, current.Number, itemCount, cancellationToken); @@ -96,14 +100,15 @@ public sealed class ListPartitionService( if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl)) return []; - await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken); + var archived = await registry.GetArchivedAsync(requestType, cancellationToken); + var current = await registry.BuildCurrentAsync(requestType, archived, 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); + var result = new List(archived.Count + 1); + result.AddRange(archived.Select(x => + new RequestListPartitionDto(x.Number, requestType.SiteUrl!, x.ListId, x.Title, IsCurrent: false, x.CreatedAt))); + result.Add(current); + + return result; } /// diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index 7829f26..a3a6edd 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -65,27 +65,26 @@ public sealed class ReminderService( /// списке, и не должна пропадать из напоминаний только из-за того, что для /// новых заявок завели следующий список. /// - /// EnsureCurrentRegisteredAsync — на случай, если этот тип заявок ни разу не - /// читали через ListPartitionService (сам реестр партиций мог ещё не знать - /// про текущий список): без неё серия была бы пустой и напоминания молча не - /// строились бы даже по текущему списку. + /// request_list_partitions хранит только архив (см. ListPartitionRegistry) — + /// текущая партиция синтезируется отдельно и добавляется к архиву здесь же, + /// а не читается из БД. /// private async Task> GetOverdueForTypeAsync( RequestType type, DateTimeOffset now, CancellationToken cancellationToken) { - await partitionRegistry.EnsureCurrentRegisteredAsync(type, cancellationToken); + var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken); + var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken); - var partitions = await db.RequestListPartitions.AsNoTracking() - .Where(x => x.RequestTypeId == type.Id) - .OrderBy(x => x.Number) - .ToListAsync(cancellationToken); + var partitions = archived + .Select(x => (x.ListId, x.Number)) + .Append((current.ListId, current.Number)); var reminders = new List(); - foreach (var partition in partitions) + foreach (var (listId, number) in partitions) { try { - reminders.AddRange(await GetOverdueForPartitionAsync(type, partition, now, cancellationToken)); + reminders.AddRange(await GetOverdueForPartitionAsync(type, listId, number, now, cancellationToken)); } catch (Exception e) { @@ -94,7 +93,7 @@ public sealed class ReminderService( // остальным партициям того же типа. logger.LogError( e, "Напоминания по списку {ListId} (партиция №{Number}) типа «{Type}» не собраны", - partition.ListId, partition.Number, type.Name); + listId, number, type.Name); } } @@ -102,20 +101,18 @@ public sealed class ReminderService( } private async Task> GetOverdueForPartitionAsync( - RequestType type, RequestListPartition partition, DateTimeOffset now, CancellationToken cancellationToken) + RequestType type, string listId, int number, DateTimeOffset now, CancellationToken cancellationToken) { var siteUrl = type.SiteUrl!; - // Читаем Title ЖИВЬЁМ, а не из partition.Title (закешированного на момент - // создания партиции) — тот же принцип, что был здесь и раньше для - // единственного списка: заголовок могли переименовать вручную, и кеш - // тогда соврёт, а по url список найдётся всегда. - var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, partition.ListId, cancellationToken); + // Читаем Title ЖИВЬЁМ — заголовок могли переименовать вручную, а по url + // список найдётся всегда. + var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken); if (string.IsNullOrWhiteSpace(dataListTitle)) { logger.LogWarning( "Список данных партиции №{Number} типа «{Type}» не найден по url {Url}", - partition.Number, type.Name, partition.ListId); + number, type.Name, listId); return []; } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitionRegistryTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitionRegistryTests.cs new file mode 100644 index 0000000..0fe9619 --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ListPartitionRegistryTests.cs @@ -0,0 +1,137 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using Tnsu.ApprovalRoute.Application.ListPartitioning; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; +using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +/// +/// request_list_partitions хранит ТОЛЬКО архив — партиция, которая ни разу не +/// архивировалась, не должна получать строку просто от того, что её прочитали +/// (см. ListPartitionRegistry). Раньше самозаполняющийся реестр писал строку +/// уже на первое чтение, из-за чего один и тот же список оказывался записан +/// дважды: и в RequestType.ListId, и строкой в этой таблице. +/// +public sealed class ListPartitionRegistryTests(PostgresDatabaseFixture fixture) : IClassFixture +{ + private readonly ApprovalRouteDbContext _db = + TestServiceProviderFactory.Build(fixture.ConnectionString).GetRequiredService(); + + private ListPartitionRegistry BuildRegistry() => + new(_db, Mock.Of(), NullLogger.Instance); + + private async Task CreateTypeAsync(string listId) + { + var type = new RequestType + { + Name = $"Тест {Guid.NewGuid():N}", + PageUrl = $"/test-{Guid.NewGuid():N}", + ListId = listId, + SiteUrl = "https://tnsukz.sharepoint.com/sites/requests", + Section = "General" + }; + _db.RequestTypes.Add(type); + await _db.SaveChangesAsync(); + return type; + } + + [Fact(DisplayName = "Партиция, которую ни разу не архивировали, не пишется в request_list_partitions")] + public async Task BuildCurrentAsync_NeverArchived_DoesNotWriteRow() + { + // Arrange + var registry = BuildRegistry(); + var type = await CreateTypeAsync("/sites/requests/Lists/1"); + + // Action + var archived = await registry.GetArchivedAsync(type, CancellationToken.None); + var current = await registry.BuildCurrentAsync(type, archived, CancellationToken.None); + + // Assert + archived.ShouldBeEmpty(); + current.Number.ShouldBe(1); + current.ListId.ShouldBe(type.ListId); + current.IsCurrent.ShouldBeTrue(); + + var rowsInDb = await _db.RequestListPartitions + .Where(x => x.RequestTypeId == type.Id) + .CountAsync(); + rowsInDb.ShouldBe(0); + } + + [Fact(DisplayName = "ArchiveCurrentAsync пишет ровно одну строку архива под номером 1")] + public async Task ArchiveCurrentAsync_WritesExactlyOneRow() + { + // Arrange + var registry = BuildRegistry(); + var type = await CreateTypeAsync("/sites/requests/Lists/1"); + + // Action + var archived = await registry.ArchiveCurrentAsync(type, CancellationToken.None); + + // Assert + archived.Number.ShouldBe(1); + archived.ListId.ShouldBe("/sites/requests/Lists/1"); + + var rowsInDb = await _db.RequestListPartitions + .Where(x => x.RequestTypeId == type.Id) + .ToListAsync(); + rowsInDb.Count.ShouldBe(1); + rowsInDb[0].ListId.ShouldBe("/sites/requests/Lists/1"); + } + + [Fact(DisplayName = "ArchiveCurrentAsync идемпотентна — повтор не создаёт вторую строку")] + public async Task ArchiveCurrentAsync_CalledTwice_DoesNotDuplicate() + { + // Arrange + var registry = BuildRegistry(); + var type = await CreateTypeAsync("/sites/requests/Lists/1"); + + // Action — как повторная попытка Hangfire после частичного сбоя: пока указатель + // ещё не переключён, GetArchivedAsync только что записанную строку не увидит + // (фильтрует по ListId != RequestType.ListId) — именно поэтому идемпотентность + // ArchiveCurrentAsync не должна зависеть от него (см. её doc-комментарий). + await registry.ArchiveCurrentAsync(type, CancellationToken.None); + await registry.ArchiveCurrentAsync(type, CancellationToken.None); + + // Assert + var rowsInDb = await _db.RequestListPartitions + .Where(x => x.RequestTypeId == type.Id) + .CountAsync(); + rowsInDb.ShouldBe(1); + } + + [Fact(DisplayName = "После архивации и переключения текущая партиция получает следующий номер")] + public async Task BuildCurrentAsync_AfterArchiving_ReturnsNextNumber() + { + // Arrange: тип с одной партицией, которую сейчас архивируем (как + // CreateNextListPartitionJob перед переключением на следующую). + var registry = BuildRegistry(); + var type = await CreateTypeAsync("/sites/requests/Lists/1"); + await registry.ArchiveCurrentAsync(type, CancellationToken.None); + + // Action: переключаем указатель на новую партицию (сама она архивной + // строки ещё не получает — см. doc-комментарий ListPartitionRegistry). + type.ListId = "/sites/requests/Lists/2"; + await _db.SaveChangesAsync(); + + var archived = await registry.GetArchivedAsync(type, CancellationToken.None); + var current = await registry.BuildCurrentAsync(type, archived, CancellationToken.None); + + // Assert + archived.Count.ShouldBe(1); + archived[0].ListId.ShouldBe("/sites/requests/Lists/1"); + current.Number.ShouldBe(2); + current.ListId.ShouldBe("/sites/requests/Lists/2"); + + var rowsInDb = await _db.RequestListPartitions + .Where(x => x.RequestTypeId == type.Id) + .CountAsync(); + rowsInDb.ShouldBe(1); // новая партиция строки ещё не получила + } +} -- 2.34.1 From ef4325921b4c5313b7044ee19c602843183d0e7f Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 19 Aug 2026 11:14:49 +0500 Subject: [PATCH 13/16] =?UTF-8?q?=D1=83=D1=82=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BD=D0=B5=D0=B4=D0=BE=D1=81=D1=82=D0=B0=D1=8E=D1=89?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TemplateRoutes/TemplateRouteService.cs | 7 +++++-- .../TnsuPortal/CachingTnsuPortalClient.cs | 2 +- .../TnsuPortal/HttpTnsuPortalClient.cs | 7 ++++--- .../TnsuPortal/ITnsuPortalClient.cs | 2 +- .../TnsuPortal/TnsuPortalDtos.cs | 3 +++ .../ApplicationServices/TemplateRouteServiceTests.cs | 2 +- 6 files changed, 15 insertions(+), 8 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index aae5af9..e1122c3 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -261,9 +261,12 @@ public sealed class TemplateRouteService( } else if (!string.IsNullOrWhiteSpace(step.Position)) { - resolvedEmail = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken); - if (string.IsNullOrWhiteSpace(resolvedEmail)) + var employeeByPosition = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken); + resolvedEmail = employeeByPosition.Email; + if (!employeeByPosition.Found) warning = $"Не найден сотрудник на должности «{step.Position}» в компании «{initiatorCompany}»."; + else if (string.IsNullOrWhiteSpace(resolvedEmail)) + warning = $"Сотрудник {employeeByPosition.Fio} на должности «{step.Position}» найден, но email не указан в системе портала."; } else if (step.ProjectRole is not (null or ProjectRole.None)) { diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs index 6ddab54..785e246 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs @@ -22,7 +22,7 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC $"emp:email:{email.ToLowerInvariant()}", () => inner.GetEmployeeByEmailAsync(email, cancellationToken)); - public Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) => + public Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) => GetOrAddAsync( $"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}", () => inner.GetEmployeeByPositionAsync(position, company, cancellationToken)); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index 24bd5e7..9c050ac 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -25,12 +25,12 @@ public sealed class HttpTnsuPortalClient( return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId, employee.Company ?? string.Empty, employee.Position); } - public async Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) + public async Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) { var employee = await GetAsync( $"/Employee/ByPosition?position={Uri.EscapeDataString(position)}&company={Uri.EscapeDataString(company)}", cancellationToken); - return employee?.Email; + return new EmployeeLookupResult(employee is not null, employee?.Email, employee?.Fio); } public async Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) @@ -107,7 +107,8 @@ public sealed class HttpTnsuPortalClient( [property: JsonPropertyName("company")] string? Company, [property: JsonPropertyName("companyId")] Guid? CompanyId, [property: JsonPropertyName("departmentId")] Guid? DepartmentId, - [property: JsonPropertyName("position")] string? Position); + [property: JsonPropertyName("position")] string? Position, + [property: JsonPropertyName("fio")] string? Fio); private sealed record ManagerResponse( [property: JsonPropertyName("managerEmail")] string? ManagerEmail); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs index 502affa..2821d9d 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs @@ -4,7 +4,7 @@ public interface ITnsuPortalClient { Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken); - Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken); + Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken); Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs index 29fe375..9a0636b 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs @@ -5,3 +5,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId, string Company, string? Position); public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email); + +/// Найден ли сотрудник вообще — отдельно от того, заполнен ли у него email в портале (это разные проблемы). +public sealed record EmployeeLookupResult(bool Found, string? Email, string? Fio); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index cc77636..ad5e0e7 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -560,7 +560,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny())) - .ReturnsAsync("lead@tnsu.kz"); + .ReturnsAsync(new EmployeeLookupResult(true, "lead@tnsu.kz", "Иванов Иван Иванович")); var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); var routeService = services.GetRequiredService(); -- 2.34.1 From 9d590742896fddc3c58a48e61722ff5eb9df7507 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 19 Aug 2026 12:03:12 +0500 Subject: [PATCH 14/16] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++- backend/Tnsu.ApprovalRoute.Api/Program.cs | 5 +++++ backend/Tnsu.ApprovalRoute.Api/appsettings.json | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index b449280..b969f7a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ workspace.xml **/bin/ **/obj/ .DS_Store -logs/ \ No newline at end of file +logs/ +appsettings.Local.json \ No newline at end of file diff --git a/backend/Tnsu.ApprovalRoute.Api/Program.cs b/backend/Tnsu.ApprovalRoute.Api/Program.cs index 759a947..2390684 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Program.cs @@ -21,6 +21,11 @@ const string CorsPolicyName = "Frontend"; var builder = WebApplication.CreateBuilder(args); +// Локальные оверрайды, не попадающие в git (см. .gitignore) — на своей машине +// можно, например, занизить ListPartitioning:ItemCountThreshold, чтобы не +// накапливать тысячи тестовых заявок для проверки создания партиций. +builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); + builder.Host.UseSerilog((context, services, configuration) => configuration .ReadFrom.Configuration(context.Configuration) .Enrich.FromLogContext() diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json index 387270e..184daba 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json @@ -25,7 +25,7 @@ ] }, "ListPartitioning": { - "ItemCountThreshold": 2, + "ItemCountThreshold": 4900, "StaleLockAfter": "00:30:00", "CheckThresholdOnRead": true }, -- 2.34.1 From cf44ae3e2e3614f587edaadd04061b422f58fe7d Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 19 Aug 2026 12:15:52 +0500 Subject: [PATCH 15/16] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=BB=D0=BE=D0=B3=D0=B3=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RequestTypes/RequestTypeService.cs | 8 +++++++- .../TemplateRouteSteps/TemplateRouteStepService.cs | 13 ++++++++++++- .../TemplateRoutes/TemplateRouteService.cs | 8 +++++++- .../TemplateRouteServiceTests.cs | 3 +++ .../Infrastructure/TestServiceProviderFactory.cs | 1 + 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs index fa58fa8..7ee49e8 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs @@ -1,12 +1,14 @@ using FluentResults; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Infrastructure.Persistence; namespace Tnsu.ApprovalRoute.Application.RequestTypes; -public sealed class RequestTypeService(ApprovalRouteDbContext db) +public sealed class RequestTypeService(ApprovalRouteDbContext db, ICurrentUser currentUser, ILogger logger) { public async Task>> ListAsync(CancellationToken cancellationToken) { @@ -45,6 +47,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) }; db.RequestTypes.Add(entity); await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Тип заявки {RequestTypeId} создан пользователем {Author}", entity.Id, currentUser.Email); return Result.Ok(ToDto(entity)); } @@ -67,6 +70,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) entity.SlaHours = request.SlaHours; entity.AskOverdueReason = request.AskOverdueReason; await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Тип заявки {RequestTypeId} изменён пользователем {Author}", entity.Id, currentUser.Email); return Result.Ok(ToDto(entity)); } @@ -95,6 +99,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) entity.SlaHours = request.SlaHours ?? entity.SlaHours; entity.AskOverdueReason = request.AskOverdueReason ?? entity.AskOverdueReason; await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Тип заявки {RequestTypeId} изменён пользователем {Author}", entity.Id, currentUser.Email); return Result.Ok(ToDto(entity)); } @@ -107,6 +112,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) db.RequestTypes.Remove(entity); await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Тип заявки {RequestTypeId} удалён пользователем {Author}", id, currentUser.Email); return Result.Ok(); } diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index 516efa9..b2b3654 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -1,13 +1,16 @@ using FluentResults; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Domain.Enums; using Tnsu.ApprovalRoute.Infrastructure.Persistence; namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; -public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) +public sealed class TemplateRouteStepService( + ApprovalRouteDbContext db, ICurrentUser currentUser, ILogger logger) { public async Task>> ListAsync(int routeId, CancellationToken cancellationToken) { @@ -79,6 +82,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) }; db.TemplateRouteSteps.Add(entity); await db.SaveChangesAsync(cancellationToken); + logger.LogInformation( + "Шаг {StepId} маршрута {RouteId} создан пользователем {Author}", entity.Id, route.Id, currentUser.Email); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); } @@ -130,6 +135,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) entity.SlaHours = r.SlaHours; entity.AskOverdueReason = r.AskOverdueReason; await db.SaveChangesAsync(cancellationToken); + logger.LogInformation( + "Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); } @@ -199,6 +206,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) entity.SlaHours = r.SlaHours ?? entity.SlaHours; entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason; await db.SaveChangesAsync(cancellationToken); + logger.LogInformation( + "Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); } @@ -211,6 +220,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) db.TemplateRouteSteps.Remove(entity); await db.SaveChangesAsync(cancellationToken); + logger.LogInformation( + "Шаг {StepId} маршрута {RouteId} удалён пользователем {Author}", id, routeId, currentUser.Email); return Result.Ok(); } diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index e1122c3..34b9dd5 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -1,6 +1,7 @@ using System.Globalization; using FluentResults; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Application.Common.Interfaces; @@ -15,7 +16,8 @@ public sealed class TemplateRouteService( ApprovalRouteDbContext db, ITnsuPortalClient portalClient, ICurrentUser currentUser, - IOptions portalOptions) + IOptions portalOptions, + ILogger logger) { public async Task>> ListAsync(int? requestTypeId, CancellationToken cancellationToken) { @@ -61,6 +63,7 @@ public sealed class TemplateRouteService( }; db.TemplateRoutes.Add(entity); await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Маршрут {RouteId} создан пользователем {Author}", entity.Id, currentUser.Email); return Result.Ok(ToDto(entity)); } @@ -81,6 +84,7 @@ public sealed class TemplateRouteService( entity.ByProject = r.ByProject; entity.RequiresControl = r.RequiresControl; await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Маршрут {RouteId} изменён пользователем {Author}", entity.Id, currentUser.Email); return Result.Ok(ToDto(entity)); } @@ -105,6 +109,7 @@ public sealed class TemplateRouteService( entity.ByProject = byProject; entity.RequiresControl = r.RequiresControl ?? entity.RequiresControl; await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Маршрут {RouteId} изменён пользователем {Author}", entity.Id, currentUser.Email); return Result.Ok(ToDto(entity)); } @@ -117,6 +122,7 @@ public sealed class TemplateRouteService( db.TemplateRoutes.Remove(entity); await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Маршрут {RouteId} удалён пользователем {Author}", id, currentUser.Email); return Result.Ok(); } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index ad5e0e7..a33942c 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -31,6 +31,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture x.GetProjectExecutorsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]); + portalClient + .Setup(x => x.GetEmployeeByPositionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new EmployeeLookupResult(false, null, null)); _services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); _service = _services.GetRequiredService(); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestServiceProviderFactory.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestServiceProviderFactory.cs index 7461499..0167ffa 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestServiceProviderFactory.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestServiceProviderFactory.cs @@ -22,6 +22,7 @@ internal static class TestServiceProviderFactory .Build(); var services = new ServiceCollection(); + services.AddLogging(); services.AddApprovalRouteInfrastructure(configuration); services.AddApprovalRouteApplication(); services.AddSingleton(new TestCurrentUser(currentUserEmail)); -- 2.34.1 From a1e6c5c8b4b86c933f1dabdd8acea2318cf7bcfd Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 19 Aug 2026 13:30:09 +0500 Subject: [PATCH 16/16] =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tnsu.ApprovalRoute.Api/appsettings.json | 3 +- .../CreateNextListPartitionJob.cs | 19 +- .../ListPartitioning/ListPartitionService.cs | 8 +- ...frastructureServiceCollectionExtensions.cs | 15 ++ .../SharePoint/SharePointListProvisioner.cs | 13 ++ .../SharePoint/SharePointOptions.cs | 11 + .../HttpSharePointWebhookSubscriber.cs | 94 +++++++++ .../TnsuPortal/HttpTnsuPortalClient.cs | 20 +- .../ISharePointWebhookSubscriber.cs | 16 ++ .../TnsuPortal/TnsuPortalAuthorizer.cs | 39 ++++ .../TnsuPortal/TnsuPortalCallerToken.cs | 37 ++++ .../SharePointWebhookSubscriberTests.cs | 189 ++++++++++++++++++ 12 files changed, 443 insertions(+), 21 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpSharePointWebhookSubscriber.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ISharePointWebhookSubscriber.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalAuthorizer.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalCallerToken.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/SharePointWebhookSubscriberTests.cs diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json index 184daba..d6fc1ee 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json @@ -39,7 +39,8 @@ "TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a", "ClientId": "9232d9e2-9807-4753-808f-07250e5474b0", "CertificateSettingName": "AzurePfx", - "CertificatePassword": "Rk.xDjccnfyjdktybz1" + "CertificatePassword": "Rk.xDjccnfyjdktybz1", + "NotificationUrl": "https://api.tnsu.kz/Webhook/SharePoint" }, "TnsuPortal": { "BaseUrl": "https://api.tnsu.kz", diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs index 9c6d1dc..06494c1 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.SharePoint; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Application.ListPartitioning; @@ -35,11 +36,27 @@ public sealed class CreateNextListPartitionJob( ListPartitioningLock provisioningLock, ListPartitionRegistry registry, ISharePointListProvisioner provisioner, + TnsuPortalCallerToken callerToken, IOptions options, ILogger logger) { - public async Task RunAsync(int requestTypeId, CancellationToken cancellationToken) + /// + /// Заголовок Authorization пользователя, чей запрос вызвал провижининг (см. + /// ListPartitionService.TryEnqueueProvisioningAsync). Нужен, чтобы подписать + /// новый список на уведомления от имени этого пользователя: своего HttpContext + /// у фоновой задачи нет, а client_credentials сейчас не настроен (см. + /// TnsuPortalAuthorizer). Провижининг занимает секунды, поэтому токен не + /// успевает истечь — но на ПОВТОРЕ Hangfire (минуты спустя) он уже может быть + /// просроченным: тогда подписка вернёт 401 и упадёт мягко, не сорвав создание + /// списка (см. ISharePointWebhookSubscriber). + /// + /// Учтите: как аргумент задачи он попадает в хранилище Hangfire (Postgres) и + /// виден в его дашборде. + /// + public async Task RunAsync(int requestTypeId, string? authorization, CancellationToken cancellationToken) { + callerToken.Set(authorization); + // Единственная настоящая защита от параллельного запуска: атомарный захват // флага в строке request_types. Проверка перед постановкой в очередь — // только чтобы не плодить лишние задачи; гонку разруливает именно здесь. diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs index b0567c0..f7862c9 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs @@ -7,6 +7,7 @@ using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.SharePoint; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Application.ListPartitioning; @@ -19,6 +20,7 @@ public sealed class ListPartitionService( ISharePointClient sharePoint, ListPartitionRegistry registry, IBackgroundJobClient backgroundJobs, + TnsuPortalCallerToken callerToken, IOptions options, ILogger logger) { @@ -151,8 +153,12 @@ public sealed class ListPartitionService( // самой задачи (см. CreateNextListPartitionJob). if (requestType.ListPartitioningStartedAt is not null) return false; + // Токен ЭТОГО запроса уезжает в задачу: подписать новый список на + // уведомления нужно от имени пользователя, а своего токена у фоновой задачи + // нет (см. CreateNextListPartitionJob.RunAsync — там же про его хранение и + // срок жизни). backgroundJobs.Enqueue( - job => job.RunAsync(requestType.Id, CancellationToken.None)); + job => job.RunAsync(requestType.Id, callerToken.Current, CancellationToken.None)); logger.LogInformation( "В списке {ListId} типа заявок {RequestTypeId} {ItemCount} элементов (порог {Threshold}) — " + diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs index bf46e61..9d62bf5 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -31,6 +31,21 @@ public static class InfrastructureServiceCollectionExtensions services.Configure(configuration.GetSection("TnsuPortal")); services.AddHttpClient(); + // Токен вызывающего и общая авторизация запросов к порталу — одни на всех + // его клиентов (см. TnsuPortalCallerToken/TnsuPortalAuthorizer). Именно + // Scoped: Hangfire даёт задаче свою область DI, и положенный в задаче токен + // не должен протекать в соседние. + services.AddScoped(); + services.AddScoped(); + + // Подписка новых списков на уведомления SharePoint. Клиент отдельный от + // ITnsuPortalClient: там кешируемые чтения справочника, здесь команда. + // Своих опций нет — адрес приёмника лежит в SharePoint:NotificationUrl. + services.AddHttpClient((sp, client) => + { + var options = sp.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + }); // ITnsuPortalClient резолвится в CachingTnsuPortalClient (кеш на 5 минут, // см. её комментарий) — сам HttpTnsuPortalClient регистрируем по // конкретному типу, а не по интерфейсу, чтобы декоратор мог его получить diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs index 9021951..8f7714c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using Microsoft.Extensions.Logging; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; @@ -16,6 +17,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; ///
public sealed class SharePointListProvisioner( ISharePointClient sharePoint, + ISharePointWebhookSubscriber webhookSubscriber, ILogger logger) : ISharePointListProvisioner { public async Task CreateNextListPairAsync( @@ -47,6 +49,17 @@ public sealed class SharePointListProvisioner( "Пара списков №{Number} готова за {ElapsedMs} мс: данные «{Title}» ({ListId}).", number, stopwatch.ElapsedMilliseconds, newData.Title, newData.ServerRelativeUrl); + // Подписка — ПОСЛЕДНИМ шагом, когда списки уже созданы: + // • подписывать нечего, пока списка нет; + // • только для списка ДАННЫХ — заявки портал отслеживает по нему, списку + // согласования подписка не нужна; + // • в самом конце, а не сразу после создания списка данных: сорвись + // дублирование пары на списке согласования, подписка была бы уже + // заведена, и повтор задачи Hangfire отправил бы второй запрос на ту же + // подписку (идемпотентна ли ручка портала — неизвестно). + // Не бросает и провижининг не рушит (см. ISharePointWebhookSubscriber). + await webhookSubscriber.TrySubscribeAsync(siteUrl, newData.Title, cancellationToken); + return new ProvisionedList(newData.ServerRelativeUrl, newData.Title); } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointOptions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointOptions.cs index 71186da..2a4b14c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointOptions.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointOptions.cs @@ -20,4 +20,15 @@ public sealed class SharePointOptions /// это здесь не стоит — пароль от сертификата в исходниках не место. ///
public string CertificatePassword { get; set; } = string.Empty; + + /// + /// Куда SharePoint будет присылать уведомления об изменениях списка — параметр + /// notificationUrl ручки POST /Webhook/Subscribe, которой новый список данных + /// подписывается на уведомления (см. HttpSharePointWebhookSubscriber). + /// Это приёмник на СТОРОНЕ ПОРТАЛА, а не адрес нашего сервиса. + /// + /// Пусто — подписывать не будем вовсе: без адреса приёмника подписка + /// бессмысленна, и это же единственный способ её выключить. + /// + public string NotificationUrl { get; set; } = "https://api.tnsu.kz/Webhook/SharePoint"; } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpSharePointWebhookSubscriber.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpSharePointWebhookSubscriber.cs new file mode 100644 index 0000000..92cad00 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpSharePointWebhookSubscriber.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +/// +/// POST {BaseUrl}/Webhook/Subscribe?listName=…&url=…&notificationUrl=… — просит +/// портал завести подписку SharePoint на только что созданный список данных. +/// +/// Параметры уходят строкой запроса, тела у запроса нет — так описана ручка. +/// +/// Отдельный клиент, а не метод в ITnsuPortalClient: тот про справочник сотрудников +/// и обёрнут кешем (CachingTnsuPortalClient), а здесь команда, которую кешировать +/// нельзя ни в каком виде. Авторизация общая с ним — см. TnsuPortalAuthorizer. +/// +/// Адрес приёмника уведомлений живёт в SharePoint:NotificationUrl — в одном блоке с +/// остальными настройками SharePoint, а не в своём: подписка относится к спискам, +/// а не к порталу как таковому. +/// +/// Ошибки НЕ пробрасываются наружу. Список к этому моменту уже создан, и уронить +/// провижининг из-за недоступности портала значило бы оставить партиционирование +/// нерабочим целиком (задача Hangfire ушла бы в повторы, а порог по элементам +/// продолжал бы срабатывать). Поэтому сбой — Error в лог с полным контекстом +/// (имя списка и сайт видны, подписку можно завести руками), а провижининг идёт +/// дальше. Отдельно предупреждаем про 401/403: в фоновой задаче нет входящего +/// токена, а client_credentials требует заполненных TnsuPortal:ClientSecret/Scope +/// (в конфигурации они пустые — см. TnsuPortalAuthorizer). +/// +public sealed class HttpSharePointWebhookSubscriber( + HttpClient httpClient, + TnsuPortalAuthorizer authorizer, + IOptions options, + ILogger logger) : ISharePointWebhookSubscriber +{ + public async Task TrySubscribeAsync(string siteUrl, string listTitle, CancellationToken cancellationToken) + { + var notificationUrl = options.Value.NotificationUrl; + if (string.IsNullOrWhiteSpace(notificationUrl)) + { + // Без адреса приёмника подписка бессмысленна — он же служит выключателем + // (см. SharePointOptions.NotificationUrl). + logger.LogInformation( + "SharePoint:NotificationUrl не задан — список «{ListTitle}» на уведомления не подписан.", + listTitle); + return false; + } + + if (string.IsNullOrWhiteSpace(listTitle)) + { + // Без имени списка ручке нечего подписывать: у неё нет другого способа + // его найти (адрес списка она не принимает). + logger.LogError("Заголовок нового списка пуст — подписка на уведомления невозможна."); + return false; + } + + // Полный адрес сайта целиком («https://tnsukz.sharepoint.com/sites/requests»), + // а не короткое имя — именно это ждёт ручка. + var site = siteUrl.TrimEnd('/'); + var url = + $"/Webhook/Subscribe?listName={Uri.EscapeDataString(listTitle)}" + + $"&url={Uri.EscapeDataString(site)}" + + $"¬ificationUrl={Uri.EscapeDataString(notificationUrl)}"; + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, url); + await authorizer.AuthorizeAsync(request, cancellationToken); + + using var response = await httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + // Тело читаем именно на неуспехе — в нём причина отказа портала. + var body = await response.Content.ReadAsStringAsync(cancellationToken); + logger.LogError( + "Портал отказал в подписке списка «{ListTitle}» (сайт {Site}): {StatusCode}. Ответ: {Body}", + listTitle, site, (int)response.StatusCode, body); + return false; + } + + logger.LogInformation( + "Список «{ListTitle}» (сайт {Site}) подписан на уведомления SharePoint.", listTitle, site); + return true; + } + catch (Exception ex) + { + logger.LogError( + ex, + "Не удалось подписать список «{ListTitle}» (сайт {Site}) на уведомления — подпишите вручную.", + listTitle, site); + return false; + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index 9c050ac..40c5b9c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -1,17 +1,14 @@ using System.Net; -using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Http; using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; public sealed class HttpTnsuPortalClient( HttpClient httpClient, - TnsuPortalTokenProvider tokenProvider, - IHttpContextAccessor httpContextAccessor) : ITnsuPortalClient + TnsuPortalAuthorizer authorizer) : ITnsuPortalClient { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; @@ -73,7 +70,7 @@ public sealed class HttpTnsuPortalClient( private async Task GetAsync(string requestUri, CancellationToken cancellationToken) where T : class { using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); - await AuthorizeAsync(request, cancellationToken); + await authorizer.AuthorizeAsync(request, cancellationToken); using var response = await httpClient.SendAsync(request, cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) @@ -89,19 +86,6 @@ public sealed class HttpTnsuPortalClient( executors.Add(new ProjectExecutorInfo(role, email)); } - private async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); - if (!string.IsNullOrWhiteSpace(incoming)) - { - request.Headers.TryAddWithoutValidation("Authorization", incoming); - return; - } - - var token = await tokenProvider.GetAccessTokenAsync(cancellationToken); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - } - private sealed record EmployeeResponse( [property: JsonPropertyName("email")] string? Email, [property: JsonPropertyName("company")] string? Company, diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ISharePointWebhookSubscriber.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ISharePointWebhookSubscriber.cs new file mode 100644 index 0000000..890273d --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ISharePointWebhookSubscriber.cs @@ -0,0 +1,16 @@ +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +/// +/// Подписывает список данных SharePoint на уведомления об изменениях через портал. +/// +public interface ISharePointWebhookSubscriber +{ + /// + /// Подписать список. НЕ бросает: подписка — следствие создания списка, а не его + /// условие (см. реализацию), поэтому сбой портала не должен рушить провижининг + /// партиции. Возвращает true, только если портал подтвердил подписку. + /// + /// Абсолютный адрес сайта, где создан список. + /// Заголовок списка данных — русское имя, как в SharePoint. + Task TrySubscribeAsync(string siteUrl, string listTitle, CancellationToken cancellationToken); +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalAuthorizer.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalAuthorizer.cs new file mode 100644 index 0000000..124f70b --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalAuthorizer.cs @@ -0,0 +1,39 @@ +using System.Net.Http.Headers; + +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +/// +/// Авторизация запросов к api.tnsu.kz. Вынесена из HttpTnsuPortalClient, потому что +/// к порталу ходит не только справочник сотрудников (см. +/// HttpSharePointWebhookSubscriber), а правило выбора токена должно быть одно. +/// +/// Токен берётся в двух режимах, и порядок принципиален: +/// 1) Токен вызывающего пользователя (см. TnsuPortalCallerToken) — из текущего +/// HTTP-запроса либо переданный аргументом в фоновую задачу. Портал тогда +/// видит исходного пользователя, а не сервисное приложение. +/// 2) Его нет вовсе — client_credentials самого сервиса (см. +/// TnsuPortalTokenProvider). +/// +/// ВАЖНО про второй путь: TnsuPortal:ClientSecret/Scope в конфигурации пусты +/// (см. appsettings.json), поэтому он сейчас нерабочий — это именно фолбэк, а не +/// штатный режим. Вызывающая сторона обязана это учитывать: см. +/// HttpSharePointWebhookSubscriber, где сбой подписки намеренно не рушит +/// провижининг списка. +/// +public sealed class TnsuPortalAuthorizer( + TnsuPortalCallerToken callerToken, + TnsuPortalTokenProvider tokenProvider) +{ + public async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var caller = callerToken.Current; + if (!string.IsNullOrWhiteSpace(caller)) + { + request.Headers.TryAddWithoutValidation("Authorization", caller); + return; + } + + var token = await tokenProvider.GetAccessTokenAsync(cancellationToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalCallerToken.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalCallerToken.cs new file mode 100644 index 0000000..882aa64 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalCallerToken.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Http; + +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +/// +/// Authorization, с которым идти в api.tnsu.kz в пределах текущей области DI. +/// +/// Два источника, и второй нужен именно фоновым задачам: +/// • обрабатывается HTTP-запрос — заголовок берётся прямо из него; +/// • выполняется задача Hangfire — своего HttpContext'а у неё нет, поэтому +/// токен инициировавшего пользователя ей передают аргументом и кладут сюда +/// через (см. CreateNextListPartitionJob). +/// +/// Scoped: Hangfire создаёт отдельную область DI на каждое выполнение задачи, +/// поэтому положенный здесь токен не протекает между задачами. +/// +public sealed class TnsuPortalCallerToken(IHttpContextAccessor httpContextAccessor) +{ + private string? _explicit; + + /// + /// Заголовок целиком, вида «Bearer …». null — вызывающего токена нет + /// (тогда авторизация уходит на client_credentials, см. TnsuPortalAuthorizer). + /// + public string? Current + { + get + { + var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); + return string.IsNullOrWhiteSpace(incoming) ? _explicit : incoming; + } + } + + /// Положить токен явно — для кода без HttpContext (фоновые задачи). + public void Set(string? authorization) => + _explicit = string.IsNullOrWhiteSpace(authorization) ? null : authorization; +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/SharePointWebhookSubscriberTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/SharePointWebhookSubscriberTests.cs new file mode 100644 index 0000000..996c5dd --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/SharePointWebhookSubscriberTests.cs @@ -0,0 +1,189 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Shouldly; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +/// +/// Подписка нового списка данных на уведомления SharePoint (POST /Webhook/Subscribe). +/// +/// Проверяется ровно то, что нельзя увидеть по типам: как складывается строка +/// запроса (ручка принимает всё параметрами, тела нет) и что сбой портала не рушит +/// провижининг — подписка следствие создания списка, а не его условие. +/// +public sealed class SharePointWebhookSubscriberTests +{ + private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests"; + private const string ListTitle = "Обходной лист 3 — данные"; + + /// Перехватывает запрос вместо сети: запоминает его и отдаёт заданный ответ. + private sealed class StubHandler(Func respond) : HttpMessageHandler + { + public HttpRequestMessage? Captured { get; private set; } + public int CallCount { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Captured = request; + CallCount++; + return Task.FromResult(respond(request)); + } + } + + // Токен вызывающего есть — тогда авторизатор пробрасывает его и не идёт за + // client_credentials (у которого в тестах нет ни секрета, ни сети). + // fromHttpContext=false — путь фоновой задачи: токен положен явно, HttpContext'а + // нет вовсе (см. TnsuPortalCallerToken.Set / CreateNextListPartitionJob). + private static TnsuPortalAuthorizer BuildAuthorizer(bool fromHttpContext = true) + { + var accessor = new HttpContextAccessor(); + var callerToken = new TnsuPortalCallerToken(accessor); + + if (fromHttpContext) + { + var context = new DefaultHttpContext(); + context.Request.Headers.Authorization = "Bearer test-token"; + accessor.HttpContext = context; + } + else + { + callerToken.Set("Bearer test-token"); + } + + return new TnsuPortalAuthorizer( + callerToken, + new TnsuPortalTokenProvider(new HttpClient(), Options.Create(new TnsuPortalOptions()))); + } + + private static (HttpSharePointWebhookSubscriber Subscriber, StubHandler Handler) Build( + Func respond, + SharePointOptions? options = null, + bool tokenFromHttpContext = true) + { + var handler = new StubHandler(respond); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.tnsu.kz") }; + + var subscriber = new HttpSharePointWebhookSubscriber( + httpClient, + BuildAuthorizer(tokenFromHttpContext), + Options.Create(options ?? new SharePointOptions()), + NullLogger.Instance); + + return (subscriber, handler); + } + + private static HttpResponseMessage Ok() => new(System.Net.HttpStatusCode.OK); + + [Fact(DisplayName = "Подписка уходит POST'ом со всеми тремя параметрами в строке запроса")] + public async Task TrySubscribeAsync_SendsPostWithQueryParameters() + { + // Arrange + var (subscriber, handler) = Build(_ => Ok()); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + subscribed.ShouldBeTrue(); + handler.Captured!.Method.ShouldBe(HttpMethod.Post); + handler.Captured.Content.ShouldBeNull(); // тела у ручки нет + + var query = Uri.UnescapeDataString(handler.Captured.RequestUri!.Query); + handler.Captured.RequestUri.AbsolutePath.ShouldBe("/Webhook/Subscribe"); + query.ShouldContain($"listName={ListTitle}"); + query.ShouldContain("notificationUrl=https://api.tnsu.kz/Webhook/SharePoint"); + // Токен из входящего запроса пробрасывается как есть (см. TnsuPortalAuthorizer). + handler.Captured.Headers.Authorization?.ToString().ShouldBe("Bearer test-token"); + } + + [Fact(DisplayName = "В url уходит ПОЛНЫЙ адрес сайта, а не короткое имя")] + public async Task TrySubscribeAsync_SendsFullSiteUrl() + { + // Arrange + var (subscriber, handler) = Build(_ => Ok()); + + // Action + await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + var query = Uri.UnescapeDataString(handler.Captured!.RequestUri!.Query); + query.ShouldContain($"url={SiteUrl}"); + } + + [Fact(DisplayName = "Токен фоновой задачи (без HttpContext) тоже уходит в заголовок")] + public async Task TrySubscribeAsync_TokenSetExplicitly_IsSentAsAuthorization() + { + // Arrange: так работает провижининг — HttpContext'а нет, токен передан + // аргументом задачи (см. CreateNextListPartitionJob). + var (subscriber, handler) = Build(_ => Ok(), tokenFromHttpContext: false); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + subscribed.ShouldBeTrue(); + handler.Captured!.Headers.Authorization?.ToString().ShouldBe("Bearer test-token"); + } + + [Fact(DisplayName = "Отказ портала не бросает исключение — провижининг списка не рушится")] + public async Task TrySubscribeAsync_PortalRejects_ReturnsFalseWithoutThrowing() + { + // Arrange + var (subscriber, _) = Build(_ => new HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized) + { + Content = new StringContent("no token") + }); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + subscribed.ShouldBeFalse(); + } + + [Fact(DisplayName = "Недоступный портал (исключение в сети) тоже не бросает наружу")] + public async Task TrySubscribeAsync_NetworkFailure_ReturnsFalseWithoutThrowing() + { + // Arrange + var (subscriber, _) = Build(_ => throw new HttpRequestException("портал недоступен")); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + subscribed.ShouldBeFalse(); + } + + [Fact(DisplayName = "NotificationUrl не задан — запрос не отправляется вовсе")] + public async Task TrySubscribeAsync_NoNotificationUrl_DoesNotCallPortal() + { + // Arrange: пустой адрес приёмника — единственный выключатель подписки + // (отдельного флага Enabled нет, см. SharePointOptions.NotificationUrl). + var (subscriber, handler) = Build(_ => Ok(), new SharePointOptions { NotificationUrl = "" }); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + subscribed.ShouldBeFalse(); + handler.CallCount.ShouldBe(0); + } + + [Fact(DisplayName = "Пустой заголовок списка — подписывать нечего, запроса нет")] + public async Task TrySubscribeAsync_EmptyListTitle_DoesNotCallPortal() + { + // Arrange + var (subscriber, handler) = Build(_ => Ok()); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, " ", CancellationToken.None); + + // Assert + subscribed.ShouldBeFalse(); + handler.CallCount.ShouldBe(0); + } +} -- 2.34.1