Архивация списков (#13)
Tests / frontend-test (push) Successful in 44s
Tests / backend-test (push) Failing after 17m4s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/13
This commit was merged in pull request #13.
This commit is contained in:
2026-08-19 09:08:36 +00:00
parent a2672042d7
commit 82512eb1db
56 changed files with 2587 additions and 51 deletions
+14
View File
@@ -1,6 +1,12 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<!--
Транзитивные зависимости тоже подчиняются версиям ниже. Нужно из-за Hangfire:
он тянет Newtonsoft.Json 11.0.1 с известной уязвимостью, и без пиннинга её
не поднять, не добавляя пакет в проекты, которым он сам по себе не нужен.
-->
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Azure.Identity" Version="1.14.0" />
@@ -9,6 +15,9 @@
<PackageVersion Include="FluentResults" Version="4.0.0" />
<PackageVersion Include="FluentValidation" 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.OpenApi" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
@@ -18,8 +27,13 @@
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
<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="Scalar.AspNetCore" Version="2.16.16" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
@@ -0,0 +1,69 @@
using System.Security.Cryptography;
using System.Text;
using Hangfire.Dashboard;
using Microsoft.Net.Http.Headers;
namespace Tnsu.ApprovalRoute.Api.Auth;
/// <summary>
/// Простая Basic Auth для дашборда Hangfire — единственный логин/пароль из
/// конфигурации (Hangfire:Dashboard), а не полноценная схема аутентификации: сам
/// дашборд не участвует в остальной авторизации API (это отдельная страница,
/// открываемая прямо в браузере, не по JWT).
///
/// Пустые логин/пароль (не заданы в конфигурации) означают «дашборд закрыт для
/// всех» — не «открыт для всех»: пустая строка в конфиге не должна случайно снять
/// защиту.
/// </summary>
public sealed class HangfireDashboardBasicAuthFilter(string username, string password) : IDashboardAuthorizationFilter
{
public bool Authorize(DashboardContext context)
{
var httpContext = context.GetHttpContext();
if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password) &&
TryGetBasicAuthCredentials(httpContext.Request.Headers.Authorization, out var user, out var pass) &&
FixedTimeEquals(user, username) && FixedTimeEquals(pass, password))
{
return true;
}
httpContext.Response.Headers[HeaderNames.WWWAuthenticate] = "Basic realm=\"Hangfire\"";
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
return false;
}
private static bool TryGetBasicAuthCredentials(
string? authorizationHeader, out string username, out string password)
{
username = string.Empty;
password = string.Empty;
if (string.IsNullOrEmpty(authorizationHeader) ||
!authorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
return false;
try
{
var decoded = Encoding.UTF8.GetString(
Convert.FromBase64String(authorizationHeader["Basic ".Length..].Trim()));
var separatorIndex = decoded.IndexOf(':');
if (separatorIndex < 0) return false;
username = decoded[..separatorIndex];
password = decoded[(separatorIndex + 1)..];
return true;
}
catch (FormatException)
{
// Битый base64 в заголовке — не наша забота, просто не авторизуем.
return false;
}
}
// Сравнение за постоянное время: длина логина/пароля в Basic Auth и так видна
// по заголовку, но посимвольное сравнение через == утекало бы ЕЩЁ и через
// тайминг совпадения символов — дешёво закрыть и это.
private static bool FixedTimeEquals(string a, string b) =>
CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));
}
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tnsu.ApprovalRoute.Api.Common;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
namespace Tnsu.ApprovalRoute.Api.Controllers;
/// <summary>
/// Нумерованная серия партиций SharePoint-списка по типу заявок: куда писать сейчас
/// и вся серия для админки/портала.
/// </summary>
[ApiController]
[Authorize]
[Route("api/request-types")]
[Produces("application/json")]
public sealed class RequestTypeListsController(ListPartitionService service) : ControllerBase
{
/// <summary>
/// Серии партиций ВСЕХ типов заявок разом — для портала: чтобы искать по
/// заявкам сразу во всех партициях (архив + текущая) любого типа, не нужно
/// запрашивать серию по каждому типу отдельно.
/// </summary>
[HttpGet("lists")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeListSeriesDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> ListAll(CancellationToken ct) =>
(await service.ListAllAsync(ct)).ToActionResult();
/// <summary>Вся серия партиций одного типа заявок (архив + текущая), от первой к последней.</summary>
[HttpGet("{requestTypeId:int}/lists")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestListPartitionDto>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> List(int requestTypeId, CancellationToken ct) =>
(await service.ListAsync(requestTypeId, ct)).ToActionResult();
/// <summary>
/// Партиция, в которую нужно писать новые заявки этого типа.
///
/// Не чистый GET: если партиция добрала порог элементов, запрос ставит в очередь
/// фоновую задачу создания следующей (см. ListPartitionService.GetCurrentAsync).
/// </summary>
[HttpGet("{requestTypeId:int}/lists/current")]
[ProducesResponseType(typeof(ApiResponse<CurrentListPartitionDto>), 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();
}
+39 -1
View File
@@ -1,14 +1,18 @@
using System.Text.Json.Serialization;
using Extensions.Hosting.AsyncInitialization;
using Hangfire;
using Hangfire.PostgreSql;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi;
using Scalar.AspNetCore;
using Serilog;
using Tnsu.ApprovalRoute.Api.Auth;
using Tnsu.ApprovalRoute.Api.Filters;
using Tnsu.ApprovalRoute.Application;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Infrastructure;
@@ -17,11 +21,25 @@ const string CorsPolicyName = "Frontend";
var builder = WebApplication.CreateBuilder(args);
// Локальные оверрайды, не попадающие в git (см. .gitignore) — на своей машине
// можно, например, занизить ListPartitioning:ItemCountThreshold, чтобы не
// накапливать тысячи тестовых заявок для проверки создания партиций.
builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true);
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File(
"logs/log-.txt",
rollingInterval: RollingInterval.Month,
retainedFileCountLimit: 6));
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
@@ -37,6 +55,16 @@ builder.Services.AddCors(options =>
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
builder.Services.AddApprovalRouteApplication();
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
builder.Services.Configure<ListPartitioningOptions>(builder.Configuration.GetSection("ListPartitioning"));
builder.Services.AddHangfire(configuration => configuration
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UsePostgreSqlStorage(postgres =>
postgres.UseNpgsqlConnection(builder.Configuration.GetConnectionString("Postgres"))));
builder.Services.AddHangfireServer();
builder.Services.AddApprovalRouteAuth(builder.Configuration);
builder.Services.AddScoped<ICurrentUser, CurrentUserAccessor>();
builder.Services.AddOpenApi(options =>
@@ -92,6 +120,16 @@ app.UseCors(CorsPolicyName);
app.UseAuthentication();
app.UseAuthorization();
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization =
[
new HangfireDashboardBasicAuthFilter(
app.Configuration["Hangfire:Dashboard:Username"] ?? string.Empty,
app.Configuration["Hangfire:Dashboard:Password"] ?? string.Empty)
]
});
app.MapControllers();
await app.InitAndRunAsync();
@@ -5,6 +5,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
<PackageReference Include="Hangfire.AspNetCore" />
<PackageReference Include="Hangfire.PostgreSql" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
@@ -12,5 +14,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
</ItemGroup>
</Project>
@@ -1,8 +1,13 @@
{
"Logging": {
"LogLevel": {
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"Hangfire": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
}
},
"ConnectionStrings": {
@@ -19,11 +24,23 @@
"https://localhost:4321"
]
},
"ListPartitioning": {
"ItemCountThreshold": 4900,
"StaleLockAfter": "00:30:00",
"CheckThresholdOnRead": true
},
"Hangfire": {
"Dashboard": {
"Username": "admin",
"Password": "RzaT2krczbRpE36QAucdCXR"
}
},
"SharePoint": {
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
"CertificateSettingName": "AzurePfx",
"CertificatePassword": "Rk.xDjccnfyjdktybz1"
"CertificatePassword": "Rk.xDjccnfyjdktybz1",
"NotificationUrl": "https://api.tnsu.kz/Webhook/SharePoint"
},
"TnsuPortal": {
"BaseUrl": "https://api.tnsu.kz",
@@ -2,6 +2,7 @@ using System.Reflection;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
@@ -14,6 +15,10 @@ public static class ApplicationServiceCollectionExtensions
{
services.AddScoped<ReminderService>();
services.AddScoped<RequestTypeService>();
services.AddScoped<ListPartitionRegistry>();
services.AddScoped<ListPartitionService>();
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
services.AddScoped<CreateNextListPartitionJob>();
services.AddScoped<TemplateRouteService>();
services.AddScoped<TemplateRouteStepService>();
@@ -0,0 +1,148 @@
using System.Diagnostics;
using Hangfire;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
/// <summary>
/// Фоновая задача: создать следующую партицию списка по типу заявок, записать её в
/// реестр и переключить на неё запись.
///
/// Блокировка захватывается ЗДЕСЬ, при каждом запуске (в т.ч. на каждой повторной
/// попытке Hangfire), а не в момент постановки в очередь. Enqueue и фактическое
/// выполнение — разные события во времени: задача может полежать в очереди, быть
/// повторена автоматически после сбоя, в теории — перезапущена вручную из дашборда.
/// Всё это заново становится тем же самым «выполнением» с точки зрения Hangfire, но
/// не с точки зрения блокировки: settled только выполнение может её удержать, и
/// проверять это надо на каждом фактическом входе в код, создающий список в
/// SharePoint, а не один раз на входе в очередь. Иначе, к примеру, зависший на
/// повторе процесс и новый независимый триггер (например, второй HTTP-запрос,
/// поймавший тот же порог до переключения списка) синхронизировались бы неверно.
///
/// Повторы разрешены, но их немного: провижинер идемпотентен (переиспользует список
/// с целевым именем и дозаполняет колонки), поэтому повтор после частичного сбоя
/// безопасен и не создаёт вторую партицию. Больше трёх попыток смысла нет — если
/// SharePoint отвечает ошибкой стабильно, задача должна остаться видимой в Hangfire,
/// а не молотить впустую.
/// </summary>
[AutomaticRetry(Attempts = 3)]
public sealed class CreateNextListPartitionJob(
ApprovalRouteDbContext db,
ListPartitioningLock provisioningLock,
ListPartitionRegistry registry,
ISharePointListProvisioner provisioner,
TnsuPortalCallerToken callerToken,
IOptions<ListPartitioningOptions> options,
ILogger<CreateNextListPartitionJob> logger)
{
/// <param name="authorization">
/// Заголовок Authorization пользователя, чей запрос вызвал провижининг (см.
/// ListPartitionService.TryEnqueueProvisioningAsync). Нужен, чтобы подписать
/// новый список на уведомления от имени этого пользователя: своего HttpContext
/// у фоновой задачи нет, а client_credentials сейчас не настроен (см.
/// TnsuPortalAuthorizer). Провижининг занимает секунды, поэтому токен не
/// успевает истечь — но на ПОВТОРЕ Hangfire (минуты спустя) он уже может быть
/// просроченным: тогда подписка вернёт 401 и упадёт мягко, не сорвав создание
/// списка (см. ISharePointWebhookSubscriber).
///
/// Учтите: как аргумент задачи он попадает в хранилище Hangfire (Postgres) и
/// виден в его дашборде.
/// </param>
public async Task RunAsync(int requestTypeId, string? authorization, CancellationToken cancellationToken)
{
callerToken.Set(authorization);
// Единственная настоящая защита от параллельного запуска: атомарный захват
// флага в строке request_types. Проверка перед постановкой в очередь —
// только чтобы не плодить лишние задачи; гонку разруливает именно здесь.
if (!await provisioningLock.TryAcquireAsync(
requestTypeId, options.Value.StaleLockAfter, cancellationToken))
{
logger.LogInformation(
"Провижининг списка для типа заявок {RequestTypeId} уже идёт — задача пропущена.",
requestTypeId);
return;
}
try
{
var requestType = await db.RequestTypes
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
if (requestType is null)
{
logger.LogWarning("Тип заявок {RequestTypeId} не найден — провижининг отменён.", requestTypeId);
return;
}
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
{
logger.LogWarning(
"У типа заявок {RequestTypeId} не настроен список SharePoint — провижининг отменён.",
requestTypeId);
return;
}
// Партиция, на которую сейчас указывает RequestType.ListId, прямо сейчас
// перестаёт быть текущей — записываем её в архив (request_list_partitions
// хранит только архив, см. ListPartitionRegistry). До этого момента она
// нигде отдельной строкой не значилась.
var archivedCurrent = await registry.ArchiveCurrentAsync(requestType, cancellationToken);
var nextNumber = archivedCurrent.Number + 1;
logger.LogInformation(
"Создаю партицию №{Number} для типа заявок {RequestTypeId} ({RequestTypeName}) " +
"по структуре {SourceListId}.",
nextNumber, requestTypeId, requestType.Name, requestType.ListId);
var stopwatch = Stopwatch.StartNew();
ProvisionedList created;
try
{
// Партиция всегда создаётся на том же сайте, что и текущая (см.
// комментарий на RequestListPartition о недопустимости разъезда серии
// по сайтам) — сайт берём из RequestType, а не откуда-либо ещё, так что
// расхождение структурно невозможно.
created = await provisioner.CreateNextListPairAsync(
requestType.SiteUrl, requestType.ListId, nextNumber, cancellationToken);
}
catch (Exception ex)
{
// Логируем с контекстом (номер партиции, тип заявок) здесь, а не
// полагаемся только на то, что исключение долетит до лога Hangfire:
// там оно будет без этих подробностей, вперемешку с логами других задач.
// Дальше даём ему пойти в Hangfire как обычно — так сработает
// AutomaticRetry(Attempts = 3).
logger.LogError(
ex,
"Провижининг партиции №{Number} для типа заявок {RequestTypeId} не удался за {ElapsedMs} мс.",
nextNumber, requestTypeId, stopwatch.ElapsedMilliseconds);
throw;
}
// Новая партиция строкой в request_list_partitions пока не появляется —
// она получит свою запись только когда САМА станет архивом, следующим
// вызовом этой же задачи (см. doc-комментарий класса ListPartitionRegistry).
// Здесь переключаем только указатель.
requestType.ListId = created.ListId;
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Партиция №{Number} ({ListId}) создана за {ElapsedMs} мс, запись по типу заявок " +
"{RequestTypeId} переключена на неё.",
nextNumber, created.ListId, stopwatch.ElapsedMilliseconds, requestTypeId);
}
finally
{
// Снимаем блокировку и на успехе, и на исключении: иначе упавшая задача
// держала бы тип заблокированным до истечения StaleLockAfter.
await provisioningLock.ReleaseAsync(requestTypeId, CancellationToken.None);
}
}
}
@@ -0,0 +1,35 @@
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
/// <summary>Одна партиция серии — для админки/портала (вся серия) и для ответа «текущий список».</summary>
/// <param name="IsCurrent">
/// true — в эту партицию идёт запись новых заявок; остальные партиции серии — архив только для чтения.
/// </param>
public sealed record RequestListPartitionDto(
int Number,
string SiteUrl,
string ListId,
string? Title,
bool IsCurrent,
DateTimeOffset CreatedAt);
/// <summary>Вся серия партиций одного типа заявок — единица ответа bulk-эндпоинта для портала.</summary>
public sealed record RequestTypeListSeriesDto(
int RequestTypeId,
IReadOnlyList<RequestListPartitionDto> Partitions);
/// <summary>Ответ «куда писать новую заявку».</summary>
/// <param name="ItemCount">
/// Сколько элементов сейчас в списке — по нему видно, насколько он близок к порогу.
/// null, если SharePoint не ответил (тогда условие создания следующей партиции не проверялось).
/// </param>
/// <param name="ProvisioningQueued">
/// true — этим запросом поставлена в очередь задача создания следующей партиции серии.
/// </param>
public sealed record CurrentListPartitionDto(
int RequestTypeId,
int Number,
string SiteUrl,
string ListId,
string? Title,
int? ItemCount = null,
bool ProvisioningQueued = false);
@@ -0,0 +1,123 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
/// <summary>
/// Наполнение реестра партиций. Вынесено отдельно, потому что нужно и читающему
/// эндпоинту, и фоновой задаче.
///
/// request_list_partitions хранит ТОЛЬКО архив — партиции, которые уже перестали
/// быть текущими. Текущая партиция нигде отдельной строкой не пишется: она всегда
/// синтезируется на лету из RequestType.ListId/SiteUrl (см. BuildCurrentAsync).
/// Иначе один и тот же список оказывался бы записан дважды — и в RequestType.ListId,
/// и строкой в этой таблице — сразу же, при первом же чтении, до того как партиция
/// реально стала архивом.
/// </summary>
public sealed class ListPartitionRegistry(
ApprovalRouteDbContext db,
ISharePointClient sharePoint,
ILogger<ListPartitionRegistry> logger)
{
/// <summary>
/// Партиции, которые УЖЕ архив — всё, что реестр знает о серии, кроме текущей.
///
/// Фильтр по ListId != RequestType.ListId — не оптимизация, а самовосстановление
/// на случай узкого окна в ArchiveCurrentAsync (см. её комментарий): если старая
/// партиция уже успела записаться архивом, а переключение указателя на новую ещё
/// не прошло (SharePoint упал между этими двумя шагами), без фильтра она была бы
/// видна СРАЗУ в обоих качествах — и как архив, и как текущая (см.
/// BuildCurrentAsync). Фильтр держит текущую ровно в одном месте, даже в этом
/// переходном состоянии.
/// </summary>
public async Task<IReadOnlyList<RequestListPartition>> GetArchivedAsync(
RequestType requestType, CancellationToken cancellationToken) =>
await db.RequestListPartitions.AsNoTracking()
.Where(x => x.RequestTypeId == requestType.Id && x.ListId != requestType.ListId)
.OrderBy(x => x.Number)
.ToListAsync(cancellationToken);
/// <summary>
/// Синтезирует запись о ТЕКУЩЕЙ партиции — без обращения к request_list_partitions
/// на запись: строка появится там только когда эта партиция реально станет
/// архивом (см. ArchiveCurrentAsync). Номер — следующий за последним архивным
/// (или 1, если архива ещё нет); Title читается REST'ом каждый раз, а не
/// кешируется — кешировать было бы негде без строки в БД.
///
/// archived — уже загруженный список архивных партиций этого типа (см.
/// GetArchivedAsync), передаётся, а не читается заново, чтобы вызывающая
/// сторона (которой archived обычно уже нужен для остальной серии) не делала
/// один и тот же запрос дважды.
/// </summary>
public async Task<RequestListPartitionDto> BuildCurrentAsync(
RequestType requestType, IReadOnlyList<RequestListPartition> archived, CancellationToken cancellationToken)
{
var number = archived.Count == 0 ? 1 : archived.Max(x => x.Number) + 1;
var title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken);
return new RequestListPartitionDto(
number, requestType.SiteUrl!, requestType.ListId!, title, IsCurrent: true, DateTimeOffset.UtcNow);
}
/// <summary>
/// Записывает ТЕКУЩУЮ партицию как архивную строку — вызывать строго в момент,
/// когда она перестаёт быть текущей (единственный вызывающий —
/// CreateNextListPartitionJob, прямо перед тем как переключить
/// RequestType.ListId на следующую партицию).
///
/// Читает свой собственный, НЕфильтрованный список существующих партиций —
/// намеренно не переиспользует GetArchivedAsync(): та фильтрует по
/// ListId != RequestType.ListId, и ровно между этим вызовом и переключением
/// указателя (см. CreateNextListPartitionJob) только что записанная строка
/// на мгновение совпадает с текущим ListId — фильтр GetArchivedAsync её бы не
/// увидел, идемпотентность сломалась бы, и повтор Hangfire упал бы на
/// уникальном индексе (request_type_id, number) вместо того, чтобы найти уже
/// записанное и вернуть его.
/// </summary>
public async Task<RequestListPartition> ArchiveCurrentAsync(
RequestType requestType, CancellationToken cancellationToken)
{
var series = await db.RequestListPartitions
.Where(x => x.RequestTypeId == requestType.Id)
.ToListAsync(cancellationToken);
var existing = series.FirstOrDefault(x => x.ListId == requestType.ListId);
if (existing is not null) return existing;
var entity = new RequestListPartition
{
RequestTypeId = requestType.Id,
Number = series.Count == 0 ? 1 : series.Max(x => x.Number) + 1,
ListId = requestType.ListId!,
Title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken),
CreatedAt = DateTimeOffset.UtcNow
};
db.RequestListPartitions.Add(entity);
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Список {ListId} типа заявок {RequestTypeId} заархивирован под номером {Number}.",
entity.ListId, requestType.Id, entity.Number);
return entity;
}
// Заголовок нужен только для отображения в админке/портале — из-за его
// отсутствия чтение партиции падать не должно.
private async Task<string?> TryGetTitleAsync(string siteUrl, string listId, CancellationToken cancellationToken)
{
try
{
return await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось прочитать заголовок списка {ListId}.", listId);
return null;
}
}
}
@@ -0,0 +1,170 @@
using FluentResults;
using Hangfire;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
/// <summary>
/// Реестр нумерованных партиций списка по типу заявок: какая партиция текущая (куда
/// писать) и вся серия целиком (архив + текущая) для админки и портала.
/// </summary>
public sealed class ListPartitionService(
ApprovalRouteDbContext db,
ISharePointClient sharePoint,
ListPartitionRegistry registry,
IBackgroundJobClient backgroundJobs,
TnsuPortalCallerToken callerToken,
IOptions<ListPartitioningOptions> options,
ILogger<ListPartitionService> logger)
{
/// <summary>
/// Партиция, в которую сейчас нужно писать новые заявки.
///
/// Побочный эффект: если партиция набрала порог элементов, ставит в очередь
/// задачу создания следующей. Побочный эффект в GET — сознательный компромисс
/// (так описано в ТЗ): вызывающая сторона всё равно спрашивает адрес перед каждой
/// записью, и это единственный момент, когда мы точно знаем, что список живой.
/// Повторная постановка безопасна — задача разруливает гонку захватом блокировки.
/// </summary>
public async Task<Result<CurrentListPartitionDto>> GetCurrentAsync(
int requestTypeId, CancellationToken cancellationToken)
{
var requestType = await db.RequestTypes
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
if (requestType is null)
return Result.Fail<CurrentListPartitionDto>(new NotFoundError("Тип заявки", requestTypeId));
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
return Result.Fail<CurrentListPartitionDto>(new ValidationError(
$"У типа заявки {requestTypeId} не настроен список SharePoint."));
// Синтезируется на лету, не пишется в request_list_partitions — см.
// ListPartitionRegistry.BuildCurrentAsync: строка появится там только когда
// партиция реально станет архивом.
var archived = await registry.GetArchivedAsync(requestType, cancellationToken);
var current = await registry.BuildCurrentAsync(requestType, archived, cancellationToken);
var itemCount = await TryGetItemCountAsync(requestType.SiteUrl, requestType.ListId, cancellationToken);
var queued = await TryEnqueueProvisioningAsync(requestType, current.Number, itemCount, cancellationToken);
return Result.Ok(new CurrentListPartitionDto(
requestTypeId, current.Number, requestType.SiteUrl, current.ListId, current.Title, itemCount, queued));
}
/// <summary>Вся серия партиций типа заявок, от первой к последней — для админки.</summary>
public async Task<Result<IReadOnlyList<RequestListPartitionDto>>> ListAsync(
int requestTypeId, CancellationToken cancellationToken)
{
var requestType = await db.RequestTypes
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
if (requestType is null)
return Result.Fail<IReadOnlyList<RequestListPartitionDto>>(new NotFoundError("Тип заявки", requestTypeId));
return Result.Ok(await BuildSeriesAsync(requestType, cancellationToken));
}
/// <summary>
/// Серии всех типов заявок разом — для портала: чтобы искать по заявкам сразу во
/// всех партициях (архив + текущая) любого типа, порталу нужен весь список
/// адресов один раз, а не по одному запросу на тип. Типы без настроенного
/// списка SharePoint в ответ не попадают — искать там всё равно нечего.
/// </summary>
public async Task<Result<IReadOnlyList<RequestTypeListSeriesDto>>> ListAllAsync(CancellationToken cancellationToken)
{
var requestTypes = await db.RequestTypes
.Where(x => x.ListId != null && x.SiteUrl != null)
.ToListAsync(cancellationToken);
var result = new List<RequestTypeListSeriesDto>(requestTypes.Count);
foreach (var requestType in requestTypes)
{
var partitions = await BuildSeriesAsync(requestType, cancellationToken);
result.Add(new RequestTypeListSeriesDto(requestType.Id, partitions));
}
return Result.Ok<IReadOnlyList<RequestTypeListSeriesDto>>(result);
}
private async Task<IReadOnlyList<RequestListPartitionDto>> BuildSeriesAsync(
RequestType requestType, CancellationToken cancellationToken)
{
// Тип без настроенного списка — пустая серия, а не ошибка: в админке такой
// тип заявок вполне может существовать до привязки к SharePoint.
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
return [];
var archived = await registry.GetArchivedAsync(requestType, cancellationToken);
var current = await registry.BuildCurrentAsync(requestType, archived, cancellationToken);
var result = new List<RequestListPartitionDto>(archived.Count + 1);
result.AddRange(archived.Select(x =>
new RequestListPartitionDto(x.Number, requestType.SiteUrl!, x.ListId, x.Title, IsCurrent: false, x.CreatedAt)));
result.Add(current);
return result;
}
/// <summary>
/// Число элементов в списке. Ошибку SharePoint глушим: эндпоинт обязан отдать
/// адрес списка даже когда SharePoint недоступен, иначе портал не сможет
/// принять заявку из-за сбоя необязательной проверки порога.
/// </summary>
private async Task<int?> TryGetItemCountAsync(string siteUrl, string listId, CancellationToken cancellationToken)
{
if (!options.Value.CheckThresholdOnRead) return null;
try
{
return await sharePoint.GetItemCountAsync(siteUrl, listId, cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось получить число элементов списка {ListId} — порог не проверен.", listId);
return null;
}
}
/// <summary>
/// Ставит задачу создания следующей партиции, если порог достигнут. Возвращает
/// true, только если задача действительно поставлена.
/// </summary>
private async Task<bool> TryEnqueueProvisioningAsync(
RequestType requestType, int currentNumber, int? itemCount, CancellationToken cancellationToken)
{
if (itemCount is null || itemCount < options.Value.ItemCountThreshold) return false;
// Следующая партиция уже создана, а запись на неё ещё не переключили —
// задача не нужна (и переключать здесь нельзя: этим владеет провижининг).
var hasNext = await db.RequestListPartitions
.AnyAsync(x => x.RequestTypeId == requestType.Id && x.Number > currentNumber, cancellationToken);
if (hasNext) return false;
// Провижининг уже идёт. Проверка неатомарная и это нормально: она экономит
// постановку лишней задачи, а настоящую гонку ловит захват блокировки внутри
// самой задачи (см. CreateNextListPartitionJob).
if (requestType.ListPartitioningStartedAt is not null) return false;
// Токен ЭТОГО запроса уезжает в задачу: подписать новый список на
// уведомления нужно от имени пользователя, а своего токена у фоновой задачи
// нет (см. CreateNextListPartitionJob.RunAsync — там же про его хранение и
// срок жизни).
backgroundJobs.Enqueue<CreateNextListPartitionJob>(
job => job.RunAsync(requestType.Id, callerToken.Current, CancellationToken.None));
logger.LogInformation(
"В списке {ListId} типа заявок {RequestTypeId} {ItemCount} элементов (порог {Threshold}) — " +
"поставлена задача создания партиции №{NextNumber}.",
requestType.ListId, requestType.Id, itemCount, options.Value.ItemCountThreshold, currentNumber + 1);
return true;
}
}
@@ -0,0 +1,31 @@
namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
/// <summary>Настройки нумерованных партиций списков заявок (секция ListPartitioning в конфигурации).</summary>
public sealed class ListPartitioningOptions
{
/// <summary>
/// Порог элементов в списке данных, по достижении которого ставится задача на
/// создание следующей партиции (пары списков).
///
/// 5000 — не предел SharePoint на число элементов (их может быть миллионы), а
/// «list view threshold»: запросы, которым приходится просмотреть больше 5000
/// элементов без подходящего индекса, SharePoint отклоняет. Порог намеренно чуть
/// ниже, чтобы задача успела создать следующую партицию до того, как текущая
/// начнёт упираться.
/// </summary>
public int ItemCountThreshold { get; set; } = 4500;
/// <summary>
/// Через сколько блокировка провижининга считается брошенной и может быть
/// перехвачена (процесс упал, не сняв флаг). См. RequestType.ListPartitioningStartedAt.
/// </summary>
public TimeSpan StaleLockAfter { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Проверять ли порог в эндпоинте «текущий список». false — эндпоинт только
/// отдаёт список, не обращаясь к SharePoint за числом элементов и никого не
/// ставя в очередь. Полезно, если SharePoint временно недоступен или нужно
/// приостановить автосоздание партиций, не трогая остальную логику.
/// </summary>
public bool CheckThresholdOnRead { get; set; } = true;
}
@@ -3,6 +3,7 @@ using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
@@ -20,6 +21,7 @@ namespace Tnsu.ApprovalRoute.Application.Reminders;
public sealed class ReminderService(
ApprovalRouteDbContext db,
ISharePointClient sharePoint,
ListPartitionRegistry partitionRegistry,
IOptions<RemindersOptions> options,
ILogger<ReminderService> logger)
{
@@ -62,14 +64,60 @@ public sealed class ReminderService(
}
}
/// <summary>
/// Обходит ВСЕ партиции типа заявки (архив + текущая) — заявка, уехавшая в
/// архив, продолжает ждать чьего-то решения точно так же, как в текущем
/// списке, и не должна пропадать из напоминаний только из-за того, что для
/// новых заявок завели следующий список.
///
/// request_list_partitions хранит только архив (см. ListPartitionRegistry) —
/// текущая партиция синтезируется отдельно и добавляется к архиву здесь же,
/// а не читается из БД.
/// </summary>
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeAsync(
RequestType type, DateTimeOffset now, CancellationToken cancellationToken)
{
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
var partitions = archived
.Select(x => (x.ListId, x.Number))
.Append((current.ListId, current.Number));
var reminders = new List<ReminderDto>();
foreach (var (listId, number) in partitions)
{
try
{
reminders.AddRange(await GetOverdueForPartitionAsync(type, listId, number, now, cancellationToken));
}
catch (Exception e)
{
// Недоступная партиция (например, архивный список переименовали
// или удалили вручную) не должна обнулять напоминания по
// остальным партициям того же типа.
logger.LogError(
e, "Напоминания по списку {ListId} (партиция №{Number}) типа «{Type}» не собраны",
listId, number, type.Name);
}
}
return reminders;
}
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForPartitionAsync(
RequestType type, string listId, int number, DateTimeOffset now, CancellationToken cancellationToken)
{
var siteUrl = type.SiteUrl!;
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, type.ListId!, cancellationToken);
// Читаем Title ЖИВЬЁМ — заголовок могли переименовать вручную, а по url
// список найдётся всегда.
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
if (string.IsNullOrWhiteSpace(dataListTitle))
{
logger.LogWarning("Список данных типа «{Type}» не найден по url {Url}", type.Name, type.ListId);
logger.LogWarning(
"Список данных партиции №{Number} типа «{Type}» не найден по url {Url}",
number, type.Name, listId);
return [];
}
@@ -1,12 +1,14 @@
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
public sealed class RequestTypeService(ApprovalRouteDbContext db)
public sealed class RequestTypeService(ApprovalRouteDbContext db, ICurrentUser currentUser, ILogger<RequestTypeService> logger)
{
public async Task<Result<IReadOnlyList<RequestTypeDto>>> ListAsync(CancellationToken cancellationToken)
{
@@ -45,6 +47,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
};
db.RequestTypes.Add(entity);
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation("Тип заявки {RequestTypeId} создан пользователем {Author}", entity.Id, currentUser.Email);
return Result.Ok(ToDto(entity));
}
@@ -67,6 +70,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
entity.SlaHours = request.SlaHours;
entity.AskOverdueReason = request.AskOverdueReason;
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation("Тип заявки {RequestTypeId} изменён пользователем {Author}", entity.Id, currentUser.Email);
return Result.Ok(ToDto(entity));
}
@@ -95,6 +99,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
entity.SlaHours = request.SlaHours ?? entity.SlaHours;
entity.AskOverdueReason = request.AskOverdueReason ?? entity.AskOverdueReason;
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation("Тип заявки {RequestTypeId} изменён пользователем {Author}", entity.Id, currentUser.Email);
return Result.Ok(ToDto(entity));
}
@@ -107,6 +112,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
db.RequestTypes.Remove(entity);
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation("Тип заявки {RequestTypeId} удалён пользователем {Author}", id, currentUser.Email);
return Result.Ok();
}
@@ -1,13 +1,16 @@
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Domain.Enums;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
public sealed class TemplateRouteStepService(
ApprovalRouteDbContext db, ICurrentUser currentUser, ILogger<TemplateRouteStepService> logger)
{
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListAsync(int routeId, CancellationToken cancellationToken)
{
@@ -79,6 +82,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
};
db.TemplateRouteSteps.Add(entity);
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Шаг {StepId} маршрута {RouteId} создан пользователем {Author}", entity.Id, route.Id, currentUser.Email);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
}
@@ -130,6 +135,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
entity.SlaHours = r.SlaHours;
entity.AskOverdueReason = r.AskOverdueReason;
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
}
@@ -199,6 +206,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
entity.SlaHours = r.SlaHours ?? entity.SlaHours;
entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason;
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
}
@@ -211,6 +220,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
db.TemplateRouteSteps.Remove(entity);
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Шаг {StepId} маршрута {RouteId} удалён пользователем {Author}", id, routeId, currentUser.Email);
return Result.Ok();
}
@@ -1,6 +1,7 @@
using System.Globalization;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
@@ -15,7 +16,8 @@ public sealed class TemplateRouteService(
ApprovalRouteDbContext db,
ITnsuPortalClient portalClient,
ICurrentUser currentUser,
IOptions<TnsuPortalOptions> portalOptions)
IOptions<TnsuPortalOptions> portalOptions,
ILogger<TemplateRouteService> logger)
{
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(int? requestTypeId, CancellationToken cancellationToken)
{
@@ -61,6 +63,7 @@ public sealed class TemplateRouteService(
};
db.TemplateRoutes.Add(entity);
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation("Маршрут {RouteId} создан пользователем {Author}", entity.Id, currentUser.Email);
return Result.Ok(ToDto(entity));
}
@@ -81,6 +84,7 @@ public sealed class TemplateRouteService(
entity.ByProject = r.ByProject;
entity.RequiresControl = r.RequiresControl;
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation("Маршрут {RouteId} изменён пользователем {Author}", entity.Id, currentUser.Email);
return Result.Ok(ToDto(entity));
}
@@ -105,6 +109,7 @@ public sealed class TemplateRouteService(
entity.ByProject = byProject;
entity.RequiresControl = r.RequiresControl ?? entity.RequiresControl;
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation("Маршрут {RouteId} изменён пользователем {Author}", entity.Id, currentUser.Email);
return Result.Ok(ToDto(entity));
}
@@ -117,6 +122,7 @@ public sealed class TemplateRouteService(
db.TemplateRoutes.Remove(entity);
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation("Маршрут {RouteId} удалён пользователем {Author}", id, currentUser.Email);
return Result.Ok();
}
@@ -261,9 +267,12 @@ public sealed class TemplateRouteService(
}
else if (!string.IsNullOrWhiteSpace(step.Position))
{
resolvedEmail = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken);
if (string.IsNullOrWhiteSpace(resolvedEmail))
var employeeByPosition = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken);
resolvedEmail = employeeByPosition.Email;
if (!employeeByPosition.Found)
warning = $"Не найден сотрудник на должности «{step.Position}» в компании «{initiatorCompany}».";
else if (string.IsNullOrWhiteSpace(resolvedEmail))
warning = $"Сотрудник {employeeByPosition.Fio} на должности «{step.Position}» найден, но email не указан в системе портала.";
}
else if (step.ProjectRole is not (null or ProjectRole.None))
{
@@ -7,6 +7,7 @@
<PackageReference Include="FluentResults" />
<PackageReference Include="FluentValidation" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
<PackageReference Include="Hangfire.Core" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
</ItemGroup>
</Project>
@@ -0,0 +1,47 @@
namespace Tnsu.ApprovalRoute.Domain.Entities;
/// <summary>
/// Один SharePoint-список данных из нумерованной серии по типу заявок.
///
/// Списки заявок нумерованные («&lt;Форма&gt; &lt;N&gt; — данные»): когда в текущем
/// списке набирается порог элементов, рядом создаётся следующая пара (партиция), а
/// прежняя остаётся архивом только для чтения. Эта таблица — реестр всей серии: без
/// неё поиск по заявкам типа не знает, по каким спискам искать.
///
/// Текущий (тот, куда идёт запись) список продолжает лежать в
/// <see cref="RequestType.ListId"/> — реестр его дублирует строкой с наибольшим
/// <see cref="Number"/>, но источником правды для записи остаётся RequestType.
///
/// Сайта у партиции нет намеренно: вся серия живёт на одном сайте — том, что указан
/// в <see cref="RequestType.SiteUrl"/> на момент создания партиции. Хранить его
/// отдельно на каждой партиции — заводить возможность архивам разъехаться по разным
/// сайтам, а такой сценарий не поддерживается ни провижинером (см.
/// SharePointListProvisioner — всегда пишет на siteUrl запрашивающего типа заявок),
/// ни поиском. Если сайт типа заявок когда-нибудь придётся сменить, старые партиции
/// нужно будет переносить осознанно, а не молча продолжать читать их со старого адреса.
/// </summary>
public class RequestListPartition
{
public int Id { get; set; }
public int RequestTypeId { get; set; }
public RequestType? RequestType { get; set; }
/// <summary>Номер партиции в серии, с 1. Уникален в пределах типа заявок.</summary>
public int Number { get; set; }
/// <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; }
}
@@ -25,5 +25,23 @@ public class RequestType
/// </summary>
public bool? AskOverdueReason { get; set; }
/// <summary>
/// Момент, когда фоновая задача создания следующей партиции списка взяла эту
/// строку в работу. null — задача не выполняется.
///
/// Одновременно и признак «идёт провижининг», и БЛОКИРОВКА: задача стартует
/// только если сумела перевести поле из null в now() одним условным UPDATE
/// (см. ListPartitioningLock) — тогда две параллельные задачи по одному типу
/// заявок невозможны.
///
/// Метка времени, а не bool, намеренно: упавший процесс не успевает снять
/// флаг, и bool заблокировал бы тип навсегда, а по метке видно, что блокировка
/// просрочена, и её можно перехватить.
/// </summary>
public DateTimeOffset? ListPartitioningStartedAt { get; set; }
public ICollection<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>();
/// <summary>Нумерованная серия партиций списка этого типа заявок (архив + текущая).</summary>
public ICollection<RequestListPartition> ListPartitions { get; set; } = new List<RequestListPartition>();
}
@@ -20,15 +20,32 @@ public static class InfrastructureServiceCollectionExtensions
opt.UseNpgsql(configuration.GetConnectionString("Postgres"))
.UseSnakeCaseNamingConvention());
services.AddAsyncInitializer<DatabaseMigrationInitializer>();
services.AddScoped<ListPartitioningLock>();
services.Configure<SharePointOptions>(configuration.GetSection("SharePoint"));
// Провайдер сертификата — синглтон: разобранный .pfx кешируется на всё
// время жизни процесса (см. DbCertificateProvider).
services.AddSingleton<ICertificateProvider, DbCertificateProvider>();
services.AddHttpClient<ISharePointClient, SharePointClient>();
services.AddScoped<ISharePointListProvisioner, SharePointListProvisioner>();
services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal"));
services.AddHttpClient<TnsuPortalTokenProvider>();
// Токен вызывающего и общая авторизация запросов к порталу — одни на всех
// его клиентов (см. TnsuPortalCallerToken/TnsuPortalAuthorizer). Именно
// Scoped: Hangfire даёт задаче свою область DI, и положенный в задаче токен
// не должен протекать в соседние.
services.AddScoped<TnsuPortalCallerToken>();
services.AddScoped<TnsuPortalAuthorizer>();
// Подписка новых списков на уведомления SharePoint. Клиент отдельный от
// ITnsuPortalClient: там кешируемые чтения справочника, здесь команда.
// Своих опций нет — адрес приёмника лежит в SharePoint:NotificationUrl.
services.AddHttpClient<ISharePointWebhookSubscriber, HttpSharePointWebhookSubscriber>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<TnsuPortalOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl);
});
// ITnsuPortalClient резолвится в CachingTnsuPortalClient (кеш на 5 минут,
// см. её комментарий) — сам HttpTnsuPortalClient регистрируем по
// конкретному типу, а не по интерфейсу, чтобы декоратор мог его получить
@@ -7,6 +7,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
{
public DbSet<RequestType> RequestTypes => Set<RequestType>();
public DbSet<RequestListPartition> RequestListPartitions => Set<RequestListPartition>();
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
public DbSet<AppSetting> Settings => Set<AppSetting>();
@@ -33,6 +34,18 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
e.Property(x => x.Section).HasMaxLength(256).IsRequired();
});
modelBuilder.Entity<RequestListPartition>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.ListId).HasMaxLength(512).IsRequired();
e.Property(x => x.Title).HasMaxLength(512);
e.HasOne(x => x.RequestType).WithMany(x => x.ListPartitions).HasForeignKey(x => x.RequestTypeId);
// Номер уникален в пределах типа заявок — вторая попытка записать тот же
// номер (например, при двойном запуске задачи) упадёт на этом индексе,
// а не создаст в реестре дубль.
e.HasIndex(x => new { x.RequestTypeId, x.Number }).IsUnique();
});
modelBuilder.Entity<TemplateRoute>(e =>
{
e.HasKey(x => x.Id);
@@ -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 ListPartitioningLock(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_partitioning_started_at = now()
WHERE id = {requestTypeId}
AND (list_partitioning_started_at IS NULL
OR list_partitioning_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_partitioning_started_at = NULL WHERE id = {requestTypeId}",
cancellationToken);
}
@@ -0,0 +1,314 @@
// <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("20260817102423_AddListPartitioning")]
partial class AddListPartitioning
{
/// <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.RequestListPartition", 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>("Title")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("title");
b.HasKey("Id")
.HasName("pk_request_list_partitions");
b.HasIndex("RequestTypeId", "Number")
.IsUnique()
.HasDatabaseName("ix_request_list_partitions_request_type_id_number");
b.ToTable("request_list_partitions", (string)null);
});
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?>("ListPartitioningStartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("list_partitioning_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.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.RequestListPartition", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("ListPartitions")
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_request_list_partitions_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("ListPartitions");
b.Navigation("Routes");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Navigation("Steps");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,62 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddListPartitioning : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "list_partitioning_started_at",
table: "request_types",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateTable(
name: "request_list_partitions",
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),
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_list_partitions", x => x.id);
table.ForeignKey(
name: "fk_request_list_partitions_request_types_request_type_id",
column: x => x.request_type_id,
principalTable: "request_types",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_request_list_partitions_request_type_id_number",
table: "request_list_partitions",
columns: new[] { "request_type_id", "number" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "request_list_partitions");
migrationBuilder.DropColumn(
name: "list_partitioning_started_at",
table: "request_types");
}
}
}
@@ -22,6 +22,48 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", 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>("Title")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("title");
b.HasKey("Id")
.HasName("pk_request_list_partitions");
b.HasIndex("RequestTypeId", "Number")
.IsUnique()
.HasDatabaseName("ix_request_list_partitions_request_type_id_number");
b.ToTable("request_list_partitions", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Property<int>("Id")
@@ -40,6 +82,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(512)")
.HasColumnName("list_id");
b.Property<DateTimeOffset?>("ListPartitioningStartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("list_partitioning_started_at");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
@@ -212,6 +258,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.ToTable("settings", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("ListPartitions")
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_request_list_partitions_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
@@ -238,6 +296,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Navigation("ListPartitions");
b.Navigation("Routes");
});
@@ -0,0 +1,27 @@
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
/// <summary>Созданный список: серверный относительный url (он же ListId) и Title.</summary>
public sealed record ProvisionedList(string ListId, string Title);
/// <summary>
/// Создание следующей пары списков серии в SharePoint («&lt;Форма&gt; &lt;N&gt; — данные»
/// + «&lt;Форма&gt; &lt;N&gt; — согласование») дублированием текущей пары.
///
/// Интерфейс живёт в 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>
/// Конвенция имён списков заявок: «&lt;Форма&gt; &lt;N&gt; — данные» и парный ему
/// «&lt;Форма&gt; &lt;N&gt; — согласование».
///
/// Третья копия одного и того же соглашения — оно же в провижинере 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;
/// <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
{
/// <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>
Task<string?> GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
/// <summary>
/// Число элементов в списке (ItemCount самого списка, без выгрузки элементов) —
/// по нему решается, не пора ли заводить следующий список серии.
/// </summary>
Task<int?> GetItemCountAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
/// <summary>Элементы списка данных, найденного по серверному относительному URL.</summary>
Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken);
@@ -50,6 +84,116 @@ public sealed class SharePointClient(
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, ни CanBeDeleted не годятся как признак «колонку явно
// добавили в список»: у переиспользуемых стандартных site-колонок SharePoint
// (например, «Department» — встроенная текстовая колонка из схемы v3,
// подключаемая через DelayActivateTemplateBinding) ОБА флага стоят как у
// настоящего системного поля, хотя колонка реально добавлена в список и в
// неё пишут данные. Из-за этого такие колонки тихо выпадали из копирования —
// без ошибки, просто без предупреждения.
//
// Поэтому здесь фильтруем только Hidden/ReadOnlyField (их создать всё равно
// нельзя), а отделять настоящие базовые поля (Title, ID, ContentType …) от
// явно добавленных — задача вызывающего кода: он сравнивает с полями
// ТОЛЬКО ЧТО СОЗДАННОГО пустого списка того же шаблона (см. CloneAsync,
// alreadyThere) — то, что есть на обоих, действительно базовое; то, что
// есть только на источнике, добавлено явно, независимо от флагов SharePoint.
var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/fields" +
"?$select=InternalName,SchemaXml,Hidden,ReadOnlyField" +
"&$filter=Hidden eq false and ReadOnlyField 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(
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) =>
GetItemsAsync(
@@ -79,9 +223,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(
"Bearer", await GetTokenAsync(siteUrl, cancellationToken));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")
@@ -89,14 +250,30 @@ public sealed class SharePointClient(
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);
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)
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();
}
@@ -0,0 +1,174 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
/// <summary>
/// Создаёт следующую пару списков ДУБЛИРОВАНИЕМ текущей: берёт структуру живого
/// списка (его пользовательские колонки) и повторяет её в новом списке с
/// номером N+1. Манифесты provisioning.json request-portal при этом не читаются —
/// источник структуры сам SharePoint, поэтому дубль не может разойтись с тем, что
/// реально в списке.
///
/// Идемпотентен: список с целевым именем переиспользуется, уже существующие колонки
/// пропускаются. Повторный запуск после частичного сбоя дозаполняет недостающее, а
/// не создаёт второй список.
/// </summary>
public sealed class SharePointListProvisioner(
ISharePointClient sharePoint,
ISharePointWebhookSubscriber webhookSubscriber,
ILogger<SharePointListProvisioner> logger) : ISharePointListProvisioner
{
public async Task<ProvisionedList> CreateNextListPairAsync(
string siteUrl,
string sourceDataListId,
int number,
CancellationToken cancellationToken)
{
logger.LogInformation(
"Дублирую пару списков №{Number} на {SiteUrl} по образцу {SourceListId}.",
number, siteUrl, sourceDataListId);
var stopwatch = Stopwatch.StartNew();
var sourceData = await sharePoint.GetListByUrlAsync(siteUrl, sourceDataListId, cancellationToken)
?? throw new InvalidOperationException(
$"Исходный список {sourceDataListId} не найден на {siteUrl} — дублировать нечего.");
var newData = await CloneAsync(
siteUrl,
sourceData,
ListTitleConvention.WithNumber(sourceData.Title, number),
role: "данные",
retarget: null,
cancellationToken);
await CloneApprovalPairAsync(siteUrl, sourceData, newData, number, cancellationToken);
logger.LogInformation(
"Пара списков №{Number} готова за {ElapsedMs} мс: данные «{Title}» ({ListId}).",
number, stopwatch.ElapsedMilliseconds, newData.Title, newData.ServerRelativeUrl);
// Подписка — ПОСЛЕДНИМ шагом, когда списки уже созданы:
// • подписывать нечего, пока списка нет;
// • только для списка ДАННЫХ — заявки портал отслеживает по нему, списку
// согласования подписка не нужна;
// • в самом конце, а не сразу после создания списка данных: сорвись
// дублирование пары на списке согласования, подписка была бы уже
// заведена, и повтор задачи Hangfire отправил бы второй запрос на ту же
// подписку (идемпотентна ли ручка портала — неизвестно).
// Не бросает и провижининг не рушит (см. ISharePointWebhookSubscriber).
await webhookSubscriber.TrySubscribeAsync(siteUrl, newData.Title, 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),
role: "согласование",
// Ticket списка согласования смотрит на список данных — в дубле он должен
// смотреть на НОВЫЙ список данных, иначе строки маршрута новых заявок
// будут ссылаться в старый список.
retarget: (sourceData.Id, newData.Id),
cancellationToken);
}
/// <summary>
/// role — только для логов («данные»/«согласование»): по нему в логе видно, какую
/// половину пары обрабатывает конкретный вызов, не заглядывая в стек вызовов.
/// </summary>
private async Task<SharePointListInfo> CloneAsync(
string siteUrl,
SharePointListInfo source,
string newTitle,
string role,
(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
? "Список {Role} «{Title}» создан."
: "Список {Role} «{Title}» уже существует — дозаполняю недостающие колонки.",
role, 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);
var copied = 0;
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);
copied++;
// По каждой колонке — только на Debug: при штатной первой партии их
// десятки на список, и на Information это был бы шум. Итог всё равно
// виден в сводке ниже.
logger.LogDebug(
"Список {Role} «{Title}»: скопирована колонка {InternalName}.", role, newTitle, field.InternalName);
}
logger.LogInformation(
"Список {Role} «{Title}»: скопировано колонок {Copied}, уже было {AlreadyThere}.",
role, newTitle, copied, sourceFields.Count - copied);
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;
}
}
@@ -20,4 +20,15 @@ public sealed class SharePointOptions
/// это здесь не стоит — пароль от сертификата в исходниках не место.
/// </summary>
public string CertificatePassword { get; set; } = string.Empty;
/// <summary>
/// Куда SharePoint будет присылать уведомления об изменениях списка — параметр
/// notificationUrl ручки POST /Webhook/Subscribe, которой новый список данных
/// подписывается на уведомления (см. HttpSharePointWebhookSubscriber).
/// Это приёмник на СТОРОНЕ ПОРТАЛА, а не адрес нашего сервиса.
///
/// Пусто — подписывать не будем вовсе: без адреса приёмника подписка
/// бессмысленна, и это же единственный способ её выключить.
/// </summary>
public string NotificationUrl { get; set; } = "https://api.tnsu.kz/Webhook/SharePoint";
}
@@ -22,7 +22,7 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC
$"emp:email:{email.ToLowerInvariant()}",
() => inner.GetEmployeeByEmailAsync(email, cancellationToken));
public Task<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) =>
public Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) =>
GetOrAddAsync(
$"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}",
() => inner.GetEmployeeByPositionAsync(position, company, cancellationToken));
@@ -0,0 +1,94 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
/// <summary>
/// POST {BaseUrl}/Webhook/Subscribe?listName=…&amp;url=…&amp;notificationUrl=… — просит
/// портал завести подписку SharePoint на только что созданный список данных.
///
/// Параметры уходят строкой запроса, тела у запроса нет — так описана ручка.
///
/// Отдельный клиент, а не метод в ITnsuPortalClient: тот про справочник сотрудников
/// и обёрнут кешем (CachingTnsuPortalClient), а здесь команда, которую кешировать
/// нельзя ни в каком виде. Авторизация общая с ним — см. TnsuPortalAuthorizer.
///
/// Адрес приёмника уведомлений живёт в SharePoint:NotificationUrl — в одном блоке с
/// остальными настройками SharePoint, а не в своём: подписка относится к спискам,
/// а не к порталу как таковому.
///
/// Ошибки НЕ пробрасываются наружу. Список к этому моменту уже создан, и уронить
/// провижининг из-за недоступности портала значило бы оставить партиционирование
/// нерабочим целиком (задача Hangfire ушла бы в повторы, а порог по элементам
/// продолжал бы срабатывать). Поэтому сбой — Error в лог с полным контекстом
/// (имя списка и сайт видны, подписку можно завести руками), а провижининг идёт
/// дальше. Отдельно предупреждаем про 401/403: в фоновой задаче нет входящего
/// токена, а client_credentials требует заполненных TnsuPortal:ClientSecret/Scope
/// (в конфигурации они пустые — см. TnsuPortalAuthorizer).
/// </summary>
public sealed class HttpSharePointWebhookSubscriber(
HttpClient httpClient,
TnsuPortalAuthorizer authorizer,
IOptions<SharePointOptions> options,
ILogger<HttpSharePointWebhookSubscriber> logger) : ISharePointWebhookSubscriber
{
public async Task<bool> TrySubscribeAsync(string siteUrl, string listTitle, CancellationToken cancellationToken)
{
var notificationUrl = options.Value.NotificationUrl;
if (string.IsNullOrWhiteSpace(notificationUrl))
{
// Без адреса приёмника подписка бессмысленна — он же служит выключателем
// (см. SharePointOptions.NotificationUrl).
logger.LogInformation(
"SharePoint:NotificationUrl не задан — список «{ListTitle}» на уведомления не подписан.",
listTitle);
return false;
}
if (string.IsNullOrWhiteSpace(listTitle))
{
// Без имени списка ручке нечего подписывать: у неё нет другого способа
// его найти (адрес списка она не принимает).
logger.LogError("Заголовок нового списка пуст — подписка на уведомления невозможна.");
return false;
}
// Полный адрес сайта целиком («https://tnsukz.sharepoint.com/sites/requests»),
// а не короткое имя — именно это ждёт ручка.
var site = siteUrl.TrimEnd('/');
var url =
$"/Webhook/Subscribe?listName={Uri.EscapeDataString(listTitle)}" +
$"&url={Uri.EscapeDataString(site)}" +
$"&notificationUrl={Uri.EscapeDataString(notificationUrl)}";
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, url);
await authorizer.AuthorizeAsync(request, cancellationToken);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
// Тело читаем именно на неуспехе — в нём причина отказа портала.
var body = await response.Content.ReadAsStringAsync(cancellationToken);
logger.LogError(
"Портал отказал в подписке списка «{ListTitle}» (сайт {Site}): {StatusCode}. Ответ: {Body}",
listTitle, site, (int)response.StatusCode, body);
return false;
}
logger.LogInformation(
"Список «{ListTitle}» (сайт {Site}) подписан на уведомления SharePoint.", listTitle, site);
return true;
}
catch (Exception ex)
{
logger.LogError(
ex,
"Не удалось подписать список «{ListTitle}» (сайт {Site}) на уведомления — подпишите вручную.",
listTitle, site);
return false;
}
}
}
@@ -1,17 +1,14 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
public sealed class HttpTnsuPortalClient(
HttpClient httpClient,
TnsuPortalTokenProvider tokenProvider,
IHttpContextAccessor httpContextAccessor) : ITnsuPortalClient
TnsuPortalAuthorizer authorizer) : ITnsuPortalClient
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
@@ -25,12 +22,12 @@ public sealed class HttpTnsuPortalClient(
return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId, employee.Company ?? string.Empty, employee.Position);
}
public async Task<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken)
public async Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken)
{
var employee = await GetAsync<EmployeeResponse>(
$"/Employee/ByPosition?position={Uri.EscapeDataString(position)}&company={Uri.EscapeDataString(company)}", cancellationToken);
return employee?.Email;
return new EmployeeLookupResult(employee is not null, employee?.Email, employee?.Fio);
}
public async Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken)
@@ -73,7 +70,7 @@ public sealed class HttpTnsuPortalClient(
private async Task<T?> GetAsync<T>(string requestUri, CancellationToken cancellationToken) where T : class
{
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
await AuthorizeAsync(request, cancellationToken);
await authorizer.AuthorizeAsync(request, cancellationToken);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
@@ -89,25 +86,13 @@ public sealed class HttpTnsuPortalClient(
executors.Add(new ProjectExecutorInfo(role, email));
}
private async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
if (!string.IsNullOrWhiteSpace(incoming))
{
request.Headers.TryAddWithoutValidation("Authorization", incoming);
return;
}
var token = await tokenProvider.GetAccessTokenAsync(cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
private sealed record EmployeeResponse(
[property: JsonPropertyName("email")] string? Email,
[property: JsonPropertyName("company")] string? Company,
[property: JsonPropertyName("companyId")] Guid? CompanyId,
[property: JsonPropertyName("departmentId")] Guid? DepartmentId,
[property: JsonPropertyName("position")] string? Position);
[property: JsonPropertyName("position")] string? Position,
[property: JsonPropertyName("fio")] string? Fio);
private sealed record ManagerResponse(
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
@@ -0,0 +1,16 @@
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
/// <summary>
/// Подписывает список данных SharePoint на уведомления об изменениях через портал.
/// </summary>
public interface ISharePointWebhookSubscriber
{
/// <summary>
/// Подписать список. НЕ бросает: подписка — следствие создания списка, а не его
/// условие (см. реализацию), поэтому сбой портала не должен рушить провижининг
/// партиции. Возвращает true, только если портал подтвердил подписку.
/// </summary>
/// <param name="siteUrl">Абсолютный адрес сайта, где создан список.</param>
/// <param name="listTitle">Заголовок списка данных — русское имя, как в SharePoint.</param>
Task<bool> TrySubscribeAsync(string siteUrl, string listTitle, CancellationToken cancellationToken);
}
@@ -4,7 +4,7 @@ public interface ITnsuPortalClient
{
Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken);
Task<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
@@ -0,0 +1,39 @@
using System.Net.Http.Headers;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
/// <summary>
/// Авторизация запросов к api.tnsu.kz. Вынесена из HttpTnsuPortalClient, потому что
/// к порталу ходит не только справочник сотрудников (см.
/// HttpSharePointWebhookSubscriber), а правило выбора токена должно быть одно.
///
/// Токен берётся в двух режимах, и порядок принципиален:
/// 1) Токен вызывающего пользователя (см. TnsuPortalCallerToken) — из текущего
/// HTTP-запроса либо переданный аргументом в фоновую задачу. Портал тогда
/// видит исходного пользователя, а не сервисное приложение.
/// 2) Его нет вовсе — client_credentials самого сервиса (см.
/// TnsuPortalTokenProvider).
///
/// ВАЖНО про второй путь: TnsuPortal:ClientSecret/Scope в конфигурации пусты
/// (см. appsettings.json), поэтому он сейчас нерабочий — это именно фолбэк, а не
/// штатный режим. Вызывающая сторона обязана это учитывать: см.
/// HttpSharePointWebhookSubscriber, где сбой подписки намеренно не рушит
/// провижининг списка.
/// </summary>
public sealed class TnsuPortalAuthorizer(
TnsuPortalCallerToken callerToken,
TnsuPortalTokenProvider tokenProvider)
{
public async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var caller = callerToken.Current;
if (!string.IsNullOrWhiteSpace(caller))
{
request.Headers.TryAddWithoutValidation("Authorization", caller);
return;
}
var token = await tokenProvider.GetAccessTokenAsync(cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
}
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Http;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
/// <summary>
/// Authorization, с которым идти в api.tnsu.kz в пределах текущей области DI.
///
/// Два источника, и второй нужен именно фоновым задачам:
/// • обрабатывается HTTP-запрос — заголовок берётся прямо из него;
/// • выполняется задача Hangfire — своего HttpContext'а у неё нет, поэтому
/// токен инициировавшего пользователя ей передают аргументом и кладут сюда
/// через <see cref="Set"/> (см. CreateNextListPartitionJob).
///
/// Scoped: Hangfire создаёт отдельную область DI на каждое выполнение задачи,
/// поэтому положенный здесь токен не протекает между задачами.
/// </summary>
public sealed class TnsuPortalCallerToken(IHttpContextAccessor httpContextAccessor)
{
private string? _explicit;
/// <summary>
/// Заголовок целиком, вида «Bearer …». null — вызывающего токена нет
/// (тогда авторизация уходит на client_credentials, см. TnsuPortalAuthorizer).
/// </summary>
public string? Current
{
get
{
var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
return string.IsNullOrWhiteSpace(incoming) ? _explicit : incoming;
}
}
/// <summary>Положить токен явно — для кода без HttpContext (фоновые задачи).</summary>
public void Set(string? authorization) =>
_explicit = string.IsNullOrWhiteSpace(authorization) ? null : authorization;
}
@@ -5,3 +5,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId, string Company, string? Position);
public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email);
/// <summary>Найден ли сотрудник вообще — отдельно от того, заполнен ли у него email в портале (это разные проблемы).</summary>
public sealed record EmployeeLookupResult(bool Found, string? Email, string? Fio);
@@ -0,0 +1,137 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Shouldly;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// request_list_partitions хранит ТОЛЬКО архив — партиция, которая ни разу не
/// архивировалась, не должна получать строку просто от того, что её прочитали
/// (см. ListPartitionRegistry). Раньше самозаполняющийся реестр писал строку
/// уже на первое чтение, из-за чего один и тот же список оказывался записан
/// дважды: и в RequestType.ListId, и строкой в этой таблице.
/// </summary>
public sealed class ListPartitionRegistryTests(PostgresDatabaseFixture fixture) : IClassFixture<PostgresDatabaseFixture>
{
private readonly ApprovalRouteDbContext _db =
TestServiceProviderFactory.Build(fixture.ConnectionString).GetRequiredService<ApprovalRouteDbContext>();
private ListPartitionRegistry BuildRegistry() =>
new(_db, Mock.Of<ISharePointClient>(), NullLogger<ListPartitionRegistry>.Instance);
private async Task<RequestType> CreateTypeAsync(string listId)
{
var type = new RequestType
{
Name = $"Тест {Guid.NewGuid():N}",
PageUrl = $"/test-{Guid.NewGuid():N}",
ListId = listId,
SiteUrl = "https://tnsukz.sharepoint.com/sites/requests",
Section = "General"
};
_db.RequestTypes.Add(type);
await _db.SaveChangesAsync();
return type;
}
[Fact(DisplayName = "Партиция, которую ни разу не архивировали, не пишется в request_list_partitions")]
public async Task BuildCurrentAsync_NeverArchived_DoesNotWriteRow()
{
// Arrange
var registry = BuildRegistry();
var type = await CreateTypeAsync("/sites/requests/Lists/1");
// Action
var archived = await registry.GetArchivedAsync(type, CancellationToken.None);
var current = await registry.BuildCurrentAsync(type, archived, CancellationToken.None);
// Assert
archived.ShouldBeEmpty();
current.Number.ShouldBe(1);
current.ListId.ShouldBe(type.ListId);
current.IsCurrent.ShouldBeTrue();
var rowsInDb = await _db.RequestListPartitions
.Where(x => x.RequestTypeId == type.Id)
.CountAsync();
rowsInDb.ShouldBe(0);
}
[Fact(DisplayName = "ArchiveCurrentAsync пишет ровно одну строку архива под номером 1")]
public async Task ArchiveCurrentAsync_WritesExactlyOneRow()
{
// Arrange
var registry = BuildRegistry();
var type = await CreateTypeAsync("/sites/requests/Lists/1");
// Action
var archived = await registry.ArchiveCurrentAsync(type, CancellationToken.None);
// Assert
archived.Number.ShouldBe(1);
archived.ListId.ShouldBe("/sites/requests/Lists/1");
var rowsInDb = await _db.RequestListPartitions
.Where(x => x.RequestTypeId == type.Id)
.ToListAsync();
rowsInDb.Count.ShouldBe(1);
rowsInDb[0].ListId.ShouldBe("/sites/requests/Lists/1");
}
[Fact(DisplayName = "ArchiveCurrentAsync идемпотентна — повтор не создаёт вторую строку")]
public async Task ArchiveCurrentAsync_CalledTwice_DoesNotDuplicate()
{
// Arrange
var registry = BuildRegistry();
var type = await CreateTypeAsync("/sites/requests/Lists/1");
// Action — как повторная попытка Hangfire после частичного сбоя: пока указатель
// ещё не переключён, GetArchivedAsync только что записанную строку не увидит
// (фильтрует по ListId != RequestType.ListId) — именно поэтому идемпотентность
// ArchiveCurrentAsync не должна зависеть от него (см. её doc-комментарий).
await registry.ArchiveCurrentAsync(type, CancellationToken.None);
await registry.ArchiveCurrentAsync(type, CancellationToken.None);
// Assert
var rowsInDb = await _db.RequestListPartitions
.Where(x => x.RequestTypeId == type.Id)
.CountAsync();
rowsInDb.ShouldBe(1);
}
[Fact(DisplayName = "После архивации и переключения текущая партиция получает следующий номер")]
public async Task BuildCurrentAsync_AfterArchiving_ReturnsNextNumber()
{
// Arrange: тип с одной партицией, которую сейчас архивируем (как
// CreateNextListPartitionJob перед переключением на следующую).
var registry = BuildRegistry();
var type = await CreateTypeAsync("/sites/requests/Lists/1");
await registry.ArchiveCurrentAsync(type, CancellationToken.None);
// Action: переключаем указатель на новую партицию (сама она архивной
// строки ещё не получает — см. doc-комментарий ListPartitionRegistry).
type.ListId = "/sites/requests/Lists/2";
await _db.SaveChangesAsync();
var archived = await registry.GetArchivedAsync(type, CancellationToken.None);
var current = await registry.BuildCurrentAsync(type, archived, CancellationToken.None);
// Assert
archived.Count.ShouldBe(1);
archived[0].ListId.ShouldBe("/sites/requests/Lists/1");
current.Number.ShouldBe(2);
current.ListId.ShouldBe("/sites/requests/Lists/2");
var rowsInDb = await _db.RequestListPartitions
.Where(x => x.RequestTypeId == type.Id)
.CountAsync();
rowsInDb.ShouldBe(1); // новая партиция строки ещё не получила
}
}
@@ -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 ListPartitioningLockTests(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<ListPartitioningLock>()
.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_partitioning_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<ListPartitioningLock>()
.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;
}
}
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Shouldly;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
@@ -102,6 +103,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
return new ReminderService(
_services.GetRequiredService<ApprovalRouteDbContext>(),
sharePoint.Object,
_services.GetRequiredService<ListPartitionRegistry>(),
Options.Create(new RemindersOptions()),
NullLogger<ReminderService>.Instance);
}
@@ -0,0 +1,189 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// Подписка нового списка данных на уведомления SharePoint (POST /Webhook/Subscribe).
///
/// Проверяется ровно то, что нельзя увидеть по типам: как складывается строка
/// запроса (ручка принимает всё параметрами, тела нет) и что сбой портала не рушит
/// провижининг — подписка следствие создания списка, а не его условие.
/// </summary>
public sealed class SharePointWebhookSubscriberTests
{
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
private const string ListTitle = "Обходной лист 3 — данные";
/// <summary>Перехватывает запрос вместо сети: запоминает его и отдаёт заданный ответ.</summary>
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
public HttpRequestMessage? Captured { get; private set; }
public int CallCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Captured = request;
CallCount++;
return Task.FromResult(respond(request));
}
}
// Токен вызывающего есть — тогда авторизатор пробрасывает его и не идёт за
// client_credentials (у которого в тестах нет ни секрета, ни сети).
// fromHttpContext=false — путь фоновой задачи: токен положен явно, HttpContext'а
// нет вовсе (см. TnsuPortalCallerToken.Set / CreateNextListPartitionJob).
private static TnsuPortalAuthorizer BuildAuthorizer(bool fromHttpContext = true)
{
var accessor = new HttpContextAccessor();
var callerToken = new TnsuPortalCallerToken(accessor);
if (fromHttpContext)
{
var context = new DefaultHttpContext();
context.Request.Headers.Authorization = "Bearer test-token";
accessor.HttpContext = context;
}
else
{
callerToken.Set("Bearer test-token");
}
return new TnsuPortalAuthorizer(
callerToken,
new TnsuPortalTokenProvider(new HttpClient(), Options.Create(new TnsuPortalOptions())));
}
private static (HttpSharePointWebhookSubscriber Subscriber, StubHandler Handler) Build(
Func<HttpRequestMessage, HttpResponseMessage> respond,
SharePointOptions? options = null,
bool tokenFromHttpContext = true)
{
var handler = new StubHandler(respond);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.tnsu.kz") };
var subscriber = new HttpSharePointWebhookSubscriber(
httpClient,
BuildAuthorizer(tokenFromHttpContext),
Options.Create(options ?? new SharePointOptions()),
NullLogger<HttpSharePointWebhookSubscriber>.Instance);
return (subscriber, handler);
}
private static HttpResponseMessage Ok() => new(System.Net.HttpStatusCode.OK);
[Fact(DisplayName = "Подписка уходит POST'ом со всеми тремя параметрами в строке запроса")]
public async Task TrySubscribeAsync_SendsPostWithQueryParameters()
{
// Arrange
var (subscriber, handler) = Build(_ => Ok());
// Action
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
// Assert
subscribed.ShouldBeTrue();
handler.Captured!.Method.ShouldBe(HttpMethod.Post);
handler.Captured.Content.ShouldBeNull(); // тела у ручки нет
var query = Uri.UnescapeDataString(handler.Captured.RequestUri!.Query);
handler.Captured.RequestUri.AbsolutePath.ShouldBe("/Webhook/Subscribe");
query.ShouldContain($"listName={ListTitle}");
query.ShouldContain("notificationUrl=https://api.tnsu.kz/Webhook/SharePoint");
// Токен из входящего запроса пробрасывается как есть (см. TnsuPortalAuthorizer).
handler.Captured.Headers.Authorization?.ToString().ShouldBe("Bearer test-token");
}
[Fact(DisplayName = "В url уходит ПОЛНЫЙ адрес сайта, а не короткое имя")]
public async Task TrySubscribeAsync_SendsFullSiteUrl()
{
// Arrange
var (subscriber, handler) = Build(_ => Ok());
// Action
await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
// Assert
var query = Uri.UnescapeDataString(handler.Captured!.RequestUri!.Query);
query.ShouldContain($"url={SiteUrl}");
}
[Fact(DisplayName = "Токен фоновой задачи (без HttpContext) тоже уходит в заголовок")]
public async Task TrySubscribeAsync_TokenSetExplicitly_IsSentAsAuthorization()
{
// Arrange: так работает провижининг — HttpContext'а нет, токен передан
// аргументом задачи (см. CreateNextListPartitionJob).
var (subscriber, handler) = Build(_ => Ok(), tokenFromHttpContext: false);
// Action
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
// Assert
subscribed.ShouldBeTrue();
handler.Captured!.Headers.Authorization?.ToString().ShouldBe("Bearer test-token");
}
[Fact(DisplayName = "Отказ портала не бросает исключение — провижининг списка не рушится")]
public async Task TrySubscribeAsync_PortalRejects_ReturnsFalseWithoutThrowing()
{
// Arrange
var (subscriber, _) = Build(_ => new HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized)
{
Content = new StringContent("no token")
});
// Action
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
// Assert
subscribed.ShouldBeFalse();
}
[Fact(DisplayName = "Недоступный портал (исключение в сети) тоже не бросает наружу")]
public async Task TrySubscribeAsync_NetworkFailure_ReturnsFalseWithoutThrowing()
{
// Arrange
var (subscriber, _) = Build(_ => throw new HttpRequestException("портал недоступен"));
// Action
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
// Assert
subscribed.ShouldBeFalse();
}
[Fact(DisplayName = "NotificationUrl не задан — запрос не отправляется вовсе")]
public async Task TrySubscribeAsync_NoNotificationUrl_DoesNotCallPortal()
{
// Arrange: пустой адрес приёмника — единственный выключатель подписки
// (отдельного флага Enabled нет, см. SharePointOptions.NotificationUrl).
var (subscriber, handler) = Build(_ => Ok(), new SharePointOptions { NotificationUrl = "" });
// Action
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
// Assert
subscribed.ShouldBeFalse();
handler.CallCount.ShouldBe(0);
}
[Fact(DisplayName = "Пустой заголовок списка — подписывать нечего, запроса нет")]
public async Task TrySubscribeAsync_EmptyListTitle_DoesNotCallPortal()
{
// Arrange
var (subscriber, handler) = Build(_ => Ok());
// Action
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, " ", CancellationToken.None);
// Assert
subscribed.ShouldBeFalse();
handler.CallCount.ShouldBe(0);
}
}
@@ -31,6 +31,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
portalClient
.Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]);
portalClient
.Setup(x => x.GetEmployeeByPositionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeLookupResult(false, null, null));
_services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
_service = _services.GetRequiredService<TemplateRouteService>();
@@ -560,7 +563,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer"));
portalClient
.Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
.ReturnsAsync("lead@tnsu.kz");
.ReturnsAsync(new EmployeeLookupResult(true, "lead@tnsu.kz", "Иванов Иван Иванович"));
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
var routeService = services.GetRequiredService<TemplateRouteService>();
@@ -22,6 +22,7 @@ internal static class TestServiceProviderFactory
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddApprovalRouteInfrastructure(configuration);
services.AddApprovalRouteApplication();
services.AddSingleton<ICurrentUser>(new TestCurrentUser(currentUserEmail));