diff --git a/Client.Wasm/Client.Wasm.csproj b/Client.Wasm/Client.Wasm.csproj
index 0ba9f90c..2b0d49fb 100644
--- a/Client.Wasm/Client.Wasm.csproj
+++ b/Client.Wasm/Client.Wasm.csproj
@@ -12,10 +12,11 @@
-
+
+
diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor
index 661f1181..d164131c 100644
--- a/Client.Wasm/Components/StudentCard.razor
+++ b/Client.Wasm/Components/StudentCard.razor
@@ -4,10 +4,10 @@
- Номер №X "Название лабораторной"
- Вариант №Х "Название варианта"
- Выполнена Фамилией Именем 65ХХ
- Ссылка на форк
+ Номер №3 Интеграционное тестирование"
+ Вариант №25 "Программный проект"
+ Выполнена Кропивенцевым Юрием 6512
+ Ссылка на форк
diff --git a/Client.Wasm/Program.cs b/Client.Wasm/Program.cs
index a182a920..c510f420 100644
--- a/Client.Wasm/Program.cs
+++ b/Client.Wasm/Program.cs
@@ -1,17 +1,23 @@
using Blazorise;
-using Blazorise.Bootstrap;
+using Blazorise.Bootstrap5;
using Blazorise.Icons.FontAwesome;
using Client.Wasm;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
+
+builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false);
+builder.Configuration.AddJsonFile($"appsettings.{builder.HostEnvironment.Environment}.json", optional: true, reloadOnChange: false);
+
builder.RootComponents.Add("#app");
builder.RootComponents.Add("head::after");
-builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
+var baseAddress = builder.Configuration["BaseAddress"] ?? "https://localhost:7139/api/project";
+builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(baseAddress) });
+
builder.Services.AddBlazorise(options => { options.Immediate = true; })
- .AddBootstrapProviders()
+ .AddBootstrap5Providers()
.AddFontAwesomeIcons();
await builder.Build().RunAsync();
diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json
index d1fe7ab3..8c44c721 100644
--- a/Client.Wasm/wwwroot/appsettings.json
+++ b/Client.Wasm/wwwroot/appsettings.json
@@ -6,5 +6,5 @@
}
},
"AllowedHosts": "*",
- "BaseAddress": ""
+ "BaseAddress": "https://localhost:7139/api/project"
}
diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln
index cb48241d..72b8e1a4 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}") = "ProjectApp.Api", "ProjectApp.Api\ProjectApp.Api.csproj", "{E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.Domain", "ProjectApp.Domain\ProjectApp.Domain.csproj", "{CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.ServiceDefaults", "ProjectApp.ServiceDefaults\ProjectApp.ServiceDefaults.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.AppHost", "ProjectApp.AppHost\ProjectApp.AppHost.csproj", "{2A5FB573-9376-4FEB-9289-A8387F435C13}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.ApiGateway", "ProjectApp.ApiGateway\ProjectApp.ApiGateway.csproj", "{C7D4CA8B-53EA-9676-D96D-BE2F0CB11055}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Service.FileStorage", "Service.FileStorage\Service.FileStorage.csproj", "{5BFA3D71-4E8F-4E6F-A6F9-3E8D8B3A4E11}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.AppHost.Test", "ProjectApp.AppHost.Test\ProjectApp.AppHost.Test.csproj", "{8F1C1E77-CC67-4B2D-8D4D-2E6A2E0F9A22}"
+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
+ {C7D4CA8B-53EA-9676-D96D-BE2F0CB11055}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C7D4CA8B-53EA-9676-D96D-BE2F0CB11055}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C7D4CA8B-53EA-9676-D96D-BE2F0CB11055}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C7D4CA8B-53EA-9676-D96D-BE2F0CB11055}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5BFA3D71-4E8F-4E6F-A6F9-3E8D8B3A4E11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5BFA3D71-4E8F-4E6F-A6F9-3E8D8B3A4E11}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5BFA3D71-4E8F-4E6F-A6F9-3E8D8B3A4E11}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5BFA3D71-4E8F-4E6F-A6F9-3E8D8B3A4E11}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8F1C1E77-CC67-4B2D-8D4D-2E6A2E0F9A22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8F1C1E77-CC67-4B2D-8D4D-2E6A2E0F9A22}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8F1C1E77-CC67-4B2D-8D4D-2E6A2E0F9A22}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8F1C1E77-CC67-4B2D-8D4D-2E6A2E0F9A22}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/ProjectApp.Api/Controllers/ProjectController.cs b/ProjectApp.Api/Controllers/ProjectController.cs
new file mode 100644
index 00000000..137d0e1f
--- /dev/null
+++ b/ProjectApp.Api/Controllers/ProjectController.cs
@@ -0,0 +1,26 @@
+using ProjectApp.Api.Services.ProjectGeneratorService;
+using ProjectApp.Domain.Entities;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ProjectApp.Api.Controllers;
+
+[Route("api/[controller]")]
+[ApiController]
+public class ProjectController(ISoftwareProjectGeneratorService generatorService, ILogger logger) : ControllerBase
+{
+ ///
+ /// Получить программный проект по ID, если не найден в кэше — сгенерировать новый
+ ///
+ /// ID проекта
+ /// Токен отмены операции
+ /// Программный проект
+ [HttpGet]
+ public async Task> GetById([FromQuery] int id, CancellationToken cancellationToken)
+ {
+ logger.LogInformation("Received request to retrieve/generate project {Id}", id);
+
+ var project = await generatorService.GetByIdAsync(id, cancellationToken);
+
+ return Ok(project);
+ }
+}
diff --git a/ProjectApp.Api/Messaging/IProjectPublisherService.cs b/ProjectApp.Api/Messaging/IProjectPublisherService.cs
new file mode 100644
index 00000000..cb81c304
--- /dev/null
+++ b/ProjectApp.Api/Messaging/IProjectPublisherService.cs
@@ -0,0 +1,15 @@
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.Api.Messaging;
+
+///
+/// Служба для отправки сгенерированных проектов в брокер сообщений.
+///
+public interface IProjectPublisherService
+{
+ ///
+ /// Отправляет проект в брокер сообщений.
+ ///
+ /// Программный проект
+ public Task SendMessage(SoftwareProject project);
+}
diff --git a/ProjectApp.Api/Messaging/SqsProducerService.cs b/ProjectApp.Api/Messaging/SqsProducerService.cs
new file mode 100644
index 00000000..ac3ed42e
--- /dev/null
+++ b/ProjectApp.Api/Messaging/SqsProducerService.cs
@@ -0,0 +1,41 @@
+using System.Net;
+using System.Text.Json;
+using Amazon.SQS;
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.Api.Messaging;
+
+///
+/// Служба для отправки сообщений в SQS.
+///
+/// Клиент SQS
+/// Конфигурация
+/// Логгер
+public class SqsProducerService(IAmazonSQS client, IConfiguration configuration, ILogger logger) : IProjectPublisherService
+{
+ private static readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ private readonly string _queueName = configuration["AWS:Resources:SQSQueueName"]
+ ?? throw new KeyNotFoundException("SQS queue name was not found in configuration");
+
+ ///
+ public async Task SendMessage(SoftwareProject project)
+ {
+ try
+ {
+ var json = JsonSerializer.Serialize(project, _jsonOptions);
+ var response = await client.SendMessageAsync(_queueName, json);
+ if (response.HttpStatusCode == HttpStatusCode.OK)
+ logger.LogInformation("Проект {id} отправлен в файловый сервис через SQS", project.Id);
+ else
+ throw new Exception($"SQS вернул статус {response.HttpStatusCode}");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Не удалось отправить проект в очередь SQS");
+ }
+ }
+}
diff --git a/ProjectApp.Api/Program.cs b/ProjectApp.Api/Program.cs
new file mode 100644
index 00000000..1ba02500
--- /dev/null
+++ b/ProjectApp.Api/Program.cs
@@ -0,0 +1,54 @@
+using Amazon.SQS;
+using LocalStack.Client.Extensions;
+using ProjectApp.Api.Messaging;
+using ProjectApp.Api.Services.ProjectGeneratorService;
+using ProjectApp.ServiceDefaults;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.AddRedisDistributedCache("cache");
+builder.Services.AddLocalStack(builder.Configuration);
+builder.Services.AddAwsService();
+
+builder.Services.AddSingleton();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(options =>
+{
+ options.SwaggerDoc("v1", new Microsoft.OpenApi.OpenApiInfo
+ {
+ Title = "Project Generator 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, "ProjectApp.Domain.xml");
+ if (File.Exists(domainXmlPath))
+ {
+ options.IncludeXmlComments(domainXmlPath);
+ }
+});
+
+var app = builder.Build();
+
+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/ProjectApp.Api/ProjectApp.Api.csproj b/ProjectApp.Api/ProjectApp.Api.csproj
new file mode 100644
index 00000000..4e2f56ff
--- /dev/null
+++ b/ProjectApp.Api/ProjectApp.Api.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+ $(NoWarn);1591
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ProjectApp.Api/Properties/launchSettings.json b/ProjectApp.Api/Properties/launchSettings.json
new file mode 100644
index 00000000..b50d42a3
--- /dev/null
+++ b/ProjectApp.Api/Properties/launchSettings.json
@@ -0,0 +1,31 @@
+{
+ "$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"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/ProjectApp.Api/Services/ProjectGeneratorService/ISoftwareProjectGeneratorService.cs b/ProjectApp.Api/Services/ProjectGeneratorService/ISoftwareProjectGeneratorService.cs
new file mode 100644
index 00000000..298f0252
--- /dev/null
+++ b/ProjectApp.Api/Services/ProjectGeneratorService/ISoftwareProjectGeneratorService.cs
@@ -0,0 +1,11 @@
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.Api.Services.ProjectGeneratorService;
+
+///
+/// Сервис получения программного проекта
+///
+public interface ISoftwareProjectGeneratorService
+{
+ public Task GetByIdAsync(int id, CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/ProjectApp.Api/Services/ProjectGeneratorService/ProjectGenerator.cs b/ProjectApp.Api/Services/ProjectGeneratorService/ProjectGenerator.cs
new file mode 100644
index 00000000..2ca601f6
--- /dev/null
+++ b/ProjectApp.Api/Services/ProjectGeneratorService/ProjectGenerator.cs
@@ -0,0 +1,69 @@
+using Bogus;
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.Api.Services.ProjectGeneratorService;
+
+///
+/// Генератор случайных программных проектов с использованием Bogus
+///
+public class ProjectGenerator
+{
+ private readonly Faker _faker;
+
+ public ProjectGenerator()
+ {
+ _faker = new Faker("ru")
+ .RuleFor(p => p.Id, f => f.IndexFaker + 1)
+ .RuleFor(p => p.ProjectName, f => string.Join(" ",
+ f.PickRandom(
+ f.Commerce.ProductName(),
+ f.Hacker.Phrase(),
+ f.Finance.AccountName(),
+ f.Lorem.Word()),
+ f.PickRandom(
+ f.Commerce.Department(),
+ f.Hacker.Verb(),
+ f.Finance.TransactionType(),
+ f.Lorem.Word())))
+ .RuleFor(p => p.Customer, f => f.Company.CompanyName())
+ .RuleFor(p => p.ProjectManager, f => $"{f.Name.LastName()} {f.Name.FirstName()} {f.Name.FirstName()}")
+ .RuleFor(p => p.StartDate, f => f.Date.PastDateOnly(3))
+ .RuleFor(p => p.PlannedEndDate, (f, p) => p.StartDate.AddDays(f.Random.Int(30, 730)))
+ .RuleFor(p => p.Budget, f => Math.Round(f.Finance.Amount(500000, 50000000), 2))
+ .RuleFor(p => p.ActualEndDate, (f, p) =>
+ {
+ var isCompleted = f.Random.Bool(0.4f);
+ if (!isCompleted)
+ {
+ return null;
+ }
+
+ var startDateTime = p.StartDate.ToDateTime(TimeOnly.MinValue);
+ var minDate = startDateTime.AddDays(1);
+ var maxDate = DateTime.Now;
+
+ if (minDate > maxDate)
+ {
+ return DateOnly.FromDateTime(minDate);
+ }
+
+ var endDate = f.Date.Between(minDate, maxDate);
+ return DateOnly.FromDateTime(endDate);
+ })
+ .RuleFor(p => p.CompletionPercentage, (f, p) => p.ActualEndDate.HasValue ? 100 : f.Random.Int(0, 100))
+ .RuleFor(p => p.ActualCost, (f, p) =>
+ {
+ var completionFactor = p.CompletionPercentage / 100m;
+ var minFactor = Math.Max(0m, completionFactor * 0.8m);
+ var maxFactor = Math.Max(minFactor, Math.Min(1.2m, completionFactor * 1.2m + 0.05m));
+
+ var costFactor = f.Random.Decimal(minFactor, maxFactor);
+ return Math.Round(p.Budget * costFactor, 2);
+ });
+ }
+
+ ///
+ /// Генерирует один случайный программный проект
+ ///
+ public SoftwareProject Generate() => _faker.Generate();
+}
\ No newline at end of file
diff --git a/ProjectApp.Api/Services/ProjectGeneratorService/SoftwareProjectGeneratorService.cs b/ProjectApp.Api/Services/ProjectGeneratorService/SoftwareProjectGeneratorService.cs
new file mode 100644
index 00000000..f4df9c9d
--- /dev/null
+++ b/ProjectApp.Api/Services/ProjectGeneratorService/SoftwareProjectGeneratorService.cs
@@ -0,0 +1,95 @@
+using ProjectApp.Api.Messaging;
+using ProjectApp.Domain.Entities;
+using Microsoft.Extensions.Caching.Distributed;
+using System.Text.Json;
+
+namespace ProjectApp.Api.Services.ProjectGeneratorService;
+
+///
+/// Сервис получения программного проекта: сначала ищет в кэше, при промахе — генерирует новый и сохраняет
+///
+public class SoftwareProjectGeneratorService(
+ IDistributedCache cache,
+ ProjectGenerator generator,
+ IProjectPublisherService producer,
+ IConfiguration configuration,
+ ILogger logger) : ISoftwareProjectGeneratorService
+{
+ private readonly int _expirationMinutes = configuration.GetValue("CacheSettings:ExpirationMinutes", 10);
+
+ ///
+ /// Возвращает программный проект по идентификатору.
+ /// Если проект найден в кэше — возвращается из него; иначе генерируется, сохраняется в кэш и возвращается.
+ ///
+ /// Идентификатор проекта
+ /// Токен отмены операции
+ /// Программный проект
+ public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default)
+ {
+ logger.LogInformation("Attempting to retrieve software project {Id} from cache", id);
+
+ var cacheKey = $"software-project-{id}";
+
+ // Получаем проект из кэша
+ SoftwareProject? project = null;
+ try
+ {
+ var cachedData = await cache.GetStringAsync(cacheKey, cancellationToken);
+
+ if (!string.IsNullOrEmpty(cachedData))
+ {
+ project = JsonSerializer.Deserialize(cachedData);
+
+ if (project != null)
+ {
+ logger.LogInformation("Software project {Id} found in cache", id);
+ return project;
+ }
+
+ logger.LogWarning("Project {Id} was found in cache but could not be deserialized. Generating a new one", id);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to retrieve project {Id} from cache (error ignored)", id);
+ }
+
+ // Если в кэше нет или ошибка — генерируем новый проект
+ logger.LogInformation("Project {Id} not found in cache or cache unavailable, generating a new one", id);
+ project = generator.Generate();
+ project.Id = id;
+
+ await producer.SendMessage(project);
+
+ // Попытка сохранить в кэш
+ try
+ {
+ logger.LogInformation("Saving project {Id} to cache", id);
+
+ var cacheOptions = new DistributedCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_expirationMinutes)
+ };
+
+ await cache.SetStringAsync(
+ cacheKey,
+ JsonSerializer.Serialize(project),
+ cacheOptions,
+ cancellationToken);
+
+ logger.LogInformation(
+ "Software project generated and cached: Id={Id}, Name={ProjectName}, Customer={Customer}, Budget={Budget}, Completion={CompletionPercent}",
+ project.Id,
+ project.ProjectName,
+ project.Customer,
+ project.Budget,
+ project.CompletionPercentage);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to save project {Id} to cache (error ignored)", id);
+ }
+
+ return project;
+ }
+}
\ No newline at end of file
diff --git a/ProjectApp.Api/appsettings.Development.json b/ProjectApp.Api/appsettings.Development.json
new file mode 100644
index 00000000..b642d7aa
--- /dev/null
+++ b/ProjectApp.Api/appsettings.Development.json
@@ -0,0 +1,12 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "CacheSettings": {
+ "ExpirationMinutes": 10
+ }
+}
diff --git a/ProjectApp.Api/appsettings.json b/ProjectApp.Api/appsettings.json
new file mode 100644
index 00000000..b642d7aa
--- /dev/null
+++ b/ProjectApp.Api/appsettings.json
@@ -0,0 +1,12 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "CacheSettings": {
+ "ExpirationMinutes": 10
+ }
+}
diff --git a/ProjectApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs b/ProjectApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs
new file mode 100644
index 00000000..18553f64
--- /dev/null
+++ b/ProjectApp.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs
@@ -0,0 +1,68 @@
+using Ocelot.LoadBalancer.Errors;
+using Ocelot.LoadBalancer.Interfaces;
+using Ocelot.Responses;
+using Ocelot.Values;
+
+namespace ProjectApp.ApiGateway.LoadBalancing;
+
+///
+/// Weighted Round Robin балансировщик нагрузки для Ocelot.
+///
+public class WeightedRoundRobinBalancer(Func>> servicesProvider, Dictionary hostPortWeights) : ILoadBalancer
+{
+ private int _currentIndex = -1;
+ private int _remainingRequests = 0;
+ private readonly object _lock = new();
+
+ public string Type => nameof(WeightedRoundRobinBalancer);
+
+ 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.GetValueOrDefault(hostPort, 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.GetValueOrDefault(hostPort, 1);
+ _remainingRequests = weight;
+ }
+
+ var currentService = availableServices[_currentIndex];
+ selectedService = currentService.HostAndPort;
+
+ _remainingRequests--;
+ }
+ return new OkResponse(selectedService);
+ }
+
+ public void Release(ServiceHostAndPort hostAndPort) {}
+}
diff --git a/ProjectApp.ApiGateway/Program.cs b/ProjectApp.ApiGateway/Program.cs
new file mode 100644
index 00000000..5a4307de
--- /dev/null
+++ b/ProjectApp.ApiGateway/Program.cs
@@ -0,0 +1,71 @@
+using ProjectApp.ApiGateway.LoadBalancing;
+using ProjectApp.ServiceDefaults;
+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}:http: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 WeightedRoundRobinBalancer(serviceDiscovery.GetAsync, hostPortWeights);
+ });
+
+builder.Services.AddCors(options =>
+{
+ options.AddPolicy("AllowClient", policy =>
+ {
+ policy.WithOrigins("http://localhost:5127", "https://localhost:7282")
+ .WithMethods("GET")
+ .WithHeaders("Content-Type");
+ });
+});
+
+var app = builder.Build();
+
+app.UseCors("AllowClient");
+app.MapDefaultEndpoints();
+await app.UseOcelot();
+
+app.Run();
diff --git a/ProjectApp.ApiGateway/ProjectApp.ApiGateway.csproj b/ProjectApp.ApiGateway/ProjectApp.ApiGateway.csproj
new file mode 100644
index 00000000..351e8a41
--- /dev/null
+++ b/ProjectApp.ApiGateway/ProjectApp.ApiGateway.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ProjectApp.ApiGateway/Properties/launchSettings.json b/ProjectApp.ApiGateway/Properties/launchSettings.json
new file mode 100644
index 00000000..5d925a68
--- /dev/null
+++ b/ProjectApp.ApiGateway/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7139;http://localhost:5139",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/ProjectApp.ApiGateway/appsettings.Development.json b/ProjectApp.ApiGateway/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/ProjectApp.ApiGateway/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/ProjectApp.ApiGateway/appsettings.json b/ProjectApp.ApiGateway/appsettings.json
new file mode 100644
index 00000000..7d67777f
--- /dev/null
+++ b/ProjectApp.ApiGateway/appsettings.json
@@ -0,0 +1,18 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "ProjectApp.ApiGateway.LoadBalancing": "Debug"
+ }
+ },
+ "AllowedHosts": "*",
+ "GeneratorServices": [
+ "projectapp-api-0",
+ "projectapp-api-1"
+ ],
+ "ReplicaWeights": {
+ "projectapp-api-0": 5,
+ "projectapp-api-1": 3
+ }
+}
diff --git a/ProjectApp.ApiGateway/ocelot.json b/ProjectApp.ApiGateway/ocelot.json
new file mode 100644
index 00000000..cd1e7641
--- /dev/null
+++ b/ProjectApp.ApiGateway/ocelot.json
@@ -0,0 +1,26 @@
+{
+ "Routes": [
+ {
+ "DownstreamPathTemplate": "/api/project",
+ "DownstreamScheme": "http",
+ "DownstreamHostAndPorts": [
+ {
+ "Host": "localhost",
+ "Port": 7173
+ },
+ {
+ "Host": "localhost",
+ "Port": 7174
+ }
+ ],
+ "UpstreamPathTemplate": "/api/project",
+ "UpstreamHttpMethod": [ "Get" ],
+ "LoadBalancerOptions": {
+ "Type": "WeightedRoundRobinBalancer"
+ }
+ }
+ ],
+ "GlobalConfiguration": {
+ "BaseUrl": "https://localhost:7139"
+ }
+}
diff --git a/ProjectApp.AppHost.Test/IntegrationTest.cs b/ProjectApp.AppHost.Test/IntegrationTest.cs
new file mode 100644
index 00000000..313f29c2
--- /dev/null
+++ b/ProjectApp.AppHost.Test/IntegrationTest.cs
@@ -0,0 +1,160 @@
+using System.Text.Json;
+using Aspire.Hosting;
+using Microsoft.Extensions.Logging;
+using ProjectApp.Domain.Entities;
+using Xunit.Abstractions;
+
+namespace ProjectApp.AppHost.Test;
+
+///
+/// Интеграционные тесты для проверки микросервисного пайплайна:
+/// API -> SQS -> FileStorage -> MinIO
+///
+/// Служба журналирования юнит-тестов
+public class IntegrationTest(ITestOutputHelper output) : IAsyncLifetime
+{
+ private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
+
+ private DistributedApplication? _app;
+
+ ///
+ public async Task InitializeAsync()
+ {
+ var cancellationToken = CancellationToken.None;
+ Environment.SetEnvironmentVariable("DisableClient", "true");
+ var builder = await DistributedApplicationTestingBuilder
+ .CreateAsync(cancellationToken);
+ builder.Configuration["DcpPublisher:RandomizePorts"] = "false";
+ builder.Services.AddLogging(logging =>
+ {
+ logging.AddXUnit(output);
+ logging.SetMinimumLevel(LogLevel.Debug);
+ logging.AddFilter("Aspire.Hosting.Dcp", LogLevel.Debug);
+ logging.AddFilter("Aspire.Hosting", LogLevel.Debug);
+ });
+ _app = await builder.BuildAsync(cancellationToken);
+ await _app.StartAsync(cancellationToken);
+ }
+
+ ///
+ /// Проверяет, что вызов гейтвея:
+ ///
+ /// - В ответ отдаёт сгенерированный проект
+ /// - Сериализует проект в MinIO через брокер SQS и файловый сервис
+ /// - Данные, отданные клиенту и положенные в объектное хранилище, идентичны
+ ///
+ ///
+ [Fact]
+ public async Task ApiToStorageIntegrationTest()
+ {
+ var id = new Random().Next(1, 100);
+
+ using var gatewayClient = _app!.CreateHttpClient("projectapp-apigateway");
+ using var gatewayResponse = await gatewayClient.GetAsync($"/api/project?id={id}");
+ var apiProject = JsonSerializer.Deserialize(await gatewayResponse.Content.ReadAsStringAsync(), _jsonOptions);
+
+ await Task.Delay(10_000);
+
+ using var storageClient = _app!.CreateHttpClient("service-filestorage");
+ using var listResponse = await storageClient.GetAsync("/api/files");
+ var fileList = JsonSerializer.Deserialize>(await listResponse.Content.ReadAsStringAsync());
+ using var fileResponse = await storageClient.GetAsync($"/api/files/project_{id}.json");
+ var s3Project = JsonSerializer.Deserialize(await fileResponse.Content.ReadAsStringAsync(), _jsonOptions);
+
+ Assert.NotNull(fileList);
+ Assert.Single(fileList);
+ Assert.Equal($"project_{id}.json", fileList[0]);
+ Assert.NotNull(apiProject);
+ Assert.NotNull(s3Project);
+ Assert.Equal(id, s3Project.Id);
+ Assert.Equivalent(apiProject, s3Project);
+ }
+
+ ///
+ /// Проверяет, что повторный запрос проекта с тем же id обслуживается из кэша
+ /// и не приводит к повторной публикации в брокер: в бакете остаётся ровно один файл.
+ ///
+ [Fact]
+ public async Task CacheHitDoesNotDuplicateStorageObjectTest()
+ {
+ var id = new Random().Next(1, 100);
+
+ using var gatewayClient = _app!.CreateHttpClient("projectapp-apigateway");
+ using var firstResponse = await gatewayClient.GetAsync($"/api/project?id={id}");
+ var firstProject = JsonSerializer.Deserialize(await firstResponse.Content.ReadAsStringAsync(), _jsonOptions);
+ using var secondResponse = await gatewayClient.GetAsync($"/api/project?id={id}");
+ var secondProject = JsonSerializer.Deserialize(await secondResponse.Content.ReadAsStringAsync(), _jsonOptions);
+
+ await Task.Delay(10_000);
+
+ using var storageClient = _app!.CreateHttpClient("service-filestorage");
+ using var listResponse = await storageClient.GetAsync("/api/files");
+ var fileList = JsonSerializer.Deserialize>(await listResponse.Content.ReadAsStringAsync());
+
+ Assert.NotNull(firstProject);
+ Assert.NotNull(secondProject);
+ Assert.Equivalent(firstProject, secondProject);
+ Assert.NotNull(fileList);
+ Assert.Single(fileList);
+ Assert.Equal($"project_{id}.json", fileList[0]);
+ }
+
+ ///
+ /// Проверяет, что три запроса с разными идентификаторами создают три отдельных
+ /// файла в бакете, содержимое которых совпадает с ответами гейтвея.
+ ///
+ [Fact]
+ public async Task MultipleDistinctProjectsAreStoredTest()
+ {
+ var ids = new[] { 11, 22, 33 };
+ var apiProjects = new Dictionary();
+
+ using var gatewayClient = _app!.CreateHttpClient("projectapp-apigateway");
+ foreach (var id in ids)
+ {
+ using var response = await gatewayClient.GetAsync($"/api/project?id={id}");
+ var project = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(), _jsonOptions);
+ Assert.NotNull(project);
+ apiProjects[id] = project!;
+ }
+
+ await Task.Delay(10_000);
+
+ using var storageClient = _app!.CreateHttpClient("service-filestorage");
+ using var listResponse = await storageClient.GetAsync("/api/files");
+ var fileList = JsonSerializer.Deserialize>(await listResponse.Content.ReadAsStringAsync());
+
+ Assert.NotNull(fileList);
+ Assert.Equal(ids.Length, fileList!.Count);
+ foreach (var id in ids)
+ Assert.Contains($"project_{id}.json", fileList);
+
+ foreach (var id in ids)
+ {
+ using var fileResponse = await storageClient.GetAsync($"/api/files/project_{id}.json");
+ var s3Project = JsonSerializer.Deserialize(await fileResponse.Content.ReadAsStringAsync(), _jsonOptions);
+ Assert.NotNull(s3Project);
+ Assert.Equal(id, s3Project!.Id);
+ Assert.Equivalent(apiProjects[id], s3Project);
+ }
+ }
+
+ ///
+ /// Проверяет, что запрос несуществующего объекта из бакета завершается ошибкой.
+ ///
+ [Fact]
+ public async Task MissingStorageObjectReturnsErrorTest()
+ {
+ using var storageClient = _app!.CreateHttpClient("service-filestorage");
+ using var response = await storageClient.GetAsync("/api/files/project_99999.json");
+
+ Assert.False(response.IsSuccessStatusCode);
+ }
+
+ ///
+ public async Task DisposeAsync()
+ {
+ await _app!.StopAsync();
+ await _app.DisposeAsync();
+ }
+}
diff --git a/ProjectApp.AppHost.Test/ProjectApp.AppHost.Test.csproj b/ProjectApp.AppHost.Test/ProjectApp.AppHost.Test.csproj
new file mode 100644
index 00000000..080fd202
--- /dev/null
+++ b/ProjectApp.AppHost.Test/ProjectApp.AppHost.Test.csproj
@@ -0,0 +1,33 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ProjectApp.AppHost/CloudFormation/project-template.yaml b/ProjectApp.AppHost/CloudFormation/project-template.yaml
new file mode 100644
index 00000000..c067bd64
--- /dev/null
+++ b/ProjectApp.AppHost/CloudFormation/project-template.yaml
@@ -0,0 +1,32 @@
+AWSTemplateFormatVersion: '2010-09-09'
+Description: 'Cloud formation template for project app'
+
+Parameters:
+ QueueName:
+ Type: String
+ Description: Name for the SQS queue
+ Default: 'project-queue'
+
+Resources:
+ ProjectQueue:
+ Type: AWS::SQS::Queue
+ Properties:
+ QueueName: !Ref QueueName
+ VisibilityTimeout: 30
+ MessageRetentionPeriod: 345600
+ DelaySeconds: 0
+ ReceiveMessageWaitTimeSeconds: 0
+ Tags:
+ - Key: Name
+ Value: !Ref QueueName
+ - Key: Environment
+ Value: Sample
+
+Outputs:
+ SQSQueueName:
+ Description: Name of the SQS queue
+ Value: !GetAtt ProjectQueue.QueueName
+
+ SQSQueueArn:
+ Description: ARN of the SQS queue
+ Value: !GetAtt ProjectQueue.Arn
diff --git a/ProjectApp.AppHost/Program.cs b/ProjectApp.AppHost/Program.cs
new file mode 100644
index 00000000..fdccb8e7
--- /dev/null
+++ b/ProjectApp.AppHost/Program.cs
@@ -0,0 +1,60 @@
+using Aspire.Hosting.LocalStack.Container;
+using Microsoft.Extensions.Configuration;
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+var redis = builder.AddRedis("cache")
+ .WithRedisCommander();
+
+var ports = new[] { 7173, 7174 };
+var disableClient =
+ builder.Configuration.GetValue("DisableClient", false)
+ || builder.Configuration.GetValue("DisableFrontend", false);
+var apiCount = 2;
+
+var gateway = builder.AddProject("projectapp-apigateway");
+
+var localstack = builder.AddLocalStack("projectapp-localstack", configureContainer: container =>
+{
+ container.Lifetime = ContainerLifetime.Session;
+ container.DebugLevel = 1;
+ container.LogLevel = LocalStackLogLevel.Debug;
+ container.Port = 4566;
+ container.AdditionalEnvironmentVariables.Add("DEBUG", "1");
+});
+
+var awsResources = builder.AddAWSCloudFormationTemplate("resources", "CloudFormation/project-template.yaml", "projectapp");
+
+var minio = builder.AddMinioContainer("projectapp-minio");
+
+for (var i = 0; i < apiCount; i++)
+{
+ var api = builder.AddProject($"projectapp-api-{i}", launchProfileName: null)
+ .WithReference(redis)
+ .WithReference(awsResources)
+ .WithHttpEndpoint(port: ports[i])
+ .WaitFor(redis)
+ .WaitFor(awsResources);
+
+ gateway.WithReference(api).WaitFor(api);
+}
+
+builder.AddProject("service-filestorage")
+ .WithReference(awsResources)
+ .WithReference(minio)
+ .WithHttpEndpoint(port: 7180)
+ .WithEnvironment("AWS__Resources__MinioBucketName", "projectapp-bucket")
+ .WaitFor(awsResources)
+ .WaitFor(minio);
+
+if (!disableClient)
+{
+ builder.AddProject("client")
+ .WithReference(gateway)
+ .WithHttpEndpoint(port: 5127, name: "client")
+ .WaitFor(gateway);
+}
+
+builder.UseLocalStack(localstack);
+
+builder.Build().Run();
diff --git a/ProjectApp.AppHost/ProjectApp.AppHost.csproj b/ProjectApp.AppHost/ProjectApp.AppHost.csproj
new file mode 100644
index 00000000..5a55eb62
--- /dev/null
+++ b/ProjectApp.AppHost/ProjectApp.AppHost.csproj
@@ -0,0 +1,34 @@
+
+
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ true
+ b8f3eae0-771a-4f3a-8df3-ef0a21b09b55
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
diff --git a/ProjectApp.AppHost/Properties/launchSettings.json b/ProjectApp.AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000..fa890ea9
--- /dev/null
+++ b/ProjectApp.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/ProjectApp.AppHost/appsettings.Development.json b/ProjectApp.AppHost/appsettings.Development.json
new file mode 100644
index 00000000..c51d0e86
--- /dev/null
+++ b/ProjectApp.AppHost/appsettings.Development.json
@@ -0,0 +1,11 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "LocalStack": {
+ "UseLocalStack": true
+ }
+}
diff --git a/ProjectApp.AppHost/appsettings.json b/ProjectApp.AppHost/appsettings.json
new file mode 100644
index 00000000..a6b256bb
--- /dev/null
+++ b/ProjectApp.AppHost/appsettings.json
@@ -0,0 +1,12 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ },
+ "LocalStack": {
+ "UseLocalStack": true
+ }
+}
diff --git a/ProjectApp.Domain/Entities/SoftwareProject.cs b/ProjectApp.Domain/Entities/SoftwareProject.cs
new file mode 100644
index 00000000..67e21c35
--- /dev/null
+++ b/ProjectApp.Domain/Entities/SoftwareProject.cs
@@ -0,0 +1,48 @@
+namespace ProjectApp.Domain.Entities;
+
+///
+/// Программный проект
+///
+public class SoftwareProject
+{
+ ///
+ /// Идентификатор в системе
+ ///
+ public required int Id { get; set; }
+ ///
+ /// Название проекта
+ ///
+ public required string ProjectName { get; set; }
+ ///
+ /// Заказчик проекта
+ ///
+ public required string Customer { get; set; }
+ ///
+ /// Менеджер проекта
+ ///
+ public required string ProjectManager { get; set; }
+ ///
+ /// Дата начала
+ ///
+ public DateOnly StartDate { get; set; }
+ ///
+ /// Плановая дата завершения
+ ///
+ public DateOnly PlannedEndDate { get; set; }
+ ///
+ /// Фактическая дата завершения
+ ///
+ public DateOnly? ActualEndDate { get; set; }
+ ///
+ /// Бюджет
+ ///
+ public decimal Budget { get; set; }
+ ///
+ /// Фактические затраты
+ ///
+ public decimal ActualCost { get; set; }
+ ///
+ /// Процент выполнения
+ ///
+ public int CompletionPercentage { get; set; }
+}
diff --git a/ProjectApp.Domain/ProjectApp.Domain.csproj b/ProjectApp.Domain/ProjectApp.Domain.csproj
new file mode 100644
index 00000000..fcfb2654
--- /dev/null
+++ b/ProjectApp.Domain/ProjectApp.Domain.csproj
@@ -0,0 +1,11 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+ $(NoWarn);1591
+
+
+
diff --git a/ProjectApp.ServiceDefaults/Extensions.cs b/ProjectApp.ServiceDefaults/Extensions.cs
new file mode 100644
index 00000000..ac558a7d
--- /dev/null
+++ b/ProjectApp.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 ProjectApp.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/ProjectApp.ServiceDefaults/ProjectApp.ServiceDefaults.csproj b/ProjectApp.ServiceDefaults/ProjectApp.ServiceDefaults.csproj
new file mode 100644
index 00000000..bf9f33a7
--- /dev/null
+++ b/ProjectApp.ServiceDefaults/ProjectApp.ServiceDefaults.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index dcaa5eb7..21d9f1ce 100644
--- a/README.md
+++ b/README.md
@@ -1,128 +1,94 @@
-# Современные технологии разработки программного обеспечения
-[Таблица с успеваемостью](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 диаграмма
-
-
-
-## Варианты заданий
-Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи.
-
-[Список вариантов](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).
+# Лабораторные работы — «Кэширование» и «Балансировка нагрузки»
+
+**Вариант:** №25 — «Программный проект»
+**Балансировка:** Weighted Round Robin
+**Брокер:** SQS
+**Хостинг S3:** Minio
+**Выполнил:** Кропивецнев Юрий, 6512
+
+## Реализовано
+
+### ЛР1 — «Кэширование»
+
+- Генерация сущности «Программный проект» через Bogus.
+- Кэширование результатов генерации через `IDistributedCache` (Redis) с TTL 10 минут.
+- Структурное логирование запросов и результатов генерации.
+- Оркестрация сервисов через .NET Aspire.
+- REST endpoint генератора: `GET /api/project?id={id}`.
+
+### ЛР2 — «Балансировка нагрузки»
+
+- Реализован API Gateway на базе Ocelot (`ProjectApp.ApiGateway`).
+- Поднято 3 реплики сервиса генерации в Aspire:
+ - `projectapp-api-0` -> `http://localhost:7173`
+ - `projectapp-api-1` -> `http://localhost:7174`
+ - `projectapp-api-2` -> `http://localhost:7175`
+- Gateway настроен на маршрут `GET /api/project` и проксирование на реплики.
+- Имплементирован кастомный алгоритм `Weighted Round Robin`:
+ - `projectapp-api-0` — вес 5
+ - `projectapp-api-1` — вес 3
+ - `projectapp-api-2` — вес 2
+
+## Характеристики генерируемого проекта
+
+1. Идентификатор в системе — `int`
+2. Название проекта — `string`
+3. Заказчик проекта — `string`
+4. Менеджер проекта — `string`
+5. Дата начала — `DateOnly`
+6. Плановая дата завершения — `DateOnly`
+7. Фактическая дата завершения — `DateOnly?`
+8. Бюджет — `decimal`
+9. Фактические затраты — `decimal`
+10. Процент выполнения — `int`
+
+## Правила генерации
+
+- Название проекта: конкатенация генераторов из секций Commerce, Hacker, Finance, Lorem.
+- Заказчик: имя компании (Commerce).
+- Менеджер: фамилия, имя и «отчество» (конкатенация из Name).
+- Плановая дата завершения всегда позже даты начала.
+- Фактическая дата завершения (если есть) всегда позже даты начала.
+- Бюджет и фактические затраты округляются до 2 знаков после запятой.
+- Фактические затраты рассчитываются пропорционально бюджету.
+- Если фактическая дата завершения присутствует, процент выполнения равен 100, иначе 0-99.
+
+## Архитектура
+
+- `ProjectApp.AppHost` — оркестрация Aspire.
+- `ProjectApp.Api` — сервис генерации и кэширования.
+- `ProjectApp.ApiGateway` — API Gateway и балансировка нагрузки.
+- `ProjectApp.ServiceDefaults` — общие настройки observability/health checks.
+- Redis + Redis Commander — кэш и администрирование.
+- `Client.Wasm` — клиентское приложение.
+
+## Запуск
+
+1. Сборка решения:
+
+```bash
+dotnet build .\CloudDevelopment.sln
+```
+
+2. Запуск оркестрации Aspire:
+
+```bash
+dotnet run --project .\ProjectApp.AppHost\ProjectApp.AppHost.csproj
+```
+
+3. Открыть Aspire Dashboard по URL из консоли.
+
+## Проверка API
+
+- Через gateway:
+ - `https://localhost:7139/api/project?id=1`
+- Напрямую в реплики:
+ - `http://localhost:7173/api/project?id=1`
+ - `http://localhost:7174/api/project?id=1`
+ - `http://localhost:7175/api/project?id=1`
+
+## Проверка балансировки
+
+- Отправить серию запросов через gateway на `GET /api/project?id={id}`.
+- Проверить в логах/дашборде, что запросы распределяются между репликами в пропорции, соответствующей весам `5:3:2`.
diff --git a/Service.FileStorage/Controllers/FileStorageController.cs b/Service.FileStorage/Controllers/FileStorageController.cs
new file mode 100644
index 00000000..87b44322
--- /dev/null
+++ b/Service.FileStorage/Controllers/FileStorageController.cs
@@ -0,0 +1,64 @@
+using System.Text;
+using System.Text.Json.Nodes;
+using Microsoft.AspNetCore.Mvc;
+using Service.FileStorage.Storage;
+
+namespace Service.FileStorage.Controllers;
+
+///
+/// Контроллер для взаимодействия с объектным хранилищем.
+///
+/// Служба для работы с хранилищем
+/// Логгер
+[ApiController]
+[Route("api/files")]
+public class FileStorageController(IFileStorageService storageService, ILogger logger) : ControllerBase
+{
+ ///
+ /// Получает список хранящихся в бакете файлов.
+ ///
+ /// Список ключей файлов
+ [HttpGet]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(500)]
+ public async Task>> ListFiles()
+ {
+ logger.LogInformation("Вызван метод {method} контроллера {controller}", nameof(ListFiles), nameof(FileStorageController));
+ try
+ {
+ var list = await storageService.GetFileList();
+ logger.LogInformation("Получен список из {count} файлов из бакета", list.Count);
+ return Ok(list);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Ошибка при выполнении метода {method} контроллера {controller}", nameof(ListFiles), nameof(FileStorageController));
+ return BadRequest(ex.Message);
+ }
+ }
+
+ ///
+ /// Получает JSON-представление хранящегося в бакете файла.
+ ///
+ /// Ключ файла
+ /// JSON-представление файла
+ [HttpGet("{key}")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(404)]
+ [ProducesResponseType(500)]
+ public async Task> GetFile(string key)
+ {
+ logger.LogInformation("Вызван метод {method} контроллера {controller}", nameof(GetFile), nameof(FileStorageController));
+ try
+ {
+ var node = await storageService.DownloadFile(key);
+ logger.LogInformation("Получен JSON размером {size} байт", Encoding.UTF8.GetByteCount(node.ToJsonString()));
+ return Ok(node);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Ошибка при выполнении метода {method} контроллера {controller}", nameof(GetFile), nameof(FileStorageController));
+ return NotFound(ex.Message);
+ }
+ }
+}
diff --git a/Service.FileStorage/Messaging/SqsConsumerService.cs b/Service.FileStorage/Messaging/SqsConsumerService.cs
new file mode 100644
index 00000000..381c74ad
--- /dev/null
+++ b/Service.FileStorage/Messaging/SqsConsumerService.cs
@@ -0,0 +1,69 @@
+using Amazon.SQS;
+using Amazon.SQS.Model;
+using Service.FileStorage.Storage;
+
+namespace Service.FileStorage.Messaging;
+
+///
+/// Клиентская служба для приема сообщений из очереди SQS.
+///
+/// Клиент SQS
+/// Фабрика контекста
+/// Конфигурация
+/// Логгер
+public class SqsConsumerService(IAmazonSQS sqsClient,
+ IServiceScopeFactory scopeFactory,
+ IConfiguration configuration,
+ ILogger logger) : BackgroundService
+{
+ private readonly string _queueName = configuration["AWS:Resources:SQSQueueName"]
+ ?? throw new KeyNotFoundException("SQS queue name was not found in configuration");
+
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ logger.LogInformation("Служба потребителя SQS запущена");
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ var response = await sqsClient.ReceiveMessageAsync(
+ new ReceiveMessageRequest
+ {
+ QueueUrl = _queueName,
+ MaxNumberOfMessages = 10,
+ WaitTimeSeconds = 5
+ }, stoppingToken);
+
+ if (response == null)
+ {
+ logger.LogWarning("Получен пустой ответ из очереди {queue}", _queueName);
+ continue;
+ }
+
+ logger.LogInformation("Получено {count} сообщений из очереди", response.Messages?.Count ?? 0);
+
+ if (response.Messages != null)
+ {
+ foreach (var message in response.Messages)
+ {
+ try
+ {
+ logger.LogInformation("Обработка сообщения {messageId}", message.MessageId);
+
+ using var scope = scopeFactory.CreateScope();
+ var storageService = scope.ServiceProvider.GetRequiredService();
+ await storageService.UploadFile(message.Body);
+
+ await sqsClient.DeleteMessageAsync(_queueName, message.ReceiptHandle, stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Ошибка при обработке сообщения {messageId}", message.MessageId);
+ continue;
+ }
+ }
+ logger.LogInformation("Пачка из {count} сообщений обработана", response.Messages.Count);
+ }
+ }
+ }
+}
diff --git a/Service.FileStorage/Program.cs b/Service.FileStorage/Program.cs
new file mode 100644
index 00000000..7ac97dc9
--- /dev/null
+++ b/Service.FileStorage/Program.cs
@@ -0,0 +1,30 @@
+using Amazon.SQS;
+using LocalStack.Client.Extensions;
+using ProjectApp.ServiceDefaults;
+using Service.FileStorage.Messaging;
+using Service.FileStorage.Storage;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.Services.AddControllers();
+
+builder.Services.AddLocalStack(builder.Configuration);
+builder.Services.AddAwsService();
+builder.Services.AddHostedService();
+
+builder.AddMinioClient("projectapp-minio");
+builder.Services.AddScoped();
+
+var app = builder.Build();
+
+using (var scope = app.Services.CreateScope())
+{
+ var storage = scope.ServiceProvider.GetRequiredService();
+ await storage.EnsureBucketExists();
+}
+
+app.MapDefaultEndpoints();
+app.MapControllers();
+app.Run();
diff --git a/Service.FileStorage/Service.FileStorage.csproj b/Service.FileStorage/Service.FileStorage.csproj
new file mode 100644
index 00000000..83ee03e9
--- /dev/null
+++ b/Service.FileStorage/Service.FileStorage.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+ $(NoWarn);1591
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Service.FileStorage/Storage/IFileStorageService.cs b/Service.FileStorage/Storage/IFileStorageService.cs
new file mode 100644
index 00000000..c9c1bf2a
--- /dev/null
+++ b/Service.FileStorage/Storage/IFileStorageService.cs
@@ -0,0 +1,33 @@
+using System.Text.Json.Nodes;
+
+namespace Service.FileStorage.Storage;
+
+///
+/// Интерфейс службы для манипуляции файлами в объектном хранилище.
+///
+public interface IFileStorageService
+{
+ ///
+ /// Отправляет файл в хранилище.
+ ///
+ /// Строковая репрезентация сохраняемого файла
+ public Task UploadFile(string fileData);
+
+ ///
+ /// Получает список всех файлов из хранилища.
+ ///
+ /// Список ключей файлов
+ public Task> GetFileList();
+
+ ///
+ /// Получает строковую репрезентацию файла из хранилища.
+ ///
+ /// Ключ файла в бакете
+ /// JSON-репрезентация прочтенного файла
+ public Task DownloadFile(string key);
+
+ ///
+ /// Создает бакет при необходимости.
+ ///
+ public Task EnsureBucketExists();
+}
diff --git a/Service.FileStorage/Storage/MinioFileStorageService.cs b/Service.FileStorage/Storage/MinioFileStorageService.cs
new file mode 100644
index 00000000..ea58b398
--- /dev/null
+++ b/Service.FileStorage/Storage/MinioFileStorageService.cs
@@ -0,0 +1,129 @@
+using System.Net;
+using System.Text;
+using System.Text.Json.Nodes;
+using Minio;
+using Minio.DataModel.Args;
+
+namespace Service.FileStorage.Storage;
+
+///
+/// Служба для манипуляции файлами в объектном хранилище MinIO.
+///
+/// MinIO клиент
+/// Конфигурация
+/// Логгер
+public class MinioFileStorageService(IMinioClient client, IConfiguration configuration, ILogger logger) : IFileStorageService
+{
+ private readonly string _bucketName = configuration["AWS:Resources:MinioBucketName"]
+ ?? throw new KeyNotFoundException("Minio bucket name was not found in configuration");
+
+ ///
+ public async Task> GetFileList()
+ {
+ var list = new List();
+ var request = new ListObjectsArgs()
+ .WithBucket(_bucketName)
+ .WithPrefix("")
+ .WithRecursive(true);
+ logger.LogInformation("Запрашиваем список файлов в бакете {bucket}", _bucketName);
+ var responseList = client.ListObjectsEnumAsync(request);
+
+ if (responseList == null)
+ logger.LogWarning("Получен пустой ответ от бакета {bucket}", _bucketName);
+
+ await foreach (var response in responseList!)
+ list.Add(response.Key);
+ return list;
+ }
+
+ ///
+ public async Task UploadFile(string fileData)
+ {
+ var rootNode = JsonNode.Parse(fileData) ?? throw new ArgumentException("Passed string is not a valid JSON");
+ var id = rootNode["id"]?.GetValue() ?? throw new ArgumentException("Passed JSON has invalid structure");
+
+ var bytes = Encoding.UTF8.GetBytes(fileData);
+ using var stream = new MemoryStream(bytes);
+ stream.Seek(0, SeekOrigin.Begin);
+
+ logger.LogInformation("Начинаем загрузку проекта {file} в бакет {bucket}", id, _bucketName);
+ var prefix = configuration["FilePrefix"] ?? "project_";
+ var request = new PutObjectArgs()
+ .WithBucket(_bucketName)
+ .WithStreamData(stream)
+ .WithObjectSize(bytes.Length)
+ .WithObject($"{prefix}{id}.json");
+
+ var response = await client.PutObjectAsync(request);
+
+ if (response.ResponseStatusCode != HttpStatusCode.OK)
+ {
+ logger.LogError("Не удалось загрузить проект {file}: {code}", id, response.ResponseStatusCode);
+ return false;
+ }
+ logger.LogInformation("Проект {file} успешно загружен в бакет {bucket}", id, _bucketName);
+ return true;
+ }
+
+ ///
+ public async Task DownloadFile(string key)
+ {
+ logger.LogInformation("Начинаем скачивание файла {file} из бакета {bucket}", key, _bucketName);
+
+ try
+ {
+ var memoryStream = new MemoryStream();
+
+ var request = new GetObjectArgs()
+ .WithBucket(_bucketName)
+ .WithObject(key)
+ .WithCallbackStream(async (stream, cancellationToken) =>
+ {
+ await stream.CopyToAsync(memoryStream, cancellationToken);
+ memoryStream.Seek(0, SeekOrigin.Begin);
+ });
+
+ var response = await client.GetObjectAsync(request);
+
+ if (response == null)
+ {
+ logger.LogError("Не удалось скачать файл {file}", key);
+ throw new InvalidOperationException($"Error occurred downloading {key} - object is null");
+ }
+ using var reader = new StreamReader(memoryStream, Encoding.UTF8);
+ return JsonNode.Parse(reader.ReadToEnd()) ?? throw new InvalidOperationException("Downloaded document is not a valid JSON");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Ошибка при скачивании файла {file}", key);
+ throw;
+ }
+ }
+
+ ///
+ public async Task EnsureBucketExists()
+ {
+ logger.LogInformation("Проверяем существование бакета {bucket}", _bucketName);
+ try
+ {
+ var request = new BucketExistsArgs()
+ .WithBucket(_bucketName);
+
+ var exists = await client.BucketExistsAsync(request);
+ if (!exists)
+ {
+ logger.LogInformation("Создаём бакет {bucket}", _bucketName);
+ var createRequest = new MakeBucketArgs()
+ .WithBucket(_bucketName);
+ await client.MakeBucketAsync(createRequest);
+ return;
+ }
+ logger.LogInformation("Бакет {bucket} уже существует", _bucketName);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Необработанная ошибка при проверке бакета {bucket}", _bucketName);
+ throw;
+ }
+ }
+}
diff --git a/Service.FileStorage/appsettings.Development.json b/Service.FileStorage/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/Service.FileStorage/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/Service.FileStorage/appsettings.json b/Service.FileStorage/appsettings.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/Service.FileStorage/appsettings.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}