171 lines
9.8 KiB
C#
171 lines
9.8 KiB
C#
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;
|
||
}
|
||
}
|