Compare commits
5
Commits
03560b0dc8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ca42581e6 | ||
|
|
672d7c40b4 | ||
|
|
c590a8a6c7 | ||
|
|
01bab77900 | ||
|
|
cfd9196083 |
No files matched your search
@@ -10,4 +10,8 @@ public interface IPlugin
|
||||
|
||||
// Регистрация маршрутов (Minimal API) плагина после Build()
|
||||
void MapEndpoints(IEndpointRouteBuilder endpoints);
|
||||
|
||||
// Инвалидация или обновление внутренних кэшей плагина.
|
||||
Task InvalidateCacheAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
+1
-1
Submodule ModeusSchedule.MSAuth updated: 89316e247f...2c4fccb22d.
@@ -47,6 +47,10 @@
|
||||
- `POST /api/proxy/events/search` — получить расписание;
|
||||
- `POST /api/proxy/rooms/search` — искать аудитории.
|
||||
|
||||
В ответе `POST /api/proxy/events/search` ссылки на подключение к занятиям защищены. Если `customLocation` содержит абсолютный URI отдельно, внутри текста или Markdown-разметки, пользователь без активной Microsoft-сессии получит значение `авторизуйтесь для просмотра` вместо всего содержимого поля. После входа через Microsoft API возвращает исходное значение. `X-Api-Key` не открывает доступ к ссылкам.
|
||||
|
||||
Фильтрация реализована в `ScheduleLocationFilter`. Плагины могут вызывать этот общий helper для своих JSON-ответов, не дублируя проверку авторизации и обработку `customLocation`.
|
||||
|
||||
Если `Modeus` отвечает ошибкой, сервис возвращает соответствующий HTTP-статус и текст с префиксом `Proxied Modeus:`.
|
||||
|
||||
### 2. Работа с расписанием
|
||||
@@ -82,13 +86,18 @@
|
||||
Обновление выполняет Quartz job [UpdateEmployeesJob](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Jobs/UpdateEmployeesJob.cs):
|
||||
|
||||
- ходит в `Modeus`;
|
||||
- загружает большой список сотрудников;
|
||||
- загружает список людей из Modeus и проверяет полноту результата;
|
||||
- сохраняет общий индекс людей с признаками студента и сотрудника в `data/people.json`;
|
||||
- группирует записи по ФИО;
|
||||
- сохраняет результат на диск;
|
||||
- после успешного обновления предлагает всем плагинам инвалидировать свои кэши;
|
||||
- повторяет попытки при временных сбоях.
|
||||
|
||||
Если сервис ещё не инициализирован, `GET /api/schedule/searchemployee` возвращает `503`.
|
||||
|
||||
`ModeusPeopleService` предоставляет плагинам последний полный снимок людей и восстанавливает его при запуске. Новая версия файла заменяет предыдущую атомарно;
|
||||
ошибка загрузки или записи сохраняет рабочий снимок. Для всего прохода страниц выделено пять минут. Контракт поиска преподавателей не меняется, студенты в его ответ не добавляются.
|
||||
|
||||
### 5. JWT lifecycle
|
||||
|
||||
Quartz job [UpdateJwtJob](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Jobs/UpdateJWTJob.cs):
|
||||
@@ -127,7 +136,11 @@ Quartz job [UpdateJwtJob](/home/serega404/Рабочий стол/SfeduSchedule/
|
||||
На старте хост ищет все `*.plugin.dll` внутри `data/Plugins` и загружает найденные сборки как плагины. Плагин должен реализовывать интерфейс [IPlugin](/home/serega404/Рабочий стол/SfeduSchedule/ModeusSchedule.Abstractions/IPlugin.cs), чтобы:
|
||||
|
||||
- зарегистрировать свои сервисы в DI через `ConfigureServices`;
|
||||
- добавить minimal API маршруты через `MapEndpoints`.
|
||||
- добавить minimal API маршруты через `MapEndpoints`;
|
||||
- опционально обновить или очистить свои кэши через `InvalidateCacheAsync`.
|
||||
|
||||
`InvalidateCacheAsync` имеет default-реализацию, поэтому плагины, собранные до добавления этого метода,
|
||||
продолжают загружаться. Ошибка инвалидации одного плагина логируется и не мешает вызвать остальные.
|
||||
|
||||
MVC-контроллеры из плагинов тоже подключаются автоматически через `ApplicationPart`.
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ public class ProxyController(ModeusService modeusService, ILogger<ProxyControlle
|
||||
"Proxied Modeus: " + e.Message);
|
||||
}
|
||||
|
||||
schedule = await ScheduleLocationFilter.FilterAsync(schedule, HttpContext);
|
||||
return Ok(schedule);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,19 +4,18 @@ using SfeduSchedule.Services;
|
||||
|
||||
namespace SfeduSchedule.Jobs;
|
||||
|
||||
// TODO: Обновляет список сотрудников из modeus и сохраняет в локальный файл
|
||||
// TODO: Нужно вынести функционал обновления сюда, а в сервисе оставить только загрузку с диска
|
||||
// TODO: Нужно настроить выполнение задачи на часов 10 утра каждый день
|
||||
// TODO: Нужно обработать событие когда данные о сотрудниках запрашиваются до первого обновления
|
||||
|
||||
[DisallowConcurrentExecution]
|
||||
public class UpdateEmployeesJob(
|
||||
ILogger<UpdateEmployeesJob> logger,
|
||||
ModeusEmployeeService employeeService,
|
||||
ModeusService modeusService) : IJob
|
||||
ModeusService modeusService,
|
||||
ModeusPeopleService peopleService,
|
||||
PluginCacheInvalidator pluginCacheInvalidator) : IJob
|
||||
{
|
||||
private const int MaxAttempts = 5; // Максимальное число попыток
|
||||
private const int DelaySeconds = 50; // Задержка между попытками в секундах
|
||||
private const int TimeoutSeconds = 60; // Таймаут для каждого запроса в секундах
|
||||
private const int TimeoutSeconds = 300; // Таймаут полной загрузки всех страниц в секундах
|
||||
|
||||
public async Task Execute(IJobExecutionContext jobContext)
|
||||
{
|
||||
@@ -28,16 +27,18 @@ public class UpdateEmployeesJob(
|
||||
logger.LogInformation("Попытка {Attempt}/{MaxAttempts} получения списка сотрудников из Modeus", attempt,
|
||||
MaxAttempts);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(TimeoutSeconds));
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(jobContext.CancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(TimeoutSeconds));
|
||||
|
||||
var employees = await modeusService.GetEmployeesAsync(cts.Token);
|
||||
var people = await modeusService.GetPeopleAsync(cts.Token);
|
||||
var employees = ModeusService.GetEmployees(people);
|
||||
if (employees.Count == 0)
|
||||
{
|
||||
logger.LogWarningHere("Не удалось получить список сотрудников из Modeus.");
|
||||
|
||||
if (attempt == MaxAttempts)
|
||||
{
|
||||
logger.LogError("Достигнуто максимальное число попыток получения JWT");
|
||||
logger.LogError("Достигнуто максимальное число попыток загрузки людей Modeus");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,18 +46,24 @@ public class UpdateEmployeesJob(
|
||||
continue;
|
||||
}
|
||||
|
||||
await peopleService.SetAsync(people, cts.Token);
|
||||
await employeeService.SetEmployees(employees);
|
||||
logger.LogInformationHere($"Получено {employees.Count} сотрудников из Modeus.");
|
||||
await pluginCacheInvalidator.InvalidateAsync(jobContext.CancellationToken);
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException) when (jobContext.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Таймаут при получении JWT (попытка {Attempt}/{MaxAttempts})", attempt,
|
||||
logger.LogWarning(ex, "Таймаут при загрузке людей Modeus (попытка {Attempt}/{MaxAttempts})", attempt,
|
||||
MaxAttempts);
|
||||
|
||||
if (attempt == MaxAttempts)
|
||||
{
|
||||
logger.LogError("Достигнут лимит по таймаутам при запросе JWT");
|
||||
logger.LogError("Достигнут лимит по таймаутам при загрузке людей Modeus");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,7 +75,7 @@ public class UpdateEmployeesJob(
|
||||
|
||||
if (attempt == MaxAttempts)
|
||||
{
|
||||
logger.LogError("Достигнуто максимальное число попыток из-за ошибок при запросе JWT");
|
||||
logger.LogError("Достигнуто максимальное число попыток из-за ошибок при загрузке людей Modeus");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,11 +46,20 @@ public static class PluginLoader
|
||||
public sealed class PluginLoadContext(string pluginMainAssemblyPath) : AssemblyLoadContext(true)
|
||||
{
|
||||
private readonly AssemblyDependencyResolver _resolver = new(pluginMainAssemblyPath);
|
||||
private static readonly HashSet<string> SharedAssemblyNames =
|
||||
[
|
||||
typeof(IPlugin).Assembly.GetName().Name!,
|
||||
typeof(PluginLoader).Assembly.GetName().Name!
|
||||
];
|
||||
|
||||
// Разрешаем управляемые зависимости плагина из его папки.
|
||||
// Возвращаем null, чтобы отдать решение в Default ALC для общих сборок (например, ModeusSchedule.Abstractions).
|
||||
// Контракт плагина и API хоста должны иметь одинаковую идентичность типов в обоих контекстах загрузки.
|
||||
// Поэтому их всегда берём из Default ALC, даже если копии DLL лежат рядом с плагином.
|
||||
protected override Assembly? Load(AssemblyName assemblyName)
|
||||
{
|
||||
if (assemblyName.Name != null && SharedAssemblyNames.Contains(assemblyName.Name))
|
||||
return null;
|
||||
|
||||
// Остальные управляемые зависимости разрешаем из папки плагина.
|
||||
var path = _resolver.ResolveAssemblyToPath(assemblyName);
|
||||
return path is null ? null : LoadFromAssemblyPath(path);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.Mvc.ApplicationParts;
|
||||
using Microsoft.Identity.Web;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using ModeusSchedule.Abstractions;
|
||||
using Prometheus;
|
||||
using Quartz;
|
||||
using Quartz.Listener;
|
||||
@@ -84,6 +85,8 @@ builder.Services.AddSingleton<ModeusHttpClient>();
|
||||
builder.Services.AddSingleton<ModeusEmployeeService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<ModeusEmployeeService>());
|
||||
builder.Services.AddSingleton<ModeusService>();
|
||||
builder.Services.AddSingleton<ModeusPeopleService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<ModeusPeopleService>());
|
||||
builder.Services.AddHttpClient("authClient");
|
||||
|
||||
builder.Services.AddAuthentication()
|
||||
@@ -133,11 +136,14 @@ foreach (var p in loadedPlugins)
|
||||
|
||||
// DI из плагина
|
||||
p.Instance.ConfigureServices(builder.Services);
|
||||
builder.Services.AddSingleton<IPlugin>(p.Instance);
|
||||
|
||||
// Подключаем контроллеры плагина
|
||||
mvcBuilder.PartManager.ApplicationParts.Add(new AssemblyPart(p.Assembly));
|
||||
}
|
||||
|
||||
builder.Services.AddSingleton<PluginCacheInvalidator>();
|
||||
|
||||
var updateJwtJob = new JobKey("UpdateJWTJob");
|
||||
|
||||
builder.Services.AddQuartz(q =>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SfeduSchedule.Services;
|
||||
|
||||
public static class AtomicJsonFile
|
||||
{
|
||||
public static async Task WriteAsync<T>(string path, T value, CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
var temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
await using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, value, cancellationToken: cancellationToken);
|
||||
await stream.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
File.Move(temporaryPath, path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporaryPath))
|
||||
File.Delete(temporaryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ public class ModeusHttpClient
|
||||
|
||||
return content;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogErrorHere(ex, "SearchPersonAsync: Deserialization failed.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SfeduSchedule.Services;
|
||||
|
||||
public sealed record ModeusPerson(string PersonId, string FullName, bool IsStudent, bool IsEmployee);
|
||||
|
||||
public sealed record ModeusPeopleSnapshot(DateTimeOffset UpdatedAt, ImmutableArray<ModeusPerson> Persons);
|
||||
|
||||
/// <summary>Последний полностью загруженный список людей Modeus. Не публикуется через API.</summary>
|
||||
public sealed class ModeusPeopleService(ILogger<ModeusPeopleService> logger) : IHostedService
|
||||
{
|
||||
private readonly string _path = Path.Combine(AppContext.BaseDirectory, AppConsts.DataFolderName, "people.json");
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private ModeusPeopleSnapshot? _snapshot;
|
||||
|
||||
public ModeusPeopleSnapshot? Snapshot => Volatile.Read(ref _snapshot);
|
||||
|
||||
public async Task SetAsync(ModeusPeopleSnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
Validate(snapshot);
|
||||
await _writeLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await AtomicJsonFile.WriteAsync(_path, snapshot, cancellationToken);
|
||||
Volatile.Write(ref _snapshot, snapshot);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(_path)) return;
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(_path);
|
||||
var snapshot = await JsonSerializer.DeserializeAsync<ModeusPeopleSnapshot>(stream,
|
||||
cancellationToken: cancellationToken);
|
||||
Validate(snapshot);
|
||||
Volatile.Write(ref _snapshot, snapshot);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "Не удалось восстановить индекс людей Modeus; ожидается полная загрузка.");
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private static void Validate(ModeusPeopleSnapshot? snapshot)
|
||||
{
|
||||
if (snapshot is null || snapshot.UpdatedAt == default || snapshot.Persons.IsDefaultOrEmpty ||
|
||||
snapshot.Persons.Any(p => p is null || !Guid.TryParse(p.PersonId, out _)) ||
|
||||
snapshot.Persons.Select(p => p.PersonId).Distinct(StringComparer.OrdinalIgnoreCase).Count() != snapshot.Persons.Length)
|
||||
throw new InvalidDataException("Неполный или некорректный индекс людей Modeus.");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Ical.Net;
|
||||
@@ -185,98 +186,71 @@ public class ModeusService(
|
||||
return serializedCalendar;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, (string, List<string>)>> GetEmployeesAsync(CancellationToken cancellationToken = default)
|
||||
/// <summary>Получить полный список людей одним проходом всех страниц Modeus.</summary>
|
||||
public async Task<ModeusPeopleSnapshot> GetPeopleAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var searchPersonResponse =
|
||||
await modeusHttpClient.SearchPersonAsync(new ModeusSearchPersonRequest() { Size = 38000 }, cancellationToken);
|
||||
if (searchPersonResponse == null)
|
||||
const int pageSize = 1000;
|
||||
var persons = new Dictionary<string, SfeduSchedule.DTO.Responses.Person>(StringComparer.OrdinalIgnoreCase);
|
||||
var students = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var employees = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
int? expectedCount = null;
|
||||
int? expectedPages = null;
|
||||
|
||||
// Modeus нумерует страницы с 1; page=0 является повтором первой страницы.
|
||||
for (var page = 1; ; page++)
|
||||
{
|
||||
logger.LogErrorHere("persons is null");
|
||||
return [];
|
||||
var response = await modeusHttpClient.SearchPersonAsync(
|
||||
new ModeusSearchPersonRequest(Size: pageSize, Page: page), cancellationToken)
|
||||
?? throw new HttpRequestException($"Не удалось загрузить страницу {page} людей Modeus.");
|
||||
if (response.Page is null || response.Embedded?.Persons is null ||
|
||||
response.Embedded.Students is null || response.Embedded.Employees is null ||
|
||||
response.Page.Number != page || response.Page.TotalElements is not > 0 ||
|
||||
response.Page.TotalPages is not > 0 or > 10000)
|
||||
throw new InvalidDataException("Некорректная пагинация или неполный ответ поиска людей Modeus.");
|
||||
|
||||
expectedCount ??= response.Page.TotalElements;
|
||||
expectedPages ??= response.Page.TotalPages;
|
||||
if (expectedCount != response.Page.TotalElements || expectedPages != response.Page.TotalPages)
|
||||
throw new InvalidDataException("Список людей Modeus изменился во время загрузки; требуется повтор.");
|
||||
|
||||
var countBefore = persons.Count;
|
||||
foreach (var person in response.Embedded.Persons)
|
||||
{
|
||||
if (person is null || !Guid.TryParse(person.Id, out _))
|
||||
throw new InvalidDataException("Некорректный personId в ответе Modeus.");
|
||||
persons[person.Id] = person;
|
||||
}
|
||||
foreach (var student in response.Embedded.Students)
|
||||
if (!string.IsNullOrWhiteSpace(student?.PersonId)) students.Add(student.PersonId);
|
||||
foreach (var employee in response.Embedded.Employees)
|
||||
if (!string.IsNullOrWhiteSpace(employee?.PersonId)) employees.Add(employee.PersonId);
|
||||
|
||||
if (persons.Count == countBefore)
|
||||
throw new InvalidDataException("Пустая или повторяющаяся страница людей Modeus.");
|
||||
if (page >= expectedPages) break;
|
||||
}
|
||||
|
||||
var persons = searchPersonResponse.Embedded.Persons;
|
||||
var employees = searchPersonResponse.Embedded.Employees;
|
||||
if (persons.Count != expectedCount || students.Any(id => !persons.ContainsKey(id)) ||
|
||||
employees.Any(id => !persons.ContainsKey(id)))
|
||||
throw new InvalidDataException("Не удалось получить полный список людей Modeus.");
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var personsById = new Dictionary<string, SfeduSchedule.DTO.Responses.Person>();
|
||||
foreach (var p in persons)
|
||||
return new ModeusPeopleSnapshot(DateTimeOffset.UtcNow,
|
||||
persons.Values.Select(p => new ModeusPerson(p.Id, (p.FullName ?? string.Empty).Trim(),
|
||||
students.Contains(p.Id), employees.Contains(p.Id))).ToImmutableArray());
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, (string, List<string>)>> GetEmployeesAsync(CancellationToken cancellationToken = default)
|
||||
=> GetEmployees(await GetPeopleAsync(cancellationToken));
|
||||
|
||||
public static Dictionary<string, (string, List<string>)> GetEmployees(ModeusPeopleSnapshot snapshot)
|
||||
{
|
||||
var employees = new Dictionary<string, (string, List<string>)>(StringComparer.Ordinal);
|
||||
foreach (var person in snapshot.Persons.Where(p => p.IsEmployee))
|
||||
{
|
||||
var id = p?.Id;
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
personsById[id] = p;
|
||||
}
|
||||
var name = string.IsNullOrWhiteSpace(person.FullName) ? person.PersonId : person.FullName;
|
||||
employees.TryAdd(name, (person.PersonId, []));
|
||||
}
|
||||
|
||||
var mapMs = stopwatch.ElapsedMilliseconds;
|
||||
|
||||
var groupStartMs = stopwatch.ElapsedMilliseconds;
|
||||
var grouped =
|
||||
new Dictionary<string, (string PersonId, List<string> Positions)>(employees.Count,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
|
||||
foreach (var e in employees)
|
||||
{
|
||||
if (e == null) continue;
|
||||
var personId = e.PersonId ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(personId)) continue;
|
||||
|
||||
var fullName = personId;
|
||||
if (personsById.TryGetValue(personId, out var person))
|
||||
{
|
||||
var name = (person.FullName ?? string.Empty).Trim();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
fullName = name;
|
||||
}
|
||||
}
|
||||
|
||||
// Отключено так как информация нигде не используется
|
||||
// var position = (e.GroupName ?? string.Empty).Trim();
|
||||
//
|
||||
// static string FormatDateRange(string? dateIn, string? dateOut)
|
||||
// {
|
||||
// var start = (dateIn ?? string.Empty).Trim();
|
||||
// if (string.IsNullOrEmpty(start)) start = "?";
|
||||
//
|
||||
// var end = (dateOut ?? string.Empty).Trim();
|
||||
// if (string.IsNullOrEmpty(end)) end = "по наст.вр.";
|
||||
//
|
||||
// return $"{start}–{end}";
|
||||
// }
|
||||
//
|
||||
// var dateRange = FormatDateRange(e.DateIn, e.DateOut);
|
||||
// var positionWithDates = string.IsNullOrEmpty(position) ? $"({dateRange})" : $"{position} ({dateRange})"; // Место работы с датой
|
||||
|
||||
if (!grouped.TryGetValue(fullName, out var entry))
|
||||
{
|
||||
entry = (personId, new List<string>());
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrEmpty(entry.PersonId) || entry.PersonId == personId)
|
||||
{
|
||||
entry.PersonId = personId;
|
||||
}
|
||||
}
|
||||
|
||||
// if (!entry.Positions.Contains(positionWithDates))
|
||||
// {
|
||||
// entry.Positions.Add(positionWithDates);
|
||||
// }
|
||||
|
||||
grouped[fullName] = entry;
|
||||
}
|
||||
|
||||
var groupMs = stopwatch.ElapsedMilliseconds - groupStartMs;
|
||||
var totalMs = stopwatch.ElapsedMilliseconds;
|
||||
|
||||
logger.LogInformationHere(
|
||||
$"GetEmployeesAsync timing: mapPersons={mapMs}ms, groupEmployees={groupMs}ms, total={totalMs}ms");
|
||||
|
||||
return grouped;
|
||||
return employees;
|
||||
}
|
||||
|
||||
#region Проксирование методов из ModeusHttpClient
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using ModeusSchedule.Abstractions;
|
||||
|
||||
namespace SfeduSchedule.Services;
|
||||
|
||||
public sealed class PluginCacheInvalidator(
|
||||
IEnumerable<IPlugin> plugins,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<PluginCacheInvalidator> logger)
|
||||
{
|
||||
public async Task InvalidateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
await plugin.InvalidateCacheAsync(scope.ServiceProvider, cancellationToken);
|
||||
logger.LogInformation("Кэш плагина {PluginName} инвалидирован", plugin.Name);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Не удалось инвалидировать кэш плагина {PluginName}", plugin.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
|
||||
namespace SfeduSchedule.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Скрывает ссылки на подключение к занятиям от пользователей без Microsoft-сессии.
|
||||
/// </summary>
|
||||
public static class ScheduleLocationFilter
|
||||
{
|
||||
private const string AuthorizationRequiredMessage = "авторизуйтесь для просмотра";
|
||||
private static readonly Regex AbsoluteUriPattern = new(
|
||||
@"(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]*:[^\s<>\[\]()]+",
|
||||
RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает исходное расписание авторизованному пользователю или заменяет значение
|
||||
/// <c>customLocation</c> безопасной заглушкой, если оно содержит абсолютный URI.
|
||||
/// </summary>
|
||||
/// <param name="schedule">JSON расписания Modeus.</param>
|
||||
/// <param name="httpContext">Контекст запроса, содержащий Microsoft authentication cookie.</param>
|
||||
/// <returns>Исходный либо отфильтрованный JSON; <see langword="null"/>, если расписание отсутствует.</returns>
|
||||
/// <exception cref="JsonException">JSON расписания пуст или имеет некорректный формат.</exception>
|
||||
public static async Task<string?> FilterAsync(string? schedule, HttpContext httpContext)
|
||||
{
|
||||
if (schedule == null)
|
||||
return null;
|
||||
|
||||
// API key не даёт доступ к ссылкам: проверяем только cookie, созданную Microsoft OIDC-входом.
|
||||
var authenticationResult =
|
||||
await httpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
if (authenticationResult.Succeeded)
|
||||
return schedule;
|
||||
|
||||
var root = JsonNode.Parse(schedule) ?? throw new JsonException("Schedule response is empty.");
|
||||
if (root["_embedded"]?["event-locations"] is not JsonArray eventLocations)
|
||||
return root.ToJsonString();
|
||||
|
||||
// Если ссылка окружена текстом или Markdown-разметкой, скрываем всё значение customLocation.
|
||||
foreach (var eventLocation in eventLocations.OfType<JsonObject>())
|
||||
{
|
||||
if (eventLocation["customLocation"] is not JsonValue value ||
|
||||
!value.TryGetValue<string>(out var customLocation) ||
|
||||
string.IsNullOrWhiteSpace(customLocation) ||
|
||||
!ContainsAbsoluteUri(customLocation))
|
||||
continue;
|
||||
|
||||
eventLocation["customLocation"] = AuthorizationRequiredMessage;
|
||||
}
|
||||
|
||||
return root.ToJsonString();
|
||||
}
|
||||
|
||||
private static bool ContainsAbsoluteUri(string value)
|
||||
{
|
||||
return AbsoluteUriPattern.Matches(value)
|
||||
.Any(match => Uri.TryCreate(match.Value, UriKind.Absolute, out _));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ services:
|
||||
- Sentry:Dsn=${SENTRY_DSN:-}
|
||||
- SENTRY_TRACES_SAMPLE_RATE=${SENTRY_TRACES_SAMPLE_RATE:-1}
|
||||
- SENTRY_PROFILES_SAMPLE_RATE=${SENTRY_PROFILES_SAMPLE_RATE:-1}
|
||||
- TZ=Europe/Moscow
|
||||
volumes:
|
||||
- data:/app/data
|
||||
restart: always
|
||||
|
||||
@@ -17,6 +17,7 @@ services:
|
||||
- AUTH_API_KEY=${AUTH_API_KEY}
|
||||
- SENTRY_TRACES_SAMPLE_RATE=${SENTRY_TRACES_SAMPLE_RATE:-1}
|
||||
- SENTRY_PROFILES_SAMPLE_RATE=${SENTRY_PROFILES_SAMPLE_RATE:-1}
|
||||
- TZ=Europe/Moscow
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
Reference in new issue
Block a user