Compare commits

...
2 Commits
10 changed files with 186 additions and 111 deletions

No files matched your search

+5 -1
View File
@@ -86,7 +86,8 @@
Обновление выполняет Quartz job [UpdateEmployeesJob](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Jobs/UpdateEmployeesJob.cs):
- ходит в `Modeus`;
- загружает большой список сотрудников;
- загружает список людей из Modeus и проверяет полноту результата;
- сохраняет общий индекс людей с признаками студента и сотрудника в `data/people.json`;
- группирует записи по ФИО;
- сохраняет результат на диск;
- после успешного обновления предлагает всем плагинам инвалидировать свои кэши;
@@ -94,6 +95,9 @@
Если сервис ещё не инициализирован, `GET /api/schedule/searchemployee` возвращает `503`.
`ModeusPeopleService` предоставляет плагинам последний полный снимок людей и восстанавливает его при запуске. Новая версия файла заменяет предыдущую атомарно;
ошибка загрузки или записи сохраняет рабочий снимок. Для всего прохода страниц выделено пять минут. Контракт поиска преподавателей не меняется, студенты в его ответ не добавляются.
### 5. JWT lifecycle
Quartz job [UpdateJwtJob](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Jobs/UpdateJWTJob.cs):
+16 -11
View File
@@ -4,20 +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,
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)
{
@@ -29,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;
}
@@ -46,19 +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;
}
@@ -70,7 +75,7 @@ public class UpdateEmployeesJob(
if (attempt == MaxAttempts)
{
logger.LogError("Достигнуто максимальное число попыток из-за ошибок при запросе JWT");
logger.LogError("Достигнуто максимальное число попыток из-за ошибок при загрузке людей Modeus");
return;
}
+2
View File
@@ -85,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()
+28
View File
@@ -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);
}
}
}
+2 -2
View File
@@ -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.");
}
@@ -122,4 +122,4 @@ public class ModeusHttpClient
return await response.Content.ReadAsStringAsync();
}
}
}
@@ -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.");
}
}
+60 -86
View File
@@ -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
@@ -322,4 +296,4 @@ public class ModeusService(
}
#endregion
}
}
+6 -5
View File
@@ -14,14 +14,15 @@ services:
- API_KEY=${API_KEY}
# - TOKEN=${TOKEN}
- AUTH_URL=${AUTH_URL}
- AUTH_API_KEY=${AUTH_API_KEY}
- Sentry:Dsn=${SENTRY_DSN:-}
- SENTRY_TRACES_SAMPLE_RATE=${SENTRY_TRACES_SAMPLE_RATE:-1}
- SENTRY_PROFILES_SAMPLE_RATE=${SENTRY_PROFILES_SAMPLE_RATE:-1}
- AUTH_API_KEY=${AUTH_API_KEY}
- 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
image: git.zetcraft.ru/serega404/sfeduschedule:main
volumes:
data:
data:
+6 -5
View File
@@ -13,13 +13,14 @@ services:
- AzureAd:CallbackPath=/signin-oidc
- API_KEY=${API_KEY}
# - TOKEN=${TOKEN}
- AUTH_URL=${AUTH_URL}
- AUTH_API_KEY=${AUTH_API_KEY}
- SENTRY_TRACES_SAMPLE_RATE=${SENTRY_TRACES_SAMPLE_RATE:-1}
- SENTRY_PROFILES_SAMPLE_RATE=${SENTRY_PROFILES_SAMPLE_RATE:-1}
- AUTH_URL=${AUTH_URL}
- 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
build:
context: ./SfeduSchedule
dockerfile: Dockerfile
dockerfile: Dockerfile