diff --git a/.github/ISSUE_TEMPLATE/#U0432#U043e#U043f#U0440#U043e#U0441-#U043f#U043e-#U043b#U0430#U0431#U043e#U0440#U0430#U0442#U043e#U0440#U043d#U043e#U0439.md b/.github/ISSUE_TEMPLATE/#U0432#U043e#U043f#U0440#U043e#U0441-#U043f#U043e-#U043b#U0430#U0431#U043e#U0440#U0430#U0442#U043e#U0440#U043d#U043e#U0439.md
new file mode 100644
index 00000000..02038ea5
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/#U0432#U043e#U043f#U0440#U043e#U0441-#U043f#U043e-#U043b#U0430#U0431#U043e#U0440#U0430#U0442#U043e#U0440#U043d#U043e#U0439.md
@@ -0,0 +1,33 @@
+---
+name: Вопрос по лабораторной
+about: Этот шаблон предназначен для того, чтобы студенты могли задать вопрос по лабораторной
+title: Вопрос по лабораторной
+labels: ''
+assignees: Gwymlas, alxmcs, danlla
+
+---
+
+**Меня зовут:**
+Укажите свои ФИО
+
+**Я из группы:**
+Укажите номер группы
+
+**У меня вопрос по лабе:**
+Укажите номер и название лабораторной, по которой появился вопрос.
+
+**Мой вопрос:**
+Максимально подробно опишите, что вы хотите узнать/что у вас не получается/что у вас не работает. При необходимости, добавьте примеры кода. Примеры кода должны быть оформлены с использованием md разметки, чтобы их можно было удобно воспринимать:
+
+```cs
+public class Program
+{
+ public static void Main(string[] args)
+ {
+ System.Console.WriteLine("Hello, World!");
+ }
+}
+```
+
+**Дополнительная информация**
+Опишите тут все, что не попадает под перечисленные ранее категории (если в том есть необходимость).
\ No newline at end of file
diff --git a/ApiGateway/ApiGateway.csproj b/ApiGateway/ApiGateway.csproj
new file mode 100644
index 00000000..c8100831
--- /dev/null
+++ b/ApiGateway/ApiGateway.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net8.0
+ enable
+ enable
+ ApiGateway
+ ApiGateway
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
diff --git a/ApiGateway/Configuration/WeightedRoundRobinOptions.cs b/ApiGateway/Configuration/WeightedRoundRobinOptions.cs
new file mode 100644
index 00000000..d5547d71
--- /dev/null
+++ b/ApiGateway/Configuration/WeightedRoundRobinOptions.cs
@@ -0,0 +1,16 @@
+namespace ApiGateway.Configuration;
+
+public sealed class WeightedRoundRobinOptions
+{
+ public const string SectionName = "WeightedRoundRobin";
+
+ public List Nodes { get; init; } = new();
+}
+
+public sealed class ReplicaNodeOptions
+{
+ public string Host { get; init; } = string.Empty;
+ public int Port { get; init; }
+ public int Weight { get; init; } = 1;
+ public string ReplicaId { get; init; } = string.Empty;
+}
diff --git a/ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs b/ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs
new file mode 100644
index 00000000..33425e09
--- /dev/null
+++ b/ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs
@@ -0,0 +1,72 @@
+using ApiGateway.Configuration;
+using Microsoft.Extensions.Options;
+using Ocelot.LoadBalancer.Interfaces;
+using Ocelot.Responses;
+using Ocelot.Values;
+
+namespace ApiGateway.LoadBalancing;
+
+public sealed class WeightedRoundRobinBalancer : ILoadBalancer
+{
+ private static readonly object Sync = new();
+ private readonly ILogger _logger;
+ private readonly List _rotation;
+ private int _currentIndex;
+
+ public WeightedRoundRobinBalancer(
+ IOptions options,
+ ILogger logger)
+ {
+ _logger = logger;
+ _rotation = BuildRotation(options.Value.Nodes);
+
+ if (_rotation.Count == 0)
+ {
+ throw new InvalidOperationException("Не настроены узлы для Weighted Round Robin балансировки.");
+ }
+ }
+
+ public string Type => nameof(WeightedRoundRobinBalancer);
+
+ public Task> LeaseAsync(HttpContext context)
+ {
+ lock (Sync)
+ {
+ if (_currentIndex >= _rotation.Count)
+ {
+ _currentIndex = 0;
+ }
+
+ var next = _rotation[_currentIndex++];
+
+ _logger.LogInformation(
+ "Gateway routed request to {ReplicaAddress} by {BalancerType}",
+ next,
+ Type);
+
+ return Task.FromResult>(
+ new OkResponse(next));
+ }
+ }
+
+ public void Release(ServiceHostAndPort hostAndPort)
+ {
+ }
+
+ private static List BuildRotation(IEnumerable nodes)
+ {
+ var rotation = new List();
+
+ foreach (var node in nodes.Where(static n => !string.IsNullOrWhiteSpace(n.Host) && n.Port > 0))
+ {
+ var normalizedWeight = Math.Max(1, node.Weight);
+
+ for (var i = 0; i < normalizedWeight; i++)
+ {
+ rotation.Add(new ServiceHostAndPort(node.Host, node.Port));
+ }
+ }
+
+ return rotation;
+ }
+}
\ No newline at end of file
diff --git a/ApiGateway/Program.cs b/ApiGateway/Program.cs
new file mode 100644
index 00000000..dda7b95a
--- /dev/null
+++ b/ApiGateway/Program.cs
@@ -0,0 +1,43 @@
+using ApiGateway.Configuration;
+using ApiGateway.LoadBalancing;
+using Ocelot.DependencyInjection;
+using Ocelot.Middleware;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+builder.Services.AddServiceDiscovery();
+builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true);
+
+builder.Logging.ClearProviders();
+builder.Logging.AddJsonConsole(options =>
+{
+ options.IncludeScopes = true;
+ options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ ";
+});
+
+builder.Services.Configure(
+ builder.Configuration.GetSection(WeightedRoundRobinOptions.SectionName));
+
+builder.Services.AddOcelot(builder.Configuration)
+ .AddCustomLoadBalancer(sp =>
+ new WeightedRoundRobinBalancer(
+ sp.GetRequiredService>(),
+ sp.GetRequiredService>()));
+
+builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
+{
+ policy.WithOrigins(["http://localhost:5127", "https://localhost:7282"]);
+ policy.WithMethods("GET");
+ policy.WithHeaders("Content-Type");
+ policy.WithExposedHeaders("X-Service-Replica", "X-Service-Weight");
+}));
+
+var app = builder.Build();
+
+app.UseCors();
+app.MapDefaultEndpoints();
+
+await app.UseOcelot();
+
+app.Run();
diff --git a/ApiGateway/Properties/launchSettings.json b/ApiGateway/Properties/launchSettings.json
new file mode 100644
index 00000000..98385b13
--- /dev/null
+++ b/ApiGateway/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:7200",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/ApiGateway/appsettings.Development.json b/ApiGateway/appsettings.Development.json
new file mode 100644
index 00000000..79cac825
--- /dev/null
+++ b/ApiGateway/appsettings.Development.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Ocelot": "Information"
+ }
+ }
+}
diff --git a/ApiGateway/appsettings.json b/ApiGateway/appsettings.json
new file mode 100644
index 00000000..cc426f6c
--- /dev/null
+++ b/ApiGateway/appsettings.json
@@ -0,0 +1,10 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Ocelot": "Information"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/ApiGateway/ocelot.json b/ApiGateway/ocelot.json
new file mode 100644
index 00000000..7c8dceea
--- /dev/null
+++ b/ApiGateway/ocelot.json
@@ -0,0 +1,41 @@
+{
+ "Routes": [
+ {
+ "UpstreamPathTemplate": "/employee",
+ "UpstreamHttpMethod": [ "GET" ],
+ "DownstreamPathTemplate": "/employee",
+ "DownstreamScheme": "https",
+ "LoadBalancerOptions": {
+ "Type": "WeightedRoundRobinBalancer"
+ },
+ "DownstreamHostAndPorts": [
+ { "Host": "localhost", "Port": 15000 },
+ { "Host": "localhost", "Port": 15001 },
+ { "Host": "localhost", "Port": 15002 },
+ { "Host": "localhost", "Port": 15003 },
+ { "Host": "localhost", "Port": 15004 }
+ ]
+ },
+ {
+ "UpstreamPathTemplate": "/files/{id}",
+ "UpstreamHttpMethod": [ "GET" ],
+ "DownstreamPathTemplate": "/files/{id}",
+ "DownstreamScheme": "http",
+ "DownstreamHostAndPorts": [
+ { "Host": "localhost", "Port": 16000 }
+ ]
+ }
+ ],
+ "GlobalConfiguration": {
+ "BaseUrl": "http://localhost:7200"
+ },
+ "WeightedRoundRobin": {
+ "Nodes": [
+ { "ReplicaId": "R1", "Host": "localhost", "Port": 15000, "Weight": 1 },
+ { "ReplicaId": "R2", "Host": "localhost", "Port": 15001, "Weight": 2 },
+ { "ReplicaId": "R3", "Host": "localhost", "Port": 15002, "Weight": 3 },
+ { "ReplicaId": "R4", "Host": "localhost", "Port": 15003, "Weight": 2 },
+ { "ReplicaId": "R5", "Host": "localhost", "Port": 15004, "Weight": 1 }
+ ]
+ }
+}
diff --git a/AspireApp/AspireApp.AppHost/AppHost.cs b/AspireApp/AspireApp.AppHost/AppHost.cs
new file mode 100644
index 00000000..df267839
--- /dev/null
+++ b/AspireApp/AspireApp.AppHost/AppHost.cs
@@ -0,0 +1,50 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+var cache = builder.AddRedis("employee-cache")
+ .WithRedisInsight(containerName: "employee-insight");
+
+var localstack = builder.AddContainer("localstack", "localstack/localstack", "3.5")
+ .WithEnvironment("SERVICES", "s3,sns,sqs")
+ .WithEnvironment("AWS_DEFAULT_REGION", "us-east-1")
+ .WithEnvironment("DEBUG", "1")
+ .WithHttpEndpoint(port: 4566, targetPort: 4566, name: "http", isProxied: false);
+
+var gateway = builder.AddProject("api-gateway");
+
+var replicaWeights = new[] { 1, 2, 3, 2, 1 };
+
+var fileService = builder.AddProject("file-service", launchProfileName: null)
+ .WithHttpEndpoint(port: 16000)
+ .WithEnvironment("Aws__ServiceUrl", "http://localhost:4566")
+ .WithEnvironment("Aws__Region", "us-east-1")
+ .WithEnvironment("Aws__AccessKey", "test")
+ .WithEnvironment("Aws__SecretKey", "test")
+ .WithEnvironment("Aws__TopicName", "employee-generated-topic")
+ .WithEnvironment("Aws__QueueName", "employee-generated-queue")
+ .WithEnvironment("Aws__BucketName", "employee-files")
+ .WaitFor(localstack);
+
+for (var i = 0; i < 5; i++)
+{
+ var service = builder.AddProject($"service-api-{i}", launchProfileName: null)
+ .WithHttpsEndpoint(port: 15000 + i)
+ .WithReference(cache, "RedisCache")
+ .WithEnvironment("ReplicaId", "R" + (i + 1))
+ .WithEnvironment("ReplicaWeight", replicaWeights[i].ToString())
+ .WithEnvironment("Aws__ServiceUrl", "http://localhost:4566")
+ .WithEnvironment("Aws__Region", "us-east-1")
+ .WithEnvironment("Aws__AccessKey", "test")
+ .WithEnvironment("Aws__SecretKey", "test")
+ .WithEnvironment("Aws__TopicName", "employee-generated-topic")
+ .WaitFor(cache)
+ .WaitFor(localstack);
+
+ gateway.WaitFor(service);
+}
+
+gateway.WaitFor(fileService);
+
+builder.AddProject("employee")
+ .WaitFor(gateway);
+
+builder.Build().Run();
diff --git a/AspireApp/AspireApp.AppHost/AspireApp.AppHost.csproj b/AspireApp/AspireApp.AppHost/AspireApp.AppHost.csproj
new file mode 100644
index 00000000..719a5b0c
--- /dev/null
+++ b/AspireApp/AspireApp.AppHost/AspireApp.AppHost.csproj
@@ -0,0 +1,25 @@
+
+
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AspireApp/AspireApp.AppHost/Properties/launchSettings.json b/AspireApp/AspireApp.AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000..df45cce3
--- /dev/null
+++ b/AspireApp/AspireApp.AppHost/Properties/launchSettings.json
@@ -0,0 +1,32 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17096;http://localhost:15155",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21139",
+ "DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "https://localhost:21140",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22017"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15155",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19197",
+ "DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "http://localhost:19198",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20116"
+ }
+ }
+ }
+}
diff --git a/AspireApp/AspireApp.AppHost/appsettings.Development.json b/AspireApp/AspireApp.AppHost/appsettings.Development.json
new file mode 100644
index 00000000..167eb683
--- /dev/null
+++ b/AspireApp/AspireApp.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Aspire.Hosting": "Information"
+ }
+ }
+}
diff --git a/AspireApp/AspireApp.AppHost/appsettings.json b/AspireApp/AspireApp.AppHost/appsettings.json
new file mode 100644
index 00000000..167eb683
--- /dev/null
+++ b/AspireApp/AspireApp.AppHost/appsettings.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Aspire.Hosting": "Information"
+ }
+ }
+}
diff --git a/AspireApp/AspireApp.ServiceDefaults/AspireApp.ServiceDefaults.csproj b/AspireApp/AspireApp.ServiceDefaults/AspireApp.ServiceDefaults.csproj
new file mode 100644
index 00000000..530518fa
--- /dev/null
+++ b/AspireApp/AspireApp.ServiceDefaults/AspireApp.ServiceDefaults.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AspireApp/AspireApp.ServiceDefaults/Extensions.cs b/AspireApp/AspireApp.ServiceDefaults/Extensions.cs
new file mode 100644
index 00000000..e8399ba7
--- /dev/null
+++ b/AspireApp/AspireApp.ServiceDefaults/Extensions.cs
@@ -0,0 +1,101 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.ServiceDiscovery;
+using OpenTelemetry.Logs;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace Microsoft.Extensions.Hosting;
+
+public static class Extensions
+{
+ public static TBuilder AddServiceDefaults(this TBuilder builder)
+ where TBuilder : IHostApplicationBuilder
+ {
+ builder.ConfigureOpenTelemetry();
+ builder.AddDefaultHealthChecks();
+
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ http.AddStandardResilienceHandler();
+ http.AddServiceDiscovery();
+ });
+
+ return builder;
+ }
+
+ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder)
+ where TBuilder : IHostApplicationBuilder
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddSource(builder.Environment.ApplicationName)
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ private static TBuilder AddOpenTelemetryExporters(this TBuilder builder)
+ where TBuilder : IHostApplicationBuilder
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (!useOtlpExporter)
+ {
+ return builder;
+ }
+
+ builder.Logging.AddOpenTelemetry(logging => logging.AddOtlpExporter());
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics => metrics.AddOtlpExporter())
+ .WithTracing(tracing => tracing.AddOtlpExporter());
+
+ return builder;
+ }
+
+ public static TBuilder AddDefaultHealthChecks(this TBuilder builder)
+ where TBuilder : IHostApplicationBuilder
+ {
+ builder.Services.AddHealthChecks()
+ .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"]);
+
+ return builder;
+ }
+
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ if (app.Environment.IsDevelopment())
+ {
+ app.MapHealthChecks("/health");
+ app.MapHealthChecks("/alive", new HealthCheckOptions
+ {
+ Predicate = registration => registration.Tags.Contains("live")
+ });
+ }
+
+ return app;
+ }
+}
diff --git a/Backend.IntegrationTests/Backend.IntegrationTests.csproj b/Backend.IntegrationTests/Backend.IntegrationTests.csproj
new file mode 100644
index 00000000..aa1fbe9d
--- /dev/null
+++ b/Backend.IntegrationTests/Backend.IntegrationTests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Backend.IntegrationTests/EmployeeExportIntegrationTests.cs b/Backend.IntegrationTests/EmployeeExportIntegrationTests.cs
new file mode 100644
index 00000000..0b1f4e51
--- /dev/null
+++ b/Backend.IntegrationTests/EmployeeExportIntegrationTests.cs
@@ -0,0 +1,170 @@
+using System.Net;
+using System.Text.Json;
+using Aspire.Hosting;
+using Aspire.Hosting.Testing;
+
+namespace Backend.IntegrationTests;
+
+public sealed class EmployeeExportIntegrationTests : IAsyncLifetime
+{
+ private static readonly TimeSpan StartupTimeout = TimeSpan.FromSeconds(120);
+ private static readonly TimeSpan ExportTimeout = TimeSpan.FromSeconds(120);
+
+ private DistributedApplication? _app;
+
+ public async Task InitializeAsync()
+ {
+ var builder = await DistributedApplicationTestingBuilder.CreateAsync();
+ _app = await builder.BuildAsync().WaitAsync(StartupTimeout);
+ await _app.StartAsync().WaitAsync(StartupTimeout);
+
+ var fileClient = _app.CreateHttpClient("file-service");
+ var apiClient = _app.CreateHttpClient("service-api-0");
+
+ using var cts = new CancellationTokenSource(StartupTimeout);
+
+ await WaitForFileServiceReadyAsync(fileClient, cts.Token);
+ await WaitForApiAsync(apiClient, cts.Token);
+ }
+
+ public async Task DisposeAsync()
+ {
+ if (_app is not null)
+ {
+ await _app.DisposeAsync();
+ }
+ }
+
+ [Fact]
+ public async Task GeneratedEmployeeIsEventuallyExportedToObjectStorage()
+ {
+ Assert.NotNull(_app);
+
+ const int employeeId = 501;
+
+ var apiClient = _app!.CreateHttpClient("service-api-0");
+ var fileClient = _app.CreateHttpClient("file-service");
+
+ using var response = await apiClient.GetAsync($"/employee?id={employeeId}");
+ response.EnsureSuccessStatusCode();
+
+ var generatedJson = await response.Content.ReadAsStringAsync();
+ var exportedJson = await WaitForExportAsync(fileClient, employeeId);
+
+ Assert.Equal(Normalize(generatedJson), Normalize(exportedJson));
+ }
+
+ [Fact]
+ public async Task SameEmployeeIdReturnsCachedPayloadAndExportRemainsAvailable()
+ {
+ Assert.NotNull(_app);
+
+ const int employeeId = 777;
+
+ var apiClient = _app!.CreateHttpClient("service-api-0");
+ var fileClient = _app.CreateHttpClient("file-service");
+
+ var first = await apiClient.GetStringAsync($"/employee?id={employeeId}");
+ var second = await apiClient.GetStringAsync($"/employee?id={employeeId}");
+
+ Assert.Equal(Normalize(first), Normalize(second));
+
+ var exportedJson = await WaitForExportAsync(fileClient, employeeId);
+ Assert.Equal(Normalize(first), Normalize(exportedJson));
+ }
+
+ private static async Task WaitForFileServiceReadyAsync(HttpClient client, CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ using var response = await client.GetAsync("/ready", cancellationToken);
+
+ if (response.IsSuccessStatusCode)
+ {
+ return;
+ }
+
+ if (response.StatusCode != HttpStatusCode.ServiceUnavailable)
+ {
+ response.EnsureSuccessStatusCode();
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch
+ {
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
+ }
+
+ throw new TimeoutException("File Service не стал ready за отведённое время.");
+ }
+
+ private static async Task WaitForApiAsync(HttpClient client, CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ using var response = await client.GetAsync("/", cancellationToken);
+ if (response.IsSuccessStatusCode)
+ {
+ return;
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch
+ {
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
+ }
+
+ throw new TimeoutException("Service API не стал доступен за отведённое время.");
+ }
+
+ private static async Task WaitForExportAsync(HttpClient fileClient, int employeeId)
+ {
+ using var cts = new CancellationTokenSource(ExportTimeout);
+
+ while (!cts.IsCancellationRequested)
+ {
+ try
+ {
+ using var response = await fileClient.GetAsync($"/files/{employeeId}", cts.Token);
+
+ if (response.IsSuccessStatusCode)
+ {
+ return await response.Content.ReadAsStringAsync(cts.Token);
+ }
+
+ if (response.StatusCode != HttpStatusCode.NotFound)
+ {
+ response.EnsureSuccessStatusCode();
+ }
+ }
+ catch (OperationCanceledException) when (cts.IsCancellationRequested)
+ {
+ break;
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(2), cts.Token);
+ }
+
+ throw new TimeoutException($"Файл сотрудника {employeeId} не был выгружен в объектное хранилище за отведённое время.");
+ }
+
+ private static string Normalize(string json)
+ {
+ using var document = JsonDocument.Parse(json);
+ return JsonSerializer.Serialize(document.RootElement);
+ }
+}
\ No newline at end of file
diff --git a/Client.Wasm/Components/DataCard.razor b/Client.Wasm/Components/DataCard.razor
index c646a839..68c080d9 100644
--- a/Client.Wasm/Components/DataCard.razor
+++ b/Client.Wasm/Components/DataCard.razor
@@ -1,22 +1,34 @@
-@inject IConfiguration Configuration
+@using System.Net.Http.Json
+@using System.Text.Json.Nodes
+@inject IConfiguration Configuration
@inject HttpClient Client
- Характеристики текущего объекта
+ Характеристики текущего объекта
-
+ @if (!string.IsNullOrWhiteSpace(ErrorMessage))
+ {
+ @ErrorMessage
+ }
+
+ @if (!string.IsNullOrWhiteSpace(ReplicaInfo))
+ {
+ @ReplicaInfo
+ }
+
+
-
+
#
Характеристика
Значение
-
+
- @if(Value is null)
+ @if (Value is null)
{
1
@@ -30,7 +42,7 @@
foreach (var property in array)
{
- @(Array.IndexOf(array, property)+1)
+ @(Array.IndexOf(array, property) + 1)
@property.Key
@property.Value?.ToString()
@@ -40,10 +52,10 @@
-
+
- Запросить новый объект
+ Запросить новый объект
@@ -51,10 +63,10 @@
Идентификатор нового объекта:
-
+
-
+
@@ -63,12 +75,51 @@
@code {
private JsonObject? Value { get; set; }
- private int Id { get; set; }
+ private string? ErrorMessage { get; set; }
+ private string? ReplicaInfo { get; set; }
+ private int Id { get; set; } = 1;
private async Task RequestNewData()
{
- var baseAddress = Configuration["BaseAddress"] ?? throw new KeyNotFoundException("Конфигурация клиента не содержит параметра BaseAddress");
- Value = await Client.GetFromJsonAsync($"{baseAddress}?id={Id}", new JsonSerializerOptions { });
- StateHasChanged();
+ ErrorMessage = null;
+ ReplicaInfo = null;
+
+ if (Id <= 0)
+ {
+ ErrorMessage = "Идентификатор должен быть больше нуля.";
+ return;
+ }
+
+ var baseAddress = Configuration["BaseAddress"];
+ if (string.IsNullOrWhiteSpace(baseAddress))
+ {
+ ErrorMessage = "Конфигурация клиента не содержит параметра BaseAddress.";
+ return;
+ }
+
+ try
+ {
+ using var response = await Client.GetAsync($"{baseAddress}?id={Id}");
+ response.EnsureSuccessStatusCode();
+
+ Value = await response.Content.ReadFromJsonAsync();
+
+ var replica = response.Headers.TryGetValues("X-Service-Replica", out var replicaValues)
+ ? replicaValues.FirstOrDefault()
+ : null;
+ var weight = response.Headers.TryGetValues("X-Service-Weight", out var weightValues)
+ ? weightValues.FirstOrDefault()
+ : null;
+
+ if (!string.IsNullOrWhiteSpace(replica))
+ {
+ ReplicaInfo = $"Ответ пришёл от реплики {replica} (вес {weight ?? "1"}). Алгоритм: Weighted Round Robin.";
+ }
+ }
+ catch (Exception ex)
+ {
+ ErrorMessage = $"Не удалось получить данные: {ex.Message}";
+ Value = null;
+ }
}
}
diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor
index 661f1181..2b64c5ce 100644
--- a/Client.Wasm/Components/StudentCard.razor
+++ b/Client.Wasm/Components/StudentCard.razor
@@ -1,13 +1,17 @@
- Лабораторная работа
+
+ Лабораторная работа
+
- Номер №X "Название лабораторной"
- Вариант №Х "Название варианта"
- Выполнена Фамилией Именем 65ХХ
- Ссылка на форк
+ Номер №3 "Интеграционное тестирование"
+ Вариант №28 "Сотрудник компании"
+ Выполнена Миронюк Матвеем 6512
+
+ Ссылка на форк
+
diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json
index d1fe7ab3..d4f650b0 100644
--- a/Client.Wasm/wwwroot/appsettings.json
+++ b/Client.Wasm/wwwroot/appsettings.json
@@ -6,5 +6,5 @@
}
},
"AllowedHosts": "*",
- "BaseAddress": ""
+ "BaseAddress": "http://localhost:7200/employee"
}
diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln
index cb48241d..2d87f8d4 100644
--- a/CloudDevelopment.sln
+++ b/CloudDevelopment.sln
@@ -3,18 +3,54 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36811.4
MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiGateway", "ApiGateway\ApiGateway.csproj", "{A12BE865-36BB-4326-A104-2CD8CE6771E7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireApp.AppHost", "AspireApp\AspireApp.AppHost\AspireApp.AppHost.csproj", "{B7822A24-50CB-4BF6-AEB0-7A54DA4CEB89}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireApp.ServiceDefaults", "AspireApp\AspireApp.ServiceDefaults\AspireApp.ServiceDefaults.csproj", "{37C683F6-6A55-4B79-8C4C-E9F632B4A3A2}"
+EndProject
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}") = "File.Service", "FileService\File.Service.csproj", "{F2141FAE-7E53-44A4-8FE9-A06B37787660}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Service.Api", "ServiceApi\Service.Api.csproj", "{7A67B2AB-14E5-467B-BE6A-EAE7C9C538C4}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Backend.IntegrationTests", "Backend.IntegrationTests\Backend.IntegrationTests.csproj", "{975A8B0A-4D45-41D9-A26D-76DB152AA1F1}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {A12BE865-36BB-4326-A104-2CD8CE6771E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A12BE865-36BB-4326-A104-2CD8CE6771E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A12BE865-36BB-4326-A104-2CD8CE6771E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A12BE865-36BB-4326-A104-2CD8CE6771E7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B7822A24-50CB-4BF6-AEB0-7A54DA4CEB89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B7822A24-50CB-4BF6-AEB0-7A54DA4CEB89}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B7822A24-50CB-4BF6-AEB0-7A54DA4CEB89}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B7822A24-50CB-4BF6-AEB0-7A54DA4CEB89}.Release|Any CPU.Build.0 = Release|Any CPU
+ {37C683F6-6A55-4B79-8C4C-E9F632B4A3A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {37C683F6-6A55-4B79-8C4C-E9F632B4A3A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {37C683F6-6A55-4B79-8C4C-E9F632B4A3A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {37C683F6-6A55-4B79-8C4C-E9F632B4A3A2}.Release|Any CPU.Build.0 = Release|Any CPU
{AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F2141FAE-7E53-44A4-8FE9-A06B37787660}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F2141FAE-7E53-44A4-8FE9-A06B37787660}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F2141FAE-7E53-44A4-8FE9-A06B37787660}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F2141FAE-7E53-44A4-8FE9-A06B37787660}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7A67B2AB-14E5-467B-BE6A-EAE7C9C538C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7A67B2AB-14E5-467B-BE6A-EAE7C9C538C4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7A67B2AB-14E5-467B-BE6A-EAE7C9C538C4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7A67B2AB-14E5-467B-BE6A-EAE7C9C538C4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {975A8B0A-4D45-41D9-A26D-76DB152AA1F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {975A8B0A-4D45-41D9-A26D-76DB152AA1F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {975A8B0A-4D45-41D9-A26D-76DB152AA1F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {975A8B0A-4D45-41D9-A26D-76DB152AA1F1}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/FileService/Background/FileExportHealthCheck.cs b/FileService/Background/FileExportHealthCheck.cs
new file mode 100644
index 00000000..68cd4674
--- /dev/null
+++ b/FileService/Background/FileExportHealthCheck.cs
@@ -0,0 +1,17 @@
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+namespace File.Service.Background;
+
+///
+/// Проверка готовности файлового сервиса к обработке сообщений.
+///
+public sealed class FileExportHealthCheck(FileExportInfrastructureState state) : IHealthCheck
+{
+ public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(
+ state.IsInitialized
+ ? HealthCheckResult.Healthy("Инфраструктура LocalStack инициализирована.")
+ : HealthCheckResult.Unhealthy("Файловый сервис ещё не инициализировал SNS/SQS/S3."));
+ }
+}
diff --git a/FileService/Background/FileExportInfrastructureState.cs b/FileService/Background/FileExportInfrastructureState.cs
new file mode 100644
index 00000000..ab3c73af
--- /dev/null
+++ b/FileService/Background/FileExportInfrastructureState.cs
@@ -0,0 +1,12 @@
+namespace File.Service.Background;
+
+///
+/// Состояние инициализации инфраструктуры файлового сервиса.
+///
+public sealed class FileExportInfrastructureState
+{
+ ///
+ /// Признак готовности инфраструктуры SNS/SQS/S3.
+ ///
+ public bool IsInitialized { get; set; }
+}
diff --git a/FileService/Background/SnsSqsFileExportWorker.cs b/FileService/Background/SnsSqsFileExportWorker.cs
new file mode 100644
index 00000000..ec47296f
--- /dev/null
+++ b/FileService/Background/SnsSqsFileExportWorker.cs
@@ -0,0 +1,283 @@
+using System.Text.Json;
+using Amazon.Runtime;
+using Amazon.SimpleNotificationService;
+using Amazon.SimpleNotificationService.Model;
+using Amazon.SQS;
+using Amazon.SQS.Model;
+using File.Service.Configuration;
+using File.Service.Storage;
+using Microsoft.Extensions.Options;
+
+namespace File.Service.Background;
+
+///
+/// Фоновый обработчик, читающий события из SNS через подписанную SQS-очередь,
+/// сериализующий их в файлы и сохраняющий в S3.
+///
+public sealed class SnsSqsFileExportWorker : BackgroundService
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ WriteIndented = true
+ };
+
+ private readonly AwsStorageOptions _options;
+ private readonly IEmployeeFileStorage _fileStorage;
+ private readonly ILogger _logger;
+ private readonly IAmazonSimpleNotificationService _snsClient;
+ private readonly IAmazonSQS _sqsClient;
+ private readonly FileExportInfrastructureState _state;
+
+ private string? _queueUrl;
+ private string? _topicArn;
+
+ public SnsSqsFileExportWorker(
+ IOptions options,
+ IEmployeeFileStorage fileStorage,
+ ILogger logger,
+ FileExportInfrastructureState state)
+ {
+ _options = options.Value;
+ _fileStorage = fileStorage;
+ _logger = logger;
+ _state = state;
+
+ var credentials = new BasicAWSCredentials(_options.AccessKey, _options.SecretKey);
+
+ var snsConfig = new AmazonSimpleNotificationServiceConfig
+ {
+ ServiceURL = _options.ServiceUrl,
+ AuthenticationRegion = _options.Region,
+ UseHttp = _options.ServiceUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
+ };
+
+ var sqsConfig = new AmazonSQSConfig
+ {
+ ServiceURL = _options.ServiceUrl,
+ AuthenticationRegion = _options.Region,
+ UseHttp = _options.ServiceUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
+ };
+
+ _snsClient = new AmazonSimpleNotificationServiceClient(credentials, snsConfig);
+ _sqsClient = new AmazonSQSClient(credentials, sqsConfig);
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _state.IsInitialized = false;
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await EnsureInfrastructureAsync(stoppingToken);
+ _state.IsInitialized = true;
+
+ _logger.LogInformation(
+ "File export infrastructure initialized. Topic={TopicArn}, Queue={QueueUrl}",
+ _topicArn,
+ _queueUrl);
+
+ break;
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (Exception ex)
+ {
+ _state.IsInitialized = false;
+ _logger.LogWarning(ex, "LocalStack infrastructure is not ready yet. Retrying initialization...");
+ await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
+ }
+ }
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ var response = await _sqsClient.ReceiveMessageAsync(new ReceiveMessageRequest
+ {
+ QueueUrl = _queueUrl,
+ MaxNumberOfMessages = 10,
+ WaitTimeSeconds = 10,
+ MessageAttributeNames = ["All"],
+ MessageSystemAttributeNames = ["All"]
+ }, stoppingToken);
+
+ if (response.Messages.Count == 0)
+ {
+ continue;
+ }
+
+ foreach (var message in response.Messages)
+ {
+ await ProcessMessageAsync(message, stoppingToken);
+ await _sqsClient.DeleteMessageAsync(_queueUrl, message.ReceiptHandle, stoppingToken);
+ }
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while processing messages from queue {QueueName}", _options.QueueName);
+ await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
+ }
+ }
+ }
+
+ private async Task EnsureInfrastructureAsync(CancellationToken cancellationToken)
+ {
+ _topicArn = (await _snsClient.CreateTopicAsync(new CreateTopicRequest
+ {
+ Name = _options.TopicName
+ }, cancellationToken)).TopicArn;
+
+ _queueUrl = (await _sqsClient.CreateQueueAsync(new CreateQueueRequest
+ {
+ QueueName = _options.QueueName
+ }, cancellationToken)).QueueUrl;
+
+ var attributes = await _sqsClient.GetQueueAttributesAsync(new GetQueueAttributesRequest
+ {
+ QueueUrl = _queueUrl,
+ AttributeNames = ["QueueArn"]
+ }, cancellationToken);
+
+ var queueArn = attributes.Attributes["QueueArn"];
+
+ var policy = $$"""
+ {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Sid": "AllowSnsPublish",
+ "Effect": "Allow",
+ "Principal": "*",
+ "Action": "sqs:SendMessage",
+ "Resource": "{{queueArn}}",
+ "Condition": {
+ "ArnEquals": {
+ "aws:SourceArn": "{{_topicArn}}"
+ }
+ }
+ }
+ ]
+ }
+ """;
+
+ await _sqsClient.SetQueueAttributesAsync(new SetQueueAttributesRequest
+ {
+ QueueUrl = _queueUrl,
+ Attributes = new Dictionary
+ {
+ ["Policy"] = policy
+ }
+ }, cancellationToken);
+
+ var subscriptions = await _snsClient.ListSubscriptionsByTopicAsync(new ListSubscriptionsByTopicRequest
+ {
+ TopicArn = _topicArn
+ }, cancellationToken);
+
+ var existingSubscription = subscriptions.Subscriptions
+ .FirstOrDefault(s => string.Equals(s.Endpoint, queueArn, StringComparison.OrdinalIgnoreCase));
+
+ if (existingSubscription is null)
+ {
+ await _snsClient.SubscribeAsync(new SubscribeRequest
+ {
+ TopicArn = _topicArn,
+ Protocol = "sqs",
+ Endpoint = queueArn,
+ Attributes = new Dictionary
+ {
+ ["RawMessageDelivery"] = "true"
+ }
+ }, cancellationToken);
+ }
+ else if (!string.IsNullOrWhiteSpace(existingSubscription.SubscriptionArn) &&
+ !string.Equals(existingSubscription.SubscriptionArn, "PendingConfirmation", StringComparison.OrdinalIgnoreCase))
+ {
+ await _snsClient.SetSubscriptionAttributesAsync(new SetSubscriptionAttributesRequest
+ {
+ SubscriptionArn = existingSubscription.SubscriptionArn,
+ AttributeName = "RawMessageDelivery",
+ AttributeValue = "true"
+ }, cancellationToken);
+ }
+ }
+
+ private async Task ProcessMessageAsync(Message message, CancellationToken cancellationToken)
+ {
+ var payloadJson = ExtractPayloadJson(message.Body);
+
+ if (string.IsNullOrWhiteSpace(payloadJson))
+ {
+ _logger.LogWarning("Received empty employee export message");
+ return;
+ }
+
+ var envelope = JsonSerializer.Deserialize(payloadJson, JsonOptions);
+ if (envelope is null || envelope.Payload.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
+ {
+ _logger.LogWarning("Received invalid employee export message: {Body}", message.Body);
+ return;
+ }
+
+ var employeeJson = envelope.Payload.GetRawText();
+
+ await _fileStorage.SaveEmployeeJsonAsync(envelope.EmployeeId, employeeJson, cancellationToken);
+
+ _logger.LogInformation(
+ "Employee {EmployeeId} exported to object storage from replica {ReplicaId}",
+ envelope.EmployeeId,
+ envelope.ReplicaId);
+ }
+
+ private static string? ExtractPayloadJson(string? body)
+ {
+ if (string.IsNullOrWhiteSpace(body))
+ {
+ return null;
+ }
+
+ try
+ {
+ using var document = JsonDocument.Parse(body);
+ var root = document.RootElement;
+
+ if (root.TryGetProperty("Message", out var messageProperty) &&
+ messageProperty.ValueKind == JsonValueKind.String)
+ {
+ return messageProperty.GetString();
+ }
+ }
+ catch
+ {
+ // Если body не envelope SNS, считаем что это raw JSON.
+ }
+
+ return body;
+ }
+
+ public override void Dispose()
+ {
+ _snsClient.Dispose();
+ _sqsClient.Dispose();
+ base.Dispose();
+ }
+
+ private sealed class EmployeeGeneratedEnvelope
+ {
+ public int EmployeeId { get; init; }
+
+ public DateTime PublishedAtUtc { get; init; }
+
+ public string ReplicaId { get; init; } = string.Empty;
+
+ public JsonElement Payload { get; init; }
+ }
+}
\ No newline at end of file
diff --git a/FileService/Configuration/AwsStorageOptions.cs b/FileService/Configuration/AwsStorageOptions.cs
new file mode 100644
index 00000000..0f277515
--- /dev/null
+++ b/FileService/Configuration/AwsStorageOptions.cs
@@ -0,0 +1,44 @@
+namespace File.Service.Configuration;
+
+///
+/// Настройки интеграции с LocalStack для хранения файлов и чтения сообщений.
+///
+public sealed class AwsStorageOptions
+{
+ public const string SectionName = "Aws";
+
+ ///
+ /// Базовый URL LocalStack.
+ ///
+ public string ServiceUrl { get; set; } = "http://localhost:4566";
+
+ ///
+ /// Регион AWS.
+ ///
+ public string Region { get; set; } = "us-east-1";
+
+ ///
+ /// Access key для LocalStack.
+ ///
+ public string AccessKey { get; set; } = "test";
+
+ ///
+ /// Secret key для LocalStack.
+ ///
+ public string SecretKey { get; set; } = "test";
+
+ ///
+ /// Имя SNS-топика с событиями генерации сотрудников.
+ ///
+ public string TopicName { get; set; } = "employee-generated-topic";
+
+ ///
+ /// Имя SQS-очереди, подписанной на SNS-топик.
+ ///
+ public string QueueName { get; set; } = "employee-generated-queue";
+
+ ///
+ /// Имя S3-бакета для файлов сотрудников.
+ ///
+ public string BucketName { get; set; } = "employee-files";
+}
diff --git a/FileService/File.Service.csproj b/FileService/File.Service.csproj
new file mode 100644
index 00000000..ea9ad640
--- /dev/null
+++ b/FileService/File.Service.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0
+ enable
+ enable
+ File.Service
+ File.Service
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FileService/Infrastructure/FileExportReadinessProbe.cs b/FileService/Infrastructure/FileExportReadinessProbe.cs
new file mode 100644
index 00000000..fcb78df4
--- /dev/null
+++ b/FileService/Infrastructure/FileExportReadinessProbe.cs
@@ -0,0 +1,197 @@
+using Amazon.Runtime;
+using Amazon.SimpleNotificationService;
+using Amazon.SimpleNotificationService.Model;
+using Amazon.SQS;
+using Amazon.SQS.Model;
+using File.Service.Configuration;
+using Microsoft.Extensions.Options;
+using System.Reflection;
+
+namespace File.Service.Infrastructure;
+
+public sealed class FileExportReadinessProbe
+{
+ private readonly AwsStorageOptions _options;
+ private readonly ILogger _logger;
+
+ public FileExportReadinessProbe(
+ IOptions options,
+ ILogger logger)
+ {
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ public async Task IsReadyAsync(CancellationToken cancellationToken)
+ {
+ var serviceUrl = GetStringProperty(_options, "ServiceUrl", "AwsServiceUrl");
+ var region = GetStringProperty(_options, "Region", "RegionName") ?? "us-east-1";
+ var accessKey = GetStringProperty(_options, "AccessKey", "AccessKeyId") ?? "test";
+ var secretKey = GetStringProperty(_options, "SecretKey", "SecretAccessKey") ?? "test";
+ var topicName = GetStringProperty(_options, "TopicName", "SnsTopicName");
+ var queueName = GetStringProperty(_options, "QueueName", "SqsQueueName");
+
+ if (string.IsNullOrWhiteSpace(serviceUrl) ||
+ string.IsNullOrWhiteSpace(topicName) ||
+ string.IsNullOrWhiteSpace(queueName))
+ {
+ _logger.LogDebug(
+ "File export readiness is false because required AWS settings are missing. ServiceUrl={ServiceUrl}, TopicName={TopicName}, QueueName={QueueName}",
+ serviceUrl,
+ topicName,
+ queueName);
+
+ return false;
+ }
+
+ try
+ {
+ var credentials = new BasicAWSCredentials(accessKey, secretKey);
+
+ using var sns = new AmazonSimpleNotificationServiceClient(
+ credentials,
+ new AmazonSimpleNotificationServiceConfig
+ {
+ ServiceURL = serviceUrl,
+ AuthenticationRegion = region
+ });
+
+ using var sqs = new AmazonSQSClient(
+ credentials,
+ new AmazonSQSConfig
+ {
+ ServiceURL = serviceUrl,
+ AuthenticationRegion = region
+ });
+
+ var topicArn = await FindTopicArnAsync(sns, topicName, cancellationToken);
+ if (string.IsNullOrWhiteSpace(topicArn))
+ {
+ return false;
+ }
+
+ var queueUrlResponse = await sqs.GetQueueUrlAsync(
+ new GetQueueUrlRequest
+ {
+ QueueName = queueName
+ },
+ cancellationToken);
+
+ if (string.IsNullOrWhiteSpace(queueUrlResponse.QueueUrl))
+ {
+ return false;
+ }
+
+ var subscriptionExists = await HasQueueSubscriptionAsync(
+ sns,
+ topicArn,
+ queueName,
+ cancellationToken);
+
+ return subscriptionExists;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "File export readiness probe failed.");
+ return false;
+ }
+ }
+
+ private static async Task FindTopicArnAsync(
+ IAmazonSimpleNotificationService sns,
+ string topicName,
+ CancellationToken cancellationToken)
+ {
+ string? nextToken = null;
+
+ do
+ {
+ var response = await sns.ListTopicsAsync(
+ new ListTopicsRequest
+ {
+ NextToken = nextToken
+ },
+ cancellationToken);
+
+ var topic = response.Topics.FirstOrDefault(t =>
+ !string.IsNullOrWhiteSpace(t.TopicArn) &&
+ t.TopicArn.EndsWith($":{topicName}", StringComparison.OrdinalIgnoreCase));
+
+ if (topic is not null)
+ {
+ return topic.TopicArn;
+ }
+
+ nextToken = response.NextToken;
+ }
+ while (!string.IsNullOrWhiteSpace(nextToken));
+
+ return null;
+ }
+
+ private static async Task HasQueueSubscriptionAsync(
+ IAmazonSimpleNotificationService sns,
+ string topicArn,
+ string queueName,
+ CancellationToken cancellationToken)
+ {
+ string? nextToken = null;
+
+ do
+ {
+ var response = await sns.ListSubscriptionsByTopicAsync(
+ new ListSubscriptionsByTopicRequest
+ {
+ TopicArn = topicArn,
+ NextToken = nextToken
+ },
+ cancellationToken);
+
+ var exists = response.Subscriptions.Any(subscription =>
+ !string.IsNullOrWhiteSpace(subscription.Endpoint) &&
+ EndpointMatchesQueue(subscription.Endpoint, queueName));
+
+ if (exists)
+ {
+ return true;
+ }
+
+ nextToken = response.NextToken;
+ }
+ while (!string.IsNullOrWhiteSpace(nextToken));
+
+ return false;
+ }
+
+ private static bool EndpointMatchesQueue(string endpoint, string queueName)
+ {
+ return endpoint.EndsWith($"/{queueName}", StringComparison.OrdinalIgnoreCase) ||
+ endpoint.Contains($":{queueName}", StringComparison.OrdinalIgnoreCase) ||
+ endpoint.Contains(queueName, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string? GetStringProperty(object source, params string[] names)
+ {
+ var type = source.GetType();
+
+ foreach (var name in names)
+ {
+ var property = type.GetProperty(
+ name,
+ BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
+
+ if (property?.PropertyType != typeof(string))
+ {
+ continue;
+ }
+
+ var value = property.GetValue(source) as string;
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ return value;
+ }
+ }
+
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/FileService/Program.cs b/FileService/Program.cs
new file mode 100644
index 00000000..a0798758
--- /dev/null
+++ b/FileService/Program.cs
@@ -0,0 +1,57 @@
+using File.Service.Background;
+using File.Service.Configuration;
+using File.Service.Storage;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.Logging.AddJsonConsole(options =>
+{
+ options.IncludeScopes = true;
+ options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ ";
+});
+
+builder.Services.Configure(builder.Configuration.GetSection(AwsStorageOptions.SectionName));
+
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
+
+var app = builder.Build();
+
+app.MapDefaultEndpoints();
+
+app.MapGet("/", () => Results.Ok(new
+{
+ service = "File.Service",
+ description = "Файловый сервис, сохраняющий сведения о сотрудниках в объектное хранилище",
+ endpoints = new[] { "/ready", "/files/{id}" }
+}));
+
+app.MapGet("/ready", (FileExportInfrastructureState state) =>
+{
+ return state.IsInitialized
+ ? Results.Ok(new { status = "ready" })
+ : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
+});
+
+app.MapGet("/files/{id:int}", async (int id, IEmployeeFileStorage storage, CancellationToken cancellationToken) =>
+{
+ if (id <= 0)
+ {
+ return Results.BadRequest(new { message = "Идентификатор сотрудника должен быть больше нуля." });
+ }
+
+ var content = await storage.TryReadEmployeeJsonAsync(id, cancellationToken);
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ return Results.NotFound(new { message = $"Файл для сотрудника {id} не найден." });
+ }
+
+ return Results.Text(content, "application/json");
+});
+
+app.Run();
+
+public partial class Program;
\ No newline at end of file
diff --git a/FileService/Properties/launchSettings.json b/FileService/Properties/launchSettings.json
new file mode 100644
index 00000000..111d9598
--- /dev/null
+++ b/FileService/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:16000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/FileService/Storage/IEmployeeFileStorage.cs b/FileService/Storage/IEmployeeFileStorage.cs
new file mode 100644
index 00000000..8580a9c2
--- /dev/null
+++ b/FileService/Storage/IEmployeeFileStorage.cs
@@ -0,0 +1,17 @@
+namespace File.Service.Storage;
+
+///
+/// Обеспечивает сохранение и чтение файлов сотрудников из объектного хранилища.
+///
+public interface IEmployeeFileStorage
+{
+ ///
+ /// Сохраняет JSON-файл сотрудника в объектное хранилище.
+ ///
+ Task SaveEmployeeJsonAsync(int employeeId, string json, CancellationToken cancellationToken = default);
+
+ ///
+ /// Пытается прочитать JSON-файл сотрудника из объектного хранилища.
+ ///
+ Task TryReadEmployeeJsonAsync(int employeeId, CancellationToken cancellationToken = default);
+}
diff --git a/FileService/Storage/S3EmployeeFileStorage.cs b/FileService/Storage/S3EmployeeFileStorage.cs
new file mode 100644
index 00000000..63e7eb50
--- /dev/null
+++ b/FileService/Storage/S3EmployeeFileStorage.cs
@@ -0,0 +1,91 @@
+using Amazon.Runtime;
+using Amazon.S3;
+using Amazon.S3.Model;
+using Amazon.S3.Util;
+using Microsoft.Extensions.Options;
+using File.Service.Configuration;
+
+namespace File.Service.Storage;
+
+///
+/// Хранилище файлов сотрудников в S3/LocalStack.
+///
+public sealed class S3EmployeeFileStorage : IEmployeeFileStorage, IAsyncDisposable
+{
+ private readonly AwsStorageOptions _options;
+ private readonly ILogger _logger;
+ private readonly IAmazonS3 _s3Client;
+
+ public S3EmployeeFileStorage(IOptions options, ILogger logger)
+ {
+ _options = options.Value;
+ _logger = logger;
+
+ var credentials = new BasicAWSCredentials(_options.AccessKey, _options.SecretKey);
+ var config = new AmazonS3Config
+ {
+ ServiceURL = _options.ServiceUrl,
+ AuthenticationRegion = _options.Region,
+ ForcePathStyle = true,
+ UseHttp = _options.ServiceUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
+ };
+
+ _s3Client = new AmazonS3Client(credentials, config);
+ }
+
+ public async Task SaveEmployeeJsonAsync(int employeeId, string json, CancellationToken cancellationToken = default)
+ {
+ await EnsureBucketExistsAsync(cancellationToken);
+
+ var key = BuildObjectKey(employeeId);
+ using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json));
+
+ await _s3Client.PutObjectAsync(new PutObjectRequest
+ {
+ BucketName = _options.BucketName,
+ Key = key,
+ InputStream = stream,
+ ContentType = "application/json"
+ }, cancellationToken);
+
+ _logger.LogInformation("Employee file stored in bucket {Bucket} with key {Key}", _options.BucketName, key);
+ }
+
+ public async Task TryReadEmployeeJsonAsync(int employeeId, CancellationToken cancellationToken = default)
+ {
+ await EnsureBucketExistsAsync(cancellationToken);
+
+ try
+ {
+ var response = await _s3Client.GetObjectAsync(_options.BucketName, BuildObjectKey(employeeId), cancellationToken);
+ using var reader = new StreamReader(response.ResponseStream);
+ return await reader.ReadToEndAsync();
+ }
+ catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound || ex.ErrorCode == "NoSuchKey")
+ {
+ return null;
+ }
+ }
+
+ private async Task EnsureBucketExistsAsync(CancellationToken cancellationToken)
+ {
+ var exists = await AmazonS3Util.DoesS3BucketExistV2Async(_s3Client, _options.BucketName);
+ if (exists)
+ {
+ return;
+ }
+
+ await _s3Client.PutBucketAsync(new PutBucketRequest
+ {
+ BucketName = _options.BucketName
+ }, cancellationToken);
+ }
+
+ private static string BuildObjectKey(int employeeId) => $"employees/employee-{employeeId}.json";
+
+ public async ValueTask DisposeAsync()
+ {
+ _s3Client.Dispose();
+ await Task.CompletedTask;
+ }
+}
diff --git a/FileService/appsettings.Development.json b/FileService/appsettings.Development.json
new file mode 100644
index 00000000..149d8558
--- /dev/null
+++ b/FileService/appsettings.Development.json
@@ -0,0 +1,17 @@
+{
+ "Aws": {
+ "ServiceUrl": "http://localhost:4566",
+ "Region": "us-east-1",
+ "AccessKey": "test",
+ "SecretKey": "test",
+ "TopicName": "employee-generated-topic",
+ "QueueName": "employee-generated-queue",
+ "BucketName": "employee-files"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/FileService/appsettings.json b/FileService/appsettings.json
new file mode 100644
index 00000000..da66dcd8
--- /dev/null
+++ b/FileService/appsettings.json
@@ -0,0 +1,18 @@
+{
+ "Aws": {
+ "ServiceUrl": "http://localhost:4566",
+ "Region": "us-east-1",
+ "AccessKey": "test",
+ "SecretKey": "test",
+ "TopicName": "employee-generated-topic",
+ "QueueName": "employee-generated-queue",
+ "BucketName": "employee-files"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/README.md b/README.md
index dcaa5eb7..8ea40cc3 100644
--- a/README.md
+++ b/README.md
@@ -1,128 +1,57 @@
# Современные технологии разработки программного обеспечения
-[Таблица с успеваемостью](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).
+## Лабораторная работа 3
+### Интеграционное тестирование — файловый сервис, SNS и объектное хранилище
+
+> Вариант 28: брокер **SNS** и хранилище **S3 LocalStack**. Для надёжной доставки в файловый сервис SNS-топик подписан на SQS-очередь, которую опрашивает `File.Service`.
+
+
+## Запуск
+
+### Старт приложения
+
+```bash
+dotnet restore
+dotnet run --project ./AspireApp/AspireApp.AppHost/AspireApp.AppHost.csproj --launch-profile http
+```
+
+## Ручная проверка
+
+### 1. Генерация сотрудника
+
+```bash
+curl -k "https://localhost:15000/employee?id=101"
+```
+### 2. Проверка gateway
+
+```bash
+curl -k -i "https://localhost:7200/employee?id=101"
+```
+
+### 3. Проверка сохранённого файла
+
+```bash
+curl "http://localhost:16000/files/101"
+```
+
+
+## Интеграционные тесты
+
+Тестовый проект `Backend.IntegrationTests` поднимает весь backend через `Aspire.Hosting.Testing` и проверяет:
+
+- что `Service.Api` возвращает сотрудника;
+- что сотрудник после публикации события появляется в объектном хранилище;
+- что повторный запрос по одному и тому же `id` возвращает тот же JSON и файл остаётся доступен.
+
+Запуск:
+
+```bash
+dotnet test ./Backend.IntegrationTests/Backend.IntegrationTests.csproj
+```
+
+
+## Ключевые endpoints
+
+- `GET https://localhost:15000/employee?id=1` — прямая генерация через одну реплику сервиса;
+- `GET http://localhost:7200/employee?id=1` — запрос через Ocelot gateway;
+- `GET http://localhost:16000/files/1` — чтение сохранённого файла сотрудника.
\ No newline at end of file
diff --git a/ServiceApi/Configuration/AwsMessagingOptions.cs b/ServiceApi/Configuration/AwsMessagingOptions.cs
new file mode 100644
index 00000000..32feee93
--- /dev/null
+++ b/ServiceApi/Configuration/AwsMessagingOptions.cs
@@ -0,0 +1,34 @@
+namespace Service.Api.Configuration;
+
+///
+/// Параметры интеграции с AWS-совместимыми сервисами LocalStack.
+///
+public sealed class AwsMessagingOptions
+{
+ public const string SectionName = "Aws";
+
+ ///
+ /// Базовый URL LocalStack.
+ ///
+ public string ServiceUrl { get; set; } = "http://localhost:4566";
+
+ ///
+ /// Регион AWS.
+ ///
+ public string Region { get; set; } = "us-east-1";
+
+ ///
+ /// Access key для LocalStack.
+ ///
+ public string AccessKey { get; set; } = "test";
+
+ ///
+ /// Secret key для LocalStack.
+ ///
+ public string SecretKey { get; set; } = "test";
+
+ ///
+ /// Имя SNS-топика с событиями генерации сотрудников.
+ ///
+ public string TopicName { get; set; } = "employee-generated-topic";
+}
diff --git a/ServiceApi/Entities/Employee.cs b/ServiceApi/Entities/Employee.cs
new file mode 100644
index 00000000..fec349fb
--- /dev/null
+++ b/ServiceApi/Entities/Employee.cs
@@ -0,0 +1,39 @@
+using System.Text.Json.Serialization;
+
+namespace Service.Api.Entities;
+
+///
+/// Сотрудник компании.
+///
+public sealed class Employee
+{
+ [JsonPropertyName("id")]
+ public int Id { get; set; }
+
+ [JsonPropertyName("fullName")]
+ public string FullName { get; set; } = string.Empty;
+
+ [JsonPropertyName("position")]
+ public string Position { get; set; } = string.Empty;
+
+ [JsonPropertyName("department")]
+ public string Department { get; set; } = string.Empty;
+
+ [JsonPropertyName("hireDate")]
+ public DateOnly HireDate { get; set; }
+
+ [JsonPropertyName("salary")]
+ public decimal Salary { get; set; }
+
+ [JsonPropertyName("email")]
+ public string Email { get; set; } = string.Empty;
+
+ [JsonPropertyName("phone")]
+ public string Phone { get; set; } = string.Empty;
+
+ [JsonPropertyName("isFired")]
+ public bool IsFired { get; set; }
+
+ [JsonPropertyName("fireDate")]
+ public DateOnly? FireDate { get; set; }
+}
diff --git a/ServiceApi/Generator/EmployeeGenerator.cs b/ServiceApi/Generator/EmployeeGenerator.cs
new file mode 100644
index 00000000..b3e7645e
--- /dev/null
+++ b/ServiceApi/Generator/EmployeeGenerator.cs
@@ -0,0 +1,92 @@
+using Bogus;
+using Bogus.DataSets;
+using Service.Api.Entities;
+
+namespace Service.Api.Generator;
+
+///
+/// Генератор случайных сотрудников компании.
+///
+public static class EmployeeGenerator
+{
+ private static readonly string[] ProfessionCatalog =
+ {
+ "Developer",
+ "Manager",
+ "Analyst",
+ "Tester",
+ "Administrator",
+ "Designer"
+ };
+
+ private static readonly string[] PositionLevels =
+ {
+ "Junior",
+ "Middle",
+ "Senior"
+ };
+
+ public static Employee Generate(int id)
+ {
+ var faker = new Faker("ru");
+
+ var gender = faker.PickRandom();
+ var firstName = faker.Name.FirstName(gender);
+ var lastName = faker.Name.LastName(gender);
+ var patronymic = BuildPatronymic(faker.Name.FirstName(Name.Gender.Male), gender);
+
+ var level = faker.PickRandom(PositionLevels);
+ var profession = faker.PickRandom(ProfessionCatalog);
+ var hireDate = faker.Date.Past(10, DateTime.Today);
+ var isFired = faker.Random.Bool(0.18f);
+
+ DateOnly? fireDate = null;
+ if (isFired)
+ {
+ fireDate = DateOnly.FromDateTime(faker.Date.Between(hireDate, DateTime.Today));
+ }
+
+ return new Employee
+ {
+ Id = id,
+ FullName = $"{lastName} {firstName} {patronymic}",
+ Position = $"{level} {profession}",
+ Department = faker.Commerce.Department(),
+ HireDate = DateOnly.FromDateTime(hireDate),
+ Salary = CalculateSalary(level, faker),
+ Email = faker.Internet.Email(firstName, lastName),
+ Phone = faker.Phone.PhoneNumber("+7(###)###-##-##"),
+ IsFired = isFired,
+ FireDate = fireDate
+ };
+ }
+
+ private static string BuildPatronymic(string sourceName, Name.Gender gender)
+ {
+ if (string.IsNullOrWhiteSpace(sourceName))
+ {
+ return gender == Name.Gender.Male ? "Иванович" : "Ивановна";
+ }
+
+ return gender switch
+ {
+ Name.Gender.Male when sourceName.EndsWith('й') => $"{sourceName[..^1]}евич",
+ Name.Gender.Female when sourceName.EndsWith('й') => $"{sourceName[..^1]}евна",
+ Name.Gender.Male => $"{sourceName}ович",
+ _ => $"{sourceName}овна"
+ };
+ }
+
+ private static decimal CalculateSalary(string level, Faker faker)
+ {
+ var value = level switch
+ {
+ "Junior" => faker.Random.Decimal(60_000m, 95_000m),
+ "Middle" => faker.Random.Decimal(100_000m, 170_000m),
+ "Senior" => faker.Random.Decimal(180_000m, 280_000m),
+ _ => faker.Random.Decimal(80_000m, 120_000m)
+ };
+
+ return Math.Round(value, 2, MidpointRounding.AwayFromZero);
+ }
+}
diff --git a/ServiceApi/Generator/EmployeeGeneratorService.cs b/ServiceApi/Generator/EmployeeGeneratorService.cs
new file mode 100644
index 00000000..15b43aba
--- /dev/null
+++ b/ServiceApi/Generator/EmployeeGeneratorService.cs
@@ -0,0 +1,88 @@
+using System.Text.Json;
+using Microsoft.Extensions.Caching.Distributed;
+using Service.Api.Entities;
+using Service.Api.Messaging;
+
+namespace Service.Api.Generator;
+
+public sealed class EmployeeGeneratorService(
+ IDistributedCache cache,
+ ILogger logger,
+ IConfiguration configuration,
+ IEmployeeEventPublisher eventPublisher) : IEmployeeGeneratorService
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ private readonly TimeSpan _cacheExpiration =
+ TimeSpan.FromMinutes(configuration.GetValue("CacheExpirationMinutes") ?? 30);
+
+ public async Task ProcessEmployee(int id, CancellationToken cancellationToken = default)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(id);
+
+ var cacheKey = $"employee:{id}";
+ var fromCache = false;
+
+ using var _ = logger.BeginScope(new Dictionary
+ {
+ ["EmployeeId"] = id,
+ ["CacheKey"] = cacheKey
+ });
+
+ logger.LogInformation("Employee request received");
+
+ try
+ {
+ var employee = await RetrieveFromCache(cacheKey, cancellationToken);
+ if (employee is not null)
+ {
+ fromCache = true;
+ logger.LogInformation("Cache hit. Returning employee from Redis");
+ await eventPublisher.PublishAsync(employee, cancellationToken);
+ return employee;
+ }
+
+ logger.LogInformation("Cache miss. Generating new employee");
+ employee = EmployeeGenerator.Generate(id);
+ await PopulateCache(cacheKey, employee, cancellationToken);
+ await eventPublisher.PublishAsync(employee, cancellationToken);
+
+ logger.LogInformation(
+ "Employee {EmployeeId} stored in cache for {CacheLifetimeMinutes} minutes and published to SNS",
+ id,
+ _cacheExpiration.TotalMinutes);
+
+ return employee;
+ }
+ catch (Exception exception)
+ {
+ logger.LogError(exception, "Error while processing employee {EmployeeId}. FromCache={FromCache}", id, fromCache);
+ throw;
+ }
+ }
+
+ private async Task RetrieveFromCache(string cacheKey, CancellationToken cancellationToken)
+ {
+ var json = await cache.GetStringAsync(cacheKey, cancellationToken);
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return null;
+ }
+
+ return JsonSerializer.Deserialize(json, JsonOptions);
+ }
+
+ private async Task PopulateCache(string cacheKey, Employee employee, CancellationToken cancellationToken)
+ {
+ var json = JsonSerializer.Serialize(employee, JsonOptions);
+
+ await cache.SetStringAsync(
+ cacheKey,
+ json,
+ new DistributedCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = _cacheExpiration
+ },
+ cancellationToken);
+ }
+}
diff --git a/ServiceApi/Generator/IEmployeeGeneratorService.cs b/ServiceApi/Generator/IEmployeeGeneratorService.cs
new file mode 100644
index 00000000..c8de0288
--- /dev/null
+++ b/ServiceApi/Generator/IEmployeeGeneratorService.cs
@@ -0,0 +1,11 @@
+using Service.Api.Entities;
+
+namespace Service.Api.Generator;
+
+///
+/// Интерфейс обработки запросов на получение сотрудника.
+///
+public interface IEmployeeGeneratorService
+{
+ Task ProcessEmployee(int id, CancellationToken cancellationToken = default);
+}
diff --git a/ServiceApi/Messaging/EmployeeGeneratedMessage.cs b/ServiceApi/Messaging/EmployeeGeneratedMessage.cs
new file mode 100644
index 00000000..e0d06531
--- /dev/null
+++ b/ServiceApi/Messaging/EmployeeGeneratedMessage.cs
@@ -0,0 +1,29 @@
+using Service.Api.Entities;
+
+namespace Service.Api.Messaging;
+
+///
+/// Сообщение о сформированных данных сотрудника.
+///
+public sealed class EmployeeGeneratedMessage
+{
+ ///
+ /// Идентификатор сотрудника.
+ ///
+ public int EmployeeId { get; init; }
+
+ ///
+ /// Время публикации события в UTC.
+ ///
+ public DateTime PublishedAtUtc { get; init; }
+
+ ///
+ /// Идентификатор реплики сервиса, опубликовавшей событие.
+ ///
+ public string ReplicaId { get; init; } = string.Empty;
+
+ ///
+ /// Сформированные данные сотрудника.
+ ///
+ public Employee Payload { get; init; } = new();
+}
diff --git a/ServiceApi/Messaging/IEmployeeEventPublisher.cs b/ServiceApi/Messaging/IEmployeeEventPublisher.cs
new file mode 100644
index 00000000..7a3f844c
--- /dev/null
+++ b/ServiceApi/Messaging/IEmployeeEventPublisher.cs
@@ -0,0 +1,14 @@
+using Service.Api.Entities;
+
+namespace Service.Api.Messaging;
+
+///
+/// Публикует сведения о сотруднике в брокер сообщений.
+///
+public interface IEmployeeEventPublisher
+{
+ ///
+ /// Публикует событие генерации сотрудника.
+ ///
+ Task PublishAsync(Employee employee, CancellationToken cancellationToken = default);
+}
diff --git a/ServiceApi/Messaging/SnsEmployeeEventPublisher.cs b/ServiceApi/Messaging/SnsEmployeeEventPublisher.cs
new file mode 100644
index 00000000..9d6efa11
--- /dev/null
+++ b/ServiceApi/Messaging/SnsEmployeeEventPublisher.cs
@@ -0,0 +1,149 @@
+using System.Text.Json;
+using Amazon.Runtime;
+using Amazon.SimpleNotificationService;
+using Amazon.SimpleNotificationService.Model;
+using Microsoft.Extensions.Options;
+using Service.Api.Configuration;
+using Service.Api.Entities;
+
+namespace Service.Api.Messaging;
+
+///
+/// Публикует события генерации сотрудников в SNS.
+///
+public sealed class SnsEmployeeEventPublisher : IEmployeeEventPublisher, IAsyncDisposable
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ WriteIndented = false
+ };
+
+ private readonly AwsMessagingOptions _options;
+ private readonly ILogger _logger;
+ private readonly IAmazonSimpleNotificationService _snsClient;
+ private readonly string _replicaId;
+ private string? _topicArn;
+
+ public SnsEmployeeEventPublisher(
+ IOptions options,
+ IConfiguration configuration,
+ ILogger logger)
+ {
+ _options = options.Value;
+ _logger = logger;
+ _replicaId = configuration["ReplicaId"] ?? Environment.MachineName;
+
+ var credentials = new BasicAWSCredentials(_options.AccessKey, _options.SecretKey);
+ var config = new AmazonSimpleNotificationServiceConfig
+ {
+ ServiceURL = _options.ServiceUrl,
+ AuthenticationRegion = _options.Region,
+ UseHttp = _options.ServiceUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
+ };
+
+ _snsClient = new AmazonSimpleNotificationServiceClient(credentials, config);
+ }
+
+ public async Task PublishAsync(Employee employee, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(employee);
+
+ Exception? lastException = null;
+
+ for (var attempt = 1; attempt <= 15; attempt++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ var topicArn = await EnsureTopicAsync(cancellationToken);
+
+ var message = new EmployeeGeneratedMessage
+ {
+ EmployeeId = employee.Id,
+ PublishedAtUtc = DateTime.UtcNow,
+ ReplicaId = _replicaId,
+ Payload = employee
+ };
+
+ var payload = JsonSerializer.Serialize(message, JsonOptions);
+
+ _logger.LogInformation(
+ "Publishing employee {EmployeeId} to SNS topic {TopicArn}. Attempt {Attempt}",
+ employee.Id,
+ topicArn,
+ attempt);
+
+ await _snsClient.PublishAsync(new PublishRequest
+ {
+ TopicArn = topicArn,
+ Subject = $"employee-{employee.Id}",
+ Message = payload,
+ MessageAttributes = new Dictionary
+ {
+ ["employeeId"] = new()
+ {
+ DataType = "Number",
+ StringValue = employee.Id.ToString()
+ },
+ ["replicaId"] = new()
+ {
+ DataType = "String",
+ StringValue = _replicaId
+ }
+ }
+ }, cancellationToken);
+
+ return;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ lastException = ex;
+ _topicArn = null;
+
+ _logger.LogWarning(
+ ex,
+ "Failed to publish employee {EmployeeId} to SNS on attempt {Attempt}. Retrying...",
+ employee.Id,
+ attempt);
+
+ if (attempt == 15)
+ {
+ break;
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
+ }
+ }
+
+ throw new InvalidOperationException(
+ $"Не удалось опубликовать сотрудника {employee.Id} в SNS после повторных попыток.",
+ lastException);
+ }
+
+ private async Task EnsureTopicAsync(CancellationToken cancellationToken)
+ {
+ if (!string.IsNullOrWhiteSpace(_topicArn))
+ {
+ return _topicArn;
+ }
+
+ var response = await _snsClient.CreateTopicAsync(new CreateTopicRequest
+ {
+ Name = _options.TopicName
+ }, cancellationToken);
+
+ _topicArn = response.TopicArn;
+ return _topicArn;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ _snsClient.Dispose();
+ await Task.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/ServiceApi/Program.cs b/ServiceApi/Program.cs
new file mode 100644
index 00000000..26560057
--- /dev/null
+++ b/ServiceApi/Program.cs
@@ -0,0 +1,83 @@
+using Service.Api.Configuration;
+using Service.Api.Generator;
+using Service.Api.Messaging;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+builder.AddRedisDistributedCache("RedisCache");
+
+builder.Logging.AddJsonConsole(options =>
+{
+ options.IncludeScopes = true;
+ options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ ";
+});
+
+builder.Services.Configure(builder.Configuration.GetSection(AwsMessagingOptions.SectionName));
+builder.Services.AddSingleton();
+builder.Services.AddScoped();
+
+builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
+{
+ policy.SetIsOriginAllowed(origin =>
+ Uri.TryCreate(origin, UriKind.Absolute, out var uri)
+ && uri.IsLoopback
+ && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
+ .AllowAnyHeader()
+ .WithMethods("GET")
+ .WithExposedHeaders("X-Service-Replica", "X-Service-Weight");
+}));
+
+var app = builder.Build();
+
+var replicaId = app.Configuration["ReplicaId"] ?? Environment.MachineName;
+var replicaWeight = app.Configuration.GetValue("ReplicaWeight") ?? 1;
+
+app.UseCors();
+app.Use(async (context, next) =>
+{
+ context.Response.Headers["X-Service-Replica"] = replicaId;
+ context.Response.Headers["X-Service-Weight"] = replicaWeight.ToString();
+ await next();
+});
+
+app.MapDefaultEndpoints();
+
+app.MapGet("/", () => Results.Ok(new
+{
+ service = "Service.Api",
+ replica = replicaId,
+ weight = replicaWeight,
+ description = "Сервис генерации сотрудников компании",
+ endpoints = new[] { "/employee?id=1", "/employee/1" }
+}));
+
+app.MapGet("/employee", async (IEmployeeGeneratorService service, ILoggerFactory loggerFactory, int id, CancellationToken cancellationToken) =>
+{
+ var logger = loggerFactory.CreateLogger("ServiceApiEndpoints");
+ logger.LogInformation("Replica {ReplicaId} received request for employee {EmployeeId}", replicaId, id);
+
+ if (id <= 0)
+ {
+ return Results.BadRequest(new { message = "Идентификатор сотрудника должен быть больше нуля." });
+ }
+
+ return Results.Ok(await service.ProcessEmployee(id, cancellationToken));
+});
+
+app.MapGet("/employee/{id:int}", async (IEmployeeGeneratorService service, ILoggerFactory loggerFactory, int id, CancellationToken cancellationToken) =>
+{
+ var logger = loggerFactory.CreateLogger("ServiceApiEndpoints");
+ logger.LogInformation("Replica {ReplicaId} received request for employee {EmployeeId}", replicaId, id);
+
+ if (id <= 0)
+ {
+ return Results.BadRequest(new { message = "Идентификатор сотрудника должен быть больше нуля." });
+ }
+
+ return Results.Ok(await service.ProcessEmployee(id, cancellationToken));
+});
+
+app.Run();
+
+public partial class Program;
diff --git a/ServiceApi/Properties/launchSettings.json b/ServiceApi/Properties/launchSettings.json
new file mode 100644
index 00000000..0641ca27
--- /dev/null
+++ b/ServiceApi/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:7099",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/ServiceApi/Service.Api.csproj b/ServiceApi/Service.Api.csproj
new file mode 100644
index 00000000..f6c45b81
--- /dev/null
+++ b/ServiceApi/Service.Api.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0
+ enable
+ enable
+ Service.Api
+ Service.Api
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ServiceApi/appsettings.Development.json b/ServiceApi/appsettings.Development.json
new file mode 100644
index 00000000..30303786
--- /dev/null
+++ b/ServiceApi/appsettings.Development.json
@@ -0,0 +1,19 @@
+{
+ "CacheExpirationMinutes": 30,
+ "ConnectionStrings": {
+ "RedisCache": "localhost:6379"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "Aws": {
+ "ServiceUrl": "http://localhost:4566",
+ "Region": "us-east-1",
+ "AccessKey": "test",
+ "SecretKey": "test",
+ "TopicName": "employee-generated-topic"
+ }
+}
diff --git a/ServiceApi/appsettings.json b/ServiceApi/appsettings.json
new file mode 100644
index 00000000..a743d9e1
--- /dev/null
+++ b/ServiceApi/appsettings.json
@@ -0,0 +1,17 @@
+{
+ "CacheExpirationMinutes": 30,
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "Aws": {
+ "ServiceUrl": "http://localhost:4566",
+ "Region": "us-east-1",
+ "AccessKey": "test",
+ "SecretKey": "test",
+ "TopicName": "employee-generated-topic"
+ }
+}
diff --git a/image1.png b/image1.png
new file mode 100644
index 00000000..17c8e2f8
Binary files /dev/null and b/image1.png differ
diff --git a/image2.png b/image2.png
new file mode 100644
index 00000000..cd6d8782
Binary files /dev/null and b/image2.png differ