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; /// /// Реестр нумерованных партиций списка по типу заявок: какая партиция текущая (куда /// писать) и вся серия целиком (архив + текущая) для админки и портала. /// public sealed class ListPartitionService( ApprovalRouteDbContext db, ISharePointClient sharePoint, ListPartitionRegistry registry, IBackgroundJobClient backgroundJobs, TnsuPortalCallerToken callerToken, IOptions options, ILogger logger) { /// /// Партиция, в которую сейчас нужно писать новые заявки. /// /// Побочный эффект: если партиция набрала порог элементов, ставит в очередь /// задачу создания следующей. Побочный эффект в GET — сознательный компромисс /// (так описано в ТЗ): вызывающая сторона всё равно спрашивает адрес перед каждой /// записью, и это единственный момент, когда мы точно знаем, что список живой. /// Повторная постановка безопасна — задача разруливает гонку захватом блокировки. /// public async Task> GetCurrentAsync( int requestTypeId, CancellationToken cancellationToken) { var requestType = await db.RequestTypes .FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken); if (requestType is null) return Result.Fail(new NotFoundError("Тип заявки", requestTypeId)); if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl)) return Result.Fail(new ValidationError( $"У типа заявки {requestTypeId} не настроен список SharePoint.")); // Синтезируется на лету, не пишется в 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)); } /// Вся серия партиций типа заявок, от первой к последней — для админки. 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.Ok(await BuildSeriesAsync(requestType, cancellationToken)); } /// /// Серии всех типов заявок разом — для портала: чтобы искать по заявкам сразу во /// всех партициях (архив + текущая) любого типа, порталу нужен весь список /// адресов один раз, а не по одному запросу на тип. Типы без настроенного /// списка SharePoint в ответ не попадают — искать там всё равно нечего. /// /// /// Необязательный фильтр по заголовку партиции (Title списка SharePoint), /// без учёта регистра, по вхождению подстроки. null — фильтр не применяется. /// public async Task>> ListAllAsync( string? title, CancellationToken cancellationToken) { var requestTypes = await db.RequestTypes .Where(x => x.ListId != null && x.SiteUrl != null) .ToListAsync(cancellationToken); var result = new List(requestTypes.Count); foreach (var requestType in requestTypes) { var partitions = await BuildSeriesAsync(requestType, cancellationToken); if (!string.IsNullOrWhiteSpace(title)) partitions = partitions .Where(x => x.Title is not null && x.Title.Contains(title, StringComparison.OrdinalIgnoreCase)) .ToList(); if (partitions.Count == 0) continue; 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 []; var archived = await registry.GetArchivedAsync(requestType, cancellationToken); var current = await registry.BuildCurrentAsync(requestType, archived, cancellationToken); var result = new List(archived.Count + 1); result.AddRange(archived.Select(x => new RequestListPartitionDto(x.Number, requestType.SiteUrl!, x.ListId, x.Title, IsCurrent: false, x.CreatedAt))); result.Add(current); return result; } /// /// Число элементов в списке. Ошибку SharePoint глушим: эндпоинт обязан отдать /// адрес списка даже когда SharePoint недоступен, иначе портал не сможет /// принять заявку из-за сбоя необязательной проверки порога. /// private async Task TryGetItemCountAsync(string siteUrl, string listId, CancellationToken cancellationToken) { if (!options.Value.CheckThresholdOnRead) return null; try { return await sharePoint.GetItemCountAsync(siteUrl, listId, cancellationToken); } catch (Exception ex) { logger.LogWarning(ex, "Не удалось получить число элементов списка {ListId} — порог не проверен.", listId); return null; } } /// /// Ставит задачу создания следующей партиции, если порог достигнут. Возвращает /// true, только если задача действительно поставлена. /// private async Task TryEnqueueProvisioningAsync( RequestType requestType, int currentNumber, int? itemCount, CancellationToken cancellationToken) { if (itemCount is null || itemCount < options.Value.ItemCountThreshold) return false; // Следующая партиция уже создана, а запись на неё ещё не переключили — // задача не нужна (и переключать здесь нельзя: этим владеет провижининг). var hasNext = await db.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( 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; } }