feat(employees): Внедрил фоновое обновление списка сотрудников
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 1m12s

Реализовал новую фоновую задачу для периодического получения данных о сотрудниках из Modeus.
This commit is contained in:
2026-02-01 08:36:19 +03:00
parent 46c50dc8e2
commit 46bdc07910
2 changed files with 147 additions and 57 deletions

View File

@@ -0,0 +1,78 @@
using Quartz;
using SfeduSchedule.Logging;
using SfeduSchedule.Services;
namespace SfeduSchedule.Jobs;
// TODO: Обновляет список сотрудников из modeus и сохраняет в локальный файл
// TODO: Нужно вынести функционал обновления сюда, а в сервисе оставить только загрузку с диска
// TODO: Нужно настроить выполнение задачи на часов 10 утра каждый день
// TODO: Нужно обработать событие когда данные о сотрудниках запрашиваются до первого обновления
public class UpdateEmployeesJob(
ILogger<UpdateEmployeesJob> logger,
ModeusEmployeeService employeeService,
ModeusService modeusService) : IJob
{
private const int MaxAttempts = 5; // Максимальное число попыток
private const int DelaySeconds = 50; // Задержка между попытками в секундах
private const int TimeoutSeconds = 60; // Таймаут для каждого запроса в секундах
public async Task Execute(IJobExecutionContext jobContext)
{
logger.LogInformation("Начало выполнения UpdateEmployeesJob");
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
try
{
logger.LogInformation("Попытка {Attempt}/{MaxAttempts} получения списка сотрудников из Modeus", attempt,
MaxAttempts);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(TimeoutSeconds));
var employees = await modeusService.GetEmployeesAsync(cts.Token);
if (employees.Count == 0)
{
logger.LogWarningHere("Не удалось получить список сотрудников из Modeus.");
if (attempt == MaxAttempts)
{
logger.LogError("Достигнуто максимальное число попыток получения JWT");
return;
}
await Task.Delay(TimeSpan.FromSeconds(DelaySeconds), jobContext.CancellationToken);
continue;
}
await employeeService.SetEmployees(employees);
logger.LogInformationHere($"Получено {employees.Count} сотрудников из Modeus.");
return;
}
catch (OperationCanceledException ex)
{
logger.LogWarning(ex, "Таймаут при получении JWT (попытка {Attempt}/{MaxAttempts})", attempt,
MaxAttempts);
if (attempt == MaxAttempts)
{
logger.LogError("Достигнут лимит по таймаутам при запросе JWT");
return;
}
await Task.Delay(TimeSpan.FromSeconds(DelaySeconds), jobContext.CancellationToken);
}
catch (Exception ex)
{
logger.LogErrorHere(ex, "Ошибка при загрузке сотрудников из Modeus.");
if (attempt == MaxAttempts)
{
logger.LogError("Достигнуто максимальное число попыток из-за ошибок при запросе JWT");
return;
}
await Task.Delay(TimeSpan.FromSeconds(DelaySeconds), jobContext.CancellationToken);
}
}
}

View File

