diff --git a/.gitignore b/.gitignore
index ce89292..5afb6cc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -416,3 +416,6 @@ FodyWeavers.xsd
*.msix
*.msm
*.msp
+
+# User-specific
+.DS_Store
diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor
index 661f118..13deb80 100644
--- a/Client.Wasm/Components/StudentCard.razor
+++ b/Client.Wasm/Components/StudentCard.razor
@@ -4,10 +4,10 @@
- Номер №X "Название лабораторной"
- Вариант №Х "Название варианта"
- Выполнена Фамилией Именем 65ХХ
- Ссылка на форк
+ Номер №1 "Кэширование"
+ Вариант №37 "Учебный курс"
+ Выполнена Вольговым Даниилом 6513
+ Ссылка на форк
diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json
index d1fe7ab..20b85a3 100644
--- a/Client.Wasm/wwwroot/appsettings.json
+++ b/Client.Wasm/wwwroot/appsettings.json
@@ -6,5 +6,5 @@
}
},
"AllowedHosts": "*",
- "BaseAddress": ""
+ "BaseAddress": "http://localhost:5117/api/courses/by-id"
}
diff --git a/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj b/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj
new file mode 100644
index 0000000..6edd01b
--- /dev/null
+++ b/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CloudDevelopment.AppHost/Program.cs b/CloudDevelopment.AppHost/Program.cs
new file mode 100644
index 0000000..e6b11e5
--- /dev/null
+++ b/CloudDevelopment.AppHost/Program.cs
@@ -0,0 +1,16 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+var redis = builder.AddRedis("redis")
+ .WithRedisInsight(containerName: "redis-insight");
+
+var courseGeneratorApi = builder.AddProject("course-generator-api")
+ .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
+ .WithReference(redis)
+ .WaitFor(redis)
+ .WithHttpEndpoint(name: "api", port: 5117);
+
+builder.AddProject("client-wasm")
+ .WaitFor(courseGeneratorApi)
+ .WithExternalHttpEndpoints();
+
+builder.Build().Run();
diff --git a/CloudDevelopment.AppHost/Properties/launchSettings.json b/CloudDevelopment.AppHost/Properties/launchSettings.json
new file mode 100644
index 0000000..38a3bbb
--- /dev/null
+++ b/CloudDevelopment.AppHost/Properties/launchSettings.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15044",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19078",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20218"
+ }
+ }
+ }
+}
diff --git a/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj b/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj
new file mode 100644
index 0000000..87b70e2
--- /dev/null
+++ b/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CloudDevelopment.ServiceDefaults/Extensions.cs b/CloudDevelopment.ServiceDefaults/Extensions.cs
new file mode 100644
index 0000000..86ae232
--- /dev/null
+++ b/CloudDevelopment.ServiceDefaults/Extensions.cs
@@ -0,0 +1,102 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace Microsoft.Extensions.Hosting;
+
+public static class Extensions
+{
+ private const string HealthEndpointPath = "/health";
+ private const string AlivenessEndpointPath = "/alive";
+
+ public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.ConfigureOpenTelemetry();
+
+ builder.AddDefaultHealthChecks();
+
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ http.AddStandardResilienceHandler();
+ http.AddServiceDiscovery();
+ });
+
+ return builder;
+ }
+
+ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder)
+ where TBuilder : IHostApplicationBuilder
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddSource(builder.Environment.ApplicationName)
+ .AddAspNetCoreInstrumentation(tracing =>
+ tracing.Filter = context =>
+ !context.Request.Path.StartsWithSegments(HealthEndpointPath)
+ && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
+ )
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ private static TBuilder AddOpenTelemetryExporters(this TBuilder builder)
+ where TBuilder : IHostApplicationBuilder
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.AddOpenTelemetry().UseOtlpExporter();
+ }
+
+ return builder;
+ }
+
+ public static TBuilder AddDefaultHealthChecks(this TBuilder builder)
+ where TBuilder : IHostApplicationBuilder
+ {
+ builder.Services.AddHealthChecks()
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ if (app.Environment.IsDevelopment())
+ {
+ app.MapHealthChecks(HealthEndpointPath);
+ app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
+ {
+ Predicate = healthCheck => healthCheck.Tags.Contains("live")
+ });
+ }
+
+ return app;
+ }
+}
diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln
index cb48241..3c2c273 100644
--- a/CloudDevelopment.sln
+++ b/CloudDevelopment.sln
@@ -5,6 +5,12 @@ VisualStudioVersion = 17.14.36811.4
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CourseGenerator.Api", "CourseGenerator.Api\CourseGenerator.Api.csproj", "{7A4FEA0A-49BC-4E8C-BF70-686F8353F47B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudDevelopment.AppHost", "CloudDevelopment.AppHost\CloudDevelopment.AppHost.csproj", "{9EF677E1-2CD8-4CE1-8D8D-5BF85E445475}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudDevelopment.ServiceDefaults", "CloudDevelopment.ServiceDefaults\CloudDevelopment.ServiceDefaults.csproj", "{CCF09110-50FF-43D7-9E4A-3CE2089D2354}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -15,6 +21,18 @@ Global
{AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7A4FEA0A-49BC-4E8C-BF70-686F8353F47B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7A4FEA0A-49BC-4E8C-BF70-686F8353F47B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7A4FEA0A-49BC-4E8C-BF70-686F8353F47B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7A4FEA0A-49BC-4E8C-BF70-686F8353F47B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9EF677E1-2CD8-4CE1-8D8D-5BF85E445475}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9EF677E1-2CD8-4CE1-8D8D-5BF85E445475}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9EF677E1-2CD8-4CE1-8D8D-5BF85E445475}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9EF677E1-2CD8-4CE1-8D8D-5BF85E445475}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CCF09110-50FF-43D7-9E4A-3CE2089D2354}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CCF09110-50FF-43D7-9E4A-3CE2089D2354}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CCF09110-50FF-43D7-9E4A-3CE2089D2354}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CCF09110-50FF-43D7-9E4A-3CE2089D2354}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/CourseGenerator.Api/Controllers/CourseContractsController.cs b/CourseGenerator.Api/Controllers/CourseContractsController.cs
new file mode 100644
index 0000000..d34f04b
--- /dev/null
+++ b/CourseGenerator.Api/Controllers/CourseContractsController.cs
@@ -0,0 +1,88 @@
+using System.ComponentModel.DataAnnotations;
+using CourseGenerator.Api.Dto;
+using CourseGenerator.Api.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace CourseGenerator.Api.Controllers;
+
+[ApiController]
+[Route("api/courses")]
+public sealed class CourseContractsController(
+ ICourseContractsService contractsService,
+ ICourseContractGenerator contractGenerator) : ControllerBase
+{
+ ///
+ /// Генерирует список контрактов курсов с кэшированием результата в Redis.
+ ///
+ /// Количество контрактов для генерации (от 1 до 100).
+ /// Токен отмены запроса.
+ /// Список сгенерированных контрактов курсов.
+ /// Контракты успешно получены.
+ /// Передан недопустимый параметр count.
+ [HttpGet("generate")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task>> GenerateAsync(
+ [FromQuery, Range(1, 100)] int count,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var contracts = await contractsService.GenerateAsync(count, cancellationToken);
+ var dto = contracts
+ .Select(contract => new CourseContractDto(
+ contract.Id,
+ contract.CourseName,
+ contract.TeacherFullName,
+ contract.StartDate,
+ contract.EndDate,
+ contract.MaxStudents,
+ contract.CurrentStudents,
+ contract.HasCertificate,
+ contract.Price,
+ contract.Rating))
+ .ToList();
+
+ return Ok(dto);
+ }
+ catch (ArgumentOutOfRangeException ex)
+ {
+ var problem = new ValidationProblemDetails(new Dictionary
+ {
+ ["count"] = [ex.Message]
+ });
+ return BadRequest(problem);
+ }
+ }
+
+ ///
+ /// Возвращает один сгенерированный контракт по идентификатору для совместимости с клиентом.
+ ///
+ /// Неотрицательный идентификатор объекта.
+ /// Токен отмены запроса.
+ /// Сгенерированный контракт.
+ /// Контракт успешно получен.
+ /// Передан недопустимый параметр id.
+ [HttpGet("by-id")]
+ [ProducesResponseType(typeof(CourseContractDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public ActionResult GetByIdAsync(
+ [FromQuery, Range(0, int.MaxValue)] int id,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var contract = contractGenerator.GenerateById(id);
+
+ return Ok(new CourseContractDto(
+ contract.Id,
+ contract.CourseName,
+ contract.TeacherFullName,
+ contract.StartDate,
+ contract.EndDate,
+ contract.MaxStudents,
+ contract.CurrentStudents,
+ contract.HasCertificate,
+ contract.Price,
+ contract.Rating));
+ }
+}
diff --git a/CourseGenerator.Api/CourseGenerator.Api.csproj b/CourseGenerator.Api/CourseGenerator.Api.csproj
new file mode 100644
index 0000000..d5e8615
--- /dev/null
+++ b/CourseGenerator.Api/CourseGenerator.Api.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+ $(NoWarn);1591
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CourseGenerator.Api/Dto/CourseContractDto.cs b/CourseGenerator.Api/Dto/CourseContractDto.cs
new file mode 100644
index 0000000..40ee119
--- /dev/null
+++ b/CourseGenerator.Api/Dto/CourseContractDto.cs
@@ -0,0 +1,16 @@
+namespace CourseGenerator.Api.Dto;
+
+///
+/// Контракт на проведение учебного курса.
+///
+public sealed record CourseContractDto(
+ int Id,
+ string CourseName,
+ string TeacherFullName,
+ DateOnly StartDate,
+ DateOnly EndDate,
+ int MaxStudents,
+ int CurrentStudents,
+ bool HasCertificate,
+ decimal Price,
+ int Rating);
diff --git a/CourseGenerator.Api/Interfaces/ICourseContractCacheService.cs b/CourseGenerator.Api/Interfaces/ICourseContractCacheService.cs
new file mode 100644
index 0000000..cc53907
--- /dev/null
+++ b/CourseGenerator.Api/Interfaces/ICourseContractCacheService.cs
@@ -0,0 +1,25 @@
+using CourseGenerator.Api.Models;
+
+namespace CourseGenerator.Api.Interfaces;
+
+///
+/// Контракт сервиса кэширования сгенерированных учебных контрактов.
+///
+public interface ICourseContractCacheService
+{
+ ///
+ /// Возвращает список контрактов из кэша по размеру выборки.
+ ///
+ /// Количество контрактов в запрошенной выборке.
+ /// Токен отмены операции.
+ /// Список контрактов из кэша или null, если запись не найдена.
+ Task?> GetAsync(int count, CancellationToken cancellationToken = default);
+
+ ///
+ /// Сохраняет список контрактов в кэш.
+ ///
+ /// Количество контрактов в выборке.
+ /// Контракты для сохранения.
+ /// Токен отмены операции.
+ Task SetAsync(int count, IReadOnlyList contracts, CancellationToken cancellationToken = default);
+}
diff --git a/CourseGenerator.Api/Interfaces/ICourseContractGenerator.cs b/CourseGenerator.Api/Interfaces/ICourseContractGenerator.cs
new file mode 100644
index 0000000..050869a
--- /dev/null
+++ b/CourseGenerator.Api/Interfaces/ICourseContractGenerator.cs
@@ -0,0 +1,23 @@
+using CourseGenerator.Api.Models;
+
+namespace CourseGenerator.Api.Interfaces;
+
+///
+/// Контракт генератора учебных контрактов.
+///
+public interface ICourseContractGenerator
+{
+ ///
+ /// Генерирует указанное количество учебных контрактов.
+ ///
+ /// Количество элементов для генерации.
+ /// Список сгенерированных контрактов.
+ IReadOnlyList Generate(int count);
+
+ ///
+ /// Генерирует один контракт детерминированно по идентификатору.
+ ///
+ /// Идентификатор, используемый как seed генерации.
+ /// Сгенерированный контракт.
+ CourseContract GenerateById(int id);
+}
diff --git a/CourseGenerator.Api/Interfaces/ICourseContractsService.cs b/CourseGenerator.Api/Interfaces/ICourseContractsService.cs
new file mode 100644
index 0000000..716b0f1
--- /dev/null
+++ b/CourseGenerator.Api/Interfaces/ICourseContractsService.cs
@@ -0,0 +1,17 @@
+using CourseGenerator.Api.Models;
+
+namespace CourseGenerator.Api.Interfaces;
+
+///
+/// Контракт прикладного сервиса генерации контрактов с учетом кэша.
+///
+public interface ICourseContractsService
+{
+ ///
+ /// Возвращает список контрактов из кэша или генерирует новые.
+ ///
+ /// Количество требуемых контрактов.
+ /// Токен отмены операции.
+ /// Список контрактов.
+ Task> GenerateAsync(int count, CancellationToken cancellationToken = default);
+}
diff --git a/CourseGenerator.Api/Models/CourseContract.cs b/CourseGenerator.Api/Models/CourseContract.cs
new file mode 100644
index 0000000..b63500c
--- /dev/null
+++ b/CourseGenerator.Api/Models/CourseContract.cs
@@ -0,0 +1,57 @@
+namespace CourseGenerator.Api.Models;
+
+///
+/// Контракт на проведение учебного курса.
+///
+public sealed record CourseContract
+{
+ ///
+ /// Идентификатор контракта.
+ ///
+ public int Id { get; init; }
+
+ ///
+ /// Название курса.
+ ///
+ public string CourseName { get; init; } = string.Empty;
+
+ ///
+ /// ФИО преподавателя.
+ ///
+ public string TeacherFullName { get; init; } = string.Empty;
+
+ ///
+ /// Дата начала курса.
+ ///
+ public DateOnly StartDate { get; init; }
+
+ ///
+ /// Дата окончания курса.
+ ///
+ public DateOnly EndDate { get; init; }
+
+ ///
+ /// Максимальное число студентов.
+ ///
+ public int MaxStudents { get; init; }
+
+ ///
+ /// Текущее число студентов.
+ ///
+ public int CurrentStudents { get; init; }
+
+ ///
+ /// Признак выдачи сертификата по итогам курса.
+ ///
+ public bool HasCertificate { get; init; }
+
+ ///
+ /// Стоимость курса.
+ ///
+ public decimal Price { get; init; }
+
+ ///
+ /// Рейтинг курса.
+ ///
+ public int Rating { get; init; }
+}
\ No newline at end of file
diff --git a/CourseGenerator.Api/Program.cs b/CourseGenerator.Api/Program.cs
new file mode 100644
index 0000000..960c3aa
--- /dev/null
+++ b/CourseGenerator.Api/Program.cs
@@ -0,0 +1,50 @@
+using System.Reflection;
+using CourseGenerator.Api.Interfaces;
+using CourseGenerator.Api.Services;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.Services.AddCors(options =>
+{
+ options.AddDefaultPolicy(policy =>
+ {
+ policy
+ .AllowAnyOrigin()
+ .AllowAnyMethod()
+ .AllowAnyHeader();
+ });
+});
+
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(options =>
+{
+ var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
+ var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
+ if (File.Exists(xmlPath))
+ {
+ options.IncludeXmlComments(xmlPath, includeControllerXmlComments: true);
+ }
+});
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.AddRedisDistributedCache(connectionName: "redis");
+
+var app = builder.Build();
+
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI();
+}
+
+app.UseCors();
+
+app.MapDefaultEndpoints();
+
+app.MapControllers();
+
+app.Run();
\ No newline at end of file
diff --git a/CourseGenerator.Api/Services/CourseContractCacheService.cs b/CourseGenerator.Api/Services/CourseContractCacheService.cs
new file mode 100644
index 0000000..7ed129b
--- /dev/null
+++ b/CourseGenerator.Api/Services/CourseContractCacheService.cs
@@ -0,0 +1,48 @@
+using System.Text.Json;
+using CourseGenerator.Api.Interfaces;
+using CourseGenerator.Api.Models;
+using Microsoft.Extensions.Caching.Distributed;
+
+namespace CourseGenerator.Api.Services;
+
+///
+/// Сервис работы с Redis-кэшем для списков учебных контрактов.
+///
+public sealed class CourseContractCacheService(IDistributedCache cache, ILogger logger) : ICourseContractCacheService
+{
+ private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
+
+ ///
+ public async Task?> GetAsync(int count, CancellationToken cancellationToken = default)
+ {
+ var key = BuildKey(count);
+ var cachedPayload = await cache.GetStringAsync(key, cancellationToken);
+
+ if (string.IsNullOrWhiteSpace(cachedPayload))
+ {
+ logger.LogInformation("Cache miss for key {CacheKey}", key);
+ return null;
+ }
+
+ logger.LogInformation("Cache hit for key {CacheKey}", key);
+
+ return JsonSerializer.Deserialize>(cachedPayload, SerializerOptions);
+ }
+
+ ///
+ public async Task SetAsync(int count, IReadOnlyList contracts, CancellationToken cancellationToken = default)
+ {
+ var key = BuildKey(count);
+ var payload = JsonSerializer.Serialize(contracts, SerializerOptions);
+
+ var options = new DistributedCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
+ };
+
+ await cache.SetStringAsync(key, payload, options, cancellationToken);
+ logger.LogInformation("Cache updated for key {CacheKey}", key);
+ }
+
+ private static string BuildKey(int count) => $"courses:count:{count}";
+}
diff --git a/CourseGenerator.Api/Services/CourseContractGenerator.cs b/CourseGenerator.Api/Services/CourseContractGenerator.cs
new file mode 100644
index 0000000..3fc3b3b
--- /dev/null
+++ b/CourseGenerator.Api/Services/CourseContractGenerator.cs
@@ -0,0 +1,121 @@
+using Bogus;
+using Bogus.DataSets;
+using CourseGenerator.Api.Interfaces;
+using CourseGenerator.Api.Models;
+
+namespace CourseGenerator.Api.Services;
+
+///
+/// Генератор тестовых учебных контрактов на основе Bogus.
+///
+public sealed class CourseContractGenerator(ILogger logger) : ICourseContractGenerator
+{
+ private static readonly object FakerLock = new();
+
+ private static readonly string[] CourseDictionary =
+ [
+ "Основы программирования на C#",
+ "Проектирование микросервисов",
+ "Базы данных и SQL",
+ "Инженерия требований",
+ "Тестирование программного обеспечения",
+ "Алгоритмы и структуры данных",
+ "Распределенные системы",
+ "Web-разработка на ASP.NET Core",
+ "DevOps и CI/CD",
+ "Машинное обучение в разработке ПО"
+ ];
+
+ private static readonly string[] MalePatronymicDictionary =
+ [
+ "Иванович",
+ "Петрович",
+ "Сергеевич",
+ "Алексеевич",
+ "Дмитриевич",
+ "Андреевич",
+ "Игоревич",
+ "Олегович",
+ "Владимирович",
+ "Николаевич"
+ ];
+
+ private static readonly string[] FemalePatronymicDictionary =
+ [
+ "Ивановна",
+ "Петровна",
+ "Сергеевна",
+ "Алексеевна",
+ "Дмитриевна",
+ "Андреевна",
+ "Игоревна",
+ "Олеговна",
+ "Владимировна",
+ "Николаевна"
+ ];
+
+ private static readonly Faker ContractFaker = new Faker("ru")
+ .RuleFor(contract => contract.Id, _ => 0)
+ .RuleFor(contract => contract.CourseName, f => f.PickRandom(CourseDictionary))
+ .RuleFor(contract => contract.TeacherFullName, f =>
+ {
+ var gender = f.PickRandom(Name.Gender.Male, Name.Gender.Female);
+ var firstName = f.Name.FirstName(gender);
+ var lastName = f.Name.LastName(gender);
+ var patronymic = gender == Name.Gender.Male
+ ? f.PickRandom(MalePatronymicDictionary)
+ : f.PickRandom(FemalePatronymicDictionary);
+
+ return $"{lastName} {firstName} {patronymic}";
+ })
+ .RuleFor(contract => contract.StartDate, f => DateOnly.FromDateTime(f.Date.Soon(60)))
+ .RuleFor(contract => contract.EndDate, (f, contract) => contract.StartDate.AddDays(f.Random.Int(1, 180)))
+ .RuleFor(contract => contract.MaxStudents, f => f.Random.Int(10, 200))
+ .RuleFor(contract => contract.CurrentStudents, (f, contract) => f.Random.Int(0, contract.MaxStudents))
+ .RuleFor(contract => contract.HasCertificate, f => f.Random.Bool())
+ .RuleFor(contract => contract.Price, f => decimal.Round(f.Random.Decimal(1000m, 120000m), 2, MidpointRounding.AwayFromZero))
+ .RuleFor(contract => contract.Rating, f => f.Random.Int(1, 5));
+
+ ///
+ public IReadOnlyList Generate(int count)
+ {
+ logger.LogInformation("Course generation started: {Count}", count);
+
+ if (count <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(count), "Count must be greater than zero.");
+ }
+
+ List generatedContracts;
+ lock (FakerLock)
+ {
+ generatedContracts = ContractFaker.Generate(count);
+ }
+
+ var courses = generatedContracts
+ .Select((contract, index) => contract with { Id = index + 1 })
+ .ToList();
+ logger.LogInformation("Course generation completed: {Count}", courses.Count);
+
+ return courses;
+ }
+
+ ///
+ public CourseContract GenerateById(int id)
+ {
+ if (id < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(id), "Id must be non-negative.");
+ }
+
+ CourseContract contract;
+ lock (FakerLock)
+ {
+ contract = ContractFaker
+ .UseSeed(id + 1)
+ .Generate() with { Id = id };
+ }
+
+ return contract;
+ }
+}
\ No newline at end of file
diff --git a/CourseGenerator.Api/Services/CourseContractsService.cs b/CourseGenerator.Api/Services/CourseContractsService.cs
new file mode 100644
index 0000000..4c84afc
--- /dev/null
+++ b/CourseGenerator.Api/Services/CourseContractsService.cs
@@ -0,0 +1,44 @@
+using CourseGenerator.Api.Interfaces;
+using CourseGenerator.Api.Models;
+
+namespace CourseGenerator.Api.Services;
+
+///
+/// Прикладной сервис генерации контрактов с использованием кэша.
+///
+public sealed class CourseContractsService(
+ ICourseContractGenerator generator,
+ ICourseContractCacheService cache,
+ ILogger logger) : ICourseContractsService
+{
+ ///
+ public async Task> GenerateAsync(int count, CancellationToken cancellationToken = default)
+ {
+ if (count is < 1 or > 100)
+ {
+ throw new ArgumentOutOfRangeException(nameof(count), "Count must be between 1 and 100.");
+ }
+
+ var startedAt = DateTimeOffset.UtcNow;
+ var cachedContracts = await cache.GetAsync(count, cancellationToken);
+
+ if (cachedContracts is not null)
+ {
+ logger.LogInformation(
+ "Request processed from cache: {Count}, DurationMs={DurationMs}",
+ count,
+ (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds);
+ return cachedContracts;
+ }
+
+ var contracts = generator.Generate(count);
+ await cache.SetAsync(count, contracts, cancellationToken);
+
+ logger.LogInformation(
+ "Request processed with generation: {Count}, DurationMs={DurationMs}",
+ count,
+ (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds);
+
+ return contracts;
+ }
+}
diff --git a/CourseGenerator.Api/appsettings.json b/CourseGenerator.Api/appsettings.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/CourseGenerator.Api/appsettings.json
@@ -0,0 +1 @@
+{}
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..05a30b8
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,54 @@
+SHELL := /bin/bash
+
+DOTNET8_PREFIX ?= /opt/homebrew/opt/dotnet@8
+DOTNET8_BIN := $(DOTNET8_PREFIX)/bin
+DOTNET8_ROOT := $(DOTNET8_PREFIX)/libexec
+
+APPHOST_PROJECT := CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj
+API_PROJECT := CourseGenerator.Api/CourseGenerator.Api.csproj
+REDIS_CONTAINER := lab1-redis
+REDIS_IMAGE := redis:7-alpine
+
+.PHONY: help redis-up redis-down restore build run-apphost run-api api-check
+
+help:
+ @echo "Targets:"
+ @echo " make redis-up - start Redis container"
+ @echo " make redis-down - stop Redis container"
+ @echo " make restore - restore workloads and NuGet packages"
+ @echo " make build - build solution in Debug"
+ @echo " make run-apphost - run Aspire AppHost"
+ @echo " make run-api - run API standalone on http://localhost:5117"
+ @echo " make api-check - call API endpoint (standalone mode)"
+
+redis-up:
+ @docker ps -a --format '{{.Names}}' | grep -qx '$(REDIS_CONTAINER)' \
+ && docker start $(REDIS_CONTAINER) \
+ || docker run -d --name $(REDIS_CONTAINER) -p 6379:6379 $(REDIS_IMAGE)
+
+redis-down:
+ @docker stop $(REDIS_CONTAINER)
+
+restore:
+ @export PATH="$(DOTNET8_BIN):$$PATH"; \
+ export DOTNET_ROOT="$(DOTNET8_ROOT)"; \
+ dotnet workload restore $(APPHOST_PROJECT); \
+ dotnet restore CloudDevelopment.sln
+
+build:
+ @export PATH="$(DOTNET8_BIN):$$PATH"; \
+ export DOTNET_ROOT="$(DOTNET8_ROOT)"; \
+ dotnet build CloudDevelopment.sln -c Debug
+
+run-apphost:
+ @export PATH="$(DOTNET8_BIN):$$PATH"; \
+ export DOTNET_ROOT="$(DOTNET8_ROOT)"; \
+ dotnet run --project $(APPHOST_PROJECT)
+
+run-api:
+ @export PATH="$(DOTNET8_BIN):$$PATH"; \
+ export DOTNET_ROOT="$(DOTNET8_ROOT)"; \
+ ASPNETCORE_ENVIRONMENT=Development dotnet run --project $(API_PROJECT) --urls http://localhost:5117
+
+api-check:
+ @curl "http://localhost:5117/api/courses/generate?count=2"
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..759e025
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "8.0.124",
+ "rollForward": "disable"
+ }
+}