Compare commits
2
Commits
c590a8a6c7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ca42581e6 | ||
|
|
672d7c40b4 |
No files matched your search
+1
-1
Submodule ModeusSchedule.MSAuth updated: 89316e247f...2c4fccb22d.
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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