разные правки по беку

This commit is contained in:
gorbenko.pavel
2026-08-17 15:33:54 +05:00
parent 2a7130c37f
commit b92897254d
23 changed files with 475 additions and 315 deletions
@@ -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<ReminderService>();
services.AddScoped<RequestTypeService>();
services.AddScoped<RequestTypeListRegistry>();
services.AddScoped<RequestTypeListService>();
services.AddScoped<ListPartitionRegistry>();
services.AddScoped<ListPartitionService>();
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
services.AddScoped<CreateNextRequestTypeListJob>();
services.AddScoped<CreateNextListPartitionJob>();
services.AddScoped<TemplateRouteService>();
services.AddScoped<TemplateRouteStepService>();
@@ -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;
/// <summary>
/// Фоновая задача: создать следующий список серии по типу заявок, записать его в
/// реестр и переключить на него запись.
/// Фоновая задача: создать следующую партицию списка по типу заявок, записать её в
/// реестр и переключить на неё запись.
///
/// Блокировка захватывается ЗДЕСЬ, при каждом запуске (в т.ч. на каждой повторной
/// попытке Hangfire), а не в момент постановки в очередь. Enqueue и фактическое
/// выполнение — разные события во времени: задача может полежать в очереди, быть
/// повторена автоматически после сбоя, в теории — перезапущена вручную из дашборда.
/// Всё это заново становится тем же самым «выполнением» с точки зрения Hangfire, но
/// не с точки зрения блокировки: settled только выполнение может её удержать, и
/// проверять это надо на каждом фактическом входе в код, создающий список в
/// SharePoint, а не один раз на входе в очередь. Иначе, к примеру, зависший на
/// повторе процесс и новый независимый триггер (например, второй HTTP-запрос,
/// поймавший тот же порог до переключения списка) синхронизировались бы неверно.
///
/// Повторы разрешены, но их немного: провижинер идемпотентен (переиспользует список
/// с целевым именем и дозаполняет колонки), поэтому повтор после частичного сбоя
/// безопасен и не создаёт второй список. Больше трёх попыток смысла нет — если
/// безопасен и не создаёт вторую партицию. Больше трёх попыток смысла нет — если
/// SharePoint отвечает ошибкой стабильно, задача должна остаться видимой в Hangfire,
/// а не молотить впустую.
/// </summary>
[AutomaticRetry(Attempts = 3)]
public sealed class CreateNextRequestTypeListJob(
public sealed class CreateNextListPartitionJob(
ApprovalRouteDbContext db,
RequestTypeProvisioningLock provisioningLock,
RequestTypeListRegistry registry,
ListPartitioningLock provisioningLock,
ListPartitionRegistry registry,
ISharePointListProvisioner provisioner,
IOptions<ListProvisioningOptions> options,
ILogger<CreateNextRequestTypeListJob> logger)
IOptions<ListPartitioningOptions> options,
ILogger<CreateNextListPartitionJob> 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
@@ -0,0 +1,35 @@
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
/// <summary>Одна партиция серии — для админки/портала (вся серия) и для ответа «текущий список».</summary>
/// <param name="IsCurrent">
/// true — в эту партицию идёт запись новых заявок; остальные партиции серии — архив только для чтения.
/// </param>
public sealed record RequestListPartitionDto(
int Number,
string SiteUrl,
string ListId,
string? Title,
bool IsCurrent,
DateTimeOffset CreatedAt);
/// <summary>Вся серия партиций одного типа заявок — единица ответа bulk-эндпоинта для портала.</summary>
public sealed record RequestTypeListSeriesDto(
int RequestTypeId,
IReadOnlyList<RequestListPartitionDto> Partitions);
/// <summary>Ответ «куда писать новую заявку».</summary>
/// <param name="ItemCount">
/// Сколько элементов сейчас в списке — по нему видно, насколько он близок к порогу.
/// null, если SharePoint не ответил (тогда условие создания следующей партиции не проверялось).
/// </param>
/// <param name="ProvisioningQueued">
/// true — этим запросом поставлена в очередь задача создания следующей партиции серии.
/// </param>
public sealed record CurrentListPartitionDto(
int RequestTypeId,
int Number,
string SiteUrl,
string ListId,
string? Title,
int? ItemCount = null,
bool ProvisioningQueued = false);
@@ -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;
/// <summary>
/// Наполнение реестра списков. Вынесено отдельно, потому что нужно и читающему
/// Наполнение реестра партиций. Вынесено отдельно, потому что нужно и читающему
/// эндпоинту, и фоновой задаче: реестр появился позже самих списков, и любой из них
/// может встретить тип заявок, которого в реестре ещё нет.
/// </summary>
public sealed class RequestTypeListRegistry(
public sealed class ListPartitionRegistry(
ApprovalRouteDbContext db,
ISharePointClient sharePoint,
ILogger<RequestTypeListRegistry> logger)
ILogger<ListPartitionRegistry> logger)
{
/// <summary>
/// Гарантирует, что текущий список типа заявок есть в реестре, и возвращает его строку.
/// Гарантирует, что текущая партиция типа заявок есть в реестре, и возвращает её строку.
///
/// Реестр самозаполняющийся: до этой фичи списки жили только в
/// RequestType.ListId, и переключить список руками в админке можно и сейчас.
/// Поэтому неизвестный реестру ListId дописывается следующим номером, а не
/// считается ошибкой.
///
/// Требует заполненных ListId/SiteUrl — вызывающая сторона это проверяет.
/// Требует заполненного ListId — вызывающая сторона это проверяет.
/// </summary>
public async Task<RequestTypeList> EnsureCurrentRegisteredAsync(
public async Task<RequestListPartition> 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<string?> TryGetTitleAsync(string siteUrl, string listId, CancellationToken cancellationToken)
{
try
@@ -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;
/// <summary>
/// Реестр нумерованных списков по типу заявок: какой список текущий (куда писать) и
/// вся серия целиком (архив + текущий) для админки.
/// Реестр нумерованных партиций списка по типу заявок: какая партиция текущая (куда
/// писать) и вся серия целиком (архив + текущая) для админки и портала.
/// </summary>
public sealed class RequestTypeListService(
public sealed class ListPartitionService(
ApprovalRouteDbContext db,
ISharePointClient sharePoint,
RequestTypeListRegistry registry,
ListPartitionRegistry registry,
IBackgroundJobClient backgroundJobs,
IOptions<ListProvisioningOptions> options,
ILogger<RequestTypeListService> logger)
IOptions<ListPartitioningOptions> options,
ILogger<ListPartitionService> logger)
{
/// <summary>
/// Список, в который сейчас нужно писать новые заявки.
/// Партиция, в которую сейчас нужно писать новые заявки.
///
/// Побочный эффект: если в списке набрался порог элементов, ставит в очередь
/// задачу создания следующего. Побочный эффект в GET — сознательный компромисс
/// Побочный эффект: если партиция набрала порог элементов, ставит в очередь
/// задачу создания следующей. Побочный эффект в GET — сознательный компромисс
/// (так описано в ТЗ): вызывающая сторона всё равно спрашивает адрес перед каждой
/// записью, и это единственный момент, когда мы точно знаем, что список живой.
/// Повторная постановка безопасна — задача разруливает гонку захватом блокировки.
/// </summary>
public async Task<Result<CurrentRequestTypeListDto>> GetCurrentAsync(
public async Task<Result<CurrentListPartitionDto>> GetCurrentAsync(
int requestTypeId, CancellationToken cancellationToken)
{
var requestType = await db.RequestTypes
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
if (requestType is null)
return Result.Fail<CurrentRequestTypeListDto>(new NotFoundError("Тип заявки", requestTypeId));
return Result.Fail<CurrentListPartitionDto>(new NotFoundError("Тип заявки", requestTypeId));
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
return Result.Fail<CurrentRequestTypeListDto>(new ValidationError(
return Result.Fail<CurrentListPartitionDto>(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));
}
/// <summary>Вся серия списков типа заявок, от первого к последнему — для админки.</summary>
public async Task<Result<IReadOnlyList<RequestTypeListDto>>> ListAsync(
/// <summary>Вся серия партиций типа заявок, от первой к последней — для админки.</summary>
public async Task<Result<IReadOnlyList<RequestListPartitionDto>>> ListAsync(
int requestTypeId, CancellationToken cancellationToken)
{
var requestType = await db.RequestTypes
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
if (requestType is null)
return Result.Fail<IReadOnlyList<RequestTypeListDto>>(new NotFoundError("Тип заявки", requestTypeId));
return Result.Fail<IReadOnlyList<RequestListPartitionDto>>(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))
/// <summary>
/// Серии всех типов заявок разом — для портала: чтобы искать по заявкам сразу во
/// всех партициях (архив + текущая) любого типа, порталу нужен весь список
/// адресов один раз, а не по одному запросу на тип. Типы без настроенного
/// списка SharePoint в ответ не попадают — искать там всё равно нечего.
/// </summary>
public async Task<Result<IReadOnlyList<RequestTypeListSeriesDto>>> ListAllAsync(CancellationToken cancellationToken)
{
var requestTypes = await db.RequestTypes
.Where(x => x.ListId != null && x.SiteUrl != null)
.ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<RequestTypeListDto>>(items);
var result = new List<RequestTypeListSeriesDto>(requestTypes.Count);
foreach (var requestType in requestTypes)
{
var partitions = await BuildSeriesAsync(requestType, cancellationToken);
result.Add(new RequestTypeListSeriesDto(requestType.Id, partitions));
}
return Result.Ok<IReadOnlyList<RequestTypeListSeriesDto>>(result);
}
private async Task<IReadOnlyList<RequestListPartitionDto>> 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);
}
/// <summary>
@@ -99,7 +127,7 @@ public sealed class RequestTypeListService(
}
/// <summary>
/// Ставит задачу создания следующего списка, если порог достигнут. Возвращает
/// Ставит задачу создания следующей партиции, если порог достигнут. Возвращает
/// true, только если задача действительно поставлена.
/// </summary>
private async Task<bool> 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<CreateNextRequestTypeListJob>(
backgroundJobs.Enqueue<CreateNextListPartitionJob>(
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;
@@ -1,30 +1,31 @@
namespace Tnsu.ApprovalRoute.Application.RequestTypeLists;
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
/// <summary>Настройки нумерованных списков заявок (секция ListProvisioning в конфигурации).</summary>
public sealed class ListProvisioningOptions
/// <summary>Настройки нумерованных партиций списков заявок (секция ListPartitioning в конфигурации).</summary>
public sealed class ListPartitioningOptions
{
/// <summary>
/// Порог элементов в списке данных, по достижении которого ставится задача на
/// создание следующей пары списков.
/// создание следующей партиции (пары списков).
///
/// 5000 — не предел SharePoint на число элементов (их может быть миллионы), а
/// «list view threshold»: запросы, которым приходится просмотреть больше 5000
/// элементов без подходящего индекса, SharePoint отклоняет. Порог намеренно чуть
/// ниже, чтобы задача успела создать следующий список до того, как текущий
/// ниже, чтобы задача успела создать следующую партицию до того, как текущая
/// начнёт упираться.
/// </summary>
public int ItemCountThreshold { get; set; } = 4500;
/// <summary>
/// Через сколько блокировка провижининга считается брошенной и может быть
/// перехвачена (процесс упал, не сняв флаг). См. RequestType.ListProvisioningStartedAt.
/// перехвачена (процесс упал, не сняв флаг). См. RequestType.ListPartitioningStartedAt.
/// </summary>
public TimeSpan StaleLockAfter { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Проверять ли порог в эндпоинте «текущий список». false — эндпоинт только
/// отдаёт список, не обращаясь к SharePoint и никого не ставя в очередь
/// (например, пока провижинер не готов).
/// отдаёт список, не обращаясь к SharePoint за числом элементов и никого не
/// ставя в очередь. Полезно, если SharePoint временно недоступен или нужно
/// приостановить автосоздание партиций, не трогая остальную логику.
/// </summary>
public bool CheckThresholdOnRead { get; set; } = true;
}
@@ -1,30 +0,0 @@
namespace Tnsu.ApprovalRoute.Application.RequestTypeLists;
/// <summary>Один список серии — для админки (вся серия) и для ответа «текущий список».</summary>
/// <param name="IsCurrent">
/// true — в этот список идёт запись новых заявок; остальные серии — архив только для чтения.
/// </param>
public sealed record RequestTypeListDto(
int Number,
string SiteUrl,
string ListId,
string? Title,
bool IsCurrent,
DateTimeOffset CreatedAt);
/// <summary>Ответ «куда писать новую заявку».</summary>
/// <param name="ItemCount">
/// Сколько элементов сейчас в списке — по нему видно, насколько он близок к порогу.
/// null, если SharePoint не ответил (тогда условие создания следующего не проверялось).
/// </param>
/// <param name="ProvisioningQueued">
/// true — этим запросом поставлена в очередь задача создания следующего списка серии.
/// </param>
public sealed record CurrentRequestTypeListDto(
int RequestTypeId,
int Number,
string SiteUrl,
string ListId,
string? Title,
int? ItemCount = null,
bool ProvisioningQueued = false);