diff --git a/.gitignore b/.gitignore
index ce892922..f272ea46 100644
--- a/.gitignore
+++ b/.gitignore
@@ -416,3 +416,13 @@ FodyWeavers.xsd
*.msix
*.msm
*.msp
+
+vehicle-publish/
+publish/
+fileservice-publish/
+
+*.zip
+
+# Yandex Cloud — артефакты сборки и деплоя
+cloud/build/
+Client.Wasm/wwwroot/appsettings.Production.json
\ No newline at end of file
diff --git a/Client.Wasm/Client.Wasm.csproj b/Client.Wasm/Client.Wasm.csproj
index 0ba9f90c..40edaeee 100644
--- a/Client.Wasm/Client.Wasm.csproj
+++ b/Client.Wasm/Client.Wasm.csproj
@@ -16,6 +16,7 @@
+
diff --git a/Client.Wasm/Components/DataCard.razor b/Client.Wasm/Components/DataCard.razor
index c646a839..20b06fa8 100644
--- a/Client.Wasm/Components/DataCard.razor
+++ b/Client.Wasm/Components/DataCard.razor
@@ -1,5 +1,5 @@
-@inject IConfiguration Configuration
-@inject HttpClient Client
+@using Client.Wasm
+@inject VehicleApiClient ApiClient
@@ -67,8 +67,7 @@
private async Task RequestNewData()
{
- var baseAddress = Configuration["BaseAddress"] ?? throw new KeyNotFoundException("Конфигурация клиента не содержит параметра BaseAddress");
- Value = await Client.GetFromJsonAsync($"{baseAddress}?id={Id}", new JsonSerializerOptions { });
+ Value = await ApiClient.GetVehicleAsync(Id);
StateHasChanged();
}
}
diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor
index 661f1181..1598d77b 100644
--- a/Client.Wasm/Components/StudentCard.razor
+++ b/Client.Wasm/Components/StudentCard.razor
@@ -1,13 +1,13 @@
-
+
- Лабораторная работа
+ Лабораторная работа
- Номер №X "Название лабораторной"
- Вариант №Х "Название варианта"
- Выполнена Фамилией Именем 65ХХ
- Ссылка на форк
+ Номер №4 "Переход на облачную инфраструктуру"
+ Вариант №29 "Транспортное средство"
+ Выполнена Нестеренко Андреем 6512
+ Ссылка на репозиторий
diff --git a/Client.Wasm/Program.cs b/Client.Wasm/Program.cs
index a182a920..87c7cad9 100644
--- a/Client.Wasm/Program.cs
+++ b/Client.Wasm/Program.cs
@@ -9,7 +9,12 @@
builder.RootComponents.Add("#app");
builder.RootComponents.Add("head::after");
-builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
+builder.Services.AddHttpClient(client =>
+{
+ var baseAddress = builder.Configuration["BaseAddress"]
+ ?? throw new InvalidOperationException("BaseAddress not found in configuration.");
+ client.BaseAddress = new Uri(baseAddress);
+});
builder.Services.AddBlazorise(options => { options.Immediate = true; })
.AddBootstrapProviders()
.AddFontAwesomeIcons();
diff --git a/Client.Wasm/VehicleApiClient.cs b/Client.Wasm/VehicleApiClient.cs
new file mode 100644
index 00000000..80fab7a9
--- /dev/null
+++ b/Client.Wasm/VehicleApiClient.cs
@@ -0,0 +1,12 @@
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+
+namespace Client.Wasm;
+
+public class VehicleApiClient(HttpClient httpClient)
+{
+ public async Task GetVehicleAsync(int id)
+ {
+ return await httpClient.GetFromJsonAsync($"api/vehicle/{id}");
+ }
+}
diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json
index d1fe7ab3..f1a09156 100644
--- a/Client.Wasm/wwwroot/appsettings.json
+++ b/Client.Wasm/wwwroot/appsettings.json
@@ -6,5 +6,5 @@
}
},
"AllowedHosts": "*",
- "BaseAddress": ""
+ "BaseAddress": "http://localhost:5200/"
}
diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln
index cb48241d..fc0e6a1f 100644
--- a/CloudDevelopment.sln
+++ b/CloudDevelopment.sln
@@ -1,10 +1,27 @@
-
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.Gateway", "ProjectApp.Gateway\ProjectApp.Gateway.csproj", "{EBCDE049-6A3B-431E-ACBA-DD9C90898F49}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.FileService", "ProjectApp.FileService\ProjectApp.FileService.csproj", "{FBAF0E91-832A-4594-9515-C9D42EA8A25B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.Tests", "ProjectApp.Tests\ProjectApp.Tests.csproj", "{38936714-96E7-4B0C-904A-0B01D8E96B2D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.Api.Function", "ProjectApp.Api.Function\ProjectApp.Api.Function.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.FileService.Function", "ProjectApp.FileService.Function\ProjectApp.FileService.Function.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F23456789012}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -15,6 +32,42 @@ 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
+ {EBCDE049-6A3B-431E-ACBA-DD9C90898F49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EBCDE049-6A3B-431E-ACBA-DD9C90898F49}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EBCDE049-6A3B-431E-ACBA-DD9C90898F49}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EBCDE049-6A3B-431E-ACBA-DD9C90898F49}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FBAF0E91-832A-4594-9515-C9D42EA8A25B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FBAF0E91-832A-4594-9515-C9D42EA8A25B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FBAF0E91-832A-4594-9515-C9D42EA8A25B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FBAF0E91-832A-4594-9515-C9D42EA8A25B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {38936714-96E7-4B0C-904A-0B01D8E96B2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {38936714-96E7-4B0C-904A-0B01D8E96B2D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {38936714-96E7-4B0C-904A-0B01D8E96B2D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {38936714-96E7-4B0C-904A-0B01D8E96B2D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/ProjectApp.Api.Function/Handler.cs b/ProjectApp.Api.Function/Handler.cs
new file mode 100644
index 00000000..fcf1c478
--- /dev/null
+++ b/ProjectApp.Api.Function/Handler.cs
@@ -0,0 +1,145 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Amazon.Runtime;
+using Amazon.SQS;
+using Amazon.SQS.Model;
+
+namespace ApiFunction;
+
+///
+/// Cloud Function генерации транспортного средства по идентификатору.
+/// При наличии кэша в Object Storage возвращает сохранённые данные;
+/// иначе генерирует новые и публикует их в Message Queue.
+///
+public class Handler
+{
+ private static readonly Random _rng = Random.Shared;
+ private static readonly HttpClient _http = new();
+
+ private static readonly string[] _brands = ["Toyota", "BMW", "Mercedes", "Audi", "Ford", "Honda", "Volkswagen", "Hyundai", "Kia", "Nissan"];
+ private static readonly string[] _models = ["Sedan", "SUV", "Hatchback", "Coupe", "Crossover", "Pickup", "Minivan", "Wagon"];
+ private static readonly string[] _bodyTypes = ["Sedan", "SUV", "Hatchback", "Coupe", "Crossover"];
+ private static readonly string[] _fuelTypes = ["Petrol", "Diesel", "Electric", "Hybrid"];
+ private static readonly string[] _colors = ["White", "Black", "Silver", "Blue", "Red", "Grey", "Green"];
+
+ private static readonly JsonSerializerOptions _camelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
+
+ ///
+ /// Точка входа Cloud Function. Принимает HTTP-запрос от API Gateway,
+ /// возвращает JSON транспортного средства с CORS-заголовками.
+ ///
+ /// Сериализованный запрос от API Gateway
+ public string FunctionHandler(string input)
+ {
+ FunctionRequest? req = null;
+ try { req = JsonSerializer.Deserialize(input ?? "{}"); } catch { }
+
+ string? idRaw = null;
+ req?.PathParams?.TryGetValue("id", out idRaw);
+ if (string.IsNullOrEmpty(idRaw))
+ {
+ var url = req?.Url ?? req?.Path ?? "";
+ idRaw = url.Split('?')[0].Split('/', StringSplitOptions.RemoveEmptyEntries)
+ .LastOrDefault(p => !p.StartsWith('{'));
+ }
+
+ if (!int.TryParse(idRaw, out var id) || id <= 0)
+ return Envelope(400, """{"error":"Identifier must be a positive number."}""");
+
+ var cached = TryGetCachedAsync(id).GetAwaiter().GetResult();
+ if (cached != null)
+ return Envelope(200, cached);
+
+ var vehicle = new
+ {
+ id,
+ vin = GenerateVin(),
+ brand = _brands[_rng.Next(_brands.Length)],
+ model = _models[_rng.Next(_models.Length)],
+ year = _rng.Next(2010, 2025),
+ bodyType = _bodyTypes[_rng.Next(_bodyTypes.Length)],
+ fuelType = _fuelTypes[_rng.Next(_fuelTypes.Length)],
+ color = _colors[_rng.Next(_colors.Length)],
+ mileage = Math.Round(_rng.NextDouble() * 200000, 1),
+ lastServiceDate = DateTime.UtcNow.AddDays(-_rng.Next(30, 730)).ToString("yyyy-MM-dd")
+ };
+
+ var json = JsonSerializer.Serialize(vehicle, _camelCase);
+
+ Task.Run(() => TryPublishAsync(json));
+
+ return Envelope(200, json);
+ }
+
+ private static string Envelope(int status, string body) =>
+ JsonSerializer.Serialize(new
+ {
+ statusCode = status,
+ headers = new Dictionary
+ {
+ ["Content-Type"] = "application/json",
+ ["Access-Control-Allow-Origin"] = "*"
+ },
+ body
+ });
+
+ private static string GenerateVin()
+ {
+ const string chars = "ABCDEFGHJKLMNPRSTUVWXYZ0123456789";
+ return string.Concat(Enumerable.Range(0, 17).Select(_ => chars[_rng.Next(chars.Length)]));
+ }
+
+ private static async Task TryGetCachedAsync(int id)
+ {
+ try
+ {
+ var bucket = Environment.GetEnvironmentVariable("S3_BUCKET") ?? "vehicle-data-store";
+ var url = $"https://storage.yandexcloud.net/{bucket}/vehicle-{id}.json";
+ var resp = await _http.GetAsync(url);
+ if (resp.IsSuccessStatusCode)
+ return await resp.Content.ReadAsStringAsync();
+ }
+ catch { }
+ return null;
+ }
+
+ private static async Task TryPublishAsync(string messageBody)
+ {
+ try
+ {
+ var queueUrl = Environment.GetEnvironmentVariable("SQS_QUEUE_URL") ?? "";
+ var accessKey = Environment.GetEnvironmentVariable("AWS_ACCESS_KEY_ID") ?? "";
+ var secretKey = Environment.GetEnvironmentVariable("AWS_SECRET_ACCESS_KEY") ?? "";
+ if (string.IsNullOrEmpty(queueUrl) || string.IsNullOrEmpty(accessKey)) return;
+
+ var credentials = new BasicAWSCredentials(accessKey, secretKey);
+ var config = new AmazonSQSConfig
+ {
+ ServiceURL = "https://message-queue.api.cloud.yandex.net",
+ AuthenticationRegion = "ru-central1"
+ };
+ using var sqs = new AmazonSQSClient(credentials, config);
+ await sqs.SendMessageAsync(new SendMessageRequest { QueueUrl = queueUrl, MessageBody = messageBody });
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[WARN] SQS publish failed: {ex.Message}");
+ }
+ }
+}
+
+///
+/// Модель входящего запроса от Yandex API Gateway.
+///
+public class FunctionRequest
+{
+ [JsonPropertyName("httpMethod")] public string HttpMethod { get; set; } = "";
+ [JsonPropertyName("headers")] public Dictionary? Headers { get; set; }
+ [JsonPropertyName("path")] public string? Path { get; set; }
+ [JsonPropertyName("queryStringParameters")] public Dictionary? QueryStringParameters { get; set; }
+ [JsonPropertyName("pathParameters")] public Dictionary? PathParameters { get; set; }
+ [JsonPropertyName("pathParams")] public Dictionary? PathParams { get; set; }
+ [JsonPropertyName("url")] public string? Url { get; set; }
+ [JsonPropertyName("body")] public string? Body { get; set; }
+ [JsonPropertyName("isBase64Encoded")] public bool IsBase64Encoded { get; set; }
+}
diff --git a/ProjectApp.Api.Function/Models/FunctionModels.cs b/ProjectApp.Api.Function/Models/FunctionModels.cs
new file mode 100644
index 00000000..c8a8ce2e
--- /dev/null
+++ b/ProjectApp.Api.Function/Models/FunctionModels.cs
@@ -0,0 +1,42 @@
+using System.Text.Json.Serialization;
+
+namespace ApiFunction.Models;
+
+public class FunctionRequest
+{
+ [JsonPropertyName("httpMethod")]
+ public string HttpMethod { get; set; } = "";
+
+ [JsonPropertyName("headers")]
+ public Dictionary? Headers { get; set; }
+
+ [JsonPropertyName("path")]
+ public string? Path { get; set; }
+
+ [JsonPropertyName("queryStringParameters")]
+ public Dictionary? QueryStringParameters { get; set; }
+
+ [JsonPropertyName("pathParameters")]
+ public Dictionary? PathParameters { get; set; }
+
+ [JsonPropertyName("body")]
+ public string? Body { get; set; }
+
+ [JsonPropertyName("isBase64Encoded")]
+ public bool IsBase64Encoded { get; set; }
+}
+
+public class FunctionResponse
+{
+ [JsonPropertyName("statusCode")]
+ public int StatusCode { get; set; }
+
+ [JsonPropertyName("headers")]
+ public Dictionary? Headers { get; set; }
+
+ [JsonPropertyName("body")]
+ public string Body { get; set; } = "";
+
+ [JsonPropertyName("isBase64Encoded")]
+ public bool IsBase64Encoded { get; set; }
+}
diff --git a/ProjectApp.Api.Function/ModuleInit.cs b/ProjectApp.Api.Function/ModuleInit.cs
new file mode 100644
index 00000000..d3090060
--- /dev/null
+++ b/ProjectApp.Api.Function/ModuleInit.cs
@@ -0,0 +1,42 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+
+namespace ApiFunction;
+
+///
+/// Инициализатор модуля — регистрирует обработчик AssemblyResolve до обращения к любому классу.
+///
+public static class ModuleInit
+{
+ [ModuleInitializer]
+ public static void Initialize()
+ {
+ AppDomain.CurrentDomain.AssemblyResolve += ResolveAssembly;
+ }
+
+ private static Assembly? ResolveAssembly(object? sender, ResolveEventArgs args)
+ {
+ try
+ {
+ var name = new AssemblyName(args.Name).Name;
+ if (string.IsNullOrEmpty(name)) return null;
+
+ var assemblyDir = Path.GetDirectoryName(typeof(ModuleInit).Assembly.Location) ?? "";
+ var dllPath = Path.Combine(assemblyDir, name + ".dll");
+
+ if (File.Exists(dllPath))
+ {
+ Console.WriteLine($"[ModuleInit] Resolved {name} from {dllPath}");
+ return Assembly.LoadFrom(dllPath);
+ }
+
+ Console.WriteLine($"[ModuleInit] Could not resolve {name} (looked in {assemblyDir})");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[ModuleInit] ResolveAssembly error: {ex.Message}");
+ }
+
+ return null;
+ }
+}
diff --git a/ProjectApp.Api.Function/Program.cs b/ProjectApp.Api.Function/Program.cs
new file mode 100644
index 00000000..4a882f6d
--- /dev/null
+++ b/ProjectApp.Api.Function/Program.cs
@@ -0,0 +1,6 @@
+namespace ApiFunction;
+
+public partial class Program
+{
+ public static void Main(string[] args) { }
+}
diff --git a/ProjectApp.Api.Function/ProjectApp.Api.Function.csproj b/ProjectApp.Api.Function/ProjectApp.Api.Function.csproj
new file mode 100644
index 00000000..4acedef0
--- /dev/null
+++ b/ProjectApp.Api.Function/ProjectApp.Api.Function.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net8.0
+ enable
+ enable
+ ApiFunction
+ ApiFunction
+
+
+
+
+
+
+
diff --git a/ProjectApp.Api.Function/Services/VehicleFaker.cs b/ProjectApp.Api.Function/Services/VehicleFaker.cs
new file mode 100644
index 00000000..eba87331
--- /dev/null
+++ b/ProjectApp.Api.Function/Services/VehicleFaker.cs
@@ -0,0 +1 @@
+namespace ApiFunction.Services;
diff --git a/ProjectApp.Api.Function/VehicleProcessor.cs b/ProjectApp.Api.Function/VehicleProcessor.cs
new file mode 100644
index 00000000..a503dac3
--- /dev/null
+++ b/ProjectApp.Api.Function/VehicleProcessor.cs
@@ -0,0 +1,131 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using ApiFunction.Models;
+
+namespace ApiFunction;
+
+///
+/// Бизнес-логика генерации и публикации транспортного средства.
+///
+internal static class VehicleProcessor
+{
+ private static readonly Random Rng = Random.Shared;
+ private static readonly HttpClient Http = new();
+
+ private static readonly string[] Brands = ["Toyota", "BMW", "Mercedes", "Audi", "Ford", "Honda", "Volkswagen", "Hyundai", "Kia", "Nissan"];
+ private static readonly string[] Models = ["Sedan", "SUV", "Hatchback", "Coupe", "Crossover", "Pickup", "Minivan", "Wagon"];
+ private static readonly string[] BodyTypes = ["Sedan", "SUV", "Hatchback", "Coupe", "Crossover"];
+ private static readonly string[] FuelTypes = ["Petrol", "Diesel", "Electric", "Hybrid"];
+ private static readonly string[] Colors = ["White", "Black", "Silver", "Blue", "Red", "Grey", "Green"];
+
+ public static async Task ProcessAsync(int id)
+ {
+ var vehicle = GenerateVehicle(id);
+ var json = JsonSerializer.Serialize(vehicle, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
+
+ _ = TryPublishToQueueAsync(json);
+
+ return new FunctionResponse
+ {
+ StatusCode = 200,
+ Headers = new()
+ {
+ ["Content-Type"] = "application/json",
+ ["Access-Control-Allow-Origin"] = "*"
+ },
+ Body = json
+ };
+ }
+
+ private static object GenerateVehicle(int id) => new
+ {
+ id,
+ vin = GenerateVin(),
+ brand = Brands[Rng.Next(Brands.Length)],
+ model = Models[Rng.Next(Models.Length)],
+ year = Rng.Next(2010, 2025),
+ bodyType = BodyTypes[Rng.Next(BodyTypes.Length)],
+ fuelType = FuelTypes[Rng.Next(FuelTypes.Length)],
+ color = Colors[Rng.Next(Colors.Length)],
+ mileage = Math.Round(Rng.NextDouble() * 200000, 1),
+ lastServiceDate = DateTime.UtcNow.AddDays(-Rng.Next(30, 730)).ToString("yyyy-MM-dd")
+ };
+
+ private static string GenerateVin()
+ {
+ const string chars = "ABCDEFGHJKLMNPRSTUVWXYZ0123456789";
+ return string.Concat(Enumerable.Range(0, 17).Select(_ => chars[Rng.Next(chars.Length)]));
+ }
+
+ private static async Task TryPublishToQueueAsync(string messageBody)
+ {
+ try
+ {
+ var queueUrl = Environment.GetEnvironmentVariable("SQS_QUEUE_URL") ?? "";
+ var accessKey = Environment.GetEnvironmentVariable("AWS_ACCESS_KEY_ID") ?? "";
+ var secretKey = Environment.GetEnvironmentVariable("AWS_SECRET_ACCESS_KEY") ?? "";
+ if (string.IsNullOrEmpty(queueUrl) || string.IsNullOrEmpty(accessKey)) return;
+
+ var uri = new Uri(queueUrl);
+ var endpoint = $"{uri.Scheme}://{uri.Host}";
+ var path = uri.AbsolutePath;
+
+ var body = $"Action=SendMessage&MessageBody={Uri.EscapeDataString(messageBody)}&Version=2012-11-05";
+ var now = DateTime.UtcNow;
+ var dateStamp = now.ToString("yyyyMMdd");
+ var amzDate = now.ToString("yyyyMMddTHHmmssZ");
+
+ var payloadHash = Sha256Hash(body);
+ var headers = $"content-type:application/x-www-form-urlencoded\nhost:{uri.Host}\nx-amz-date:{amzDate}\n";
+ var signedHeaders = "content-type;host;x-amz-date";
+ var canonicalRequest = $"POST\n{path}\n\n{headers}\n{signedHeaders}\n{payloadHash}";
+
+ var region = "ru-central1";
+ var service = "sqs";
+ var credentialScope = $"{dateStamp}/{region}/{service}/aws4_request";
+ var stringToSign = $"AWS4-HMAC-SHA256\n{amzDate}\n{credentialScope}\n{Sha256Hash(canonicalRequest)}";
+
+ var signingKey = GetSigningKey(secretKey, dateStamp, region, service);
+ var signature = HmacSha256Hex(signingKey, stringToSign);
+
+ var authHeader = $"AWS4-HMAC-SHA256 Credential={accessKey}/{credentialScope}, SignedHeaders={signedHeaders}, Signature={signature}";
+
+ var request = new HttpRequestMessage(HttpMethod.Post, $"{endpoint}{path}")
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded")
+ };
+ request.Headers.TryAddWithoutValidation("Authorization", authHeader);
+ request.Headers.TryAddWithoutValidation("x-amz-date", amzDate);
+
+ var resp = await Http.SendAsync(request);
+ if (!resp.IsSuccessStatusCode)
+ Console.WriteLine($"[WARN] SQS returned {resp.StatusCode}");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[WARN] SQS publish failed: {ex.Message}");
+ }
+ }
+
+ private static string Sha256Hash(string data)
+ {
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(data));
+ return Convert.ToHexString(bytes).ToLowerInvariant();
+ }
+
+ private static byte[] HmacSha256(byte[] key, string data)
+ => HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(data));
+
+ private static string HmacSha256Hex(byte[] key, string data)
+ => Convert.ToHexString(HmacSha256(key, data)).ToLowerInvariant();
+
+ private static byte[] GetSigningKey(string secret, string date, string region, string service)
+ {
+ var kSecret = Encoding.UTF8.GetBytes("AWS4" + secret);
+ var kDate = HmacSha256(kSecret, date);
+ var kRegion = HmacSha256(kDate, region);
+ var kService = HmacSha256(kRegion, service);
+ return HmacSha256(kService, "aws4_request");
+ }
+}
diff --git a/ProjectApp.Api/Controllers/VehicleController.cs b/ProjectApp.Api/Controllers/VehicleController.cs
new file mode 100644
index 00000000..a19c0cf6
--- /dev/null
+++ b/ProjectApp.Api/Controllers/VehicleController.cs
@@ -0,0 +1,50 @@
+using ProjectApp.Api.Services.SqsProducer;
+using ProjectApp.Api.Services.VehicleGeneratorService;
+using ProjectApp.Domain.Entities;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ProjectApp.Api.Controllers;
+
+///
+/// Контроллер для генерации и получения характеристик транспортных средств
+///
+[Route("api/[controller]")]
+[ApiController]
+public class VehicleController(
+ IVehicleGeneratorService vehicleService,
+ ISqsProducer sqsProducer,
+ ILogger logger) : ControllerBase
+{
+ ///
+ /// Возвращает сгенерированное транспортное средство по его уникальному идентификатору
+ ///
+ /// Идентификатор транспортного средства
+ /// Токен отмены
+ [HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(Vehicle), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById([FromRoute] int id, CancellationToken cancellationToken)
+ {
+ logger.LogInformation("Request received for vehicle id {Id}", id);
+
+ if (id <= 0)
+ {
+ logger.LogWarning("Invalid vehicle id {Id} received", id);
+ return BadRequest("Identifier must be a positive number.");
+ }
+
+ var vehicle = await vehicleService.FetchByIdAsync(id, cancellationToken);
+
+ try
+ {
+ await sqsProducer.SendVehicleAsync(vehicle, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to send vehicle {Id} to SQS", id);
+ }
+
+ return Ok(vehicle);
+ }
+}
diff --git a/ProjectApp.Api/Program.cs b/ProjectApp.Api/Program.cs
new file mode 100644
index 00000000..8cc11e93
--- /dev/null
+++ b/ProjectApp.Api/Program.cs
@@ -0,0 +1,65 @@
+using Amazon.SQS;
+using LocalStack.Client.Extensions;
+using ProjectApp.Api.Services.SqsProducer;
+using ProjectApp.Api.Services.VehicleGeneratorService;
+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.AddSingleton();
+builder.Services.AddScoped();
+builder.Services.AddScoped(sp =>
+ ActivatorUtilities.CreateInstance(sp,
+ sp.GetRequiredService()));
+
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(options =>
+{
+ options.SwaggerDoc("v1", new Microsoft.OpenApi.OpenApiInfo
+ {
+ Title = "Vehicle 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();
+}
+
+if (!app.Environment.IsDevelopment())
+{
+ app.UseHttpsRedirection();
+}
+
+app.MapControllers();
+app.MapDefaultEndpoints();
+
+app.Run();
+
+public partial class Program;
\ 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..eeb35d8e
--- /dev/null
+++ b/ProjectApp.Api/ProjectApp.Api.csproj
@@ -0,0 +1,25 @@
+
+
+
+ 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..bbe9ee66
--- /dev/null
+++ b/ProjectApp.Api/Properties/launchSettings.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:46825",
+ "sslPort": 44333
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5179",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "https://localhost:7170;http://localhost:5179",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/ProjectApp.Api/Services/SqsProducer/ISqsProducer.cs b/ProjectApp.Api/Services/SqsProducer/ISqsProducer.cs
new file mode 100644
index 00000000..e1e8e724
--- /dev/null
+++ b/ProjectApp.Api/Services/SqsProducer/ISqsProducer.cs
@@ -0,0 +1,14 @@
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.Api.Services.SqsProducer;
+
+///
+/// Сервис отправки данных транспортного средства в очередь SQS
+///
+public interface ISqsProducer
+{
+ ///
+ /// Отправляет данные транспортного средства в очередь
+ ///
+ Task SendVehicleAsync(Vehicle vehicle, CancellationToken cancellationToken = default);
+}
diff --git a/ProjectApp.Api/Services/SqsProducer/SqsProducer.cs b/ProjectApp.Api/Services/SqsProducer/SqsProducer.cs
new file mode 100644
index 00000000..047b0ed7
--- /dev/null
+++ b/ProjectApp.Api/Services/SqsProducer/SqsProducer.cs
@@ -0,0 +1,45 @@
+using System.Text.Json;
+using Amazon.SQS;
+using Amazon.SQS.Model;
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.Api.Services.SqsProducer;
+
+///
+/// Реализация отправки данных транспортного средства в SQS
+///
+public class SqsProducer(
+ IAmazonSQS sqsClient,
+ IConfiguration configuration,
+ ILogger logger) : ISqsProducer
+{
+ private readonly string _queueName = configuration["Sqs:QueueName"] ?? "vehicle-queue";
+ private string? _queueUrl;
+
+ ///
+ /// Отправляет сериализованные данные транспортного средства в очередь SQS
+ ///
+ public async Task SendVehicleAsync(Vehicle vehicle, CancellationToken cancellationToken = default)
+ {
+ var queueUrl = await GetQueueUrlAsync(cancellationToken);
+ var body = JsonSerializer.Serialize(vehicle);
+
+ await sqsClient.SendMessageAsync(new SendMessageRequest
+ {
+ QueueUrl = queueUrl,
+ MessageBody = body
+ }, cancellationToken);
+
+ logger.LogInformation("Vehicle {Id} sent to SQS", vehicle.Id);
+ }
+
+ private async Task GetQueueUrlAsync(CancellationToken ct)
+ {
+ if (_queueUrl != null)
+ return _queueUrl;
+
+ var response = await sqsClient.CreateQueueAsync(_queueName, ct);
+ _queueUrl = response.QueueUrl;
+ return _queueUrl;
+ }
+}
diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/CachedVehicleGeneratorService.cs b/ProjectApp.Api/Services/VehicleGeneratorService/CachedVehicleGeneratorService.cs
new file mode 100644
index 00000000..b1f5c1d9
--- /dev/null
+++ b/ProjectApp.Api/Services/VehicleGeneratorService/CachedVehicleGeneratorService.cs
@@ -0,0 +1,67 @@
+using ProjectApp.Domain.Entities;
+using Microsoft.Extensions.Caching.Distributed;
+using System.Text.Json;
+
+namespace ProjectApp.Api.Services.VehicleGeneratorService;
+
+///
+/// Декоратор над генератором транспортных средств с поддержкой кэширования через Redis
+///
+public class CachedVehicleGeneratorService(
+ IVehicleGeneratorService innerService,
+ IDistributedCache cache,
+ IConfiguration configuration,
+ ILogger logger) : IVehicleGeneratorService
+{
+ private readonly int _ttlMinutes = configuration.GetValue("CacheSettings:ExpirationMinutes", 10);
+
+ ///
+ /// Возвращает транспортное средство из кэша или генерирует новое и сохраняет в кэш
+ ///
+ public async Task FetchByIdAsync(int id, CancellationToken cancellationToken = default)
+ {
+ var key = $"vehicle-{id}";
+
+ try
+ {
+ var raw = await cache.GetStringAsync(key, cancellationToken);
+ if (!string.IsNullOrEmpty(raw))
+ {
+ var vehicle = JsonSerializer.Deserialize(raw);
+ if (vehicle != null)
+ {
+ logger.LogInformation("Vehicle {Id} retrieved from cache", id);
+ return vehicle;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Cache read failed for vehicle {Id}. Falling back to generation.", id);
+ }
+
+ var generatedVehicle = await innerService.FetchByIdAsync(id, cancellationToken);
+
+ try
+ {
+ var opts = new DistributedCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_ttlMinutes)
+ };
+
+ await cache.SetStringAsync(
+ key,
+ JsonSerializer.Serialize(generatedVehicle),
+ opts,
+ cancellationToken);
+
+ logger.LogInformation("Vehicle {Id} successfully stored in cache.", id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to store vehicle {Id} in cache.", id);
+ }
+
+ return generatedVehicle;
+ }
+}
diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/IVehicleGeneratorService.cs b/ProjectApp.Api/Services/VehicleGeneratorService/IVehicleGeneratorService.cs
new file mode 100644
index 00000000..671ca786
--- /dev/null
+++ b/ProjectApp.Api/Services/VehicleGeneratorService/IVehicleGeneratorService.cs
@@ -0,0 +1,14 @@
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.Api.Services.VehicleGeneratorService;
+
+///
+/// Интерфейс сервиса получения характеристик машины
+///
+public interface IVehicleGeneratorService
+{
+ ///
+ /// Запрашивает машину по ID
+ ///
+ public Task FetchByIdAsync(int id, CancellationToken cancellationToken = default);
+}
diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs
new file mode 100644
index 00000000..82a79981
--- /dev/null
+++ b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs
@@ -0,0 +1,55 @@
+using Bogus;
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.Api.Services.VehicleGeneratorService;
+
+///
+/// Генератор тестовых данных транспортных средств на основе Bogus
+///
+public class VehicleFaker
+{
+ private static readonly string[] _fuelTypes = ["Бензин", "Дизель", "Электро", "Гибрид", "Газ"];
+
+ private static readonly string[] _bodyTypes = ["Седан", "Хэтчбек", "Универсал", "Кроссовер", "Внедорожник", "Купе", "Минивэн", "Пикап"];
+
+ private static readonly Dictionary _brandModels = new()
+ {
+ ["Toyota"] = ["Camry", "Corolla", "RAV4", "Land Cruiser", "Yaris", "Highlander", "Prado"],
+ ["BMW"] = ["3 Series", "5 Series", "7 Series", "X3", "X5", "X6", "M4"],
+ ["Mercedes"] = ["C-Class", "E-Class", "S-Class", "GLE", "GLC", "A-Class", "CLA"],
+ ["Volkswagen"] = ["Passat", "Golf", "Tiguan", "Polo", "Touareg", "Jetta", "ID.4"],
+ ["Hyundai"] = ["Solaris", "Tucson", "Santa Fe", "Creta", "Elantra", "Sonata", "i30"],
+ ["Ford"] = ["Focus", "Mondeo", "Explorer", "Kuga", "Puma", "Mustang", "Ranger"],
+ ["Kia"] = ["Rio", "Sportage", "Sorento", "Ceed", "K5", "Seltos", "Stinger"],
+ ["Audi"] = ["A3", "A4", "A6", "Q3", "Q5", "Q7", "TT"],
+ ["Nissan"] = ["Qashqai", "X-Trail", "Altima", "Leaf", "Juke", "Murano", "Note"],
+ ["Renault"] = ["Logan", "Duster", "Sandero", "Megane", "Arkana", "Captur", "Laguna"],
+ ["Lada"] = ["Granta", "Vesta", "Largus", "Niva Travel", "XRAY", "4x4"],
+ ["Skoda"] = ["Octavia", "Superb", "Kodiaq", "Karoq", "Fabia", "Scala", "Kamiq"],
+ ["Mazda"] = ["Mazda3", "Mazda6", "CX-5", "CX-9", "MX-5", "CX-30"],
+ ["Honda"] = ["Civic", "Accord", "CR-V", "HR-V", "Jazz", "Pilot"],
+ ["Subaru"] = ["Outback", "Forester", "Impreza", "XV", "Legacy", "WRX"],
+ };
+
+ private readonly Faker _faker;
+
+ public VehicleFaker()
+ {
+ var currentYear = DateTime.Now.Year;
+
+ _faker = new Faker("ru")
+ .RuleFor(v => v.Id, f => f.IndexFaker + 1)
+ .RuleFor(v => v.Vin, f => f.Vehicle.Vin())
+ .RuleFor(v => v.Brand, f => f.PickRandom(_brandModels.Keys.ToArray()))
+ .RuleFor(v => v.Model, (f, v) => f.PickRandom(_brandModels[v.Brand]))
+ .RuleFor(v => v.Year, f => f.Random.Int(1984, currentYear))
+ .RuleFor(v => v.BodyType, f => f.PickRandom(_bodyTypes))
+ .RuleFor(v => v.FuelType, f => f.PickRandom(_fuelTypes))
+ .RuleFor(v => v.Color, f => f.Commerce.Color())
+ .RuleFor(v => v.Mileage, (f, v) => Math.Round(f.Random.Double(0, 500_000), 1))
+ .RuleFor(v => v.LastServiceDate, (f, v) =>
+ f.Date.BetweenDateOnly(new DateOnly(v.Year, 1, 1), DateOnly.FromDateTime(DateTime.Now)));
+ }
+
+ public Vehicle Generate() => _faker.Generate();
+}
diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs
new file mode 100644
index 00000000..edc42a36
--- /dev/null
+++ b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs
@@ -0,0 +1,22 @@
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.Api.Services.VehicleGeneratorService;
+
+///
+/// Сервис генерации данных транспортных средств на основе Bogus
+///
+public class VehicleGeneratorService(
+ VehicleFaker faker,
+ ILogger logger) : IVehicleGeneratorService
+{
+ ///
+ /// Генерирует новые данные транспортного средства для заданного идентификатора
+ ///
+ public Task FetchByIdAsync(int id, CancellationToken cancellationToken = default)
+ {
+ logger.LogInformation("Generating new vehicle data for id {Id}", id);
+ var vehicle = faker.Generate();
+ vehicle.Id = id;
+ return Task.FromResult(vehicle);
+ }
+}
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..407e8dbf
--- /dev/null
+++ b/ProjectApp.Api/appsettings.json
@@ -0,0 +1,27 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "CacheSettings": {
+ "ExpirationMinutes": 10
+ },
+ "LocalStack": {
+ "UseLocalStack": true,
+ "Session": {
+ "AwsAccessKeyId": "test",
+ "AwsAccessKeySecret": "test",
+ "RegionName": "us-east-1"
+ },
+ "Config": {
+ "LocalStackHost": "localhost",
+ "EdgePort": 9324
+ }
+ },
+ "Sqs": {
+ "QueueName": "vehicle-queue"
+ }
+}
diff --git a/ProjectApp.AppHost/Program.cs b/ProjectApp.AppHost/Program.cs
new file mode 100644
index 00000000..dca08b53
--- /dev/null
+++ b/ProjectApp.AppHost/Program.cs
@@ -0,0 +1,34 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+var redis = builder.AddRedis("cache")
+ .WithRedisCommander();
+
+var minio = builder.AddMinioContainer("minio");
+
+var sqs = builder.AddContainer("sqs", "softwaremill/elasticmq-native")
+ .WithHttpEndpoint(port: 9324, targetPort: 9324, name: "sqs");
+
+var fileService = builder.AddProject("projectapp-fileservice")
+ .WithReference(minio)
+ .WaitFor(minio)
+ .WaitFor(sqs);
+
+var gateway = builder.AddProject("projectapp-gateway")
+ .WithEndpoint("http", e => e.Port = 5200);
+
+for (var i = 0; i < 3; i++)
+{
+ var api = builder.AddProject($"projectapp-api-{i + 1}")
+ .WithReference(redis)
+ .WaitFor(redis)
+ .WaitFor(sqs)
+ .WithEndpoint("http", e => e.Port = 5180 + i)
+ .WithEndpoint("https", e => e.Port = 7170 + i);
+
+ gateway = gateway.WithReference(api).WaitFor(api);
+}
+
+builder.AddProject("client")
+ .WaitFor(gateway);
+
+builder.Build().Run();
diff --git a/ProjectApp.AppHost/ProjectApp.AppHost.csproj b/ProjectApp.AppHost/ProjectApp.AppHost.csproj
new file mode 100644
index 00000000..f5cc2982
--- /dev/null
+++ b/ProjectApp.AppHost/ProjectApp.AppHost.csproj
@@ -0,0 +1,27 @@
+
+
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ true
+ b8f3eae0-771a-4f3a-8df3-ef0a21b09b55
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..0c208ae9
--- /dev/null
+++ b/ProjectApp.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/ProjectApp.AppHost/appsettings.json b/ProjectApp.AppHost/appsettings.json
new file mode 100644
index 00000000..31c092aa
--- /dev/null
+++ b/ProjectApp.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/ProjectApp.Domain/Entities/Vehicle.cs b/ProjectApp.Domain/Entities/Vehicle.cs
new file mode 100644
index 00000000..250ec9a3
--- /dev/null
+++ b/ProjectApp.Domain/Entities/Vehicle.cs
@@ -0,0 +1,37 @@
+namespace ProjectApp.Domain.Entities;
+
+///
+/// Сущность транспортного средства
+///
+public class Vehicle
+{
+ /// Уникальный идентификатор транспортного средства в системе
+ public required int Id { get; set; }
+
+ /// VIN-номер транспортного средства
+ public required string Vin { get; set; }
+
+ /// Производитель транспортного средства
+ public required string Brand { get; set; }
+
+ /// Модель транспортного средства
+ public required string Model { get; set; }
+
+ /// Год выпуска транспортного средства
+ public int Year { get; set; }
+
+ /// Тип корпуса (кузова)
+ public required string BodyType { get; set; }
+
+ /// Тип используемого топлива
+ public required string FuelType { get; set; }
+
+ /// Цвет корпуса
+ public required string Color { get; set; }
+
+ /// Пробег в километрах
+ public double Mileage { get; set; }
+
+ /// Дата последнего техобслуживания
+ public DateOnly LastServiceDate { 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.FileService.Function/Handler.cs b/ProjectApp.FileService.Function/Handler.cs
new file mode 100644
index 00000000..60f6f7e3
--- /dev/null
+++ b/ProjectApp.FileService.Function/Handler.cs
@@ -0,0 +1,129 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Amazon.Runtime;
+using Amazon.S3;
+using Amazon.S3.Model;
+
+namespace FileServiceFunction;
+
+///
+/// Cloud Function файлового сервиса. Получает пакет сообщений из Yandex Message Queue
+/// и сохраняет данные каждого транспортного средства в Yandex Object Storage.
+///
+public class Handler
+{
+ private readonly IAmazonS3 _s3Client;
+ private readonly string _bucketName;
+
+ public Handler()
+ {
+ var endpoint = Environment.GetEnvironmentVariable("S3_ENDPOINT")
+ ?? "https://storage.yandexcloud.net";
+ var accessKey = Environment.GetEnvironmentVariable("AWS_ACCESS_KEY_ID") ?? "";
+ var secretKey = Environment.GetEnvironmentVariable("AWS_SECRET_ACCESS_KEY") ?? "";
+ _bucketName = Environment.GetEnvironmentVariable("S3_BUCKET") ?? "vehicles-storage";
+
+ var credentials = new BasicAWSCredentials(accessKey, secretKey);
+ var config = new AmazonS3Config
+ {
+ ServiceURL = endpoint,
+ ForcePathStyle = true
+ };
+ _s3Client = new AmazonS3Client(credentials, config);
+ }
+
+ ///
+ /// Точка входа Cloud Function. Обрабатывает пакет сообщений из очереди
+ /// и сохраняет каждое транспортное средство как JSON-файл в Object Storage.
+ ///
+ /// Сериализованное событие триггера Message Queue
+ public string FunctionHandler(string input)
+ {
+ QueueTriggerEvent? trigger = null;
+ try { trigger = JsonSerializer.Deserialize(input ?? "{}"); } catch { }
+
+ var messages = trigger?.Messages ?? [];
+ foreach (var message in messages)
+ {
+ try { ProcessMessage(message); }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[ERROR] {message.Details?.Message?.MessageId}: {ex.Message}");
+ }
+ }
+
+ return "{}";
+ }
+
+ private void ProcessMessage(QueueMessage message)
+ {
+ var rawBody = message.Details?.Message?.Body ?? "";
+
+ string json;
+ try
+ {
+ var decoded = Convert.FromBase64String(rawBody);
+ json = Encoding.UTF8.GetString(decoded);
+ }
+ catch
+ {
+ json = rawBody;
+ }
+
+ using var doc = JsonDocument.Parse(json);
+ var idProp = doc.RootElement.TryGetProperty("id", out var idEl) ? idEl
+ : doc.RootElement.TryGetProperty("Id", out var idEl2) ? idEl2 : default;
+ var id = idProp.ValueKind == JsonValueKind.Number ? idProp.GetInt32() : 0;
+ var objectName = $"vehicle-{id}.json";
+
+ var bytes = Encoding.UTF8.GetBytes(json);
+ using var stream = new MemoryStream(bytes);
+
+ _s3Client.PutObjectAsync(new PutObjectRequest
+ {
+ BucketName = _bucketName,
+ Key = objectName,
+ InputStream = stream,
+ ContentType = "application/json"
+ }).GetAwaiter().GetResult();
+
+ Console.WriteLine($"[INFO] Saved {objectName}");
+ }
+}
+
+public class QueueTriggerEvent
+{
+ [JsonPropertyName("messages")]
+ public List Messages { get; set; } = [];
+}
+
+public class QueueMessage
+{
+ [JsonPropertyName("event_metadata")]
+ public EventMetadata? EventMetadata { get; set; }
+
+ [JsonPropertyName("details")]
+ public MessageDetails? Details { get; set; }
+}
+
+public class EventMetadata
+{
+ [JsonPropertyName("event_id")] public string EventId { get; set; } = "";
+ [JsonPropertyName("event_type")] public string EventType { get; set; } = "";
+ [JsonPropertyName("created_at")] public string CreatedAt { get; set; } = "";
+}
+
+public class MessageDetails
+{
+ [JsonPropertyName("queue_id")] public string QueueId { get; set; } = "";
+ [JsonPropertyName("message")] public SqsMessage? Message { get; set; }
+}
+
+public class SqsMessage
+{
+ [JsonPropertyName("message_id")] public string MessageId { get; set; } = "";
+ [JsonPropertyName("md5_of_body")] public string Md5OfBody { get; set; } = "";
+ [JsonPropertyName("body")] public string Body { get; set; } = "";
+ [JsonPropertyName("attributes")] public Dictionary? Attributes { get; set; }
+}
diff --git a/ProjectApp.FileService.Function/Models/QueueTriggerEvent.cs b/ProjectApp.FileService.Function/Models/QueueTriggerEvent.cs
new file mode 100644
index 00000000..60e065d7
--- /dev/null
+++ b/ProjectApp.FileService.Function/Models/QueueTriggerEvent.cs
@@ -0,0 +1,54 @@
+using System.Text.Json.Serialization;
+
+namespace FileServiceFunction.Models;
+
+public class QueueTriggerEvent
+{
+ [JsonPropertyName("messages")]
+ public List Messages { get; set; } = [];
+}
+
+public class QueueMessage
+{
+ [JsonPropertyName("event_metadata")]
+ public EventMetadata EventMetadata { get; set; } = new();
+
+ [JsonPropertyName("details")]
+ public MessageDetails Details { get; set; } = new();
+}
+
+public class EventMetadata
+{
+ [JsonPropertyName("event_id")]
+ public string EventId { get; set; } = "";
+
+ [JsonPropertyName("event_type")]
+ public string EventType { get; set; } = "";
+
+ [JsonPropertyName("created_at")]
+ public string CreatedAt { get; set; } = "";
+}
+
+public class MessageDetails
+{
+ [JsonPropertyName("queue_id")]
+ public string QueueId { get; set; } = "";
+
+ [JsonPropertyName("message")]
+ public SqsMessage Message { get; set; } = new();
+}
+
+public class SqsMessage
+{
+ [JsonPropertyName("message_id")]
+ public string MessageId { get; set; } = "";
+
+ [JsonPropertyName("md5_of_body")]
+ public string Md5OfBody { get; set; } = "";
+
+ [JsonPropertyName("body")]
+ public string Body { get; set; } = "";
+
+ [JsonPropertyName("attributes")]
+ public Dictionary? Attributes { get; set; }
+}
diff --git a/ProjectApp.FileService.Function/ProjectApp.FileService.Function.csproj b/ProjectApp.FileService.Function/ProjectApp.FileService.Function.csproj
new file mode 100644
index 00000000..86e37129
--- /dev/null
+++ b/ProjectApp.FileService.Function/ProjectApp.FileService.Function.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net8.0
+ enable
+ enable
+ FileServiceFunction
+ FileServiceFunction
+
+
+
+
+
+
+
diff --git a/ProjectApp.FileService/Program.cs b/ProjectApp.FileService/Program.cs
new file mode 100644
index 00000000..32cb739b
--- /dev/null
+++ b/ProjectApp.FileService/Program.cs
@@ -0,0 +1,48 @@
+using Amazon.SQS;
+using LocalStack.Client.Extensions;
+using Minio;
+using ProjectApp.FileService.Services;
+using ProjectApp.ServiceDefaults;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.Services.AddLocalStack(builder.Configuration);
+builder.Services.AddAwsService();
+
+builder.Services.AddSingleton(sp =>
+{
+ var configuration = sp.GetRequiredService();
+
+ // When run via Aspire, WithReference(minio) sets ConnectionStrings:minio = "http://host:port"
+ // For local run without Aspire, fall back to Minio:Endpoint setting
+ string minioEndpoint;
+ var connectionString = configuration.GetConnectionString("minio");
+ if (connectionString is not null)
+ {
+ var uri = new Uri(connectionString);
+ minioEndpoint = $"{uri.Host}:{uri.Port}";
+ }
+ else
+ {
+ minioEndpoint = configuration["Minio:Endpoint"] ?? "localhost:9000";
+ }
+
+ return new MinioClient()
+ .WithEndpoint(minioEndpoint)
+ .WithCredentials(
+ configuration["Minio:AccessKey"] ?? "minioadmin",
+ configuration["Minio:SecretKey"] ?? "minioadmin")
+ .WithSSL(false)
+ .Build();
+});
+
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
+
+var app = builder.Build();
+
+app.MapDefaultEndpoints();
+
+app.Run();
diff --git a/ProjectApp.FileService/ProjectApp.FileService.csproj b/ProjectApp.FileService/ProjectApp.FileService.csproj
new file mode 100644
index 00000000..1b024de7
--- /dev/null
+++ b/ProjectApp.FileService/ProjectApp.FileService.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ProjectApp.FileService/Properties/launchSettings.json b/ProjectApp.FileService/Properties/launchSettings.json
new file mode 100644
index 00000000..2d1b79c6
--- /dev/null
+++ b/ProjectApp.FileService/Properties/launchSettings.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:17853",
+ "sslPort": 44303
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5166",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "https://localhost:7072;http://localhost:5166",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/ProjectApp.FileService/Services/MinioStorageService.cs b/ProjectApp.FileService/Services/MinioStorageService.cs
new file mode 100644
index 00000000..62df2e71
--- /dev/null
+++ b/ProjectApp.FileService/Services/MinioStorageService.cs
@@ -0,0 +1,79 @@
+using System.Text;
+using System.Text.Json;
+using Minio;
+using Minio.DataModel.Args;
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.FileService.Services;
+
+///
+/// Сервис сохранения и чтения файлов транспортных средств в MinIO
+///
+public class MinioStorageService(
+ IMinioClient minioClient,
+ IConfiguration configuration,
+ ILogger logger)
+{
+ private readonly string _bucketName = configuration["Minio:BucketName"] ?? "vehicles";
+
+ ///
+ /// Создаёт бакет, если он ещё не существует
+ ///
+ public async Task EnsureBucketExistsAsync(CancellationToken ct = default)
+ {
+ var exists = await minioClient.BucketExistsAsync(
+ new BucketExistsArgs().WithBucket(_bucketName), ct);
+
+ if (!exists)
+ {
+ await minioClient.MakeBucketAsync(
+ new MakeBucketArgs().WithBucket(_bucketName), ct);
+ logger.LogInformation("Bucket {Bucket} created", _bucketName);
+ }
+ }
+
+ ///
+ /// Сохраняет данные транспортного средства в виде JSON-файла
+ ///
+ public async Task SaveVehicleAsync(Vehicle vehicle, CancellationToken ct = default)
+ {
+ var json = JsonSerializer.Serialize(vehicle, new JsonSerializerOptions { WriteIndented = true });
+ await SaveRawAsync(vehicle.Id, json, ct);
+ }
+
+ ///
+ /// Сохраняет сырой JSON напрямую в MinIO без повторной сериализации
+ ///
+ public async Task SaveRawAsync(int id, string rawJson, CancellationToken ct = default)
+ {
+ var bytes = Encoding.UTF8.GetBytes(rawJson);
+ using var stream = new MemoryStream(bytes);
+ var objectName = $"vehicle-{id}.json";
+
+ await minioClient.PutObjectAsync(new PutObjectArgs()
+ .WithBucket(_bucketName)
+ .WithObject(objectName)
+ .WithStreamData(stream)
+ .WithObjectSize(stream.Length)
+ .WithContentType("application/json"), ct);
+
+ logger.LogInformation("Vehicle {Id} saved as {Object}", id, objectName);
+ }
+
+ ///
+ /// Читает данные транспортного средства из MinIO по идентификатору
+ ///
+ public async Task GetVehicleAsync(int id, CancellationToken ct = default)
+ {
+ var objectName = $"vehicle-{id}.json";
+ using var ms = new MemoryStream();
+
+ await minioClient.GetObjectAsync(new GetObjectArgs()
+ .WithBucket(_bucketName)
+ .WithObject(objectName)
+ .WithCallbackStream(stream => stream.CopyTo(ms)), ct);
+
+ ms.Position = 0;
+ return await JsonSerializer.DeserializeAsync(ms, cancellationToken: ct);
+ }
+}
diff --git a/ProjectApp.FileService/Services/SqsConsumerService.cs b/ProjectApp.FileService/Services/SqsConsumerService.cs
new file mode 100644
index 00000000..cfe2ba4b
--- /dev/null
+++ b/ProjectApp.FileService/Services/SqsConsumerService.cs
@@ -0,0 +1,77 @@
+using System.Text.Json;
+using Amazon.SQS;
+using Amazon.SQS.Model;
+
+namespace ProjectApp.FileService.Services;
+
+///
+/// Фоновый сервис для получения сообщений из SQS и сохранения данных в MinIO
+///
+public class SqsConsumerService(
+ IAmazonSQS sqsClient,
+ MinioStorageService storageService,
+ IConfiguration configuration,
+ ILogger logger) : BackgroundService
+{
+ private readonly string _queueName = configuration["Sqs:QueueName"] ?? "vehicle-queue";
+ private string? _queueUrl;
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ await InitializeAsync(stoppingToken);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ var response = await sqsClient.ReceiveMessageAsync(new ReceiveMessageRequest
+ {
+ QueueUrl = _queueUrl,
+ MaxNumberOfMessages = 10,
+ WaitTimeSeconds = 1
+ }, stoppingToken);
+
+ foreach (var message in response.Messages)
+ {
+ await ProcessMessageAsync(message, stoppingToken);
+ }
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error polling SQS");
+ await Task.Delay(3000, stoppingToken);
+ }
+ }
+ }
+
+ private async Task InitializeAsync(CancellationToken ct)
+ {
+ var response = await sqsClient.CreateQueueAsync(_queueName, ct);
+ _queueUrl = response.QueueUrl;
+ logger.LogInformation("SQS queue {Queue} ready at {Url}", _queueName, _queueUrl);
+
+ await storageService.EnsureBucketExistsAsync(ct);
+ }
+
+ private async Task ProcessMessageAsync(Message message, CancellationToken ct)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(message.Body);
+ var id = doc.RootElement.GetProperty("Id").GetInt32();
+
+ await storageService.SaveRawAsync(id, message.Body, ct);
+ logger.LogInformation("Vehicle {Id} saved to MinIO from SQS message {MessageId}", id, message.MessageId);
+
+ await sqsClient.DeleteMessageAsync(_queueUrl, message.ReceiptHandle, ct);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to process message {MessageId}", message.MessageId);
+ }
+ }
+}
diff --git a/ProjectApp.FileService/appsettings.Development.json b/ProjectApp.FileService/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/ProjectApp.FileService/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/ProjectApp.FileService/appsettings.json b/ProjectApp.FileService/appsettings.json
new file mode 100644
index 00000000..18af9f67
--- /dev/null
+++ b/ProjectApp.FileService/appsettings.json
@@ -0,0 +1,30 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "LocalStack": {
+ "UseLocalStack": true,
+ "Session": {
+ "AwsAccessKeyId": "test",
+ "AwsAccessKeySecret": "test",
+ "RegionName": "us-east-1"
+ },
+ "Config": {
+ "LocalStackHost": "localhost",
+ "EdgePort": 9324
+ }
+ },
+ "Sqs": {
+ "QueueName": "vehicle-queue"
+ },
+ "Minio": {
+ "Endpoint": "localhost:9000",
+ "AccessKey": "minioadmin",
+ "SecretKey": "minioadmin",
+ "BucketName": "vehicles"
+ }
+}
diff --git a/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs b/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs
new file mode 100644
index 00000000..ea8c48c0
--- /dev/null
+++ b/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs
@@ -0,0 +1,54 @@
+using Ocelot.Configuration;
+using Ocelot.LoadBalancer.LoadBalancers;
+using Ocelot.Responses;
+using Ocelot.Values;
+
+namespace ProjectApp.Gateway.LoadBalancing;
+
+///
+/// Балансировщик нагрузки, распределяющий запросы на основе значения параметра id
+/// из строки запроса или пути. Один и тот же id всегда направляется на один и тот же хост.
+///
+public class QueryBasedLoadBalancer(DownstreamRoute route) : ILoadBalancer
+{
+ private readonly List _hosts = route.DownstreamAddresses
+ .Select(a => new ServiceHostAndPort(a.Host, a.Port))
+ .ToList();
+
+ ///
+ /// Тип используемого балансировщика нагрузки.
+ ///
+ public string Type => nameof(QueryBasedLoadBalancer);
+
+ ///
+ /// Выбирает downstream-хост на основе идентификатора из запроса.
+ ///
+ public Task> LeaseAsync(HttpContext httpContext)
+ {
+ if (_hosts.Count == 0)
+ return Task.FromResult>(
+ new ErrorResponse(
+ new ServicesAreEmptyError("No downstream hosts configured")));
+
+ var idRaw = httpContext.Request.Query["id"].FirstOrDefault()
+ ?? ExtractIdFromPath(httpContext.Request.Path);
+
+ var index = 0;
+ if (int.TryParse(idRaw, out var id))
+ index = Math.Abs(id) % _hosts.Count;
+
+ return Task.FromResult>(
+ new OkResponse(_hosts[index]));
+ }
+
+ ///
+ /// Освобождает ранее выданный хост (балансировщик без состояния).
+ ///
+ public void Release(ServiceHostAndPort hostAndPort) { }
+
+ private static string? ExtractIdFromPath(PathString path)
+ {
+ var segments = path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ return segments?.LastOrDefault();
+ }
+}
diff --git a/ProjectApp.Gateway/LoadBalancing/ServicesAreEmptyError.cs b/ProjectApp.Gateway/LoadBalancing/ServicesAreEmptyError.cs
new file mode 100644
index 00000000..ebef4b1c
--- /dev/null
+++ b/ProjectApp.Gateway/LoadBalancing/ServicesAreEmptyError.cs
@@ -0,0 +1,5 @@
+using Ocelot.Errors;
+
+namespace ProjectApp.Gateway.LoadBalancing;
+
+public class ServicesAreEmptyError(string message) : Error(message, OcelotErrorCode.UnknownError, 503);
diff --git a/ProjectApp.Gateway/Program.cs b/ProjectApp.Gateway/Program.cs
new file mode 100644
index 00000000..f42be308
--- /dev/null
+++ b/ProjectApp.Gateway/Program.cs
@@ -0,0 +1,37 @@
+using Ocelot.DependencyInjection;
+using Ocelot.Middleware;
+using ProjectApp.Gateway.LoadBalancing;
+using ProjectApp.ServiceDefaults;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.Configuration
+ .AddJsonFile("ocelot.json", optional: false, reloadOnChange: true)
+ .AddJsonFile($"ocelot.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
+ .AddEnvironmentVariables();
+
+var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get() ?? ["http://localhost:5127"];
+builder.Services.AddCors(options =>
+{
+ options.AddDefaultPolicy(policy =>
+ {
+ policy.WithOrigins(allowedOrigins)
+ .WithMethods("GET")
+ .WithHeaders("Content-Type");
+ });
+});
+
+builder.Services
+ .AddOcelot(builder.Configuration)
+ .AddCustomLoadBalancer((route, _) => new QueryBasedLoadBalancer(route));
+
+var app = builder.Build();
+
+app.UseCors();
+app.MapDefaultEndpoints();
+
+await app.UseOcelot();
+
+app.Run();
diff --git a/ProjectApp.Gateway/ProjectApp.Gateway.csproj b/ProjectApp.Gateway/ProjectApp.Gateway.csproj
new file mode 100644
index 00000000..afa35b75
--- /dev/null
+++ b/ProjectApp.Gateway/ProjectApp.Gateway.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
diff --git a/ProjectApp.Gateway/Properties/launchSettings.json b/ProjectApp.Gateway/Properties/launchSettings.json
new file mode 100644
index 00000000..595cd1bd
--- /dev/null
+++ b/ProjectApp.Gateway/Properties/launchSettings.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:64048",
+ "sslPort": 44366
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5130",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7286;http://localhost:5130",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": false,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/ProjectApp.Gateway/appsettings.Development.json b/ProjectApp.Gateway/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/ProjectApp.Gateway/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/ProjectApp.Gateway/appsettings.json b/ProjectApp.Gateway/appsettings.json
new file mode 100644
index 00000000..5860f55c
--- /dev/null
+++ b/ProjectApp.Gateway/appsettings.json
@@ -0,0 +1,13 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Ocelot": "Information"
+ }
+ },
+ "AllowedHosts": "*",
+ "AllowedOrigins": [
+ "http://localhost:5127"
+ ]
+}
diff --git a/ProjectApp.Gateway/ocelot.json b/ProjectApp.Gateway/ocelot.json
new file mode 100644
index 00000000..be45093c
--- /dev/null
+++ b/ProjectApp.Gateway/ocelot.json
@@ -0,0 +1,21 @@
+{
+ "Routes": [
+ {
+ "UpstreamPathTemplate": "/api/vehicle/{id}",
+ "UpstreamHttpMethod": [ "GET" ],
+ "DownstreamPathTemplate": "/api/vehicle/{id}",
+ "DownstreamScheme": "http",
+ "DownstreamHostAndPorts": [
+ { "Host": "localhost", "Port": 5180 },
+ { "Host": "localhost", "Port": 5181 },
+ { "Host": "localhost", "Port": 5182 }
+ ],
+ "LoadBalancerOptions": {
+ "Type": "QueryBasedLoadBalancer"
+ }
+ }
+ ],
+ "GlobalConfiguration": {
+ "BaseUrl": "http://localhost:5200"
+ }
+}
diff --git a/ProjectApp.ServiceDefaults/Extensions.cs b/ProjectApp.ServiceDefaults/Extensions.cs
new file mode 100644
index 00000000..9f167125
--- /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;
+
+ ///
+ /// Вспомогательный класс для настройки общих сервисов (логи, метрики, проверки состояния)
+ ///
+public static class Extensions
+{
+ ///
+ /// Регистрация базовой конфигурации для микросервисов
+ ///
+ 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 для мониторинга доступности
+ ///
+ 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/ProjectApp.Tests/Fixtures/ServiceFixture.cs b/ProjectApp.Tests/Fixtures/ServiceFixture.cs
new file mode 100644
index 00000000..94d29827
--- /dev/null
+++ b/ProjectApp.Tests/Fixtures/ServiceFixture.cs
@@ -0,0 +1,133 @@
+using Amazon.SQS;
+using DotNet.Testcontainers.Builders;
+using DotNet.Testcontainers.Containers;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Minio;
+using StackExchange.Redis;
+using Xunit;
+
+namespace ProjectApp.Tests.Fixtures;
+
+///
+/// Фикстура для интеграционных тестов — поднимает контейнеры Redis, MinIO и ElasticMQ
+///
+public class ServiceFixture : IAsyncLifetime
+{
+ private IContainer _redis = null!;
+ private IContainer _minio = null!;
+ private IContainer _elasticMq = null!;
+
+ public WebApplicationFactory ApiFactory { get; private set; } = null!;
+ public IAmazonSQS SqsClient { get; private set; } = null!;
+ public IMinioClient MinioClient { get; private set; } = null!;
+ public IConnectionMultiplexer RedisConnection { get; private set; } = null!;
+
+ public string SqsServiceUrl => $"http://localhost:{_elasticMq.GetMappedPublicPort(9324)}";
+ public string MinioEndpoint => $"localhost:{_minio.GetMappedPublicPort(9000)}";
+ public string RedisConnectionString => $"localhost:{_redis.GetMappedPublicPort(6379)},abortConnect=false";
+
+ public async Task InitializeAsync()
+ {
+ _redis = new ContainerBuilder()
+ .WithImage("redis:7-alpine")
+ .WithPortBinding(6379, true)
+ .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(6379))
+ .Build();
+
+ _minio = new ContainerBuilder()
+ .WithImage("minio/minio")
+ .WithCommand("server", "/data")
+ .WithEnvironment("MINIO_ROOT_USER", "minioadmin")
+ .WithEnvironment("MINIO_ROOT_PASSWORD", "minioadmin")
+ .WithPortBinding(9000, true)
+ .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(9000))
+ .Build();
+
+ _elasticMq = new ContainerBuilder()
+ .WithImage("softwaremill/elasticmq-native")
+ .WithPortBinding(9324, true)
+ .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(9324))
+ .Build();
+
+ await Task.WhenAll(
+ _redis.StartAsync(),
+ _minio.StartAsync(),
+ _elasticMq.StartAsync());
+
+ SqsClient = new AmazonSQSClient("test", "test", new AmazonSQSConfig
+ {
+ ServiceURL = SqsServiceUrl
+ });
+
+ MinioClient = new Minio.MinioClient()
+ .WithEndpoint(MinioEndpoint)
+ .WithCredentials("minioadmin", "minioadmin")
+ .WithSSL(false)
+ .Build();
+
+ RedisConnection = await ConnectionMultiplexer.ConnectAsync(RedisConnectionString);
+
+ var redisConnStr = RedisConnectionString;
+ var sqsPort = _elasticMq.GetMappedPublicPort(9324);
+
+ ApiFactory = new WebApplicationFactory()
+ .WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureAppConfiguration((_, config) =>
+ {
+ config.AddInMemoryCollection(new Dictionary
+ {
+ ["ConnectionStrings:cache"] = redisConnStr,
+ ["LocalStack:UseLocalStack"] = "true",
+ ["LocalStack:Session:AwsAccessKeyId"] = "test",
+ ["LocalStack:Session:AwsAccessKeySecret"] = "test",
+ ["LocalStack:Session:RegionName"] = "us-east-1",
+ ["LocalStack:Config:LocalStackHost"] = "localhost",
+ ["LocalStack:Config:EdgePort"] = sqsPort.ToString(),
+ ["Sqs:QueueName"] = "vehicle-queue"
+ });
+ });
+
+ builder.ConfigureServices(services =>
+ {
+ services.AddSingleton(
+ ConnectionMultiplexer.Connect(redisConnStr));
+ });
+ });
+ }
+
+ ///
+ /// Сброс состояния между тестами: очистка Redis и SQS
+ ///
+ public async Task ResetAsync()
+ {
+ var db = RedisConnection.GetDatabase();
+ await db.ExecuteAsync("FLUSHDB");
+
+ try
+ {
+ var queueUrl = (await SqsClient.CreateQueueAsync("vehicle-queue")).QueueUrl;
+ await SqsClient.PurgeQueueAsync(new Amazon.SQS.Model.PurgeQueueRequest
+ {
+ QueueUrl = queueUrl
+ });
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[WARN] Failed to purge SQS queue during reset: {ex.Message}");
+ }
+ }
+
+ public async Task DisposeAsync()
+ {
+ RedisConnection?.Dispose();
+ ApiFactory?.Dispose();
+
+ await Task.WhenAll(
+ _redis.DisposeAsync().AsTask(),
+ _minio.DisposeAsync().AsTask(),
+ _elasticMq.DisposeAsync().AsTask());
+ }
+}
diff --git a/ProjectApp.Tests/IntegrationTests/VehicleIntegrationTests.cs b/ProjectApp.Tests/IntegrationTests/VehicleIntegrationTests.cs
new file mode 100644
index 00000000..7250ac11
--- /dev/null
+++ b/ProjectApp.Tests/IntegrationTests/VehicleIntegrationTests.cs
@@ -0,0 +1,221 @@
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using Amazon.SQS.Model;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Minio;
+using Minio.DataModel.Args;
+using ProjectApp.Domain.Entities;
+using ProjectApp.FileService.Services;
+using ProjectApp.Tests.Fixtures;
+using Xunit;
+
+namespace ProjectApp.Tests.IntegrationTests;
+
+///
+/// Интеграционные тесты для проверки совместной работы сервисов
+///
+public class VehicleIntegrationTests : IClassFixture, IAsyncLifetime
+{
+ private readonly ServiceFixture _fixture;
+ private readonly HttpClient _httpClient;
+
+ public VehicleIntegrationTests(ServiceFixture fixture)
+ {
+ _fixture = fixture;
+ _httpClient = fixture.ApiFactory.CreateClient();
+ }
+
+ public async Task InitializeAsync()
+ {
+ await _fixture.ResetAsync();
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ [Fact]
+ public async Task GetVehicle_ReturnsValidData()
+ {
+ var response = await _httpClient.GetAsync("/api/vehicle/1");
+
+ response.EnsureSuccessStatusCode();
+ var vehicle = await response.Content.ReadFromJsonAsync();
+
+ Assert.NotNull(vehicle);
+ Assert.Equal(1, vehicle.Id);
+ Assert.False(string.IsNullOrEmpty(vehicle.Vin));
+ Assert.False(string.IsNullOrEmpty(vehicle.Brand));
+ Assert.False(string.IsNullOrEmpty(vehicle.Model));
+ Assert.InRange(vehicle.Year, 1984, DateTime.Now.Year);
+ Assert.True(vehicle.Mileage >= 0);
+ }
+
+ [Fact]
+ public async Task GetVehicle_SecondRequest_ReturnsCachedData()
+ {
+ var first = await _httpClient.GetFromJsonAsync("/api/vehicle/42");
+ var second = await _httpClient.GetFromJsonAsync("/api/vehicle/42");
+
+ Assert.NotNull(first);
+ Assert.NotNull(second);
+ Assert.Equal(first.Vin, second.Vin);
+ Assert.Equal(first.Brand, second.Brand);
+ Assert.Equal(first.Model, second.Model);
+ Assert.Equal(first.Year, second.Year);
+ Assert.Equal(first.Mileage, second.Mileage);
+ }
+
+ [Fact]
+ public async Task GetVehicle_PublishesMessageToSqs()
+ {
+ await _httpClient.GetAsync("/api/vehicle/7");
+
+ await Task.Delay(700);
+
+ var queueUrl = (await _fixture.SqsClient.CreateQueueAsync("vehicle-queue")).QueueUrl;
+
+ var messages = await _fixture.SqsClient.ReceiveMessageAsync(new ReceiveMessageRequest
+ {
+ QueueUrl = queueUrl,
+ MaxNumberOfMessages = 10,
+ WaitTimeSeconds = 5
+ });
+
+ Assert.NotEmpty(messages.Messages);
+
+ var vehicle = JsonSerializer.Deserialize(messages.Messages[0].Body);
+ Assert.NotNull(vehicle);
+ Assert.Equal(7, vehicle.Id);
+ }
+
+ [Fact]
+ public async Task MinioStorage_SavesAndRetrievesFile()
+ {
+ var minio = _fixture.MinioClient;
+ var bucketName = "test-vehicles";
+
+ var exists = await minio.BucketExistsAsync(new BucketExistsArgs().WithBucket(bucketName));
+ if (!exists)
+ await minio.MakeBucketAsync(new MakeBucketArgs().WithBucket(bucketName));
+
+ var vehicle = new Vehicle
+ {
+ Id = 99,
+ Vin = "TEST12345678VIN01",
+ Brand = "Toyota",
+ Model = "Camry",
+ Year = 2020,
+ BodyType = "Седан",
+ FuelType = "Бензин",
+ Color = "white",
+ Mileage = 15000,
+ LastServiceDate = new DateOnly(2023, 6, 15)
+ };
+
+ var json = JsonSerializer.Serialize(vehicle);
+ var bytes = Encoding.UTF8.GetBytes(json);
+ using var uploadStream = new MemoryStream(bytes);
+
+ await minio.PutObjectAsync(new PutObjectArgs()
+ .WithBucket(bucketName)
+ .WithObject("vehicle-99.json")
+ .WithStreamData(uploadStream)
+ .WithObjectSize(uploadStream.Length)
+ .WithContentType("application/json"));
+
+ using var downloadStream = new MemoryStream();
+ await minio.GetObjectAsync(new GetObjectArgs()
+ .WithBucket(bucketName)
+ .WithObject("vehicle-99.json")
+ .WithCallbackStream(s => s.CopyTo(downloadStream)));
+
+ downloadStream.Position = 0;
+ var loaded = await JsonSerializer.DeserializeAsync(downloadStream);
+
+ Assert.NotNull(loaded);
+ Assert.Equal(vehicle.Id, loaded.Id);
+ Assert.Equal(vehicle.Vin, loaded.Vin);
+ Assert.Equal(vehicle.Brand, loaded.Brand);
+ }
+
+ [Fact]
+ public async Task Redis_CachesVehicleData()
+ {
+ await _httpClient.GetAsync("/api/vehicle/55");
+
+ using var scope = _fixture.ApiFactory.Services.CreateScope();
+ var cache = scope.ServiceProvider.GetRequiredService();
+ var cached = await cache.GetStringAsync("vehicle-55");
+
+ Assert.NotNull(cached);
+
+ var vehicle = JsonSerializer.Deserialize(cached);
+ Assert.NotNull(vehicle);
+ Assert.Equal(55, vehicle.Id);
+ }
+
+ [Fact]
+ public async Task GetVehicle_WithInvalidId_ReturnsBadRequest()
+ {
+ var response = await _httpClient.GetAsync("/api/vehicle/-1");
+
+ Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ ///
+ /// Проверяет, что объект из API совпадает с тем, что сохранено в объектном хранилище.
+ ///
+ [Fact]
+ public async Task EndToEnd_VehicleFlow_ApiResponseMatchesMinioStorage()
+ {
+ const int vehicleId = 88;
+
+ var loggerFactory = LoggerFactory.Create(b => b.AddConsole());
+
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Sqs:ServiceUrl"] = _fixture.SqsServiceUrl,
+ ["Sqs:QueueName"] = "vehicle-queue",
+ ["Minio:BucketName"] = "vehicles"
+ })
+ .Build();
+
+ var minioStorage = new MinioStorageService(
+ _fixture.MinioClient,
+ config,
+ loggerFactory.CreateLogger());
+
+ await minioStorage.EnsureBucketExistsAsync();
+
+ var consumer = new SqsConsumerService(
+ _fixture.SqsClient,
+ minioStorage,
+ config,
+ loggerFactory.CreateLogger());
+
+ using var cts = new CancellationTokenSource();
+ _ = consumer.StartAsync(cts.Token);
+
+ var apiVehicle = await _httpClient.GetFromJsonAsync($"/api/vehicle/{vehicleId}");
+
+ await Task.Delay(4000);
+
+ var savedVehicle = await minioStorage.GetVehicleAsync(vehicleId, CancellationToken.None);
+
+ await cts.CancelAsync();
+ await consumer.StopAsync(CancellationToken.None);
+ await Task.Delay(500);
+
+ Assert.NotNull(apiVehicle);
+ Assert.NotNull(savedVehicle);
+ Assert.Equal(apiVehicle.Id, savedVehicle.Id);
+ Assert.Equal(apiVehicle.Vin, savedVehicle.Vin);
+ Assert.Equal(apiVehicle.Brand, savedVehicle.Brand);
+ Assert.Equal(apiVehicle.Model, savedVehicle.Model);
+ Assert.Equal(apiVehicle.Year, savedVehicle.Year);
+ }
+}
diff --git a/ProjectApp.Tests/ProjectApp.Tests.csproj b/ProjectApp.Tests/ProjectApp.Tests.csproj
new file mode 100644
index 00000000..71f58620
--- /dev/null
+++ b/ProjectApp.Tests/ProjectApp.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index dcaa5eb7..ae07a11f 100644
--- a/README.md
+++ b/README.md
@@ -1,128 +1,165 @@
-# Современные технологии разработки программного обеспечения
-[Таблица с успеваемостью](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).
+# Лабораторная работа №1 — «Кэширование»
+**Вариант:** №29 — «Транспортное средство»
+**Балансировка:** Query Based
+**Брокер:** SQS
+**Хостинг S3:** Minio
+**Выполнил:** Нестеренко Андрей, 6512
+
+## Что реализовано
+
+- Генерация сущности «Транспортное средство» через Bogus.
+- Кэширование результатов генерации через IDistributedCache (Redis) с TTL 10 минут.
+- Структурное логирование запросов и результатов генерации.
+- Оркестрация сервисов через .NET Aspire.
+- REST endpoint: `GET /api/vehicle/{id}`.
+
+# Лабораторная работа №2 — «Балансировка нагрузки»
+
+## Описание
+
+API-шлюз на основе Ocelot с кастомным алгоритмом балансировки нагрузки Query Based. Клиентские запросы распределяются по репликам сервиса генерации на основе параметра `id` из строки запроса.
+
+## Что реализовано
+
+### Репликация сервиса генерации
+- Aspire AppHost запускает 3 реплики `ProjectApp.Api` на портах 5180, 5181, 5182
+- Гейтвей ожидает готовности всех реплик перед стартом (`.WaitFor()`)
+
+### API Gateway (Ocelot)
+- Проект `ProjectApp.Gateway` — единая точка входа для клиента
+- Маршрутизация настроена через `ocelot.json`
+- CORS-политика вынесена в конфигурацию (`AllowedOrigins`)
+
+### Кастомный балансировщик — `QueryBasedLoadBalancer`
+- Реализует интерфейс `ILoadBalancer` из Ocelot
+- Алгоритм: `index = id % N`, где `N` — число реплик
+- При отсутствии параметра `id` запрос направляется на первую реплику
+
+## Характеристики генерируемого транспортного средства
+
+1. Идентификатор в системе — `int`
+2. VIN-номер — `string`
+3. Производитель — `string`
+4. Модель — `string`
+5. Год выпуска — `int`
+6. Тип корпуса — `string`
+7. Тип топлива — `string`
+8. Цвет корпуса — `string`
+9. Пробег — `double`
+10. Дата последнего техобслуживания — `DateOnly`
+
+## Правила генерации
+
+- VIN-номер: берётся из раздела Vehicle (Bogus).
+- Производитель: случайно выбирается из фиксированного списка популярных марок.
+- Модель: выбирается из набора моделей, соответствующих производителю.
+- Год выпуска: от 1984 до текущего года включительно.
+- Тип корпуса: берётся из раздела Vehicle (Bogus).
+- Тип топлива: берётся из раздела Vehicle (Bogus); выбирается из списка (Бензин, Дизель, Электро, Гибрид, Газ).
+- Цвет корпуса: берётся из раздела Commerce (Bogus).
+- Пробег: от 0 до 500 000 км, не может быть меньше нуля.
+- Дата последнего техобслуживания: не ранее 1 января года выпуска и не позже сегодняшней даты.
+
+# Лабораторная работа №3 — «Интеграционное тестирование»
+
+## Описание
+
+Добавлен файловый сервис, объектное хранилище MinIO и очередь сообщений SQS (ElasticMQ). Написаны интеграционные тесты для проверки совместной работы всех компонентов.
+
+## Что реализовано
+
+### Объектное хранилище (MinIO)
+- В оркестрацию Aspire добавлен контейнер MinIO (`minio/minio`)
+- MinIO доступен на порту 9000 (API) и 9001 (Console)
+- Учётные данные по умолчанию: `minioadmin` / `minioadmin`
+- Бакет `vehicles` создаётся автоматически при старте файлового сервиса
+
+### Очередь сообщений (SQS → ElasticMQ)
+- В оркестрацию добавлен контейнер ElasticMQ (`softwaremill/elasticmq-native`) — легковесная SQS-совместимая очередь
+- ElasticMQ доступен на порту 9324
+- Очередь `vehicle-queue` создаётся автоматически
+
+### Файловый сервис (`ProjectApp.FileService`)
+- `SqsConsumerService` — фоновый сервис (`BackgroundService`), который опрашивает очередь SQS
+- `MinioStorageService` — сохраняет десериализованные данные транспортного средства в MinIO как JSON-файлы
+- Файлы хранятся в бакете `vehicles` с именами вида `vehicle-{id}.json`
+
+### Отправка данных через SQS
+- `SqsPublisher` в проекте `ProjectApp.Api` отправляет JSON-сериализованные данные транспортного средства в очередь после генерации
+- Ошибки отправки в SQS не блокируют ответ клиенту
+
+### Интеграционные тесты (`ProjectApp.Tests`)
+- Фикстура `ServiceFixture` поднимает контейнеры Redis, MinIO и ElasticMQ через Testcontainers
+- `WebApplicationFactory` запускает API с подменёнными подключениями к тестовым контейнерам
+- Между тестами выполняется сброс состояния (flush Redis, purge SQS)
+
+### Список тестов
+
+| Тест | Что проверяет |
+|------|---------------|
+| `GetVehicle_ReturnsValidData` | Генерация транспортного средства возвращает корректные поля |
+| `GetVehicle_SecondRequest_ReturnsCachedData` | Повторный запрос с тем же ID возвращает закэшированные данные |
+| `GetVehicle_PublishesMessageToSqs` | После генерации сообщение попадает в очередь SQS |
+| `GetVehicle_WithInvalidId_ReturnsBadRequest` | Запрос с невалидным ID (≤ 0) возвращает 400 Bad Request |
+| `MinioStorage_SavesAndRetrievesFile` | Запись и чтение JSON-файла из MinIO |
+| `Redis_CachesVehicleData` | Данные сохраняются в Redis после генерации |
+
+# Лабораторная работа №4 — «Переход на облачную инфраструктуру»
+
+## Описание
+
+Все сервисы перенесены в Yandex Cloud. Локальная инфраструктура (Aspire, Docker-контейнеры) заменена облачными managed-сервисами. Клиентское приложение размещено в Object Storage, сервисы генерации и обработки файлов развёрнуты как Cloud Functions, маршрутизация настроена через Serverless API Gateway, очередь сообщений перенесена в Yandex Message Queue, а объектное хранилище файлов — в отдельный бакет Object Storage.
+
+## Что реализовано
+
+### Клиент (`Client.Wasm`) → Object Storage
+- Blazor WebAssembly собирается в Release с конфигурацией `appsettings.Production.json`
+- Статические файлы загружаются в бакет `vehicles-client`
+- Включён режим статического сайта с `index.html` по умолчанию
+- `BaseAddress` клиента указывает на URL API Gateway
+
+### Сервис генерации (`ProjectApp.Api.Function`) → Cloud Function
+- Проект `ProjectApp.Api.Function` — самодостаточная Cloud Function (без Aspire, без Redis)
+- Точка входа: `ApiFunction.Handler`, метод `FunctionHandler(string input) : string`
+- Runtime: `dotnet8`, память: 256 МБ, таймаут: 30 с
+- Деплой через архив с исходным кодом (`.cs` + `.csproj`); YC компилирует на своей стороне
+- Получает HTTP-запрос от API Gateway, парсит `id` из поля `pathParams`
+- Генерирует транспортное средство случайным образом, публикует JSON в очередь через `AWSSDK.SQS`
+- Возвращает JSON-ответ с CORS-заголовками в формате `{ statusCode, headers, body }`
+- Конфигурация через переменные окружения: `SQS_QUEUE_URL`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`
+
+### API Gateway → Serverless API Gateway
+- OpenAPI 3.0 спецификация (`cloud/api-gateway.yaml`)
+- Маршрут `GET /api/vehicle/{id}` интегрирован с Cloud Function через расширение `x-yc-apigateway-integration: type: cloud_functions`
+- CORS настроен на уровне Gateway (`x-yc-apigateway: cors`)
+- Балансировка нагрузки при масштабировании осуществляется платформой автоматически
+
+### Брокер сообщений → Yandex Message Queue
+- Очередь `vehicle-queue` создаётся через boto3 (SQS-совместимый API)
+- Endpoint: `https://message-queue.api.cloud.yandex.net`, регион `ru-central1`
+- Триггер `vehicle-mq-trigger` связывает очередь с Cloud Function файлового сервиса (batch size 10, окно 10 с)
+
+### Файловый сервис (`ProjectApp.FileService.Function`) → Cloud Function
+- Проект `ProjectApp.FileService.Function` — Cloud Function с триггером на Message Queue
+- Точка входа: `FileServiceFunction.Handler`, метод `FunctionHandler(string input) : string`
+- Runtime: `dotnet8`, память: 256 МБ, таймаут: 60 с
+- Деплой через архив с исходным кодом; зависимость `AWSSDK.S3` устанавливается YC при сборке
+- Получает пакет сообщений из очереди, декодирует тело (base64 или plain) и сохраняет в Object Storage
+- Конфигурация через переменные окружения: `S3_ENDPOINT`, `S3_BUCKET`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`
+
+### Объектное хранилище → Object Storage
+- Бакет `vehicle-data-store` — хранит JSON-файлы транспортных средств (`vehicle-{id}.json`)
+- Бакет создаётся через boto3 от имени сервисного аккаунта (владелец — `vehicle-sa`)
+- Доступ через `AWSSDK.S3` (YC Object Storage совместим с S3 API), endpoint: `https://storage.yandexcloud.net`
+
+
+## Ресурсы
+
+| Ресурс | Значение |
+|--------|----------|
+| API Gateway URL | `https://d5djaicgufnt5rijrt0u.p8361f8z.apigw.yandexcloud.net` |
+| Клиент | `http://vehicles-client.website.yandexcloud.net` |
+| Очередь | `vehicle-queue` |
+| Бакет файлов | `vehicle-data-store` |
+| Бакет клиента | `vehicles-client` |
\ No newline at end of file
diff --git a/cloud/api-gateway.yaml b/cloud/api-gateway.yaml
new file mode 100644
index 00000000..1f0576a4
--- /dev/null
+++ b/cloud/api-gateway.yaml
@@ -0,0 +1,62 @@
+openapi: "3.0.0"
+info:
+ title: Vehicle API Gateway
+ version: "1.0"
+
+x-yc-apigateway:
+ cors:
+ origin: '*'
+ methods: '*'
+ allowedHeaders: 'Content-Type,Authorization'
+
+paths:
+ /api/vehicle/{id}:
+ get:
+ summary: Получить транспортное средство по идентификатору
+ operationId: getVehicleById
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ x-yc-apigateway-integration:
+ type: cloud_functions
+ function_id: ${FUNCTION_ID}
+ service_account_id: ${SERVICE_ACCOUNT_ID}
+ tag: "$latest"
+ responses:
+ '200':
+ description: Транспортное средство успешно получено
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Vehicle'
+ '400':
+ description: Некорректный идентификатор
+
+components:
+ schemas:
+ Vehicle:
+ type: object
+ properties:
+ id:
+ type: integer
+ vin:
+ type: string
+ brand:
+ type: string
+ model:
+ type: string
+ year:
+ type: integer
+ bodyType:
+ type: string
+ fuelType:
+ type: string
+ color:
+ type: string
+ mileage:
+ type: number
+ lastServiceDate:
+ type: string
diff --git a/cloud/deploy.sh b/cloud/deploy.sh
new file mode 100755
index 00000000..41b9149e
--- /dev/null
+++ b/cloud/deploy.sh
@@ -0,0 +1,341 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(dirname "$SCRIPT_DIR")"
+
+source "$SCRIPT_DIR/env.sh"
+
+BUILD_DIR="$SCRIPT_DIR/build"
+mkdir -p "$BUILD_DIR"
+
+log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
+ok() { echo -e "\033[1;32m[OK]\033[0m $*"; }
+err() { echo -e "\033[1;31m[ERROR]\033[0m $*" >&2; exit 1; }
+
+command -v yc >/dev/null 2>&1 || err "yc CLI не установлен. Запустите: curl -sSL https://storage.yandexcloud.net/yandexcloud-yc/install.sh | bash"
+command -v dotnet >/dev/null 2>&1 || err "dotnet SDK не найден"
+command -v zip >/dev/null 2>&1 || err "zip не найден"
+
+log "Используем каталог: $YC_FOLDER_ID"
+
+# 1. Сервисный аккаунт
+log "Создание сервисного аккаунта $SA_NAME..."
+SA_ID=$(yc iam service-account list --folder-id "$YC_FOLDER_ID" \
+ --format json | python3 -c "
+import json,sys
+data=json.load(sys.stdin)
+sa=[x for x in data if x['name']=='$SA_NAME']
+print(sa[0]['id'] if sa else '')
+")
+
+if [ -z "$SA_ID" ]; then
+ SA_ID=$(yc iam service-account create \
+ --name "$SA_NAME" \
+ --folder-id "$YC_FOLDER_ID" \
+ --format json | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
+ ok "Сервисный аккаунт создан: $SA_ID"
+else
+ ok "Сервисный аккаунт уже существует: $SA_ID"
+fi
+
+for ROLE in storage.admin ymq.admin serverless.functions.invoker; do
+ yc resource-manager folder add-access-binding "$YC_FOLDER_ID" \
+ --role "$ROLE" \
+ --subject "serviceAccount:$SA_ID" \
+ --quiet 2>/dev/null || true
+done
+ok "Роли назначены сервисному аккаунту"
+
+KEY_FILE="$BUILD_DIR/sa-key.json"
+if [ ! -f "$KEY_FILE" ]; then
+ yc iam access-key create --service-account-id "$SA_ID" --folder-id "$YC_FOLDER_ID" \
+ --format json > "$BUILD_DIR/sa-access-key-raw.json"
+ ACCESS_KEY_ID=$(python3 -c "import json; d=json.load(open('$BUILD_DIR/sa-access-key-raw.json')); print(d['access_key']['key_id'])")
+ ACCESS_KEY_SECRET=$(python3 -c "import json; d=json.load(open('$BUILD_DIR/sa-access-key-raw.json')); print(d['secret'])")
+ echo "{\"key_id\":\"$ACCESS_KEY_ID\",\"secret\":\"$ACCESS_KEY_SECRET\"}" > "$KEY_FILE"
+ ok "IAM ключ создан"
+else
+ ACCESS_KEY_ID=$(python3 -c "import json; print(json.load(open('$KEY_FILE'))['key_id'])")
+ ACCESS_KEY_SECRET=$(python3 -c "import json; print(json.load(open('$KEY_FILE'))['secret'])")
+ ok "IAM ключ загружен из кэша"
+fi
+
+# 2. Бакет для файлов транспортных средств
+log "Создание бакета Object Storage: $STORAGE_BUCKET..."
+python3 - </dev/null || ok "Бакет $CLIENT_BUCKET уже существует"
+
+yc storage bucket update --name "$CLIENT_BUCKET" \
+ --website-settings '{"index":"index.html","error":"index.html"}' \
+ --folder-id "$YC_FOLDER_ID" 2>/dev/null || true
+
+yc storage bucket set-https --name "$CLIENT_BUCKET" --folder-id "$YC_FOLDER_ID" 2>/dev/null || true
+ok "Бакеты настроены"
+
+# 4. Message Queue
+log "Создание Message Queue: $QUEUE_NAME..."
+QUEUE_URL=$(python3 - </dev/null || ok "$API_FUNCTION_NAME уже существует"
+
+API_FUNCTION_ID=$(yc serverless function get "$API_FUNCTION_NAME" \
+ --folder-id "$YC_FOLDER_ID" --format json \
+ | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
+
+yc serverless function version create \
+ --function-name "$API_FUNCTION_NAME" \
+ --folder-id "$YC_FOLDER_ID" \
+ --runtime dotnet8 \
+ --entrypoint "ApiFunction.Handler" \
+ --memory 256m \
+ --execution-timeout 30s \
+ --source-path "$BUILD_DIR/api-function.zip" \
+ --environment "SQS_ENDPOINT=https://message-queue.api.cloud.yandex.net" \
+ --environment "SQS_QUEUE_URL=$QUEUE_URL" \
+ --environment "AWS_ACCESS_KEY_ID=$ACCESS_KEY_ID" \
+ --environment "AWS_SECRET_ACCESS_KEY=$ACCESS_KEY_SECRET" \
+ --service-account-id "$SA_ID"
+
+yc serverless function allow-unauthenticated-invoke "$API_FUNCTION_NAME" \
+ --folder-id "$YC_FOLDER_ID" 2>/dev/null || true
+
+ok "Cloud Function $API_FUNCTION_NAME развёрнута: $API_FUNCTION_ID"
+
+# 7. Архив исходного кода vehicle-file-service
+log "Подготовка исходного кода $FILE_FUNCTION_NAME..."
+FILE_SRC_DIR="$ROOT_DIR/ProjectApp.FileService.Function"
+rm -f "$BUILD_DIR/file-function.zip"
+cd "$FILE_SRC_DIR"
+zip -r "$BUILD_DIR/file-function.zip" \
+ Handler.cs \
+ ProjectApp.FileService.Function.csproj \
+ -q
+cd "$ROOT_DIR"
+ok "Архив создан: $BUILD_DIR/file-function.zip ($(du -sh "$BUILD_DIR/file-function.zip" | cut -f1))"
+
+# 8. Развёртывание Cloud Function vehicle-file-service
+log "Развёртывание Cloud Function $FILE_FUNCTION_NAME..."
+yc serverless function create --name "$FILE_FUNCTION_NAME" \
+ --folder-id "$YC_FOLDER_ID" 2>/dev/null || ok "$FILE_FUNCTION_NAME уже существует"
+
+FILE_FUNCTION_ID=$(yc serverless function get "$FILE_FUNCTION_NAME" \
+ --folder-id "$YC_FOLDER_ID" --format json \
+ | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
+
+yc serverless function version create \
+ --function-name "$FILE_FUNCTION_NAME" \
+ --folder-id "$YC_FOLDER_ID" \
+ --runtime dotnet8 \
+ --entrypoint "FileServiceFunction.Handler" \
+ --memory 256m \
+ --execution-timeout 60s \
+ --source-path "$BUILD_DIR/file-function.zip" \
+ --environment "S3_ENDPOINT=https://storage.yandexcloud.net" \
+ --environment "S3_BUCKET=$STORAGE_BUCKET" \
+ --environment "AWS_ACCESS_KEY_ID=$ACCESS_KEY_ID" \
+ --environment "AWS_SECRET_ACCESS_KEY=$ACCESS_KEY_SECRET" \
+ --service-account-id "$SA_ID"
+
+ok "Cloud Function $FILE_FUNCTION_NAME развёрнута: $FILE_FUNCTION_ID"
+
+# 9. Триггер Message Queue → vehicle-file-service
+log "Создание триггера Message Queue → $FILE_FUNCTION_NAME..."
+TRIGGER_EXISTS=$(yc serverless trigger list --folder-id "$YC_FOLDER_ID" --format json 2>/dev/null \
+ | python3 -c "import json,sys; d=json.load(sys.stdin); print('yes' if any(x.get('name')=='vehicle-mq-trigger' for x in d) else '')" 2>/dev/null || echo "")
+
+if [ -z "$TRIGGER_EXISTS" ]; then
+ yc serverless trigger create message-queue \
+ --name "vehicle-mq-trigger" \
+ --folder-id "$YC_FOLDER_ID" \
+ --queue "$QUEUE_URL" \
+ --queue-service-account-id "$SA_ID" \
+ --invoke-function-id "$FILE_FUNCTION_ID" \
+ --invoke-function-service-account-id "$SA_ID" \
+ --batch-size 10 \
+ --batch-cutoff 10s 2>&1 | grep -v "^$" || ok "Триггер создан с предупреждением"
+ ok "Триггер создан"
+else
+ ok "Триггер уже существует"
+fi
+
+# 10. API Gateway
+log "Развёртывание API Gateway $API_GATEWAY_NAME..."
+GATEWAY_SPEC="$BUILD_DIR/api-gateway-rendered.yaml"
+sed -e "s|\${FUNCTION_ID}|$API_FUNCTION_ID|g" \
+ -e "s|\${SERVICE_ACCOUNT_ID}|$SA_ID|g" \
+ "$SCRIPT_DIR/api-gateway.yaml" > "$GATEWAY_SPEC"
+
+GATEWAY_ID=$(yc serverless api-gateway list --folder-id "$YC_FOLDER_ID" --format json \
+ | python3 -c "
+import json,sys
+data=json.load(sys.stdin)
+gw=[x for x in data if x['name']=='$API_GATEWAY_NAME']
+print(gw[0]['id'] if gw else '')
+")
+
+if [ -z "$GATEWAY_ID" ]; then
+ GATEWAY_ID=$(yc serverless api-gateway create \
+ --name "$API_GATEWAY_NAME" \
+ --folder-id "$YC_FOLDER_ID" \
+ --spec "$GATEWAY_SPEC" \
+ --format json | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
+ ok "API Gateway создан: $GATEWAY_ID"
+else
+ yc serverless api-gateway update "$API_GATEWAY_NAME" \
+ --folder-id "$YC_FOLDER_ID" \
+ --spec "$GATEWAY_SPEC" 2>/dev/null || true
+ ok "API Gateway обновлён: $GATEWAY_ID"
+fi
+
+GATEWAY_DOMAIN=$(yc serverless api-gateway get "$API_GATEWAY_NAME" \
+ --folder-id "$YC_FOLDER_ID" --format json \
+ | python3 -c "import json,sys; print(json.load(sys.stdin).get('domain',''))")
+
+API_GATEWAY_URL="https://$GATEWAY_DOMAIN"
+ok "API Gateway URL: $API_GATEWAY_URL"
+
+# 11. Сборка и деплой клиентского приложения
+log "Сборка Blazor WASM клиента..."
+CLIENT_SETTINGS="$ROOT_DIR/Client.Wasm/wwwroot/appsettings.Production.json"
+cat > "$CLIENT_SETTINGS" < "$BUILD_DIR/deployment-info.json" <