джоба есть
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.RequestTypeLists;
|
||||
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<RequestTypeListRegistry>();
|
||||
services.AddScoped<RequestTypeListService>();
|
||||
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
||||
services.AddScoped<CreateNextRequestTypeListJob>();
|
||||
services.AddScoped<TemplateRouteService>();
|
||||
services.AddScoped<TemplateRouteStepService>();
|
||||
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
using Hangfire;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestTypeLists;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновая задача: создать следующий список серии по типу заявок, записать его в
|
||||
/// реестр и переключить на него запись.
|
||||
///
|
||||
/// Повторы разрешены, но их немного: провижинер идемпотентен (переиспользует список
|
||||
/// с целевым именем и дозаполняет колонки), поэтому повтор после частичного сбоя
|
||||
/// безопасен и не создаёт второй список. Больше трёх попыток смысла нет — если
|
||||
/// SharePoint отвечает ошибкой стабильно, задача должна остаться видимой в Hangfire,
|
||||
/// а не молотить впустую.
|
||||
/// </summary>
|
||||
[AutomaticRetry(Attempts = 3)]
|
||||
public sealed class CreateNextRequestTypeListJob(
|
||||
ApprovalRouteDbContext db,
|
||||
RequestTypeProvisioningLock provisioningLock,
|
||||
RequestTypeListRegistry registry,
|
||||
ISharePointListProvisioner provisioner,
|
||||
IOptions<ListProvisioningOptions> options,
|
||||
ILogger<CreateNextRequestTypeListJob> logger)
|
||||
{
|
||||
public async Task RunAsync(int requestTypeId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Единственная настоящая защита от параллельного запуска: атомарный захват
|
||||
// флага в строке 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;
|
||||
}
|
||||
|
||||
// Задачу могли поставить раньше, чем реестр успел узнать про текущий
|
||||
// список (например, ручным запуском) — регистрируем его, иначе серия
|
||||
// осталась бы без своего первого номера.
|
||||
var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
|
||||
|
||||
var maxNumber = await db.RequestTypeLists
|
||||
.Where(x => x.RequestTypeId == requestTypeId)
|
||||
.MaxAsync(x => (int?)x.Number, cancellationToken) ?? current.Number;
|
||||
|
||||
var nextNumber = maxNumber + 1;
|
||||
|
||||
logger.LogInformation(
|
||||
"Создаю список №{Number} для типа заявок {RequestTypeId} по структуре {SourceListId}.",
|
||||
nextNumber, requestTypeId, requestType.ListId);
|
||||
|
||||
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
|
||||
{
|
||||
RequestTypeId = requestTypeId,
|
||||
Number = nextNumber,
|
||||
SiteUrl = requestType.SiteUrl,
|
||||
ListId = created.ListId,
|
||||
Title = created.Title,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
requestType.ListId = created.ListId;
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Список №{Number} ({ListId}) создан, запись по типу заявок {RequestTypeId} переключена на него.",
|
||||
nextNumber, created.ListId, requestTypeId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Снимаем блокировку и на успехе, и на исключении: иначе упавшая задача
|
||||
// держала бы тип заблокированным до истечения StaleLockAfter.
|
||||
await provisioningLock.ReleaseAsync(requestTypeId, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestTypeLists;
|
||||
|
||||
/// <summary>Настройки нумерованных списков заявок (секция ListProvisioning в конфигурации).</summary>
|
||||
public sealed class ListProvisioningOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Порог элементов в списке данных, по достижении которого ставится задача на
|
||||
/// создание следующей пары списков.
|
||||
///
|
||||
/// 5000 — не предел SharePoint на число элементов (их может быть миллионы), а
|
||||
/// «list view threshold»: запросы, которым приходится просмотреть больше 5000
|
||||
/// элементов без подходящего индекса, SharePoint отклоняет. Порог намеренно чуть
|
||||
/// ниже, чтобы задача успела создать следующий список до того, как текущий
|
||||
/// начнёт упираться.
|
||||
/// </summary>
|
||||
public int ItemCountThreshold { get; set; } = 4500;
|
||||
|
||||
/// <summary>
|
||||
/// Через сколько блокировка провижининга считается брошенной и может быть
|
||||
/// перехвачена (процесс упал, не сняв флаг). См. RequestType.ListProvisioningStartedAt.
|
||||
/// </summary>
|
||||
public TimeSpan StaleLockAfter { get; set; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Проверять ли порог в эндпоинте «текущий список». false — эндпоинт только
|
||||
/// отдаёт список, не обращаясь к SharePoint и никого не ставя в очередь
|
||||
/// (например, пока провижинер не готов).
|
||||
/// </summary>
|
||||
public bool CheckThresholdOnRead { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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);
|
||||
@@ -0,0 +1,73 @@
|
||||
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.RequestTypeLists;
|
||||
|
||||
/// <summary>
|
||||
/// Наполнение реестра списков. Вынесено отдельно, потому что нужно и читающему
|
||||
/// эндпоинту, и фоновой задаче: реестр появился позже самих списков, и любой из них
|
||||
/// может встретить тип заявок, которого в реестре ещё нет.
|
||||
/// </summary>
|
||||
public sealed class RequestTypeListRegistry(
|
||||
ApprovalRouteDbContext db,
|
||||
ISharePointClient sharePoint,
|
||||
ILogger<RequestTypeListRegistry> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// Гарантирует, что текущий список типа заявок есть в реестре, и возвращает его строку.
|
||||
///
|
||||
/// Реестр самозаполняющийся: до этой фичи списки жили только в
|
||||
/// RequestType.ListId, и переключить список руками в админке можно и сейчас.
|
||||
/// Поэтому неизвестный реестру ListId дописывается следующим номером, а не
|
||||
/// считается ошибкой.
|
||||
///
|
||||
/// Требует заполненных ListId/SiteUrl — вызывающая сторона это проверяет.
|
||||
/// </summary>
|
||||
public async Task<RequestTypeList> EnsureCurrentRegisteredAsync(
|
||||
RequestType requestType, CancellationToken cancellationToken)
|
||||
{
|
||||
var series = await db.RequestTypeLists
|
||||
.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
|
||||
{
|
||||
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);
|
||||
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,131 @@
|
||||
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;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestTypeLists;
|
||||
|
||||
/// <summary>
|
||||
/// Реестр нумерованных списков по типу заявок: какой список текущий (куда писать) и
|
||||
/// вся серия целиком (архив + текущий) для админки.
|
||||
/// </summary>
|
||||
public sealed class RequestTypeListService(
|
||||
ApprovalRouteDbContext db,
|
||||
ISharePointClient sharePoint,
|
||||
RequestTypeListRegistry registry,
|
||||
IBackgroundJobClient backgroundJobs,
|
||||
IOptions<ListProvisioningOptions> options,
|
||||
ILogger<RequestTypeListService> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// Список, в который сейчас нужно писать новые заявки.
|
||||
///
|
||||
/// Побочный эффект: если в списке набрался порог элементов, ставит в очередь
|
||||
/// задачу создания следующего. Побочный эффект в GET — сознательный компромисс
|
||||
/// (так описано в ТЗ): вызывающая сторона всё равно спрашивает адрес перед каждой
|
||||
/// записью, и это единственный момент, когда мы точно знаем, что список живой.
|
||||
/// Повторная постановка безопасна — задача разруливает гонку захватом блокировки.
|
||||
/// </summary>
|
||||
public async Task<Result<CurrentRequestTypeListDto>> 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));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||
return Result.Fail<CurrentRequestTypeListDto>(new ValidationError(
|
||||
$"У типа заявки {requestTypeId} не настроен список SharePoint."));
|
||||
|
||||
var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Вся серия списков типа заявок, от первого к последнему — для админки.</summary>
|
||||
public async Task<Result<IReadOnlyList<RequestTypeListDto>>> 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));
|
||||
|
||||
// Тип без настроенного списка — пустая серия, а не ошибка: в админке такой
|
||||
// тип заявок вполне может существовать до привязки к SharePoint.
|
||||
if (!string.IsNullOrWhiteSpace(requestType.ListId) && !string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||
await registry.EnsureCurrentRegisteredAsync(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))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok<IReadOnlyList<RequestTypeListDto>>(items);
|
||||
}
|
||||
|
||||
/// <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.RequestTypeLists
|
||||
.AnyAsync(x => x.RequestTypeId == requestType.Id && x.Number > currentNumber, cancellationToken);
|
||||
if (hasNext) return false;
|
||||
|
||||
// Провижининг уже идёт. Проверка неатомарная и это нормально: она экономит
|
||||
// постановку лишней задачи, а настоящую гонку ловит захват блокировки внутри
|
||||
// самой задачи.
|
||||
if (requestType.ListProvisioningStartedAt is not null) return false;
|
||||
|
||||
backgroundJobs.Enqueue<CreateNextRequestTypeListJob>(
|
||||
job => job.RunAsync(requestType.Id, CancellationToken.None));
|
||||
|
||||
logger.LogInformation(
|
||||
"В списке {ListId} типа заявок {RequestTypeId} {ItemCount} элементов (порог {Threshold}) — " +
|
||||
"поставлена задача создания списка №{NextNumber}.",
|
||||
requestType.ListId, requestType.Id, itemCount, options.Value.ItemCountThreshold, currentNumber + 1);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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