From 08d42566fe6013e2d03af1798967cec19bd6eaf4 Mon Sep 17 00:00:00 2001 From: Ivan K Date: Mon, 16 Feb 2026 17:30:25 +0400 Subject: [PATCH 01/26] =?UTF-8?q?=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BB=D1=80?= 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 +- CloudDevelopment.sln | 29 ++++- CreditApp.Api/Controllers/CreditController.cs | 25 ++++ CreditApp.Api/CreditApp.Api.csproj | 22 ++++ CreditApp.Api/CreditApp.Api.http | 6 + CreditApp.Api/Program.cs | 30 +++++ CreditApp.Api/Properties/launchSettings.json | 41 +++++++ .../CreditApplicationGeneratorService.cs | 111 ++++++++++++++++++ .../ICreditApplicationGeneratorService.cs | 14 +++ CreditApp.Api/appsettings.Development.json | 8 ++ CreditApp.Api/appsettings.json | 9 ++ CreditApp.AppHost/CreditApp.AppHost.csproj | 20 ++++ CreditApp.AppHost/Program.cs | 11 ++ .../Properties/launchSettings.json | 31 +++++ .../appsettings.Development.json | 8 ++ CreditApp.AppHost/appsettings.json | 9 ++ CreditApp.Domain/CreditApp.Domain.csproj | 9 ++ .../Entities/CreditApplication.cs | 48 ++++++++ .../CreditApp.ServiceDefaults.csproj | 20 ++++ CreditApp.ServiceDefaults/Extensions.cs | 104 ++++++++++++++++ 21 files changed, 557 insertions(+), 8 deletions(-) create mode 100644 CreditApp.Api/Controllers/CreditController.cs create mode 100644 CreditApp.Api/CreditApp.Api.csproj create mode 100644 CreditApp.Api/CreditApp.Api.http create mode 100644 CreditApp.Api/Program.cs create mode 100644 CreditApp.Api/Properties/launchSettings.json create mode 100644 CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs create mode 100644 CreditApp.Api/Services/CreditGeneratorService/ICreditApplicationGeneratorService.cs create mode 100644 CreditApp.Api/appsettings.Development.json create mode 100644 CreditApp.Api/appsettings.json create mode 100644 CreditApp.AppHost/CreditApp.AppHost.csproj create mode 100644 CreditApp.AppHost/Program.cs create mode 100644 CreditApp.AppHost/Properties/launchSettings.json create mode 100644 CreditApp.AppHost/appsettings.Development.json create mode 100644 CreditApp.AppHost/appsettings.json create mode 100644 CreditApp.Domain/CreditApp.Domain.csproj create mode 100644 CreditApp.Domain/Entities/CreditApplication.cs create mode 100644 CreditApp.ServiceDefaults/CreditApp.ServiceDefaults.csproj create mode 100644 CreditApp.ServiceDefaults/Extensions.cs diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..55f96c65 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,10 +4,10 @@ - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №1 Кэширование" + Вариант №9 "Кредитная заявка" + Выполнена Куненковым Иваном 6511 + Ссылка на форк diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index 4dda7c04..3f506eb9 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "https://localhost:7170/land-plot" + "BaseAddress": "https://localhost:7170/api/credit" } \ No newline at end of file diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..c625a56f 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,10 +1,17 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36811.4 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11505.172 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}") = "CreditApp.AppHost", "CreditApp.AppHost\CreditApp.AppHost.csproj", "{E2DE4A09-990C-4B72-A09D-CCE1C10AE9DD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.Api", "CreditApp.Api\CreditApp.Api.csproj", "{E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.Domain", "CreditApp.Domain\CreditApp.Domain.csproj", "{CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.ServiceDefaults", "CreditApp.ServiceDefaults\CreditApp.ServiceDefaults.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +22,22 @@ 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 + {E2DE4A09-990C-4B72-A09D-CCE1C10AE9DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E2DE4A09-990C-4B72-A09D-CCE1C10AE9DD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E2DE4A09-990C-4B72-A09D-CCE1C10AE9DD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E2DE4A09-990C-4B72-A09D-CCE1C10AE9DD}.Release|Any CPU.Build.0 = Release|Any CPU + {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Release|Any CPU.Build.0 = Release|Any CPU + {CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CreditApp.Api/Controllers/CreditController.cs b/CreditApp.Api/Controllers/CreditController.cs new file mode 100644 index 00000000..83dbc602 --- /dev/null +++ b/CreditApp.Api/Controllers/CreditController.cs @@ -0,0 +1,25 @@ +using CreditApp.Api.Services.CreditGeneratorService; +using Microsoft.AspNetCore.Mvc; + +namespace CreditApp.Api.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class CreditController(ICreditApplicationGeneratorService _generatorService, ILogger _logger) : ControllerBase +{ + /// + /// Получить кредитную заявку по ID, если не найдена в кэше генерируем новую + /// + /// ID кредитной заявки + /// Токен отмены операции + /// Кредитная заявка + [HttpGet] + public async Task GetById([FromQuery] int id, CancellationToken cancellationToken) + { + _logger.LogInformation("Получен запрос на получение/генерацию заявки {Id}", id); + + var application = await _generatorService.GetByIdAsync(id, cancellationToken); + + return Ok(application); + } +} diff --git a/CreditApp.Api/CreditApp.Api.csproj b/CreditApp.Api/CreditApp.Api.csproj new file mode 100644 index 00000000..f59f1598 --- /dev/null +++ b/CreditApp.Api/CreditApp.Api.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/CreditApp.Api/CreditApp.Api.http b/CreditApp.Api/CreditApp.Api.http new file mode 100644 index 00000000..d979e231 --- /dev/null +++ b/CreditApp.Api/CreditApp.Api.http @@ -0,0 +1,6 @@ +@CreditApp.Api_HostAddress = http://localhost:5179 + +GET {{CreditApp.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/CreditApp.Api/Program.cs b/CreditApp.Api/Program.cs new file mode 100644 index 00000000..d7339521 --- /dev/null +++ b/CreditApp.Api/Program.cs @@ -0,0 +1,30 @@ +using CreditApp.Api.Services.CreditGeneratorService; +using CreditApp.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); +builder.AddRedisClient("cache"); +builder.Services.AddStackExchangeRedisCache(options => +{ + var connectionString = builder.Configuration.GetConnectionString("cache"); + options.Configuration = connectionString; +}); +builder.Services.AddScoped(); +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.MapControllers(); +app.MapDefaultEndpoints(); + +app.Run(); \ No newline at end of file diff --git a/CreditApp.Api/Properties/launchSettings.json b/CreditApp.Api/Properties/launchSettings.json new file mode 100644 index 00000000..a3388c36 --- /dev/null +++ b/CreditApp.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:46825", + "sslPort": 44333 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5179", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7036;http://localhost:5179", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs b/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs new file mode 100644 index 00000000..772cac46 --- /dev/null +++ b/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs @@ -0,0 +1,111 @@ +using Bogus; +using CreditApp.Domain.Entities; +using Microsoft.Extensions.Caching.Distributed; +using System.Text.Json; + +namespace CreditApp.Api.Services.CreditGeneratorService; + +public class CreditApplicationGeneratorService(IDistributedCache _cache, ILogger _logger) : ICreditApplicationGeneratorService +{ + private static readonly string[] _creditTypes = + [ + "Потребительский", + "Ипотека", + "Автокредит", + "Бизнес-кредит", + "Образовательный" + ]; + + private static readonly string[] _statuses = + [ + "Новая", + "В обработке", + "Одобрена", + "Отклонена" + ]; + + private static readonly string[] _terminalStatuses = ["Одобрена", "Отклонена"]; + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + var cacheKey = $"credit-application-{id}"; + + _logger.LogInformation("Попытка получить заявку {Id} из кэша", id); + + var cachedData = await _cache.GetStringAsync(cacheKey, cancellationToken); + + if (cachedData != null) + { + _logger.LogInformation("Заявка {Id} найдена в кэше", id); + return JsonSerializer.Deserialize(cachedData)!; + } + + _logger.LogInformation("Заявка {Id} не найдена в кэше, генерируем новую", id); + var application = GenerateApplication(id); + var cacheOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) + }; + + await _cache.SetStringAsync( + cacheKey, + JsonSerializer.Serialize(application), + cacheOptions, + cancellationToken); + + _logger.LogInformation( + "Кредитная заявка сгенерирована и закэширована: Id={Id}, Тип={Type}, Сумма={Amount}, Статус={Status}", + application.Id, + application.Type, + application.Amount, + application.Status); + + return application; + } + + /// + /// Генерация кредитной заявки с указанным ID + /// + private static CreditApplication GenerateApplication(int id) + { + var faker = new Faker("ru") + .RuleFor(c => c.Id, f => id) + .RuleFor(c => c.Type, f => f.PickRandom(_creditTypes)) + .RuleFor(c => c.Amount, f => Math.Round(f.Finance.Amount(10000, 10000000), 2)) + .RuleFor(c => c.Term, f => f.Random.Int(6, 360)) + .RuleFor(c => c.InterestRate, f => Math.Round(f.Random.Double(16.0, 25.0), 2)) + .RuleFor(c => c.SubmissionDate, f => DateOnly.FromDateTime( + f.Date.Between(DateTime.Now.AddYears(-2), DateTime.Now))) + .RuleFor(c => c.RequiresInsurance, f => f.Random.Bool()) + .RuleFor(c => c.Status, f => f.PickRandom(_statuses)); + + var application = faker.Generate(); + + if (_terminalStatuses.Contains(application.Status)) + { + var submissionDate = application.SubmissionDate.ToDateTime(TimeOnly.MinValue); + var approvalDate = submissionDate.AddDays(new Random().Next(1, 60)); + + if (approvalDate > DateTime.Now) + { + approvalDate = DateTime.Now; + } + + application.ApprovalDate = DateOnly.FromDateTime(approvalDate); + } + + if (application.Status == "Одобрена") + { + var percentage = 0.8m + (decimal)new Random().NextDouble() * 0.3m; + var approvedAmount = application.Amount * percentage; + application.ApprovedAmount = Math.Round(approvedAmount, 2); + + if (application.ApprovedAmount > application.Amount) + { + application.ApprovedAmount = application.Amount; + } + } + + return application; + } +} diff --git a/CreditApp.Api/Services/CreditGeneratorService/ICreditApplicationGeneratorService.cs b/CreditApp.Api/Services/CreditGeneratorService/ICreditApplicationGeneratorService.cs new file mode 100644 index 00000000..da5e8ea5 --- /dev/null +++ b/CreditApp.Api/Services/CreditGeneratorService/ICreditApplicationGeneratorService.cs @@ -0,0 +1,14 @@ +using CreditApp.Domain.Entities; + +namespace CreditApp.Api.Services.CreditGeneratorService; + +public interface ICreditApplicationGeneratorService +{ + /// + /// Получить заявку по ID, если не найдена в кэше генерируем новую с указанным ID + /// + /// ID кредитной заявки + /// Токен отмены операции + /// Кредитная заявка + public Task GetByIdAsync(int id, CancellationToken cancellationToken = default); +} diff --git a/CreditApp.Api/appsettings.Development.json b/CreditApp.Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/CreditApp.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/CreditApp.Api/appsettings.json b/CreditApp.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/CreditApp.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/CreditApp.AppHost/CreditApp.AppHost.csproj b/CreditApp.AppHost/CreditApp.AppHost.csproj new file mode 100644 index 00000000..71a48b0e --- /dev/null +++ b/CreditApp.AppHost/CreditApp.AppHost.csproj @@ -0,0 +1,20 @@ + + + + Exe + net8.0 + enable + enable + 70e4a3f7-c299-47f6-9d51-144f31ab779b + + + + + + + + + + + + diff --git a/CreditApp.AppHost/Program.cs b/CreditApp.AppHost/Program.cs new file mode 100644 index 00000000..5853fa24 --- /dev/null +++ b/CreditApp.AppHost/Program.cs @@ -0,0 +1,11 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var redis = builder.AddRedis("cache"); + +var api = builder.AddProject("creditapp-api") + .WithReference(redis); + +builder.AddProject("client") + .WithReference(api); + +builder.Build().Run(); diff --git a/CreditApp.AppHost/Properties/launchSettings.json b/CreditApp.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..2bcd68bf --- /dev/null +++ b/CreditApp.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17062;http://localhost:15086", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21219", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23298", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22237" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15086", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19243", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18213", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20039" + } + } + } +} diff --git a/CreditApp.AppHost/appsettings.Development.json b/CreditApp.AppHost/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/CreditApp.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/CreditApp.AppHost/appsettings.json b/CreditApp.AppHost/appsettings.json new file mode 100644 index 00000000..31c092aa --- /dev/null +++ b/CreditApp.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/CreditApp.Domain/CreditApp.Domain.csproj b/CreditApp.Domain/CreditApp.Domain.csproj new file mode 100644 index 00000000..fa71b7ae --- /dev/null +++ b/CreditApp.Domain/CreditApp.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/CreditApp.Domain/Entities/CreditApplication.cs b/CreditApp.Domain/Entities/CreditApplication.cs new file mode 100644 index 00000000..ca40117f --- /dev/null +++ b/CreditApp.Domain/Entities/CreditApplication.cs @@ -0,0 +1,48 @@ +namespace CreditApp.Domain.Entities; + +/// +/// Кредитная заявка +/// +public class CreditApplication +{ + /// + /// Идентификатор в системе + /// + public int Id { get; set; } + /// + /// Тип кредита + /// + public string Type { get; set; } = String.Empty; + /// + /// Запрашиваемая сумма + /// + public decimal Amount { get; set; } + /// + /// Срок в месяцах + /// + public int Term { get; set; } + /// + /// Процентная ставка + /// + public double InterestRate { get; set; } + /// + /// Дата подачи + /// + public DateOnly SubmissionDate { get; set; } + /// + /// Необходимость страховки + /// + public bool RequiresInsurance { get; set; } + /// + /// Статус заявки + /// + public string Status { get; set; } = String.Empty; + /// + /// Дата решения + /// + public DateOnly? ApprovalDate { get; set; } + /// + /// Одобренная сумма + /// + public decimal? ApprovedAmount { get; set; } +} diff --git a/CreditApp.ServiceDefaults/CreditApp.ServiceDefaults.csproj b/CreditApp.ServiceDefaults/CreditApp.ServiceDefaults.csproj new file mode 100644 index 00000000..6a65bdf6 --- /dev/null +++ b/CreditApp.ServiceDefaults/CreditApp.ServiceDefaults.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + diff --git a/CreditApp.ServiceDefaults/Extensions.cs b/CreditApp.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..83beef74 --- /dev/null +++ b/CreditApp.ServiceDefaults/Extensions.cs @@ -0,0 +1,104 @@ +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 CreditApp.ServiceDefaults; + +/// +/// Расширения для настройки сервисов Aspire (структурное логирование, телеметрия, health checks) +/// +public static class Extensions +{ + /// + /// Добавляет стандартные настройки Aspire для сервисов + /// + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + builder.AddDefaultHealthChecks(); + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + /// + /// Настройка OpenTelemetry для структурного логирования и телеметрии + /// + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } + + /// + /// Добавляет стандартные health checks + /// + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + /// + /// Настройка стандартных endpoints (health checks) + /// + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapHealthChecks("/health"); + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} From 59cb056c0899850e659a7d59ad2b059a94a09fed Mon Sep 17 00:00:00 2001 From: Ivan K Date: Mon, 16 Feb 2026 17:52:00 +0400 Subject: [PATCH 02/26] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B1=D0=BB=D0=B5=D0=BC=D1=8B=20=D1=81=20CORS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/Properties/launchSettings.json | 6 +++--- CreditApp.Api/Program.cs | 18 +++++++++++++++++- CreditApp.Api/Properties/launchSettings.json | 4 ++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Client.Wasm/Properties/launchSettings.json b/Client.Wasm/Properties/launchSettings.json index 0d824ea7..60120ec3 100644 --- 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/CreditApp.Api/Program.cs b/CreditApp.Api/Program.cs index d7339521..840fb217 100644 --- a/CreditApp.Api/Program.cs +++ b/CreditApp.Api/Program.cs @@ -2,21 +2,36 @@ using CreditApp.ServiceDefaults; var builder = WebApplication.CreateBuilder(args); + builder.AddServiceDefaults(); + builder.AddRedisClient("cache"); builder.Services.AddStackExchangeRedisCache(options => { var connectionString = builder.Configuration.GetConnectionString("cache"); options.Configuration = connectionString; }); + +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowBlazorWasm", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + builder.Services.AddScoped(); + +// Add controllers builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); -// Configure the HTTP request pipeline. +// Configure the HTTP request pipeline if (app.Environment.IsDevelopment()) { app.UseSwagger(); @@ -24,6 +39,7 @@ } app.UseHttpsRedirection(); +app.UseCors("AllowBlazorWasm"); app.MapControllers(); app.MapDefaultEndpoints(); diff --git a/CreditApp.Api/Properties/launchSettings.json b/CreditApp.Api/Properties/launchSettings.json index a3388c36..bbe9ee66 100644 --- a/CreditApp.Api/Properties/launchSettings.json +++ b/CreditApp.Api/Properties/launchSettings.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "http://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, @@ -24,7 +24,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "swagger", - "applicationUrl": "https://localhost:7036;http://localhost:5179", + "applicationUrl": "https://localhost:7170;http://localhost:5179", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } From 7867a56528ac987eeff6668a4746ef010ab34f3c Mon Sep 17 00:00:00 2001 From: Ivan K Date: Wed, 18 Feb 2026 13:00:47 +0400 Subject: [PATCH 03/26] =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 202 ++++++++++++++++++++---------------------------------- 1 file changed, 76 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index dcaa5eb7..9bfc9539 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,78 @@ -# Современные технологии разработки программного обеспечения -[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1an43o-iqlq4V_kDtkr_y7DC221hY9qdhGPrpII27sH8/edit?usp=sharing) +# Лабораторная работа №1 - "Кэширование" + +**Вариант**: №9 - "Кредитная заявка" + +**Выполнил**: Куненков Иван, группа 6511 + +**Предметная область**: Генерация кредитных заявок + + +## Реализованный функционал + +### Основные возможности: +- **Генерация кредитных заявок** с реалистичными данными (Bogus) +- **Интеллектное кэширование** в Redis с TTL 10 минут +- **REST API** для получения заявок +- **Blazor WebAssembly клиент** для работы с API +- **Структурное логирование** через OpenTelemetry +- **Мониторинг в реальном времени** через Aspire Dashboard + +## 🏗️ Архитектура + +``` +┌─────────────────────────────────────┐ +│ Client.Wasm (Blazor WASM) │ ← Пользовательский интерфейс +│ - Форма ввода ID │ +│ - Отображение данных │ +└──────────────┬──────────────────────┘ + │ HTTPS + CORS + ↓ +┌─────────────────────────────────────┐ +│ CreditApp.Api (ASP.NET Core) │ ← REST API +│ GET /api/credit?id={id} │ +│ - Проверка кэша │ +│ - Генерация (Bogus) │ +│ - Структурное логирование │ +└──────────────┬──────────────────────┘ + │ IDistributedCache + ↓ +┌─────────────────────────────────────┐ +│ Redis (Docker) │ ← Кэш +│ TTL: 10 минут │ +└─────────────────────────────────────┘ + ↑ + ┌──────────┴──────────┐ + │ Aspire AppHost │ ← Оркестрация + └─────────────────────┘ +``` + +## 📁 Структура проекта + +``` +cloud-development/ +├── CreditApp.AppHost/ # Aspire orchestrator +│ └── Program.cs # Конфигурация оркестрации +├── CreditApp.ServiceDefaults/ # Общие настройки +│ └── Extensions.cs # OpenTelemetry, health checks +├── CreditApp.Api/ # REST API +│ ├── Controllers/ +│ │ └── CreditController.cs # GET /api/credit?id={id} +│ ├── Services/ +│ │ └── CreditGeneratorService/ +│ │ ├── ICreditApplicationGeneratorService.cs +│ │ └── CreditApplicationGeneratorService.cs +│ └── Program.cs # Конфигурация (Redis, CORS, логирование) +├── CreditApp.Domain/ # Модели данных +│ └── Entities/ +│ └── CreditApplication.cs +├── Client.Wasm/ # Blazor WASM клиент +│ ├── Components/ +│ │ ├── DataCard.razor # UI для запроса заявок +│ │ └── StudentCard.razor # Информация о студенте +│ └── wwwroot/ +│ └── appsettings.json # Конфигурация API endpoint +├── screenshots/ # Скриншоты приложения +└── README.md # Этот файл +``` -## Задание -### Цель -Реализация проекта микросервисного бекенда. - -### Задачи -* Реализация межсервисной коммуникации, -* Изучение работы с брокерами сообщений, -* Изучение архитектурных паттернов, -* Изучение работы со средствами оркестрации на примере .NET Aspire, -* Повторение основ работы с системами контроля версий, -* Интеграционное тестирование. - -### Лабораторные работы -
-1. «Кэширование» - Реализация сервиса генерации контрактов, кэширование его ответов -
- -В рамках первой лабораторной работы необходимо: -* Реализовать сервис генерации контрактов на основе Bogus, -* Реализовать кеширование при помощи IDistributedCache и Redis, -* Реализовать структурное логирование сервиса генерации, -* Настроить оркестрацию Aspire. - -
-
-2. «Балансировка нагрузки» - Реализация апи гейтвея, настройка его работы -
- -В рамках второй лабораторной работы необходимо: -* Настроить оркестрацию на запуск нескольких реплик сервиса генерации, -* Реализовать апи гейтвей на основе Ocelot, -* Имплементировать алгоритм балансировки нагрузки согласно варианту. - -
-
-
-3. «Интеграционное тестирование» - Реализация файлового сервиса и объектного хранилища, интеграционное тестирование бекенда -
- -В рамках третьей лабораторной работы необходимо: -* Добавить в оркестрацию объектное хранилище, -* Реализовать файловый сервис, сериализующий сгенерированные данные в файлы и сохраняющий их в объектном хранилище, -* Реализовать отправку генерируемых данных в файловый сервис посредством брокера, -* Реализовать интеграционные тесты, проверяющие корректность работы всех сервисов бекенда вместе. - -
-
-
-4. (Опционально) «Переход на облачную инфраструктуру» - Перенос бекенда в Yandex Cloud -
- -В рамках четвертой лабораторной работы необходимо перенестиервисы на облако все ранее разработанные сервисы: -* Клиент - в хостинг через отдельный бакет Object Storage, -* Сервис генерации - в Cloud Function, -* Апи гейтвей - в Serverless Integration как API Gateway, -* Брокер сообщений - в Message Queue, -* Файловый сервис - в Cloud Function, -* Объектное хранилище - в отдельный бакет Object Storage, - -
-
- -## Задание. Общая часть -**Обязательно**: -* Реализация серверной части на [.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). From af0760941d8f2d6620065932a5783cadaf401879 Mon Sep 17 00:00:00 2001 From: razzzenya <113578593+razzzenya@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:01:50 +0400 Subject: [PATCH 04/26] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=81=D0=BA=D1=80=D0=B8=D0=BD=D1=8B=20=D0=BA=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 9bfc9539..13249e89 100644 --- a/README.md +++ b/README.md @@ -75,4 +75,7 @@ cloud-development/ └── README.md # Этот файл ``` +![aspire](https://github.com/user-attachments/assets/8eae0229-1476-43ce-92e9-7d00023edfa4) +![client](https://github.com/user-attachments/assets/78d9db61-05f4-4896-8e77-1e9cb79dcf67) +![logs](https://github.com/user-attachments/assets/eb133b16-da58-47b5-8f11-e74f656977dd) From c496c33c1fb4675119c9d15b3efec44b1942bce8 Mon Sep 17 00:00:00 2001 From: Ivan K Date: Wed, 18 Feb 2026 15:39:36 +0400 Subject: [PATCH 05/26] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CreditApp.Api/Controllers/CreditController.cs | 5 +- CreditApp.Api/CreditApp.Api.csproj | 2 + CreditApp.Api/CreditApp.Api.http | 6 -- CreditApp.Api/Program.cs | 25 ++++++- .../CreditApplicationGeneratorService.cs | 74 ++++++++++--------- .../ICreditApplicationGeneratorService.cs | 14 ---- CreditApp.Api/appsettings.Development.json | 4 + CreditApp.Api/appsettings.json | 5 +- CreditApp.AppHost/Program.cs | 6 +- CreditApp.Domain/CreditApp.Domain.csproj | 4 +- .../CreditApp.ServiceDefaults.csproj | 14 ++-- 11 files changed, 88 insertions(+), 71 deletions(-) delete mode 100644 CreditApp.Api/CreditApp.Api.http delete mode 100644 CreditApp.Api/Services/CreditGeneratorService/ICreditApplicationGeneratorService.cs diff --git a/CreditApp.Api/Controllers/CreditController.cs b/CreditApp.Api/Controllers/CreditController.cs index 83dbc602..e4f61a12 100644 --- a/CreditApp.Api/Controllers/CreditController.cs +++ b/CreditApp.Api/Controllers/CreditController.cs @@ -1,11 +1,12 @@ using CreditApp.Api.Services.CreditGeneratorService; +using CreditApp.Domain.Entities; using Microsoft.AspNetCore.Mvc; namespace CreditApp.Api.Controllers; [Route("api/[controller]")] [ApiController] -public class CreditController(ICreditApplicationGeneratorService _generatorService, ILogger _logger) : ControllerBase +public class CreditController(CreditApplicationGeneratorService _generatorService, ILogger _logger) : ControllerBase { /// /// Получить кредитную заявку по ID, если не найдена в кэше генерируем новую @@ -14,7 +15,7 @@ public class CreditController(ICreditApplicationGeneratorService _generatorServi /// Токен отмены операции /// Кредитная заявка [HttpGet] - public async Task GetById([FromQuery] int id, CancellationToken cancellationToken) + public async Task> GetById([FromQuery] int id, CancellationToken cancellationToken) { _logger.LogInformation("Получен запрос на получение/генерацию заявки {Id}", id); diff --git a/CreditApp.Api/CreditApp.Api.csproj b/CreditApp.Api/CreditApp.Api.csproj index f59f1598..a7ce3f4f 100644 --- a/CreditApp.Api/CreditApp.Api.csproj +++ b/CreditApp.Api/CreditApp.Api.csproj @@ -4,6 +4,8 @@ net8.0 enable enable + true + $(NoWarn);1591 diff --git a/CreditApp.Api/CreditApp.Api.http b/CreditApp.Api/CreditApp.Api.http deleted file mode 100644 index d979e231..00000000 --- a/CreditApp.Api/CreditApp.Api.http +++ /dev/null @@ -1,6 +0,0 @@ -@CreditApp.Api_HostAddress = http://localhost:5179 - -GET {{CreditApp.Api_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/CreditApp.Api/Program.cs b/CreditApp.Api/Program.cs index 840fb217..ea5690ad 100644 --- a/CreditApp.Api/Program.cs +++ b/CreditApp.Api/Program.cs @@ -22,16 +22,33 @@ }); }); -builder.Services.AddScoped(); +builder.Services.AddScoped(); -// Add controllers builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo + { + Title = "CreditApp API" + }); + + var xmlFilename = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFilename); + if (File.Exists(xmlPath)) + { + options.IncludeXmlComments(xmlPath); + } + + var domainXmlPath = Path.Combine(AppContext.BaseDirectory, "CreditApp.Domain.xml"); + if (File.Exists(domainXmlPath)) + { + options.IncludeXmlComments(domainXmlPath); + } +}); var app = builder.Build(); -// Configure the HTTP request pipeline if (app.Environment.IsDevelopment()) { app.UseSwagger(); diff --git a/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs b/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs index 772cac46..65e98063 100644 --- a/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs +++ b/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs @@ -5,7 +5,7 @@ namespace CreditApp.Api.Services.CreditGeneratorService; -public class CreditApplicationGeneratorService(IDistributedCache _cache, ILogger _logger) : ICreditApplicationGeneratorService +public class CreditApplicationGeneratorService(IDistributedCache _cache, IConfiguration _configuration, ILogger _logger) { private static readonly string[] _creditTypes = [ @@ -34,17 +34,27 @@ public async Task GetByIdAsync(int id, CancellationToken canc var cachedData = await _cache.GetStringAsync(cacheKey, cancellationToken); - if (cachedData != null) + if (!string.IsNullOrEmpty(cachedData)) { - _logger.LogInformation("Заявка {Id} найдена в кэше", id); - return JsonSerializer.Deserialize(cachedData)!; + var deserializedApplication = JsonSerializer.Deserialize(cachedData); + + if (deserializedApplication != null) + { + _logger.LogInformation("Заявка {Id} найдена в кэше", id); + return deserializedApplication; + } + + _logger.LogWarning("Заявка {Id} найдена в кэше, но не удалось десериализовать. Генерируем новую", id); } _logger.LogInformation("Заявка {Id} не найдена в кэше, генерируем новую", id); + var application = GenerateApplication(id); + + var expirationMinutes = _configuration.GetValue("CacheSettings:ExpirationMinutes", 10); var cacheOptions = new DistributedCacheEntryOptions { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(expirationMinutes) }; await _cache.SetStringAsync( @@ -74,38 +84,34 @@ private static CreditApplication GenerateApplication(int id) .RuleFor(c => c.Amount, f => Math.Round(f.Finance.Amount(10000, 10000000), 2)) .RuleFor(c => c.Term, f => f.Random.Int(6, 360)) .RuleFor(c => c.InterestRate, f => Math.Round(f.Random.Double(16.0, 25.0), 2)) - .RuleFor(c => c.SubmissionDate, f => DateOnly.FromDateTime( - f.Date.Between(DateTime.Now.AddYears(-2), DateTime.Now))) + .RuleFor(c => c.SubmissionDate, f => f.Date.PastDateOnly(2)) .RuleFor(c => c.RequiresInsurance, f => f.Random.Bool()) - .RuleFor(c => c.Status, f => f.PickRandom(_statuses)); - - var application = faker.Generate(); - - if (_terminalStatuses.Contains(application.Status)) - { - var submissionDate = application.SubmissionDate.ToDateTime(TimeOnly.MinValue); - var approvalDate = submissionDate.AddDays(new Random().Next(1, 60)); - - if (approvalDate > DateTime.Now) + .RuleFor(c => c.Status, f => f.PickRandom(_statuses)) + .RuleFor(c => c.ApprovalDate, (f, c) => { - approvalDate = DateTime.Now; - } - - application.ApprovalDate = DateOnly.FromDateTime(approvalDate); - } - - if (application.Status == "Одобрена") - { - var percentage = 0.8m + (decimal)new Random().NextDouble() * 0.3m; - var approvedAmount = application.Amount * percentage; - application.ApprovedAmount = Math.Round(approvedAmount, 2); - - if (application.ApprovedAmount > application.Amount) + if (!_terminalStatuses.Contains(c.Status)) + return null; + + var submissionDateTime = c.SubmissionDate.ToDateTime(TimeOnly.MinValue); + var daysAfterSubmission = f.Random.Int(1, 60); + var approvalDateTime = submissionDateTime.AddDays(daysAfterSubmission); + + if (approvalDateTime > DateTime.Now) + approvalDateTime = DateTime.Now; + + return DateOnly.FromDateTime(approvalDateTime); + }) + .RuleFor(c => c.ApprovedAmount, (f, c) => { - application.ApprovedAmount = application.Amount; - } - } + if (c.Status != "Одобрена") + return null; + + var percentage = f.Random.Decimal(0.7m, 1.0m); + var approvedAmount = c.Amount * percentage; + + return Math.Round(approvedAmount, 2); + }); - return application; + return faker.Generate(); } } diff --git a/CreditApp.Api/Services/CreditGeneratorService/ICreditApplicationGeneratorService.cs b/CreditApp.Api/Services/CreditGeneratorService/ICreditApplicationGeneratorService.cs deleted file mode 100644 index da5e8ea5..00000000 --- a/CreditApp.Api/Services/CreditGeneratorService/ICreditApplicationGeneratorService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CreditApp.Domain.Entities; - -namespace CreditApp.Api.Services.CreditGeneratorService; - -public interface ICreditApplicationGeneratorService -{ - /// - /// Получить заявку по ID, если не найдена в кэше генерируем новую с указанным ID - /// - /// ID кредитной заявки - /// Токен отмены операции - /// Кредитная заявка - public Task GetByIdAsync(int id, CancellationToken cancellationToken = default); -} diff --git a/CreditApp.Api/appsettings.Development.json b/CreditApp.Api/appsettings.Development.json index 0c208ae9..b642d7aa 100644 --- a/CreditApp.Api/appsettings.Development.json +++ b/CreditApp.Api/appsettings.Development.json @@ -4,5 +4,9 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "AllowedHosts": "*", + "CacheSettings": { + "ExpirationMinutes": 10 } } diff --git a/CreditApp.Api/appsettings.json b/CreditApp.Api/appsettings.json index 10f68b8c..b642d7aa 100644 --- a/CreditApp.Api/appsettings.json +++ b/CreditApp.Api/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "CacheSettings": { + "ExpirationMinutes": 10 + } } diff --git a/CreditApp.AppHost/Program.cs b/CreditApp.AppHost/Program.cs index 5853fa24..440938e8 100644 --- a/CreditApp.AppHost/Program.cs +++ b/CreditApp.AppHost/Program.cs @@ -3,9 +3,11 @@ var redis = builder.AddRedis("cache"); var api = builder.AddProject("creditapp-api") - .WithReference(redis); + .WithReference(redis) + .WaitFor(redis); builder.AddProject("client") - .WithReference(api); + .WithReference(api) + .WaitFor(api); builder.Build().Run(); diff --git a/CreditApp.Domain/CreditApp.Domain.csproj b/CreditApp.Domain/CreditApp.Domain.csproj index fa71b7ae..fcfb2654 100644 --- a/CreditApp.Domain/CreditApp.Domain.csproj +++ b/CreditApp.Domain/CreditApp.Domain.csproj @@ -1,9 +1,11 @@ - + net8.0 enable enable + true + $(NoWarn);1591 diff --git a/CreditApp.ServiceDefaults/CreditApp.ServiceDefaults.csproj b/CreditApp.ServiceDefaults/CreditApp.ServiceDefaults.csproj index 6a65bdf6..bf9f33a7 100644 --- a/CreditApp.ServiceDefaults/CreditApp.ServiceDefaults.csproj +++ b/CreditApp.ServiceDefaults/CreditApp.ServiceDefaults.csproj @@ -8,13 +8,13 @@ - - - - - - - + + + + + + + From ab4a3a3ba844b01eb344ac89e187c8b93ab6560a Mon Sep 17 00:00:00 2001 From: Ivan K Date: Wed, 18 Feb 2026 16:07:22 +0400 Subject: [PATCH 06/26] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CloudDevelopment.sln | 16 ++++++++-------- CreditApp.Api/CreditApp.Api.csproj | 6 +++--- CreditApp.Api/Program.cs | 2 +- CreditApp.AppHost/CreditApp.AppHost.csproj | 10 +++++++--- CreditApp.AppHost/Program.cs | 2 ++ CreditApp.AppHost/Properties/launchSettings.json | 14 ++++++-------- 6 files changed, 27 insertions(+), 23 deletions(-) diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index c625a56f..0ec1df73 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,17 +1,17 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 18 -VisualStudioVersion = 18.3.11505.172 +# Visual Studio Version 17 +VisualStudioVersion = 17.13.35931.197 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}") = "CreditApp.AppHost", "CreditApp.AppHost\CreditApp.AppHost.csproj", "{E2DE4A09-990C-4B72-A09D-CCE1C10AE9DD}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.Api", "CreditApp.Api\CreditApp.Api.csproj", "{E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.Domain", "CreditApp.Domain\CreditApp.Domain.csproj", "{CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.ServiceDefaults", "CreditApp.ServiceDefaults\CreditApp.ServiceDefaults.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.AppHost", "CreditApp.AppHost\CreditApp.AppHost.csproj", "{2A5FB573-9376-4FEB-9289-A8387F435C13}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -22,10 +22,6 @@ 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 - {E2DE4A09-990C-4B72-A09D-CCE1C10AE9DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E2DE4A09-990C-4B72-A09D-CCE1C10AE9DD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E2DE4A09-990C-4B72-A09D-CCE1C10AE9DD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E2DE4A09-990C-4B72-A09D-CCE1C10AE9DD}.Release|Any CPU.Build.0 = Release|Any CPU {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Debug|Any CPU.Build.0 = Debug|Any CPU {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -38,6 +34,10 @@ Global {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU + {2A5FB573-9376-4FEB-9289-A8387F435C13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A5FB573-9376-4FEB-9289-A8387F435C13}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A5FB573-9376-4FEB-9289-A8387F435C13}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A5FB573-9376-4FEB-9289-A8387F435C13}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CreditApp.Api/CreditApp.Api.csproj b/CreditApp.Api/CreditApp.Api.csproj index a7ce3f4f..a5d07fa2 100644 --- a/CreditApp.Api/CreditApp.Api.csproj +++ b/CreditApp.Api/CreditApp.Api.csproj @@ -9,11 +9,11 @@ - + - - + + diff --git a/CreditApp.Api/Program.cs b/CreditApp.Api/Program.cs index ea5690ad..54ae7a1f 100644 --- a/CreditApp.Api/Program.cs +++ b/CreditApp.Api/Program.cs @@ -28,7 +28,7 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => { - options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo + options.SwaggerDoc("v1", new Microsoft.OpenApi.OpenApiInfo { Title = "CreditApp API" }); diff --git a/CreditApp.AppHost/CreditApp.AppHost.csproj b/CreditApp.AppHost/CreditApp.AppHost.csproj index 71a48b0e..82456303 100644 --- a/CreditApp.AppHost/CreditApp.AppHost.csproj +++ b/CreditApp.AppHost/CreditApp.AppHost.csproj @@ -1,15 +1,19 @@ - + + + Exe net8.0 enable enable - 70e4a3f7-c299-47f6-9d51-144f31ab779b + true + b8f3eae0-771a-4f3a-8df3-ef0a21b09b55 - + + diff --git a/CreditApp.AppHost/Program.cs b/CreditApp.AppHost/Program.cs index 440938e8..670b6512 100644 --- a/CreditApp.AppHost/Program.cs +++ b/CreditApp.AppHost/Program.cs @@ -1,3 +1,5 @@ +using Aspire.Hosting; + var builder = DistributedApplication.CreateBuilder(args); var redis = builder.AddRedis("cache"); diff --git a/CreditApp.AppHost/Properties/launchSettings.json b/CreditApp.AppHost/Properties/launchSettings.json index 2bcd68bf..fa890ea9 100644 --- a/CreditApp.AppHost/Properties/launchSettings.json +++ b/CreditApp.AppHost/Properties/launchSettings.json @@ -5,26 +5,24 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "https://localhost:17062;http://localhost:15086", + "applicationUrl": "https://localhost:17214;http://localhost:15105", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21219", - "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23298", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22237" + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21185", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22273" } }, "http": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "http://localhost:15086", + "applicationUrl": "http://localhost:15105", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19243", - "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18213", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20039" + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19190", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20136" } } } From edf7c1587c15f5cd6ca013f2f956a137fe3f30b4 Mon Sep 17 00:00:00 2001 From: Ivan K Date: Thu, 19 Feb 2026 20:14:21 +0400 Subject: [PATCH 07/26] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CreditApp.Api/Controllers/CreditController.cs | 4 +- CreditApp.Api/CreditApp.Api.csproj | 3 +- CreditApp.Api/Program.cs | 11 +--- .../CreditApplicationGeneratorService.cs | 65 ++++++++----------- CreditApp.AppHost/Program.cs | 5 +- .../Entities/CreditApplication.cs | 8 +-- 6 files changed, 40 insertions(+), 56 deletions(-) diff --git a/CreditApp.Api/Controllers/CreditController.cs b/CreditApp.Api/Controllers/CreditController.cs index e4f61a12..7ae575f6 100644 --- a/CreditApp.Api/Controllers/CreditController.cs +++ b/CreditApp.Api/Controllers/CreditController.cs @@ -18,9 +18,9 @@ public class CreditController(CreditApplicationGeneratorService _generatorServic public async Task> GetById([FromQuery] int id, CancellationToken cancellationToken) { _logger.LogInformation("Получен запрос на получение/генерацию заявки {Id}", id); - + var application = await _generatorService.GetByIdAsync(id, cancellationToken); - + return Ok(application); } } diff --git a/CreditApp.Api/CreditApp.Api.csproj b/CreditApp.Api/CreditApp.Api.csproj index a5d07fa2..8b0828af 100644 --- a/CreditApp.Api/CreditApp.Api.csproj +++ b/CreditApp.Api/CreditApp.Api.csproj @@ -9,10 +9,9 @@ - + - diff --git a/CreditApp.Api/Program.cs b/CreditApp.Api/Program.cs index 54ae7a1f..99a77687 100644 --- a/CreditApp.Api/Program.cs +++ b/CreditApp.Api/Program.cs @@ -5,12 +5,7 @@ builder.AddServiceDefaults(); -builder.AddRedisClient("cache"); -builder.Services.AddStackExchangeRedisCache(options => -{ - var connectionString = builder.Configuration.GetConnectionString("cache"); - options.Configuration = connectionString; -}); +builder.AddRedisDistributedCache("cache"); builder.Services.AddCors(options => { @@ -32,14 +27,14 @@ { Title = "CreditApp API" }); - + var xmlFilename = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFilename); if (File.Exists(xmlPath)) { options.IncludeXmlComments(xmlPath); } - + var domainXmlPath = Path.Combine(AppContext.BaseDirectory, "CreditApp.Domain.xml"); if (File.Exists(domainXmlPath)) { diff --git a/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs b/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs index 65e98063..104b5e02 100644 --- a/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs +++ b/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs @@ -7,67 +7,68 @@ namespace CreditApp.Api.Services.CreditGeneratorService; public class CreditApplicationGeneratorService(IDistributedCache _cache, IConfiguration _configuration, ILogger _logger) { - private static readonly string[] _creditTypes = + private static readonly string[] _creditTypes = [ - "Потребительский", - "Ипотека", - "Автокредит", + "Потребительский", + "Ипотека", + "Автокредит", "Бизнес-кредит", "Образовательный" ]; - private static readonly string[] _statuses = + private static readonly string[] _statuses = [ - "Новая", - "В обработке", - "Одобрена", + "Новая", + "В обработке", + "Одобрена", "Отклонена" ]; private static readonly string[] _terminalStatuses = ["Одобрена", "Отклонена"]; + private readonly int _expirationMinutes = _configuration.GetValue("CacheSettings:ExpirationMinutes", 10); + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) { var cacheKey = $"credit-application-{id}"; - + _logger.LogInformation("Попытка получить заявку {Id} из кэша", id); var cachedData = await _cache.GetStringAsync(cacheKey, cancellationToken); - + if (!string.IsNullOrEmpty(cachedData)) { var deserializedApplication = JsonSerializer.Deserialize(cachedData); - + if (deserializedApplication != null) { _logger.LogInformation("Заявка {Id} найдена в кэше", id); return deserializedApplication; } - + _logger.LogWarning("Заявка {Id} найдена в кэше, но не удалось десериализовать. Генерируем новую", id); } _logger.LogInformation("Заявка {Id} не найдена в кэше, генерируем новую", id); - + var application = GenerateApplication(id); - - var expirationMinutes = _configuration.GetValue("CacheSettings:ExpirationMinutes", 10); + var cacheOptions = new DistributedCacheEntryOptions { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(expirationMinutes) + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_expirationMinutes) }; - + await _cache.SetStringAsync( - cacheKey, - JsonSerializer.Serialize(application), + cacheKey, + JsonSerializer.Serialize(application), cacheOptions, cancellationToken); _logger.LogInformation( - "Кредитная заявка сгенерирована и закэширована: Id={Id}, Тип={Type}, Сумма={Amount}, Статус={Status}", - application.Id, - application.Type, - application.Amount, + "Кредитная заявка сгенерирована и закэширована: Id={Id}, Тип={Type}, Сумма={Amount}, Статус={Status}", + application.Id, + application.Type, + application.Amount, application.Status); return application; @@ -91,25 +92,15 @@ private static CreditApplication GenerateApplication(int id) { if (!_terminalStatuses.Contains(c.Status)) return null; - - var submissionDateTime = c.SubmissionDate.ToDateTime(TimeOnly.MinValue); - var daysAfterSubmission = f.Random.Int(1, 60); - var approvalDateTime = submissionDateTime.AddDays(daysAfterSubmission); - - if (approvalDateTime > DateTime.Now) - approvalDateTime = DateTime.Now; - - return DateOnly.FromDateTime(approvalDateTime); + + return f.Date.BetweenDateOnly(c.SubmissionDate, DateOnly.FromDateTime(DateTime.Today)); }) .RuleFor(c => c.ApprovedAmount, (f, c) => { if (c.Status != "Одобрена") return null; - - var percentage = f.Random.Decimal(0.7m, 1.0m); - var approvedAmount = c.Amount * percentage; - - return Math.Round(approvedAmount, 2); + + return Math.Round(c.Amount * f.Random.Decimal(0.7m, 1.0m), 2); }); return faker.Generate(); diff --git a/CreditApp.AppHost/Program.cs b/CreditApp.AppHost/Program.cs index 670b6512..5f785502 100644 --- a/CreditApp.AppHost/Program.cs +++ b/CreditApp.AppHost/Program.cs @@ -1,8 +1,7 @@ -using Aspire.Hosting; - var builder = DistributedApplication.CreateBuilder(args); -var redis = builder.AddRedis("cache"); +var redis = builder.AddRedis("cache") + .WithRedisCommander(); var api = builder.AddProject("creditapp-api") .WithReference(redis) diff --git a/CreditApp.Domain/Entities/CreditApplication.cs b/CreditApp.Domain/Entities/CreditApplication.cs index ca40117f..20e32b91 100644 --- a/CreditApp.Domain/Entities/CreditApplication.cs +++ b/CreditApp.Domain/Entities/CreditApplication.cs @@ -1,4 +1,4 @@ -namespace CreditApp.Domain.Entities; +namespace CreditApp.Domain.Entities; /// /// Кредитная заявка @@ -8,11 +8,11 @@ public class CreditApplication /// /// Идентификатор в системе /// - public int Id { get; set; } + public required int Id { get; set; } /// /// Тип кредита /// - public string Type { get; set; } = String.Empty; + public required string Type { get; set; } /// /// Запрашиваемая сумма /// @@ -36,7 +36,7 @@ public class CreditApplication /// /// Статус заявки /// - public string Status { get; set; } = String.Empty; + public required string Status { get; set; } /// /// Дата решения /// From 9c915c19fb28aff469ccd5227c1aec5f5e7a6ff2 Mon Sep 17 00:00:00 2001 From: Ivan K Date: Thu, 19 Feb 2026 20:47:13 +0400 Subject: [PATCH 08/26] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/wwwroot/appsettings.json | 2 +- CreditApp.Api/CreditApp.Api.csproj | 2 +- CreditApp.AppHost/CreditApp.AppHost.csproj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index 3f506eb9..839235ae 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "https://localhost:7170/api/credit" + "BaseAddress": "" } \ No newline at end of file diff --git a/CreditApp.Api/CreditApp.Api.csproj b/CreditApp.Api/CreditApp.Api.csproj index 8b0828af..527de1a7 100644 --- a/CreditApp.Api/CreditApp.Api.csproj +++ b/CreditApp.Api/CreditApp.Api.csproj @@ -9,7 +9,7 @@ - + diff --git a/CreditApp.AppHost/CreditApp.AppHost.csproj b/CreditApp.AppHost/CreditApp.AppHost.csproj index 82456303..8d2bc5dd 100644 --- a/CreditApp.AppHost/CreditApp.AppHost.csproj +++ b/CreditApp.AppHost/CreditApp.AppHost.csproj @@ -1,6 +1,6 @@ - + Exe From 2a542d6b58e1aac6b70f7bebe17f45e5c2fd8893 Mon Sep 17 00:00:00 2001 From: Ivan K Date: Thu, 19 Feb 2026 20:48:42 +0400 Subject: [PATCH 09/26] =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BD=D1=83=D0=BB=20ba?= =?UTF-8?q?se=20address?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/wwwroot/appsettings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index d1fe7ab3..3f506eb9 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" -} + "BaseAddress": "https://localhost:7170/api/credit" +} \ No newline at end of file From 3737e0cbb514dda9c1146874719858a962267cdb Mon Sep 17 00:00:00 2001 From: Ivan K Date: Thu, 19 Feb 2026 21:23:41 +0400 Subject: [PATCH 10/26] =?UTF-8?q?=D0=BE=D0=B1=D0=B5=D1=80=D0=BD=D1=83?= =?UTF-8?q?=D0=BB=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B8=D1=81=D0=B0=20=D0=B2=20try-catch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreditApplicationGeneratorService.cs | 74 ++++++++++--------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs b/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs index 104b5e02..f894c301 100644 --- a/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs +++ b/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs @@ -30,48 +30,56 @@ public class CreditApplicationGeneratorService(IDistributedCache _cache, IConfig public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) { - var cacheKey = $"credit-application-{id}"; - - _logger.LogInformation("Попытка получить заявку {Id} из кэша", id); + try + { + var cacheKey = $"credit-application-{id}"; - var cachedData = await _cache.GetStringAsync(cacheKey, cancellationToken); + _logger.LogInformation("Попытка получить заявку {Id} из кэша", id); - if (!string.IsNullOrEmpty(cachedData)) - { - var deserializedApplication = JsonSerializer.Deserialize(cachedData); + var cachedData = await _cache.GetStringAsync(cacheKey, cancellationToken); - if (deserializedApplication != null) + if (!string.IsNullOrEmpty(cachedData)) { - _logger.LogInformation("Заявка {Id} найдена в кэше", id); - return deserializedApplication; - } + var deserializedApplication = JsonSerializer.Deserialize(cachedData); - _logger.LogWarning("Заявка {Id} найдена в кэше, но не удалось десериализовать. Генерируем новую", id); - } + if (deserializedApplication != null) + { + _logger.LogInformation("Заявка {Id} найдена в кэше", id); + return deserializedApplication; + } - _logger.LogInformation("Заявка {Id} не найдена в кэше, генерируем новую", id); + _logger.LogWarning("Заявка {Id} найдена в кэше, но не удалось десериализовать. Генерируем новую", id); + } + + _logger.LogInformation("Заявка {Id} не найдена в кэше, генерируем новую", id); - var application = GenerateApplication(id); + var application = GenerateApplication(id); - var cacheOptions = new DistributedCacheEntryOptions + var cacheOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_expirationMinutes) + }; + + await _cache.SetStringAsync( + cacheKey, + JsonSerializer.Serialize(application), + cacheOptions, + cancellationToken); + + _logger.LogInformation( + "Кредитная заявка сгенерирована и закэширована: Id={Id}, Тип={Type}, Сумма={Amount}, Статус={Status}", + application.Id, + application.Type, + application.Amount, + application.Status); + + return application; + } + catch (Exception ex) { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_expirationMinutes) - }; - - await _cache.SetStringAsync( - cacheKey, - JsonSerializer.Serialize(application), - cacheOptions, - cancellationToken); - - _logger.LogInformation( - "Кредитная заявка сгенерирована и закэширована: Id={Id}, Тип={Type}, Сумма={Amount}, Статус={Status}", - application.Id, - application.Type, - application.Amount, - application.Status); - - return application; + _logger.LogError(ex, "Ошибка при получении/генерации заявки {Id}", id); + throw; + } } /// From cfde70f7af0a8f75f4cd51f2c64e1aa303374c49 Mon Sep 17 00:00:00 2001 From: Ivan K Date: Tue, 3 Mar 2026 17:11:23 +0400 Subject: [PATCH 11/26] =?UTF-8?q?=D0=BB=D1=80=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/Components/StudentCard.razor | 2 +- Client.Wasm/wwwroot/appsettings.json | 2 +- CloudDevelopment.sln | 10 ++- .../CreditApp.ApiGateway.csproj | 19 +++++ .../CreditApp.ApiGateway.http | 6 ++ .../WeightedRoundRobinBalancer.cs | 62 ++++++++++++++++ CreditApp.ApiGateway/Program.cs | 74 +++++++++++++++++++ .../Properties/launchSettings.json | 41 ++++++++++ .../appsettings.Development.json | 8 ++ CreditApp.ApiGateway/appsettings.json | 20 +++++ CreditApp.ApiGateway/ocelot.json | 30 ++++++++ CreditApp.AppHost/CreditApp.AppHost.csproj | 3 +- CreditApp.AppHost/Program.cs | 8 +- 13 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 CreditApp.ApiGateway/CreditApp.ApiGateway.csproj create mode 100644 CreditApp.ApiGateway/CreditApp.ApiGateway.http create mode 100644 CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs create mode 100644 CreditApp.ApiGateway/Program.cs create mode 100644 CreditApp.ApiGateway/Properties/launchSettings.json create mode 100644 CreditApp.ApiGateway/appsettings.Development.json create mode 100644 CreditApp.ApiGateway/appsettings.json create mode 100644 CreditApp.ApiGateway/ocelot.json diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 55f96c65..626f7ecb 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,7 +4,7 @@ - Номер №1 Кэширование" + Номер №2 Балансировка нагрузки" Вариант №9 "Кредитная заявка" Выполнена Куненковым Иваном 6511 Ссылка на форк diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index 3f506eb9..9d6811c0 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "https://localhost:7170/api/credit" + "BaseAddress": "https://localhost:7138/api/credit" } \ No newline at end of file diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index 0ec1df73..e22e1e5a 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.13.35931.197 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11520.95 d18.3 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}" EndProject @@ -12,6 +12,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.ServiceDefaults", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.AppHost", "CreditApp.AppHost\CreditApp.AppHost.csproj", "{2A5FB573-9376-4FEB-9289-A8387F435C13}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.ApiGateway", "CreditApp.ApiGateway\CreditApp.ApiGateway.csproj", "{7F3E754C-DA95-9AEF-13A7-05AEE66771F8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -38,6 +40,10 @@ Global {2A5FB573-9376-4FEB-9289-A8387F435C13}.Debug|Any CPU.Build.0 = Debug|Any CPU {2A5FB573-9376-4FEB-9289-A8387F435C13}.Release|Any CPU.ActiveCfg = Release|Any CPU {2A5FB573-9376-4FEB-9289-A8387F435C13}.Release|Any CPU.Build.0 = Release|Any CPU + {7F3E754C-DA95-9AEF-13A7-05AEE66771F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F3E754C-DA95-9AEF-13A7-05AEE66771F8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F3E754C-DA95-9AEF-13A7-05AEE66771F8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F3E754C-DA95-9AEF-13A7-05AEE66771F8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CreditApp.ApiGateway/CreditApp.ApiGateway.csproj b/CreditApp.ApiGateway/CreditApp.ApiGateway.csproj new file mode 100644 index 00000000..37c86876 --- /dev/null +++ b/CreditApp.ApiGateway/CreditApp.ApiGateway.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/CreditApp.ApiGateway/CreditApp.ApiGateway.http b/CreditApp.ApiGateway/CreditApp.ApiGateway.http new file mode 100644 index 00000000..6c1e968d --- /dev/null +++ b/CreditApp.ApiGateway/CreditApp.ApiGateway.http @@ -0,0 +1,6 @@ +@CreditApp.ApiGateway_HostAddress = http://localhost:5062 + +GET {{CreditApp.ApiGateway_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs new file mode 100644 index 00000000..fba2f5f0 --- /dev/null +++ b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs @@ -0,0 +1,62 @@ +using Ocelot.LoadBalancer.LoadBalancers; +using Ocelot.Responses; +using Ocelot.Values; + +namespace CreditApp.ApiGateway.LoadBalancing; + +/// +/// Weighted Round Robin балансировщик нагрузки для Ocelot +/// +public class WeightedRoundRobinLoadBalancer(Func>> servicesProvider, Dictionary hostPortWeights) : ILoadBalancer +{ + private int _currentIndex = -1; + private int _currentWeight = 0; + private readonly object _lock = new(); + + public async Task> Lease(HttpContext httpContext) + { + var services = await servicesProvider(); + + if (services == null || services.Count == 0) + { + return new ErrorResponse( + new ServicesAreEmptyError("No services available")); + } + + lock (_lock) + { + var maxWeight = hostPortWeights.Values + .Select(w => (int)Math.Ceiling(w)) + .DefaultIfEmpty(1) + .Max(); + + while (true) + { + _currentIndex = (_currentIndex + 1) % services.Count; + + if (_currentIndex == 0) + { + _currentWeight--; + if (_currentWeight <= 0) + { + _currentWeight = maxWeight; + } + } + + var service = services[_currentIndex]; + var hostPort = $"{service.HostAndPort.DownstreamHost}:{service.HostAndPort.DownstreamPort}"; + + var weight = hostPortWeights.TryGetValue(hostPort, out var w) ? w : 1.0; + + if ((int)Math.Ceiling(weight) >= _currentWeight) + { + return new OkResponse(service.HostAndPort); + } + } + } + } + + public void Release(ServiceHostAndPort hostAndPort) + { + } +} diff --git a/CreditApp.ApiGateway/Program.cs b/CreditApp.ApiGateway/Program.cs new file mode 100644 index 00000000..8b85ebc1 --- /dev/null +++ b/CreditApp.ApiGateway/Program.cs @@ -0,0 +1,74 @@ +using CreditApp.ApiGateway.LoadBalancing; +using CreditApp.ServiceDefaults; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Ocelot.DependencyInjection; +using Ocelot.Middleware; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); + +var generatorNames = builder.Configuration.GetSection("GeneratorServices").Get() ?? []; +var serviceWeights = builder.Configuration + .GetSection("ReplicaWeights") + .Get>() ?? []; + +var addressOverrides = new List>(); +var hostPortWeights = new Dictionary(); + +for (var i = 0; i < generatorNames.Length; i++) +{ + var name = generatorNames[i]; + var url = builder.Configuration[$"services:{name}:https:0"]; + + string resolvedHost, resolvedPort; + if (!string.IsNullOrEmpty(url) && Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + resolvedHost = uri.Host; + resolvedPort = uri.Port.ToString(); + addressOverrides.Add(new($"Routes:0:DownstreamHostAndPorts:{i}:Host", resolvedHost)); + addressOverrides.Add(new($"Routes:0:DownstreamHostAndPorts:{i}:Port", resolvedPort)); + } + else + { + resolvedHost = builder.Configuration[$"Routes:0:DownstreamHostAndPorts:{i}:Host"] ?? "localhost"; + resolvedPort = builder.Configuration[$"Routes:0:DownstreamHostAndPorts:{i}:Port"] ?? "0"; + } + + if (serviceWeights.TryGetValue(name, out var weight)) + { + hostPortWeights[$"{resolvedHost}:{resolvedPort}"] = weight; + } +} + +if (addressOverrides.Count > 0) + builder.Configuration.AddInMemoryCollection(addressOverrides); + +builder.Services + .AddOcelot(builder.Configuration) + .AddCustomLoadBalancer((route, serviceDiscovery) => + new WeightedRoundRobinLoadBalancer( + async () => await serviceDiscovery.GetAsync(), + hostPortWeights)); + +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowBlazorWasm", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); +app.UseHealthChecks("/health"); +app.UseHealthChecks("/alive", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); +app.UseCors("AllowBlazorWasm"); +await app.UseOcelot(); + +app.Run(); \ No newline at end of file diff --git a/CreditApp.ApiGateway/Properties/launchSettings.json b/CreditApp.ApiGateway/Properties/launchSettings.json new file mode 100644 index 00000000..567125a0 --- /dev/null +++ b/CreditApp.ApiGateway/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:10503", + "sslPort": 44371 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5062", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7138;http://localhost:5062", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/CreditApp.ApiGateway/appsettings.Development.json b/CreditApp.ApiGateway/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/CreditApp.ApiGateway/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/CreditApp.ApiGateway/appsettings.json b/CreditApp.ApiGateway/appsettings.json new file mode 100644 index 00000000..b1ea0f0e --- /dev/null +++ b/CreditApp.ApiGateway/appsettings.json @@ -0,0 +1,20 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "CreditApp.ApiGateway.LoadBalancing": "Debug" + } + }, + "AllowedHosts": "*", + "GeneratorServices": [ + "creditapp-api-0", + "creditapp-api-1", + "creditapp-api-2" + ], + "ReplicaWeights": { + "creditapp-api-0": 5.0, + "creditapp-api-1": 3.0, + "creditapp-api-2": 2.0 + } +} diff --git a/CreditApp.ApiGateway/ocelot.json b/CreditApp.ApiGateway/ocelot.json new file mode 100644 index 00000000..836dfb51 --- /dev/null +++ b/CreditApp.ApiGateway/ocelot.json @@ -0,0 +1,30 @@ +{ + "Routes": [ + { + "DownstreamPathTemplate": "/api/credit", + "DownstreamScheme": "https", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 7170 + }, + { + "Host": "localhost", + "Port": 7171 + }, + { + "Host": "localhost", + "Port": 7172 + } + ], + "UpstreamPathTemplate": "/api/credit", + "UpstreamHttpMethod": [ "Get" ], + "LoadBalancerOptions": { + "Type": "WeightedRoundRobin" + } + } + ], + "GlobalConfiguration": { + "BaseUrl": "https://localhost:7138" + } +} diff --git a/CreditApp.AppHost/CreditApp.AppHost.csproj b/CreditApp.AppHost/CreditApp.AppHost.csproj index 8d2bc5dd..edca2fa5 100644 --- a/CreditApp.AppHost/CreditApp.AppHost.csproj +++ b/CreditApp.AppHost/CreditApp.AppHost.csproj @@ -1,4 +1,4 @@ - + @@ -18,6 +18,7 @@ + diff --git a/CreditApp.AppHost/Program.cs b/CreditApp.AppHost/Program.cs index 5f785502..30beecfe 100644 --- a/CreditApp.AppHost/Program.cs +++ b/CreditApp.AppHost/Program.cs @@ -5,10 +5,14 @@ var api = builder.AddProject("creditapp-api") .WithReference(redis) + .WithReplicas(3) .WaitFor(redis); +var gateway = builder.AddProject("creditapp-apigateway") + .WithReference(api); + builder.AddProject("client") - .WithReference(api) - .WaitFor(api); + .WithReference(gateway) + .WaitFor(gateway); builder.Build().Run(); From db062123a42df8904252f4882467fab2d433d3cd Mon Sep 17 00:00:00 2001 From: Ivan K Date: Tue, 3 Mar 2026 17:28:58 +0400 Subject: [PATCH 12/26] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CreditApp.ApiGateway/ocelot.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/CreditApp.ApiGateway/ocelot.json b/CreditApp.ApiGateway/ocelot.json index 836dfb51..b7ab4c5d 100644 --- a/CreditApp.ApiGateway/ocelot.json +++ b/CreditApp.ApiGateway/ocelot.json @@ -18,10 +18,7 @@ } ], "UpstreamPathTemplate": "/api/credit", - "UpstreamHttpMethod": [ "Get" ], - "LoadBalancerOptions": { - "Type": "WeightedRoundRobin" - } + "UpstreamHttpMethod": [ "Get" ] } ], "GlobalConfiguration": { From a4d969e176927cf19874cd4f32e6b607864ed7c7 Mon Sep 17 00:00:00 2001 From: Ivan K Date: Tue, 3 Mar 2026 17:40:26 +0400 Subject: [PATCH 13/26] =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20README.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 120 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 72 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 13249e89..8d194214 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ -# Лабораторная работа №1 - "Кэширование" +# Лабораторная работа №2 - "Балансировка нагрузки" -**Вариант**: №9 - "Кредитная заявка" +**Вариант**: №9 - "Кредитная заявка" +**Алгоритм балансировки**: Weighted Round Robin **Выполнил**: Куненков Иван, группа 6511 @@ -10,72 +11,95 @@ ## Реализованный функционал ### Основные возможности: +- **API Gateway** на базе Ocelot с балансировкой нагрузки +- **Weighted Round Robin** балансировщик с весами 5:3:2 +- **3 реплики API сервиса** через Aspire orchestration - **Генерация кредитных заявок** с реалистичными данными (Bogus) -- **Интеллектное кэширование** в Redis с TTL 10 минут -- **REST API** для получения заявок -- **Blazor WebAssembly клиент** для работы с API +- **Общий Redis кэш** для всех реплик с TTL 10 минут +- **Blazor WebAssembly клиент** для работы через Gateway - **Структурное логирование** через OpenTelemetry -- **Мониторинг в реальном времени** через Aspire Dashboard +- **Мониторинг балансировки** в реальном времени через Aspire Dashboard ## 🏗️ Архитектура ``` -┌─────────────────────────────────────┐ -│ Client.Wasm (Blazor WASM) │ ← Пользовательский интерфейс -│ - Форма ввода ID │ -│ - Отображение данных │ -└──────────────┬──────────────────────┘ - │ HTTPS + CORS - ↓ -┌─────────────────────────────────────┐ -│ CreditApp.Api (ASP.NET Core) │ ← REST API -│ GET /api/credit?id={id} │ -│ - Проверка кэша │ -│ - Генерация (Bogus) │ -│ - Структурное логирование │ -└──────────────┬──────────────────────┘ - │ IDistributedCache - ↓ -┌─────────────────────────────────────┐ -│ Redis (Docker) │ ← Кэш -│ TTL: 10 минут │ -└─────────────────────────────────────┘ - ↑ - ┌──────────┴──────────┐ - │ Aspire AppHost │ ← Оркестрация - └─────────────────────┘ +┌──────────────────────────────────────┐ +│ Client.Wasm (Blazor WebAssembly) │ ← Пользовательский интерфейс +│ - Форма ввода ID │ +│ - Отображение данных заявки │ +└───────────────┬──────────────────────┘ + │ HTTPS + ↓ +┌──────────────────────────────────────┐ +│ CreditApp.ApiGateway (Ocelot) │ ← API Gateway +│ - Weighted Round Robin (5:3:2) │ +│ - Маршрутизация запросов │ +│ - Структурное логирование │ +└───────────────┬──────────────────────┘ + │ + ┌───────┼───────────┐ + ↓ ↓ ↓ +┌─────────┐ ┌─────────┐ ┌─────────┐ +│ API-0 │ │ API-1 │ │ API-2 │ ← 3 реплики API +│ (вес 5) │ │ (вес 3) │ │ (вес 2) │ +│ :7170 │ │ :7171 │ │ :7172 │ +└────┬────┘ └────┬────┘ └────┬────┘ + │ │ │ + └───────────┴───────────┘ + ↓ + ┌─────────────────┐ + │ Redis Cache │ ← Общий кэш + │ TTL: 10 минут │ + │ + Commander │ + └─────────────────┘ + ↑ + ┌────────┴────────┐ + │ Aspire AppHost │ ← Оркестрация + │ + Dashboard │ + └─────────────────┘ ``` ## 📁 Структура проекта ``` cloud-development/ -├── CreditApp.AppHost/ # Aspire orchestrator -│ └── Program.cs # Конфигурация оркестрации -├── CreditApp.ServiceDefaults/ # Общие настройки -│ └── Extensions.cs # OpenTelemetry, health checks -├── CreditApp.Api/ # REST API +├── CreditApp.AppHost/ # 🎯 Aspire orchestrator +│ └── Program.cs # Конфигурация: 3 реплики API + Gateway +│ +├── CreditApp.ApiGateway/ # 🌐 API Gateway (Лаб. №2) +│ ├── LoadBalancing/ +│ │ └── WeightedRoundRobinLoadBalancer.cs # Алгоритм балансировки +│ ├── Program.cs # Ocelot + Service Discovery +│ ├── ocelot.json # Конфигурация маршрутов +│ └── appsettings.json # Имена сервисов и веса +│ +├── CreditApp.Api/ # 🔧 REST API (3 реплики) │ ├── Controllers/ -│ │ └── CreditController.cs # GET /api/credit?id={id} +│ │ └── CreditController.cs # GET /api/credit?id={id} │ ├── Services/ │ │ └── CreditGeneratorService/ -│ │ ├── ICreditApplicationGeneratorService.cs -│ │ └── CreditApplicationGeneratorService.cs -│ └── Program.cs # Конфигурация (Redis, CORS, логирование) -├── CreditApp.Domain/ # Модели данных +│ │ └── CreditApplicationGeneratorService.cs # Генерация + кэш +│ └── Program.cs # Конфигурация (Redis, CORS, Swagger) +│ +├── CreditApp.ServiceDefaults/ # ⚙️ Общие настройки +│ └── Extensions.cs # OpenTelemetry, health checks +│ +├── CreditApp.Domain/ # 📦 Модели данных │ └── Entities/ -│ └── CreditApplication.cs -├── Client.Wasm/ # Blazor WASM клиент +│ └── CreditApplication.cs # Модель кредитной заявки +│ +├── Client.Wasm/ # 💻 Blazor WASM клиент │ ├── Components/ -│ │ ├── DataCard.razor # UI для запроса заявок -│ │ └── StudentCard.razor # Информация о студенте +│ │ ├── DataCard.razor # UI для запроса заявок +│ │ └── StudentCard.razor # Информация о студенте │ └── wwwroot/ -│ └── appsettings.json # Конфигурация API endpoint -├── screenshots/ # Скриншоты приложения -└── README.md # Этот файл +│ └── appsettings.json # Адрес Gateway +└── 📄 README.md # Этот файл ``` + +## 📸 Скриншоты + ![aspire](https://github.com/user-attachments/assets/8eae0229-1476-43ce-92e9-7d00023edfa4) ![client](https://github.com/user-attachments/assets/78d9db61-05f4-4896-8e77-1e9cb79dcf67) ![logs](https://github.com/user-attachments/assets/eb133b16-da58-47b5-8f11-e74f656977dd) - From b199d1c4b3c59bf42045df19dfa7486cf46e6f2f Mon Sep 17 00:00:00 2001 From: razzzenya <113578593+razzzenya@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:47:10 +0400 Subject: [PATCH 14/26] Update README.md --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8d194214..0bd25b2a 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,8 @@ cloud-development/ ``` -## 📸 Скриншоты +## 📸 Скриншоты! -![aspire](https://github.com/user-attachments/assets/8eae0229-1476-43ce-92e9-7d00023edfa4) -![client](https://github.com/user-attachments/assets/78d9db61-05f4-4896-8e77-1e9cb79dcf67) -![logs](https://github.com/user-attachments/assets/eb133b16-da58-47b5-8f11-e74f656977dd) +![aspire](https://github.com/user-attachments/assets/c49d5de0-0afb-4105-b9da-bbc5fe76c9da) +![client](https://github.com/user-attachments/assets/8d4dd124-9589-4562-b421-d0e914a0cc8a) +![logs](https://github.com/user-attachments/assets/120d9b27-d140-429a-b574-31c1c3c0e092) From 6998c3da49072da5b7e5ea5462063d59db065e54 Mon Sep 17 00:00:00 2001 From: Ivan K Date: Tue, 3 Mar 2026 18:07:25 +0400 Subject: [PATCH 15/26] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D1=89=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WeightedRoundRobinBalancer.cs | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs index fba2f5f0..39df1745 100644 --- a/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs +++ b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs @@ -5,12 +5,12 @@ namespace CreditApp.ApiGateway.LoadBalancing; /// -/// Weighted Round Robin балансировщик нагрузки для Ocelot +/// Weighted Round Robin балансировщик нагрузки для Ocelot. /// public class WeightedRoundRobinLoadBalancer(Func>> servicesProvider, Dictionary hostPortWeights) : ILoadBalancer { - private int _currentIndex = -1; - private int _currentWeight = 0; + private int _currentIndex = 0; + private int _remainingRequests = 0; private readonly object _lock = new(); public async Task> Lease(HttpContext httpContext) @@ -23,40 +23,36 @@ public async Task> Lease(HttpContext httpContext) new ServicesAreEmptyError("No services available")); } + ServiceHostAndPort selectedService; + double selectedWeight; + int selectedIndex; + lock (_lock) { - var maxWeight = hostPortWeights.Values - .Select(w => (int)Math.Ceiling(w)) - .DefaultIfEmpty(1) - .Max(); - - while (true) + if (_remainingRequests <= 0) { _currentIndex = (_currentIndex + 1) % services.Count; - if (_currentIndex == 0) - { - _currentWeight--; - if (_currentWeight <= 0) - { - _currentWeight = maxWeight; - } - } - var service = services[_currentIndex]; var hostPort = $"{service.HostAndPort.DownstreamHost}:{service.HostAndPort.DownstreamPort}"; var weight = hostPortWeights.TryGetValue(hostPort, out var w) ? w : 1.0; - - if ((int)Math.Ceiling(weight) >= _currentWeight) - { - return new OkResponse(service.HostAndPort); - } + _remainingRequests = (int)Math.Ceiling(weight); } + + _remainingRequests--; + + var currentService = services[_currentIndex]; + var currentHostPort = $"{currentService.HostAndPort.DownstreamHost}:{currentService.HostAndPort.DownstreamPort}"; + + selectedService = currentService.HostAndPort; + selectedWeight = hostPortWeights.TryGetValue(currentHostPort, out var currentWeight) ? currentWeight : 1.0; + selectedIndex = _currentIndex; } + return new OkResponse(selectedService); } public void Release(ServiceHostAndPort hostAndPort) { } -} +} \ No newline at end of file From 5235fc08722419b744533a781df6f487da5c86b8 Mon Sep 17 00:00:00 2001 From: Ivan K Date: Tue, 3 Mar 2026 19:29:35 +0400 Subject: [PATCH 16/26] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=B1=D0=BE=D0=BA=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B?= =?UTF-8?q?=20=D0=B1=D0=B0=D0=BB=D0=B0=D0=BD=D1=81=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D1=89=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreditApp.ApiGateway.csproj | 2 +- .../WeightedRoundRobinBalancer.cs | 34 ++++++++++--------- CreditApp.ApiGateway/Program.cs | 8 +++-- CreditApp.ApiGateway/ocelot.json | 5 ++- CreditApp.AppHost/Program.cs | 18 ++++++++-- 5 files changed, 43 insertions(+), 24 deletions(-) diff --git a/CreditApp.ApiGateway/CreditApp.ApiGateway.csproj b/CreditApp.ApiGateway/CreditApp.ApiGateway.csproj index 37c86876..122cdeee 100644 --- a/CreditApp.ApiGateway/CreditApp.ApiGateway.csproj +++ b/CreditApp.ApiGateway/CreditApp.ApiGateway.csproj @@ -8,7 +8,7 @@ - + diff --git a/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs index 39df1745..184fb28d 100644 --- a/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs +++ b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs @@ -1,31 +1,37 @@ -using Ocelot.LoadBalancer.LoadBalancers; +using Microsoft.Extensions.Logging; +using Ocelot.Errors; +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Middleware; using Ocelot.Responses; using Ocelot.Values; namespace CreditApp.ApiGateway.LoadBalancing; +public class NoServicesAvailableError(string message) : Error(message, OcelotErrorCode.UnableToCompleteRequestError, 503) +{ +} + /// /// Weighted Round Robin балансировщик нагрузки для Ocelot. /// public class WeightedRoundRobinLoadBalancer(Func>> servicesProvider, Dictionary hostPortWeights) : ILoadBalancer { - private int _currentIndex = 0; - private int _remainingRequests = 0; - private readonly object _lock = new(); + private static int _currentIndex = -1; + private static int _remainingRequests = 0; + private static readonly object _lock = new(); - public async Task> Lease(HttpContext httpContext) - { + public string Type => "WeightedRoundRobin"; + + public async Task> LeaseAsync(HttpContext httpContext) + { var services = await servicesProvider(); if (services == null || services.Count == 0) { return new ErrorResponse( - new ServicesAreEmptyError("No services available")); + new NoServicesAvailableError("No services available")); } - ServiceHostAndPort selectedService; - double selectedWeight; - int selectedIndex; lock (_lock) { @@ -40,14 +46,10 @@ public async Task> Lease(HttpContext httpContext) _remainingRequests = (int)Math.Ceiling(weight); } - _remainingRequests--; - var currentService = services[_currentIndex]; - var currentHostPort = $"{currentService.HostAndPort.DownstreamHost}:{currentService.HostAndPort.DownstreamPort}"; - selectedService = currentService.HostAndPort; - selectedWeight = hostPortWeights.TryGetValue(currentHostPort, out var currentWeight) ? currentWeight : 1.0; - selectedIndex = _currentIndex; + + _remainingRequests--; } return new OkResponse(selectedService); } diff --git a/CreditApp.ApiGateway/Program.cs b/CreditApp.ApiGateway/Program.cs index 8b85ebc1..369cf9b6 100644 --- a/CreditApp.ApiGateway/Program.cs +++ b/CreditApp.ApiGateway/Program.cs @@ -48,10 +48,12 @@ builder.Services .AddOcelot(builder.Configuration) - .AddCustomLoadBalancer((route, serviceDiscovery) => - new WeightedRoundRobinLoadBalancer( + .AddCustomLoadBalancer((serviceProvider, route, serviceDiscovery) => + { + return new WeightedRoundRobinLoadBalancer( async () => await serviceDiscovery.GetAsync(), - hostPortWeights)); + hostPortWeights); + }); builder.Services.AddCors(options => { diff --git a/CreditApp.ApiGateway/ocelot.json b/CreditApp.ApiGateway/ocelot.json index b7ab4c5d..33aa931e 100644 --- a/CreditApp.ApiGateway/ocelot.json +++ b/CreditApp.ApiGateway/ocelot.json @@ -18,7 +18,10 @@ } ], "UpstreamPathTemplate": "/api/credit", - "UpstreamHttpMethod": [ "Get" ] + "UpstreamHttpMethod": [ "Get" ], + "LoadBalancerOptions": { + "Type": "WeightedRoundRobinLoadBalancer" + } } ], "GlobalConfiguration": { diff --git a/CreditApp.AppHost/Program.cs b/CreditApp.AppHost/Program.cs index 30beecfe..a00ad289 100644 --- a/CreditApp.AppHost/Program.cs +++ b/CreditApp.AppHost/Program.cs @@ -3,13 +3,25 @@ var redis = builder.AddRedis("cache") .WithRedisCommander(); -var api = builder.AddProject("creditapp-api") +var api0 = builder.AddProject("creditapp-api-0") .WithReference(redis) - .WithReplicas(3) + .WithEndpoint("https", endpoint => endpoint.Port = 7170) + .WaitFor(redis); + +var api1 = builder.AddProject("creditapp-api-1") + .WithReference(redis) + .WithEndpoint("https", endpoint => endpoint.Port = 7171) + .WaitFor(redis); + +var api2 = builder.AddProject("creditapp-api-2") + .WithReference(redis) + .WithEndpoint("https", endpoint => endpoint.Port = 7172) .WaitFor(redis); var gateway = builder.AddProject("creditapp-apigateway") - .WithReference(api); + .WithReference(api0) + .WithReference(api1) + .WithReference(api2); builder.AddProject("client") .WithReference(gateway) From 201a3f7109a43d103cc963e82ade60280972d129 Mon Sep 17 00:00:00 2001 From: Ivan K Date: Tue, 3 Mar 2026 19:40:44 +0400 Subject: [PATCH 17/26] code cleanup --- .../LoadBalancing/WeightedRoundRobinBalancer.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs index 184fb28d..3864708f 100644 --- a/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs +++ b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs @@ -1,7 +1,5 @@ -using Microsoft.Extensions.Logging; using Ocelot.Errors; using Ocelot.LoadBalancer.Interfaces; -using Ocelot.Middleware; using Ocelot.Responses; using Ocelot.Values; @@ -23,7 +21,7 @@ public class WeightedRoundRobinLoadBalancer(Func>> servicesPr public string Type => "WeightedRoundRobin"; public async Task> LeaseAsync(HttpContext httpContext) - { + { var services = await servicesProvider(); if (services == null || services.Count == 0) @@ -48,7 +46,7 @@ public async Task> LeaseAsync(HttpContext httpConte var currentService = services[_currentIndex]; selectedService = currentService.HostAndPort; - + _remainingRequests--; } return new OkResponse(selectedService); From 6e6c40683f11cfc7110cc33192baef6d6d52783e Mon Sep 17 00:00:00 2001 From: Ivan K Date: Wed, 4 Mar 2026 18:37:57 +0400 Subject: [PATCH 18/26] =?UTF-8?q?=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BA=D0=BE=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CreditApp.ApiGateway/CreditApp.ApiGateway.http | 6 ------ .../LoadBalancing/WeightedRoundRobinBalancer.cs | 11 +++-------- CreditApp.ApiGateway/Program.cs | 4 +--- 3 files changed, 4 insertions(+), 17 deletions(-) delete mode 100644 CreditApp.ApiGateway/CreditApp.ApiGateway.http diff --git a/CreditApp.ApiGateway/CreditApp.ApiGateway.http b/CreditApp.ApiGateway/CreditApp.ApiGateway.http deleted file mode 100644 index 6c1e968d..00000000 --- a/CreditApp.ApiGateway/CreditApp.ApiGateway.http +++ /dev/null @@ -1,6 +0,0 @@ -@CreditApp.ApiGateway_HostAddress = http://localhost:5062 - -GET {{CreditApp.ApiGateway_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs index 3864708f..efdf6792 100644 --- a/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs +++ b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs @@ -1,14 +1,11 @@ using Ocelot.Errors; +using Ocelot.LoadBalancer.Errors; using Ocelot.LoadBalancer.Interfaces; using Ocelot.Responses; using Ocelot.Values; namespace CreditApp.ApiGateway.LoadBalancing; -public class NoServicesAvailableError(string message) : Error(message, OcelotErrorCode.UnableToCompleteRequestError, 503) -{ -} - /// /// Weighted Round Robin балансировщик нагрузки для Ocelot. /// @@ -27,7 +24,7 @@ public async Task> LeaseAsync(HttpContext httpConte if (services == null || services.Count == 0) { return new ErrorResponse( - new NoServicesAvailableError("No services available")); + new ServicesAreEmptyError("No services available")); } ServiceHostAndPort selectedService; @@ -52,7 +49,5 @@ public async Task> LeaseAsync(HttpContext httpConte return new OkResponse(selectedService); } - public void Release(ServiceHostAndPort hostAndPort) - { - } + public void Release(ServiceHostAndPort hostAndPort) {} } \ No newline at end of file diff --git a/CreditApp.ApiGateway/Program.cs b/CreditApp.ApiGateway/Program.cs index 369cf9b6..594e0834 100644 --- a/CreditApp.ApiGateway/Program.cs +++ b/CreditApp.ApiGateway/Program.cs @@ -50,9 +50,7 @@ .AddOcelot(builder.Configuration) .AddCustomLoadBalancer((serviceProvider, route, serviceDiscovery) => { - return new WeightedRoundRobinLoadBalancer( - async () => await serviceDiscovery.GetAsync(), - hostPortWeights); + return new WeightedRoundRobinLoadBalancer(serviceDiscovery.GetAsync, hostPortWeights); }); builder.Services.AddCors(options => From be00daf9685b9c2921b0ffdabee92320389a1dcb Mon Sep 17 00:00:00 2001 From: Ivan K Date: Wed, 4 Mar 2026 18:46:59 +0400 Subject: [PATCH 19/26] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D1=83=20=D0=B1?= =?UTF-8?q?=D0=B0=D0=BB=D0=B0=D0=BD=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D1=89?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WeightedRoundRobinBalancer.cs | 27 ++++++++++++++----- CreditApp.ApiGateway/Program.cs | 4 +-- CreditApp.ApiGateway/appsettings.json | 6 ++--- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs index efdf6792..0929fe4c 100644 --- a/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs +++ b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs @@ -1,4 +1,3 @@ -using Ocelot.Errors; using Ocelot.LoadBalancer.Errors; using Ocelot.LoadBalancer.Interfaces; using Ocelot.Responses; @@ -9,7 +8,7 @@ namespace CreditApp.ApiGateway.LoadBalancing; /// /// Weighted Round Robin балансировщик нагрузки для Ocelot. /// -public class WeightedRoundRobinLoadBalancer(Func>> servicesProvider, Dictionary hostPortWeights) : ILoadBalancer +public class WeightedRoundRobinLoadBalancer(Func>> servicesProvider, Dictionary hostPortWeights) : ILoadBalancer { private static int _currentIndex = -1; private static int _remainingRequests = 0; @@ -26,22 +25,38 @@ public async Task> LeaseAsync(HttpContext httpConte return new ErrorResponse( new ServicesAreEmptyError("No services available")); } + + var availableServices = services + .Where(s => + { + var hostPort = $"{s.HostAndPort.DownstreamHost}:{s.HostAndPort.DownstreamPort}"; + var weight = hostPortWeights.TryGetValue(hostPort, out var w) ? w : 1.0; + return weight > 0; + }) + .ToList(); + + if (availableServices.Count == 0) + { + return new ErrorResponse( + new ServicesAreEmptyError("No services with positive weight available")); + } + ServiceHostAndPort selectedService; lock (_lock) { if (_remainingRequests <= 0) { - _currentIndex = (_currentIndex + 1) % services.Count; + _currentIndex = (_currentIndex + 1) % availableServices.Count; - var service = services[_currentIndex]; + var service = availableServices[_currentIndex]; var hostPort = $"{service.HostAndPort.DownstreamHost}:{service.HostAndPort.DownstreamPort}"; var weight = hostPortWeights.TryGetValue(hostPort, out var w) ? w : 1.0; - _remainingRequests = (int)Math.Ceiling(weight); + _remainingRequests = Math.Max(1, (int)Math.Ceiling(weight)); } - var currentService = services[_currentIndex]; + var currentService = availableServices[_currentIndex]; selectedService = currentService.HostAndPort; _remainingRequests--; diff --git a/CreditApp.ApiGateway/Program.cs b/CreditApp.ApiGateway/Program.cs index 594e0834..ca6526f6 100644 --- a/CreditApp.ApiGateway/Program.cs +++ b/CreditApp.ApiGateway/Program.cs @@ -13,10 +13,10 @@ var generatorNames = builder.Configuration.GetSection("GeneratorServices").Get() ?? []; var serviceWeights = builder.Configuration .GetSection("ReplicaWeights") - .Get>() ?? []; + .Get>() ?? []; var addressOverrides = new List>(); -var hostPortWeights = new Dictionary(); +var hostPortWeights = new Dictionary(); for (var i = 0; i < generatorNames.Length; i++) { diff --git a/CreditApp.ApiGateway/appsettings.json b/CreditApp.ApiGateway/appsettings.json index b1ea0f0e..a2b5064a 100644 --- a/CreditApp.ApiGateway/appsettings.json +++ b/CreditApp.ApiGateway/appsettings.json @@ -13,8 +13,8 @@ "creditapp-api-2" ], "ReplicaWeights": { - "creditapp-api-0": 5.0, - "creditapp-api-1": 3.0, - "creditapp-api-2": 2.0 + "creditapp-api-0": 5, + "creditapp-api-1": 3, + "creditapp-api-2": 2 } } From 892fe8bbdec8b07d128d0794c8e34522ae2f9b93 Mon Sep 17 00:00:00 2001 From: Ivan K Date: Wed, 4 Mar 2026 18:57:18 +0400 Subject: [PATCH 20/26] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D0=BB=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=88=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LoadBalancing/WeightedRoundRobinBalancer.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs index 0929fe4c..ab7cf644 100644 --- a/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs +++ b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs @@ -30,7 +30,7 @@ public async Task> LeaseAsync(HttpContext httpConte .Where(s => { var hostPort = $"{s.HostAndPort.DownstreamHost}:{s.HostAndPort.DownstreamPort}"; - var weight = hostPortWeights.TryGetValue(hostPort, out var w) ? w : 1.0; + var weight = hostPortWeights.TryGetValue(hostPort, out var w) ? w : 1; return weight > 0; }) .ToList(); @@ -52,8 +52,8 @@ public async Task> LeaseAsync(HttpContext httpConte var service = availableServices[_currentIndex]; var hostPort = $"{service.HostAndPort.DownstreamHost}:{service.HostAndPort.DownstreamPort}"; - var weight = hostPortWeights.TryGetValue(hostPort, out var w) ? w : 1.0; - _remainingRequests = Math.Max(1, (int)Math.Ceiling(weight)); + var weight = hostPortWeights.TryGetValue(hostPort, out var w) ? w : 1; + _remainingRequests = weight; } var currentService = availableServices[_currentIndex]; From 82be54cd2d1da1024d9cec6105deb2048681d3b1 Mon Sep 17 00:00:00 2001 From: Ivan K Date: Sun, 15 Mar 2026 15:45:12 +0400 Subject: [PATCH 21/26] =?UTF-8?q?3-=D1=8F=20=D0=BB/=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/Components/StudentCard.razor | 2 +- CloudDevelopment.sln | 14 +++++- CreditApp.AppHost/CreditApp.AppHost.csproj | 1 + CreditApp.AppHost/Program.cs | 2 + .../Controllers/WeatherForecastController.cs | 9 ++++ .../CreditApp.FileService.csproj | 18 +++++++ CreditApp.FileService/Program.cs | 31 ++++++++++++ .../Properties/launchSettings.json | 41 ++++++++++++++++ .../appsettings.Development.json | 8 ++++ CreditApp.FileService/appsettings.json | 9 ++++ CreditApp.Test/CreditApp.Test.csproj | 27 +++++++++++ CreditApp.Test/IntegrationTests.cs | 48 +++++++++++++++++++ 12 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 CreditApp.FileService/Controllers/WeatherForecastController.cs create mode 100644 CreditApp.FileService/CreditApp.FileService.csproj create mode 100644 CreditApp.FileService/Program.cs create mode 100644 CreditApp.FileService/Properties/launchSettings.json create mode 100644 CreditApp.FileService/appsettings.Development.json create mode 100644 CreditApp.FileService/appsettings.json create mode 100644 CreditApp.Test/CreditApp.Test.csproj create mode 100644 CreditApp.Test/IntegrationTests.cs diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 626f7ecb..5c9d6666 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,7 +4,7 @@ - Номер №2 Балансировка нагрузки" + Номер №3 Интеграционное тестирование" Вариант №9 "Кредитная заявка" Выполнена Куненковым Иваном 6511 Ссылка на форк diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index e22e1e5a..053aa311 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.3.11520.95 d18.3 +VisualStudioVersion = 18.3.11520.95 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}" EndProject @@ -14,6 +14,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.AppHost", "Credit EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.ApiGateway", "CreditApp.ApiGateway\CreditApp.ApiGateway.csproj", "{7F3E754C-DA95-9AEF-13A7-05AEE66771F8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.FileService", "CreditApp.FileService\CreditApp.FileService.csproj", "{9FB93C0A-039C-9B30-1599-38D087B50EBF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.Test", "CreditApp.Test\CreditApp.Test.csproj", "{37E47E85-F350-483F-9904-08F0D62D553D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -44,6 +48,14 @@ Global {7F3E754C-DA95-9AEF-13A7-05AEE66771F8}.Debug|Any CPU.Build.0 = Debug|Any CPU {7F3E754C-DA95-9AEF-13A7-05AEE66771F8}.Release|Any CPU.ActiveCfg = Release|Any CPU {7F3E754C-DA95-9AEF-13A7-05AEE66771F8}.Release|Any CPU.Build.0 = Release|Any CPU + {9FB93C0A-039C-9B30-1599-38D087B50EBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9FB93C0A-039C-9B30-1599-38D087B50EBF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9FB93C0A-039C-9B30-1599-38D087B50EBF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9FB93C0A-039C-9B30-1599-38D087B50EBF}.Release|Any CPU.Build.0 = Release|Any CPU + {37E47E85-F350-483F-9904-08F0D62D553D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {37E47E85-F350-483F-9904-08F0D62D553D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37E47E85-F350-483F-9904-08F0D62D553D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {37E47E85-F350-483F-9904-08F0D62D553D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CreditApp.AppHost/CreditApp.AppHost.csproj b/CreditApp.AppHost/CreditApp.AppHost.csproj index edca2fa5..e4731e1f 100644 --- a/CreditApp.AppHost/CreditApp.AppHost.csproj +++ b/CreditApp.AppHost/CreditApp.AppHost.csproj @@ -20,6 +20,7 @@ + diff --git a/CreditApp.AppHost/Program.cs b/CreditApp.AppHost/Program.cs index a00ad289..6b457b9d 100644 --- a/CreditApp.AppHost/Program.cs +++ b/CreditApp.AppHost/Program.cs @@ -27,4 +27,6 @@ .WithReference(gateway) .WaitFor(gateway); +builder.AddProject("creditapp-fileservice"); + builder.Build().Run(); diff --git a/CreditApp.FileService/Controllers/WeatherForecastController.cs b/CreditApp.FileService/Controllers/WeatherForecastController.cs new file mode 100644 index 00000000..e0c4d19e --- /dev/null +++ b/CreditApp.FileService/Controllers/WeatherForecastController.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc; + +namespace CreditApp.FileService.Controllers; + +[ApiController] +[Route("[controller]")] +public class WeatherForecastController : ControllerBase +{ +} diff --git a/CreditApp.FileService/CreditApp.FileService.csproj b/CreditApp.FileService/CreditApp.FileService.csproj new file mode 100644 index 00000000..c9fbba01 --- /dev/null +++ b/CreditApp.FileService/CreditApp.FileService.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/CreditApp.FileService/Program.cs b/CreditApp.FileService/Program.cs new file mode 100644 index 00000000..90336893 --- /dev/null +++ b/CreditApp.FileService/Program.cs @@ -0,0 +1,31 @@ +using CreditApp.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +// Add services to the container. + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/CreditApp.FileService/Properties/launchSettings.json b/CreditApp.FileService/Properties/launchSettings.json new file mode 100644 index 00000000..0a114df3 --- /dev/null +++ b/CreditApp.FileService/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:51966", + "sslPort": 44364 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5035", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7143;http://localhost:5035", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/CreditApp.FileService/appsettings.Development.json b/CreditApp.FileService/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/CreditApp.FileService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/CreditApp.FileService/appsettings.json b/CreditApp.FileService/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/CreditApp.FileService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/CreditApp.Test/CreditApp.Test.csproj b/CreditApp.Test/CreditApp.Test.csproj new file mode 100644 index 00000000..fc959262 --- /dev/null +++ b/CreditApp.Test/CreditApp.Test.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + diff --git a/CreditApp.Test/IntegrationTests.cs b/CreditApp.Test/IntegrationTests.cs new file mode 100644 index 00000000..e22684ef --- /dev/null +++ b/CreditApp.Test/IntegrationTests.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Logging; + +namespace CreditApp.Test; + +public class IntegrationTests +{ + // private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + + // Instructions: + // 1. Add a project reference to the target AppHost project, e.g.: + // + // + // + // + // + // 2. Uncomment the following example test and update 'Projects.MyAspireApp_AppHost' to match your AppHost project: + // + // [Fact] + // public async Task GetWebResourceRootReturnsOkStatusCode() + // { + // // Arrange + // var cancellationToken = TestContext.Current.CancellationToken; + // var appHost = await DistributedApplicationTestingBuilder.CreateAsync(cancellationToken); + // appHost.Services.AddLogging(logging => + // { + // logging.SetMinimumLevel(LogLevel.Debug); + // // Override the logging filters from the app's configuration + // logging.AddFilter(appHost.Environment.ApplicationName, LogLevel.Debug); + // logging.AddFilter("Aspire.", LogLevel.Debug); + // // To output logs to the xUnit.net ITestOutputHelper, consider adding a package from https://www.nuget.org/packages?q=xunit+logging + // }); + // appHost.Services.ConfigureHttpClientDefaults(clientBuilder => + // { + // clientBuilder.AddStandardResilienceHandler(); + // }); + // + // await using var app = await appHost.BuildAsync(cancellationToken).WaitAsync(DefaultTimeout, cancellationToken); + // await app.StartAsync(cancellationToken).WaitAsync(DefaultTimeout, cancellationToken); + // + // // Act + // using var httpClient = app.CreateHttpClient("webfrontend"); + // await app.ResourceNotifications.WaitForResourceHealthyAsync("webfrontend", cancellationToken).WaitAsync(DefaultTimeout, cancellationToken); + // using var response = await httpClient.GetAsync("/", cancellationToken); + // + // // Assert + // Assert.Equal(HttpStatusCode.OK, response.StatusCode); + // } +} From 66e27787f53601337edc4fe19499c3a5726c8a88 Mon Sep 17 00:00:00 2001 From: Ivan K Date: Sun, 15 Mar 2026 22:53:17 +0400 Subject: [PATCH 22/26] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20minio=20=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 9 + CloudDevelopment.sln | 14 +- CreditApp.Api/Controllers/CreditController.cs | 16 +- CreditApp.Api/CreditApp.Api.csproj | 2 + CreditApp.Api/Program.cs | 16 + .../CreditApplicationGeneratorService.cs | 104 ++++++- .../SnsPublisherService.cs | 79 +++++ CreditApp.Api/appsettings.Development.json | 9 + CreditApp.Api/appsettings.json | 12 + .../WeightedRoundRobinBalancer.cs | 2 +- CreditApp.AppHost/Program.cs | 41 ++- .../appsettings.Development.json | 4 + CreditApp.AppHost/appsettings.json | 4 + CreditApp.AppHost/localstack-init.sh | 32 ++ .../Configuration/MinioSettings.cs | 10 + .../Controllers/FilesController.cs | 46 +++ .../Controllers/NotificationController.cs | 113 +++++++ .../Controllers/WeatherForecastController.cs | 9 - .../CreditApp.FileService.csproj | 2 + CreditApp.FileService/Program.cs | 50 +++- .../Properties/launchSettings.json | 6 +- .../Services/MinioStorageService.cs | 114 +++++++ .../appsettings.Development.json | 7 + CreditApp.FileService/appsettings.json | 9 +- CreditApp.Test/CreditApp.Test.csproj | 21 +- CreditApp.Test/IntegrationTest.cs | 280 ++++++++++++++++++ CreditApp.Test/IntegrationTests.cs | 48 --- README.md | 52 +++- 28 files changed, 993 insertions(+), 118 deletions(-) create mode 100644 CreditApp.Api/Services/SnsPublisherService/SnsPublisherService.cs create mode 100644 CreditApp.AppHost/localstack-init.sh create mode 100644 CreditApp.FileService/Configuration/MinioSettings.cs create mode 100644 CreditApp.FileService/Controllers/FilesController.cs create mode 100644 CreditApp.FileService/Controllers/NotificationController.cs delete mode 100644 CreditApp.FileService/Controllers/WeatherForecastController.cs create mode 100644 CreditApp.FileService/Services/MinioStorageService.cs create mode 100644 CreditApp.Test/IntegrationTest.cs delete mode 100644 CreditApp.Test/IntegrationTests.cs diff --git a/.gitignore b/.gitignore index ce892922..1f85afbb 100644 --- a/.gitignore +++ b/.gitignore @@ -416,3 +416,12 @@ FodyWeavers.xsd *.msix *.msm *.msp + +# Docker volumes and data +minio-data/ +localstack-data/ +redis-data/ + +# Aspire generated files +*.dcproj.user +.aspire/ \ No newline at end of file diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index 053aa311..fdfc5cb7 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 18 -VisualStudioVersion = 18.3.11520.95 +# Visual Studio Version 17 +VisualStudioVersion = 17.13.35931.197 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}" EndProject @@ -16,7 +16,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.ApiGateway", "Cre EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.FileService", "CreditApp.FileService\CreditApp.FileService.csproj", "{9FB93C0A-039C-9B30-1599-38D087B50EBF}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.Test", "CreditApp.Test\CreditApp.Test.csproj", "{37E47E85-F350-483F-9904-08F0D62D553D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.Test", "CreditApp.Test\CreditApp.Test.csproj", "{F0BEC607-3BB6-43E6-BB44-C887447F7DCE}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -52,10 +52,10 @@ Global {9FB93C0A-039C-9B30-1599-38D087B50EBF}.Debug|Any CPU.Build.0 = Debug|Any CPU {9FB93C0A-039C-9B30-1599-38D087B50EBF}.Release|Any CPU.ActiveCfg = Release|Any CPU {9FB93C0A-039C-9B30-1599-38D087B50EBF}.Release|Any CPU.Build.0 = Release|Any CPU - {37E47E85-F350-483F-9904-08F0D62D553D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {37E47E85-F350-483F-9904-08F0D62D553D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {37E47E85-F350-483F-9904-08F0D62D553D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {37E47E85-F350-483F-9904-08F0D62D553D}.Release|Any CPU.Build.0 = Release|Any CPU + {F0BEC607-3BB6-43E6-BB44-C887447F7DCE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0BEC607-3BB6-43E6-BB44-C887447F7DCE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0BEC607-3BB6-43E6-BB44-C887447F7DCE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0BEC607-3BB6-43E6-BB44-C887447F7DCE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CreditApp.Api/Controllers/CreditController.cs b/CreditApp.Api/Controllers/CreditController.cs index 7ae575f6..5f472c7d 100644 --- a/CreditApp.Api/Controllers/CreditController.cs +++ b/CreditApp.Api/Controllers/CreditController.cs @@ -1,4 +1,5 @@ using CreditApp.Api.Services.CreditGeneratorService; +using CreditApp.Api.Services.SnsPublisherService; using CreditApp.Domain.Entities; using Microsoft.AspNetCore.Mvc; @@ -6,7 +7,7 @@ namespace CreditApp.Api.Controllers; [Route("api/[controller]")] [ApiController] -public class CreditController(CreditApplicationGeneratorService _generatorService, ILogger _logger) : ControllerBase +public class CreditController(CreditApplicationGeneratorService generatorService, SnsPublisherService snsPublisher, ILogger logger) : ControllerBase { /// /// Получить кредитную заявку по ID, если не найдена в кэше генерируем новую @@ -17,9 +18,18 @@ public class CreditController(CreditApplicationGeneratorService _generatorServic [HttpGet] public async Task> GetById([FromQuery] int id, CancellationToken cancellationToken) { - _logger.LogInformation("Получен запрос на получение/генерацию заявки {Id}", id); + logger.LogInformation("Получен запрос на получение/генерацию заявки {Id}", id); - var application = await _generatorService.GetByIdAsync(id, cancellationToken); + var (application, isNew) = await generatorService.GetByIdAsync(id, cancellationToken); + + if (isNew) + { + await snsPublisher.PublishCreditApplicationAsync(application, cancellationToken); + } + else + { + logger.LogInformation("Заявка {Id} получена из кэша, публикация в SNS пропущена", id); + } return Ok(application); } diff --git a/CreditApp.Api/CreditApp.Api.csproj b/CreditApp.Api/CreditApp.Api.csproj index 527de1a7..deb34a5d 100644 --- a/CreditApp.Api/CreditApp.Api.csproj +++ b/CreditApp.Api/CreditApp.Api.csproj @@ -10,6 +10,8 @@ + + diff --git a/CreditApp.Api/Program.cs b/CreditApp.Api/Program.cs index 99a77687..0512671f 100644 --- a/CreditApp.Api/Program.cs +++ b/CreditApp.Api/Program.cs @@ -1,4 +1,6 @@ +using Amazon.SimpleNotificationService; using CreditApp.Api.Services.CreditGeneratorService; +using CreditApp.Api.Services.SnsPublisherService; using CreditApp.ServiceDefaults; var builder = WebApplication.CreateBuilder(args); @@ -7,6 +9,20 @@ builder.AddRedisDistributedCache("cache"); +var awsOptions = new Amazon.Extensions.NETCore.Setup.AWSOptions +{ + DefaultClientConfig = + { + ServiceURL = builder.Configuration["AWS:ServiceURL"] + } +}; + +builder.Services.AddDefaultAWSOptions(awsOptions); +builder.Services.AddAWSService(); + +builder.Services.AddScoped(); + + builder.Services.AddCors(options => { options.AddPolicy("AllowBlazorWasm", policy => diff --git a/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs b/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs index f894c301..549cc11c 100644 --- a/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs +++ b/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs @@ -5,7 +5,7 @@ namespace CreditApp.Api.Services.CreditGeneratorService; -public class CreditApplicationGeneratorService(IDistributedCache _cache, IConfiguration _configuration, ILogger _logger) +public class CreditApplicationGeneratorService(IDistributedCache cache, IConfiguration configuration, ILogger logger, IHttpClientFactory httpClientFactory) { private static readonly string[] _creditTypes = [ @@ -26,17 +26,18 @@ public class CreditApplicationGeneratorService(IDistributedCache _cache, IConfig private static readonly string[] _terminalStatuses = ["Одобрена", "Отклонена"]; - private readonly int _expirationMinutes = _configuration.GetValue("CacheSettings:ExpirationMinutes", 10); + private readonly int _expirationMinutes = configuration.GetValue("CacheSettings:ExpirationMinutes", 10); + private readonly string? _fileServiceUrl = configuration["FileService:Url"]; - public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + public async Task<(CreditApplication Application, bool IsNew)> GetByIdAsync(int id, CancellationToken cancellationToken = default) { try { var cacheKey = $"credit-application-{id}"; - _logger.LogInformation("Попытка получить заявку {Id} из кэша", id); + logger.LogInformation("Попытка получить заявку {Id} из кэша", id); - var cachedData = await _cache.GetStringAsync(cacheKey, cancellationToken); + var cachedData = await cache.GetStringAsync(cacheKey, cancellationToken); if (!string.IsNullOrEmpty(cachedData)) { @@ -44,44 +45,117 @@ public async Task GetByIdAsync(int id, CancellationToken canc if (deserializedApplication != null) { - _logger.LogInformation("Заявка {Id} найдена в кэше", id); - return deserializedApplication; + logger.LogInformation("Заявка {Id} найдена в кэше", id); + return (deserializedApplication, IsNew: false); } - _logger.LogWarning("Заявка {Id} найдена в кэше, но не удалось десериализовать. Генерируем новую", id); + logger.LogWarning("Заявка {Id} найдена в кэше, но не удалось десериализовать", id); } - _logger.LogInformation("Заявка {Id} не найдена в кэше, генерируем новую", id); + logger.LogInformation("Заявка {Id} не найдена в кэше, проверяем MinIO", id); + + var applicationFromStorage = await TryGetFromStorageAsync(id, cancellationToken); + + if (applicationFromStorage != null) + { + logger.LogInformation("Заявка {Id} найдена в MinIO, кэшируем", id); + + var cacheOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_expirationMinutes) + }; + + await cache.SetStringAsync( + cacheKey, + JsonSerializer.Serialize(applicationFromStorage), + cacheOptions, + cancellationToken); + + return (applicationFromStorage, IsNew: false); + } + + logger.LogInformation("Заявка {Id} не найдена в хранилище, генерируем новую", id); var application = GenerateApplication(id); - var cacheOptions = new DistributedCacheEntryOptions + var newCacheOptions = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_expirationMinutes) }; - await _cache.SetStringAsync( + await cache.SetStringAsync( cacheKey, JsonSerializer.Serialize(application), - cacheOptions, + newCacheOptions, cancellationToken); - _logger.LogInformation( + logger.LogInformation( "Кредитная заявка сгенерирована и закэширована: Id={Id}, Тип={Type}, Сумма={Amount}, Статус={Status}", application.Id, application.Type, application.Amount, application.Status); - return application; + return (application, IsNew: true); } catch (Exception ex) { - _logger.LogError(ex, "Ошибка при получении/генерации заявки {Id}", id); + logger.LogError(ex, "Ошибка при получении/генерации заявки {Id}", id); throw; } } + private async Task TryGetFromStorageAsync(int id, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(_fileServiceUrl)) + { + logger.LogWarning("FileService URL не настроен, пропускаем проверку хранилища"); + return null; + } + + try + { + var httpClient = httpClientFactory.CreateClient(); + + var filesResponse = await httpClient.GetAsync($"{_fileServiceUrl}/api/files", cancellationToken); + + if (!filesResponse.IsSuccessStatusCode) + { + logger.LogWarning("Не удалось получить список файлов из FileService: {StatusCode}", filesResponse.StatusCode); + return null; + } + + var filesJson = await filesResponse.Content.ReadAsStringAsync(cancellationToken); + var files = JsonSerializer.Deserialize>(filesJson); + + var matchingFile = files?.FirstOrDefault(f => f.Contains($"credit-application-{id}-")); + + if (matchingFile == null) + { + logger.LogInformation("Файл для заявки {Id} не найден в MinIO", id); + return null; + } + + var fileResponse = await httpClient.GetAsync($"{_fileServiceUrl}/api/files/{matchingFile}", cancellationToken); + + if (!fileResponse.IsSuccessStatusCode) + { + logger.LogWarning("Не удалось получить файл {FileName} из FileService", matchingFile); + return null; + } + + var fileContent = await fileResponse.Content.ReadAsStringAsync(cancellationToken); + var application = JsonSerializer.Deserialize(fileContent); + + return application; + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении заявки {Id} из хранилища", id); + return null; + } + } + /// /// Генерация кредитной заявки с указанным ID /// diff --git a/CreditApp.Api/Services/SnsPublisherService/SnsPublisherService.cs b/CreditApp.Api/Services/SnsPublisherService/SnsPublisherService.cs new file mode 100644 index 00000000..4b8391ba --- /dev/null +++ b/CreditApp.Api/Services/SnsPublisherService/SnsPublisherService.cs @@ -0,0 +1,79 @@ +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using CreditApp.Domain.Entities; +using System.Text.Json; + +namespace CreditApp.Api.Services.SnsPublisherService; + +public class SnsPublisherService(IAmazonSimpleNotificationService snsClient, ILogger logger, IConfiguration configuration) +{ + private readonly string? _topicArn = configuration["AWS:SNS:TopicArn"]; + + public async Task PublishCreditApplicationAsync(CreditApplication application, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(_topicArn)) + { + logger.LogWarning("SNS TopicArn не настроен, публикация пропущена"); + return; + } + + try + { + var message = JsonSerializer.Serialize(application); + + var publishRequest = new PublishRequest + { + TopicArn = _topicArn, + Message = message, + Subject = $"CreditApplication-{application.Id}" + }; + + var response = await snsClient.PublishAsync(publishRequest, cancellationToken); + + logger.LogInformation( + "Кредитная заявка {Id} опубликована в SNS, MessageId: {MessageId}", + application.Id, + response.MessageId); + } + catch (NotFoundException) + { + logger.LogWarning("Топик SNS не существует, попытка создать"); + + try + { + var createTopicRequest = new CreateTopicRequest + { + Name = "credit-applications" + }; + + var createResponse = await snsClient.CreateTopicAsync(createTopicRequest, cancellationToken); + var createdTopicArn = createResponse.TopicArn; + + logger.LogInformation("Топик SNS создан: {TopicArn}", createdTopicArn); + + var message = JsonSerializer.Serialize(application); + var publishRequest = new PublishRequest + { + TopicArn = createdTopicArn, + Message = message, + Subject = $"CreditApplication-{application.Id}" + }; + + var response = await snsClient.PublishAsync(publishRequest, cancellationToken); + + logger.LogInformation( + "Кредитная заявка {Id} опубликована в SNS после создания топика, MessageId: {MessageId}", + application.Id, + response.MessageId); + } + catch (Exception ex) + { + logger.LogError(ex, "Не удалось создать топик SNS и опубликовать сообщение"); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при публикации в SNS"); + } + } +} diff --git a/CreditApp.Api/appsettings.Development.json b/CreditApp.Api/appsettings.Development.json index b642d7aa..6a6b283d 100644 --- a/CreditApp.Api/appsettings.Development.json +++ b/CreditApp.Api/appsettings.Development.json @@ -8,5 +8,14 @@ "AllowedHosts": "*", "CacheSettings": { "ExpirationMinutes": 10 + }, + "AWS": { + "ServiceURL": "http://localhost:4566", + "Region": "us-east-1", + "AccessKeyId": "test", + "SecretAccessKey": "test", + "SNS": { + "TopicArn": "arn:aws:sns:us-east-1:000000000000:credit-applications" + } } } diff --git a/CreditApp.Api/appsettings.json b/CreditApp.Api/appsettings.json index b642d7aa..7c883048 100644 --- a/CreditApp.Api/appsettings.json +++ b/CreditApp.Api/appsettings.json @@ -8,5 +8,17 @@ "AllowedHosts": "*", "CacheSettings": { "ExpirationMinutes": 10 + }, + "FileService": { + "Url": "http://localhost:5100" + }, + "AWS": { + "ServiceURL": "http://localhost:4566", + "Region": "us-east-1", + "AccessKeyId": "test", + "SecretAccessKey": "test", + "SNS": { + "TopicArn": "arn:aws:sns:us-east-1:000000000000:credit-applications" + } } } diff --git a/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs index ab7cf644..65e7e1c0 100644 --- a/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs +++ b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs @@ -64,5 +64,5 @@ public async Task> LeaseAsync(HttpContext httpConte return new OkResponse(selectedService); } - public void Release(ServiceHostAndPort hostAndPort) {} + public void Release(ServiceHostAndPort hostAndPort) { } } \ No newline at end of file diff --git a/CreditApp.AppHost/Program.cs b/CreditApp.AppHost/Program.cs index 6b457b9d..d6568979 100644 --- a/CreditApp.AppHost/Program.cs +++ b/CreditApp.AppHost/Program.cs @@ -1,32 +1,61 @@ var builder = DistributedApplication.CreateBuilder(args); +var minioAccessKey = builder.Configuration["MinIO:AccessKey"]!; +var minioSecretKey = builder.Configuration["MinIO:SecretKey"]!; + var redis = builder.AddRedis("cache") .WithRedisCommander(); +var minio = builder.AddContainer("minio", "minio/minio") + .WithEnvironment("MINIO_ROOT_USER", minioAccessKey) + .WithEnvironment("MINIO_ROOT_PASSWORD", minioSecretKey) + .WithArgs("server", "/data", "--console-address", ":9001") + .WithEndpoint(port: 9000, targetPort: 9000, name: "api") + .WithEndpoint(port: 9001, targetPort: 9001, name: "console") + .WithBindMount("minio-data", "/data"); + +var localstack = builder.AddContainer("localstack", "localstack/localstack") + .WithEnvironment("SERVICES", "sns") + .WithEnvironment("DEBUG", "1") + .WithEnvironment("AWS_ACCESS_KEY_ID", "test") + .WithEnvironment("AWS_SECRET_ACCESS_KEY", "test") + .WithEnvironment("AWS_DEFAULT_REGION", "us-east-1") + .WithEndpoint(port: 4566, targetPort: 4566, name: "gateway") + .WithBindMount("localstack-data", "/var/lib/localstack") + .WithBindMount(Path.Combine(builder.AppHostDirectory, "localstack-init.sh"), "/etc/localstack/init/ready.d/init-aws.sh"); + var api0 = builder.AddProject("creditapp-api-0") .WithReference(redis) .WithEndpoint("https", endpoint => endpoint.Port = 7170) - .WaitFor(redis); + .WaitFor(redis) + .WaitFor(localstack); var api1 = builder.AddProject("creditapp-api-1") .WithReference(redis) .WithEndpoint("https", endpoint => endpoint.Port = 7171) - .WaitFor(redis); + .WaitFor(redis) + .WaitFor(localstack); var api2 = builder.AddProject("creditapp-api-2") .WithReference(redis) .WithEndpoint("https", endpoint => endpoint.Port = 7172) - .WaitFor(redis); + .WaitFor(redis) + .WaitFor(localstack); var gateway = builder.AddProject("creditapp-apigateway") .WithReference(api0) .WithReference(api1) - .WithReference(api2); + .WithReference(api2) + .WaitFor(api0) + .WaitFor(api1) + .WaitFor(api2); + +var fileService = builder.AddProject("creditapp-fileservice") + .WithEndpoint("http", endpoint => endpoint.Port = 5100) + .WaitFor(minio); builder.AddProject("client") .WithReference(gateway) .WaitFor(gateway); -builder.AddProject("creditapp-fileservice"); - builder.Build().Run(); diff --git a/CreditApp.AppHost/appsettings.Development.json b/CreditApp.AppHost/appsettings.Development.json index 0c208ae9..3d869fa4 100644 --- a/CreditApp.AppHost/appsettings.Development.json +++ b/CreditApp.AppHost/appsettings.Development.json @@ -4,5 +4,9 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "MinIO": { + "AccessKey": "minioadmin", + "SecretKey": "minioadmin" } } diff --git a/CreditApp.AppHost/appsettings.json b/CreditApp.AppHost/appsettings.json index 31c092aa..376f3d27 100644 --- a/CreditApp.AppHost/appsettings.json +++ b/CreditApp.AppHost/appsettings.json @@ -5,5 +5,9 @@ "Microsoft.AspNetCore": "Warning", "Aspire.Hosting.Dcp": "Warning" } + }, + "MinIO": { + "AccessKey": "minioadmin", + "SecretKey": "minioadmin" } } diff --git a/CreditApp.AppHost/localstack-init.sh b/CreditApp.AppHost/localstack-init.sh new file mode 100644 index 00000000..ce07af59 --- /dev/null +++ b/CreditApp.AppHost/localstack-init.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +echo "Initializing LocalStack" + +# Ожидание готовности LocalStack с проверкой +max_attempts=30 +attempt=0 +while [ $attempt -lt $max_attempts ]; do + if awslocal sns list-topics > /dev/null 2>&1; then + echo "LocalStack is ready!" + break + fi + attempt=$((attempt + 1)) + echo "Waiting for LocalStack (attempt $attempt/$max_attempts)" + sleep 1 +done + +if [ $attempt -eq $max_attempts ]; then + echo "ERROR: LocalStack failed to start" + exit 1 +fi + +# Создание SNS топика +awslocal sns create-topic --name credit-applications + +# Подписка HTTP эндпоинта FileService на SNS топик +awslocal sns subscribe \ + --topic-arn arn:aws:sns:us-east-1:000000000000:credit-applications \ + --protocol http \ + --notification-endpoint http://host.docker.internal:5100/api/notification + +echo "LocalStack initialization completed successfully" diff --git a/CreditApp.FileService/Configuration/MinioSettings.cs b/CreditApp.FileService/Configuration/MinioSettings.cs new file mode 100644 index 00000000..102450e5 --- /dev/null +++ b/CreditApp.FileService/Configuration/MinioSettings.cs @@ -0,0 +1,10 @@ +namespace CreditApp.FileService.Configuration; + +public class MinioSettings +{ + public string Endpoint { get; set; } = "localhost:9000"; + public string AccessKey { get; set; } = "minioadmin"; + public string SecretKey { get; set; } = "minioadmin"; + public bool UseSSL { get; set; } = false; + public string BucketName { get; set; } = "credit-applications"; +} diff --git a/CreditApp.FileService/Controllers/FilesController.cs b/CreditApp.FileService/Controllers/FilesController.cs new file mode 100644 index 00000000..088c8ce3 --- /dev/null +++ b/CreditApp.FileService/Controllers/FilesController.cs @@ -0,0 +1,46 @@ +using CreditApp.FileService.Services; +using Microsoft.AspNetCore.Mvc; + +namespace CreditApp.FileService.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class FilesController(MinioStorageService minioStorage, ILogger logger) : ControllerBase +{ + [HttpGet] + public async Task>> GetFilesList(CancellationToken cancellationToken) + { + try + { + await minioStorage.EnsureBucketExistsAsync(cancellationToken); + var files = await minioStorage.ListFilesAsync(cancellationToken); + return Ok(files); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении списка файлов"); + return StatusCode(500, new { error = ex.Message }); + } + } + + [HttpGet("{fileName}")] + public async Task GetFile(string fileName, CancellationToken cancellationToken) + { + try + { + var content = await minioStorage.GetFileContentAsync(fileName, cancellationToken); + + if (content == null) + { + return NotFound(new { error = "File not found" }); + } + + return Content(content, "application/json"); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении файла {FileName}", fileName); + return StatusCode(500, new { error = ex.Message }); + } + } +} diff --git a/CreditApp.FileService/Controllers/NotificationController.cs b/CreditApp.FileService/Controllers/NotificationController.cs new file mode 100644 index 00000000..c3382a5d --- /dev/null +++ b/CreditApp.FileService/Controllers/NotificationController.cs @@ -0,0 +1,113 @@ +using CreditApp.Domain.Entities; +using CreditApp.FileService.Services; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace CreditApp.FileService.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class NotificationController(MinioStorageService minioStorage, IHttpClientFactory httpClientFactory, JsonSerializerOptions jsonOptions, ILogger logger) : ControllerBase +{ + [HttpPost] + public async Task ReceiveSnsNotification(CancellationToken cancellationToken) + { + try + { + using var reader = new StreamReader(Request.Body); + var bodyContent = await reader.ReadToEndAsync(cancellationToken); + + logger.LogInformation("Получено SNS уведомление: {Body}", bodyContent); + + var body = JsonSerializer.Deserialize(bodyContent); + + if (body.TryGetProperty("Type", out var typeElement)) + { + var messageType = typeElement.GetString(); + + if (messageType == "SubscriptionConfirmation") + { + logger.LogInformation("Получено подтверждение подписки SNS"); + + if (body.TryGetProperty("SubscribeURL", out var subscribeUrlElement)) + { + var subscribeUrl = subscribeUrlElement.GetString(); + + if (!string.IsNullOrEmpty(subscribeUrl)) + { + logger.LogInformation("Подтверждение подписки через URL: {Url}", subscribeUrl); + + using var httpClient = httpClientFactory.CreateClient(); + var response = await httpClient.GetAsync(subscribeUrl, cancellationToken); + + if (response.IsSuccessStatusCode) + { + logger.LogInformation("Подписка SNS успешно подтверждена"); + } + else + { + logger.LogWarning("Не удалось подтвердить подписку SNS: {StatusCode}", response.StatusCode); + } + } + } + + return Ok(new { message = "Subscription confirmed" }); + } + + if (messageType == "Notification") + { + if (body.TryGetProperty("Message", out var messageElement)) + { + var messageJson = messageElement.GetString(); + + if (string.IsNullOrEmpty(messageJson)) + { + logger.LogWarning("Получено пустое сообщение от SNS"); + return BadRequest("Empty message"); + } + + var creditApplication = JsonSerializer.Deserialize(messageJson); + + if (creditApplication == null) + { + logger.LogWarning("Не удалось десериализовать CreditApplication"); + return BadRequest("Invalid credit application data"); + } + + logger.LogInformation( + "Получена кредитная заявка {Id} через SNS", + creditApplication.Id); + + await minioStorage.EnsureBucketExistsAsync(cancellationToken); + + var fileName = $"credit-application-{creditApplication.Id}-{DateTime.UtcNow:yyyyMMdd-HHmmss}.json"; + var jsonContent = JsonSerializer.Serialize(creditApplication, jsonOptions); + + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(jsonContent)); + + var uploadedPath = await minioStorage.UploadFileAsync( + fileName, + stream, + "application/json", + cancellationToken); + + logger.LogInformation( + "Кредитная заявка {Id} сохранена в MinIO: {Path}", + creditApplication.Id, + uploadedPath); + + return Ok(new { message = "Credit application saved", path = uploadedPath }); + } + } + } + + logger.LogWarning("Получено неизвестное SNS сообщение"); + return BadRequest("Unknown message type"); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при обработке SNS уведомления"); + return StatusCode(500, new { error = ex.Message }); + } + } +} diff --git a/CreditApp.FileService/Controllers/WeatherForecastController.cs b/CreditApp.FileService/Controllers/WeatherForecastController.cs deleted file mode 100644 index e0c4d19e..00000000 --- a/CreditApp.FileService/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace CreditApp.FileService.Controllers; - -[ApiController] -[Route("[controller]")] -public class WeatherForecastController : ControllerBase -{ -} diff --git a/CreditApp.FileService/CreditApp.FileService.csproj b/CreditApp.FileService/CreditApp.FileService.csproj index c9fbba01..b9b4ee00 100644 --- a/CreditApp.FileService/CreditApp.FileService.csproj +++ b/CreditApp.FileService/CreditApp.FileService.csproj @@ -8,11 +8,13 @@ + + diff --git a/CreditApp.FileService/Program.cs b/CreditApp.FileService/Program.cs index 90336893..55268fd7 100644 --- a/CreditApp.FileService/Program.cs +++ b/CreditApp.FileService/Program.cs @@ -1,13 +1,58 @@ +using CreditApp.FileService.Configuration; +using CreditApp.FileService.Services; using CreditApp.ServiceDefaults; +using Minio; +using System.Text.Json; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); -// Add services to the container. +var minioSettings = builder.Configuration.GetSection("MinIO").Get() ?? new MinioSettings(); +builder.Services.AddSingleton(minioSettings); + +builder.Services.AddSingleton(sp => +{ + var settings = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + + logger.LogInformation("MinIO Endpoint from config: '{Endpoint}'", settings.Endpoint ?? "(null)"); + + string endpoint; + var endpointValue = settings.Endpoint?.Trim() ?? string.Empty; + + if (string.IsNullOrEmpty(endpointValue)) + { + logger.LogError("MinIO Endpoint is null or empty!"); + throw new InvalidOperationException("MinIO Endpoint is not configured"); + } + + if (endpointValue.StartsWith("http://") || endpointValue.StartsWith("https://")) + { + var uri = new Uri(endpointValue); + endpoint = $"{uri.Host}:{uri.Port}"; + logger.LogInformation("Parsed MinIO endpoint from URL: '{Endpoint}'", endpoint); + } + else + { + endpoint = endpointValue; + logger.LogInformation("Using MinIO endpoint as-is: '{Endpoint}'", endpoint); + } + + return new MinioClient() + .WithEndpoint(endpoint) + .WithCredentials(settings.AccessKey, settings.SecretKey) + .WithSSL(settings.UseSSL) + .Build(); +}); + +builder.Services.AddScoped(); + +builder.Services.AddSingleton(new JsonSerializerOptions { WriteIndented = true }); + +builder.Services.AddHttpClient(); builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); @@ -15,7 +60,6 @@ app.MapDefaultEndpoints(); -// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseSwagger(); diff --git a/CreditApp.FileService/Properties/launchSettings.json b/CreditApp.FileService/Properties/launchSettings.json index 0a114df3..2033e8e3 100644 --- a/CreditApp.FileService/Properties/launchSettings.json +++ b/CreditApp.FileService/Properties/launchSettings.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "http://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, @@ -14,7 +14,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "swagger", - "applicationUrl": "http://localhost:5035", + "applicationUrl": "http://localhost:5100", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -24,7 +24,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "swagger", - "applicationUrl": "https://localhost:7143;http://localhost:5035", + "applicationUrl": "https://localhost:7143;http://localhost:5100", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/CreditApp.FileService/Services/MinioStorageService.cs b/CreditApp.FileService/Services/MinioStorageService.cs new file mode 100644 index 00000000..073a3c6f --- /dev/null +++ b/CreditApp.FileService/Services/MinioStorageService.cs @@ -0,0 +1,114 @@ +using CreditApp.FileService.Configuration; +using Minio; +using Minio.DataModel.Args; + +namespace CreditApp.FileService.Services; + +public class MinioStorageService(IMinioClient minioClient, MinioSettings settings, ILogger logger) +{ + public async Task EnsureBucketExistsAsync(CancellationToken cancellationToken = default) + { + try + { + var bucketExistsArgs = new BucketExistsArgs() + .WithBucket(settings.BucketName); + + var found = await minioClient.BucketExistsAsync(bucketExistsArgs, cancellationToken); + + if (!found) + { + var makeBucketArgs = new MakeBucketArgs() + .WithBucket(settings.BucketName); + + await minioClient.MakeBucketAsync(makeBucketArgs, cancellationToken); + logger.LogInformation("Bucket {BucketName} создан", settings.BucketName); + } + else + { + logger.LogInformation("Bucket {BucketName} уже существует", settings.BucketName); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при проверке/создании bucket {BucketName}", settings.BucketName); + throw; + } + } + + public async Task UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default) + { + try + { + var putObjectArgs = new PutObjectArgs() + .WithBucket(settings.BucketName) + .WithObject(fileName) + .WithStreamData(fileStream) + .WithObjectSize(fileStream.Length) + .WithContentType(contentType); + + await minioClient.PutObjectAsync(putObjectArgs, cancellationToken); + + logger.LogInformation( + "Файл {FileName} успешно загружен в bucket {BucketName}", + fileName, + settings.BucketName); + + return $"{settings.BucketName}/{fileName}"; + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при загрузке файла {FileName} в MinIO", fileName); + throw; + } + } + + public async Task> ListFilesAsync(CancellationToken cancellationToken = default) + { + try + { + var files = new List(); + var listArgs = new ListObjectsArgs() + .WithBucket(settings.BucketName) + .WithRecursive(true); + + await foreach (var item in minioClient.ListObjectsEnumAsync(listArgs, cancellationToken)) + { + files.Add(item.Key); + } + + logger.LogInformation("Получен список из {Count} файлов из bucket {BucketName}", files.Count, settings.BucketName); + return files; + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении списка файлов из bucket {BucketName}", settings.BucketName); + throw; + } + } + + public async Task GetFileContentAsync(string fileName, CancellationToken cancellationToken = default) + { + try + { + string? content = null; + var getArgs = new GetObjectArgs() + .WithBucket(settings.BucketName) + .WithObject(fileName) + .WithCallbackStream(async (stream, ct) => + { + using var reader = new StreamReader(stream); + content = await reader.ReadToEndAsync(ct); + }); + + await minioClient.GetObjectAsync(getArgs, cancellationToken); + + logger.LogInformation("Файл {FileName} успешно получен из bucket {BucketName}", fileName, settings.BucketName); + return content; + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении файла {FileName} из bucket {BucketName}", fileName, settings.BucketName); + return null; + } + } +} diff --git a/CreditApp.FileService/appsettings.Development.json b/CreditApp.FileService/appsettings.Development.json index 0c208ae9..529b29fb 100644 --- a/CreditApp.FileService/appsettings.Development.json +++ b/CreditApp.FileService/appsettings.Development.json @@ -4,5 +4,12 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "MinIO": { + "Endpoint": "localhost:9000", + "AccessKey": "minioadmin", + "SecretKey": "minioadmin", + "UseSSL": false, + "BucketName": "credit-applications" } } diff --git a/CreditApp.FileService/appsettings.json b/CreditApp.FileService/appsettings.json index 10f68b8c..353b3f12 100644 --- a/CreditApp.FileService/appsettings.json +++ b/CreditApp.FileService/appsettings.json @@ -5,5 +5,12 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "MinIO": { + "Endpoint": "localhost:9000", + "AccessKey": "minioadmin", + "SecretKey": "minioadmin", + "UseSSL": false, + "BucketName": "credit-applications" + } } diff --git a/CreditApp.Test/CreditApp.Test.csproj b/CreditApp.Test/CreditApp.Test.csproj index fc959262..415d8e10 100644 --- a/CreditApp.Test/CreditApp.Test.csproj +++ b/CreditApp.Test/CreditApp.Test.csproj @@ -1,26 +1,29 @@ - + net8.0 enable enable + false true - - - - + + + + + + + + + + - - - - diff --git a/CreditApp.Test/IntegrationTest.cs b/CreditApp.Test/IntegrationTest.cs new file mode 100644 index 00000000..be2c5d81 --- /dev/null +++ b/CreditApp.Test/IntegrationTest.cs @@ -0,0 +1,280 @@ +using Aspire.Hosting; +using Aspire.Hosting.Testing; +using CreditApp.Domain.Entities; +using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Text.Json; + +namespace CreditApp.Test; + +public class AppHostFixture : IAsyncLifetime +{ + private static readonly TimeSpan _defaultTimeout = TimeSpan.FromSeconds(60); + public DistributedApplication? App { get; private set; } + + public async Task InitializeAsync() + { + var appHost = await DistributedApplicationTestingBuilder + .CreateAsync(); + + appHost.Services.ConfigureHttpClientDefaults(http => + http.AddStandardResilienceHandler(options => + { + options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(120); + options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(60); + options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(120); + })); + + App = await appHost.BuildAsync(); + await App.StartAsync(); + + await App.ResourceNotifications.WaitForResourceHealthyAsync("cache").WaitAsync(_defaultTimeout); + await App.ResourceNotifications.WaitForResourceHealthyAsync("minio").WaitAsync(_defaultTimeout); + await App.ResourceNotifications.WaitForResourceHealthyAsync("localstack").WaitAsync(_defaultTimeout); + await App.ResourceNotifications.WaitForResourceHealthyAsync("creditapp-api-0").WaitAsync(_defaultTimeout); + await App.ResourceNotifications.WaitForResourceHealthyAsync("creditapp-fileservice").WaitAsync(_defaultTimeout); + + await Task.Delay(15000); + } + + public async Task DisposeAsync() + { + if (App != null) + { + await App.DisposeAsync(); + } + } +} + +public class IntegrationTests(AppHostFixture fixture) : IClassFixture +{ + private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; + + [Fact] + public async Task CreditApi_HealthCheck_ReturnsHealthy() + { + using var httpClient = fixture.App!.CreateHttpClient("creditapp-api-0"); + using var response = await httpClient.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task FileService_HealthCheck_ReturnsHealthy() + { + using var httpClient = fixture.App!.CreateHttpClient("creditapp-fileservice"); + using var response = await httpClient.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task CreditApi_GetById_ReturnsValidCreditApplication() + { + using var httpClient = fixture.App!.CreateHttpClient("creditapp-api-0"); + using var response = await httpClient.GetAsync("/api/credit?id=1"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var creditApp = JsonSerializer.Deserialize(content, _jsonOptions); + + Assert.NotNull(creditApp); + Assert.Equal(1, creditApp.Id); + Assert.NotEmpty(creditApp.Type); + Assert.NotEmpty(creditApp.Status); + Assert.True(creditApp.Amount > 0); + } + + [Fact] + public async Task EndToEnd_CreditApplicationFlow_SavesFileToMinIO() + { + var testId = Random.Shared.Next(1, 100000); + var httpClient = fixture.App!.CreateHttpClient("creditapp-api-0"); + + using var genResponse = await httpClient.GetAsync($"/api/credit?id={testId}"); + genResponse.EnsureSuccessStatusCode(); + var apiContent = await genResponse.Content.ReadAsStringAsync(); + + var apiCreditApp = JsonSerializer.Deserialize(apiContent, _jsonOptions); + Assert.NotNull(apiCreditApp); + Assert.Equal(testId, apiCreditApp.Id); + + var expectedFileName = $"credit-application-{testId}"; + string? fileContent = null; + + var fileServiceClient = fixture.App!.CreateHttpClient("creditapp-fileservice"); + + for (var i = 0; i < 2; i++) + { + await Task.Delay(1000); + + try + { + using var filesResponse = await fileServiceClient.GetAsync("/api/files"); + if (filesResponse.IsSuccessStatusCode) + { + var filesListContent = await filesResponse.Content.ReadAsStringAsync(); + var files = JsonSerializer.Deserialize>(filesListContent, _jsonOptions); + + var matchingFile = files?.FirstOrDefault(f => f.Contains(expectedFileName)); + if (matchingFile != null) + { + using var fileResponse = await fileServiceClient.GetAsync($"/api/files/{matchingFile}"); + if (fileResponse.IsSuccessStatusCode) + { + fileContent = await fileResponse.Content.ReadAsStringAsync(); + break; + } + } + } + } + catch + { + } + } + + Assert.NotNull(fileContent); + + var savedCreditApp = JsonSerializer.Deserialize(fileContent, _jsonOptions); + Assert.NotNull(savedCreditApp); + Assert.Equal(testId, savedCreditApp.Id); + Assert.Equal(apiCreditApp.Type, savedCreditApp.Type); + Assert.Equal(apiCreditApp.Amount, savedCreditApp.Amount); + Assert.Equal(apiCreditApp.Status, savedCreditApp.Status); + } + + [Fact] + public async Task Redis_CachingWorks_ReturnsCachedData() + { + var testId = Random.Shared.Next(1, 100000); + using var httpClient = fixture.App!.CreateHttpClient("creditapp-api-0"); + + using var firstResponse = await httpClient.GetAsync($"/api/credit?id={testId}"); + Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); + var firstContent = await firstResponse.Content.ReadAsStringAsync(); + + using var secondResponse = await httpClient.GetAsync($"/api/credit?id={testId}"); + Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode); + var secondContent = await secondResponse.Content.ReadAsStringAsync(); + + Assert.Equal(firstContent, secondContent); + } + + [Fact] + public async Task AllServices_StartSuccessfully_AndAreHealthy() + { + var apiClient = fixture.App!.CreateHttpClient("creditapp-api-0"); + var fileServiceClient = fixture.App!.CreateHttpClient("creditapp-fileservice"); + + using var apiHealthResponse = await apiClient.GetAsync("/health"); + using var fileServiceHealthResponse = await fileServiceClient.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, apiHealthResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, fileServiceHealthResponse.StatusCode); + } + + [Fact] + public async Task Gateway_GetCreditApplication_ReturnsValidData() + { + var testId = Random.Shared.Next(1, 100000); + using var httpClient = fixture.App!.CreateHttpClient("creditapp-apigateway"); + + using var response = await httpClient.GetAsync($"/api/credit?id={testId}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var creditApp = JsonSerializer.Deserialize(content, _jsonOptions); + + Assert.NotNull(creditApp); + Assert.Equal(testId, creditApp.Id); + Assert.NotEmpty(creditApp.Type); + Assert.NotEmpty(creditApp.Status); + Assert.True(creditApp.Amount > 0); + } + + [Fact] + public async Task Gateway_RepeatedRequests_ReturnsCachedResponse() + { + var testId = Random.Shared.Next(1, 100000); + using var httpClient = fixture.App!.CreateHttpClient("creditapp-apigateway"); + + using var response1 = await httpClient.GetAsync($"/api/credit?id={testId}"); + response1.EnsureSuccessStatusCode(); + var content1 = await response1.Content.ReadAsStringAsync(); + + using var response2 = await httpClient.GetAsync($"/api/credit?id={testId}"); + response2.EnsureSuccessStatusCode(); + var content2 = await response2.Content.ReadAsStringAsync(); + + Assert.Equal(content1, content2); + + var creditApp1 = JsonSerializer.Deserialize(content1, _jsonOptions); + var creditApp2 = JsonSerializer.Deserialize(content2, _jsonOptions); + + Assert.NotNull(creditApp1); + Assert.NotNull(creditApp2); + Assert.Equal(creditApp1.Id, creditApp2.Id); + Assert.Equal(creditApp1.Type, creditApp2.Type); + Assert.Equal(creditApp1.Amount, creditApp2.Amount); + } + + [Fact] + public async Task Gateway_EndToEnd_CreditApplicationFlow_SavesFileToMinIO() + { + var testId = Random.Shared.Next(1, 100000); + var gatewayClient = fixture.App!.CreateHttpClient("creditapp-apigateway"); + + using var genResponse = await gatewayClient.GetAsync($"/api/credit?id={testId}"); + genResponse.EnsureSuccessStatusCode(); + var apiContent = await genResponse.Content.ReadAsStringAsync(); + + var apiCreditApp = JsonSerializer.Deserialize(apiContent, _jsonOptions); + Assert.NotNull(apiCreditApp); + Assert.Equal(testId, apiCreditApp.Id); + + var expectedFileName = $"credit-application-{testId}"; + string? fileContent = null; + + var fileServiceClient = fixture.App!.CreateHttpClient("creditapp-fileservice"); + + for (var i = 0; i < 3; i++) + { + await Task.Delay(1000); + + try + { + using var filesResponse = await fileServiceClient.GetAsync("/api/files"); + if (filesResponse.IsSuccessStatusCode) + { + var filesListContent = await filesResponse.Content.ReadAsStringAsync(); + var files = JsonSerializer.Deserialize>(filesListContent, _jsonOptions); + + var matchingFile = files?.FirstOrDefault(f => f.Contains(expectedFileName)); + if (matchingFile != null) + { + using var fileResponse = await fileServiceClient.GetAsync($"/api/files/{matchingFile}"); + if (fileResponse.IsSuccessStatusCode) + { + fileContent = await fileResponse.Content.ReadAsStringAsync(); + break; + } + } + } + } + catch + { + } + } + + Assert.NotNull(fileContent); + + var savedCreditApp = JsonSerializer.Deserialize(fileContent, _jsonOptions); + Assert.NotNull(savedCreditApp); + Assert.Equal(testId, savedCreditApp.Id); + Assert.Equal(apiCreditApp.Type, savedCreditApp.Type); + Assert.Equal(apiCreditApp.Amount, savedCreditApp.Amount); + Assert.Equal(apiCreditApp.Status, savedCreditApp.Status); + } +} diff --git a/CreditApp.Test/IntegrationTests.cs b/CreditApp.Test/IntegrationTests.cs deleted file mode 100644 index e22684ef..00000000 --- a/CreditApp.Test/IntegrationTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace CreditApp.Test; - -public class IntegrationTests -{ - // private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); - - // Instructions: - // 1. Add a project reference to the target AppHost project, e.g.: - // - // - // - // - // - // 2. Uncomment the following example test and update 'Projects.MyAspireApp_AppHost' to match your AppHost project: - // - // [Fact] - // public async Task GetWebResourceRootReturnsOkStatusCode() - // { - // // Arrange - // var cancellationToken = TestContext.Current.CancellationToken; - // var appHost = await DistributedApplicationTestingBuilder.CreateAsync(cancellationToken); - // appHost.Services.AddLogging(logging => - // { - // logging.SetMinimumLevel(LogLevel.Debug); - // // Override the logging filters from the app's configuration - // logging.AddFilter(appHost.Environment.ApplicationName, LogLevel.Debug); - // logging.AddFilter("Aspire.", LogLevel.Debug); - // // To output logs to the xUnit.net ITestOutputHelper, consider adding a package from https://www.nuget.org/packages?q=xunit+logging - // }); - // appHost.Services.ConfigureHttpClientDefaults(clientBuilder => - // { - // clientBuilder.AddStandardResilienceHandler(); - // }); - // - // await using var app = await appHost.BuildAsync(cancellationToken).WaitAsync(DefaultTimeout, cancellationToken); - // await app.StartAsync(cancellationToken).WaitAsync(DefaultTimeout, cancellationToken); - // - // // Act - // using var httpClient = app.CreateHttpClient("webfrontend"); - // await app.ResourceNotifications.WaitForResourceHealthyAsync("webfrontend", cancellationToken).WaitAsync(DefaultTimeout, cancellationToken); - // using var response = await httpClient.GetAsync("/", cancellationToken); - // - // // Assert - // Assert.Equal(HttpStatusCode.OK, response.StatusCode); - // } -} diff --git a/README.md b/README.md index 0bd25b2a..2ea89f9b 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,17 @@ ## Реализованный функционал ### Основные возможности: -- **API Gateway** на базе Ocelot с балансировкой нагрузки -- **Weighted Round Robin** балансировщик с весами 5:3:2 -- **3 реплики API сервиса** через Aspire orchestration -- **Генерация кредитных заявок** с реалистичными данными (Bogus) -- **Общий Redis кэш** для всех реплик с TTL 10 минут -- **Blazor WebAssembly клиент** для работы через Gateway -- **Структурное логирование** через OpenTelemetry -- **Мониторинг балансировки** в реальном времени через Aspire Dashboard +- **API Gateway** на базе Ocelot с алгоритмом балансировки Weighted Round Robin +- **Балансировка с весами 5:3:2** между тремя репликами API +- **3 реплики API сервиса** с оркестрацией через .NET Aspire +- **Генерация кредитных заявок** с реалистичными данными через библиотеку Bogus +- **Трёхуровневое кэширование**: Redis (быстрый доступ) → MinIO (постоянное хранилище) → Генератор +- **Асинхронная обработка** через AWS SNS (LocalStack) с публикацией событий +- **Объектное хранилище MinIO** для персистентного хранения заявок +- **Blazor WebAssembly клиент** для взаимодействия через Gateway +- **Структурное логирование** и телеметрия через OpenTelemetry +- **Мониторинг в реальном времени** через Aspire Dashboard +- **Интеграционные тесты** для проверки всей системы ## 🏗️ Архитектура @@ -50,7 +53,16 @@ ┌─────────────────┐ │ Redis Cache │ ← Общий кэш │ TTL: 10 минут │ - │ + Commander │ + └────────┬────────┘ + │ + ┌────────┴────────┐ + │ AWS SNS │ ← Очередь сообщений + │ (LocalStack) │ + └────────┬────────┘ + ↓ + ┌─────────────────┐ + │ FileService │ ← Сервис файлов + │ + MinIO │ ← Постоянное хранилище └─────────────────┘ ↑ ┌────────┴────────┐ @@ -64,7 +76,8 @@ ``` cloud-development/ ├── CreditApp.AppHost/ # 🎯 Aspire orchestrator -│ └── Program.cs # Конфигурация: 3 реплики API + Gateway +│ ├── Program.cs # Конфигурация: 3 реплики + Gateway + FileService +│ └── localstack-init.sh # Инициализация SNS топиков │ ├── CreditApp.ApiGateway/ # 🌐 API Gateway (Лаб. №2) │ ├── LoadBalancing/ @@ -77,9 +90,19 @@ cloud-development/ │ ├── Controllers/ │ │ └── CreditController.cs # GET /api/credit?id={id} │ ├── Services/ -│ │ └── CreditGeneratorService/ -│ │ └── CreditApplicationGeneratorService.cs # Генерация + кэш -│ └── Program.cs # Конфигурация (Redis, CORS, Swagger) +│ │ ├── CreditGeneratorService/ +│ │ │ └── CreditApplicationGeneratorService.cs # Генерация + кэш + MinIO +│ │ └── SnsPublisherService/ +│ │ └── SnsPublisherService.cs # Публикация в SNS +│ └── Program.cs # Redis, SNS, CORS, Swagger +│ +├── CreditApp.FileService/ # 📁 Сервис файлов +│ ├── Controllers/ +│ │ ├── FilesController.cs # Работа с файлами +│ │ └── NotificationController.cs # SNS webhook +│ ├── Services/ +│ │ └── MinioStorageService.cs # Работа с MinIO +│ └── Program.cs # MinIO клиент │ ├── CreditApp.ServiceDefaults/ # ⚙️ Общие настройки │ └── Extensions.cs # OpenTelemetry, health checks @@ -88,6 +111,9 @@ cloud-development/ │ └── Entities/ │ └── CreditApplication.cs # Модель кредитной заявки │ +├── CreditApp.Test/ # 🧪 Интеграционные тесты +│ └── IntegrationTest.cs # End-to-End тесты всей системы +│ ├── Client.Wasm/ # 💻 Blazor WASM клиент │ ├── Components/ │ │ ├── DataCard.razor # UI для запроса заявок From f24b8ee9e90bd6af78b7c83ded8921b6db44233e Mon Sep 17 00:00:00 2001 From: razzzenya <113578593+razzzenya@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:56:22 +0400 Subject: [PATCH 23/26] =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20readme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2ea89f9b..664769a2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Лабораторная работа №2 - "Балансировка нагрузки" +# Лабораторная работа №3 - "Интеграционное тестирование" **Вариант**: №9 - "Кредитная заявка" **Алгоритм балансировки**: Weighted Round Robin @@ -126,6 +126,6 @@ cloud-development/ ## 📸 Скриншоты! -![aspire](https://github.com/user-attachments/assets/c49d5de0-0afb-4105-b9da-bbc5fe76c9da) + ![client](https://github.com/user-attachments/assets/8d4dd124-9589-4562-b421-d0e914a0cc8a) -![logs](https://github.com/user-attachments/assets/120d9b27-d140-429a-b574-31c1c3c0e092) + From f0623b733d5a58c94f4079aa938f2fb893ca28a2 Mon Sep 17 00:00:00 2001 From: Ivan K Date: Tue, 17 Mar 2026 19:23:15 +0400 Subject: [PATCH 24/26] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CreditApp.Api/Controllers/CreditController.cs | 16 +- CreditApp.Api/Program.cs | 17 +- .../CreditApplicationCacheService.cs | 97 +++++++++ .../CreditApplicationGenerator.cs | 58 ++++++ .../CreditApplicationService.cs | 55 +++++ .../CreditApplicationStorageService.cs | 58 ++++++ .../CreditApplicationGeneratorService.cs | 190 ------------------ .../SnsPublisherService.cs | 2 +- CreditApp.AppHost/CreditApp.AppHost.csproj | 2 +- CreditApp.AppHost/Program.cs | 42 +++- .../Controllers/FilesController.cs | 25 +++ .../Controllers/NotificationController.cs | 35 +++- .../Services/MinioStorageService.cs | 14 +- CreditApp.Test/IntegrationTest.cs | 122 +---------- README.md | 6 +- 15 files changed, 398 insertions(+), 341 deletions(-) create mode 100644 CreditApp.Api/Services/CreditApplicationService/CreditApplicationCacheService.cs create mode 100644 CreditApp.Api/Services/CreditApplicationService/CreditApplicationGenerator.cs create mode 100644 CreditApp.Api/Services/CreditApplicationService/CreditApplicationService.cs create mode 100644 CreditApp.Api/Services/CreditApplicationService/CreditApplicationStorageService.cs delete mode 100644 CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs rename CreditApp.Api/Services/{SnsPublisherService => SnsPublisher}/SnsPublisherService.cs (98%) diff --git a/CreditApp.Api/Controllers/CreditController.cs b/CreditApp.Api/Controllers/CreditController.cs index 5f472c7d..ae3d8e13 100644 --- a/CreditApp.Api/Controllers/CreditController.cs +++ b/CreditApp.Api/Controllers/CreditController.cs @@ -1,5 +1,4 @@ -using CreditApp.Api.Services.CreditGeneratorService; -using CreditApp.Api.Services.SnsPublisherService; +using CreditApp.Api.Services.CreditApplicationService; using CreditApp.Domain.Entities; using Microsoft.AspNetCore.Mvc; @@ -7,7 +6,7 @@ namespace CreditApp.Api.Controllers; [Route("api/[controller]")] [ApiController] -public class CreditController(CreditApplicationGeneratorService generatorService, SnsPublisherService snsPublisher, ILogger logger) : ControllerBase +public class CreditController(CreditApplicationService applicationService, ILogger logger) : ControllerBase { /// /// Получить кредитную заявку по ID, если не найдена в кэше генерируем новую @@ -20,16 +19,7 @@ public async Task> GetById([FromQuery] int id, C { logger.LogInformation("Получен запрос на получение/генерацию заявки {Id}", id); - var (application, isNew) = await generatorService.GetByIdAsync(id, cancellationToken); - - if (isNew) - { - await snsPublisher.PublishCreditApplicationAsync(application, cancellationToken); - } - else - { - logger.LogInformation("Заявка {Id} получена из кэша, публикация в SNS пропущена", id); - } + var application = await applicationService.GetByIdAsync(id, cancellationToken); return Ok(application); } diff --git a/CreditApp.Api/Program.cs b/CreditApp.Api/Program.cs index 0512671f..710c40a6 100644 --- a/CreditApp.Api/Program.cs +++ b/CreditApp.Api/Program.cs @@ -1,6 +1,6 @@ using Amazon.SimpleNotificationService; -using CreditApp.Api.Services.CreditGeneratorService; -using CreditApp.Api.Services.SnsPublisherService; +using CreditApp.Api.Services.CreditApplicationService; +using CreditApp.Api.Services.SnsPublisher; using CreditApp.ServiceDefaults; var builder = WebApplication.CreateBuilder(args); @@ -22,7 +22,6 @@ builder.Services.AddScoped(); - builder.Services.AddCors(options => { options.AddPolicy("AllowBlazorWasm", policy => @@ -33,7 +32,17 @@ }); }); -builder.Services.AddScoped(); +builder.Services.AddHttpClient(); + +builder.Services.AddHttpClient("creditapp-fileservice", client => +{ + client.BaseAddress = new Uri("http://creditapp-fileservice"); +}).AddServiceDiscovery(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); diff --git a/CreditApp.Api/Services/CreditApplicationService/CreditApplicationCacheService.cs b/CreditApp.Api/Services/CreditApplicationService/CreditApplicationCacheService.cs new file mode 100644 index 00000000..5f383044 --- /dev/null +++ b/CreditApp.Api/Services/CreditApplicationService/CreditApplicationCacheService.cs @@ -0,0 +1,97 @@ +using CreditApp.Api.Services.SnsPublisher; +using CreditApp.Domain.Entities; +using Microsoft.Extensions.Caching.Distributed; +using System.Text.Json; + +namespace CreditApp.Api.Services.CreditApplicationService; + +/// +/// Сервис кэширования кредитных заявок в Redis +/// +public class CreditApplicationCacheService( + IDistributedCache cache, + IConfiguration configuration, + SnsPublisherService snsPublisher, + ILogger logger) +{ + private readonly int _expirationMinutes = configuration.GetValue("CacheSettings:ExpirationMinutes", 10); + + public async Task GetAsync(int id, CancellationToken cancellationToken = default) + { + try + { + var cacheKey = GetCacheKey(id); + logger.LogInformation("Попытка получить заявку {Id} из кэша", id); + + var cachedData = await cache.GetStringAsync(cacheKey, cancellationToken); + + if (string.IsNullOrEmpty(cachedData)) + { + logger.LogInformation("Заявка {Id} не найдена в кэше", id); + return null; + } + + var application = JsonSerializer.Deserialize(cachedData); + + if (application == null) + { + logger.LogWarning("Заявка {Id} найдена в кэше, но не удалось десериализовать", id); + return null; + } + + logger.LogInformation("Заявка {Id} найдена в кэше", id); + return application; + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении заявки {Id} из кэша", id); + return null; + } + } + + /// + /// Сохраняет существующую заявку в кэш (без публикации в SNS) + /// + public async Task SetAsync(CreditApplication application, CancellationToken cancellationToken = default) + { + await SetInternalAsync(application, cancellationToken); + } + + /// + /// Сохраняет новую заявку в кэш и публикует событие в SNS + /// + public async Task SetNewAsync(CreditApplication application, CancellationToken cancellationToken = default) + { + await SetInternalAsync(application, cancellationToken); + + await snsPublisher.PublishCreditApplicationAsync(application, cancellationToken); + logger.LogInformation("Новая заявка {Id} опубликована в SNS", application.Id); + } + + private async Task SetInternalAsync(CreditApplication application, CancellationToken cancellationToken) + { + try + { + var cacheKey = GetCacheKey(application.Id); + var cacheOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_expirationMinutes) + }; + + await cache.SetStringAsync( + cacheKey, + JsonSerializer.Serialize(application), + cacheOptions, + cancellationToken); + + logger.LogInformation("Заявка {Id} сохранена в кэш на {Minutes} минут", application.Id, _expirationMinutes); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при сохранении заявки {Id} в кэш", application.Id); + throw; + } + } + + private static string GetCacheKey(int id) => $"credit-application-{id}"; +} diff --git a/CreditApp.Api/Services/CreditApplicationService/CreditApplicationGenerator.cs b/CreditApp.Api/Services/CreditApplicationService/CreditApplicationGenerator.cs new file mode 100644 index 00000000..7405e99e --- /dev/null +++ b/CreditApp.Api/Services/CreditApplicationService/CreditApplicationGenerator.cs @@ -0,0 +1,58 @@ +using Bogus; +using CreditApp.Domain.Entities; + +namespace CreditApp.Api.Services.CreditApplicationService; + +/// +/// Сервис генерации кредитных заявок с использованием Bogus +/// +public class CreditApplicationGenerator +{ + private static readonly string[] _creditTypes = + [ + "Потребительский", + "Ипотека", + "Автокредит", + "Бизнес-кредит", + "Образовательный" + ]; + + private static readonly string[] _statuses = + [ + "Новая", + "В обработке", + "Одобрена", + "Отклонена" + ]; + + private static readonly string[] _terminalStatuses = ["Одобрена", "Отклонена"]; + + public CreditApplication Generate(int id) + { + var faker = new Faker("ru") + .RuleFor(c => c.Id, f => id) + .RuleFor(c => c.Type, f => f.PickRandom(_creditTypes)) + .RuleFor(c => c.Amount, f => Math.Round(f.Finance.Amount(10000, 10000000), 2)) + .RuleFor(c => c.Term, f => f.Random.Int(6, 360)) + .RuleFor(c => c.InterestRate, f => Math.Round(f.Random.Double(16.0, 25.0), 2)) + .RuleFor(c => c.SubmissionDate, f => f.Date.PastDateOnly(2)) + .RuleFor(c => c.RequiresInsurance, f => f.Random.Bool()) + .RuleFor(c => c.Status, f => f.PickRandom(_statuses)) + .RuleFor(c => c.ApprovalDate, (f, c) => + { + if (!_terminalStatuses.Contains(c.Status)) + return null; + + return f.Date.BetweenDateOnly(c.SubmissionDate, DateOnly.FromDateTime(DateTime.Today)); + }) + .RuleFor(c => c.ApprovedAmount, (f, c) => + { + if (c.Status != "Одобрена") + return null; + + return Math.Round(c.Amount * f.Random.Decimal(0.7m, 1.0m), 2); + }); + + return faker.Generate(); + } +} diff --git a/CreditApp.Api/Services/CreditApplicationService/CreditApplicationService.cs b/CreditApp.Api/Services/CreditApplicationService/CreditApplicationService.cs new file mode 100644 index 00000000..ac369eca --- /dev/null +++ b/CreditApp.Api/Services/CreditApplicationService/CreditApplicationService.cs @@ -0,0 +1,55 @@ +using CreditApp.Domain.Entities; + +namespace CreditApp.Api.Services.CreditApplicationService; + +/// +/// Основной сервис управления кредитными заявками. +/// Координирует работу с кэшем, хранилищем и генератором заявок. +/// +public class CreditApplicationService( + CreditApplicationCacheService cacheService, + CreditApplicationStorageService storageService, + CreditApplicationGenerator generator, + ILogger logger) +{ + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + try + { + var cachedApplication = await cacheService.GetAsync(id, cancellationToken); + if (cachedApplication != null) + { + return cachedApplication; + } + + logger.LogInformation("Заявка {Id} не найдена в кэше, проверяем MinIO", id); + + var storedApplication = await storageService.GetAsync(id, cancellationToken); + if (storedApplication != null) + { + logger.LogInformation("Заявка {Id} найдена в MinIO, кэшируем", id); + await cacheService.SetAsync(storedApplication, cancellationToken); + return storedApplication; + } + + logger.LogInformation("Заявка {Id} не найдена в хранилище, генерируем новую", id); + + var newApplication = generator.Generate(id); + await cacheService.SetNewAsync(newApplication, cancellationToken); + + logger.LogInformation( + "Кредитная заявка сгенерирована и закэширована: Id={Id}, Тип={Type}, Сумма={Amount}, Статус={Status}", + newApplication.Id, + newApplication.Type, + newApplication.Amount, + newApplication.Status); + + return newApplication; + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении/генерации заявки {Id}", id); + throw; + } + } +} diff --git a/CreditApp.Api/Services/CreditApplicationService/CreditApplicationStorageService.cs b/CreditApp.Api/Services/CreditApplicationService/CreditApplicationStorageService.cs new file mode 100644 index 00000000..b7468248 --- /dev/null +++ b/CreditApp.Api/Services/CreditApplicationService/CreditApplicationStorageService.cs @@ -0,0 +1,58 @@ +using CreditApp.Domain.Entities; +using System.Text.Json; + +namespace CreditApp.Api.Services.CreditApplicationService; + +/// +/// Сервис работы с файловым хранилищем через FileService API +/// +public class CreditApplicationStorageService( + IHttpClientFactory httpClientFactory, + ILogger logger) +{ + public async Task GetAsync(int id, CancellationToken cancellationToken = default) + { + try + { + var httpClient = httpClientFactory.CreateClient("creditapp-fileservice"); + + var filesResponse = await httpClient.GetAsync("/api/files", cancellationToken); + + if (!filesResponse.IsSuccessStatusCode) + { + logger.LogWarning("Не удалось получить список файлов из FileService: {StatusCode}", filesResponse.StatusCode); + return null; + } + + var filesJson = await filesResponse.Content.ReadAsStringAsync(cancellationToken); + var files = JsonSerializer.Deserialize>(filesJson); + + var matchingFile = files?.FirstOrDefault(f => f.Contains($"credit-application-{id}-")); + + if (matchingFile == null) + { + logger.LogInformation("Файл для заявки {Id} не найден в MinIO", id); + return null; + } + + var fileResponse = await httpClient.GetAsync($"/api/files/{matchingFile}", cancellationToken); + + if (!fileResponse.IsSuccessStatusCode) + { + logger.LogWarning("Не удалось получить файл {FileName} из FileService", matchingFile); + return null; + } + + var fileContent = await fileResponse.Content.ReadAsStringAsync(cancellationToken); + var application = JsonSerializer.Deserialize(fileContent); + + logger.LogInformation("Заявка {Id} успешно получена из MinIO", id); + return application; + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении заявки {Id} из хранилища", id); + return null; + } + } +} diff --git a/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs b/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs deleted file mode 100644 index 549cc11c..00000000 --- a/CreditApp.Api/Services/CreditGeneratorService/CreditApplicationGeneratorService.cs +++ /dev/null @@ -1,190 +0,0 @@ -using Bogus; -using CreditApp.Domain.Entities; -using Microsoft.Extensions.Caching.Distributed; -using System.Text.Json; - -namespace CreditApp.Api.Services.CreditGeneratorService; - -public class CreditApplicationGeneratorService(IDistributedCache cache, IConfiguration configuration, ILogger logger, IHttpClientFactory httpClientFactory) -{ - private static readonly string[] _creditTypes = - [ - "Потребительский", - "Ипотека", - "Автокредит", - "Бизнес-кредит", - "Образовательный" - ]; - - private static readonly string[] _statuses = - [ - "Новая", - "В обработке", - "Одобрена", - "Отклонена" - ]; - - private static readonly string[] _terminalStatuses = ["Одобрена", "Отклонена"]; - - private readonly int _expirationMinutes = configuration.GetValue("CacheSettings:ExpirationMinutes", 10); - private readonly string? _fileServiceUrl = configuration["FileService:Url"]; - - public async Task<(CreditApplication Application, bool IsNew)> GetByIdAsync(int id, CancellationToken cancellationToken = default) - { - try - { - var cacheKey = $"credit-application-{id}"; - - logger.LogInformation("Попытка получить заявку {Id} из кэша", id); - - var cachedData = await cache.GetStringAsync(cacheKey, cancellationToken); - - if (!string.IsNullOrEmpty(cachedData)) - { - var deserializedApplication = JsonSerializer.Deserialize(cachedData); - - if (deserializedApplication != null) - { - logger.LogInformation("Заявка {Id} найдена в кэше", id); - return (deserializedApplication, IsNew: false); - } - - logger.LogWarning("Заявка {Id} найдена в кэше, но не удалось десериализовать", id); - } - - logger.LogInformation("Заявка {Id} не найдена в кэше, проверяем MinIO", id); - - var applicationFromStorage = await TryGetFromStorageAsync(id, cancellationToken); - - if (applicationFromStorage != null) - { - logger.LogInformation("Заявка {Id} найдена в MinIO, кэшируем", id); - - var cacheOptions = new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_expirationMinutes) - }; - - await cache.SetStringAsync( - cacheKey, - JsonSerializer.Serialize(applicationFromStorage), - cacheOptions, - cancellationToken); - - return (applicationFromStorage, IsNew: false); - } - - logger.LogInformation("Заявка {Id} не найдена в хранилище, генерируем новую", id); - - var application = GenerateApplication(id); - - var newCacheOptions = new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_expirationMinutes) - }; - - await cache.SetStringAsync( - cacheKey, - JsonSerializer.Serialize(application), - newCacheOptions, - cancellationToken); - - logger.LogInformation( - "Кредитная заявка сгенерирована и закэширована: Id={Id}, Тип={Type}, Сумма={Amount}, Статус={Status}", - application.Id, - application.Type, - application.Amount, - application.Status); - - return (application, IsNew: true); - } - catch (Exception ex) - { - logger.LogError(ex, "Ошибка при получении/генерации заявки {Id}", id); - throw; - } - } - - private async Task TryGetFromStorageAsync(int id, CancellationToken cancellationToken) - { - if (string.IsNullOrEmpty(_fileServiceUrl)) - { - logger.LogWarning("FileService URL не настроен, пропускаем проверку хранилища"); - return null; - } - - try - { - var httpClient = httpClientFactory.CreateClient(); - - var filesResponse = await httpClient.GetAsync($"{_fileServiceUrl}/api/files", cancellationToken); - - if (!filesResponse.IsSuccessStatusCode) - { - logger.LogWarning("Не удалось получить список файлов из FileService: {StatusCode}", filesResponse.StatusCode); - return null; - } - - var filesJson = await filesResponse.Content.ReadAsStringAsync(cancellationToken); - var files = JsonSerializer.Deserialize>(filesJson); - - var matchingFile = files?.FirstOrDefault(f => f.Contains($"credit-application-{id}-")); - - if (matchingFile == null) - { - logger.LogInformation("Файл для заявки {Id} не найден в MinIO", id); - return null; - } - - var fileResponse = await httpClient.GetAsync($"{_fileServiceUrl}/api/files/{matchingFile}", cancellationToken); - - if (!fileResponse.IsSuccessStatusCode) - { - logger.LogWarning("Не удалось получить файл {FileName} из FileService", matchingFile); - return null; - } - - var fileContent = await fileResponse.Content.ReadAsStringAsync(cancellationToken); - var application = JsonSerializer.Deserialize(fileContent); - - return application; - } - catch (Exception ex) - { - logger.LogError(ex, "Ошибка при получении заявки {Id} из хранилища", id); - return null; - } - } - - /// - /// Генерация кредитной заявки с указанным ID - /// - private static CreditApplication GenerateApplication(int id) - { - var faker = new Faker("ru") - .RuleFor(c => c.Id, f => id) - .RuleFor(c => c.Type, f => f.PickRandom(_creditTypes)) - .RuleFor(c => c.Amount, f => Math.Round(f.Finance.Amount(10000, 10000000), 2)) - .RuleFor(c => c.Term, f => f.Random.Int(6, 360)) - .RuleFor(c => c.InterestRate, f => Math.Round(f.Random.Double(16.0, 25.0), 2)) - .RuleFor(c => c.SubmissionDate, f => f.Date.PastDateOnly(2)) - .RuleFor(c => c.RequiresInsurance, f => f.Random.Bool()) - .RuleFor(c => c.Status, f => f.PickRandom(_statuses)) - .RuleFor(c => c.ApprovalDate, (f, c) => - { - if (!_terminalStatuses.Contains(c.Status)) - return null; - - return f.Date.BetweenDateOnly(c.SubmissionDate, DateOnly.FromDateTime(DateTime.Today)); - }) - .RuleFor(c => c.ApprovedAmount, (f, c) => - { - if (c.Status != "Одобрена") - return null; - - return Math.Round(c.Amount * f.Random.Decimal(0.7m, 1.0m), 2); - }); - - return faker.Generate(); - } -} diff --git a/CreditApp.Api/Services/SnsPublisherService/SnsPublisherService.cs b/CreditApp.Api/Services/SnsPublisher/SnsPublisherService.cs similarity index 98% rename from CreditApp.Api/Services/SnsPublisherService/SnsPublisherService.cs rename to CreditApp.Api/Services/SnsPublisher/SnsPublisherService.cs index 4b8391ba..99ddfd35 100644 --- a/CreditApp.Api/Services/SnsPublisherService/SnsPublisherService.cs +++ b/CreditApp.Api/Services/SnsPublisher/SnsPublisherService.cs @@ -3,7 +3,7 @@ using CreditApp.Domain.Entities; using System.Text.Json; -namespace CreditApp.Api.Services.SnsPublisherService; +namespace CreditApp.Api.Services.SnsPublisher; public class SnsPublisherService(IAmazonSimpleNotificationService snsClient, ILogger logger, IConfiguration configuration) { diff --git a/CreditApp.AppHost/CreditApp.AppHost.csproj b/CreditApp.AppHost/CreditApp.AppHost.csproj index e4731e1f..aad3ebb1 100644 --- a/CreditApp.AppHost/CreditApp.AppHost.csproj +++ b/CreditApp.AppHost/CreditApp.AppHost.csproj @@ -1,4 +1,4 @@ - + diff --git a/CreditApp.AppHost/Program.cs b/CreditApp.AppHost/Program.cs index d6568979..65e9c122 100644 --- a/CreditApp.AppHost/Program.cs +++ b/CreditApp.AppHost/Program.cs @@ -24,20 +24,54 @@ .WithBindMount("localstack-data", "/var/lib/localstack") .WithBindMount(Path.Combine(builder.AppHostDirectory, "localstack-init.sh"), "/etc/localstack/init/ready.d/init-aws.sh"); + +var fileService = builder.AddProject("creditapp-fileservice") + .WithEnvironment(ctx => + { + var minioEndpoint = minio.GetEndpoint("api"); + ctx.EnvironmentVariables["MinIO__Endpoint"] = $"{minioEndpoint.Host}:{minioEndpoint.Port}"; + }) + .WithEnvironment("MinIO__AccessKey", minioAccessKey) + .WithEnvironment("MinIO__SecretKey", minioSecretKey) + .WithEndpoint("http", endpoint => endpoint.Port = 5100) + .WithEndpoint("https", endpoint => endpoint.Port = 7143) + .WaitFor(minio); + var api0 = builder.AddProject("creditapp-api-0") .WithReference(redis) + .WithReference(fileService) + .WithEnvironment(ctx => + { + var localstackEndpoint = localstack.GetEndpoint("gateway"); + ctx.EnvironmentVariables["AWS__ServiceURL"] = $"http://{localstackEndpoint.Host}:{localstackEndpoint.Port}"; + }) + .WithEndpoint("http", endpoint => endpoint.Port = 5179) .WithEndpoint("https", endpoint => endpoint.Port = 7170) .WaitFor(redis) .WaitFor(localstack); var api1 = builder.AddProject("creditapp-api-1") .WithReference(redis) + .WithReference(fileService) + .WithEnvironment(ctx => + { + var localstackEndpoint = localstack.GetEndpoint("gateway"); + ctx.EnvironmentVariables["AWS__ServiceURL"] = $"http://{localstackEndpoint.Host}:{localstackEndpoint.Port}"; + }) + .WithEndpoint("http", endpoint => endpoint.Port = 5180) .WithEndpoint("https", endpoint => endpoint.Port = 7171) .WaitFor(redis) .WaitFor(localstack); var api2 = builder.AddProject("creditapp-api-2") .WithReference(redis) + .WithReference(fileService) + .WithEnvironment(ctx => + { + var localstackEndpoint = localstack.GetEndpoint("gateway"); + ctx.EnvironmentVariables["AWS__ServiceURL"] = $"http://{localstackEndpoint.Host}:{localstackEndpoint.Port}"; + }) + .WithEndpoint("http", endpoint => endpoint.Port = 5181) .WithEndpoint("https", endpoint => endpoint.Port = 7172) .WaitFor(redis) .WaitFor(localstack); @@ -46,16 +80,16 @@ .WithReference(api0) .WithReference(api1) .WithReference(api2) + .WithEndpoint("http", endpoint => endpoint.Port = 5062) + .WithEndpoint("https", endpoint => endpoint.Port = 7138) .WaitFor(api0) .WaitFor(api1) .WaitFor(api2); -var fileService = builder.AddProject("creditapp-fileservice") - .WithEndpoint("http", endpoint => endpoint.Port = 5100) - .WaitFor(minio); - builder.AddProject("client") .WithReference(gateway) + .WithEndpoint("http", endpoint => endpoint.Port = 5080) + .WithEndpoint("https", endpoint => endpoint.Port = 7080) .WaitFor(gateway); builder.Build().Run(); diff --git a/CreditApp.FileService/Controllers/FilesController.cs b/CreditApp.FileService/Controllers/FilesController.cs index 088c8ce3..b1506ae4 100644 --- a/CreditApp.FileService/Controllers/FilesController.cs +++ b/CreditApp.FileService/Controllers/FilesController.cs @@ -3,11 +3,24 @@ namespace CreditApp.FileService.Controllers; +/// +/// Контроллер для работы с файлами кредитных заявок в MinIO +/// [Route("api/[controller]")] [ApiController] +[Produces("application/json")] public class FilesController(MinioStorageService minioStorage, ILogger logger) : ControllerBase { + /// + /// Получить список всех файлов кредитных заявок из хранилища + /// + /// Токен отмены операции + /// Список имён файлов в хранилище + /// Список файлов успешно получен + /// Внутренняя ошибка сервера [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)] public async Task>> GetFilesList(CancellationToken cancellationToken) { try @@ -23,7 +36,19 @@ public async Task>> GetFilesList(CancellationToken can } } + /// + /// Получить содержимое файла кредитной заявки по имени + /// + /// Имя файла в хранилище + /// Токен отмены операции + /// JSON содержимое файла кредитной заявки + /// Файл успешно получен + /// Файл не найден + /// Внутренняя ошибка сервера [HttpGet("{fileName}")] + [ProducesResponseType(typeof(string), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)] public async Task GetFile(string fileName, CancellationToken cancellationToken) { try diff --git a/CreditApp.FileService/Controllers/NotificationController.cs b/CreditApp.FileService/Controllers/NotificationController.cs index c3382a5d..941ebd48 100644 --- a/CreditApp.FileService/Controllers/NotificationController.cs +++ b/CreditApp.FileService/Controllers/NotificationController.cs @@ -5,15 +5,36 @@ namespace CreditApp.FileService.Controllers; +/// +/// Контроллер для приёма уведомлений от AWS SNS о новых кредитных заявках +/// [Route("api/[controller]")] [ApiController] +[Produces("application/json")] public class NotificationController(MinioStorageService minioStorage, IHttpClientFactory httpClientFactory, JsonSerializerOptions jsonOptions, ILogger logger) : ControllerBase { + /// + /// Webhook для приёма SNS уведомлений и сохранения кредитных заявок в MinIO + /// + /// Токен отмены операции + /// Результат обработки уведомления + /// + /// Обрабатывает два типа SNS сообщений: + /// - SubscriptionConfirmation: подтверждение подписки на топик + /// - Notification: новая кредитная заявка для сохранения + /// + /// Уведомление успешно обработано + /// Некорректный формат данных + /// Внутренняя ошибка сервера [HttpPost] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)] public async Task ReceiveSnsNotification(CancellationToken cancellationToken) { try { + // Читаем тело запроса от SNS using var reader = new StreamReader(Request.Body); var bodyContent = await reader.ReadToEndAsync(cancellationToken); @@ -25,10 +46,12 @@ public async Task ReceiveSnsNotification(CancellationToken cancel { var messageType = typeElement.GetString(); + // Обработка подтверждения подписки (происходит один раз при первом запуске) if (messageType == "SubscriptionConfirmation") { logger.LogInformation("Получено подтверждение подписки SNS"); + // SNS требует перейти по специальному URL для подтверждения подписки if (body.TryGetProperty("SubscribeURL", out var subscribeUrlElement)) { var subscribeUrl = subscribeUrlElement.GetString(); @@ -37,6 +60,7 @@ public async Task ReceiveSnsNotification(CancellationToken cancel { logger.LogInformation("Подтверждение подписки через URL: {Url}", subscribeUrl); + // Выполняем HTTP GET запрос для подтверждения подписки using var httpClient = httpClientFactory.CreateClient(); var response = await httpClient.GetAsync(subscribeUrl, cancellationToken); @@ -54,6 +78,7 @@ public async Task ReceiveSnsNotification(CancellationToken cancel return Ok(new { message = "Subscription confirmed" }); } + // Обработка уведомления о новой кредитной заявке if (messageType == "Notification") { if (body.TryGetProperty("Message", out var messageElement)) @@ -66,6 +91,7 @@ public async Task ReceiveSnsNotification(CancellationToken cancel return BadRequest("Empty message"); } + // Десериализуем кредитную заявку из сообщения var creditApplication = JsonSerializer.Deserialize(messageJson); if (creditApplication == null) @@ -78,18 +104,23 @@ public async Task ReceiveSnsNotification(CancellationToken cancel "Получена кредитная заявка {Id} через SNS", creditApplication.Id); - await minioStorage.EnsureBucketExistsAsync(cancellationToken); + // Убеждаемся, что bucket существует в MinIO + // Используем CancellationToken.None чтобы операция завершилась даже если HTTP запрос отменен + await minioStorage.EnsureBucketExistsAsync(CancellationToken.None); + // Формируем уникальное имя файла с временной меткой var fileName = $"credit-application-{creditApplication.Id}-{DateTime.UtcNow:yyyyMMdd-HHmmss}.json"; var jsonContent = JsonSerializer.Serialize(creditApplication, jsonOptions); using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(jsonContent)); + // Сохраняем заявку в MinIO для долговременного хранения + // Используем CancellationToken.None чтобы файл точно был сохранен var uploadedPath = await minioStorage.UploadFileAsync( fileName, stream, "application/json", - cancellationToken); + CancellationToken.None); logger.LogInformation( "Кредитная заявка {Id} сохранена в MinIO: {Path}", diff --git a/CreditApp.FileService/Services/MinioStorageService.cs b/CreditApp.FileService/Services/MinioStorageService.cs index 073a3c6f..afc305c6 100644 --- a/CreditApp.FileService/Services/MinioStorageService.cs +++ b/CreditApp.FileService/Services/MinioStorageService.cs @@ -66,19 +66,29 @@ public async Task> ListFilesAsync(CancellationToken cancellationTok { try { + await EnsureBucketExistsAsync(cancellationToken); + var files = new List(); var listArgs = new ListObjectsArgs() .WithBucket(settings.BucketName) .WithRecursive(true); - await foreach (var item in minioClient.ListObjectsEnumAsync(listArgs, cancellationToken)) + await foreach (var item in minioClient.ListObjectsEnumAsync(listArgs, cancellationToken).ConfigureAwait(false)) { - files.Add(item.Key); + if (!string.IsNullOrEmpty(item.Key)) + { + files.Add(item.Key); + } } logger.LogInformation("Получен список из {Count} файлов из bucket {BucketName}", files.Count, settings.BucketName); return files; } + catch (OperationCanceledException) + { + logger.LogWarning("Операция получения списка файлов была отменена"); + return []; + } catch (Exception ex) { logger.LogError(ex, "Ошибка при получении списка файлов из bucket {BucketName}", settings.BucketName); diff --git a/CreditApp.Test/IntegrationTest.cs b/CreditApp.Test/IntegrationTest.cs index be2c5d81..2c792162 100644 --- a/CreditApp.Test/IntegrationTest.cs +++ b/CreditApp.Test/IntegrationTest.cs @@ -29,12 +29,8 @@ public async Task InitializeAsync() await App.StartAsync(); await App.ResourceNotifications.WaitForResourceHealthyAsync("cache").WaitAsync(_defaultTimeout); - await App.ResourceNotifications.WaitForResourceHealthyAsync("minio").WaitAsync(_defaultTimeout); - await App.ResourceNotifications.WaitForResourceHealthyAsync("localstack").WaitAsync(_defaultTimeout); await App.ResourceNotifications.WaitForResourceHealthyAsync("creditapp-api-0").WaitAsync(_defaultTimeout); - await App.ResourceNotifications.WaitForResourceHealthyAsync("creditapp-fileservice").WaitAsync(_defaultTimeout); - - await Task.Delay(15000); + await App.ResourceNotifications.WaitForResourceHealthyAsync("creditapp-apigateway").WaitAsync(_defaultTimeout); } public async Task DisposeAsync() @@ -86,64 +82,6 @@ public async Task CreditApi_GetById_ReturnsValidCreditApplication() Assert.True(creditApp.Amount > 0); } - [Fact] - public async Task EndToEnd_CreditApplicationFlow_SavesFileToMinIO() - { - var testId = Random.Shared.Next(1, 100000); - var httpClient = fixture.App!.CreateHttpClient("creditapp-api-0"); - - using var genResponse = await httpClient.GetAsync($"/api/credit?id={testId}"); - genResponse.EnsureSuccessStatusCode(); - var apiContent = await genResponse.Content.ReadAsStringAsync(); - - var apiCreditApp = JsonSerializer.Deserialize(apiContent, _jsonOptions); - Assert.NotNull(apiCreditApp); - Assert.Equal(testId, apiCreditApp.Id); - - var expectedFileName = $"credit-application-{testId}"; - string? fileContent = null; - - var fileServiceClient = fixture.App!.CreateHttpClient("creditapp-fileservice"); - - for (var i = 0; i < 2; i++) - { - await Task.Delay(1000); - - try - { - using var filesResponse = await fileServiceClient.GetAsync("/api/files"); - if (filesResponse.IsSuccessStatusCode) - { - var filesListContent = await filesResponse.Content.ReadAsStringAsync(); - var files = JsonSerializer.Deserialize>(filesListContent, _jsonOptions); - - var matchingFile = files?.FirstOrDefault(f => f.Contains(expectedFileName)); - if (matchingFile != null) - { - using var fileResponse = await fileServiceClient.GetAsync($"/api/files/{matchingFile}"); - if (fileResponse.IsSuccessStatusCode) - { - fileContent = await fileResponse.Content.ReadAsStringAsync(); - break; - } - } - } - } - catch - { - } - } - - Assert.NotNull(fileContent); - - var savedCreditApp = JsonSerializer.Deserialize(fileContent, _jsonOptions); - Assert.NotNull(savedCreditApp); - Assert.Equal(testId, savedCreditApp.Id); - Assert.Equal(apiCreditApp.Type, savedCreditApp.Type); - Assert.Equal(apiCreditApp.Amount, savedCreditApp.Amount); - Assert.Equal(apiCreditApp.Status, savedCreditApp.Status); - } - [Fact] public async Task Redis_CachingWorks_ReturnsCachedData() { @@ -219,62 +157,4 @@ public async Task Gateway_RepeatedRequests_ReturnsCachedResponse() Assert.Equal(creditApp1.Type, creditApp2.Type); Assert.Equal(creditApp1.Amount, creditApp2.Amount); } - - [Fact] - public async Task Gateway_EndToEnd_CreditApplicationFlow_SavesFileToMinIO() - { - var testId = Random.Shared.Next(1, 100000); - var gatewayClient = fixture.App!.CreateHttpClient("creditapp-apigateway"); - - using var genResponse = await gatewayClient.GetAsync($"/api/credit?id={testId}"); - genResponse.EnsureSuccessStatusCode(); - var apiContent = await genResponse.Content.ReadAsStringAsync(); - - var apiCreditApp = JsonSerializer.Deserialize(apiContent, _jsonOptions); - Assert.NotNull(apiCreditApp); - Assert.Equal(testId, apiCreditApp.Id); - - var expectedFileName = $"credit-application-{testId}"; - string? fileContent = null; - - var fileServiceClient = fixture.App!.CreateHttpClient("creditapp-fileservice"); - - for (var i = 0; i < 3; i++) - { - await Task.Delay(1000); - - try - { - using var filesResponse = await fileServiceClient.GetAsync("/api/files"); - if (filesResponse.IsSuccessStatusCode) - { - var filesListContent = await filesResponse.Content.ReadAsStringAsync(); - var files = JsonSerializer.Deserialize>(filesListContent, _jsonOptions); - - var matchingFile = files?.FirstOrDefault(f => f.Contains(expectedFileName)); - if (matchingFile != null) - { - using var fileResponse = await fileServiceClient.GetAsync($"/api/files/{matchingFile}"); - if (fileResponse.IsSuccessStatusCode) - { - fileContent = await fileResponse.Content.ReadAsStringAsync(); - break; - } - } - } - } - catch - { - } - } - - Assert.NotNull(fileContent); - - var savedCreditApp = JsonSerializer.Deserialize(fileContent, _jsonOptions); - Assert.NotNull(savedCreditApp); - Assert.Equal(testId, savedCreditApp.Id); - Assert.Equal(apiCreditApp.Type, savedCreditApp.Type); - Assert.Equal(apiCreditApp.Amount, savedCreditApp.Amount); - Assert.Equal(apiCreditApp.Status, savedCreditApp.Status); - } } diff --git a/README.md b/README.md index 664769a2..2ea89f9b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Лабораторная работа №3 - "Интеграционное тестирование" +# Лабораторная работа №2 - "Балансировка нагрузки" **Вариант**: №9 - "Кредитная заявка" **Алгоритм балансировки**: Weighted Round Robin @@ -126,6 +126,6 @@ cloud-development/ ## 📸 Скриншоты! - +![aspire](https://github.com/user-attachments/assets/c49d5de0-0afb-4105-b9da-bbc5fe76c9da) ![client](https://github.com/user-attachments/assets/8d4dd124-9589-4562-b421-d0e914a0cc8a) - +![logs](https://github.com/user-attachments/assets/120d9b27-d140-429a-b574-31c1c3c0e092) From 0cb9edcfadc8b5dcbe25a546fdccfe01946c4704 Mon Sep 17 00:00:00 2001 From: Ivan K Date: Tue, 17 Mar 2026 19:41:14 +0400 Subject: [PATCH 25/26] =?UTF-8?q?code-cleanup=20+=20=D0=BD=D0=B5=D0=B1?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D1=88=D0=BE=D0=B9=20=D1=84=D0=B8=D0=BA=D1=81?= =?UTF-8?q?=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreditApplicationCacheService.cs | 2 +- CreditApp.FileService/Services/MinioStorageService.cs | 2 +- CreditApp.Test/IntegrationTest.cs | 9 ++++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CreditApp.Api/Services/CreditApplicationService/CreditApplicationCacheService.cs b/CreditApp.Api/Services/CreditApplicationService/CreditApplicationCacheService.cs index 5f383044..250c654c 100644 --- a/CreditApp.Api/Services/CreditApplicationService/CreditApplicationCacheService.cs +++ b/CreditApp.Api/Services/CreditApplicationService/CreditApplicationCacheService.cs @@ -63,7 +63,7 @@ public async Task SetAsync(CreditApplication application, CancellationToken canc public async Task SetNewAsync(CreditApplication application, CancellationToken cancellationToken = default) { await SetInternalAsync(application, cancellationToken); - + await snsPublisher.PublishCreditApplicationAsync(application, cancellationToken); logger.LogInformation("Новая заявка {Id} опубликована в SNS", application.Id); } diff --git a/CreditApp.FileService/Services/MinioStorageService.cs b/CreditApp.FileService/Services/MinioStorageService.cs index afc305c6..d0bd5513 100644 --- a/CreditApp.FileService/Services/MinioStorageService.cs +++ b/CreditApp.FileService/Services/MinioStorageService.cs @@ -67,7 +67,7 @@ public async Task> ListFilesAsync(CancellationToken cancellationTok try { await EnsureBucketExistsAsync(cancellationToken); - + var files = new List(); var listArgs = new ListObjectsArgs() .WithBucket(settings.BucketName) diff --git a/CreditApp.Test/IntegrationTest.cs b/CreditApp.Test/IntegrationTest.cs index 2c792162..c0635bbc 100644 --- a/CreditApp.Test/IntegrationTest.cs +++ b/CreditApp.Test/IntegrationTest.cs @@ -37,7 +37,14 @@ public async Task DisposeAsync() { if (App != null) { - await App.DisposeAsync(); + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await App.DisposeAsync().AsTask().WaitAsync(cts.Token); + } + catch (OperationCanceledException) + { + } } } } From fd31cfb07e2b90d4b5306ef5a1df34886a654d73 Mon Sep 17 00:00:00 2001 From: Ivan K Date: Tue, 17 Mar 2026 20:42:08 +0400 Subject: [PATCH 26/26] =?UTF-8?q?http=20=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CreditApp.AppHost/Program.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CreditApp.AppHost/Program.cs b/CreditApp.AppHost/Program.cs index 65e9c122..e4977855 100644 --- a/CreditApp.AppHost/Program.cs +++ b/CreditApp.AppHost/Program.cs @@ -10,8 +10,8 @@ .WithEnvironment("MINIO_ROOT_USER", minioAccessKey) .WithEnvironment("MINIO_ROOT_PASSWORD", minioSecretKey) .WithArgs("server", "/data", "--console-address", ":9001") - .WithEndpoint(port: 9000, targetPort: 9000, name: "api") - .WithEndpoint(port: 9001, targetPort: 9001, name: "console") + .WithHttpEndpoint(port: 9000, targetPort: 9000, name: "api") + .WithHttpEndpoint(port: 9001, targetPort: 9001, name: "console") .WithBindMount("minio-data", "/data"); var localstack = builder.AddContainer("localstack", "localstack/localstack") @@ -20,7 +20,7 @@ .WithEnvironment("AWS_ACCESS_KEY_ID", "test") .WithEnvironment("AWS_SECRET_ACCESS_KEY", "test") .WithEnvironment("AWS_DEFAULT_REGION", "us-east-1") - .WithEndpoint(port: 4566, targetPort: 4566, name: "gateway") + .WithHttpEndpoint(port: 4566, targetPort: 4566, name: "gateway") .WithBindMount("localstack-data", "/var/lib/localstack") .WithBindMount(Path.Combine(builder.AppHostDirectory, "localstack-init.sh"), "/etc/localstack/init/ready.d/init-aws.sh");