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/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..5c9d6666 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,10 +4,10 @@ - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №3 Интеграционное тестирование" + Вариант №9 "Кредитная заявка" + Выполнена Куненковым Иваном 6511 + Ссылка на форк 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/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index d1fe7ab3..9d6811c0 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" -} + "BaseAddress": "https://localhost:7138/api/credit" +} \ No newline at end of file diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..fdfc5cb7 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,10 +1,23 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36811.4 +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.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 +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", "{F0BEC607-3BB6-43E6-BB44-C887447F7DCE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +28,34 @@ 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 + {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 + {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 + {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 + {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 + {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 new file mode 100644 index 00000000..ae3d8e13 --- /dev/null +++ b/CreditApp.Api/Controllers/CreditController.cs @@ -0,0 +1,26 @@ +using CreditApp.Api.Services.CreditApplicationService; +using CreditApp.Domain.Entities; +using Microsoft.AspNetCore.Mvc; + +namespace CreditApp.Api.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class CreditController(CreditApplicationService applicationService, ILogger logger) : ControllerBase +{ + /// + /// Получить кредитную заявку по ID, если не найдена в кэше генерируем новую + /// + /// ID кредитной заявки + /// Токен отмены операции + /// Кредитная заявка + [HttpGet] + public async Task> GetById([FromQuery] int id, CancellationToken cancellationToken) + { + logger.LogInformation("Получен запрос на получение/генерацию заявки {Id}", id); + + var application = await applicationService.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..deb34a5d --- /dev/null +++ b/CreditApp.Api/CreditApp.Api.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + + + + + + + diff --git a/CreditApp.Api/Program.cs b/CreditApp.Api/Program.cs new file mode 100644 index 00000000..710c40a6 --- /dev/null +++ b/CreditApp.Api/Program.cs @@ -0,0 +1,83 @@ +using Amazon.SimpleNotificationService; +using CreditApp.Api.Services.CreditApplicationService; +using CreditApp.Api.Services.SnsPublisher; +using CreditApp.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +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 => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +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(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new Microsoft.OpenApi.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(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseCors("AllowBlazorWasm"); +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..bbe9ee66 --- /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:7170;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/CreditApplicationService/CreditApplicationCacheService.cs b/CreditApp.Api/Services/CreditApplicationService/CreditApplicationCacheService.cs new file mode 100644 index 00000000..250c654c --- /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/SnsPublisher/SnsPublisherService.cs b/CreditApp.Api/Services/SnsPublisher/SnsPublisherService.cs new file mode 100644 index 00000000..99ddfd35 --- /dev/null +++ b/CreditApp.Api/Services/SnsPublisher/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.SnsPublisher; + +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 new file mode 100644 index 00000000..6a6b283d --- /dev/null +++ b/CreditApp.Api/appsettings.Development.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "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 new file mode 100644 index 00000000..7c883048 --- /dev/null +++ b/CreditApp.Api/appsettings.json @@ -0,0 +1,24 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "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/CreditApp.ApiGateway.csproj b/CreditApp.ApiGateway/CreditApp.ApiGateway.csproj new file mode 100644 index 00000000..122cdeee --- /dev/null +++ b/CreditApp.ApiGateway/CreditApp.ApiGateway.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs new file mode 100644 index 00000000..65e7e1c0 --- /dev/null +++ b/CreditApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs @@ -0,0 +1,68 @@ +using Ocelot.LoadBalancer.Errors; +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Responses; +using Ocelot.Values; + +namespace CreditApp.ApiGateway.LoadBalancing; + +/// +/// Weighted Round Robin балансировщик нагрузки для Ocelot. +/// +public class WeightedRoundRobinLoadBalancer(Func>> servicesProvider, Dictionary hostPortWeights) : ILoadBalancer +{ + private static int _currentIndex = -1; + private static int _remainingRequests = 0; + private static readonly object _lock = new(); + + 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")); + } + + var availableServices = services + .Where(s => + { + var hostPort = $"{s.HostAndPort.DownstreamHost}:{s.HostAndPort.DownstreamPort}"; + var weight = hostPortWeights.TryGetValue(hostPort, out var w) ? w : 1; + 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) % availableServices.Count; + + var service = availableServices[_currentIndex]; + var hostPort = $"{service.HostAndPort.DownstreamHost}:{service.HostAndPort.DownstreamPort}"; + + var weight = hostPortWeights.TryGetValue(hostPort, out var w) ? w : 1; + _remainingRequests = weight; + } + + var currentService = availableServices[_currentIndex]; + selectedService = currentService.HostAndPort; + + _remainingRequests--; + } + return new OkResponse(selectedService); + } + + public void Release(ServiceHostAndPort hostAndPort) { } +} \ No newline at end of file diff --git a/CreditApp.ApiGateway/Program.cs b/CreditApp.ApiGateway/Program.cs new file mode 100644 index 00000000..ca6526f6 --- /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((serviceProvider, route, serviceDiscovery) => + { + return new WeightedRoundRobinLoadBalancer(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..a2b5064a --- /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, + "creditapp-api-1": 3, + "creditapp-api-2": 2 + } +} diff --git a/CreditApp.ApiGateway/ocelot.json b/CreditApp.ApiGateway/ocelot.json new file mode 100644 index 00000000..33aa931e --- /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": "WeightedRoundRobinLoadBalancer" + } + } + ], + "GlobalConfiguration": { + "BaseUrl": "https://localhost:7138" + } +} diff --git a/CreditApp.AppHost/CreditApp.AppHost.csproj b/CreditApp.AppHost/CreditApp.AppHost.csproj new file mode 100644 index 00000000..aad3ebb1 --- /dev/null +++ b/CreditApp.AppHost/CreditApp.AppHost.csproj @@ -0,0 +1,26 @@ + + + + + + Exe + net8.0 + enable + enable + true + b8f3eae0-771a-4f3a-8df3-ef0a21b09b55 + + + + + + + + + + + + + + + diff --git a/CreditApp.AppHost/Program.cs b/CreditApp.AppHost/Program.cs new file mode 100644 index 00000000..e4977855 --- /dev/null +++ b/CreditApp.AppHost/Program.cs @@ -0,0 +1,95 @@ +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") + .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") + .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") + .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"); + + +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); + +var gateway = builder.AddProject("creditapp-apigateway") + .WithReference(api0) + .WithReference(api1) + .WithReference(api2) + .WithEndpoint("http", endpoint => endpoint.Port = 5062) + .WithEndpoint("https", endpoint => endpoint.Port = 7138) + .WaitFor(api0) + .WaitFor(api1) + .WaitFor(api2); + +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.AppHost/Properties/launchSettings.json b/CreditApp.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..fa890ea9 --- /dev/null +++ b/CreditApp.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17214;http://localhost:15105", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "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:15105", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19190", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20136" + } + } + } +} diff --git a/CreditApp.AppHost/appsettings.Development.json b/CreditApp.AppHost/appsettings.Development.json new file mode 100644 index 00000000..3d869fa4 --- /dev/null +++ b/CreditApp.AppHost/appsettings.Development.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "MinIO": { + "AccessKey": "minioadmin", + "SecretKey": "minioadmin" + } +} diff --git a/CreditApp.AppHost/appsettings.json b/CreditApp.AppHost/appsettings.json new file mode 100644 index 00000000..376f3d27 --- /dev/null +++ b/CreditApp.AppHost/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "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.Domain/CreditApp.Domain.csproj b/CreditApp.Domain/CreditApp.Domain.csproj new file mode 100644 index 00000000..fcfb2654 --- /dev/null +++ b/CreditApp.Domain/CreditApp.Domain.csproj @@ -0,0 +1,11 @@ + + + + net8.0 + enable + enable + true + $(NoWarn);1591 + + + diff --git a/CreditApp.Domain/Entities/CreditApplication.cs b/CreditApp.Domain/Entities/CreditApplication.cs new file mode 100644 index 00000000..20e32b91 --- /dev/null +++ b/CreditApp.Domain/Entities/CreditApplication.cs @@ -0,0 +1,48 @@ +namespace CreditApp.Domain.Entities; + +/// +/// Кредитная заявка +/// +public class CreditApplication +{ + /// + /// Идентификатор в системе + /// + public required int Id { get; set; } + /// + /// Тип кредита + /// + public required string Type { get; set; } + /// + /// Запрашиваемая сумма + /// + 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 required string Status { get; set; } + /// + /// Дата решения + /// + public DateOnly? ApprovalDate { get; set; } + /// + /// Одобренная сумма + /// + public decimal? ApprovedAmount { get; set; } +} 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..b1506ae4 --- /dev/null +++ b/CreditApp.FileService/Controllers/FilesController.cs @@ -0,0 +1,71 @@ +using CreditApp.FileService.Services; +using Microsoft.AspNetCore.Mvc; + +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 + { + 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 }); + } + } + + /// + /// Получить содержимое файла кредитной заявки по имени + /// + /// Имя файла в хранилище + /// Токен отмены операции + /// 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 + { + 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..941ebd48 --- /dev/null +++ b/CreditApp.FileService/Controllers/NotificationController.cs @@ -0,0 +1,144 @@ +using CreditApp.Domain.Entities; +using CreditApp.FileService.Services; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +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); + + 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"); + + // SNS требует перейти по специальному URL для подтверждения подписки + if (body.TryGetProperty("SubscribeURL", out var subscribeUrlElement)) + { + var subscribeUrl = subscribeUrlElement.GetString(); + + if (!string.IsNullOrEmpty(subscribeUrl)) + { + logger.LogInformation("Подтверждение подписки через URL: {Url}", subscribeUrl); + + // Выполняем HTTP GET запрос для подтверждения подписки + 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); + + // Убеждаемся, что 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.None); + + 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/CreditApp.FileService.csproj b/CreditApp.FileService/CreditApp.FileService.csproj new file mode 100644 index 00000000..b9b4ee00 --- /dev/null +++ b/CreditApp.FileService/CreditApp.FileService.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/CreditApp.FileService/Program.cs b/CreditApp.FileService/Program.cs new file mode 100644 index 00000000..55268fd7 --- /dev/null +++ b/CreditApp.FileService/Program.cs @@ -0,0 +1,75 @@ +using CreditApp.FileService.Configuration; +using CreditApp.FileService.Services; +using CreditApp.ServiceDefaults; +using Minio; +using System.Text.Json; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +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(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +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..2033e8e3 --- /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:5100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7143;http://localhost:5100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/CreditApp.FileService/Services/MinioStorageService.cs b/CreditApp.FileService/Services/MinioStorageService.cs new file mode 100644 index 00000000..d0bd5513 --- /dev/null +++ b/CreditApp.FileService/Services/MinioStorageService.cs @@ -0,0 +1,124 @@ +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 + { + 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).ConfigureAwait(false)) + { + 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); + 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 new file mode 100644 index 00000000..529b29fb --- /dev/null +++ b/CreditApp.FileService/appsettings.Development.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "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 new file mode 100644 index 00000000..353b3f12 --- /dev/null +++ b/CreditApp.FileService/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "MinIO": { + "Endpoint": "localhost:9000", + "AccessKey": "minioadmin", + "SecretKey": "minioadmin", + "UseSSL": false, + "BucketName": "credit-applications" + } +} diff --git a/CreditApp.ServiceDefaults/CreditApp.ServiceDefaults.csproj b/CreditApp.ServiceDefaults/CreditApp.ServiceDefaults.csproj new file mode 100644 index 00000000..bf9f33a7 --- /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; + } +} diff --git a/CreditApp.Test/CreditApp.Test.csproj b/CreditApp.Test/CreditApp.Test.csproj new file mode 100644 index 00000000..415d8e10 --- /dev/null +++ b/CreditApp.Test/CreditApp.Test.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + diff --git a/CreditApp.Test/IntegrationTest.cs b/CreditApp.Test/IntegrationTest.cs new file mode 100644 index 00000000..c0635bbc --- /dev/null +++ b/CreditApp.Test/IntegrationTest.cs @@ -0,0 +1,167 @@ +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("creditapp-api-0").WaitAsync(_defaultTimeout); + await App.ResourceNotifications.WaitForResourceHealthyAsync("creditapp-apigateway").WaitAsync(_defaultTimeout); + } + + public async Task DisposeAsync() + { + if (App != null) + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await App.DisposeAsync().AsTask().WaitAsync(cts.Token); + } + catch (OperationCanceledException) + { + } + } + } +} + +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 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); + } +} diff --git a/README.md b/README.md index dcaa5eb7..2ea89f9b 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,131 @@ -# Современные технологии разработки программного обеспечения -[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1an43o-iqlq4V_kDtkr_y7DC221hY9qdhGPrpII27sH8/edit?usp=sharing) - -## Задание -### Цель -Реализация проекта микросервисного бекенда. - -### Задачи -* Реализация межсервисной коммуникации, -* Изучение работы с брокерами сообщений, -* Изучение архитектурных паттернов, -* Изучение работы со средствами оркестрации на примере .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). - +# Лабораторная работа №2 - "Балансировка нагрузки" + +**Вариант**: №9 - "Кредитная заявка" +**Алгоритм балансировки**: Weighted Round Robin + +**Выполнил**: Куненков Иван, группа 6511 + +**Предметная область**: Генерация кредитных заявок + + +## Реализованный функционал + +### Основные возможности: +- **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 +- **Интеграционные тесты** для проверки всей системы + +## 🏗️ Архитектура + +``` +┌──────────────────────────────────────┐ +│ 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 минут │ + └────────┬────────┘ + │ + ┌────────┴────────┐ + │ AWS SNS │ ← Очередь сообщений + │ (LocalStack) │ + └────────┬────────┘ + ↓ + ┌─────────────────┐ + │ FileService │ ← Сервис файлов + │ + MinIO │ ← Постоянное хранилище + └─────────────────┘ + ↑ + ┌────────┴────────┐ + │ Aspire AppHost │ ← Оркестрация + │ + Dashboard │ + └─────────────────┘ +``` + +## 📁 Структура проекта + +``` +cloud-development/ +├── CreditApp.AppHost/ # 🎯 Aspire orchestrator +│ ├── Program.cs # Конфигурация: 3 реплики + Gateway + FileService +│ └── localstack-init.sh # Инициализация SNS топиков +│ +├── 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} +│ ├── Services/ +│ │ ├── 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 +│ +├── CreditApp.Domain/ # 📦 Модели данных +│ └── Entities/ +│ └── CreditApplication.cs # Модель кредитной заявки +│ +├── CreditApp.Test/ # 🧪 Интеграционные тесты +│ └── IntegrationTest.cs # End-to-End тесты всей системы +│ +├── Client.Wasm/ # 💻 Blazor WASM клиент +│ ├── Components/ +│ │ ├── DataCard.razor # UI для запроса заявок +│ │ └── StudentCard.razor # Информация о студенте +│ └── wwwroot/ +│ └── appsettings.json # Адрес Gateway +└── 📄 README.md # Этот файл +``` + + +## 📸 Скриншоты! + +![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)