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);
}