@@ -11,6 +11,8 @@ using Microsoft.OpenApi.Models;
using ModeusSchedule.Abstractions;
using Prometheus;
using Quartz;
using Quartz.Listener;
using Quartz.Impl.Matchers;
using SfeduSchedule;
using SfeduSchedule.Auth;
using SfeduSchedule.Jobs;
@@ -24,29 +26,24 @@ var builder = WebApplication.CreateBuilder(args);
#region Работа с конфигурацией
var configuration = builder.Configuration;
var preinstalledJwtToken = configuration["TOKEN"];
var tgChatId = configuration["TG_CHAT_ID"];
var tgToken = configuration["TG_TOKEN"];
var updateJwtCron = configuration["UPDATE_JWT_CRON"] ?? "0 0 4 ? * *";
var preinstalledJwtToken = configuration[AppConsts.PreinstalledJwtTokenEnv];
configuration[AppConsts.ModeusUrlEnv] ??= AppConsts.ModeusDefaultUrl;
configuration[AppConsts.UpdateJwtCronEnv] ??= "0 0 4 ? * *";
configuration[AppConsts.UpdateEmployeeCronEnv] ??= "0 0 6 ? * *";
// Если не указана TZ, ставим Europe/Moscow
if (string.IsNullOrEmpty(configuration["TZ"]))
configuration["TZ"] = "Europe/Moscow";
configuration["TZ"] ??= "Europe/Moscow";
if (string.IsNullOrEmpty(configuration["MODEUS_URL"]))
configuration["MODEUS_URL"] = "https://sfedu.modeus.org/";
var permitLimit = int.TryParse(configuration["PERMIT_LIMIT"], out var parsedPermitLimit) ? parsedPermitLimit : 40;
var timeLimit = int.TryParse(configuration["TIME_LIMIT"], out var parsedTimeLimit) ? parsedTimeLimit : 10;
var permitLimit = int.TryParse(configuration[AppConsts.PermitLimitEnv], out var parsedPermitLimit) ? parsedPermitLimit : 40;
var timeLimit = int.TryParse(configuration[AppConsts.TimeLimitEnv], out var parsedTimeLimit) ? parsedTimeLimit : 10;
#endregion
#region Работа с папкой данных
// Создать папку data если не существует
var dataDirectory = Path.Combine(AppContext.BaseDirectory, "data");
var dataDirectory = Path.Combine(AppContext.BaseDirectory, AppConsts.DataFolderName);
if (!Directory.Exists(dataDirectory)) Directory.CreateDirectory(dataDirectory);
GlobalConsts.JwtFilePath = Path.Combine(dataDirectory, "jwt.txt");
var jwtFilePath = Path.Combine(dataDirectory, AppConsts.JwtFileName);
// Создать подкаталог для плагинов
var pluginsPath = Path.Combine(dataDirectory, "Plugins");
@@ -63,7 +60,7 @@ builder.Logging.AddConsole(options => options.FormatterName = "CustomConsoleForm
.AddConsoleFormatter<ConsoleFormatter, ConsoleFormatterOptions>();
builder.Logging.AddFilter("Quartz", LogLevel.Warning);
if (!string.IsNullOrEmpty(tgChatId) && !string.IsNullOrEmpty(tgToken))
if (!string.IsNullOrEmpty(configuration[AppConsts.TgChatIdEnv]) && !string.IsNullOrEmpty(configuration[AppConsts.TgTokenEnv]))
builder.Logging.AddTelegram(options =>
{
options.FormatterConfiguration = new X.Extensions.Logging.Telegram.Base.Configuration.FormatterConfiguration
@@ -71,8 +68,8 @@ if (!string.IsNullOrEmpty(tgChatId) && !string.IsNullOrEmpty(tgToken))
IncludeException = true,
IncludeProperties = true,
};
options.ChatId = tgChatId;
options.AccessToken = tgToken;
options.ChatId = configuration[AppConsts.TgChatIdEnv]!;
options.AccessToken = configuration[AppConsts.TgTokenEnv]!;
options.FormatterConfiguration.UseEmoji = true;
options.FormatterConfiguration.ReadableApplicationName = "Modeus Schedule Proxy";
options.LogLevel = new Dictionary<string, LogLevel>
@@ -149,23 +146,35 @@ foreach (var p in loadedPlugins)
mvcBuilder.PartManager.ApplicationParts.Add(new AssemblyPart(p.Assembly));
}
var jobKey = new JobKey("UpdateJWTJob");
var updateJwtJob = new JobKey("UpdateJWTJob");
if (string.IsNullOrEmpty(preinstalledJwtToken))
{
builder.Services.AddQuartz(q =>
builder.Services.AddQuartz(q =>
{
q.AddJob<UpdateJwtJob>(opts => opts.WithIdentity(jobKey));
q.AddJob<UpdateJwtJob>(opts => opts.WithIdentity(updateJwtJob));
if (string.IsNullOrEmpty(preinstalledJwtToken))
{
q.AddTrigger(opts => opts
.ForJob(updateJwtJob)
.WithIdentity("UpdateJWTJob-trigger")
.WithCronSchedule(configuration["UPDATE_JWT_CRON"]!)
);
}
q.AddJob<UpdateEmployeesJob>(opts => opts.WithIdentity(nameof(UpdateEmployeesJob)));
q.AddTrigger(opts => opts
.ForJob(jobKey)
.WithIdentity("UpdateJWTJob-trigger")
.WithCronSchedule(updateJwtCron)
.ForJob(nameof(UpdateEmployeesJob))
.WithIdentity("UpdateEmployeesJob-trigger")
.WithCronSchedule(configuration[AppConsts.UpdateEmployeeCronEnv]!)
);
// после успешного выполнения UpdateJwtJob сразу выполняем UpdateEmployeesJob
var chainListener = new JobChainingJobListener("chain");
chainListener.AddJobChainLink(updateJwtJob, new JobKey(nameof(UpdateEmployeesJob)));
q.AddJobListener(chainListener, GroupMatcher<JobKey>.GroupEquals(JobKey.DefaultGroup));
});
builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
}
builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = false);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
@@ -258,45 +267,48 @@ app.UseForwardedHeaders();
// Корреляция логов по запросам
app.UseMiddleware<CorrelationIdMiddleware>();
if (string.IsNullOrEmpty(preinstalledJwtToken))
{
var schedulerFactory = app.Services.GetRequiredService<ISchedulerFactory>();
var scheduler = await schedulerFactory.GetScheduler();
// Проверить существование файла jwt.txt
if (File.Exists(GlobalConsts.JwtFilePath))
var schedulerFactory = app.Services.GetRequiredService<ISchedulerFactory>();
var scheduler = await schedulerFactory.GetScheduler();
var refreshJwt = true;
// Если есть предустановленный токен, используем его
if (!string.IsNullOrEmpty(preinstalledJwtToken))
{
logger.LogInformation("Используем предустановленный токен из конфигурации");
configuration["TOKEN"] = preinstalledJwtToken;
refreshJwt = false;
}
// Проверить существование файла jwt.txt
if (File.Exists(jwtFilePath) && refreshJwt)
{
logger.LogInformation("Обнаружена прошлая сессия");
var lines = await File.ReadAllLinesAsync(jwtFilePath);
if (lines.Length > 1 && DateTime.TryParse(lines[1], out var expirationDate))
{
logger.LogInformation("Обнаружена прошлая сессия");
var lines = await File.ReadAllLinesAsync(GlobalConsts.JwtFilePath);
if (lines.Length > 1 && DateTime.TryParse(lines[1], out var expirationDate))
logger.LogInformation("Дата истечения токена: {ExpirationDate}", expirationDate);
if (expirationDate.AddHours(23) > DateTime.Now)
{
logger.LogInformation("Дата истечения токена: {ExpirationDate}", expirationDate);
if (expirationDate.AddHours(23) > DateTime.Now)
{
var token = lines[0];
logger.LogInformation("Используем существующий токен");
configuration["TOKEN"] = token;
}
else
{
logger.LogInformation("Токен истек или скоро истечет, выполняем обновление токена");
await scheduler.TriggerJob(jobKey);
}
var token = lines[0];
logger.LogInformation("Используем существующий токен");
configuration["TOKEN"] = token;
refreshJwt = false;
}
else
{
logger.LogInformation(
"Файл jwt.txt не содержит дату истечения или она некорректна, выполняем обновление токена");
await scheduler.TriggerJob(jobKey);
}
logger.LogInformation("Токен истек или скоро истечет, выполняем обновление токена");
}
else
{
await scheduler.TriggerJob(jobKey);
}
logger.LogInformation(
"Файл jwt.txt не содержит дату истечения или она некорректна, выполняем обновление токена");
}
// Обновить JWT если нужно, либо сразу запустить обновление сотрудников
if (refreshJwt)
await scheduler.TriggerJob(updateJwtJob);
else
logger.LogInformation("Используем предустановленный токен из конфигурации");
await scheduler.TriggerJob(new JobKey(nameof(UpdateEmployeesJob)));
app.UseSwagger();
app.UseSwaggerUI();