Обходной лист (#12)
Tests / backend-test (push) Successful in 1m38s
Tests / frontend-test (push) Successful in 47s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/12
This commit was merged in pull request #12.
This commit is contained in:
2026-08-17 05:43:52 +00:00
parent d076f4e98d
commit 48ede1420d
37 changed files with 1351 additions and 435 deletions
@@ -0,0 +1,63 @@
using Microsoft.Extensions.Caching.Memory;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
/// <summary>
/// Кеширующая обёртка над <see cref="HttpTnsuPortalClient"/>. Резолв одного
/// маршрута (TemplateRouteService.ResolveAsync) может дёргать справочник
/// api.tnsu.kz по многу раз за один запрос — один и тот же согласующий,
/// должность или руководитель нередко повторяется в разных шагах длинного
/// маршрута, — и без кеша распараллеливание самих шагов резолва не спасает от
/// нагрузки на api.tnsu.kz: он просто получает то же число запросов
/// одновременно вместо по очереди. Кеш процессный (<see cref="IMemoryCache"/>,
/// не распределённый — реплик API несколько, поэтому в них он не общий), TTL
/// фиксирован на <see cref="CacheDuration"/>.
/// </summary>
public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryCache cache) : ITnsuPortalClient
{
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
public Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) =>
GetOrAddAsync(
$"emp:email:{email.ToLowerInvariant()}",
() => inner.GetEmployeeByEmailAsync(email, cancellationToken));
public Task<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) =>
GetOrAddAsync(
$"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}",
() => inner.GetEmployeeByPositionAsync(position, company, cancellationToken));
public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) =>
GetOrAddAsync(
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
() => inner.GetManagerAsync(email, managerType, cancellationToken));
public Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) =>
GetOrAddAsync(
$"projexec:{projectId}",
() => inner.GetProjectExecutorsAsync(projectId, cancellationToken));
public Task<string?> GetDepartmentHeadEmailAsync(Guid departmentId, CancellationToken cancellationToken) =>
GetOrAddAsync(
$"depthead:{departmentId}",
() => inner.GetDepartmentHeadEmailAsync(departmentId, cancellationToken));
/// <summary>
/// Кеширует и «не нашли» (null / пустой список) — повторять запрос к
/// api.tnsu.kz за тем же результатом на каждый одинаковый шаг маршрута
/// незачем. Ключ по одному и тому же значению может задеть гонку: два
/// конкурентных шага (см. Task.WhenAll в ResolveAsync) с одинаковым ключом
/// оба не найдут его в кеше и оба сходят в API — не страшно, это лишний
/// один поход, а не порча данных; блокировка per-key усложнила бы код ради
/// выигрыша, который при TTL в минуты и так почти всегда есть без неё.
/// </summary>
private async Task<T> GetOrAddAsync<T>(string key, Func<Task<T>> factory)
{
if (cache.TryGetValue(key, out T? cached))
return cached!;
var value = await factory();
cache.Set(key, value, CacheDuration);
return value;
}
}
@@ -8,4 +8,5 @@ public sealed class TnsuPortalOptions
public string ClientSecret { get; set; } = string.Empty;
public string Scope { get; set; } = string.Empty;
public string DirectorTansuEmail { get; set; } = "director.tansu@tnsu.kz";
public string ManagingDirectorTansuEmail { get; set; } = "nkim@tnsu.kz";
}