Архивация списков (#13)
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:
@@ -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));
|
||||
|
||||
+94
@@ -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=…&url=…&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)}" +
|
||||
$"¬ificationUrl={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);
|
||||
|
||||
Reference in New Issue
Block a user