diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs index 609b1ad..a9cfb4b 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/RequestManage/RequestManageService.cs @@ -1,7 +1,9 @@ +using System.Collections.Concurrent; 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 +27,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"; @@ -142,82 +150,186 @@ public sealed class RequestManageService( .Where(x => x.ListId != null && x.SiteUrl != null) .ToListAsync(cancellationToken); - 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))); + // DbContext не потокобезопасен: архивные партиции читаем из БД ПОСЛЕДОВАТЕЛЬНО, + // и только дальнейшее чтение SharePoint (без БД) идёт параллельно. Раньше + // GetArchivedAsync вызывался из параллельных задач — «вторая операция на + // контексте» падала, а пустой catch превращал это в пустой список. + var archivedByType = new Dictionary>(); + foreach (var rt in requestTypes) + archivedByType[rt.Id] = await partitionRegistry.GetArchivedAsync(rt, cancellationToken); - return Result.Ok>(results.SelectMany(r => r).ToList()); + using var gate = new SemaphoreSlim(MaxParallelTypes); + + // Search не поймать и не подменить обходом списков: если он недоступен, это + // видно по ошибке запроса, а не по тихо неполному результату. + var rows = await FindViaSearchAsync(requestTypes, archivedByType, activeStatuses, email, gate, cancellationToken); + return Result.Ok>(rows); } - private async Task> FindForTypeSafeAsync( - RequestType requestType, string statusFilter, string approverEmail, CancellationToken cancellationToken) - { - try - { - return await FindForTypeAsync(requestType, statusFilter, approverEmail, cancellationToken); - } - catch - { - // Один недоступный/переименованный список не должен обнулять результат по - // остальным типам заявок — та же терпимость, что и в ReminderService. - return []; - } - } + private const string SearchApproverProperty = "TansuSiteApproverOWSUSER"; + private const string SearchStatusProperty = "ApprovalStatusOWSCHCS"; - private async Task> FindForTypeAsync( - RequestType requestType, string statusFilter, string approverEmail, CancellationToken cancellationToken) - { - var siteUrl = requestType.SiteUrl!; - var archived = await partitionRegistry.GetArchivedAsync(requestType, cancellationToken); - var current = await partitionRegistry.BuildCurrentAsync(requestType, archived, cancellationToken); + private static readonly Regex ApprovalListSuffix = new(@"\s*—\s*согласование\s*$", RegexOptions.Compiled); - var partitions = archived - .Select(x => (ListId: x.ListId, Title: x.Title)) - .Append((current.ListId, current.Title)); + private static UserApprovalRowDto ToUserRow( + ManagedApprovalRowDto r, RequestType requestType, string siteUrl, string dataListId, string approvalListTitle, + ManagedRequestSummaryDto? dataRow) => + new( + r.Id, r.TicketId, r.ApproverId, r.ApproverName, r.ApproverEmail, r.Position, r.Status, r.DateIso, + r.Comment, r.Order, r.AllowReassign, r.Phase, r.SlaHours, r.AskOverdueReason, + requestType.Id, requestType.Name, siteUrl, dataListId, approvalListTitle, + dataRow?.Title ?? $"#{r.TicketId}", dataRow?.UniqueCode); + + /// + /// Тот же результат, что и обход списков по партициям, но кандидатов находит ОДИН + /// запрос Search на сайт (по человеку и статусам, сразу по всем спискам + /// согласования): ListId/ListItemID из индекса. Остальные поля строк согласования + /// индекс отдаёт не все (нет TicketId, SLA, комментария), поэтому найденные + /// строки дочитываются по REST — только в тех списках, где они реально нашлись. + /// Индекс отстаёт от списков, поэтому статус и согласующий перепроверяются по REST. + /// + private async Task> FindViaSearchAsync( + IReadOnlyList requestTypes, + IReadOnlyDictionary> archivedByType, + IReadOnlyList activeStatuses, + string approverEmail, + SemaphoreSlim gate, + CancellationToken cancellationToken) + { + var email = approverEmail.Replace("\"", string.Empty); + var statusQuery = string.Join( + " OR ", activeStatuses.Select(s => $"{SearchStatusProperty}:\"{s.Replace("\"", string.Empty)}\"")); + var kql = $"{SearchApproverProperty}:\"{email}\" ({statusQuery}) contentclass:STS_ListItem_GenericList"; var result = new List(); - foreach (var (dataListId, dataListTitle) in partitions) + foreach (var siteUrl in requestTypes.Select(x => x.SiteUrl!).Distinct(StringComparer.OrdinalIgnoreCase)) { - if (string.IsNullOrWhiteSpace(dataListTitle)) continue; - var approvalListTitle = ApprovalListTitleFor(dataListTitle); - if (approvalListTitle is null) continue; + var hits = await sharePoint.SearchAsync(siteUrl, kql, ["Path", "ListItemID"], cancellationToken); - IReadOnlyList hits; - try - { - hits = await sharePoint.GetItemsByListTitleAsync( - siteUrl, approvalListTitle, $"{ApprovalRowSelect}&$filter=({statusFilter})&$top=2000", cancellationToken); - } - catch - { - continue; // партиция сломана/переименована вручную — остальные не теряем. - } - - var matched = hits - .Select(ToApprovalRow) - .Where(r => string.Equals(r.ApproverEmail, approverEmail, StringComparison.OrdinalIgnoreCase)) + // Путь вида https://host/sites/x/Lists/L/DispForm.aspx?ID=5 → серверный url списка. + var byList = hits + .Select(h => ( + Url: ListUrlFromHitPath(h.GetValueOrDefault("Path")), + Id: int.TryParse(h.GetValueOrDefault("ListItemID"), out var id) ? id : 0)) + .Where(x => x.Url is not null && x.Id > 0) + .GroupBy(x => x.Url!, StringComparer.OrdinalIgnoreCase) .ToList(); - if (matched.Count == 0) continue; - var ticketIds = matched.Select(r => r.TicketId).Distinct().ToList(); - var dataById = await ReadDataRowsAsync(siteUrl, dataListId, ticketIds, cancellationToken); + var siteTypes = requestTypes + .Where(x => string.Equals(x.SiteUrl, siteUrl, StringComparison.OrdinalIgnoreCase)) + .ToList(); - result.AddRange(matched.Select(r => + var perList = await Task.WhenAll(byList.Select(async group => { - var dataRow = dataById.GetValueOrDefault(r.TicketId); - return new UserApprovalRowDto( - r.Id, r.TicketId, r.ApproverId, r.ApproverName, r.ApproverEmail, r.Position, r.Status, r.DateIso, - r.Comment, r.Order, r.AllowReassign, r.Phase, r.SlaHours, r.AskOverdueReason, - requestType.Id, requestType.Name, siteUrl, dataListId, approvalListTitle, - dataRow?.Title ?? $"#{r.TicketId}", dataRow?.UniqueCode); + await gate.WaitAsync(cancellationToken); + try + { + return await ReadSearchHitListAsync( + siteUrl, group.Key, group.Select(x => x.Id).Distinct().ToList(), siteTypes, archivedByType, + activeStatuses, approverEmail, cancellationToken); + } + finally + { + gate.Release(); + } })); + + result.AddRange(perList.SelectMany(x => x)); } return result; } + // Пара «список согласования → (его заголовок, адрес парного списка данных)» + // не меняется, пока список не переименуют, — запоминаем на время жизни процесса, + // чтобы не тратить два запроса к SharePoint на каждый вызов. Ключ — адрес списка. + private static readonly ConcurrentDictionary ListPairs = + new(StringComparer.OrdinalIgnoreCase); + + private async Task<(string ApprovalTitle, string DataListUrl)?> ResolveListPairAsync( + string siteUrl, string approvalListUrl, CancellationToken cancellationToken) + { + if (ListPairs.TryGetValue(approvalListUrl, out var cached)) return cached; + + var approvalList = await sharePoint.GetListByUrlAsync(siteUrl, approvalListUrl, cancellationToken); + if (approvalList is null || !ApprovalListSuffix.IsMatch(approvalList.Title)) return null; + + var dataTitle = ApprovalListSuffix.Replace(approvalList.Title, " — данные"); + var dataList = await sharePoint.GetListByTitleAsync(siteUrl, dataTitle, cancellationToken); + if (dataList is null) return null; + + var pair = (approvalList.Title, dataList.ServerRelativeUrl); + ListPairs[approvalListUrl] = pair; + return pair; + } + + private static string? ListUrlFromHitPath(string? path) + { + if (string.IsNullOrWhiteSpace(path) || !Uri.TryCreate(path, UriKind.Absolute, out var uri)) return null; + + var local = Uri.UnescapeDataString(uri.AbsolutePath); + var cut = local.LastIndexOf('/'); + return cut > 0 ? local[..cut] : null; + } + + private async Task> ReadSearchHitListAsync( + string siteUrl, + string approvalListUrl, + IReadOnlyList itemIds, + IReadOnlyList siteTypes, + IReadOnlyDictionary> archivedByType, + IReadOnlyList activeStatuses, + string approverEmail, + CancellationToken cancellationToken) + { + try + { + var pair = await ResolveListPairAsync(siteUrl, approvalListUrl, cancellationToken); + if (pair is null) return []; + var (approvalTitle, dataListUrl) = pair.Value; + + // Какому типу заявок принадлежит эта партиция (текущая или архивная). + var requestType = siteTypes.FirstOrDefault(rt => + string.Equals(rt.ListId, dataListUrl, StringComparison.OrdinalIgnoreCase) + || archivedByType[rt.Id].Any(a => + string.Equals(a.ListId, dataListUrl, StringComparison.OrdinalIgnoreCase))); + if (requestType is null) return []; + + var rows = new List(); + foreach (var chunk in itemIds.Chunk(50)) + { + var filter = string.Join(" or ", chunk.Select(id => $"Id eq {id}")); + var items = await sharePoint.GetItemsByListUrlAsync( + siteUrl, approvalListUrl, $"{ApprovalRowSelect}&$filter={filter}&$top={chunk.Length}", + cancellationToken); + rows.AddRange(items.Select(ToApprovalRow)); + } + + var matched = rows + .Where(r => activeStatuses.Contains(r.Status) + && string.Equals(r.ApproverEmail, approverEmail, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (matched.Count == 0) return []; + + var dataById = await ReadDataRowsAsync( + siteUrl, dataListUrl, matched.Select(r => r.TicketId).Distinct().ToList(), + cancellationToken); + + return matched + .Select(r => ToUserRow( + r, requestType, siteUrl, dataListUrl, approvalTitle, + dataById.GetValueOrDefault(r.TicketId))) + .ToList(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "by-approver: список «{ApprovalListUrl}» из Search пропущен", approvalListUrl); + return []; + } + } + private async Task> ReadDataRowsAsync( string siteUrl, string listId, IReadOnlyList ticketIds, CancellationToken cancellationToken) { @@ -303,8 +415,6 @@ public sealed class RequestManageService( return words.All(w => haystack.Contains(w.ToLowerInvariant(), StringComparison.Ordinal)); } - private static string Escape(string value) => value.Replace("'", "''"); - private static string? GetString(JsonElement e, string name) => e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs index 5574f75..db6a2fb 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs @@ -54,6 +54,15 @@ public interface ISharePointClient Task> GetItemsByListTitleAsync( string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken); + /// + /// Запрос в SharePoint Search (postquery) от имени приложения — видит ВСЕ + /// элементы, а не только доступные пользователю. Возвращает строки результата + /// как «имя managed property → значение» (RowLimit — не больше 500). + /// Индекс отстаёт от списков, поэтому найденное нужно перепроверять по REST. + /// + Task>> SearchAsync( + string siteUrl, string queryText, IReadOnlyList selectProperties, CancellationToken cancellationToken); + /// /// Обновить поля элемента списка, найденного по заголовку. fields — анонимный /// объект «внутреннее имя колонки → значение»; перечисленные колонки @@ -220,6 +229,57 @@ public sealed class SharePointClient( $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}", cancellationToken); + public async Task>> SearchAsync( + string siteUrl, string queryText, IReadOnlyList selectProperties, CancellationToken cancellationToken) + { + var body = JsonSerializer.Serialize(new + { + request = new + { + Querytext = queryText, + RowLimit = 500, + SelectProperties = selectProperties, + TrimDuplicates = false + } + }); + + var json = await SendAsync( + HttpMethod.Post, siteUrl, $"{siteUrl.TrimEnd('/')}/_api/search/postquery", body, + allowNotFound: false, cancellationToken); + + var result = new List>(); + if (json is null + || !json.Value.TryGetProperty("PrimaryQueryResult", out var primary) + || !primary.TryGetProperty("RelevantResults", out var relevant) + || !relevant.TryGetProperty("Table", out var table) + || !table.TryGetProperty("Rows", out var rows)) + return result; + + // nometadata отдаёт Rows массивом, verbose — обёрнутым в results; терпим оба. + if (rows.ValueKind == JsonValueKind.Object && rows.TryGetProperty("results", out var wrapped)) + rows = wrapped; + if (rows.ValueKind != JsonValueKind.Array) return result; + + foreach (var row in rows.EnumerateArray()) + { + if (!row.TryGetProperty("Cells", out var cells)) continue; + if (cells.ValueKind == JsonValueKind.Object && cells.TryGetProperty("results", out var wrappedCells)) + cells = wrappedCells; + + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var cell in cells.EnumerateArray()) + { + var key = cell.TryGetProperty("Key", out var k) ? k.GetString() : null; + if (key is null) continue; + dict[key] = cell.TryGetProperty("Value", out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + } + + result.Add(dict); + } + + return result; + } + public Task UpdateItemByListTitleAsync( string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken) => MergeAsync(