From 7cec63d85001292696aebfa011a807c5f2050e5b Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 14:30:11 +0400 Subject: [PATCH 01/34] Ignores macOS .DS_Store files --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ce892922..5afb6ccc 100644 --- a/.gitignore +++ b/.gitignore @@ -416,3 +416,6 @@ FodyWeavers.xsd *.msix *.msm *.msp + +# User-specific +.DS_Store From 1ef5a126ae5d4086545133e5e42ee0c2c66e57a9 Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 14:41:53 +0400 Subject: [PATCH 02/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B8=D1=81=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D1=83=D1=87=D0=B5=D0=B1=D0=BD=D1=8B=D1=85=20=D0=BA?= =?UTF-8?q?=D1=83=D1=80=D1=81=D0=BE=D0=B2=20=D0=BD=D0=B0=20Bogus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CloudDevelopment.sln | 6 ++ .../CourseGenerator.Api.csproj | 13 ++++ CourseGenerator.Api/Models/CourseContract.cs | 13 ++++ CourseGenerator.Api/Program.cs | 33 ++++++++++ .../Services/CourseContractGenerator.cs | 60 +++++++++++++++++++ 5 files changed, 125 insertions(+) create mode 100644 CourseGenerator.Api/CourseGenerator.Api.csproj create mode 100644 CourseGenerator.Api/Models/CourseContract.cs create mode 100644 CourseGenerator.Api/Program.cs create mode 100644 CourseGenerator.Api/Services/CourseContractGenerator.cs diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..4178e100 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -5,6 +5,8 @@ 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ 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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CourseGenerator.Api/CourseGenerator.Api.csproj b/CourseGenerator.Api/CourseGenerator.Api.csproj new file mode 100644 index 00000000..a0858562 --- /dev/null +++ b/CourseGenerator.Api/CourseGenerator.Api.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + \ No newline at end of file diff --git a/CourseGenerator.Api/Models/CourseContract.cs b/CourseGenerator.Api/Models/CourseContract.cs new file mode 100644 index 00000000..5b817c48 --- /dev/null +++ b/CourseGenerator.Api/Models/CourseContract.cs @@ -0,0 +1,13 @@ +namespace CourseGenerator.Api.Models; + +public sealed record CourseContract( + int Id, + string CourseName, + string TeacherFullName, + DateOnly StartDate, + DateOnly EndDate, + int MaxStudents, + int CurrentStudents, + bool HasCertificate, + decimal Price, + int Rating); \ No newline at end of file diff --git a/CourseGenerator.Api/Program.cs b/CourseGenerator.Api/Program.cs new file mode 100644 index 00000000..a33c2586 --- /dev/null +++ b/CourseGenerator.Api/Program.cs @@ -0,0 +1,33 @@ +using CourseGenerator.Api.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapGet("/api/courses/generate", (int count, ICourseContractGenerator generator) => + { + if (count is < 1 or > 100) + { + return Results.ValidationProblem(new Dictionary + { + ["count"] = ["Count must be between 1 and 100."] + }); + } + + var contracts = generator.Generate(count); + return Results.Ok(contracts); + }) + .WithName("GenerateCourses") + .WithOpenApi(); + +app.Run(); \ No newline at end of file diff --git a/CourseGenerator.Api/Services/CourseContractGenerator.cs b/CourseGenerator.Api/Services/CourseContractGenerator.cs new file mode 100644 index 00000000..af09c3b0 --- /dev/null +++ b/CourseGenerator.Api/Services/CourseContractGenerator.cs @@ -0,0 +1,60 @@ +using Bogus; +using CourseGenerator.Api.Models; + +namespace CourseGenerator.Api.Services; + +public interface ICourseContractGenerator +{ + IReadOnlyList Generate(int count); +} + +public sealed class CourseContractGenerator : ICourseContractGenerator +{ + private static readonly string[] CourseDictionary = + [ + "Основы программирования на C#", + "Проектирование микросервисов", + "Базы данных и SQL", + "Инженерия требований", + "Тестирование программного обеспечения", + "Алгоритмы и структуры данных", + "Распределенные системы", + "Web-разработка на ASP.NET Core", + "DevOps и CI/CD", + "Машинное обучение в разработке ПО" + ]; + + public IReadOnlyList Generate(int count) + { + if (count <= 0) + { + throw new ArgumentOutOfRangeException(nameof(count), "Count must be greater than zero."); + } + + var idSeed = 1; + + var faker = new Faker("ru") + .CustomInstantiator(f => + { + var startDate = DateOnly.FromDateTime(f.Date.Soon(60)); + var endDate = startDate.AddDays(f.Random.Int(1, 180)); + var maxStudents = f.Random.Int(10, 200); + var currentStudents = f.Random.Int(0, maxStudents); + var price = decimal.Round(f.Random.Decimal(1000m, 120000m), 2, MidpointRounding.AwayFromZero); + + return new CourseContract( + Id: idSeed++, + CourseName: f.PickRandom(CourseDictionary), + TeacherFullName: $"{f.Name.LastName()} {f.Name.FirstName()} {f.Name.MiddleName()}", + StartDate: startDate, + EndDate: endDate, + MaxStudents: maxStudents, + CurrentStudents: currentStudents, + HasCertificate: f.Random.Bool(), + Price: price, + Rating: f.Random.Int(1, 5)); + }); + + return faker.Generate(count); + } +} \ No newline at end of file From bb86231928a4745475af9ea3baa4c157bcacfa35 Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 14:42:22 +0400 Subject: [PATCH 03/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BA=D1=8D=D1=88?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20Redis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CourseGenerator.Api.csproj | 1 + CourseGenerator.Api/Program.cs | 16 ++++++- .../Services/CourseContractCacheService.cs | 44 +++++++++++++++++++ CourseGenerator.Api/appsettings.json | 5 +++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 CourseGenerator.Api/Services/CourseContractCacheService.cs create mode 100644 CourseGenerator.Api/appsettings.json diff --git a/CourseGenerator.Api/CourseGenerator.Api.csproj b/CourseGenerator.Api/CourseGenerator.Api.csproj index a0858562..623c7c3e 100644 --- a/CourseGenerator.Api/CourseGenerator.Api.csproj +++ b/CourseGenerator.Api/CourseGenerator.Api.csproj @@ -8,6 +8,7 @@ + \ No newline at end of file diff --git a/CourseGenerator.Api/Program.cs b/CourseGenerator.Api/Program.cs index a33c2586..921f8a3a 100644 --- a/CourseGenerator.Api/Program.cs +++ b/CourseGenerator.Api/Program.cs @@ -5,6 +5,12 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddStackExchangeRedisCache(options => +{ + options.Configuration = builder.Configuration.GetConnectionString("redis") ?? "localhost:6379"; + options.InstanceName = "course-generator:"; +}); var app = builder.Build(); @@ -14,7 +20,7 @@ app.UseSwaggerUI(); } -app.MapGet("/api/courses/generate", (int count, ICourseContractGenerator generator) => +app.MapGet("/api/courses/generate", async (int count, ICourseContractGenerator generator, ICourseContractCacheService cache, CancellationToken cancellationToken) => { if (count is < 1 or > 100) { @@ -24,7 +30,15 @@ }); } + var cachedContracts = await cache.GetAsync(count, cancellationToken); + if (cachedContracts is not null) + { + return Results.Ok(cachedContracts); + } + var contracts = generator.Generate(count); + await cache.SetAsync(count, contracts, cancellationToken); + return Results.Ok(contracts); }) .WithName("GenerateCourses") diff --git a/CourseGenerator.Api/Services/CourseContractCacheService.cs b/CourseGenerator.Api/Services/CourseContractCacheService.cs new file mode 100644 index 00000000..278e7b2e --- /dev/null +++ b/CourseGenerator.Api/Services/CourseContractCacheService.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using CourseGenerator.Api.Models; +using Microsoft.Extensions.Caching.Distributed; + +namespace CourseGenerator.Api.Services; + +public interface ICourseContractCacheService +{ + Task?> GetAsync(int count, CancellationToken cancellationToken = default); + Task SetAsync(int count, IReadOnlyList contracts, CancellationToken cancellationToken = default); +} + +public sealed class CourseContractCacheService(IDistributedCache cache) : 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)) + { + return null; + } + + 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); + } + + private static string BuildKey(int count) => $"courses:count:{count}"; +} diff --git a/CourseGenerator.Api/appsettings.json b/CourseGenerator.Api/appsettings.json new file mode 100644 index 00000000..030769bb --- /dev/null +++ b/CourseGenerator.Api/appsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "redis": "localhost:6379" + } +} From e568e91047af829aec56ceca5ec965451863fb68 Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 14:42:52 +0400 Subject: [PATCH 04/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D1=83=D0=BA=D1=82=D1=83=D1=80=D0=BD=D0=BE=D0=B5=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B3?= =?UTF-8?q?=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=B0=20=D0=B8?= =?UTF-8?q?=20=D0=BA=D1=8D=D1=88=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CourseGenerator.Api/Program.cs | 11 +++++++++++ .../Services/CourseContractCacheService.cs | 6 +++++- .../Services/CourseContractGenerator.cs | 9 +++++++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CourseGenerator.Api/Program.cs b/CourseGenerator.Api/Program.cs index 921f8a3a..60caa432 100644 --- a/CourseGenerator.Api/Program.cs +++ b/CourseGenerator.Api/Program.cs @@ -22,6 +22,8 @@ app.MapGet("/api/courses/generate", async (int count, ICourseContractGenerator generator, ICourseContractCacheService cache, CancellationToken cancellationToken) => { + var startedAt = DateTimeOffset.UtcNow; + if (count is < 1 or > 100) { return Results.ValidationProblem(new Dictionary @@ -33,12 +35,21 @@ var cachedContracts = await cache.GetAsync(count, cancellationToken); if (cachedContracts is not null) { + app.Logger.LogInformation( + "Request processed from cache: {Count}, DurationMs={DurationMs}", + count, + (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds); return Results.Ok(cachedContracts); } var contracts = generator.Generate(count); await cache.SetAsync(count, contracts, cancellationToken); + app.Logger.LogInformation( + "Request processed with generation: {Count}, DurationMs={DurationMs}", + count, + (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds); + return Results.Ok(contracts); }) .WithName("GenerateCourses") diff --git a/CourseGenerator.Api/Services/CourseContractCacheService.cs b/CourseGenerator.Api/Services/CourseContractCacheService.cs index 278e7b2e..de611a2a 100644 --- a/CourseGenerator.Api/Services/CourseContractCacheService.cs +++ b/CourseGenerator.Api/Services/CourseContractCacheService.cs @@ -10,7 +10,7 @@ public interface ICourseContractCacheService Task SetAsync(int count, IReadOnlyList contracts, CancellationToken cancellationToken = default); } -public sealed class CourseContractCacheService(IDistributedCache cache) : ICourseContractCacheService +public sealed class CourseContractCacheService(IDistributedCache cache, ILogger logger) : ICourseContractCacheService { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); @@ -21,9 +21,12 @@ public sealed class CourseContractCacheService(IDistributedCache cache) : ICours 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); } @@ -38,6 +41,7 @@ public async Task SetAsync(int count, IReadOnlyList contracts, C }; 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 index af09c3b0..34696bf6 100644 --- a/CourseGenerator.Api/Services/CourseContractGenerator.cs +++ b/CourseGenerator.Api/Services/CourseContractGenerator.cs @@ -8,7 +8,7 @@ public interface ICourseContractGenerator IReadOnlyList Generate(int count); } -public sealed class CourseContractGenerator : ICourseContractGenerator +public sealed class CourseContractGenerator(ILogger logger) : ICourseContractGenerator { private static readonly string[] CourseDictionary = [ @@ -26,6 +26,8 @@ public sealed class CourseContractGenerator : ICourseContractGenerator 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."); @@ -55,6 +57,9 @@ public IReadOnlyList Generate(int count) Rating: f.Random.Int(1, 5)); }); - return faker.Generate(count); + var courses = faker.Generate(count); + logger.LogInformation("Course generation completed: {Count}", courses.Count); + + return courses; } } \ No newline at end of file From b0910cf9a49eed44d53261e1d8d58d3e5a123ed6 Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 14:43:48 +0400 Subject: [PATCH 05/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BD=D0=B0=20=D0=BE=D1=80=D0=BA?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20.NET=20Aspire=20=D0=B8=20Redis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CloudDevelopment.AppHost.csproj | 20 ++++++++++ CloudDevelopment.AppHost/Program.cs | 9 +++++ .../CloudDevelopment.ServiceDefaults.csproj | 15 +++++++ .../Extensions.cs | 39 +++++++++++++++++++ CloudDevelopment.sln | 12 ++++++ .../CourseGenerator.Api.csproj | 5 +++ CourseGenerator.Api/Program.cs | 3 ++ 7 files changed, 103 insertions(+) create mode 100644 CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj create mode 100644 CloudDevelopment.AppHost/Program.cs create mode 100644 CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj create mode 100644 CloudDevelopment.ServiceDefaults/Extensions.cs diff --git a/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj b/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj new file mode 100644 index 00000000..9f7453b1 --- /dev/null +++ b/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj @@ -0,0 +1,20 @@ + + + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + diff --git a/CloudDevelopment.AppHost/Program.cs b/CloudDevelopment.AppHost/Program.cs new file mode 100644 index 00000000..f62ac5bd --- /dev/null +++ b/CloudDevelopment.AppHost/Program.cs @@ -0,0 +1,9 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var redis = builder.AddRedis("redis"); + +builder.AddProject("course-generator-api") + .WithReference(redis) + .WaitFor(redis); + +builder.Build().Run(); diff --git a/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj b/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj new file mode 100644 index 00000000..4224aed4 --- /dev/null +++ b/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + true + + + + + + + + diff --git a/CloudDevelopment.ServiceDefaults/Extensions.cs b/CloudDevelopment.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..f62b8b91 --- /dev/null +++ b/CloudDevelopment.ServiceDefaults/Extensions.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Extensions.Hosting; + +public static class Extensions +{ + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapHealthChecks("/health"); + app.MapHealthChecks("/alive", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions + { + Predicate = healthCheck => healthCheck.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index 4178e100..3c2c273d 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -7,6 +7,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Wasm", "Client.Wasm\ 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 @@ -21,6 +25,14 @@ Global {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/CourseGenerator.Api.csproj b/CourseGenerator.Api/CourseGenerator.Api.csproj index 623c7c3e..fdf15e85 100644 --- a/CourseGenerator.Api/CourseGenerator.Api.csproj +++ b/CourseGenerator.Api/CourseGenerator.Api.csproj @@ -9,6 +9,11 @@ + + + + + \ No newline at end of file diff --git a/CourseGenerator.Api/Program.cs b/CourseGenerator.Api/Program.cs index 60caa432..a173611f 100644 --- a/CourseGenerator.Api/Program.cs +++ b/CourseGenerator.Api/Program.cs @@ -2,6 +2,7 @@ var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddSingleton(); @@ -20,6 +21,8 @@ app.UseSwaggerUI(); } +app.MapDefaultEndpoints(); + app.MapGet("/api/courses/generate", async (int count, ICourseContractGenerator generator, ICourseContractCacheService cache, CancellationToken cancellationToken) => { var startedAt = DateTimeOffset.UtcNow; From c280b1f1f48f036eafa9373b27b4514047e2452b Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 14:58:06 +0400 Subject: [PATCH 06/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D1=8B=20=D0=BA=20.NE?= =?UTF-8?q?T=208=20=D0=B8=20backend=20=D0=B7=D0=B0=D0=BF=D1=83=D1=89=D0=B5?= =?UTF-8?q?=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj | 3 +-- CloudDevelopment.AppHost/Program.cs | 5 ++--- .../CloudDevelopment.ServiceDefaults.csproj | 1 - CloudDevelopment.ServiceDefaults/Extensions.cs | 8 -------- CourseGenerator.Api/CourseGenerator.Api.csproj | 4 ---- CourseGenerator.Api/Program.cs | 6 +----- CourseGenerator.Api/Services/CourseContractGenerator.cs | 2 +- global.json | 6 ++++++ 8 files changed, 11 insertions(+), 24 deletions(-) create mode 100644 global.json diff --git a/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj b/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj index 9f7453b1..dfbc9d6e 100644 --- a/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj +++ b/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj @@ -1,7 +1,5 @@ - - Exe net8.0 @@ -11,6 +9,7 @@ + diff --git a/CloudDevelopment.AppHost/Program.cs b/CloudDevelopment.AppHost/Program.cs index f62ac5bd..84c5949e 100644 --- a/CloudDevelopment.AppHost/Program.cs +++ b/CloudDevelopment.AppHost/Program.cs @@ -2,8 +2,7 @@ var redis = builder.AddRedis("redis"); -builder.AddProject("course-generator-api") - .WithReference(redis) - .WaitFor(redis); +builder.AddProject("course-generator-api", "../CourseGenerator.Api/CourseGenerator.Api.csproj") + .WithReference(redis); builder.Build().Run(); diff --git a/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj b/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj index 4224aed4..ba489edc 100644 --- a/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj +++ b/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj @@ -9,7 +9,6 @@ - diff --git a/CloudDevelopment.ServiceDefaults/Extensions.cs b/CloudDevelopment.ServiceDefaults/Extensions.cs index f62b8b91..c328452b 100644 --- a/CloudDevelopment.ServiceDefaults/Extensions.cs +++ b/CloudDevelopment.ServiceDefaults/Extensions.cs @@ -12,14 +12,6 @@ public static TBuilder AddServiceDefaults(this TBuilder builder) where builder.Services.AddHealthChecks() .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - builder.Services.AddServiceDiscovery(); - - builder.Services.ConfigureHttpClientDefaults(http => - { - http.AddStandardResilienceHandler(); - http.AddServiceDiscovery(); - }); - return builder; } diff --git a/CourseGenerator.Api/CourseGenerator.Api.csproj b/CourseGenerator.Api/CourseGenerator.Api.csproj index fdf15e85..06af474a 100644 --- a/CourseGenerator.Api/CourseGenerator.Api.csproj +++ b/CourseGenerator.Api/CourseGenerator.Api.csproj @@ -12,8 +12,4 @@ - - - - \ No newline at end of file diff --git a/CourseGenerator.Api/Program.cs b/CourseGenerator.Api/Program.cs index a173611f..e2088f06 100644 --- a/CourseGenerator.Api/Program.cs +++ b/CourseGenerator.Api/Program.cs @@ -2,7 +2,6 @@ var builder = WebApplication.CreateBuilder(args); -builder.AddServiceDefaults(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddSingleton(); @@ -21,8 +20,6 @@ app.UseSwaggerUI(); } -app.MapDefaultEndpoints(); - app.MapGet("/api/courses/generate", async (int count, ICourseContractGenerator generator, ICourseContractCacheService cache, CancellationToken cancellationToken) => { var startedAt = DateTimeOffset.UtcNow; @@ -55,7 +52,6 @@ return Results.Ok(contracts); }) - .WithName("GenerateCourses") - .WithOpenApi(); + .WithName("GenerateCourses"); app.Run(); \ No newline at end of file diff --git a/CourseGenerator.Api/Services/CourseContractGenerator.cs b/CourseGenerator.Api/Services/CourseContractGenerator.cs index 34696bf6..7595f393 100644 --- a/CourseGenerator.Api/Services/CourseContractGenerator.cs +++ b/CourseGenerator.Api/Services/CourseContractGenerator.cs @@ -47,7 +47,7 @@ public IReadOnlyList Generate(int count) return new CourseContract( Id: idSeed++, CourseName: f.PickRandom(CourseDictionary), - TeacherFullName: $"{f.Name.LastName()} {f.Name.FirstName()} {f.Name.MiddleName()}", + TeacherFullName: $"{f.Name.LastName()} {f.Name.FirstName()} {f.Name.FirstName()}", StartDate: startDate, EndDate: endDate, MaxStudents: maxStudents, diff --git a/global.json b/global.json new file mode 100644 index 00000000..759e025f --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.124", + "rollForward": "disable" + } +} From b8261c4c7297636e3760fdf77e3b33aa3964b610 Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 15:08:30 +0400 Subject: [PATCH 07/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA=20=D0=BE=D1=80=D0=BA=D0=B5=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20Aspire=20AppHost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CloudDevelopment.AppHost.csproj | 1 + .../Properties/launchSettings.json | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 CloudDevelopment.AppHost/Properties/launchSettings.json diff --git a/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj b/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj index dfbc9d6e..7a2d1489 100644 --- a/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj +++ b/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj @@ -5,6 +5,7 @@ net8.0 enable enable + true diff --git a/CloudDevelopment.AppHost/Properties/launchSettings.json b/CloudDevelopment.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..38a3bbba --- /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" + } + } + } +} From 34c232fb0c85f9bd7f34c63ca9940b396e352535 Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 15:45:51 +0400 Subject: [PATCH 08/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B0=20=D0=B2=D0=BD=D0=B5=D1=88?= =?UTF-8?q?=D0=BD=D0=B8=D1=85=20HTTP-=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE=D0=B8?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2=20=D0=B4=D0=BB=D1=8F=20=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=B0=20=D0=BA=D1=83?= =?UTF-8?q?=D1=80=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CloudDevelopment.AppHost/Program.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CloudDevelopment.AppHost/Program.cs b/CloudDevelopment.AppHost/Program.cs index 84c5949e..ad34b3ef 100644 --- a/CloudDevelopment.AppHost/Program.cs +++ b/CloudDevelopment.AppHost/Program.cs @@ -3,6 +3,8 @@ var redis = builder.AddRedis("redis"); builder.AddProject("course-generator-api", "../CourseGenerator.Api/CourseGenerator.Api.csproj") - .WithReference(redis); + .WithHttpEndpoint(name: "http") + .WithReference(redis) + .WithExternalHttpEndpoints(); builder.Build().Run(); From 58a6d825e2c6d8981d6288b51c97fb0dfaaa0434 Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 16:14:25 +0400 Subject: [PATCH 09/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20Makefile=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=BC=20?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80?= =?UTF-8?q?=D0=BE=D0=BC=20Redis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..05a30b84 --- /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" From 946e793673f1d9dea8efc9faca77ceac869ca229 Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 16:17:11 +0400 Subject: [PATCH 10/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=82=D1=80=D0=BE=D0=BD=D0=B8=D0=BC=D0=B8=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B4=D0=BB=D1=8F=20=D0=B3=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D0=B8=D0=BC=D0=B5=D0=BD=20=D0=BF=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/CourseContractGenerator.cs | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/CourseGenerator.Api/Services/CourseContractGenerator.cs b/CourseGenerator.Api/Services/CourseContractGenerator.cs index 7595f393..27816ebd 100644 --- a/CourseGenerator.Api/Services/CourseContractGenerator.cs +++ b/CourseGenerator.Api/Services/CourseContractGenerator.cs @@ -24,6 +24,30 @@ public sealed class CourseContractGenerator(ILogger log "Машинное обучение в разработке ПО" ]; + private static readonly string[] PatronymicDictionary = + [ + "Иванович", + "Петрович", + "Сергеевич", + "Алексеевич", + "Дмитриевич", + "Андреевич", + "Игоревич", + "Олегович", + "Владимирович", + "Николаевич", + "Ивановна", + "Петровна", + "Сергеевна", + "Алексеевна", + "Дмитриевна", + "Андреевна", + "Игоревна", + "Олеговна", + "Владимировна", + "Николаевна" + ]; + public IReadOnlyList Generate(int count) { logger.LogInformation("Course generation started: {Count}", count); @@ -47,7 +71,7 @@ public IReadOnlyList Generate(int count) return new CourseContract( Id: idSeed++, CourseName: f.PickRandom(CourseDictionary), - TeacherFullName: $"{f.Name.LastName()} {f.Name.FirstName()} {f.Name.FirstName()}", + TeacherFullName: $"{f.Name.LastName()} {f.Name.FirstName()} {f.PickRandom(PatronymicDictionary)}", StartDate: startDate, EndDate: endDate, MaxStudents: maxStudents, From b74aa51741e79aef9ebf64aa939e846fb68299f7 Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 16:30:08 +0400 Subject: [PATCH 11/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=B2=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D1=82=D0=B5=20StudentCard=20=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BD=20BaseAddress=20?= =?UTF-8?q?=D0=B2=20appsettings.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/Components/StudentCard.razor | 8 ++++---- Client.Wasm/wwwroot/appsettings.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..13deb806 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 d1fe7ab3..53288073 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" + "BaseAddress": "http://localhost:5117" } From 5457b0f89b00d1ae1ff1b68dfa0ab84461334f85 Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 16:33:18 +0400 Subject: [PATCH 12/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D1=8C=20=D0=BF=D0=B0=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B0=20=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D1=85=20=D0=BF=D0=B0=D1=82=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=B4=D0=BB=D1=8F=20=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=B8=D0=BC=D0=B5=D0=BD=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=BF=D0=BE=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/CourseContractGenerator.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/CourseGenerator.Api/Services/CourseContractGenerator.cs b/CourseGenerator.Api/Services/CourseContractGenerator.cs index 27816ebd..e8f33046 100644 --- a/CourseGenerator.Api/Services/CourseContractGenerator.cs +++ b/CourseGenerator.Api/Services/CourseContractGenerator.cs @@ -1,4 +1,5 @@ using Bogus; +using Bogus.DataSets; using CourseGenerator.Api.Models; namespace CourseGenerator.Api.Services; @@ -24,7 +25,7 @@ public sealed class CourseContractGenerator(ILogger log "Машинное обучение в разработке ПО" ]; - private static readonly string[] PatronymicDictionary = + private static readonly string[] MalePatronymicDictionary = [ "Иванович", "Петрович", @@ -35,7 +36,11 @@ public sealed class CourseContractGenerator(ILogger log "Игоревич", "Олегович", "Владимирович", - "Николаевич", + "Николаевич" + ]; + + private static readonly string[] FemalePatronymicDictionary = + [ "Ивановна", "Петровна", "Сергеевна", @@ -67,11 +72,17 @@ public IReadOnlyList Generate(int count) var maxStudents = f.Random.Int(10, 200); var currentStudents = f.Random.Int(0, maxStudents); var price = decimal.Round(f.Random.Decimal(1000m, 120000m), 2, MidpointRounding.AwayFromZero); + 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 new CourseContract( Id: idSeed++, CourseName: f.PickRandom(CourseDictionary), - TeacherFullName: $"{f.Name.LastName()} {f.Name.FirstName()} {f.PickRandom(PatronymicDictionary)}", + TeacherFullName: $"{lastName} {firstName} {patronymic}", StartDate: startDate, EndDate: endDate, MaxStudents: maxStudents, From d5265028e743c59620b87cce9dcd3ec267f60933 Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 16:46:38 +0400 Subject: [PATCH 13/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20=D0=BE=D0=BA=D1=80=D1=83?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0=20course-generator-api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CloudDevelopment.AppHost/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/CloudDevelopment.AppHost/Program.cs b/CloudDevelopment.AppHost/Program.cs index ad34b3ef..bf592d78 100644 --- a/CloudDevelopment.AppHost/Program.cs +++ b/CloudDevelopment.AppHost/Program.cs @@ -4,6 +4,7 @@ builder.AddProject("course-generator-api", "../CourseGenerator.Api/CourseGenerator.Api.csproj") .WithHttpEndpoint(name: "http") + .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") .WithReference(redis) .WithExternalHttpEndpoints(); From 22e1d0d38d849b918a4491275eb4f31c1c4e2c6d Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 16:52:43 +0400 Subject: [PATCH 14/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D1=8B=20ServiceDefau?= =?UTF-8?q?lts=20=D0=B8=20health-=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE=D0=B8?= =?UTF-8?q?=D0=BD=D1=82=D1=8B=20=D0=B2=20CourseGenerator=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CourseGenerator.Api/CourseGenerator.Api.csproj | 4 ++++ CourseGenerator.Api/Program.cs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/CourseGenerator.Api/CourseGenerator.Api.csproj b/CourseGenerator.Api/CourseGenerator.Api.csproj index 06af474a..fdf15e85 100644 --- a/CourseGenerator.Api/CourseGenerator.Api.csproj +++ b/CourseGenerator.Api/CourseGenerator.Api.csproj @@ -12,4 +12,8 @@ + + + + \ No newline at end of file diff --git a/CourseGenerator.Api/Program.cs b/CourseGenerator.Api/Program.cs index e2088f06..b005cd73 100644 --- a/CourseGenerator.Api/Program.cs +++ b/CourseGenerator.Api/Program.cs @@ -2,6 +2,8 @@ var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); + builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddSingleton(); @@ -20,6 +22,8 @@ app.UseSwaggerUI(); } +app.MapDefaultEndpoints(); + app.MapGet("/api/courses/generate", async (int count, ICourseContractGenerator generator, ICourseContractCacheService cache, CancellationToken cancellationToken) => { var startedAt = DateTimeOffset.UtcNow; From 084a517503ea6ae6929ae38a13d29f1432897c1f Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 17:00:38 +0400 Subject: [PATCH 15/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D1=88=D0=B8=D1=80=D0=B5=D0=BD=D1=8B=20ServiceDefaults=20?= =?UTF-8?q?(telemetry,=20discovery,=20resilience)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CloudDevelopment.ServiceDefaults.csproj | 7 ++ .../Extensions.cs | 75 ++++++++++++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj b/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj index ba489edc..87b70e2a 100644 --- a/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj +++ b/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj @@ -9,6 +9,13 @@ + + + + + + + diff --git a/CloudDevelopment.ServiceDefaults/Extensions.cs b/CloudDevelopment.ServiceDefaults/Extensions.cs index c328452b..86ae232d 100644 --- a/CloudDevelopment.ServiceDefaults/Extensions.cs +++ b/CloudDevelopment.ServiceDefaults/Extensions.cs @@ -1,13 +1,84 @@ 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"]); @@ -19,8 +90,8 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) { if (app.Environment.IsDevelopment()) { - app.MapHealthChecks("/health"); - app.MapHealthChecks("/alive", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions + app.MapHealthChecks(HealthEndpointPath); + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions { Predicate = healthCheck => healthCheck.Tags.Contains("live") }); From e0020ea6703a218d991e5ec770af989e4bea4230 Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 17:03:19 +0400 Subject: [PATCH 16/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=20AppHost=20=D0=B4=D0=BE?= =?UTF-8?q?=20Aspire=209.5=20=D1=81=20WaitFor=20=D0=B8=20RedisInsight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj | 6 ++++-- CloudDevelopment.AppHost/Program.cs | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj b/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj index 7a2d1489..e4cd3ecd 100644 --- a/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj +++ b/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj @@ -1,5 +1,7 @@ + + Exe net8.0 @@ -9,8 +11,8 @@ - - + + diff --git a/CloudDevelopment.AppHost/Program.cs b/CloudDevelopment.AppHost/Program.cs index bf592d78..5392f635 100644 --- a/CloudDevelopment.AppHost/Program.cs +++ b/CloudDevelopment.AppHost/Program.cs @@ -1,11 +1,13 @@ var builder = DistributedApplication.CreateBuilder(args); -var redis = builder.AddRedis("redis"); +var redis = builder.AddRedis("redis") + .WithRedisInsight(containerName: "redis-insight"); builder.AddProject("course-generator-api", "../CourseGenerator.Api/CourseGenerator.Api.csproj") .WithHttpEndpoint(name: "http") .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") .WithReference(redis) + .WaitFor(redis) .WithExternalHttpEndpoints(); builder.Build().Run(); From 9e1a3962cacfb217e0a266f71e8763781e782fbb Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 17:04:02 +0400 Subject: [PATCH 17/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20Client.Wasm=20=D0=B2=20?= =?UTF-8?q?=D0=BE=D1=80=D0=BA=D0=B5=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8E=20AppHost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CloudDevelopment.AppHost/Program.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CloudDevelopment.AppHost/Program.cs b/CloudDevelopment.AppHost/Program.cs index 5392f635..9c3d0b3f 100644 --- a/CloudDevelopment.AppHost/Program.cs +++ b/CloudDevelopment.AppHost/Program.cs @@ -3,11 +3,16 @@ var redis = builder.AddRedis("redis") .WithRedisInsight(containerName: "redis-insight"); -builder.AddProject("course-generator-api", "../CourseGenerator.Api/CourseGenerator.Api.csproj") +var courseGeneratorApi = builder.AddProject("course-generator-api", "../CourseGenerator.Api/CourseGenerator.Api.csproj") .WithHttpEndpoint(name: "http") .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") .WithReference(redis) .WaitFor(redis) .WithExternalHttpEndpoints(); +builder.AddProject("client-wasm", "../Client.Wasm/Client.Wasm.csproj") + .WithReference(courseGeneratorApi) + .WaitFor(courseGeneratorApi) + .WithExternalHttpEndpoints(); + builder.Build().Run(); From 6df7844080685ea3d1a074050a19119ff8896d43 Mon Sep 17 00:00:00 2001 From: the80hz Date: Wed, 11 Mar 2026 17:11:59 +0400 Subject: [PATCH 18/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BB=D0=BB=D0=B5=D1=80,=20DTO=20=D0=B8=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=D1=8B=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B0=20=D0=BA=D1=83=D1=80=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/CourseContractsController.cs | 54 +++++++++++++++++++ .../CourseGenerator.Api.csproj | 2 + CourseGenerator.Api/Dto/CourseContractDto.cs | 16 ++++++ .../Dto/CourseGenerationQueryDto.cs | 12 +++++ .../Interfaces/ICourseContractCacheService.cs | 9 ++++ .../Interfaces/ICourseContractGenerator.cs | 8 +++ .../Interfaces/ICourseContractsService.cs | 8 +++ CourseGenerator.Api/Program.cs | 48 +++++------------ .../Services/CourseContractCacheService.cs | 7 +-- .../Services/CourseContractGenerator.cs | 6 +-- .../Services/CourseContractsService.cs | 40 ++++++++++++++ 11 files changed, 165 insertions(+), 45 deletions(-) create mode 100644 CourseGenerator.Api/Controllers/CourseContractsController.cs create mode 100644 CourseGenerator.Api/Dto/CourseContractDto.cs create mode 100644 CourseGenerator.Api/Dto/CourseGenerationQueryDto.cs create mode 100644 CourseGenerator.Api/Interfaces/ICourseContractCacheService.cs create mode 100644 CourseGenerator.Api/Interfaces/ICourseContractGenerator.cs create mode 100644 CourseGenerator.Api/Interfaces/ICourseContractsService.cs create mode 100644 CourseGenerator.Api/Services/CourseContractsService.cs diff --git a/CourseGenerator.Api/Controllers/CourseContractsController.cs b/CourseGenerator.Api/Controllers/CourseContractsController.cs new file mode 100644 index 00000000..d249599f --- /dev/null +++ b/CourseGenerator.Api/Controllers/CourseContractsController.cs @@ -0,0 +1,54 @@ +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) : ControllerBase +{ + /// + /// Генерирует список контрактов курсов с кэшированием результата в Redis. + /// + /// Параметры генерации. Используйте `count` от 1 до 100. + /// Токен отмены запроса. + /// Список сгенерированных контрактов курсов. + /// Контракты успешно получены. + /// Передан недопустимый параметр count. + [HttpGet("generate")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> GenerateAsync( + [FromQuery] CourseGenerationQueryDto query, + CancellationToken cancellationToken) + { + try + { + var contracts = await contractsService.GenerateAsync(query.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); + } + } +} diff --git a/CourseGenerator.Api/CourseGenerator.Api.csproj b/CourseGenerator.Api/CourseGenerator.Api.csproj index fdf15e85..a0348c92 100644 --- a/CourseGenerator.Api/CourseGenerator.Api.csproj +++ b/CourseGenerator.Api/CourseGenerator.Api.csproj @@ -4,6 +4,8 @@ net8.0 enable enable + true + $(NoWarn);1591 diff --git a/CourseGenerator.Api/Dto/CourseContractDto.cs b/CourseGenerator.Api/Dto/CourseContractDto.cs new file mode 100644 index 00000000..40ee1193 --- /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/Dto/CourseGenerationQueryDto.cs b/CourseGenerator.Api/Dto/CourseGenerationQueryDto.cs new file mode 100644 index 00000000..170384d5 --- /dev/null +++ b/CourseGenerator.Api/Dto/CourseGenerationQueryDto.cs @@ -0,0 +1,12 @@ +namespace CourseGenerator.Api.Dto; + +/// +/// Query-параметры генерации списка учебных контрактов. +/// +public sealed class CourseGenerationQueryDto +{ + /// + /// Количество контрактов для генерации. Допустимый диапазон: от 1 до 100. + /// + public int Count { get; set; } +} diff --git a/CourseGenerator.Api/Interfaces/ICourseContractCacheService.cs b/CourseGenerator.Api/Interfaces/ICourseContractCacheService.cs new file mode 100644 index 00000000..cac40278 --- /dev/null +++ b/CourseGenerator.Api/Interfaces/ICourseContractCacheService.cs @@ -0,0 +1,9 @@ +using CourseGenerator.Api.Models; + +namespace CourseGenerator.Api.Interfaces; + +public interface ICourseContractCacheService +{ + 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 00000000..8b948a45 --- /dev/null +++ b/CourseGenerator.Api/Interfaces/ICourseContractGenerator.cs @@ -0,0 +1,8 @@ +using CourseGenerator.Api.Models; + +namespace CourseGenerator.Api.Interfaces; + +public interface ICourseContractGenerator +{ + IReadOnlyList Generate(int count); +} diff --git a/CourseGenerator.Api/Interfaces/ICourseContractsService.cs b/CourseGenerator.Api/Interfaces/ICourseContractsService.cs new file mode 100644 index 00000000..71423ea0 --- /dev/null +++ b/CourseGenerator.Api/Interfaces/ICourseContractsService.cs @@ -0,0 +1,8 @@ +using CourseGenerator.Api.Models; + +namespace CourseGenerator.Api.Interfaces; + +public interface ICourseContractsService +{ + Task> GenerateAsync(int count, CancellationToken cancellationToken = default); +} diff --git a/CourseGenerator.Api/Program.cs b/CourseGenerator.Api/Program.cs index b005cd73..4dd6fb8c 100644 --- a/CourseGenerator.Api/Program.cs +++ b/CourseGenerator.Api/Program.cs @@ -1,13 +1,25 @@ +using System.Reflection; +using CourseGenerator.Api.Interfaces; using CourseGenerator.Api.Services; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); +builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +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.Services.AddStackExchangeRedisCache(options => { options.Configuration = builder.Configuration.GetConnectionString("redis") ?? "localhost:6379"; @@ -24,38 +36,6 @@ app.MapDefaultEndpoints(); -app.MapGet("/api/courses/generate", async (int count, ICourseContractGenerator generator, ICourseContractCacheService cache, CancellationToken cancellationToken) => - { - var startedAt = DateTimeOffset.UtcNow; - - if (count is < 1 or > 100) - { - return Results.ValidationProblem(new Dictionary - { - ["count"] = ["Count must be between 1 and 100."] - }); - } - - var cachedContracts = await cache.GetAsync(count, cancellationToken); - if (cachedContracts is not null) - { - app.Logger.LogInformation( - "Request processed from cache: {Count}, DurationMs={DurationMs}", - count, - (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds); - return Results.Ok(cachedContracts); - } - - var contracts = generator.Generate(count); - await cache.SetAsync(count, contracts, cancellationToken); - - app.Logger.LogInformation( - "Request processed with generation: {Count}, DurationMs={DurationMs}", - count, - (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds); - - return Results.Ok(contracts); - }) - .WithName("GenerateCourses"); +app.MapControllers(); app.Run(); \ No newline at end of file diff --git a/CourseGenerator.Api/Services/CourseContractCacheService.cs b/CourseGenerator.Api/Services/CourseContractCacheService.cs index de611a2a..e8c742c9 100644 --- a/CourseGenerator.Api/Services/CourseContractCacheService.cs +++ b/CourseGenerator.Api/Services/CourseContractCacheService.cs @@ -1,15 +1,10 @@ using System.Text.Json; +using CourseGenerator.Api.Interfaces; using CourseGenerator.Api.Models; using Microsoft.Extensions.Caching.Distributed; namespace CourseGenerator.Api.Services; -public interface ICourseContractCacheService -{ - Task?> GetAsync(int count, CancellationToken cancellationToken = default); - Task SetAsync(int count, IReadOnlyList contracts, CancellationToken cancellationToken = default); -} - public sealed class CourseContractCacheService(IDistributedCache cache, ILogger logger) : ICourseContractCacheService { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); diff --git a/CourseGenerator.Api/Services/CourseContractGenerator.cs b/CourseGenerator.Api/Services/CourseContractGenerator.cs index e8f33046..ddbf969e 100644 --- a/CourseGenerator.Api/Services/CourseContractGenerator.cs +++ b/CourseGenerator.Api/Services/CourseContractGenerator.cs @@ -1,14 +1,10 @@ using Bogus; using Bogus.DataSets; +using CourseGenerator.Api.Interfaces; using CourseGenerator.Api.Models; namespace CourseGenerator.Api.Services; -public interface ICourseContractGenerator -{ - IReadOnlyList Generate(int count); -} - public sealed class CourseContractGenerator(ILogger logger) : ICourseContractGenerator { private static readonly string[] CourseDictionary = diff --git a/CourseGenerator.Api/Services/CourseContractsService.cs b/CourseGenerator.Api/Services/CourseContractsService.cs new file mode 100644 index 00000000..e3df55e9 --- /dev/null +++ b/CourseGenerator.Api/Services/CourseContractsService.cs @@ -0,0 +1,40 @@ +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; + } +} From 80b42cc64ee6a6a8a49ee8ed40ac5c8d7d120ae7 Mon Sep 17 00:00:00 2001 From: the80hz Date: Thu, 12 Mar 2026 14:31:15 +0400 Subject: [PATCH 19/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=81=D1=81=D1=8B?= =?UTF-8?q?=D0=BB=D0=BA=D0=B8=20=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D1=8B=20Client.Wasm=20=D0=B8=20CourseGenerator.Api?= =?UTF-8?q?=20=D0=B2=20AppHost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj | 1 + CloudDevelopment.AppHost/Program.cs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj b/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj index e4cd3ecd..6edd01b8 100644 --- a/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj +++ b/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj @@ -16,6 +16,7 @@ + diff --git a/CloudDevelopment.AppHost/Program.cs b/CloudDevelopment.AppHost/Program.cs index 9c3d0b3f..3d9740da 100644 --- a/CloudDevelopment.AppHost/Program.cs +++ b/CloudDevelopment.AppHost/Program.cs @@ -3,14 +3,14 @@ var redis = builder.AddRedis("redis") .WithRedisInsight(containerName: "redis-insight"); -var courseGeneratorApi = builder.AddProject("course-generator-api", "../CourseGenerator.Api/CourseGenerator.Api.csproj") +var courseGeneratorApi = builder.AddProject("course-generator-api") .WithHttpEndpoint(name: "http") .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") .WithReference(redis) .WaitFor(redis) .WithExternalHttpEndpoints(); -builder.AddProject("client-wasm", "../Client.Wasm/Client.Wasm.csproj") +builder.AddProject("client-wasm") .WithReference(courseGeneratorApi) .WaitFor(courseGeneratorApi) .WithExternalHttpEndpoints(); From 427648396df8f1af8509beabaa29042096c94918 Mon Sep 17 00:00:00 2001 From: the80hz Date: Thu, 12 Mar 2026 14:35:54 +0400 Subject: [PATCH 20/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D1=83=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=20=D0=B1=D0=B5=D1=81=D0=BF=D0=BE=D0=BB=D0=B5?= =?UTF-8?q?=D0=B7=D0=BD=D1=8B=D0=B9=20=D0=B4=D0=BB=D1=8F=20=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=83=D0=B7=D0=B5=D1=80=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0=20=D0=B2=D1=8B=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CloudDevelopment.AppHost/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/CloudDevelopment.AppHost/Program.cs b/CloudDevelopment.AppHost/Program.cs index 3d9740da..45399581 100644 --- a/CloudDevelopment.AppHost/Program.cs +++ b/CloudDevelopment.AppHost/Program.cs @@ -11,7 +11,6 @@ .WithExternalHttpEndpoints(); builder.AddProject("client-wasm") - .WithReference(courseGeneratorApi) .WaitFor(courseGeneratorApi) .WithExternalHttpEndpoints(); From f88c6d89f37fe73a599bc5a010809b3ed907d982 Mon Sep 17 00:00:00 2001 From: the80hz Date: Thu, 12 Mar 2026 14:38:54 +0400 Subject: [PATCH 21/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D1=83=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=20=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2=20WithHttpE?= =?UTF-8?q?ndpoint=20=D0=B4=D0=BB=D1=8F=20courseGeneratorApi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CloudDevelopment.AppHost/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/CloudDevelopment.AppHost/Program.cs b/CloudDevelopment.AppHost/Program.cs index 45399581..2c3e4c42 100644 --- a/CloudDevelopment.AppHost/Program.cs +++ b/CloudDevelopment.AppHost/Program.cs @@ -4,7 +4,6 @@ .WithRedisInsight(containerName: "redis-insight"); var courseGeneratorApi = builder.AddProject("course-generator-api") - .WithHttpEndpoint(name: "http") .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") .WithReference(redis) .WaitFor(redis) From 9d8256af38cfec2eba8c00c162d645cf4f5aeeff Mon Sep 17 00:00:00 2001 From: the80hz Date: Thu, 12 Mar 2026 14:47:53 +0400 Subject: [PATCH 22/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B11:=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB=20=D1=81=D0=B0=D0=BC=D0=BC=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=20=D0=B8=D0=B7=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20pr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Interfaces/ICourseContractCacheService.cs | 16 ++++++++++++++++ .../Interfaces/ICourseContractGenerator.cs | 8 ++++++++ CourseGenerator.Api/Models/CourseContract.cs | 13 +++++++++++++ .../Services/CourseContractCacheService.cs | 5 +++++ 4 files changed, 42 insertions(+) diff --git a/CourseGenerator.Api/Interfaces/ICourseContractCacheService.cs b/CourseGenerator.Api/Interfaces/ICourseContractCacheService.cs index cac40278..cc53907d 100644 --- a/CourseGenerator.Api/Interfaces/ICourseContractCacheService.cs +++ b/CourseGenerator.Api/Interfaces/ICourseContractCacheService.cs @@ -2,8 +2,24 @@ 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 index 8b948a45..65b7c385 100644 --- a/CourseGenerator.Api/Interfaces/ICourseContractGenerator.cs +++ b/CourseGenerator.Api/Interfaces/ICourseContractGenerator.cs @@ -2,7 +2,15 @@ namespace CourseGenerator.Api.Interfaces; +/// +/// Контракт генератора учебных контрактов. +/// public interface ICourseContractGenerator { + /// + /// Генерирует указанное количество учебных контрактов. + /// + /// Количество элементов для генерации. + /// Список сгенерированных контрактов. IReadOnlyList Generate(int count); } diff --git a/CourseGenerator.Api/Models/CourseContract.cs b/CourseGenerator.Api/Models/CourseContract.cs index 5b817c48..83e35e3a 100644 --- a/CourseGenerator.Api/Models/CourseContract.cs +++ b/CourseGenerator.Api/Models/CourseContract.cs @@ -1,5 +1,18 @@ namespace CourseGenerator.Api.Models; +/// +/// Контракт на проведение учебного курса. +/// +/// Идентификатор контракта. +/// Название курса. +/// ФИО преподавателя. +/// Дата начала курса. +/// Дата окончания курса. +/// Максимальное число студентов. +/// Текущее число студентов. +/// Признак выдачи сертификата по итогам курса. +/// Стоимость курса. +/// Рейтинг курса. public sealed record CourseContract( int Id, string CourseName, diff --git a/CourseGenerator.Api/Services/CourseContractCacheService.cs b/CourseGenerator.Api/Services/CourseContractCacheService.cs index e8c742c9..7ed129bf 100644 --- a/CourseGenerator.Api/Services/CourseContractCacheService.cs +++ b/CourseGenerator.Api/Services/CourseContractCacheService.cs @@ -5,10 +5,14 @@ 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); @@ -25,6 +29,7 @@ public sealed class CourseContractCacheService(IDistributedCache cache, ILogger< return JsonSerializer.Deserialize>(cachedPayload, SerializerOptions); } + /// public async Task SetAsync(int count, IReadOnlyList contracts, CancellationToken cancellationToken = default) { var key = BuildKey(count); From 1f9789b27124d60c7d83bb03e357e44f9af30788 Mon Sep 17 00:00:00 2001 From: the80hz Date: Thu, 12 Mar 2026 14:48:15 +0400 Subject: [PATCH 23/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=81=D0=B0=D0=BC=D0=BC=D0=B0=D1=80=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Interfaces/ICourseContractsService.cs | 9 +++++++++ CourseGenerator.Api/Services/CourseContractGenerator.cs | 4 ++++ CourseGenerator.Api/Services/CourseContractsService.cs | 4 ++++ 3 files changed, 17 insertions(+) diff --git a/CourseGenerator.Api/Interfaces/ICourseContractsService.cs b/CourseGenerator.Api/Interfaces/ICourseContractsService.cs index 71423ea0..716b0f1d 100644 --- a/CourseGenerator.Api/Interfaces/ICourseContractsService.cs +++ b/CourseGenerator.Api/Interfaces/ICourseContractsService.cs @@ -2,7 +2,16 @@ namespace CourseGenerator.Api.Interfaces; +/// +/// Контракт прикладного сервиса генерации контрактов с учетом кэша. +/// public interface ICourseContractsService { + /// + /// Возвращает список контрактов из кэша или генерирует новые. + /// + /// Количество требуемых контрактов. + /// Токен отмены операции. + /// Список контрактов. Task> GenerateAsync(int count, CancellationToken cancellationToken = default); } diff --git a/CourseGenerator.Api/Services/CourseContractGenerator.cs b/CourseGenerator.Api/Services/CourseContractGenerator.cs index ddbf969e..97c09cd6 100644 --- a/CourseGenerator.Api/Services/CourseContractGenerator.cs +++ b/CourseGenerator.Api/Services/CourseContractGenerator.cs @@ -5,6 +5,9 @@ namespace CourseGenerator.Api.Services; +/// +/// Генератор тестовых учебных контрактов на основе Bogus. +/// public sealed class CourseContractGenerator(ILogger logger) : ICourseContractGenerator { private static readonly string[] CourseDictionary = @@ -49,6 +52,7 @@ public sealed class CourseContractGenerator(ILogger log "Николаевна" ]; + /// public IReadOnlyList Generate(int count) { logger.LogInformation("Course generation started: {Count}", count); diff --git a/CourseGenerator.Api/Services/CourseContractsService.cs b/CourseGenerator.Api/Services/CourseContractsService.cs index e3df55e9..4c84afc1 100644 --- a/CourseGenerator.Api/Services/CourseContractsService.cs +++ b/CourseGenerator.Api/Services/CourseContractsService.cs @@ -3,11 +3,15 @@ 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) From dab62d2473a2c58a3b84d992f9dabf4c46fcc7cb Mon Sep 17 00:00:00 2001 From: the80hz Date: Thu, 12 Mar 2026 14:50:09 +0400 Subject: [PATCH 24/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=B0=D1=80=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D1=80=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D1=91?= =?UTF-8?q?=D0=BD=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=20CourseGenerationQuery?= =?UTF-8?q?Dto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/CourseContractsController.cs | 6 +++--- CourseGenerator.Api/Dto/CourseGenerationQueryDto.cs | 12 ------------ 2 files changed, 3 insertions(+), 15 deletions(-) delete mode 100644 CourseGenerator.Api/Dto/CourseGenerationQueryDto.cs diff --git a/CourseGenerator.Api/Controllers/CourseContractsController.cs b/CourseGenerator.Api/Controllers/CourseContractsController.cs index d249599f..fa022bb2 100644 --- a/CourseGenerator.Api/Controllers/CourseContractsController.cs +++ b/CourseGenerator.Api/Controllers/CourseContractsController.cs @@ -11,7 +11,7 @@ public sealed class CourseContractsController(ICourseContractsService contractsS /// /// Генерирует список контрактов курсов с кэшированием результата в Redis. /// - /// Параметры генерации. Используйте `count` от 1 до 100. + /// Количество контрактов для генерации (от 1 до 100). /// Токен отмены запроса. /// Список сгенерированных контрактов курсов. /// Контракты успешно получены. @@ -20,12 +20,12 @@ public sealed class CourseContractsController(ICourseContractsService contractsS [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task>> GenerateAsync( - [FromQuery] CourseGenerationQueryDto query, + [FromQuery] int count, CancellationToken cancellationToken) { try { - var contracts = await contractsService.GenerateAsync(query.Count, cancellationToken); + var contracts = await contractsService.GenerateAsync(count, cancellationToken); var dto = contracts .Select(contract => new CourseContractDto( contract.Id, diff --git a/CourseGenerator.Api/Dto/CourseGenerationQueryDto.cs b/CourseGenerator.Api/Dto/CourseGenerationQueryDto.cs deleted file mode 100644 index 170384d5..00000000 --- a/CourseGenerator.Api/Dto/CourseGenerationQueryDto.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CourseGenerator.Api.Dto; - -/// -/// Query-параметры генерации списка учебных контрактов. -/// -public sealed class CourseGenerationQueryDto -{ - /// - /// Количество контрактов для генерации. Допустимый диапазон: от 1 до 100. - /// - public int Count { get; set; } -} From e82b1a30a50dddc2258d528279d287cdad3fc563 Mon Sep 17 00:00:00 2001 From: the80hz Date: Thu, 12 Mar 2026 14:51:10 +0400 Subject: [PATCH 25/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BE=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80=20count=20?= =?UTF-8?q?=D0=B2=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=D0=B5=20GenerateAsync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CourseGenerator.Api/Controllers/CourseContractsController.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CourseGenerator.Api/Controllers/CourseContractsController.cs b/CourseGenerator.Api/Controllers/CourseContractsController.cs index fa022bb2..7c29ec19 100644 --- a/CourseGenerator.Api/Controllers/CourseContractsController.cs +++ b/CourseGenerator.Api/Controllers/CourseContractsController.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using CourseGenerator.Api.Dto; using CourseGenerator.Api.Interfaces; using Microsoft.AspNetCore.Mvc; @@ -20,7 +21,7 @@ public sealed class CourseContractsController(ICourseContractsService contractsS [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task>> GenerateAsync( - [FromQuery] int count, + [FromQuery, Range(1, 100)] int count, CancellationToken cancellationToken) { try From 0339ec7e7c24aa7e0d79b64471b7f9dcd1ef8fe7 Mon Sep 17 00:00:00 2001 From: the80hz Date: Thu, 12 Mar 2026 14:53:36 +0400 Subject: [PATCH 26/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20=D0=B3=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D1=8E=20=D0=BF=D0=BE=D0=B4=20Rul?= =?UTF-8?q?eFor()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CourseGenerator.Api/Models/CourseContract.cs | 73 +++++++++++++------ .../Services/CourseContractGenerator.cs | 30 +++----- 2 files changed, 64 insertions(+), 39 deletions(-) diff --git a/CourseGenerator.Api/Models/CourseContract.cs b/CourseGenerator.Api/Models/CourseContract.cs index 83e35e3a..b63500cf 100644 --- a/CourseGenerator.Api/Models/CourseContract.cs +++ b/CourseGenerator.Api/Models/CourseContract.cs @@ -3,24 +3,55 @@ namespace CourseGenerator.Api.Models; /// /// Контракт на проведение учебного курса. /// -/// Идентификатор контракта. -/// Название курса. -/// ФИО преподавателя. -/// Дата начала курса. -/// Дата окончания курса. -/// Максимальное число студентов. -/// Текущее число студентов. -/// Признак выдачи сертификата по итогам курса. -/// Стоимость курса. -/// Рейтинг курса. -public sealed record CourseContract( - int Id, - string CourseName, - string TeacherFullName, - DateOnly StartDate, - DateOnly EndDate, - int MaxStudents, - int CurrentStudents, - bool HasCertificate, - decimal Price, - int Rating); \ No newline at end of file +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/Services/CourseContractGenerator.cs b/CourseGenerator.Api/Services/CourseContractGenerator.cs index 97c09cd6..e9dabbe3 100644 --- a/CourseGenerator.Api/Services/CourseContractGenerator.cs +++ b/CourseGenerator.Api/Services/CourseContractGenerator.cs @@ -65,13 +65,10 @@ public IReadOnlyList Generate(int count) var idSeed = 1; var faker = new Faker("ru") - .CustomInstantiator(f => + .RuleFor(contract => contract.Id, _ => idSeed++) + .RuleFor(contract => contract.CourseName, f => f.PickRandom(CourseDictionary)) + .RuleFor(contract => contract.TeacherFullName, f => { - var startDate = DateOnly.FromDateTime(f.Date.Soon(60)); - var endDate = startDate.AddDays(f.Random.Int(1, 180)); - var maxStudents = f.Random.Int(10, 200); - var currentStudents = f.Random.Int(0, maxStudents); - var price = decimal.Round(f.Random.Decimal(1000m, 120000m), 2, MidpointRounding.AwayFromZero); var gender = f.PickRandom(Name.Gender.Male, Name.Gender.Female); var firstName = f.Name.FirstName(gender); var lastName = f.Name.LastName(gender); @@ -79,18 +76,15 @@ public IReadOnlyList Generate(int count) ? f.PickRandom(MalePatronymicDictionary) : f.PickRandom(FemalePatronymicDictionary); - return new CourseContract( - Id: idSeed++, - CourseName: f.PickRandom(CourseDictionary), - TeacherFullName: $"{lastName} {firstName} {patronymic}", - StartDate: startDate, - EndDate: endDate, - MaxStudents: maxStudents, - CurrentStudents: currentStudents, - HasCertificate: f.Random.Bool(), - Price: price, - Rating: f.Random.Int(1, 5)); - }); + 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)); var courses = faker.Generate(count); logger.LogInformation("Course generation completed: {Count}", courses.Count); From ffda91f8d6172d78f26a184dc6c7cc66fa522ef1 Mon Sep 17 00:00:00 2001 From: the80hz Date: Thu, 12 Mar 2026 14:56:34 +0400 Subject: [PATCH 27/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D1=91=D0=BD=20=D1=81=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BE=D0=B1=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BA=20Redis,=20=D0=B7=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=91=D0=BD=20=D0=BD=D0=B0=20AddRedisDistributedCache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CourseGenerator.Api/CourseGenerator.Api.csproj | 2 +- CourseGenerator.Api/Program.cs | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/CourseGenerator.Api/CourseGenerator.Api.csproj b/CourseGenerator.Api/CourseGenerator.Api.csproj index a0348c92..d5e8615a 100644 --- a/CourseGenerator.Api/CourseGenerator.Api.csproj +++ b/CourseGenerator.Api/CourseGenerator.Api.csproj @@ -9,8 +9,8 @@ + - diff --git a/CourseGenerator.Api/Program.cs b/CourseGenerator.Api/Program.cs index 4dd6fb8c..51f581a5 100644 --- a/CourseGenerator.Api/Program.cs +++ b/CourseGenerator.Api/Program.cs @@ -20,11 +20,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddStackExchangeRedisCache(options => -{ - options.Configuration = builder.Configuration.GetConnectionString("redis") ?? "localhost:6379"; - options.InstanceName = "course-generator:"; -}); +builder.AddRedisDistributedCache(connectionName: "redis"); var app = builder.Build(); From 495593cf25d8e0e0202959f2f67b5d042047dc19 Mon Sep 17 00:00:00 2001 From: the80hz Date: Thu, 12 Mar 2026 14:59:01 +0400 Subject: [PATCH 28/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D1=84=D0=B5?= =?UTF-8?q?=D0=B9=D0=BA=D0=B5=D1=80=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B2=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=BB=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/CourseContractGenerator.cs | 56 +++++++++++-------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/CourseGenerator.Api/Services/CourseContractGenerator.cs b/CourseGenerator.Api/Services/CourseContractGenerator.cs index e9dabbe3..d30b3853 100644 --- a/CourseGenerator.Api/Services/CourseContractGenerator.cs +++ b/CourseGenerator.Api/Services/CourseContractGenerator.cs @@ -10,6 +10,8 @@ namespace CourseGenerator.Api.Services; /// public sealed class CourseContractGenerator(ILogger logger) : ICourseContractGenerator { + private static readonly object FakerLock = new(); + private static readonly string[] CourseDictionary = [ "Основы программирования на C#", @@ -52,6 +54,28 @@ public sealed class CourseContractGenerator(ILogger log "Николаевна" ]; + 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) { @@ -62,31 +86,15 @@ public IReadOnlyList Generate(int count) throw new ArgumentOutOfRangeException(nameof(count), "Count must be greater than zero."); } - var idSeed = 1; - - var faker = new Faker("ru") - .RuleFor(contract => contract.Id, _ => idSeed++) - .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)); + List generatedContracts; + lock (FakerLock) + { + generatedContracts = ContractFaker.Generate(count); + } - var courses = faker.Generate(count); + var courses = generatedContracts + .Select((contract, index) => contract with { Id = index + 1 }) + .ToList(); logger.LogInformation("Course generation completed: {Count}", courses.Count); return courses; From 192f34b80bc4b18e87ab8fa52066d36f55a2193e Mon Sep 17 00:00:00 2001 From: the80hz Date: Thu, 12 Mar 2026 15:00:27 +0400 Subject: [PATCH 29/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D1=83=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=20Redis=20=D0=B8=D0=B7?= =?UTF-8?q?=20appsettings.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CourseGenerator.Api/appsettings.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/CourseGenerator.Api/appsettings.json b/CourseGenerator.Api/appsettings.json index 030769bb..0967ef42 100644 --- a/CourseGenerator.Api/appsettings.json +++ b/CourseGenerator.Api/appsettings.json @@ -1,5 +1 @@ -{ - "ConnectionStrings": { - "redis": "localhost:6379" - } -} +{} From c5b3106c91fedf0d46124576bd9192580e02d350 Mon Sep 17 00:00:00 2001 From: the80hz Date: Thu, 12 Mar 2026 15:10:37 +0400 Subject: [PATCH 30/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B11:=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B8=D0=B4=D0=B5=D0=BD=D1=82=D0=B8=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/wwwroot/appsettings.json | 2 +- .../Controllers/CourseContractsController.cs | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index 53288073..20b85a3f 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "http://localhost:5117" + "BaseAddress": "http://localhost:5117/api/courses/by-id" } diff --git a/CourseGenerator.Api/Controllers/CourseContractsController.cs b/CourseGenerator.Api/Controllers/CourseContractsController.cs index 7c29ec19..f8ffba27 100644 --- a/CourseGenerator.Api/Controllers/CourseContractsController.cs +++ b/CourseGenerator.Api/Controllers/CourseContractsController.cs @@ -52,4 +52,35 @@ public async Task>> GenerateAsync( return BadRequest(problem); } } + + /// + /// Возвращает один сгенерированный контракт по идентификатору для совместимости с клиентом. + /// + /// Идентификатор объекта. + /// Токен отмены запроса. + /// Сгенерированный контракт. + /// Контракт успешно получен. + /// Передан недопустимый параметр id. + [HttpGet("by-id")] + [ProducesResponseType(typeof(CourseContractDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> GetByIdAsync( + [FromQuery, Range(1, int.MaxValue)] int id, + CancellationToken cancellationToken) + { + var contracts = await contractsService.GenerateAsync(1, cancellationToken); + var contract = contracts[0] with { Id = 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)); + } } From 14b1a6d782e2ebc42e292ac4bb189980b7ad3b3a Mon Sep 17 00:00:00 2001 From: the80hz Date: Thu, 12 Mar 2026 15:18:35 +0400 Subject: [PATCH 31/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D1=83=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D0=B0=D0=BD=20=D0=BF=D0=BE=D1=80=D1=82=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B0=D0=BF=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CloudDevelopment.AppHost/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CloudDevelopment.AppHost/Program.cs b/CloudDevelopment.AppHost/Program.cs index 2c3e4c42..e6b11e50 100644 --- a/CloudDevelopment.AppHost/Program.cs +++ b/CloudDevelopment.AppHost/Program.cs @@ -7,7 +7,7 @@ .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") .WithReference(redis) .WaitFor(redis) - .WithExternalHttpEndpoints(); + .WithHttpEndpoint(name: "api", port: 5117); builder.AddProject("client-wasm") .WaitFor(courseGeneratorApi) From 67bf21919439c124dc5bbba3cd2126b7efca8c2c Mon Sep 17 00:00:00 2001 From: the80hz Date: Thu, 12 Mar 2026 15:42:10 +0400 Subject: [PATCH 32/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=201:=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B3=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=D1=82=D0=BE=D1=80=20=D0=BE=D0=B1=D1=8A=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/CourseContractsController.cs | 14 ++++++++------ .../Interfaces/ICourseContractGenerator.cs | 7 +++++++ CourseGenerator.Api/Program.cs | 13 +++++++++++++ .../Services/CourseContractGenerator.cs | 19 +++++++++++++++++++ 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/CourseGenerator.Api/Controllers/CourseContractsController.cs b/CourseGenerator.Api/Controllers/CourseContractsController.cs index f8ffba27..d34f04b4 100644 --- a/CourseGenerator.Api/Controllers/CourseContractsController.cs +++ b/CourseGenerator.Api/Controllers/CourseContractsController.cs @@ -7,7 +7,9 @@ namespace CourseGenerator.Api.Controllers; [ApiController] [Route("api/courses")] -public sealed class CourseContractsController(ICourseContractsService contractsService) : ControllerBase +public sealed class CourseContractsController( + ICourseContractsService contractsService, + ICourseContractGenerator contractGenerator) : ControllerBase { /// /// Генерирует список контрактов курсов с кэшированием результата в Redis. @@ -56,7 +58,7 @@ public async Task>> GenerateAsync( /// /// Возвращает один сгенерированный контракт по идентификатору для совместимости с клиентом. /// - /// Идентификатор объекта. + /// Неотрицательный идентификатор объекта. /// Токен отмены запроса. /// Сгенерированный контракт. /// Контракт успешно получен. @@ -64,12 +66,12 @@ public async Task>> GenerateAsync( [HttpGet("by-id")] [ProducesResponseType(typeof(CourseContractDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task> GetByIdAsync( - [FromQuery, Range(1, int.MaxValue)] int id, + public ActionResult GetByIdAsync( + [FromQuery, Range(0, int.MaxValue)] int id, CancellationToken cancellationToken) { - var contracts = await contractsService.GenerateAsync(1, cancellationToken); - var contract = contracts[0] with { Id = id }; + cancellationToken.ThrowIfCancellationRequested(); + var contract = contractGenerator.GenerateById(id); return Ok(new CourseContractDto( contract.Id, diff --git a/CourseGenerator.Api/Interfaces/ICourseContractGenerator.cs b/CourseGenerator.Api/Interfaces/ICourseContractGenerator.cs index 65b7c385..050869af 100644 --- a/CourseGenerator.Api/Interfaces/ICourseContractGenerator.cs +++ b/CourseGenerator.Api/Interfaces/ICourseContractGenerator.cs @@ -13,4 +13,11 @@ public interface ICourseContractGenerator /// Количество элементов для генерации. /// Список сгенерированных контрактов. IReadOnlyList Generate(int count); + + /// + /// Генерирует один контракт детерминированно по идентификатору. + /// + /// Идентификатор, используемый как seed генерации. + /// Сгенерированный контракт. + CourseContract GenerateById(int id); } diff --git a/CourseGenerator.Api/Program.cs b/CourseGenerator.Api/Program.cs index 51f581a5..960c3aad 100644 --- a/CourseGenerator.Api/Program.cs +++ b/CourseGenerator.Api/Program.cs @@ -6,6 +6,17 @@ builder.AddServiceDefaults(); +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => @@ -30,6 +41,8 @@ app.UseSwaggerUI(); } +app.UseCors(); + app.MapDefaultEndpoints(); app.MapControllers(); diff --git a/CourseGenerator.Api/Services/CourseContractGenerator.cs b/CourseGenerator.Api/Services/CourseContractGenerator.cs index d30b3853..3fc3b3b7 100644 --- a/CourseGenerator.Api/Services/CourseContractGenerator.cs +++ b/CourseGenerator.Api/Services/CourseContractGenerator.cs @@ -99,4 +99,23 @@ public IReadOnlyList Generate(int 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 From e431d3d6ee858dd2952c0a7678d1b207b2251054 Mon Sep 17 00:00:00 2001 From: the80hz Date: Fri, 10 Apr 2026 21:20:28 +0400 Subject: [PATCH 33/34] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0:=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=20=D0=BD=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=20=D0=BB=D0=B0=D0=B1=D0=BE=D1=80=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BD=D0=BE=D0=B9=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B?= =?UTF-8?q?,=20=D0=BE=D1=82=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=20=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D0=B9=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D1=83=D0=B7=D0=B5=D1=80=D0=B0,=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B1=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=20API?= =?UTF-8?q?,=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=D1=8B=20Makefile=20=D0=B8=20global.json.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/Components/StudentCard.razor | 2 +- Client.Wasm/Properties/launchSettings.json | 6 +-- Client.Wasm/wwwroot/appsettings.json | 2 +- Makefile | 54 ---------------------- global.json | 6 --- 5 files changed, 5 insertions(+), 65 deletions(-) mode change 100644 => 100755 Client.Wasm/Components/StudentCard.razor mode change 100644 => 100755 Client.Wasm/Properties/launchSettings.json mode change 100644 => 100755 Client.Wasm/wwwroot/appsettings.json delete mode 100644 Makefile delete mode 100644 global.json diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor old mode 100644 new mode 100755 index 13deb806..adedb666 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,7 +4,7 @@ - Номер №1 "Кэширование" + Номер №2 "Кэширование" Вариант №37 "Учебный курс" Выполнена Вольговым Даниилом 6513 Ссылка на форк diff --git a/Client.Wasm/Properties/launchSettings.json b/Client.Wasm/Properties/launchSettings.json old mode 100644 new mode 100755 index 0d824ea7..60120ec3 --- a/Client.Wasm/Properties/launchSettings.json +++ b/Client.Wasm/Properties/launchSettings.json @@ -12,7 +12,7 @@ "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "applicationUrl": "http://localhost:5127", "environmentVariables": { @@ -22,7 +22,7 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "applicationUrl": "https://localhost:7282;http://localhost:5127", "environmentVariables": { @@ -31,7 +31,7 @@ }, "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json old mode 100644 new mode 100755 index 20b85a3f..5cd94264 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "http://localhost:5117/api/courses/by-id" + "BaseAddress": "https://localhost:7297/courses" } diff --git a/Makefile b/Makefile deleted file mode 100644 index 05a30b84..00000000 --- a/Makefile +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 759e025f..00000000 --- a/global.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "sdk": { - "version": "8.0.124", - "rollForward": "disable" - } -} From 27c9c1f5f60b61402cdebbd7d095f1499437747f Mon Sep 17 00:00:00 2001 From: the80hz Date: Fri, 10 Apr 2026 21:25:10 +0400 Subject: [PATCH 34/34] =?UTF-8?q?=D0=9B=D0=B0=D0=B1=D0=B0=202=20=D1=81?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=8F=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Api.Gateway/Api.Gateway.csproj | 17 ++ .../WeightedRandomLoadBalancer.cs | 38 +++ Api.Gateway/Program.cs | 34 +++ Api.Gateway/Properties/launchSettings.json | 38 +++ Api.Gateway/appsettings.Development.json | 8 + Api.Gateway/appsettings.json | 18 ++ Api.Gateway/ocelot.json | 35 +++ .../CloudDevelopment.AppHost.csproj | 23 -- CloudDevelopment.AppHost/Program.cs | 16 -- .../Properties/launchSettings.json | 18 -- CloudDevelopment.sln | 92 +++---- CourseApp.Api/CourseApp.Api.csproj | 18 ++ CourseApp.Api/Generators/CourseGenerator.cs | 73 ++++++ CourseApp.Api/Models/Course.cs | 57 +++++ CourseApp.Api/Program.cs | 17 ++ CourseApp.Api/Properties/launchSettings.json | 38 +++ CourseApp.Api/Services/CourseService.cs | 86 +++++++ CourseApp.Api/Services/ICourseService.cs | 15 ++ CourseApp.Api/appsettings.Development.json | 8 + CourseApp.Api/appsettings.json | 12 + CourseApp/CourseApp.AppHost/AppHost.cs | 21 ++ .../CourseApp.AppHost.csproj | 24 ++ .../Properties/launchSettings.json | 29 +++ .../appsettings.Development.json | 8 + CourseApp/CourseApp.AppHost/appsettings.json | 9 + .../CourseApp.ServiceDefaults.csproj | 43 ++-- .../CourseApp.ServiceDefaults}/Extensions.cs | 229 ++++++++++-------- .../Controllers/CourseContractsController.cs | 88 ------- .../CourseGenerator.Api.csproj | 21 -- CourseGenerator.Api/Dto/CourseContractDto.cs | 16 -- .../Interfaces/ICourseContractCacheService.cs | 25 -- .../Interfaces/ICourseContractGenerator.cs | 23 -- .../Interfaces/ICourseContractsService.cs | 17 -- CourseGenerator.Api/Models/CourseContract.cs | 57 ----- CourseGenerator.Api/Program.cs | 50 ---- .../Services/CourseContractCacheService.cs | 48 ---- .../Services/CourseContractGenerator.cs | 121 --------- .../Services/CourseContractsService.cs | 44 ---- CourseGenerator.Api/appsettings.json | 1 - README.md | 145 ++--------- 40 files changed, 827 insertions(+), 853 deletions(-) create mode 100755 Api.Gateway/Api.Gateway.csproj create mode 100755 Api.Gateway/LoadBalancing/WeightedRandomLoadBalancer.cs create mode 100755 Api.Gateway/Program.cs create mode 100755 Api.Gateway/Properties/launchSettings.json create mode 100755 Api.Gateway/appsettings.Development.json create mode 100755 Api.Gateway/appsettings.json create mode 100755 Api.Gateway/ocelot.json delete mode 100644 CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj delete mode 100644 CloudDevelopment.AppHost/Program.cs delete mode 100644 CloudDevelopment.AppHost/Properties/launchSettings.json mode change 100644 => 100755 CloudDevelopment.sln create mode 100755 CourseApp.Api/CourseApp.Api.csproj create mode 100755 CourseApp.Api/Generators/CourseGenerator.cs create mode 100755 CourseApp.Api/Models/Course.cs create mode 100755 CourseApp.Api/Program.cs create mode 100755 CourseApp.Api/Properties/launchSettings.json create mode 100755 CourseApp.Api/Services/CourseService.cs create mode 100755 CourseApp.Api/Services/ICourseService.cs create mode 100755 CourseApp.Api/appsettings.Development.json create mode 100755 CourseApp.Api/appsettings.json create mode 100755 CourseApp/CourseApp.AppHost/AppHost.cs create mode 100755 CourseApp/CourseApp.AppHost/CourseApp.AppHost.csproj create mode 100755 CourseApp/CourseApp.AppHost/Properties/launchSettings.json create mode 100755 CourseApp/CourseApp.AppHost/appsettings.Development.json create mode 100755 CourseApp/CourseApp.AppHost/appsettings.json rename CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj => CourseApp/CourseApp.ServiceDefaults/CourseApp.ServiceDefaults.csproj (97%) mode change 100644 => 100755 rename {CloudDevelopment.ServiceDefaults => CourseApp/CourseApp.ServiceDefaults}/Extensions.cs (57%) mode change 100644 => 100755 delete mode 100644 CourseGenerator.Api/Controllers/CourseContractsController.cs delete mode 100644 CourseGenerator.Api/CourseGenerator.Api.csproj delete mode 100644 CourseGenerator.Api/Dto/CourseContractDto.cs delete mode 100644 CourseGenerator.Api/Interfaces/ICourseContractCacheService.cs delete mode 100644 CourseGenerator.Api/Interfaces/ICourseContractGenerator.cs delete mode 100644 CourseGenerator.Api/Interfaces/ICourseContractsService.cs delete mode 100644 CourseGenerator.Api/Models/CourseContract.cs delete mode 100644 CourseGenerator.Api/Program.cs delete mode 100644 CourseGenerator.Api/Services/CourseContractCacheService.cs delete mode 100644 CourseGenerator.Api/Services/CourseContractGenerator.cs delete mode 100644 CourseGenerator.Api/Services/CourseContractsService.cs delete mode 100644 CourseGenerator.Api/appsettings.json mode change 100644 => 100755 README.md diff --git a/Api.Gateway/Api.Gateway.csproj b/Api.Gateway/Api.Gateway.csproj new file mode 100755 index 00000000..38c81e1b --- /dev/null +++ b/Api.Gateway/Api.Gateway.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/Api.Gateway/LoadBalancing/WeightedRandomLoadBalancer.cs b/Api.Gateway/LoadBalancing/WeightedRandomLoadBalancer.cs new file mode 100755 index 00000000..14ff3aab --- /dev/null +++ b/Api.Gateway/LoadBalancing/WeightedRandomLoadBalancer.cs @@ -0,0 +1,38 @@ +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Responses; +using Ocelot.Values; + +namespace Api.Gateway.LoadBalancing; + +/// +/// Балансировка случайным образом с весами +/// +/// Функция получения списка сервисов +/// Конфигурация приложения +public class WeightedRandomLoadBalancer( + Func>> services, + IConfiguration configuration) : ILoadBalancer +{ + private readonly int[] _frequencies = configuration.GetSection("LoadBalancing:Weights").Get() ?? [5, 4, 3, 2, 1]; + + public string Type => nameof(WeightedRandomLoadBalancer); + + public async Task> LeaseAsync(HttpContext httpContext) + { + var availableServices = await services(); + + if (availableServices.Count == 0) + throw new InvalidOperationException("No available downstream services"); + + var values = Enumerable.Range(1, availableServices.Count) + .Zip(_frequencies, (val, freq) => Enumerable.Repeat(val, freq)) + .SelectMany(x => x) + .ToArray(); + + Random.Shared.Shuffle(values); + + return new OkResponse(availableServices[values.First() - 1].HostAndPort); + } + + public void Release(ServiceHostAndPort hostAndPort) { } +} diff --git a/Api.Gateway/Program.cs b/Api.Gateway/Program.cs new file mode 100755 index 00000000..3b61837b --- /dev/null +++ b/Api.Gateway/Program.cs @@ -0,0 +1,34 @@ +using Ocelot.DependencyInjection; +using Ocelot.Middleware; +using Api.Gateway.LoadBalancing; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); +builder.Services.AddOcelot() + .AddCustomLoadBalancer((sp, _, provider) => + new WeightedRandomLoadBalancer(provider.GetAsync, sp.GetRequiredService())); + +var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; + +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.WithOrigins(allowedOrigins) + .AllowAnyHeader() + .WithMethods("GET"); + }); +}); + +var app = builder.Build(); + +app.UseCors(); + +app.MapDefaultEndpoints(); + +await app.UseOcelot(); + +app.Run(); diff --git a/Api.Gateway/Properties/launchSettings.json b/Api.Gateway/Properties/launchSettings.json new file mode 100755 index 00000000..27cd238f --- /dev/null +++ b/Api.Gateway/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:51762", + "sslPort": 44308 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5297", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7297;http://localhost:5297", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Api.Gateway/appsettings.Development.json b/Api.Gateway/appsettings.Development.json new file mode 100755 index 00000000..ff66ba6b --- /dev/null +++ b/Api.Gateway/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Api.Gateway/appsettings.json b/Api.Gateway/appsettings.json new file mode 100755 index 00000000..f656df03 --- /dev/null +++ b/Api.Gateway/appsettings.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Cors": { + "AllowedOrigins": [ + "http://localhost:5127", + "https://localhost:7282" + ] + }, + "LoadBalancing": { + "Weights": [ 5, 4, 3, 2, 1 ] + } +} diff --git a/Api.Gateway/ocelot.json b/Api.Gateway/ocelot.json new file mode 100755 index 00000000..e0f53b61 --- /dev/null +++ b/Api.Gateway/ocelot.json @@ -0,0 +1,35 @@ +{ + "Routes": [ + { + "DownstreamPathTemplate": "/api/courses", + "DownstreamScheme": "https", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 5213 + }, + { + "Host": "localhost", + "Port": 5214 + }, + { + "Host": "localhost", + "Port": 5215 + }, + { + "Host": "localhost", + "Port": 5216 + }, + { + "Host": "localhost", + "Port": 5217 + } + ], + "UpstreamPathTemplate": "/courses", + "UpstreamHttpMethod": [ "GET" ], + "LoadBalancerOptions": { + "Type": "WeightedRandomLoadBalancer" + } + } + ] +} diff --git a/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj b/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj deleted file mode 100644 index 6edd01b8..00000000 --- a/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - Exe - net8.0 - enable - enable - true - - - - - - - - - - - - - diff --git a/CloudDevelopment.AppHost/Program.cs b/CloudDevelopment.AppHost/Program.cs deleted file mode 100644 index e6b11e50..00000000 --- a/CloudDevelopment.AppHost/Program.cs +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 38a3bbba..00000000 --- a/CloudDevelopment.AppHost/Properties/launchSettings.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$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.sln b/CloudDevelopment.sln old mode 100644 new mode 100755 index 3c2c273d..15209372 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,43 +1,49 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -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 - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {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 - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {90FE6B04-8381-437E-893A-FEBA1DA10AEE} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +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}") = "CourseApp.AppHost", "CourseApp\CourseApp.AppHost\CourseApp.AppHost.csproj", "{B51DE59B-BAF5-434D-B2AB-05002DF1BA24}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CourseApp.ServiceDefaults", "CourseApp\CourseApp.ServiceDefaults\CourseApp.ServiceDefaults.csproj", "{064D02F8-AA28-8AA3-C1DB-6440761E6CB3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CourseApp.Api", "CourseApp.Api\CourseApp.Api.csproj", "{2077B3EC-232D-D44F-72B9-3B84AA8AE54F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api.Gateway", "Api.Gateway\Api.Gateway.csproj", "{C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 + {B51DE59B-BAF5-434D-B2AB-05002DF1BA24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B51DE59B-BAF5-434D-B2AB-05002DF1BA24}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B51DE59B-BAF5-434D-B2AB-05002DF1BA24}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B51DE59B-BAF5-434D-B2AB-05002DF1BA24}.Release|Any CPU.Build.0 = Release|Any CPU + {064D02F8-AA28-8AA3-C1DB-6440761E6CB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {064D02F8-AA28-8AA3-C1DB-6440761E6CB3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {064D02F8-AA28-8AA3-C1DB-6440761E6CB3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {064D02F8-AA28-8AA3-C1DB-6440761E6CB3}.Release|Any CPU.Build.0 = Release|Any CPU + {2077B3EC-232D-D44F-72B9-3B84AA8AE54F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2077B3EC-232D-D44F-72B9-3B84AA8AE54F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2077B3EC-232D-D44F-72B9-3B84AA8AE54F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2077B3EC-232D-D44F-72B9-3B84AA8AE54F}.Release|Any CPU.Build.0 = Release|Any CPU + {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {90FE6B04-8381-437E-893A-FEBA1DA10AEE} + EndGlobalSection +EndGlobal diff --git a/CourseApp.Api/CourseApp.Api.csproj b/CourseApp.Api/CourseApp.Api.csproj new file mode 100755 index 00000000..2e3fa898 --- /dev/null +++ b/CourseApp.Api/CourseApp.Api.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/CourseApp.Api/Generators/CourseGenerator.cs b/CourseApp.Api/Generators/CourseGenerator.cs new file mode 100755 index 00000000..f96ea9e6 --- /dev/null +++ b/CourseApp.Api/Generators/CourseGenerator.cs @@ -0,0 +1,73 @@ +using Bogus; +using CourseApp.Api.Models; + +namespace CourseApp.Api.Generators; + +/// +/// Генератор учебных курсов на основе Bogus +/// +public static class CourseGenerator +{ + private static readonly string[] _courseNames = + [ + "Основы программирования", + "Базы данных", + "Веб-разработка", + "Машинное обучение", + "Алгоритмы и структуры данных", + "Компьютерные сети", + "Операционные системы", + "Информационная безопасность", + "Мобильная разработка", + "Облачные технологии", + "Искусственный интеллект", + "Анализ данных", + "DevOps-практики" + ]; + + private static readonly Faker _faker = new Faker("ru") + .RuleFor(c => c.Name, f => f.PickRandom(_courseNames)) + .RuleFor(c => c.TeacherFullName, f => + { + var gender = f.Random.Bool() ? Bogus.DataSets.Name.Gender.Male : Bogus.DataSets.Name.Gender.Female; + return $"{f.Name.LastName(gender)} {f.Name.FirstName(gender)} {GeneratePatronymic(f.Name.FirstName(Bogus.DataSets.Name.Gender.Male), gender)}"; + }) + .RuleFor(c => c.StartDate, f => + DateOnly.FromDateTime(f.Date.Between(DateTime.Now, DateTime.Now.AddMonths(3)))) + .RuleFor(c => c.EndDate, (f, c) => + c.StartDate.AddDays(f.Random.Int(30, 180))) + .RuleFor(c => c.MaxStudents, f => f.Random.Int(10, 100)) + .RuleFor(c => c.CurrentStudents, (f, c) => f.Random.Int(0, c.MaxStudents)) + .RuleFor(c => c.HasCertificate, f => f.Random.Bool()) + .RuleFor(c => c.Price, f => Math.Round(f.Random.Decimal(5000m, 150000m), 2)) + .RuleFor(c => c.Rating, f => f.Random.Int(1, 5)); + + /// + /// Генерация отчества из мужского имени + /// + /// Мужское имя + /// Пол преподавателя + private static string GeneratePatronymic(string maleFirstName, Bogus.DataSets.Name.Gender gender) + { + var isMale = gender == Bogus.DataSets.Name.Gender.Male; + + if (maleFirstName.EndsWith('ь') || maleFirstName.EndsWith('й')) + return maleFirstName[..^1] + (isMale ? "евич" : "евна"); + + if (maleFirstName.EndsWith('а') || maleFirstName.EndsWith('я')) + return maleFirstName[..^1] + (isMale ? "ич" : "ична"); + + return maleFirstName + (isMale ? "ович" : "овна"); + } + + /// + /// Генерация учебного курса с указанным идентификатором + /// + /// Идентификатор курса + public static Course Generate(int id) + { + var course = _faker.Generate(); + course.Id = id; + return course; + } +} diff --git a/CourseApp.Api/Models/Course.cs b/CourseApp.Api/Models/Course.cs new file mode 100755 index 00000000..ea68a99a --- /dev/null +++ b/CourseApp.Api/Models/Course.cs @@ -0,0 +1,57 @@ +namespace CourseApp.Api.Models; + +/// +/// Учебный курс +/// +public class Course +{ + /// + /// Идентификатор в системе + /// + public int Id { get; set; } + + /// + /// Наименование курса + /// + public required string Name { get; set; } + + /// + /// ФИО преподавателя + /// + public required string TeacherFullName { get; set; } + + /// + /// Дата начала + /// + public DateOnly StartDate { get; set; } + + /// + /// Дата окончания + /// + public DateOnly EndDate { get; set; } + + /// + /// Максимальное число студентов + /// + public int MaxStudents { get; set; } + + /// + /// Текущее число студентов + /// + public int CurrentStudents { get; set; } + + /// + /// Выдача сертификата + /// + public bool HasCertificate { get; set; } + + /// + /// Стоимость + /// + public decimal Price { get; set; } + + /// + /// Рейтинг + /// + public int Rating { get; set; } +} diff --git a/CourseApp.Api/Program.cs b/CourseApp.Api/Program.cs new file mode 100755 index 00000000..d83edce7 --- /dev/null +++ b/CourseApp.Api/Program.cs @@ -0,0 +1,17 @@ +using CourseApp.Api.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.AddRedisDistributedCache("redis"); + +builder.Services.AddScoped(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +app.MapGet("/api/courses", async (int id, ICourseService courseService) => + Results.Ok(await courseService.GetCourse(id))); + +app.Run(); diff --git a/CourseApp.Api/Properties/launchSettings.json b/CourseApp.Api/Properties/launchSettings.json new file mode 100755 index 00000000..64f00edb --- /dev/null +++ b/CourseApp.Api/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:11583", + "sslPort": 44345 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5213", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7156;http://localhost:5213", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/CourseApp.Api/Services/CourseService.cs b/CourseApp.Api/Services/CourseService.cs new file mode 100755 index 00000000..f2c42007 --- /dev/null +++ b/CourseApp.Api/Services/CourseService.cs @@ -0,0 +1,86 @@ +using System.Text.Json; +using CourseApp.Api.Generators; +using CourseApp.Api.Models; +using Microsoft.Extensions.Caching.Distributed; + +namespace CourseApp.Api.Services; + +/// +/// Сервис учебных курсов с кэшированием в Redis +/// +/// Распределённый кэш +/// Конфигурация приложения +/// Логгер +public sealed class CourseService( + IDistributedCache cache, + IConfiguration configuration, + ILogger logger) : ICourseService +{ + private const string CacheKeyPrefix = "course:"; + + private readonly TimeSpan _cacheExpiration = TimeSpan.FromMinutes( + configuration.GetValue("Cache:ExpirationMinutes")); + + /// + /// Получение учебного курса по идентификатору с кэшированием + /// + /// Идентификатор курса + public async Task GetCourse(int id) + { + var cachedCourse = await TryGetFromCache(id); + + if (cachedCourse is not null) + { + logger.LogInformation("Cache hit for course with id {Id}", id); + return cachedCourse; + } + + logger.LogInformation("Cache miss for course with id {Id}", id); + + var course = CourseGenerator.Generate(id); + logger.LogInformation("Generated course {@Course}", course); + + await TrySetToCache(id, course); + + return course; + } + + private async Task TryGetFromCache(int id) + { + try + { + var key = CacheKeyPrefix + id; + var data = await cache.GetStringAsync(key); + + if (data is null) + return null; + + return JsonSerializer.Deserialize(data); + } + catch (Exception ex) + { + logger.LogError(ex, "Error reading course with id {Id} from cache", id); + return null; + } + } + + private async Task TrySetToCache(int id, Course course) + { + try + { + var key = CacheKeyPrefix + id; + var data = JsonSerializer.Serialize(course); + var options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = _cacheExpiration + }; + await cache.SetStringAsync(key, data, options); + + logger.LogInformation("Course with id {Id} saved to cache", id); + } + catch (Exception ex) + { + logger.LogError(ex, "Error saving course with id {Id} to cache", id); + } + } +} diff --git a/CourseApp.Api/Services/ICourseService.cs b/CourseApp.Api/Services/ICourseService.cs new file mode 100755 index 00000000..4a9695e6 --- /dev/null +++ b/CourseApp.Api/Services/ICourseService.cs @@ -0,0 +1,15 @@ +using CourseApp.Api.Models; + +namespace CourseApp.Api.Services; + +/// +/// Интерфейс сервиса учебных курсов +/// +public interface ICourseService +{ + /// + /// Получение учебного курса по идентификатору + /// + /// Идентификатор курса + public Task GetCourse(int id); +} diff --git a/CourseApp.Api/appsettings.Development.json b/CourseApp.Api/appsettings.Development.json new file mode 100755 index 00000000..ff66ba6b --- /dev/null +++ b/CourseApp.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/CourseApp.Api/appsettings.json b/CourseApp.Api/appsettings.json new file mode 100755 index 00000000..4b07986a --- /dev/null +++ b/CourseApp.Api/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Cache": { + "ExpirationMinutes": 5 + } +} diff --git a/CourseApp/CourseApp.AppHost/AppHost.cs b/CourseApp/CourseApp.AppHost/AppHost.cs new file mode 100755 index 00000000..e25220ea --- /dev/null +++ b/CourseApp/CourseApp.AppHost/AppHost.cs @@ -0,0 +1,21 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var redis = builder.AddRedis("redis") + .WithRedisInsight(); + +var apiGateway = builder.AddProject("api-gateway"); + +for (var i = 0; i < 5; i++) +{ + var courseApi = builder.AddProject($"courseapp-api-{i}", launchProfileName: null) + .WithHttpsEndpoint(port: 5213 + i) + .WithReference(redis) + .WaitFor(redis); + + apiGateway.WaitFor(courseApi); +} + +builder.AddProject("client-wasm") + .WaitFor(apiGateway); + +builder.Build().Run(); diff --git a/CourseApp/CourseApp.AppHost/CourseApp.AppHost.csproj b/CourseApp/CourseApp.AppHost/CourseApp.AppHost.csproj new file mode 100755 index 00000000..06c07053 --- /dev/null +++ b/CourseApp/CourseApp.AppHost/CourseApp.AppHost.csproj @@ -0,0 +1,24 @@ + + + + + + Exe + net8.0 + enable + enable + c81391d2-28b1-41ef-bc85-6df5de587604 + + + + + + + + + + + + + + diff --git a/CourseApp/CourseApp.AppHost/Properties/launchSettings.json b/CourseApp/CourseApp.AppHost/Properties/launchSettings.json new file mode 100755 index 00000000..b3abb100 --- /dev/null +++ b/CourseApp/CourseApp.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17210;http://localhost:15223", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21088", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22166" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15223", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19119", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20071" + } + } + } +} diff --git a/CourseApp/CourseApp.AppHost/appsettings.Development.json b/CourseApp/CourseApp.AppHost/appsettings.Development.json new file mode 100755 index 00000000..ff66ba6b --- /dev/null +++ b/CourseApp/CourseApp.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/CourseApp/CourseApp.AppHost/appsettings.json b/CourseApp/CourseApp.AppHost/appsettings.json new file mode 100755 index 00000000..2185f955 --- /dev/null +++ b/CourseApp/CourseApp.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj b/CourseApp/CourseApp.ServiceDefaults/CourseApp.ServiceDefaults.csproj old mode 100644 new mode 100755 similarity index 97% rename from CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj rename to CourseApp/CourseApp.ServiceDefaults/CourseApp.ServiceDefaults.csproj index 87b70e2a..f40f4e11 --- a/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj +++ b/CourseApp/CourseApp.ServiceDefaults/CourseApp.ServiceDefaults.csproj @@ -1,21 +1,22 @@ - - - - net8.0 - enable - enable - true - - - - - - - - - - - - - - + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/CloudDevelopment.ServiceDefaults/Extensions.cs b/CourseApp/CourseApp.ServiceDefaults/Extensions.cs old mode 100644 new mode 100755 similarity index 57% rename from CloudDevelopment.ServiceDefaults/Extensions.cs rename to CourseApp/CourseApp.ServiceDefaults/Extensions.cs index 86ae232d..bf34b046 --- a/CloudDevelopment.ServiceDefaults/Extensions.cs +++ b/CourseApp/CourseApp.ServiceDefaults/Extensions.cs @@ -1,102 +1,127 @@ -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; - } -} +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +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 => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + 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 => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .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(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/CourseGenerator.Api/Controllers/CourseContractsController.cs b/CourseGenerator.Api/Controllers/CourseContractsController.cs deleted file mode 100644 index d34f04b4..00000000 --- a/CourseGenerator.Api/Controllers/CourseContractsController.cs +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index d5e8615a..00000000 --- a/CourseGenerator.Api/CourseGenerator.Api.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - 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 deleted file mode 100644 index 40ee1193..00000000 --- a/CourseGenerator.Api/Dto/CourseContractDto.cs +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index cc53907d..00000000 --- a/CourseGenerator.Api/Interfaces/ICourseContractCacheService.cs +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 050869af..00000000 --- a/CourseGenerator.Api/Interfaces/ICourseContractGenerator.cs +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 716b0f1d..00000000 --- a/CourseGenerator.Api/Interfaces/ICourseContractsService.cs +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index b63500cf..00000000 --- a/CourseGenerator.Api/Models/CourseContract.cs +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 960c3aad..00000000 --- a/CourseGenerator.Api/Program.cs +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 7ed129bf..00000000 --- a/CourseGenerator.Api/Services/CourseContractCacheService.cs +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 3fc3b3b7..00000000 --- a/CourseGenerator.Api/Services/CourseContractGenerator.cs +++ /dev/null @@ -1,121 +0,0 @@ -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 deleted file mode 100644 index 4c84afc1..00000000 --- a/CourseGenerator.Api/Services/CourseContractsService.cs +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 0967ef42..00000000 --- a/CourseGenerator.Api/appsettings.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/README.md b/README.md old mode 100644 new mode 100755 index dcaa5eb7..2608c03c --- a/README.md +++ b/README.md @@ -1,128 +1,35 @@ -# Современные технологии разработки программного обеспечения -[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1an43o-iqlq4V_kDtkr_y7DC221hY9qdhGPrpII27sH8/edit?usp=sharing) +# Современные технологии разработки ПО -## Задание -### Цель -Реализация проекта микросервисного бекенда. +Проект микросервисного бекенда для генерации данных об учебных курсах с кэшированием и балансировкой нагрузки. -### Задачи -* Реализация межсервисной коммуникации, -* Изучение работы с брокерами сообщений, -* Изучение архитектурных паттернов, -* Изучение работы со средствами оркестрации на примере .NET Aspire, -* Повторение основ работы с системами контроля версий, -* Интеграционное тестирование. +## Лабораторная работа 1. Кэширование -### Лабораторные работы -
-1. «Кэширование» - Реализация сервиса генерации контрактов, кэширование его ответов -
- -В рамках первой лабораторной работы необходимо: -* Реализовать сервис генерации контрактов на основе Bogus, -* Реализовать кеширование при помощи IDistributedCache и Redis, -* Реализовать структурное логирование сервиса генерации, -* Настроить оркестрацию Aspire. - -
-
-2. «Балансировка нагрузки» - Реализация апи гейтвея, настройка его работы -
- -В рамках второй лабораторной работы необходимо: -* Настроить оркестрацию на запуск нескольких реплик сервиса генерации, -* Реализовать апи гейтвей на основе Ocelot, -* Имплементировать алгоритм балансировки нагрузки согласно варианту. +- Реализован сервис генерации учебных курсов на основе Bogus +- Реализовано кэширование с помощью `IDistributedCache` и Redis +- Реализовано структурное логирование сервиса генерации +- Настроена оркестрация .NET Aspire -
-
-
-3. «Интеграционное тестирование» - Реализация файлового сервиса и объектного хранилища, интеграционное тестирование бекенда -
+### Предметная область — Учебный курс -В рамках третьей лабораторной работы необходимо: -* Добавить в оркестрацию объектное хранилище, -* Реализовать файловый сервис, сериализующий сгенерированные данные в файлы и сохраняющий их в объектном хранилище, -* Реализовать отправку генерируемых данных в файловый сервис посредством брокера, -* Реализовать интеграционные тесты, проверяющие корректность работы всех сервисов бекенда вместе. +| # | Характеристика | Тип данных | +|---|----------------|------------| +| 1 | Идентификатор в системе | `int` | +| 2 | Наименование курса | `string` | +| 3 | ФИО преподавателя | `string` | +| 4 | Дата начала | `DateOnly` | +| 5 | Дата окончания | `DateOnly` | +| 6 | Максимальное число студентов | `int` | +| 7 | Текущее число студентов | `int` | +| 8 | Выдача сертификата | `bool` | +| 9 | Стоимость | `decimal` | +| 10 | Рейтинг | `int` | -
-
-
-4. (Опционально) «Переход на облачную инфраструктуру» - Перенос бекенда в Yandex Cloud -
- -В рамках четвертой лабораторной работы необходимо перенестиервисы на облако все ранее разработанные сервисы: -* Клиент - в хостинг через отдельный бакет Object Storage, -* Сервис генерации - в Cloud Function, -* Апи гейтвей - в Serverless Integration как API Gateway, -* Брокер сообщений - в Message Queue, -* Файловый сервис - в Cloud Function, -* Объектное хранилище - в отдельный бакет Object Storage, +## Лабораторная работа 2. Балансировка нагрузки -
-
+- Настроена оркестрация на запуск 5 реплик сервиса генерации +- Реализован API Gateway на основе Ocelot +- Имплементирован алгоритм балансировки Weighted Random -## Задание. Общая часть -**Обязательно**: -* Реализация серверной части на [.NET 8](https://learn.microsoft.com/ru-ru/dotnet/core/whats-new/dotnet-8/overview). -* Оркестрация проектов при помощи [.NET Aspire](https://learn.microsoft.com/ru-ru/dotnet/aspire/get-started/aspire-overview). -* Реализация сервиса генерации данных при помощи [Bogus](https://github.com/bchavez/Bogus). -* Реализация тестов с использованием [xUnit](https://xunit.net/?tabs=cs). -* Создание минимальной документации к проекту: страница на GitHub с информацией о задании, скриншоты приложения и прочая информация. - -**Факультативно**: -* Перенос бекенда на облачную инфраструктуру Yandex Cloud - -Внимательно прочитайте [дискуссии](https://github.com/itsecd/cloud-development/discussions/1) о том, как работает автоматическое распределение на ревью. -Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю. - -По итогу работы в семестре должна получиться следующая информационная система: -
-C4 диаграмма -Современные_технологии_разработки_ПО_drawio -
- -## Варианты заданий -Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи. - -[Список вариантов](https://docs.google.com/document/d/1WGmLYwffTTaAj4TgFCk5bUyW3XKbFMiBm-DHZrfFWr4/edit?usp=sharing) -[Список предметных областей и алгоритмов балансировки](https://docs.google.com/document/d/1PLn2lKe4swIdJDZhwBYzxqFSu0AbY2MFY1SUPkIKOM4/edit?usp=sharing) - -## Схема сдачи - -На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests). - -Общая схема: -1. Сделать форк данного репозитория -2. Выполнить задание -3. Сделать PR в данный репозиторий -4. Исправить замечания после code review -5. Получить approve - -## Критерии оценивания - -Конкурентный принцип. -Так как задания в первой лабораторной будут повторяться между студентами, то выделяются следующие показатели для оценки: -1. Скорость разработки -2. Качество разработки -3. Полнота выполнения задания - -Быстрее делаете PR - у вас преимущество. -Быстрее получаете Approve - у вас преимущество. -Выполните нечто немного выходящее за рамки проекта - у вас преимущество. -Не укладываетесь в дедлайн - получаете минимально возможный балл. - -### Шкала оценивания - -- **3 балла** за качество кода, из них: - - 2 балла - базовая оценка - - 1 балл (но не более) можно получить за выполнение любого из следующих пунктов: - - Реализация факультативного функционала - - Выполнение работы раньше других: первые 5 человек из каждой группы, которые сделали PR и получили approve, получают дополнительный балл - -## Вопросы и обратная связь по курсу - -Чтобы задать вопрос по лабораторной, воспользуйтесь [соответствующим разделом дискуссий](https://github.com/itsecd/cloud-development/discussions/categories/questions) или заведите [ишью](https://github.com/itsecd/cloud-development/issues/new). -Если у вас появились идеи/пожелания/прочие полезные мысли по преподаваемой дисциплине, их можно оставить [здесь](https://github.com/itsecd/cloud-development/discussions/categories/ideas). +### Алгоритм Weighted Random +Каждой реплике сервиса присваивается вероятность выбора (сумма вероятностей равна 1). При поступлении запроса реплика выбирается случайно с учётом назначенных весов.