diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs index 609b1ad..50e18aa 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using FluentResults; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Domain.Entities; @@ -25,10 +26,16 @@ namespace Tnsu.ApprovalRoute.Application.RequestManage; public sealed class RequestManageService( ApprovalRouteDbContext db, ISharePointClient sharePoint, - ListPartitionRegistry partitionRegistry) + ListPartitionRegistry partitionRegistry, + ILogger logger) { private const int DefaultPageSize = 20; + // Сколько типов заявок читаем из SharePoint одновременно. Без ограничения + // 18 типов × партиции разом упирались в троттлинг (429), а ошибки глотались + // и экран показывал пустой список. + private const int MaxParallelTypes = 3; + // Внутренние имена колонок списка согласования — 1:1 с FIELDS в // requestManageService.ts (тот же самый список SharePoint, тот же провижининг). private const string ApproverField = "TansuSiteApprover"; @@ -145,8 +152,19 @@ public sealed class RequestManageService( var statusFilter = string.Join(" or ", activeStatuses.Select(s => $"{StatusField} eq '{Escape(s)}'")); var email = approverEmail.Trim(); - var results = await Task.WhenAll( - requestTypes.Select(rt => FindForTypeSafeAsync(rt, statusFilter, email, cancellationToken))); + using var gate = new SemaphoreSlim(MaxParallelTypes); + var results = await Task.WhenAll(requestTypes.Select(async rt => + { + await gate.WaitAsync(cancellationToken); + try + { + return await FindForTypeSafeAsync(rt, statusFilter, email, cancellationToken); + } + finally + { + gate.Release(); + } + })); return Result.Ok>(results.SelectMany(r => r).ToList()); } @@ -158,10 +176,11 @@ public sealed class RequestManageService( { return await FindForTypeAsync(requestType, statusFilter, approverEmail, cancellationToken); } - catch + catch (Exception ex) when (ex is not OperationCanceledException) { // Один недоступный/переименованный список не должен обнулять результат по // остальным типам заявок — та же терпимость, что и в ReminderService. + logger.LogWarning(ex, "by-approver: тип заявок {RequestTypeId} «{RequestTypeName}» пропущен", requestType.Id, requestType.Name); return []; } } @@ -190,9 +209,11 @@ public sealed class RequestManageService( hits = await sharePoint.GetItemsByListTitleAsync( siteUrl, approvalListTitle, $"{ApprovalRowSelect}&$filter=({statusFilter})&$top=2000", cancellationToken); } - catch + catch (Exception ex) when (ex is not OperationCanceledException) { - continue; // партиция сломана/переименована вручную — остальные не теряем. + // партиция сломана/переименована вручную — остальные не теряем. + logger.LogWarning(ex, "by-approver: список «{ApprovalList}» пропущен", approvalListTitle); + continue; } var matched = hits