Архивация списков (#13)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/13
This commit was merged in pull request #13.
This commit is contained in:
@@ -2,6 +2,7 @@ using System.Reflection;
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
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<ReminderService>();
|
||||
services.AddScoped<RequestTypeService>();
|
||||
services.AddScoped<ListPartitionRegistry>();
|
||||
services.AddScoped<ListPartitionService>();
|
||||
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
||||
services.AddScoped<CreateNextListPartitionJob>();
|
||||
services.AddScoped<TemplateRouteService>();
|
||||
services.AddScoped<TemplateRouteStepService>();
|
||||
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
using System.Diagnostics;
|
||||
using Hangfire;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновая задача: создать следующую партицию списка по типу заявок, записать её в
|
||||
/// реестр и переключить на неё запись.
|
||||
///
|
||||
/// Блокировка захватывается ЗДЕСЬ, при каждом запуске (в т.ч. на каждой повторной
|
||||
/// попытке Hangfire), а не в момент постановки в очередь. Enqueue и фактическое
|
||||
/// выполнение — разные события во времени: задача может полежать в очереди, быть
|
||||
/// повторена автоматически после сбоя, в теории — перезапущена вручную из дашборда.
|
||||
/// Всё это заново становится тем же самым «выполнением» с точки зрения Hangfire, но
|
||||
/// не с точки зрения блокировки: settled только выполнение может её удержать, и
|
||||
/// проверять это надо на каждом фактическом входе в код, создающий список в
|
||||
/// SharePoint, а не один раз на входе в очередь. Иначе, к примеру, зависший на
|
||||
/// повторе процесс и новый независимый триггер (например, второй HTTP-запрос,
|
||||
/// поймавший тот же порог до переключения списка) синхронизировались бы неверно.
|
||||
///
|
||||
/// Повторы разрешены, но их немного: провижинер идемпотентен (переиспользует список
|
||||
/// с целевым именем и дозаполняет колонки), поэтому повтор после частичного сбоя
|
||||
/// безопасен и не создаёт вторую партицию. Больше трёх попыток смысла нет — если
|
||||
/// SharePoint отвечает ошибкой стабильно, задача должна остаться видимой в Hangfire,
|
||||
/// а не молотить впустую.
|
||||
/// </summary>
|
||||
[AutomaticRetry(Attempts = 3)]
|
||||
public sealed class CreateNextListPartitionJob(
|
||||
ApprovalRouteDbContext db,
|
||||
ListPartitioningLock provisioningLock,
|
||||
ListPartitionRegistry registry,
|
||||
ISharePointListProvisioner provisioner,
|
||||
TnsuPortalCallerToken callerToken,
|
||||
IOptions<ListPartitioningOptions> options,
|
||||
ILogger<CreateNextListPartitionJob> logger)
|
||||
{
|
||||
/// <param name="authorization">
|
||||
/// Заголовок Authorization пользователя, чей запрос вызвал провижининг (см.
|
||||
/// ListPartitionService.TryEnqueueProvisioningAsync). Нужен, чтобы подписать
|
||||
/// новый список на уведомления от имени этого пользователя: своего HttpContext
|
||||
/// у фоновой задачи нет, а client_credentials сейчас не настроен (см.
|
||||
/// TnsuPortalAuthorizer). Провижининг занимает секунды, поэтому токен не
|
||||
/// успевает истечь — но на ПОВТОРЕ Hangfire (минуты спустя) он уже может быть
|
||||
/// просроченным: тогда подписка вернёт 401 и упадёт мягко, не сорвав создание
|
||||
/// списка (см. ISharePointWebhookSubscriber).
|
||||
///
|
||||
/// Учтите: как аргумент задачи он попадает в хранилище Hangfire (Postgres) и
|
||||
/// виден в его дашборде.
|
||||
/// </param>
|
||||
public async Task RunAsync(int requestTypeId, string? authorization, CancellationToken cancellationToken)
|
||||
{
|
||||
callerToken.Set(authorization);
|
||||
|
||||
// Единственная настоящая защита от параллельного запуска: атомарный захват
|
||||
// флага в строке 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;
|
||||
}
|
||||
|
||||
// Партиция, на которую сейчас указывает RequestType.ListId, прямо сейчас
|
||||
// перестаёт быть текущей — записываем её в архив (request_list_partitions
|
||||
// хранит только архив, см. ListPartitionRegistry). До этого момента она
|
||||
// нигде отдельной строкой не значилась.
|
||||
var archivedCurrent = await registry.ArchiveCurrentAsync(requestType, cancellationToken);
|
||||
|
||||
var nextNumber = archivedCurrent.Number + 1;
|
||||
|
||||
logger.LogInformation(
|
||||
"Создаю партицию №{Number} для типа заявок {RequestTypeId} ({RequestTypeName}) " +
|
||||
"по структуре {SourceListId}.",
|
||||
nextNumber, requestTypeId, requestType.Name, requestType.ListId);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Новая партиция строкой в request_list_partitions пока не появляется —
|
||||
// она получит свою запись только когда САМА станет архивом, следующим
|
||||
// вызовом этой же задачи (см. doc-комментарий класса ListPartitionRegistry).
|
||||
// Здесь переключаем только указатель.
|
||||
requestType.ListId = created.ListId;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Партиция №{Number} ({ListId}) создана за {ElapsedMs} мс, запись по типу заявок " +
|
||||
"{RequestTypeId} переключена на неё.",
|
||||
nextNumber, created.ListId, stopwatch.ElapsedMilliseconds, requestTypeId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Снимаем блокировку и на успехе, и на исключении: иначе упавшая задача
|
||||
// держала бы тип заблокированным до истечения StaleLockAfter.
|
||||
await provisioningLock.ReleaseAsync(requestTypeId, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,123 @@
|
||||
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.ListPartitioning;
|
||||
|
||||
/// <summary>
|
||||
/// Наполнение реестра партиций. Вынесено отдельно, потому что нужно и читающему
|
||||
/// эндпоинту, и фоновой задаче.
|
||||
///
|
||||
/// request_list_partitions хранит ТОЛЬКО архив — партиции, которые уже перестали
|
||||
/// быть текущими. Текущая партиция нигде отдельной строкой не пишется: она всегда
|
||||
/// синтезируется на лету из RequestType.ListId/SiteUrl (см. BuildCurrentAsync).
|
||||
/// Иначе один и тот же список оказывался бы записан дважды — и в RequestType.ListId,
|
||||
/// и строкой в этой таблице — сразу же, при первом же чтении, до того как партиция
|
||||
/// реально стала архивом.
|
||||
/// </summary>
|
||||
public sealed class ListPartitionRegistry(
|
||||
ApprovalRouteDbContext db,
|
||||
ISharePointClient sharePoint,
|
||||
ILogger<ListPartitionRegistry> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// Партиции, которые УЖЕ архив — всё, что реестр знает о серии, кроме текущей.
|
||||
///
|
||||
/// Фильтр по ListId != RequestType.ListId — не оптимизация, а самовосстановление
|
||||
/// на случай узкого окна в ArchiveCurrentAsync (см. её комментарий): если старая
|
||||
/// партиция уже успела записаться архивом, а переключение указателя на новую ещё
|
||||
/// не прошло (SharePoint упал между этими двумя шагами), без фильтра она была бы
|
||||
/// видна СРАЗУ в обоих качествах — и как архив, и как текущая (см.
|
||||
/// BuildCurrentAsync). Фильтр держит текущую ровно в одном месте, даже в этом
|
||||
/// переходном состоянии.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<RequestListPartition>> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Синтезирует запись о ТЕКУЩЕЙ партиции — без обращения к request_list_partitions
|
||||
/// на запись: строка появится там только когда эта партиция реально станет
|
||||
/// архивом (см. ArchiveCurrentAsync). Номер — следующий за последним архивным
|
||||
/// (или 1, если архива ещё нет); Title читается REST'ом каждый раз, а не
|
||||
/// кешируется — кешировать было бы негде без строки в БД.
|
||||
///
|
||||
/// archived — уже загруженный список архивных партиций этого типа (см.
|
||||
/// GetArchivedAsync), передаётся, а не читается заново, чтобы вызывающая
|
||||
/// сторона (которой archived обычно уже нужен для остальной серии) не делала
|
||||
/// один и тот же запрос дважды.
|
||||
/// </summary>
|
||||
public async Task<RequestListPartitionDto> BuildCurrentAsync(
|
||||
RequestType requestType, IReadOnlyList<RequestListPartition> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Записывает ТЕКУЩУЮ партицию как архивную строку — вызывать строго в момент,
|
||||
/// когда она перестаёт быть текущей (единственный вызывающий —
|
||||
/// CreateNextListPartitionJob, прямо перед тем как переключить
|
||||
/// RequestType.ListId на следующую партицию).
|
||||
///
|
||||
/// Читает свой собственный, НЕфильтрованный список существующих партиций —
|
||||
/// намеренно не переиспользует GetArchivedAsync(): та фильтрует по
|
||||
/// ListId != RequestType.ListId, и ровно между этим вызовом и переключением
|
||||
/// указателя (см. CreateNextListPartitionJob) только что записанная строка
|
||||
/// на мгновение совпадает с текущим ListId — фильтр GetArchivedAsync её бы не
|
||||
/// увидел, идемпотентность сломалась бы, и повтор Hangfire упал бы на
|
||||
/// уникальном индексе (request_type_id, number) вместо того, чтобы найти уже
|
||||
/// записанное и вернуть его.
|
||||
/// </summary>
|
||||
public async Task<RequestListPartition> ArchiveCurrentAsync(
|
||||
RequestType requestType, CancellationToken cancellationToken)
|
||||
{
|
||||
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 RequestListPartition
|
||||
{
|
||||
RequestTypeId = requestType.Id,
|
||||
Number = series.Count == 0 ? 1 : series.Max(x => x.Number) + 1,
|
||||
ListId = requestType.ListId!,
|
||||
Title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
db.RequestListPartitions.Add(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Список {ListId} типа заявок {RequestTypeId} заархивирован под номером {Number}.",
|
||||
entity.ListId, requestType.Id, entity.Number);
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
// Заголовок нужен только для отображения в админке/портале — из-за его
|
||||
// отсутствия чтение партиции падать не должно.
|
||||
private async Task<string?> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
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;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
|
||||
/// <summary>
|
||||
/// Реестр нумерованных партиций списка по типу заявок: какая партиция текущая (куда
|
||||
/// писать) и вся серия целиком (архив + текущая) для админки и портала.
|
||||
/// </summary>
|
||||
public sealed class ListPartitionService(
|
||||
ApprovalRouteDbContext db,
|
||||
ISharePointClient sharePoint,
|
||||
ListPartitionRegistry registry,
|
||||
IBackgroundJobClient backgroundJobs,
|
||||
TnsuPortalCallerToken callerToken,
|
||||
IOptions<ListPartitioningOptions> options,
|
||||
ILogger<ListPartitionService> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// Партиция, в которую сейчас нужно писать новые заявки.
|
||||
///
|
||||
/// Побочный эффект: если партиция набрала порог элементов, ставит в очередь
|
||||
/// задачу создания следующей. Побочный эффект в GET — сознательный компромисс
|
||||
/// (так описано в ТЗ): вызывающая сторона всё равно спрашивает адрес перед каждой
|
||||
/// записью, и это единственный момент, когда мы точно знаем, что список живой.
|
||||
/// Повторная постановка безопасна — задача разруливает гонку захватом блокировки.
|
||||
/// </summary>
|
||||
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<CurrentListPartitionDto>(new NotFoundError("Тип заявки", requestTypeId));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||
return Result.Fail<CurrentListPartitionDto>(new ValidationError(
|
||||
$"У типа заявки {requestTypeId} не настроен список SharePoint."));
|
||||
|
||||
// Синтезируется на лету, не пишется в 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);
|
||||
|
||||
return Result.Ok(new CurrentListPartitionDto(
|
||||
requestTypeId, current.Number, requestType.SiteUrl, current.ListId, current.Title, itemCount, queued));
|
||||
}
|
||||
|
||||
/// <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<RequestListPartitionDto>>(new NotFoundError("Тип заявки", requestTypeId));
|
||||
|
||||
return Result.Ok(await BuildSeriesAsync(requestType, cancellationToken));
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
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 [];
|
||||
|
||||
var archived = await registry.GetArchivedAsync(requestType, cancellationToken);
|
||||
var current = await registry.BuildCurrentAsync(requestType, archived, cancellationToken);
|
||||
|
||||
var result = new List<RequestListPartitionDto>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Число элементов в списке. Ошибку SharePoint глушим: эндпоинт обязан отдать
|
||||
/// адрес списка даже когда SharePoint недоступен, иначе портал не сможет
|
||||
/// принять заявку из-за сбоя необязательной проверки порога.
|
||||
/// </summary>
|
||||
private async Task<int?> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ставит задачу создания следующей партиции, если порог достигнут. Возвращает
|
||||
/// true, только если задача действительно поставлена.
|
||||
/// </summary>
|
||||
private async Task<bool> TryEnqueueProvisioningAsync(
|
||||
RequestType requestType, int currentNumber, int? itemCount, CancellationToken cancellationToken)
|
||||
{
|
||||
if (itemCount is null || itemCount < options.Value.ItemCountThreshold) return false;
|
||||
|
||||
// Следующая партиция уже создана, а запись на неё ещё не переключили —
|
||||
// задача не нужна (и переключать здесь нельзя: этим владеет провижининг).
|
||||
var hasNext = await db.RequestListPartitions
|
||||
.AnyAsync(x => x.RequestTypeId == requestType.Id && x.Number > currentNumber, cancellationToken);
|
||||
if (hasNext) return false;
|
||||
|
||||
// Провижининг уже идёт. Проверка неатомарная и это нормально: она экономит
|
||||
// постановку лишней задачи, а настоящую гонку ловит захват блокировки внутри
|
||||
// самой задачи (см. CreateNextListPartitionJob).
|
||||
if (requestType.ListPartitioningStartedAt is not null) return false;
|
||||
|
||||
// Токен ЭТОГО запроса уезжает в задачу: подписать новый список на
|
||||
// уведомления нужно от имени пользователя, а своего токена у фоновой задачи
|
||||
// нет (см. CreateNextListPartitionJob.RunAsync — там же про его хранение и
|
||||
// срок жизни).
|
||||
backgroundJobs.Enqueue<CreateNextListPartitionJob>(
|
||||
job => job.RunAsync(requestType.Id, callerToken.Current, CancellationToken.None));
|
||||
|
||||
logger.LogInformation(
|
||||
"В списке {ListId} типа заявок {RequestTypeId} {ItemCount} элементов (порог {Threshold}) — " +
|
||||
"поставлена задача создания партиции №{NextNumber}.",
|
||||
requestType.ListId, requestType.Id, itemCount, options.Value.ItemCountThreshold, currentNumber + 1);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
|
||||
/// <summary>Настройки нумерованных партиций списков заявок (секция ListPartitioning в конфигурации).</summary>
|
||||
public sealed class ListPartitioningOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Порог элементов в списке данных, по достижении которого ставится задача на
|
||||
/// создание следующей партиции (пары списков).
|
||||
///
|
||||
/// 5000 — не предел SharePoint на число элементов (их может быть миллионы), а
|
||||
/// «list view threshold»: запросы, которым приходится просмотреть больше 5000
|
||||
/// элементов без подходящего индекса, SharePoint отклоняет. Порог намеренно чуть
|
||||
/// ниже, чтобы задача успела создать следующую партицию до того, как текущая
|
||||
/// начнёт упираться.
|
||||
/// </summary>
|
||||
public int ItemCountThreshold { get; set; } = 4500;
|
||||
|
||||
/// <summary>
|
||||
/// Через сколько блокировка провижининга считается брошенной и может быть
|
||||
/// перехвачена (процесс упал, не сняв флаг). См. RequestType.ListPartitioningStartedAt.
|
||||
/// </summary>
|
||||
public TimeSpan StaleLockAfter { get; set; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Проверять ли порог в эндпоинте «текущий список». false — эндпоинт только
|
||||
/// отдаёт список, не обращаясь к SharePoint за числом элементов и никого не
|
||||
/// ставя в очередь. Полезно, если SharePoint временно недоступен или нужно
|
||||
/// приостановить автосоздание партиций, не трогая остальную логику.
|
||||
/// </summary>
|
||||
public bool CheckThresholdOnRead { get; set; } = true;
|
||||
}
|
||||
@@ -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<RemindersOptions> options,
|
||||
ILogger<ReminderService> logger)
|
||||
{
|
||||
@@ -62,14 +64,60 @@ public sealed class ReminderService(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обходит ВСЕ партиции типа заявки (архив + текущая) — заявка, уехавшая в
|
||||
/// архив, продолжает ждать чьего-то решения точно так же, как в текущем
|
||||
/// списке, и не должна пропадать из напоминаний только из-за того, что для
|
||||
/// новых заявок завели следующий список.
|
||||
///
|
||||
/// request_list_partitions хранит только архив (см. ListPartitionRegistry) —
|
||||
/// текущая партиция синтезируется отдельно и добавляется к архиву здесь же,
|
||||
/// а не читается из БД.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeAsync(
|
||||
RequestType type, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
||||
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
||||
|
||||
var partitions = archived
|
||||
.Select(x => (x.ListId, x.Number))
|
||||
.Append((current.ListId, current.Number));
|
||||
|
||||
var reminders = new List<ReminderDto>();
|
||||
foreach (var (listId, number) in partitions)
|
||||
{
|
||||
try
|
||||
{
|
||||
reminders.AddRange(await GetOverdueForPartitionAsync(type, listId, number, now, cancellationToken));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Недоступная партиция (например, архивный список переименовали
|
||||
// или удалили вручную) не должна обнулять напоминания по
|
||||
// остальным партициям того же типа.
|
||||
logger.LogError(
|
||||
e, "Напоминания по списку {ListId} (партиция №{Number}) типа «{Type}» не собраны",
|
||||
listId, number, type.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return reminders;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForPartitionAsync(
|
||||
RequestType type, string listId, int number, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
var siteUrl = type.SiteUrl!;
|
||||
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, type.ListId!, cancellationToken);
|
||||
|
||||
// Читаем Title ЖИВЬЁМ — заголовок могли переименовать вручную, а по url
|
||||
// список найдётся всегда.
|
||||
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(dataListTitle))
|
||||
{
|
||||
logger.LogWarning("Список данных типа «{Type}» не найден по url {Url}", type.Name, type.ListId);
|
||||
logger.LogWarning(
|
||||
"Список данных партиции №{Number} типа «{Type}» не найден по url {Url}",
|
||||
number, type.Name, listId);
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RequestTypeService> logger)
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<RequestTypeDto>>> 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();
|
||||
}
|
||||
|
||||
+12
-1
@@ -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<TemplateRouteStepService> logger)
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> 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();
|
||||
}
|
||||
|
||||
@@ -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<TnsuPortalOptions> portalOptions)
|
||||
IOptions<TnsuPortalOptions> portalOptions,
|
||||
ILogger<TemplateRouteService> logger)
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> 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();
|
||||
}
|
||||
@@ -261,9 +267,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))
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<PackageReference Include="FluentResults" />
|
||||
<PackageReference Include="FluentValidation" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
||||
<PackageReference Include="Hangfire.Core" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user