From 7ca42581e6a2c398fbf4fed9f1b678ae1ea9c942 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Thu, 15 Oct 2026 21:07:08 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=BE=D0=BC=20?= =?UTF-8?q?=D0=BB=D1=8E=D0=B4=D0=B5=D0=B9=20=D0=B8=D0=B7=20Modeus=20=D0=B8?= =?UTF-8?q?=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BE=20=D1=81=D0=BE?= =?UTF-8?q?=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- SfeduSchedule/Jobs/UpdateEmployeesJob.cs | 27 ++-- SfeduSchedule/Program.cs | 2 + SfeduSchedule/Services/AtomicJsonFile.cs | 28 ++++ SfeduSchedule/Services/ModeusHttpClient.cs | 4 +- SfeduSchedule/Services/ModeusPeopleService.cs | 60 +++++++ SfeduSchedule/Services/ModeusService.cs | 146 +++++++----------- 7 files changed, 173 insertions(+), 100 deletions(-) create mode 100644 SfeduSchedule/Services/AtomicJsonFile.cs create mode 100644 SfeduSchedule/Services/ModeusPeopleService.cs diff --git a/README.md b/README.md index f16dbbb..29482d5 100644 --- a/README.md +++ b/README.md @@ -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): diff --git a/SfeduSchedule/Jobs/UpdateEmployeesJob.cs b/SfeduSchedule/Jobs/UpdateEmployeesJob.cs index 8d77a7c..2b88262 100644 --- a/SfeduSchedule/Jobs/UpdateEmployeesJob.cs +++ b/SfeduSchedule/Jobs/UpdateEmployeesJob.cs @@ -4,20 +4,18 @@ using SfeduSchedule.Services; namespace SfeduSchedule.Jobs; -// TODO: Обновляет список сотрудников из modeus и сохраняет в локальный файл -// TODO: Нужно вынести функционал обновления сюда, а в сервисе оставить только загрузку с диска -// TODO: Нужно настроить выполнение задачи на часов 10 утра каждый день -// TODO: Нужно обработать событие когда данные о сотрудниках запрашиваются до первого обновления +[DisallowConcurrentExecution] public class UpdateEmployeesJob( ILogger 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; } diff --git a/SfeduSchedule/Program.cs b/SfeduSchedule/Program.cs index 1f66445..d30f52e 100644 --- a/SfeduSchedule/Program.cs +++ b/SfeduSchedule/Program.cs @@ -85,6 +85,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddHttpClient("authClient"); builder.Services.AddAuthentication() diff --git a/SfeduSchedule/Services/AtomicJsonFile.cs b/SfeduSchedule/Services/AtomicJsonFile.cs new file mode 100644 index 0000000..21f863a --- /dev/null +++ b/SfeduSchedule/Services/AtomicJsonFile.cs @@ -0,0 +1,28 @@ +using System.Text.Json; + +namespace SfeduSchedule.Services; + +public static class AtomicJsonFile +{ + public static async Task WriteAsync(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); + } + } +} diff --git a/SfeduSchedule/Services/ModeusHttpClient.cs b/SfeduSchedule/Services/ModeusHttpClient.cs index a221a98..e03a344 100644 --- a/SfeduSchedule/Services/ModeusHttpClient.cs +++ b/SfeduSchedule/Services/ModeusHttpClient.cs @@ -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(); } -} \ No newline at end of file +} diff --git a/SfeduSchedule/Services/ModeusPeopleService.cs b/SfeduSchedule/Services/ModeusPeopleService.cs new file mode 100644 index 0000000..75ddd48 --- /dev/null +++ b/SfeduSchedule/Services/ModeusPeopleService.cs @@ -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 Persons); + +/// Последний полностью загруженный список людей Modeus. Не публикуется через API. +public sealed class ModeusPeopleService(ILogger 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(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."); + } +} diff --git a/SfeduSchedule/Services/ModeusService.cs b/SfeduSchedule/Services/ModeusService.cs index 4d2ca53..89d0540 100644 --- a/SfeduSchedule/Services/ModeusService.cs +++ b/SfeduSchedule/Services/ModeusService.cs @@ -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)>> GetEmployeesAsync(CancellationToken cancellationToken = default) + /// Получить полный список людей одним проходом всех страниц Modeus. + public async Task 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(StringComparer.OrdinalIgnoreCase); + var students = new HashSet(StringComparer.OrdinalIgnoreCase); + var employees = new HashSet(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(); - 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)>> GetEmployeesAsync(CancellationToken cancellationToken = default) + => GetEmployees(await GetPeopleAsync(cancellationToken)); + + public static Dictionary)> GetEmployees(ModeusPeopleSnapshot snapshot) + { + var employees = new Dictionary)>(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 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()); - } - 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 -} \ No newline at end of file +}