джоба есть
This commit is contained in:
@@ -1,6 +1,12 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
<!--
|
||||||
|
Транзитивные зависимости тоже подчиняются версиям ниже. Нужно из-за Hangfire:
|
||||||
|
он тянет Newtonsoft.Json 11.0.1 с известной уязвимостью, и без пиннинга её
|
||||||
|
не поднять, не добавляя пакет в проекты, которым он сам по себе не нужен.
|
||||||
|
-->
|
||||||
|
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Azure.Identity" Version="1.14.0" />
|
<PackageVersion Include="Azure.Identity" Version="1.14.0" />
|
||||||
@@ -9,6 +15,9 @@
|
|||||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||||
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
||||||
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||||
|
<PackageVersion Include="Hangfire.AspNetCore" Version="1.8.24" />
|
||||||
|
<PackageVersion Include="Hangfire.Core" Version="1.8.24" />
|
||||||
|
<PackageVersion Include="Hangfire.PostgreSql" Version="1.21.1" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
||||||
@@ -18,6 +27,8 @@
|
|||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||||
|
<!-- Только чтобы поднять транзитивную версию из Hangfire.Core — напрямую не используется. -->
|
||||||
|
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
||||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypeLists;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Нумерованная серия SharePoint-списков по типу заявок: куда писать сейчас и вся
|
||||||
|
/// серия для админки.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/request-types/{requestTypeId:int}/lists")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class RequestTypeListsController(RequestTypeListService service) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>Вся серия списков типа заявок (архив + текущий), от первого к последнему.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeListDto>>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> List(int requestTypeId, CancellationToken ct) =>
|
||||||
|
(await service.ListAsync(requestTypeId, ct)).ToActionResult();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Список, в который нужно писать новые заявки этого типа.
|
||||||
|
///
|
||||||
|
/// Не чистый GET: если список добрал порог элементов, запрос ставит в очередь
|
||||||
|
/// фоновую задачу создания следующего (см. RequestTypeListService.GetCurrentAsync).
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("current")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<CurrentRequestTypeListDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Current(int requestTypeId, CancellationToken ct) =>
|
||||||
|
(await service.GetCurrentAsync(requestTypeId, ct)).ToActionResult();
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Extensions.Hosting.AsyncInitialization;
|
using Extensions.Hosting.AsyncInitialization;
|
||||||
|
using Hangfire;
|
||||||
|
using Hangfire.PostgreSql;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -10,6 +12,7 @@ using Tnsu.ApprovalRoute.Api.Auth;
|
|||||||
using Tnsu.ApprovalRoute.Api.Filters;
|
using Tnsu.ApprovalRoute.Api.Filters;
|
||||||
using Tnsu.ApprovalRoute.Application;
|
using Tnsu.ApprovalRoute.Application;
|
||||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypeLists;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure;
|
using Tnsu.ApprovalRoute.Infrastructure;
|
||||||
|
|
||||||
@@ -37,6 +40,20 @@ builder.Services.AddCors(options =>
|
|||||||
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
||||||
builder.Services.AddApprovalRouteApplication();
|
builder.Services.AddApprovalRouteApplication();
|
||||||
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
|
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
|
||||||
|
builder.Services.Configure<ListProvisioningOptions>(builder.Configuration.GetSection("ListProvisioning"));
|
||||||
|
|
||||||
|
// Hangfire на том же Postgres, что и остальные данные. Свои таблицы он создаёт сам
|
||||||
|
// (схема hangfire) при старте — отдельной EF-миграции для них не нужно.
|
||||||
|
builder.Services.AddHangfire(configuration => configuration
|
||||||
|
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||||
|
.UseSimpleAssemblyNameTypeSerializer()
|
||||||
|
.UseRecommendedSerializerSettings()
|
||||||
|
.UsePostgreSqlStorage(postgres =>
|
||||||
|
postgres.UseNpgsqlConnection(builder.Configuration.GetConnectionString("Postgres"))));
|
||||||
|
|
||||||
|
// Воркер живёт в процессе API: задач мало (создание списка — редкое событие), и
|
||||||
|
// отдельный хост под них был бы лишней инфраструктурой.
|
||||||
|
builder.Services.AddHangfireServer();
|
||||||
builder.Services.AddApprovalRouteAuth(builder.Configuration);
|
builder.Services.AddApprovalRouteAuth(builder.Configuration);
|
||||||
builder.Services.AddScoped<ICurrentUser, CurrentUserAccessor>();
|
builder.Services.AddScoped<ICurrentUser, CurrentUserAccessor>();
|
||||||
builder.Services.AddOpenApi(options =>
|
builder.Services.AddOpenApi(options =>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
||||||
|
<PackageReference Include="Hangfire.AspNetCore" />
|
||||||
|
<PackageReference Include="Hangfire.PostgreSql" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||||
|
|||||||
@@ -19,6 +19,11 @@
|
|||||||
"https://localhost:4321"
|
"https://localhost:4321"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ListProvisioning": {
|
||||||
|
"ItemCountThreshold": 4500,
|
||||||
|
"StaleLockAfter": "00:30:00",
|
||||||
|
"CheckThresholdOnRead": true
|
||||||
|
},
|
||||||
"SharePoint": {
|
"SharePoint": {
|
||||||
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
||||||
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
|
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Reflection;
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypeLists;
|
||||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
@@ -14,6 +15,10 @@ public static class ApplicationServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
services.AddScoped<ReminderService>();
|
services.AddScoped<ReminderService>();
|
||||||
services.AddScoped<RequestTypeService>();
|
services.AddScoped<RequestTypeService>();
|
||||||
|
services.AddScoped<RequestTypeListRegistry>();
|
||||||
|
services.AddScoped<RequestTypeListService>();
|
||||||
|
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
||||||
|
services.AddScoped<CreateNextRequestTypeListJob>();
|
||||||
services.AddScoped<TemplateRouteService>();
|
services.AddScoped<TemplateRouteService>();
|
||||||
services.AddScoped<TemplateRouteStepService>();
|
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="FluentResults" />
|
||||||
<PackageReference Include="FluentValidation" />
|
<PackageReference Include="FluentValidation" />
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
||||||
|
<PackageReference Include="Hangfire.Core" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -25,5 +25,23 @@ public class RequestType
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool? AskOverdueReason { get; set; }
|
public bool? AskOverdueReason { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Момент, когда фоновая задача создания следующего списка взяла эту строку в
|
||||||
|
/// работу. null — задача не выполняется.
|
||||||
|
///
|
||||||
|
/// Одновременно и признак «идёт провижининг», и БЛОКИРОВКА: задача стартует
|
||||||
|
/// только если сумела перевести поле из null в now() одним условным UPDATE
|
||||||
|
/// (см. RequestTypeProvisioningLock) — тогда две параллельные задачи по одному
|
||||||
|
/// типу заявок невозможны.
|
||||||
|
///
|
||||||
|
/// Метка времени, а не bool, намеренно: упавший процесс не успевает снять
|
||||||
|
/// флаг, и bool заблокировал бы тип навсегда, а по метке видно, что блокировка
|
||||||
|
/// просрочена, и её можно перехватить.
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset? ListProvisioningStartedAt { get; set; }
|
||||||
|
|
||||||
public ICollection<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>();
|
public ICollection<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>();
|
||||||
|
|
||||||
|
/// <summary>Нумерованная серия списков этого типа заявок (архив + текущий).</summary>
|
||||||
|
public ICollection<RequestTypeList> Lists { get; set; } = new List<RequestTypeList>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Один SharePoint-список данных из нумерованной серии по типу заявок.
|
||||||
|
///
|
||||||
|
/// Списки заявок нумерованные («<Форма> <N> — данные», см. провижинер
|
||||||
|
/// request-portal): когда в текущем списке набирается порог элементов, рядом
|
||||||
|
/// создаётся следующая пара, а прежняя остаётся архивом только для чтения. Эта
|
||||||
|
/// таблица — реестр всей серии: без неё поиск по заявкам типа не знает, по каким
|
||||||
|
/// спискам искать, а провижинер — какой номер выдавать следующим.
|
||||||
|
///
|
||||||
|
/// Текущий (тот, куда идёт запись) список продолжает лежать в
|
||||||
|
/// <see cref="RequestType.ListId"/> — реестр его дублирует строкой с наибольшим
|
||||||
|
/// <see cref="Number"/>, но источником правды для записи остаётся RequestType.
|
||||||
|
/// </summary>
|
||||||
|
public class RequestTypeList
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int RequestTypeId { get; set; }
|
||||||
|
public RequestType? RequestType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Номер списка в серии, с 1. Уникален в пределах типа заявок.</summary>
|
||||||
|
public int Number { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Сайт SharePoint, где живёт список (серия может переезжать между сайтами).</summary>
|
||||||
|
public string SiteUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Серверный относительный url списка — та же семантика, что у
|
||||||
|
/// <see cref="RequestType.ListId"/> (несмотря на имя, это url, а не GUID).
|
||||||
|
/// </summary>
|
||||||
|
public string ListId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Title списка на момент создания — для админки, чтобы показать серию без
|
||||||
|
/// похода в SharePoint за каждым заголовком. Может разойтись с фактическим,
|
||||||
|
/// если список переименовали вручную.
|
||||||
|
/// </summary>
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -20,12 +20,14 @@ public static class InfrastructureServiceCollectionExtensions
|
|||||||
opt.UseNpgsql(configuration.GetConnectionString("Postgres"))
|
opt.UseNpgsql(configuration.GetConnectionString("Postgres"))
|
||||||
.UseSnakeCaseNamingConvention());
|
.UseSnakeCaseNamingConvention());
|
||||||
services.AddAsyncInitializer<DatabaseMigrationInitializer>();
|
services.AddAsyncInitializer<DatabaseMigrationInitializer>();
|
||||||
|
services.AddScoped<RequestTypeProvisioningLock>();
|
||||||
|
|
||||||
services.Configure<SharePointOptions>(configuration.GetSection("SharePoint"));
|
services.Configure<SharePointOptions>(configuration.GetSection("SharePoint"));
|
||||||
// Провайдер сертификата — синглтон: разобранный .pfx кешируется на всё
|
// Провайдер сертификата — синглтон: разобранный .pfx кешируется на всё
|
||||||
// время жизни процесса (см. DbCertificateProvider).
|
// время жизни процесса (см. DbCertificateProvider).
|
||||||
services.AddSingleton<ICertificateProvider, DbCertificateProvider>();
|
services.AddSingleton<ICertificateProvider, DbCertificateProvider>();
|
||||||
services.AddHttpClient<ISharePointClient, SharePointClient>();
|
services.AddHttpClient<ISharePointClient, SharePointClient>();
|
||||||
|
services.AddScoped<ISharePointListProvisioner, SharePointListProvisioner>();
|
||||||
|
|
||||||
services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal"));
|
services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal"));
|
||||||
services.AddHttpClient<TnsuPortalTokenProvider>();
|
services.AddHttpClient<TnsuPortalTokenProvider>();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|||||||
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
|
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
|
||||||
{
|
{
|
||||||
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
||||||
|
public DbSet<RequestTypeList> RequestTypeLists => Set<RequestTypeList>();
|
||||||
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
|
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
|
||||||
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
|
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
|
||||||
public DbSet<AppSetting> Settings => Set<AppSetting>();
|
public DbSet<AppSetting> Settings => Set<AppSetting>();
|
||||||
@@ -33,6 +34,19 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
|
|||||||
e.Property(x => x.Section).HasMaxLength(256).IsRequired();
|
e.Property(x => x.Section).HasMaxLength(256).IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<RequestTypeList>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.Property(x => x.SiteUrl).HasMaxLength(512).IsRequired();
|
||||||
|
e.Property(x => x.ListId).HasMaxLength(512).IsRequired();
|
||||||
|
e.Property(x => x.Title).HasMaxLength(512);
|
||||||
|
e.HasOne(x => x.RequestType).WithMany(x => x.Lists).HasForeignKey(x => x.RequestTypeId);
|
||||||
|
// Номер уникален в пределах типа заявок — вторая попытка записать тот же
|
||||||
|
// номер (например, при двойном запуске задачи) упадёт на этом индексе,
|
||||||
|
// а не создаст в реестре дубль.
|
||||||
|
e.HasIndex(x => new { x.RequestTypeId, x.Number }).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<TemplateRoute>(e =>
|
modelBuilder.Entity<TemplateRoute>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
|
|||||||
+320
@@ -0,0 +1,320 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApprovalRouteDbContext))]
|
||||||
|
[Migration("20260817084927_AddRequestTypeListsRegistry")]
|
||||||
|
partial class AddRequestTypeListsRegistry
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.10")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool?>("AskOverdueReason")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("ask_overdue_reason");
|
||||||
|
|
||||||
|
b.Property<string>("ListId")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ListProvisioningStartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("list_provisioning_started_at");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<string>("PageUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("page_url");
|
||||||
|
|
||||||
|
b.Property<string>("Section")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("section");
|
||||||
|
|
||||||
|
b.Property<string>("SiteUrl")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("site_url");
|
||||||
|
|
||||||
|
b.Property<int?>("SlaHours")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("sla_hours");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_request_types");
|
||||||
|
|
||||||
|
b.ToTable("request_types", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("ListId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
|
b.Property<int>("Number")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("number");
|
||||||
|
|
||||||
|
b.Property<int>("RequestTypeId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("request_type_id");
|
||||||
|
|
||||||
|
b.Property<string>("SiteUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("site_url");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("title");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_request_type_lists");
|
||||||
|
|
||||||
|
b.HasIndex("RequestTypeId", "Number")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_request_type_lists_request_type_id_number");
|
||||||
|
|
||||||
|
b.ToTable("request_type_lists", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("ByProject")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("by_project");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CompanyId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("company_id");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DepartmentId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("department_id");
|
||||||
|
|
||||||
|
b.Property<int>("RequestTypeId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("request_type_id");
|
||||||
|
|
||||||
|
b.Property<bool>("RequiresControl")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("requires_control");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_template_routes");
|
||||||
|
|
||||||
|
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
|
||||||
|
|
||||||
|
b.ToTable("template_routes", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("AllowReassignToOtherApprovers")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("allow_reassign_to_other_approvers");
|
||||||
|
|
||||||
|
b.Property<bool?>("AskOverdueReason")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("ask_overdue_reason");
|
||||||
|
|
||||||
|
b.Property<string>("ExecutorEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("executor_email");
|
||||||
|
|
||||||
|
b.Property<string>("FieldName")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("field_name");
|
||||||
|
|
||||||
|
b.Property<string>("HeadType")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("head_type");
|
||||||
|
|
||||||
|
b.Property<string>("Operation")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("operation");
|
||||||
|
|
||||||
|
b.Property<int>("OrderNo")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("order_no");
|
||||||
|
|
||||||
|
b.Property<string>("Position")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("position");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectRole")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("project_role");
|
||||||
|
|
||||||
|
b.Property<int>("RouteId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("route_id");
|
||||||
|
|
||||||
|
b.Property<int?>("SlaHours")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("sla_hours");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("value");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_template_route_steps");
|
||||||
|
|
||||||
|
b.HasIndex("RouteId", "OrderNo")
|
||||||
|
.HasDatabaseName("ix_template_route_steps_route_id_order_no");
|
||||||
|
|
||||||
|
b.ToTable("template_route_steps", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("OptionName")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("option_name");
|
||||||
|
|
||||||
|
b.Property<byte[]>("FileData")
|
||||||
|
.HasColumnType("bytea")
|
||||||
|
.HasColumnName("file_data");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("value");
|
||||||
|
|
||||||
|
b.HasKey("OptionName")
|
||||||
|
.HasName("pk_settings");
|
||||||
|
|
||||||
|
b.ToTable("settings", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
|
.WithMany("Lists")
|
||||||
|
.HasForeignKey("RequestTypeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_request_type_lists_request_types_request_type_id");
|
||||||
|
|
||||||
|
b.Navigation("RequestType");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
|
.WithMany("Routes")
|
||||||
|
.HasForeignKey("RequestTypeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_template_routes_request_types_request_type_id");
|
||||||
|
|
||||||
|
b.Navigation("RequestType");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
|
||||||
|
.WithMany("Steps")
|
||||||
|
.HasForeignKey("RouteId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
|
||||||
|
|
||||||
|
b.Navigation("Route");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Lists");
|
||||||
|
|
||||||
|
b.Navigation("Routes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Steps");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddRequestTypeListsRegistry : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "list_provisioning_started_at",
|
||||||
|
table: "request_types",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "request_type_lists",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
request_type_id = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
number = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
site_url = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||||
|
list_id = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||||
|
title = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_request_type_lists", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_request_type_lists_request_types_request_type_id",
|
||||||
|
column: x => x.request_type_id,
|
||||||
|
principalTable: "request_types",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_request_type_lists_request_type_id_number",
|
||||||
|
table: "request_type_lists",
|
||||||
|
columns: new[] { "request_type_id", "number" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "request_type_lists");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "list_provisioning_started_at",
|
||||||
|
table: "request_types");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -40,6 +40,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("character varying(512)")
|
.HasColumnType("character varying(512)")
|
||||||
.HasColumnName("list_id");
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ListProvisioningStartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("list_provisioning_started_at");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
@@ -73,6 +77,54 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("request_types", (string)null);
|
b.ToTable("request_types", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("ListId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
|
b.Property<int>("Number")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("number");
|
||||||
|
|
||||||
|
b.Property<int>("RequestTypeId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("request_type_id");
|
||||||
|
|
||||||
|
b.Property<string>("SiteUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("site_url");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("title");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_request_type_lists");
|
||||||
|
|
||||||
|
b.HasIndex("RequestTypeId", "Number")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_request_type_lists_request_type_id_number");
|
||||||
|
|
||||||
|
b.ToTable("request_type_lists", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -212,6 +264,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("settings", (string)null);
|
b.ToTable("settings", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeList", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
|
.WithMany("Lists")
|
||||||
|
.HasForeignKey("RequestTypeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_request_type_lists_request_types_request_type_id");
|
||||||
|
|
||||||
|
b.Navigation("RequestType");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
@@ -238,6 +302,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("Lists");
|
||||||
|
|
||||||
b.Navigation("Routes");
|
b.Navigation("Routes");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Блокировка «по типу заявок идёт создание следующего списка» на строке
|
||||||
|
/// request_types.
|
||||||
|
///
|
||||||
|
/// Почему сырой SQL, а не обычный EF: через change tracker это было бы
|
||||||
|
/// read-modify-write (прочитали флаг → проверили → записали), и две задачи,
|
||||||
|
/// прочитавшие null одновременно, обе решили бы, что блокировка свободна.
|
||||||
|
/// Здесь проверка и захват — ОДИН условный UPDATE, атомарный на уровне строки:
|
||||||
|
/// Postgres берёт row lock на время оператора, поэтому ровно одна из
|
||||||
|
/// конкурирующих задач увидит affected rows = 1, остальные — 0.
|
||||||
|
///
|
||||||
|
/// Никакого pg_advisory_lock: он живёт в рамках сессии, а нам нужно, чтобы
|
||||||
|
/// блокировку было видно в самой строке (админке — что провижининг идёт) и чтобы
|
||||||
|
/// она переживала возврат соединения в пул между шагами задачи.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RequestTypeProvisioningLock(ApprovalRouteDbContext db)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Попытаться захватить блокировку. true — захватили (и только тогда можно
|
||||||
|
/// провижинить), false — держит кто-то другой либо типа заявок нет.
|
||||||
|
///
|
||||||
|
/// Блокировка старше staleAfter считается брошенной (процесс упал, не сняв
|
||||||
|
/// флаг) и перехватывается.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> TryAcquireAsync(int requestTypeId, TimeSpan staleAfter, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Имена таблицы/колонки — snake_case из UseSnakeCaseNamingConvention
|
||||||
|
// (см. InfrastructureServiceCollectionExtensions).
|
||||||
|
var affected = await db.Database.ExecuteSqlInterpolatedAsync(
|
||||||
|
$"""
|
||||||
|
UPDATE request_types
|
||||||
|
SET list_provisioning_started_at = now()
|
||||||
|
WHERE id = {requestTypeId}
|
||||||
|
AND (list_provisioning_started_at IS NULL
|
||||||
|
OR list_provisioning_started_at < now() - CAST({staleAfter} AS interval))
|
||||||
|
""",
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return affected == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Снять блокировку. Идемпотентно: снятая блокировка снимается повторно без ошибки.</summary>
|
||||||
|
public Task ReleaseAsync(int requestTypeId, CancellationToken cancellationToken) =>
|
||||||
|
db.Database.ExecuteSqlInterpolatedAsync(
|
||||||
|
$"UPDATE request_types SET list_provisioning_started_at = NULL WHERE id = {requestTypeId}",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
|
||||||
|
/// <summary>Созданный список: серверный относительный url (он же ListId) и Title.</summary>
|
||||||
|
public sealed record ProvisionedList(string ListId, string Title);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Создание следующей пары списков серии в SharePoint («<Форма> <N> — данные»
|
||||||
|
/// + «<Форма> <N> — согласование») дублированием текущей пары.
|
||||||
|
///
|
||||||
|
/// Интерфейс живёт в Infrastructure, а не в Application, потому что Application уже
|
||||||
|
/// ссылается на Infrastructure (так же устроен <see cref="ISharePointClient"/>) —
|
||||||
|
/// обратная ссылка замкнула бы проекты в кольцо.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISharePointListProvisioner
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Создать пару списков с номером <paramref name="number"/>, скопировав структуру
|
||||||
|
/// со списка <paramref name="sourceDataListId"/>. Возвращает созданный список ДАННЫХ
|
||||||
|
/// (список согласования создаётся тем же вызовом, но наружу не отдаётся — его
|
||||||
|
/// находят по конвенции имени).
|
||||||
|
/// </summary>
|
||||||
|
Task<ProvisionedList> CreateNextListPairAsync(
|
||||||
|
string siteUrl,
|
||||||
|
string sourceDataListId,
|
||||||
|
int number,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Конвенция имён списков заявок: «<Форма> <N> — данные» и парный ему
|
||||||
|
/// «<Форма> <N> — согласование».
|
||||||
|
///
|
||||||
|
/// Третья копия одного и того же соглашения — оно же в провижинере request-portal
|
||||||
|
/// (provisioning/Provision-Lists.ps1, Get-NumberedListTitle) и в самом портале
|
||||||
|
/// (shared/listTitles.ts). Держать в одном месте не получается: три разных runtime.
|
||||||
|
/// Меняя здесь, менять и там.
|
||||||
|
///
|
||||||
|
/// Номер стоит ПЕРЕД суффиксом, потому что имя списка согласования и портал, и
|
||||||
|
/// админка выводят из имени списка данных регексом, привязанным к концу строки.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ListTitleConvention
|
||||||
|
{
|
||||||
|
private const string DataSuffix = "данные";
|
||||||
|
private const string ApprovalSuffix = "согласование";
|
||||||
|
|
||||||
|
private static readonly Regex TitlePattern = new(
|
||||||
|
@"^(?<base>.+?)\s*—\s*(?<suffix>данные|согласование)\s*$",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static readonly Regex TrailingNumber = new(@"\s+\d+$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя того же списка с номером <paramref name="number"/>. Уже имеющийся номер
|
||||||
|
/// заменяется, а не дописывается вторым.
|
||||||
|
/// </summary>
|
||||||
|
public static string WithNumber(string title, int number)
|
||||||
|
{
|
||||||
|
var match = TitlePattern.Match(title);
|
||||||
|
if (!match.Success)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Имя списка «{title}» не по конвенции «<Форма> — данные|согласование» — номер поставить некуда.");
|
||||||
|
|
||||||
|
var baseName = TrailingNumber.Replace(match.Groups["base"].Value, string.Empty);
|
||||||
|
return $"{baseName} {number} — {match.Groups["suffix"].Value}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя парного списка согласования по имени списка данных (номер сохраняется).
|
||||||
|
/// null — имя не по конвенции либо это не список данных.
|
||||||
|
/// </summary>
|
||||||
|
public static string? ToApprovalTitle(string dataListTitle)
|
||||||
|
{
|
||||||
|
var match = TitlePattern.Match(dataListTitle);
|
||||||
|
if (!match.Success || match.Groups["suffix"].Value != DataSuffix) return null;
|
||||||
|
|
||||||
|
return $"{match.Groups["base"].Value} — {ApprovalSuffix}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,45 @@ using Microsoft.Extensions.Options;
|
|||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
|
||||||
|
/// <summary>Список SharePoint: Id нужен для операций над колонками, url — как ссылка на список.</summary>
|
||||||
|
public sealed record SharePointListInfo(Guid Id, string Title, string ServerRelativeUrl, int ItemCount);
|
||||||
|
|
||||||
|
/// <summary>Колонка списка в виде её SchemaXml — в таком виде её можно создать в другом списке.</summary>
|
||||||
|
public sealed record SharePointFieldSchema(string InternalName, string SchemaXml);
|
||||||
|
|
||||||
public interface ISharePointClient
|
public interface ISharePointClient
|
||||||
{
|
{
|
||||||
|
/// <summary>Список по серверному относительному url. null — списка нет.</summary>
|
||||||
|
Task<SharePointListInfo?> GetListByUrlAsync(
|
||||||
|
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Список по заголовку. null — списка нет.</summary>
|
||||||
|
Task<SharePointListInfo?> GetListByTitleAsync(
|
||||||
|
string siteUrl, string listTitle, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Пользовательские колонки списка — те, что нужно повторить в дубле. Системные
|
||||||
|
/// (унаследованные от базового типа, скрытые, только для чтения) отфильтрованы:
|
||||||
|
/// они появляются в новом списке сами.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<SharePointFieldSchema>> GetCustomFieldsAsync(
|
||||||
|
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Создать пустой список (GenericList с версионированием) и вернуть его.</summary>
|
||||||
|
Task<SharePointListInfo> CreateListAsync(string siteUrl, string title, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Создать колонку в списке из её SchemaXml.</summary>
|
||||||
|
Task AddFieldFromXmlAsync(string siteUrl, Guid listId, string schemaXml, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Заголовок списка по его серверному относительному URL (см. RequestType.ListId).</summary>
|
/// <summary>Заголовок списка по его серверному относительному URL (см. RequestType.ListId).</summary>
|
||||||
Task<string?> GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
|
Task<string?> GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Число элементов в списке (ItemCount самого списка, без выгрузки элементов) —
|
||||||
|
/// по нему решается, не пора ли заводить следующий список серии.
|
||||||
|
/// </summary>
|
||||||
|
Task<int?> GetItemCountAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Элементы списка данных, найденного по серверному относительному URL.</summary>
|
/// <summary>Элементы списка данных, найденного по серверному относительному URL.</summary>
|
||||||
Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
|
Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
|
||||||
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken);
|
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken);
|
||||||
@@ -50,6 +84,105 @@ public sealed class SharePointClient(
|
|||||||
return json.TryGetProperty("Title", out var title) ? title.GetString() : null;
|
return json.TryGetProperty("Title", out var title) ? title.GetString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<SharePointListInfo?> GetListByUrlAsync(
|
||||||
|
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) =>
|
||||||
|
GetListInfoAsync(
|
||||||
|
siteUrl,
|
||||||
|
$"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')",
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
public Task<SharePointListInfo?> GetListByTitleAsync(
|
||||||
|
string siteUrl, string listTitle, CancellationToken cancellationToken) =>
|
||||||
|
GetListInfoAsync(
|
||||||
|
siteUrl,
|
||||||
|
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')",
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private async Task<SharePointListInfo?> GetListInfoAsync(
|
||||||
|
string siteUrl, string listUrl, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var url = $"{listUrl}?$select=Id,Title,ItemCount,RootFolder/ServerRelativeUrl&$expand=RootFolder";
|
||||||
|
var json = await TryGetAsync(siteUrl, url, cancellationToken);
|
||||||
|
if (json is null) return null;
|
||||||
|
|
||||||
|
var value = json.Value;
|
||||||
|
var serverRelativeUrl = value.TryGetProperty("RootFolder", out var rootFolder)
|
||||||
|
&& rootFolder.TryGetProperty("ServerRelativeUrl", out var relative)
|
||||||
|
? relative.GetString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (serverRelativeUrl is null) return null;
|
||||||
|
|
||||||
|
return new SharePointListInfo(
|
||||||
|
value.GetProperty("Id").GetGuid(),
|
||||||
|
value.GetProperty("Title").GetString() ?? string.Empty,
|
||||||
|
serverRelativeUrl,
|
||||||
|
value.TryGetProperty("ItemCount", out var count) && count.TryGetInt32(out var items) ? items : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SharePointFieldSchema>> GetCustomFieldsAsync(
|
||||||
|
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// FromBaseType отсеивает колонки базового типа (Title, Author, Created …) —
|
||||||
|
// они есть в любом новом списке. Hidden/ReadOnlyField — служебные, их создать
|
||||||
|
// всё равно нельзя. Фильтр в OData, а не в C#, чтобы не тащить лишнее по сети.
|
||||||
|
var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/fields" +
|
||||||
|
"?$select=InternalName,SchemaXml,FromBaseType,Hidden,ReadOnlyField" +
|
||||||
|
"&$filter=Hidden eq false and ReadOnlyField eq false and FromBaseType eq false";
|
||||||
|
|
||||||
|
var json = await GetAsync(siteUrl, url, cancellationToken);
|
||||||
|
var rows = json.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array
|
||||||
|
? value.EnumerateArray()
|
||||||
|
: json.EnumerateArray();
|
||||||
|
|
||||||
|
var fields = new List<SharePointFieldSchema>();
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var internalName = row.TryGetProperty("InternalName", out var name) ? name.GetString() : null;
|
||||||
|
var schemaXml = row.TryGetProperty("SchemaXml", out var schema) ? schema.GetString() : null;
|
||||||
|
if (internalName is null || schemaXml is null) continue;
|
||||||
|
|
||||||
|
fields.Add(new SharePointFieldSchema(internalName, schemaXml));
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SharePointListInfo> CreateListAsync(
|
||||||
|
string siteUrl, string title, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// BaseTemplate 100 — GenericList, как -Template GenericList у провижинера.
|
||||||
|
await PostAsync(
|
||||||
|
siteUrl,
|
||||||
|
$"{siteUrl.TrimEnd('/')}/_api/web/lists",
|
||||||
|
JsonSerializer.Serialize(new { Title = title, BaseTemplate = 100, EnableVersioning = true }),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// Ответ POST'а не отдаёт RootFolder, а нам нужен серверный относительный url —
|
||||||
|
// дочитываем список отдельным запросом.
|
||||||
|
return await GetListByTitleAsync(siteUrl, title, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Список «{title}» создан, но не читается обратно.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task AddFieldFromXmlAsync(
|
||||||
|
string siteUrl, Guid listId, string schemaXml, CancellationToken cancellationToken) =>
|
||||||
|
PostAsync(
|
||||||
|
siteUrl,
|
||||||
|
$"{siteUrl.TrimEnd('/')}/_api/web/lists(guid'{listId}')/fields/createfieldasxml",
|
||||||
|
// Options = 8 (AddFieldInternalNameHint): SharePoint обязан сохранить
|
||||||
|
// InternalName из SchemaXml, а не сгенерировать свой. К колонкам обращается
|
||||||
|
// код портала именно по внутреннему имени, так что это не косметика.
|
||||||
|
JsonSerializer.Serialize(new { parameters = new { SchemaXml = schemaXml, Options = 8 } }),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
public async Task<int?> GetItemCountAsync(
|
||||||
|
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')?$select=ItemCount";
|
||||||
|
var json = await GetAsync(siteUrl, url, cancellationToken);
|
||||||
|
return json.TryGetProperty("ItemCount", out var count) && count.TryGetInt32(out var value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
|
public Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
|
||||||
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) =>
|
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) =>
|
||||||
GetItemsAsync(
|
GetItemsAsync(
|
||||||
@@ -79,9 +212,26 @@ public sealed class SharePointClient(
|
|||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<JsonElement> GetAsync(string siteUrl, string url, CancellationToken cancellationToken)
|
private async Task<JsonElement> GetAsync(string siteUrl, string url, CancellationToken cancellationToken) =>
|
||||||
|
await SendAsync(HttpMethod.Get, siteUrl, url, body: null, allowNotFound: false, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"SharePoint не вернул тело на {url}.");
|
||||||
|
|
||||||
|
/// <summary>GET, для которого «нет такого списка» — не ошибка, а null.</summary>
|
||||||
|
private Task<JsonElement?> TryGetAsync(string siteUrl, string url, CancellationToken cancellationToken) =>
|
||||||
|
SendAsync(HttpMethod.Get, siteUrl, url, body: null, allowNotFound: true, cancellationToken);
|
||||||
|
|
||||||
|
private Task PostAsync(string siteUrl, string url, string json, CancellationToken cancellationToken) =>
|
||||||
|
SendAsync(HttpMethod.Post, siteUrl, url, json, allowNotFound: false, cancellationToken);
|
||||||
|
|
||||||
|
private async Task<JsonElement?> SendAsync(
|
||||||
|
HttpMethod method,
|
||||||
|
string siteUrl,
|
||||||
|
string url,
|
||||||
|
string? body,
|
||||||
|
bool allowNotFound,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
using var request = new HttpRequestMessage(method, url);
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||||
"Bearer", await GetTokenAsync(siteUrl, cancellationToken));
|
"Bearer", await GetTokenAsync(siteUrl, cancellationToken));
|
||||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")
|
||||||
@@ -89,14 +239,30 @@ public sealed class SharePointClient(
|
|||||||
Parameters = { new NameValueHeaderValue("odata", "nometadata") }
|
Parameters = { new NameValueHeaderValue("odata", "nometadata") }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (body is not null)
|
||||||
|
{
|
||||||
|
request.Content = new StringContent(body);
|
||||||
|
// odata=nometadata и на запись: тогда в теле не нужен блок __metadata с
|
||||||
|
// типом сущности — SharePoint выводит тип из самого эндпоинта.
|
||||||
|
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json")
|
||||||
|
{
|
||||||
|
Parameters = { new NameValueHeaderValue("odata", "nometadata") }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (allowNotFound && response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
return null;
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"SharePoint ответил {(int)response.StatusCode} на {url}: {body}");
|
$"SharePoint ответил {(int)response.StatusCode} на {method} {url}: {responseBody}");
|
||||||
|
|
||||||
using var document = JsonDocument.Parse(body);
|
if (string.IsNullOrWhiteSpace(responseBody)) return null;
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(responseBody);
|
||||||
return document.RootElement.Clone();
|
return document.RootElement.Clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Создаёт следующую пару списков ДУБЛИРОВАНИЕМ текущей: берёт структуру живого
|
||||||
|
/// списка (его пользовательские колонки) и повторяет её в новом списке с
|
||||||
|
/// номером N+1. Манифесты provisioning.json request-portal при этом не читаются —
|
||||||
|
/// источник структуры сам SharePoint, поэтому дубль не может разойтись с тем, что
|
||||||
|
/// реально в списке.
|
||||||
|
///
|
||||||
|
/// Идемпотентен: список с целевым именем переиспользуется, уже существующие колонки
|
||||||
|
/// пропускаются. Повторный запуск после частичного сбоя дозаполняет недостающее, а
|
||||||
|
/// не создаёт второй список.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SharePointListProvisioner(
|
||||||
|
ISharePointClient sharePoint,
|
||||||
|
ILogger<SharePointListProvisioner> logger) : ISharePointListProvisioner
|
||||||
|
{
|
||||||
|
public async Task<ProvisionedList> CreateNextListPairAsync(
|
||||||
|
string siteUrl,
|
||||||
|
string sourceDataListId,
|
||||||
|
int number,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sourceData = await sharePoint.GetListByUrlAsync(siteUrl, sourceDataListId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Исходный список {sourceDataListId} не найден на {siteUrl} — дублировать нечего.");
|
||||||
|
|
||||||
|
var newData = await CloneAsync(
|
||||||
|
siteUrl,
|
||||||
|
sourceData,
|
||||||
|
ListTitleConvention.WithNumber(sourceData.Title, number),
|
||||||
|
retarget: null,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
await CloneApprovalPairAsync(siteUrl, sourceData, newData, number, cancellationToken);
|
||||||
|
|
||||||
|
return new ProvisionedList(newData.ServerRelativeUrl, newData.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Дублирует парный список согласования. Его не на что сослаться, кроме имени:
|
||||||
|
/// в БД хранится ссылка только на список данных, а согласование ищется по
|
||||||
|
/// конвенции — так же, как это делают портал и админка.
|
||||||
|
/// </summary>
|
||||||
|
private async Task CloneApprovalPairAsync(
|
||||||
|
string siteUrl,
|
||||||
|
SharePointListInfo sourceData,
|
||||||
|
SharePointListInfo newData,
|
||||||
|
int number,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sourceApprovalTitle = ListTitleConvention.ToApprovalTitle(sourceData.Title);
|
||||||
|
if (sourceApprovalTitle is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Список «{Title}» не по конвенции «… — данные» — парный список согласования не дублируется.",
|
||||||
|
sourceData.Title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceApproval = await sharePoint.GetListByTitleAsync(siteUrl, sourceApprovalTitle, cancellationToken);
|
||||||
|
if (sourceApproval is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Список согласования «{Title}» не найден — дублируется только список данных.",
|
||||||
|
sourceApprovalTitle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CloneAsync(
|
||||||
|
siteUrl,
|
||||||
|
sourceApproval,
|
||||||
|
ListTitleConvention.WithNumber(sourceApproval.Title, number),
|
||||||
|
// Ticket списка согласования смотрит на список данных — в дубле он должен
|
||||||
|
// смотреть на НОВЫЙ список данных, иначе строки маршрута новых заявок
|
||||||
|
// будут ссылаться в старый список.
|
||||||
|
retarget: (sourceData.Id, newData.Id),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SharePointListInfo> CloneAsync(
|
||||||
|
string siteUrl,
|
||||||
|
SharePointListInfo source,
|
||||||
|
string newTitle,
|
||||||
|
(Guid From, Guid To)? retarget,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var existing = await sharePoint.GetListByTitleAsync(siteUrl, newTitle, cancellationToken);
|
||||||
|
var target = existing ?? await sharePoint.CreateListAsync(siteUrl, newTitle, cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
existing is null ? "Создан список «{Title}»." : "Список «{Title}» уже существует — дозаполняю колонки.",
|
||||||
|
newTitle);
|
||||||
|
|
||||||
|
var sourceFields = await sharePoint.GetCustomFieldsAsync(siteUrl, source.ServerRelativeUrl, cancellationToken);
|
||||||
|
var targetFields = await sharePoint.GetCustomFieldsAsync(siteUrl, target.ServerRelativeUrl, cancellationToken);
|
||||||
|
var alreadyThere = targetFields.Select(x => x.InternalName).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var field in sourceFields)
|
||||||
|
{
|
||||||
|
if (alreadyThere.Contains(field.InternalName)) continue;
|
||||||
|
|
||||||
|
var schemaXml = retarget is null
|
||||||
|
? field.SchemaXml
|
||||||
|
: RetargetLookup(field.SchemaXml, retarget.Value.From, retarget.Value.To);
|
||||||
|
|
||||||
|
await sharePoint.AddFieldFromXmlAsync(siteUrl, target.Id, schemaXml, cancellationToken);
|
||||||
|
logger.LogInformation(" колонка {InternalName} → «{Title}»", field.InternalName, newTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Перенацеливает Lookup на новый список данных.
|
||||||
|
///
|
||||||
|
/// Целевой список Lookup хранит в SchemaXml GUID'ом (атрибут List), поэтому
|
||||||
|
/// подменяем именно GUID. Подменяем ТОЛЬКО ссылки на исходный список данных:
|
||||||
|
/// кросс-формные lookup'ы (например, «организация рабочего места» ссылается на
|
||||||
|
/// список «приема на работу») должны и дальше смотреть на список ЧУЖОЙ формы.
|
||||||
|
/// </summary>
|
||||||
|
private static string RetargetLookup(string schemaXml, Guid from, Guid to)
|
||||||
|
{
|
||||||
|
var fromText = from.ToString("D");
|
||||||
|
return schemaXml.Contains(fromText, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? schemaXml.Replace(fromText, to.ToString("D"), StringComparison.OrdinalIgnoreCase)
|
||||||
|
: schemaXml;
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Shouldly;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Блокировка провижининга — единственное, что не даёт двум фоновым задачам создать
|
||||||
|
/// по списку каждая, поэтому проверяется именно на живом Postgres: смысл здесь несёт
|
||||||
|
/// атомарность условного UPDATE, а не код на C#.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RequestTypeProvisioningLockTests(PostgresDatabaseFixture fixture)
|
||||||
|
: IClassFixture<PostgresDatabaseFixture>
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(30);
|
||||||
|
|
||||||
|
private readonly ServiceProvider _provider = TestServiceProviderFactory.Build(fixture.ConnectionString);
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Повторный захват занятой блокировки не удаётся")]
|
||||||
|
public async Task TryAcquire_WhenAlreadyHeld_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync();
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var first = await TryAcquireAsync(requestTypeId);
|
||||||
|
var second = await TryAcquireAsync(requestTypeId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
first.ShouldBeTrue();
|
||||||
|
second.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "При одновременном захвате блокировку получает ровно одна задача")]
|
||||||
|
public async Task TryAcquire_Concurrently_OnlyOneWins()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync();
|
||||||
|
|
||||||
|
// Action — каждый захват из своего scope, то есть своим соединением к БД:
|
||||||
|
// именно так это и происходит у параллельных задач Hangfire.
|
||||||
|
var results = await Task.WhenAll(
|
||||||
|
Enumerable.Range(0, 8).Select(_ => TryAcquireAsync(requestTypeId)));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
results.Count(x => x).ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "После снятия блокировку можно захватить снова")]
|
||||||
|
public async Task TryAcquire_AfterRelease_Succeeds()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync();
|
||||||
|
(await TryAcquireAsync(requestTypeId)).ShouldBeTrue();
|
||||||
|
|
||||||
|
// Action
|
||||||
|
using (var scope = _provider.CreateScope())
|
||||||
|
{
|
||||||
|
await scope.ServiceProvider.GetRequiredService<RequestTypeProvisioningLock>()
|
||||||
|
.ReleaseAsync(requestTypeId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
(await TryAcquireAsync(requestTypeId)).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Просроченная блокировка перехватывается, свежая — нет")]
|
||||||
|
public async Task TryAcquire_StaleLock_IsTakenOver()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync();
|
||||||
|
(await TryAcquireAsync(requestTypeId)).ShouldBeTrue();
|
||||||
|
|
||||||
|
// Свежую блокировку с тем же порогом перехватить нельзя.
|
||||||
|
(await TryAcquireAsync(requestTypeId, TimeSpan.FromMinutes(10))).ShouldBeFalse();
|
||||||
|
|
||||||
|
// Action — отодвигаем метку в прошлое: так выглядит блокировка, которую
|
||||||
|
// оставил упавший, не снявший её процесс.
|
||||||
|
using (var scope = _provider.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApprovalRouteDbContext>();
|
||||||
|
await db.Database.ExecuteSqlInterpolatedAsync(
|
||||||
|
$"UPDATE request_types SET list_provisioning_started_at = now() - interval '1 hour' WHERE id = {requestTypeId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
(await TryAcquireAsync(requestTypeId, TimeSpan.FromMinutes(10))).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Захват блокировки для несуществующего типа заявки не удаётся")]
|
||||||
|
public async Task TryAcquire_UnknownRequestType_ReturnsFalse() =>
|
||||||
|
(await TryAcquireAsync(999999)).ShouldBeFalse();
|
||||||
|
|
||||||
|
private async Task<bool> TryAcquireAsync(int requestTypeId, TimeSpan? staleAfter = null)
|
||||||
|
{
|
||||||
|
using var scope = _provider.CreateScope();
|
||||||
|
return await scope.ServiceProvider.GetRequiredService<RequestTypeProvisioningLock>()
|
||||||
|
.TryAcquireAsync(requestTypeId, staleAfter ?? StaleAfter, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateRequestTypeAsync()
|
||||||
|
{
|
||||||
|
using var scope = _provider.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApprovalRouteDbContext>();
|
||||||
|
|
||||||
|
var entity = new RequestType
|
||||||
|
{
|
||||||
|
Name = $"Тип {Guid.NewGuid():N}",
|
||||||
|
PageUrl = "/test",
|
||||||
|
Section = "General"
|
||||||
|
};
|
||||||
|
db.RequestTypes.Add(entity);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return entity.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user