Fix/by approver silent failures #25
@@ -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<RequestManageService> 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<int, IReadOnlyList<RequestListPartition>>();
|
||||
foreach (var rt in requestTypes)
|
||||
archivedByType[rt.Id] = await partitionRegistry.GetArchivedAsync(rt, cancellationToken);
|
||||
|
||||
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>(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<IReadOnlyList<UserApprovalRowDto>>(rows);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<UserApprovalRowDto>> 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<IReadOnlyList<UserApprovalRowDto>> 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));
|
||||
|
||||
var result = new List<UserApprovalRowDto>();
|
||||
foreach (var (dataListId, dataListTitle) in partitions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dataListTitle)) continue;
|
||||
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
|
||||
if (approvalListTitle is null) continue;
|
||||
|
||||
IReadOnlyList<JsonElement> 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))
|
||||
.ToList();
|
||||
if (matched.Count == 0) continue;
|
||||
|
||||
var ticketIds = matched.Select(r => r.TicketId).Distinct().ToList();
|
||||
var dataById = await ReadDataRowsAsync(siteUrl, dataListId, ticketIds, cancellationToken);
|
||||
|
||||
result.AddRange(matched.Select(r =>
|
||||
{
|
||||
var dataRow = dataById.GetValueOrDefault(r.TicketId);
|
||||
return new UserApprovalRowDto(
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Тот же результат, что и обход списков по партициям, но кандидатов находит ОДИН
|
||||
/// запрос Search на сайт (по человеку и статусам, сразу по всем спискам
|
||||
/// согласования): ListId/ListItemID из индекса. Остальные поля строк согласования
|
||||
/// индекс отдаёт не все (нет TicketId, SLA, комментария), поэтому найденные
|
||||
/// строки дочитываются по REST — только в тех списках, где они реально нашлись.
|
||||
/// Индекс отстаёт от списков, поэтому статус и согласующий перепроверяются по REST.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<UserApprovalRowDto>> FindViaSearchAsync(
|
||||
IReadOnlyList<RequestType> requestTypes,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<RequestListPartition>> archivedByType,
|
||||
IReadOnlyList<string> 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<UserApprovalRowDto>();
|
||||
foreach (var siteUrl in requestTypes.Select(x => x.SiteUrl!).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var hits = await sharePoint.SearchAsync(siteUrl, kql, ["Path", "ListItemID"], cancellationToken);
|
||||
|
||||
// Путь вида 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();
|
||||
|
||||
var siteTypes = requestTypes
|
||||
.Where(x => string.Equals(x.SiteUrl, siteUrl, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
var perList = await Task.WhenAll(byList.Select(async group =>
|
||||
{
|
||||
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<string, (string ApprovalTitle, string DataListUrl)> 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<IReadOnlyList<UserApprovalRowDto>> ReadSearchHitListAsync(
|
||||
string siteUrl,
|
||||
string approvalListUrl,
|
||||
IReadOnlyList<int> itemIds,
|
||||
IReadOnlyList<RequestType> siteTypes,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<RequestListPartition>> archivedByType,
|
||||
IReadOnlyList<string> 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<ManagedApprovalRowDto>();
|
||||
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<IReadOnlyDictionary<int, ManagedRequestSummaryDto>> ReadDataRowsAsync(
|
||||
string siteUrl, string listId, IReadOnlyList<int> 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;
|
||||
|
||||
|
||||
@@ -54,6 +54,15 @@ public interface ISharePointClient
|
||||
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
|
||||
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Запрос в SharePoint Search (postquery) от имени приложения — видит ВСЕ
|
||||
/// элементы, а не только доступные пользователю. Возвращает строки результата
|
||||
/// как «имя managed property → значение» (RowLimit — не больше 500).
|
||||
/// Индекс отстаёт от списков, поэтому найденное нужно перепроверять по REST.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
|
||||
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Обновить поля элемента списка, найденного по заголовку. fields — анонимный
|
||||
/// объект «внутреннее имя колонки → значение»; перечисленные колонки
|
||||
@@ -220,6 +229,57 @@ public sealed class SharePointClient(
|
||||
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}",
|
||||
cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
|
||||
string siteUrl, string queryText, IReadOnlyList<string> 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<IReadOnlyDictionary<string, string?>>();
|
||||
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<string, string?>(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(
|
||||
|
||||
Reference in New Issue
Block a user