diff --git a/.editorconfig b/.editorconfig index 0f3bba5ca..a8a58a0e7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -43,6 +43,7 @@ csharp_style_var_for_built_in_types = true:error csharp_style_var_when_type_is_apparent = true:error csharp_style_var_elsewhere = false:silent csharp_space_around_binary_operators = before_and_after + [*.{cs,vb}] #### Naming styles #### @@ -64,31 +65,31 @@ dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case dotnet_naming_symbols.interface.applicable_kinds = interface dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.interface.required_modifiers = +dotnet_naming_symbols.interface.required_modifiers = dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.types.required_modifiers = +dotnet_naming_symbols.types.required_modifiers = dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.non_field_members.required_modifiers = +dotnet_naming_symbols.non_field_members.required_modifiers = # Naming styles dotnet_naming_style.begins_with_i.required_prefix = I -dotnet_naming_style.begins_with_i.required_suffix = -dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = dotnet_naming_style.begins_with_i.capitalization = pascal_case -dotnet_naming_style.pascal_case.required_prefix = -dotnet_naming_style.pascal_case.required_suffix = -dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = dotnet_naming_style.pascal_case.capitalization = pascal_case -dotnet_naming_style.pascal_case.required_prefix = -dotnet_naming_style.pascal_case.required_suffix = -dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = dotnet_naming_style.pascal_case.capitalization = pascal_case dotnet_style_operator_placement_when_wrapping = beginning_of_line tab_width = 4 diff --git a/.github/workflows/dotnet-tests.yml b/.github/workflows/dotnet-tests.yml new file mode 100644 index 000000000..4f359414d --- /dev/null +++ b/.github/workflows/dotnet-tests.yml @@ -0,0 +1,29 @@ +name: dotnet-tests.yml +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore dependencies + run: dotnet restore BikeRental/BikeRental.sln + + - name: Build + run: dotnet build --no-restore --configuration Release BikeRental/BikeRental.sln + + - name: Run tests + run: dotnet test BikeRental/BikeRental.Tests/BikeRental.Tests.csproj --no-build --configuration Release + + \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..a013d3a07 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/projectSettingsUpdater.xml +/modules.xml +/.idea.enterprise-development.iml +/contentModel.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/indexLayout.xml b/.idea/indexLayout.xml new file mode 100644 index 000000000..7b08163ce --- /dev/null +++ b/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..830674470 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/BikeRental/.idea/.idea.BikeRental/.idea/.gitignore b/BikeRental/.idea/.idea.BikeRental/.idea/.gitignore new file mode 100644 index 000000000..e9332b459 --- /dev/null +++ b/BikeRental/.idea/.idea.BikeRental/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/.idea.BikeRental.iml +/projectSettingsUpdater.xml +/modules.xml +/contentModel.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/BikeRental/.idea/.idea.BikeRental/.idea/dictionaries/project.xml b/BikeRental/.idea/.idea.BikeRental/.idea/dictionaries/project.xml new file mode 100644 index 000000000..dfd4ac66a --- /dev/null +++ b/BikeRental/.idea/.idea.BikeRental/.idea/dictionaries/project.xml @@ -0,0 +1,8 @@ + + + + арендатели + сyclist + + + \ No newline at end of file diff --git a/BikeRental/.idea/.idea.BikeRental/.idea/indexLayout.xml b/BikeRental/.idea/.idea.BikeRental/.idea/indexLayout.xml new file mode 100644 index 000000000..7b08163ce --- /dev/null +++ b/BikeRental/.idea/.idea.BikeRental/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/BikeRental/.idea/.idea.BikeRental/.idea/vcs.xml b/BikeRental/.idea/.idea.BikeRental/.idea/vcs.xml new file mode 100644 index 000000000..6c0b86358 --- /dev/null +++ b/BikeRental/.idea/.idea.BikeRental/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/BikeRental/AppHost/AppHost.csproj b/BikeRental/AppHost/AppHost.csproj new file mode 100644 index 000000000..bbcf587dc --- /dev/null +++ b/BikeRental/AppHost/AppHost.csproj @@ -0,0 +1,30 @@ + + + + + + Exe + net8.0 + enable + enable + fa0efc8b-9978-4cb9-83d3-bbcd25eab020 + + + + + + + + + + + appsettings.json + + + + + + + + + diff --git a/BikeRental/AppHost/Program.cs b/BikeRental/AppHost/Program.cs new file mode 100644 index 000000000..e76e8ba22 --- /dev/null +++ b/BikeRental/AppHost/Program.cs @@ -0,0 +1,35 @@ +using Projects; + +IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); + +IResourceBuilder nats = builder.AddContainer("nats", "nats:2.10") + .WithArgs("-js") + .WithEndpoint(4222, 4222); + +IResourceBuilder bikeRentalDbPassword = builder.AddParameter( + "bike-rental-db-password", + "1234512345Aa$", + secret: true); +IResourceBuilder bikeRentalSql = builder.AddMySql("bike-rental-db", + bikeRentalDbPassword) + .WithAdminer() + .WithDataVolume("bike-rental-volume"); + +IResourceBuilder bikeRentalDb = + bikeRentalSql.AddDatabase("bike-rental"); + +builder.AddProject("bike-rental-nats-generator") + .WaitFor(nats) + .WithEnvironment("Nats__Url", "nats://localhost:4222") + .WithEnvironment("Nats__StreamName", "bike-rental-stream") + .WithEnvironment("Nats__SubjectName", "bike-rental.leases"); + +builder.AddProject("bike-rental-api") + .WaitFor(bikeRentalDb) + .WaitFor(nats) + .WithReference(bikeRentalDb) + .WithEnvironment("Nats__Url", "nats://localhost:4222") + .WithEnvironment("Nats__StreamName", "bike-rental-stream") + .WithEnvironment("Nats__SubjectName", "bike-rental.leases"); + +builder.Build().Run(); \ No newline at end of file diff --git a/BikeRental/AppHost/Properties/launchSettings.json b/BikeRental/AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..9103756f1 --- /dev/null +++ b/BikeRental/AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17195;http://localhost:15246", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21053", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22270", + "DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS": "true" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15246", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19196", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20132", + "DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS": "true" + } + } + } +} diff --git a/BikeRental/AppHost/appsettings.Development.json b/BikeRental/AppHost/appsettings.Development.json new file mode 100644 index 000000000..1b2d3bafd --- /dev/null +++ b/BikeRental/AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} \ No newline at end of file diff --git a/BikeRental/AppHost/appsettings.json b/BikeRental/AppHost/appsettings.json new file mode 100644 index 000000000..888f884e2 --- /dev/null +++ b/BikeRental/AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Api/BikeRental.Api.csproj b/BikeRental/BikeRental.Api/BikeRental.Api.csproj new file mode 100644 index 000000000..ed573c19b --- /dev/null +++ b/BikeRental/BikeRental.Api/BikeRental.Api.csproj @@ -0,0 +1,57 @@ + + + + net8.0 + enable + enable + Linux + bin\Debug\net8.0\BikeRental.Api.xml + ..\.. + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + appsettings.json + + + + + diff --git a/BikeRental/BikeRental.Api/Controllers/BikeModelsController.cs b/BikeRental/BikeRental.Api/Controllers/BikeModelsController.cs new file mode 100644 index 000000000..6d58c4b04 --- /dev/null +++ b/BikeRental/BikeRental.Api/Controllers/BikeModelsController.cs @@ -0,0 +1,84 @@ +using BikeRental.Application.Contracts.Dtos; +using BikeRental.Application.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace BikeRental.Api.Controllers; + +/// +/// Контроллер описывает конечные точки для работы с +/// ресурсом "BikeModel" (модель велосипеда) +/// "bikeModelService" - сервис для работы с ресурсом BikeModel +/// Зависимость от интерфейса, а не конкретной реализации в сервисе (SOLID - DIP) +/// +[ApiController] +[Route("bike-models")] +public sealed class BikeModelsController(IBikeModelService bikeModelService) : ControllerBase +{ + /// + /// Получить все модели велосипедов + /// + [HttpGet] + public async Task>> GetAll() + { + IEnumerable models = await bikeModelService.GetAll(); + var sortedModels = models.OrderBy(model => model.Id).ToList(); + return Ok(sortedModels); + } + + /// + /// Получить модель велосипеда по идентификатору + /// + [HttpGet("{id:int}")] + public async Task> GetById(int id) + { + BikeModelDto? model = await bikeModelService.GetById(id); + if (model is null) + { + return NotFound(); + } + + return Ok(model); + } + + /// + /// Создать новую модель велосипеда + /// "dto" - модель для создания модели велосипеда + /// + [HttpPost] + public async Task> Create([FromBody] BikeModelCreateUpdateDto dto) + { + BikeModelDto created = await bikeModelService.Create(dto); + + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + + /// + /// Обновить существующую модель велосипеда + /// + [HttpPut("{id:int}")] + public async Task> Update(int id, [FromBody] BikeModelCreateUpdateDto dto) + { + BikeModelDto? updated = await bikeModelService.Update(id, dto); + if (updated is null) + { + return NotFound(); + } + + return Ok(updated); + } + + /// + /// Удалить модель велосипеда по идентификатору + /// + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var deleted = await bikeModelService.Delete(id); + if (!deleted) + { + return NotFound(); + } + + return NoContent(); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Api/Controllers/BikesController.cs b/BikeRental/BikeRental.Api/Controllers/BikesController.cs new file mode 100644 index 000000000..6faf806e7 --- /dev/null +++ b/BikeRental/BikeRental.Api/Controllers/BikesController.cs @@ -0,0 +1,80 @@ +using BikeRental.Application.Contracts.Dtos; +using BikeRental.Application.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace BikeRental.Api.Controllers; + +/// +/// Контроллер описывает конечные точки для работы +/// с ресурсом "Bike" (велосипед) +/// +[ApiController] +[Route("bikes")] +public sealed class BikesController(IBikeService bikeService) : ControllerBase +{ + /// + /// Получить все ресурсы Bike + /// + [HttpGet] + public async Task>> GetAll() + { + IEnumerable bikes = await bikeService.GetAll(); + var sortedBikes = bikes.OrderBy(bike => bike.Id).ToList(); + return Ok(sortedBikes); + } + + /// + /// Получить ресурс по идентификатору Bike + /// + [HttpGet("{id:int}")] + public async Task> GetById(int id) + { + BikeDto? bike = await bikeService.GetById(id); + if (bike is null) + { + return NotFound(); + } + + return Ok(bike); + } + + /// + /// Создать новый ресурс Bike + /// + [HttpPost] + public async Task> Create([FromBody] BikeCreateUpdateDto dto) + { + BikeDto created = await bikeService.Create(dto); + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + + /// + /// Обновить существующий ресурс Bike + /// + [HttpPut("{id:int}")] + public async Task> Update(int id, [FromBody] BikeCreateUpdateDto dto) + { + BikeDto? updated = await bikeService.Update(id, dto); + if (updated is null) + { + return NotFound(); + } + + return Ok(updated); + } + + /// + /// Удалить ресурс Bike + /// + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var deleted = await bikeService.Delete(id); + if (!deleted) + { + return NotFound(); + } + + return NoContent(); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Api/Controllers/LeasesController.cs b/BikeRental/BikeRental.Api/Controllers/LeasesController.cs new file mode 100644 index 000000000..acecd1cf8 --- /dev/null +++ b/BikeRental/BikeRental.Api/Controllers/LeasesController.cs @@ -0,0 +1,80 @@ +using BikeRental.Application.Contracts.Dtos; +using BikeRental.Application.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace BikeRental.Api.Controllers; + +/// +/// Контроллер описывает конечные точки для работы с ресурсом +/// "Lease" (договор на аренду велосипеда) +/// +[ApiController] +[Route("leases")] +public sealed class LeasesController(ILeaseService leaseService) : ControllerBase +{ + /// + /// Получить все договора на аренду велосипедов + /// + [HttpGet] + public async Task>> GetAll() + { + IEnumerable leases = await leaseService.GetAll(); + var sortedLeases = leases.OrderBy(l => l.Id).ToList(); + return Ok(sortedLeases); + } + + /// + /// Получить договор на аренду велосипеда по идентификатору + /// + [HttpGet("{id:int}")] + public async Task> GetById(int id) + { + LeaseDto? lease = await leaseService.GetById(id); + if (lease is null) + { + return NotFound(); + } + + return Ok(lease); + } + + /// + /// Создать договор на аренду велосипеда + /// + [HttpPost] + public async Task> Create([FromBody] LeaseCreateUpdateDto dto) + { + LeaseDto created = await leaseService.Create(dto); + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + + /// + /// Обновить состояние текущего договора на аренду велосипеда + /// + [HttpPut("{id:int}")] + public async Task> Update(int id, [FromBody] LeaseCreateUpdateDto dto) + { + LeaseDto? updated = await leaseService.Update(id, dto); + if (updated is null) + { + return NotFound(); + } + + return Ok(updated); + } + + /// + /// Удалить договор на аренду велосипеда по идентификатору + /// + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var deleted = await leaseService.Delete(id); + if (!deleted) + { + return NotFound(); + } + + return NoContent(); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Api/Controllers/RentersController.cs b/BikeRental/BikeRental.Api/Controllers/RentersController.cs new file mode 100644 index 000000000..7b68bf80e --- /dev/null +++ b/BikeRental/BikeRental.Api/Controllers/RentersController.cs @@ -0,0 +1,85 @@ +using BikeRental.Application.Contracts.Dtos; +using BikeRental.Application.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace BikeRental.Api.Controllers; + +/// +/// Контроллер описывает конечные точки для работы с ресурсом +/// "Renter" (арендатор) +/// +[ApiController] +[Route("renters")] +public sealed class RentersController(IRenterService renterService) : ControllerBase +{ + /// + /// Получить всех арендаторов + /// + [HttpGet] + public async Task>> GetAll() + { + IEnumerable renters = await renterService.GetAll(); + var sortedRenters = renters.OrderBy(renter => renter.Id).ToList(); + return Ok(sortedRenters); + } + + /// + /// Получить арендатора по идентификатору + /// + /// + [HttpGet("{id:int}")] + public async Task> GetById(int id) + { + RenterDto? renter = await renterService.GetById(id); + if (renter is null) + { + return NotFound(); + } + + return Ok(renter); + } + + /// + /// Создать нового арендатора + /// + /// + [HttpPost] + public async Task> Create([FromBody] RenterCreateUpdateDto dto) + { + RenterDto created = await renterService.Create(dto); + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + + /// + /// Обновить существующего арендатора + /// + /// + /// + [HttpPut("{id:int}")] + public async Task> Update(int id, [FromBody] RenterCreateUpdateDto dto) + { + RenterDto? updated = await renterService.Update(id, dto); + if (updated is null) + { + return NotFound(); + } + + return Ok(updated); + } + + /// + /// Удалить арендатора по идентификатору + /// + /// + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var deleted = await renterService.Delete(id); + if (!deleted) + { + return NotFound(); + } + + return NoContent(); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Api/DependencyInjection.cs b/BikeRental/BikeRental.Api/DependencyInjection.cs new file mode 100644 index 000000000..3e9100829 --- /dev/null +++ b/BikeRental/BikeRental.Api/DependencyInjection.cs @@ -0,0 +1,117 @@ +using BikeRental.Api.Middleware; +using BikeRental.Application.Interfaces; +using BikeRental.Application.Services; +using BikeRental.Domain.Interfaces; +using BikeRental.Infrastructure.Database; +using BikeRental.Infrastructure.Repositories; +using BikeRental.Infrastructure.Services; +using BikeRental.Infrastructure.Services.Impl; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace BikeRental.Api; + +/// +/// Настройка зависимостей приложения +/// +public static class DependencyInjection +{ + /// + /// Зарегистрировать и настроить сервисы контроллеров + /// + public static void AddControllers(this WebApplicationBuilder builder) + { + builder.Services.AddControllers(options => + { + options.ReturnHttpNotAcceptable = false; // 406 + }); + } + + /// + /// Зарегистрировать и настроить сервисы обработки ошибок + /// + public static void AddErrorHandling(this WebApplicationBuilder builder) + { + builder.Services.AddExceptionHandler(); + + builder.Services.AddProblemDetails(); + } + + /// + /// Зарегистрировать и настроить сервисы OpenTelemetry + /// + public static void AddObservability(this WebApplicationBuilder builder) + { + // Зарегистрировать сервис OpenTelemetry + builder.Services.AddOpenTelemetry() + // Добавить ресурс по наименованию приложения + .ConfigureResource(resource => resource.AddService(builder.Environment.ApplicationName)) + // Настроить распределенную трассировку + .WithTracing(tracing => tracing + // Добавить инструментарии для HttpClient и ASP.NET Core + .AddHttpClientInstrumentation() + .AddAspNetCoreInstrumentation() + .AddEntityFrameworkCoreInstrumentation()) + // Добавить метрики + .WithMetrics(metrics => metrics + // Добавить инструментарии для .NET Runtime, HttpClient и ASP.NET Core + .AddHttpClientInstrumentation() + .AddAspNetCoreInstrumentation() + .AddRuntimeInstrumentation()) + // Настроить глобальный экспортер метрик + .UseOtlpExporter(); + + // Настроить ведение журнала OpenTelemetry + builder.Logging.AddOpenTelemetry(options => + { + options.IncludeScopes = true; // Включить области + options.IncludeFormattedMessage = true; // Включить форматированные сообщения + }); + } + + /// + /// Зарегистрировать и настроить сервисы взаимодействия с базой данных + /// + public static void AddDatabase(this WebApplicationBuilder builder) + { + builder.Services.AddDbContext(options => + options + .UseMySQL( + builder.Configuration.GetConnectionString("bike-rental") ?? throw new InvalidOperationException(), + npgsqlOptions => npgsqlOptions + // Настроить таблицу истории миграций + .MigrationsHistoryTable(HistoryRepository.DefaultTableName)) + // Использовать соглашение именования snake_case + .UseSnakeCaseNamingConvention()); + } + + /// + /// Зарегистрировать репозитории + /// + public static void AddRepositories(this WebApplicationBuilder builder) + { + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + } + + /// + /// Регистрация сервисов общего назначения + /// + public static void AddServices(this WebApplicationBuilder builder) + { + // Зарегистрировать сервис инициализации данных + builder.Services.AddScoped(); + + // Зарегистрировать сервисы прикладного уровня + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Api/Extensions/DatabaseExtensions.cs b/BikeRental/BikeRental.Api/Extensions/DatabaseExtensions.cs new file mode 100644 index 000000000..17286f012 --- /dev/null +++ b/BikeRental/BikeRental.Api/Extensions/DatabaseExtensions.cs @@ -0,0 +1,38 @@ +using BikeRental.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace BikeRental.Api.Extensions; + +/// +/// Предоставляет методы расширения для работы с базой данных +/// +public static class DatabaseExtensions +{ + /// + /// Применить все миграции к базе данных + /// + /// + public static async Task ApplyMigrationsAsync(this WebApplication app) + { + // мы не в HTTP запросе тк это запуск приложения + // поэтому создаем Scope(один из уровней DI контейнера) вручную, как бы новую область видимости для DI + // Scope гарантирует, что все зависимости будут правильно созданы и уничтожены + using IServiceScope scope = app.Services.CreateScope(); + await using ApplicationDbContext dbContext = scope.ServiceProvider.GetRequiredService(); + // scope.ServiceProvider - DI контейнер в рамках созданного Scope + // GetRequiredService() - получить сервис типа T + // Требует, чтобы сервис был зарегистрирован, иначе исключение + // DbContext реализует IAsyncDisposable (асинхронное освобождение ресурсов) + + try + { + await dbContext.Database.MigrateAsync(); + app.Logger.LogInformation("Database migrations applied successfully."); + } + catch (Exception e) + { + app.Logger.LogError(e, "An error occurred while applying database migrations."); + throw; + } + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Api/Extensions/SeedDataExtensions.cs b/BikeRental/BikeRental.Api/Extensions/SeedDataExtensions.cs new file mode 100644 index 000000000..4b761d2f9 --- /dev/null +++ b/BikeRental/BikeRental.Api/Extensions/SeedDataExtensions.cs @@ -0,0 +1,21 @@ +using BikeRental.Infrastructure.Services; + +namespace BikeRental.Api.Extensions; + +/// +/// Предоставляет методы расширения для выполнения +/// первичной инициализации данных в бд +/// +public static class SeedDataExtensions +{ + /// + /// Проинициализировать данные в бд + /// + /// + public static async Task SeedData(this IApplicationBuilder app) + { + using IServiceScope scope = app.ApplicationServices.CreateScope(); + ISeedDataService seedDataService = scope.ServiceProvider.GetRequiredService(); + await seedDataService.SeedDataAsync(); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Api/Messaging/NatsConsumerSettings.cs b/BikeRental/BikeRental.Api/Messaging/NatsConsumerSettings.cs new file mode 100644 index 000000000..6d5e73266 --- /dev/null +++ b/BikeRental/BikeRental.Api/Messaging/NatsConsumerSettings.cs @@ -0,0 +1,17 @@ +namespace BikeRental.Api.Messaging; + +internal sealed class NatsConsumerSettings +{ + public string Url { get; init; } = "nats://localhost:4222"; + public string StreamName { get; init; } = string.Empty; + public string SubjectName { get; init; } = string.Empty; + public string DurableName { get; init; } = "bike-rental-lease-consumer"; + public int AckWaitSeconds { get; init; } = 30; + public long MaxDeliver { get; init; } = 5; + public int ConnectRetryAttempts { get; init; } = 5; + public int ConnectRetryDelayMs { get; init; } = 2000; + public double RetryBackoffFactor { get; init; } = 2; + public int ConsumeMaxMsgs { get; init; } = 100; + public int ConsumeExpiresSeconds { get; init; } = 30; + public int ConsumeRetryDelayMs { get; init; } = 2000; +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Api/Messaging/NatsLeaseConsumer.cs b/BikeRental/BikeRental.Api/Messaging/NatsLeaseConsumer.cs new file mode 100644 index 000000000..f5450db42 --- /dev/null +++ b/BikeRental/BikeRental.Api/Messaging/NatsLeaseConsumer.cs @@ -0,0 +1,230 @@ +using System.Text.Json; +using BikeRental.Application.Contracts.Dtos; +using BikeRental.Application.Interfaces; +using BikeRental.Infrastructure.Database; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Options; +using NATS.Client.Core; +using NATS.Client.JetStream; +using NATS.Client.JetStream.Models; +using NATS.Net; + +namespace BikeRental.Api.Messaging; + +internal sealed class NatsLeaseConsumer( + INatsConnection connection, + IOptions settings, + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + private readonly INatsDeserialize _deserializer = BuildDeserializer(connection); + private readonly NatsConsumerSettings _settings = settings.Value; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + ValidateSettings(_settings); + + await ExecuteWithRetryAsync( + "connect to NATS", + _settings.ConnectRetryAttempts, + TimeSpan.FromMilliseconds(_settings.ConnectRetryDelayMs), + async () => await connection.ConnectAsync(), + stoppingToken); + + INatsJSContext context = connection.CreateJetStreamContext(); + var streamConfig = new StreamConfig(_settings.StreamName, [_settings.SubjectName]); + + await ExecuteWithRetryAsync( + "create/update stream", + _settings.ConnectRetryAttempts, + TimeSpan.FromMilliseconds(_settings.ConnectRetryDelayMs), + async () => await context.CreateOrUpdateStreamAsync(streamConfig, stoppingToken), + stoppingToken); + + var consumerConfig = new ConsumerConfig + { + Name = _settings.DurableName, + DurableName = _settings.DurableName, + AckPolicy = ConsumerConfigAckPolicy.Explicit, + DeliverPolicy = ConsumerConfigDeliverPolicy.All, + ReplayPolicy = ConsumerConfigReplayPolicy.Instant, + FilterSubject = _settings.SubjectName, + AckWait = TimeSpan.FromSeconds(Math.Max(1, _settings.AckWaitSeconds)), + MaxDeliver = Math.Max(1, _settings.MaxDeliver) + }; + + INatsJSConsumer consumer = await ExecuteWithRetryAsync( + "create/update consumer", + _settings.ConnectRetryAttempts, + TimeSpan.FromMilliseconds(_settings.ConnectRetryDelayMs), + async () => await context.CreateOrUpdateConsumerAsync(_settings.StreamName, consumerConfig, stoppingToken), + stoppingToken); + + var consumeOptions = new NatsJSConsumeOpts + { + MaxMsgs = Math.Max(1, _settings.ConsumeMaxMsgs), + Expires = TimeSpan.FromSeconds(Math.Max(1, _settings.ConsumeExpiresSeconds)) + }; + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await foreach (INatsJSMsg msg in consumer.ConsumeAsync(_deserializer, consumeOptions, + stoppingToken)) + { + await HandleMessageAsync(msg, stoppingToken); + } + } + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) + { + logger.LogError(ex, "Error while consuming leases from NATS. Retrying in {delay}ms.", + _settings.ConsumeRetryDelayMs); + await Task.Delay(Math.Max(0, _settings.ConsumeRetryDelayMs), stoppingToken); + } + } + } + + private async Task HandleMessageAsync(INatsJSMsg msg, CancellationToken stoppingToken) + { + if (msg.Data is null || msg.Data.Length == 0) + { + logger.LogWarning("Received empty lease batch message."); + await msg.AckAsync(cancellationToken: stoppingToken); + return; + } + + List? leases; + try + { + leases = JsonSerializer.Deserialize>(msg.Data); + } + catch (JsonException ex) + { + logger.LogError(ex, "Failed to deserialize lease batch payload."); + await msg.AckTerminateAsync(cancellationToken: stoppingToken); + return; + } + + if (leases is null || leases.Count == 0) + { + logger.LogWarning("Received lease batch with no items."); + await msg.AckAsync(cancellationToken: stoppingToken); + return; + } + + try + { + await SaveBatchAsync(leases, stoppingToken); + await msg.AckAsync(cancellationToken: stoppingToken); + } + catch (ArgumentException ex) + { + logger.LogWarning(ex, "Lease batch contains invalid references. Message will be terminated."); + await msg.AckTerminateAsync(cancellationToken: stoppingToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to persist lease batch. Message will be retried."); + } + } + + private async Task SaveBatchAsync(IReadOnlyList leases, CancellationToken stoppingToken) + { + await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope(); + ApplicationDbContext dbContext = scope.ServiceProvider.GetRequiredService(); + ILeaseService leaseService = scope.ServiceProvider.GetRequiredService(); + + await using IDbContextTransaction transaction = await dbContext.Database.BeginTransactionAsync(stoppingToken); + foreach (LeaseCreateUpdateDto lease in leases) + { + await leaseService.Create(lease); + } + + await transaction.CommitAsync(stoppingToken); + } + + private async Task ExecuteWithRetryAsync( + string operation, + int attempts, + TimeSpan baseDelay, + Func action, + CancellationToken stoppingToken) + { + _ = await ExecuteWithRetryAsync( + operation, + attempts, + baseDelay, + async () => + { + await action(); + return new object(); + }, + stoppingToken); + } + + private async Task ExecuteWithRetryAsync( + string operation, + int attempts, + TimeSpan baseDelay, + Func> action, + CancellationToken stoppingToken) + { + var retries = Math.Max(1, attempts); + TimeSpan delay = baseDelay; + var backoff = _settings.RetryBackoffFactor <= 0 ? 2 : _settings.RetryBackoffFactor; + + for (var attempt = 1; attempt <= retries; attempt++) + { + try + { + return await action(); + } + catch (Exception ex) when (attempt < retries && !stoppingToken.IsCancellationRequested) + { + if (delay > TimeSpan.Zero) + { + logger.LogWarning( + ex, + "Failed to {operation} (attempt {attempt}/{retries}). Retrying in {delay}.", + operation, + attempt, + retries, + delay); + await Task.Delay(delay, stoppingToken); + delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * backoff); + } + else + { + logger.LogWarning( + ex, + "Failed to {operation} (attempt {attempt}/{retries}). Retrying immediately.", + operation, + attempt, + retries); + } + } + } + + throw new InvalidOperationException($"Failed to {operation} after {retries} attempts."); + } + + private static INatsDeserialize BuildDeserializer(INatsConnection connection) + { + INatsSerializerRegistry registry = connection.Opts.SerializerRegistry; + return registry.GetDeserializer(); + } + + private static void ValidateSettings(NatsConsumerSettings settings) + { + if (string.IsNullOrWhiteSpace(settings.StreamName)) + { + throw new KeyNotFoundException("StreamName is not configured in Nats section."); + } + + if (string.IsNullOrWhiteSpace(settings.SubjectName)) + { + throw new KeyNotFoundException("SubjectName is not configured in Nats section."); + } + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Api/Middleware/GlobalExceptionHandler.cs b/BikeRental/BikeRental.Api/Middleware/GlobalExceptionHandler.cs new file mode 100644 index 000000000..b937180db --- /dev/null +++ b/BikeRental/BikeRental.Api/Middleware/GlobalExceptionHandler.cs @@ -0,0 +1,147 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +namespace BikeRental.Api.Middleware; + +/// +/// Глобальный обработчик исключений с логированием +/// +public sealed class GlobalExceptionHandler( + IProblemDetailsService problemDetailsService, + ILogger logger) + : IExceptionHandler +{ + /// + /// Попытаться обработать исключение + /// + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + // понятным сообщением сделать логи + LogExceptionWithSimpleMessage(httpContext, exception); + + ProblemDetails problemDetails = CreateProblemDetails(exception); + + return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = httpContext, + Exception = exception, + ProblemDetails = problemDetails + // 1. Пользователь кинул запрос например GET .../999 + // 2. Контроллер отсылает в NullReferenceException - тк bikeModelService.GetById(id) вернет null. + // 3. ASP.NET Core ловит исключение + // 4. Вызывает GlobalExceptionHandler.TryHandleAsync() + // 5. логируем ошибку и создаем ProblemDetails, ProblemDetailsService генерирует JSON ответ + + // Возвращает true, если исключение было успешно обработано, false - если нужно пробросить дальше + // TryWriteAsync возвращает false - клиент получает дефолтный ответ (500) + // клиент не узнаёт что именно не так + // но в консольке все выводится + }); + } + + /// + /// Логирование с короткими понятными сообщениями + /// + private void LogExceptionWithSimpleMessage(HttpContext httpContext, Exception exception) + { + PathString requestPath = httpContext.Request.Path; + var method = httpContext.Request.Method; + var exceptionType = exception.GetType().Name; + + // Основное понятное сообщение + var message = exception switch + { + KeyNotFoundException keyEx => $"Resource not found: {keyEx.Message.Replace("not found", "")}", + ArgumentException argEx => $"Invalid input: {argEx.Message}", + InvalidOperationException opEx => $"Invalid operation: {opEx.Message}", + UnauthorizedAccessException => "Access denied", + _ => "Internal server error" + }; + + // Для 404 и 400 - Warning с кратким сообщением + if (exception is KeyNotFoundException or ArgumentException or InvalidOperationException) + { + logger.LogWarning( + "[{StatusCode}] {Method} {Path} - {Message}", + GetStatusCode(exception), + method, + requestPath, + message); + } + // Для остальных - Error с полным stack trace + else + { + logger.LogError( + exception, + "[{StatusCode}] {Method} {Path} - {ExceptionType}: {Message}", + GetStatusCode(exception), + method, + requestPath, + exceptionType, + message); + } + } + + /// + /// Создание ProblemDetails + /// + private static ProblemDetails CreateProblemDetails(Exception exception) + { + var statusCode = GetStatusCode(exception); + + return new ProblemDetails + { + Title = GetTitle(exception), + Detail = GetDetail(exception), + Status = statusCode + }; + } + + /// + /// Получение статус кода + /// + private static int GetStatusCode(Exception exception) + { + return exception switch + { + KeyNotFoundException => StatusCodes.Status404NotFound, + ArgumentException => StatusCodes.Status400BadRequest, + InvalidOperationException => StatusCodes.Status400BadRequest, + UnauthorizedAccessException => StatusCodes.Status401Unauthorized, + _ => StatusCodes.Status500InternalServerError + }; + } + + /// + /// Получение заголовка + /// + private static string GetTitle(Exception exception) + { + return exception switch + { + KeyNotFoundException => "Resource not found", + ArgumentException => "Bad request", + InvalidOperationException => "Invalid operation", + UnauthorizedAccessException => "Unauthorized", + _ => "Internal server error" + }; + } + + /// + /// Получение деталей + /// + private static string GetDetail(Exception exception) + { + // Для клиентских ошибок показываем сообщение исключения + if (exception is KeyNotFoundException or ArgumentException or InvalidOperationException) + { + return exception.Message; + } + + // Для серверных ошибок - общее сообщение + return "An error occurred while processing your request. Please try again later."; + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Api/Program.cs b/BikeRental/BikeRental.Api/Program.cs new file mode 100644 index 000000000..eeb8309db --- /dev/null +++ b/BikeRental/BikeRental.Api/Program.cs @@ -0,0 +1,86 @@ +using BikeRental.Api; +using BikeRental.Api.Extensions; +using BikeRental.Api.Messaging; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using NATS.Client.Core; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.AddControllers(); +builder.AddErrorHandling(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "BikeRental API", + Version = "v1", + Description = "API для управления сервисом проката велосипедов" + }); + + var basePath = AppContext.BaseDirectory; + var xmlPathApi = Path.Combine(basePath, "BikeRental.Api.xml"); + options.IncludeXmlComments(xmlPathApi); +}); + + +builder.AddObservability(); +builder.AddDatabase(); +builder.AddRepositories(); +builder.AddServices(); + +IConfigurationSection natsSettingsSection = builder.Configuration.GetSection("NatsConsumerSettings"); + +if (natsSettingsSection.Exists()) +{ + Console.WriteLine($"Nats.Url: {natsSettingsSection["Url"]}"); + Console.WriteLine($"Nats.StreamName: {natsSettingsSection["StreamName"]}"); +} + +builder.Services.Configure(builder.Configuration.GetSection("NatsConsumerSettings")); +builder.Services.AddSingleton(sp => +{ + NatsConsumerSettings settings = sp.GetRequiredService>().Value; + var connectRetryDelayMs = Math.Max(0, settings.ConnectRetryDelayMs); + var reconnectWaitMin = TimeSpan.FromMilliseconds(connectRetryDelayMs); + var reconnectWaitMax = TimeSpan.FromMilliseconds( + Math.Max(connectRetryDelayMs, connectRetryDelayMs * Math.Max(settings.RetryBackoffFactor, 1))); + + var options = new NatsOpts + { + Url = settings.Url, + RetryOnInitialConnect = settings.ConnectRetryAttempts > 1, + MaxReconnectRetry = Math.Max(0, settings.ConnectRetryAttempts), + ReconnectWaitMin = reconnectWaitMin, + ReconnectWaitMax = reconnectWaitMax, + ReconnectJitter = TimeSpan.FromMilliseconds(connectRetryDelayMs * 0.2) + }; + return new NatsConnection(options); +}); +builder.Services.AddHostedService(); + +WebApplication app = builder.Build(); + +app.UseExceptionHandler(); + +if (app.Environment.IsDevelopment()) +{ + // https://localhost:/swagger + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "BikeRental API v1"); + c.RoutePrefix = "swagger"; + c.ShowCommonExtensions(); + }); + + await app.ApplyMigrationsAsync(); + + await app.SeedData(); +} + +app.MapControllers(); + +await app.RunAsync(); \ No newline at end of file diff --git a/BikeRental/BikeRental.Api/Properties/launchSettings.json b/BikeRental/BikeRental.Api/Properties/launchSettings.json new file mode 100644 index 000000000..52cfc9a3d --- /dev/null +++ b/BikeRental/BikeRental.Api/Properties/launchSettings.json @@ -0,0 +1,22 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5043" + }, + "Container (Dockerfile)": { + "commandName": "Docker", + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", + "environmentVariables": { + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": true, + "useSSL": false + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Api/appsettings.Development.json b/BikeRental/BikeRental.Api/appsettings.Development.json new file mode 100644 index 000000000..72b97deee --- /dev/null +++ b/BikeRental/BikeRental.Api/appsettings.Development.json @@ -0,0 +1,14 @@ +{ + "ConnectionStrings": { + "bike-rental": "server=bike-rental-db;Database=bike-rental;User Id=root;Password=1234512345Aa$;" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning", + "Microsoft": "Error", + "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "None", + "BikeRental": "Information" + } + } +} diff --git a/BikeRental/BikeRental.Api/appsettings.json b/BikeRental/BikeRental.Api/appsettings.json new file mode 100644 index 000000000..bb9d82ad1 --- /dev/null +++ b/BikeRental/BikeRental.Api/appsettings.json @@ -0,0 +1,28 @@ +{ + "ConnectionStrings": { + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft": "Error", + "Microsoft.AspNetCore": "Error", + "Microsoft.EntityFrameworkCore": "Error", + "BikeRental": "Warning" + } + }, + "AllowedHosts": "*", + "NatsConsumerSettings": { + "Url": "nats://localhost:4222", + "StreamName": "bike-rental-stream", + "SubjectName": "bike-rental.leases", + "DurableName": "bike-rental-lease-consumer", + "AckWaitSeconds": 30, + "MaxDeliver": 5, + "ConnectRetryAttempts": 5, + "ConnectRetryDelayMs": 2000, + "RetryBackoffFactor": 2, + "ConsumeMaxMsgs": 100, + "ConsumeExpiresSeconds": 30, + "ConsumeRetryDelayMs": 2000 + } +} diff --git a/BikeRental/BikeRental.Application.Contracts/BikeRental.Application.Contracts.csproj b/BikeRental/BikeRental.Application.Contracts/BikeRental.Application.Contracts.csproj new file mode 100644 index 000000000..e2354ae0c --- /dev/null +++ b/BikeRental/BikeRental.Application.Contracts/BikeRental.Application.Contracts.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + enable + enable + + + + + + diff --git a/BikeRental/BikeRental.Application.Contracts/Dtos/BikeCreateUpdateDto.cs b/BikeRental/BikeRental.Application.Contracts/Dtos/BikeCreateUpdateDto.cs new file mode 100644 index 000000000..7d3f0b25f --- /dev/null +++ b/BikeRental/BikeRental.Application.Contracts/Dtos/BikeCreateUpdateDto.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace BikeRental.Application.Contracts.Dtos; + +public class BikeCreateUpdateDto +{ + /// + /// Bike's serial number + /// + [Required] + [StringLength(50, MinimumLength = 3, ErrorMessage = "Длина SerialNumber должна быть 3-50 символов.")] + public required string SerialNumber { get; set; } + + /// + /// Bike's color + /// + [Required] + [StringLength(20, ErrorMessage = "Макс. длина Color 20 символов.")] + public required string Color { get; set; } + + /// + /// Bike's model + /// + [Required] + [Range(1, int.MaxValue, ErrorMessage = "ModelId должно быть положительное число.")] + public required int ModelId { get; set; } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application.Contracts/Dtos/BikeDto.cs b/BikeRental/BikeRental.Application.Contracts/Dtos/BikeDto.cs new file mode 100644 index 000000000..f4c0ac3c4 --- /dev/null +++ b/BikeRental/BikeRental.Application.Contracts/Dtos/BikeDto.cs @@ -0,0 +1,27 @@ +namespace BikeRental.Application.Contracts.Dtos; + +/// +/// A class describing a bike for rent +/// +public class BikeDto +{ + /// + /// Bike's unique id + /// + public required int Id { get; set; } + + /// + /// Bike's serial number + /// + public required string SerialNumber { get; set; } + + /// + /// Bike's color + /// + public required string Color { get; set; } + + /// + /// Bike's model type + /// + public required string ModelType { get; set; } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application.Contracts/Dtos/BikeModelCreateUpdateDto.cs b/BikeRental/BikeRental.Application.Contracts/Dtos/BikeModelCreateUpdateDto.cs new file mode 100644 index 000000000..66d95ae6a --- /dev/null +++ b/BikeRental/BikeRental.Application.Contracts/Dtos/BikeModelCreateUpdateDto.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using BikeRental.Domain.Enum; + +namespace BikeRental.Application.Contracts.Dtos; + +public class BikeModelCreateUpdateDto +{ + /// + /// The type of bicycle: road, sport, mountain, hybrid + /// + [Required] + public required BikeType Type { get; set; } + + /// + /// The size of the bicycle's wheels + /// + [Required] + [Range(12, 36, ErrorMessage = "Размер колес должен быть 12-36.")] + public required int WheelSize { get; set; } + + /// + /// Maximum permissible cyclist weight + /// + [Required] + [Range(30, 200, ErrorMessage = "Вес человека должен быть 30-200 кг.")] + public required int MaxCyclistWeight { get; set; } + + /// + /// Weight of the bike model + /// + [Required] + [Range(3.0, 50.0, ErrorMessage = "Вес байка 3-50 кг.")] + public required double Weight { get; set; } + + /// + /// The type of braking system used in this model of bike + /// + [Required] + [StringLength(30, ErrorMessage = "Макс. длина 30 символов.")] + public required string BrakeType { get; set; } + + /// + /// Year of manufacture of the bicycle model + /// + [Required] + [RegularExpression(@"^\d{4}$", ErrorMessage = "Год должен быть 4 цифры.")] + public required string YearOfManufacture { get; set; } + + /// + /// Cost per hour rental + /// + [Required] + [Range(0.01, 1000, ErrorMessage = "Цена должна быть > 0.")] + public required decimal RentPrice { get; set; } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application.Contracts/Dtos/BikeModelDto.cs b/BikeRental/BikeRental.Application.Contracts/Dtos/BikeModelDto.cs new file mode 100644 index 000000000..967f65f31 --- /dev/null +++ b/BikeRental/BikeRental.Application.Contracts/Dtos/BikeModelDto.cs @@ -0,0 +1,49 @@ +using BikeRental.Domain.Enum; + +namespace BikeRental.Application.Contracts.Dtos; + +/// +/// A class describing the models of bikes that can be rented +/// +public class BikeModelDto +{ + /// + /// The unique id for bike model + /// + public required int Id { get; set; } + + /// + /// The type of bicycle: road, sport, mountain, hybrid + /// + public required BikeType Type { get; set; } + + /// + /// The size of the bicycle's wheels + /// + public required int WheelSize { get; set; } + + /// + /// Maximum permissible cyclist weight + /// + public required int MaxСyclistWeight { get; set; } + + /// + /// Weight of the bike model + /// + public required double Weight { get; set; } + + /// + /// The type of braking system used in this model of bike + /// + public required string BrakeType { get; set; } + + /// + /// Year of manufacture of the bicycle model + /// + public required string YearOfManufacture { get; set; } + + /// + /// Cost per hour rental + /// + public required decimal RentPrice { get; set; } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application.Contracts/Dtos/LeaseCreateUpdateDto.cs b/BikeRental/BikeRental.Application.Contracts/Dtos/LeaseCreateUpdateDto.cs new file mode 100644 index 000000000..1022041a3 --- /dev/null +++ b/BikeRental/BikeRental.Application.Contracts/Dtos/LeaseCreateUpdateDto.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace BikeRental.Application.Contracts.Dtos; + +public class LeaseCreateUpdateDto +{ + /// + /// Person who rents a bike + /// + [Required] + [Range(1, int.MaxValue, ErrorMessage = "ID человека должно быть положительное число.")] + public required int RenterId { get; set; } + + /// + /// Bike for rent + /// + [Required] + [Range(1, int.MaxValue, ErrorMessage = "ID велика должно быть положительное число.")] + public required int BikeId { get; set; } + + /// + /// Rental start time + /// + [Required] + public required DateTime RentalStartTime { get; set; } + + /// + /// Rental duration in hours + /// + [Required] + [Range(1, int.MaxValue, ErrorMessage = "Время должно быть от часа.")] + public required int RentalDuration { get; set; } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application.Contracts/Dtos/LeaseDto.cs b/BikeRental/BikeRental.Application.Contracts/Dtos/LeaseDto.cs new file mode 100644 index 000000000..f82aed252 --- /dev/null +++ b/BikeRental/BikeRental.Application.Contracts/Dtos/LeaseDto.cs @@ -0,0 +1,32 @@ +namespace BikeRental.Application.Contracts.Dtos; + +/// +/// A class describing a lease agreement +/// +public class LeaseDto +{ + /// + /// Lease ID + /// + public required int Id { get; set; } + + /// + /// Person who rents a bike + /// + public required int RenterId { get; set; } + + /// + /// Bike for rent + /// + public required int BikeId { get; set; } + + /// + /// Rental start time + /// + public required DateTime RentalStartTime { get; set; } + + /// + /// Rental duration in hours + /// + public required int RentalDuration { get; set; } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application.Contracts/Dtos/RenterCreateUpdateDto.cs b/BikeRental/BikeRental.Application.Contracts/Dtos/RenterCreateUpdateDto.cs new file mode 100644 index 000000000..b0114b4e6 --- /dev/null +++ b/BikeRental/BikeRental.Application.Contracts/Dtos/RenterCreateUpdateDto.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace BikeRental.Application.Contracts.Dtos; + +public class RenterCreateUpdateDto +{ + /// + /// Renter's full name + /// + [Required] + [StringLength(100, MinimumLength = 3, ErrorMessage = "Длина 3-100 символов.")] + public required string FullName { get; set; } + + /// + /// Renter's phone number + /// + [Required] + [Phone(ErrorMessage = "Неверный формат телефона.")] + public required string PhoneNumber { get; set; } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application.Contracts/Dtos/RenterDto.cs b/BikeRental/BikeRental.Application.Contracts/Dtos/RenterDto.cs new file mode 100644 index 000000000..87507493a --- /dev/null +++ b/BikeRental/BikeRental.Application.Contracts/Dtos/RenterDto.cs @@ -0,0 +1,22 @@ +namespace BikeRental.Application.Contracts.Dtos; + +/// +/// A class describing a renter +/// +public class RenterDto +{ + /// + /// Renter's id + /// + public required int Id { get; set; } + + /// + /// Renter's full name + /// + public required string FullName { get; set; } + + /// + /// Renter's phone number + /// + public required string PhoneNumber { get; set; } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application/BikeRental.Application.csproj b/BikeRental/BikeRental.Application/BikeRental.Application.csproj new file mode 100644 index 000000000..403374eaf --- /dev/null +++ b/BikeRental/BikeRental.Application/BikeRental.Application.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/BikeRental/BikeRental.Application/Interfaces/IBikeModelService.cs b/BikeRental/BikeRental.Application/Interfaces/IBikeModelService.cs new file mode 100644 index 000000000..3d33f3452 --- /dev/null +++ b/BikeRental/BikeRental.Application/Interfaces/IBikeModelService.cs @@ -0,0 +1,31 @@ +using BikeRental.Application.Contracts.Dtos; + +namespace BikeRental.Application.Interfaces; + +public interface IBikeModelService +{ + /// + /// Returns all bike models. + /// + public Task> GetAll(); + + /// + /// Returns a bike model by id. + /// + public Task GetById(int id); + + /// + /// Creates a new bike model. + /// + public Task Create(BikeModelCreateUpdateDto dto); + + /// + /// Updates an existing bike model. + /// + public Task Update(int id, BikeModelCreateUpdateDto dto); + + /// + /// Deletes a bike model. + /// + public Task Delete(int id); +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application/Interfaces/IBikeService.cs b/BikeRental/BikeRental.Application/Interfaces/IBikeService.cs new file mode 100644 index 000000000..501fd2ff5 --- /dev/null +++ b/BikeRental/BikeRental.Application/Interfaces/IBikeService.cs @@ -0,0 +1,15 @@ +using BikeRental.Application.Contracts.Dtos; + +namespace BikeRental.Application.Interfaces; + +/// +/// Service for managing bikes. +/// +public interface IBikeService +{ + public Task> GetAll(); + public Task GetById(int id); + public Task Create(BikeCreateUpdateDto dto); + public Task Update(int id, BikeCreateUpdateDto dto); + public Task Delete(int id); +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application/Interfaces/ILeaseService.cs b/BikeRental/BikeRental.Application/Interfaces/ILeaseService.cs new file mode 100644 index 000000000..2309dff05 --- /dev/null +++ b/BikeRental/BikeRental.Application/Interfaces/ILeaseService.cs @@ -0,0 +1,15 @@ +using BikeRental.Application.Contracts.Dtos; + +namespace BikeRental.Application.Interfaces; + +/// +/// Service for managing bike leases. +/// +public interface ILeaseService +{ + public Task> GetAll(); + public Task GetById(int id); + public Task Create(LeaseCreateUpdateDto dto); + public Task Update(int id, LeaseCreateUpdateDto dto); + public Task Delete(int id); +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application/Interfaces/IRenterService.cs b/BikeRental/BikeRental.Application/Interfaces/IRenterService.cs new file mode 100644 index 000000000..b3941b982 --- /dev/null +++ b/BikeRental/BikeRental.Application/Interfaces/IRenterService.cs @@ -0,0 +1,15 @@ +using BikeRental.Application.Contracts.Dtos; + +namespace BikeRental.Application.Interfaces; + +/// +/// Service for managing renters. +/// +public interface IRenterService +{ + public Task> GetAll(); + public Task GetById(int id); + public Task Create(RenterCreateUpdateDto dto); + public Task Update(int id, RenterCreateUpdateDto dto); + public Task Delete(int id); +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application/Mappings/BikeMappings.cs b/BikeRental/BikeRental.Application/Mappings/BikeMappings.cs new file mode 100644 index 000000000..a26b27700 --- /dev/null +++ b/BikeRental/BikeRental.Application/Mappings/BikeMappings.cs @@ -0,0 +1,29 @@ +using BikeRental.Application.Contracts.Dtos; +using BikeRental.Domain.Models; + +namespace BikeRental.Application.Mappings; + +internal static class BikeMappings +{ + public static BikeDto ToDto(this Bike entity) + { + return new BikeDto + { + Id = entity.Id, + SerialNumber = entity.SerialNumber, + Color = entity.Color, + ModelType = entity.Model.BrakeType + }; + } + + public static Bike ToEntity(this BikeCreateUpdateDto dto, BikeModel model) + { + return new Bike + { + SerialNumber = dto.SerialNumber, + Color = dto.Color, + ModelId = dto.ModelId, + Model = model + }; + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application/Mappings/BikeModelMappings.cs b/BikeRental/BikeRental.Application/Mappings/BikeModelMappings.cs new file mode 100644 index 000000000..4ea1bf9c3 --- /dev/null +++ b/BikeRental/BikeRental.Application/Mappings/BikeModelMappings.cs @@ -0,0 +1,36 @@ +using BikeRental.Application.Contracts.Dtos; +using BikeRental.Domain.Models; + +namespace BikeRental.Application.Mappings; + +internal static class BikeModelMappings +{ + public static BikeModelDto ToDto(this BikeModel entity) + { + return new BikeModelDto + { + Id = entity.Id, + Type = entity.Type, + WheelSize = entity.WheelSize, + MaxСyclistWeight = entity.MaxCyclistWeight, + Weight = entity.Weight, + BrakeType = entity.BrakeType, + YearOfManufacture = entity.YearOfManufacture, + RentPrice = entity.RentPrice + }; + } + + public static BikeModel ToEntity(this BikeModelCreateUpdateDto dto) + { + return new BikeModel + { + Type = dto.Type, + WheelSize = dto.WheelSize, + MaxCyclistWeight = dto.MaxCyclistWeight, + Weight = dto.Weight, + BrakeType = dto.BrakeType, + YearOfManufacture = dto.YearOfManufacture, + RentPrice = dto.RentPrice + }; + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application/Mappings/LeaseMappings.cs b/BikeRental/BikeRental.Application/Mappings/LeaseMappings.cs new file mode 100644 index 000000000..95e4da1d9 --- /dev/null +++ b/BikeRental/BikeRental.Application/Mappings/LeaseMappings.cs @@ -0,0 +1,32 @@ +using BikeRental.Application.Contracts.Dtos; +using BikeRental.Domain.Models; + +namespace BikeRental.Application.Mappings; + +internal static class LeaseMappings +{ + public static LeaseDto ToDto(this Lease entity) + { + return new LeaseDto + { + Id = entity.Id, + BikeId = entity.Bike.Id, + RenterId = entity.Renter.Id, + RentalStartTime = entity.RentalStartTime, + RentalDuration = entity.RentalDuration + }; + } + + public static Lease ToEntity(this LeaseCreateUpdateDto dto, Bike bike, Renter renter) + { + return new Lease + { + BikeId = bike.Id, + RenterId = renter.Id, + Bike = bike, + Renter = renter, + RentalStartTime = dto.RentalStartTime, + RentalDuration = dto.RentalDuration + }; + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application/Mappings/RenterMappings.cs b/BikeRental/BikeRental.Application/Mappings/RenterMappings.cs new file mode 100644 index 000000000..acfe644e3 --- /dev/null +++ b/BikeRental/BikeRental.Application/Mappings/RenterMappings.cs @@ -0,0 +1,26 @@ +using BikeRental.Application.Contracts.Dtos; +using BikeRental.Domain.Models; + +namespace BikeRental.Application.Mappings; + +internal static class RenterMappings +{ + public static RenterDto ToDto(this Renter entity) + { + return new RenterDto + { + Id = entity.Id, + FullName = entity.FullName, + PhoneNumber = entity.PhoneNumber + }; + } + + public static Renter ToEntity(this RenterCreateUpdateDto dto) + { + return new Renter + { + FullName = dto.FullName, + PhoneNumber = dto.PhoneNumber + }; + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application/Services/BikeModelService.cs b/BikeRental/BikeRental.Application/Services/BikeModelService.cs new file mode 100644 index 000000000..fc3dfdeec --- /dev/null +++ b/BikeRental/BikeRental.Application/Services/BikeModelService.cs @@ -0,0 +1,62 @@ +using BikeRental.Application.Contracts.Dtos; +using BikeRental.Application.Interfaces; +using BikeRental.Application.Mappings; +using BikeRental.Domain.Interfaces; +using BikeRental.Domain.Models; + +namespace BikeRental.Application.Services; + +/// +/// Application-сервис для работы с моделями велосипедов. Инкапсулирует бизнес-логику и доступ к репозиторию. +/// +public sealed class BikeModelService(IBikeModelRepository bikeModelRepository) : IBikeModelService +{ + public async Task> GetAll() + { + return (await bikeModelRepository.GetAll()).Select(bm => bm.ToDto()); + } + + public async Task GetById(int id) + { + return (await bikeModelRepository.GetById(id))?.ToDto(); + } + + public async Task Create(BikeModelCreateUpdateDto dto) + { + var id = await bikeModelRepository.Add(dto.ToEntity()); + if (id > 0) + { + BikeModel? createdEntity = await bikeModelRepository.GetById(id); + if (createdEntity != null) + { + return createdEntity.ToDto(); + } + } + + throw new InvalidOperationException("Failed to create entity."); + } + + public async Task Update(int id, BikeModelCreateUpdateDto dto) + { + _ = await bikeModelRepository.GetById(id) + ?? throw new KeyNotFoundException($"Entity with id {id} not found."); + + BikeModel entityToUpdate = dto.ToEntity(); + entityToUpdate.Id = id; + await bikeModelRepository.Update(entityToUpdate); + BikeModel? updatedEntity = await bikeModelRepository.GetById(id); + return updatedEntity!.ToDto(); + } + + public async Task Delete(int id) + { + BikeModel? entity = await bikeModelRepository.GetById(id); + if (entity == null) + { + return false; + } + + await bikeModelRepository.Delete(entity); + return true; + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application/Services/BikeService.cs b/BikeRental/BikeRental.Application/Services/BikeService.cs new file mode 100644 index 000000000..b67a3f384 --- /dev/null +++ b/BikeRental/BikeRental.Application/Services/BikeService.cs @@ -0,0 +1,70 @@ +using BikeRental.Application.Contracts.Dtos; +using BikeRental.Application.Interfaces; +using BikeRental.Application.Mappings; +using BikeRental.Domain.Interfaces; +using BikeRental.Domain.Models; + +namespace BikeRental.Application.Services; + +/// +/// Application-сервис для работы с велосипедами. Инкапсулирует бизнес-логику и доступ к репозиторию. +/// На текущем этапе является тонкой обёрткой над IBikeRepository. +/// +public sealed class BikeService(IBikeRepository bikeRepository, IBikeModelRepository modelRepository) : IBikeService +{ + public async Task> GetAll() + { + return (await bikeRepository.GetAll()).Select(b => b.ToDto()); + } + + public async Task GetById(int id) + { + return (await bikeRepository.GetById(id))?.ToDto(); + } + + public async Task Create(BikeCreateUpdateDto dto) + { + BikeModel model = await modelRepository.GetById(dto.ModelId) + ?? throw new ArgumentException($"Model with id {dto.ModelId} not found."); + + var id = await bikeRepository.Add(dto.ToEntity(model)); + + if (id > 0) + { + Bike? createdEntity = await bikeRepository.GetById(id); + if (createdEntity != null) + { + return createdEntity.ToDto(); + } + } + + throw new InvalidOperationException("Failed to create entity."); + } + + public async Task Update(int id, BikeCreateUpdateDto dto) + { + _ = await bikeRepository.GetById(id) + ?? throw new KeyNotFoundException($"Bike with id {id} not found."); + + BikeModel model = await modelRepository.GetById(dto.ModelId) + ?? throw new ArgumentException($"Model with id {dto.ModelId} not found."); + + Bike entityToUpdate = dto.ToEntity(model); + entityToUpdate.Id = id; + await bikeRepository.Update(entityToUpdate); + Bike? updatedEntity = await bikeRepository.GetById(id); + return updatedEntity!.ToDto(); + } + + public async Task Delete(int id) + { + Bike? entity = await bikeRepository.GetById(id); + if (entity == null) + { + return false; + } + + await bikeRepository.Delete(entity); + return true; + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application/Services/LeaseService.cs b/BikeRental/BikeRental.Application/Services/LeaseService.cs new file mode 100644 index 000000000..fbe567c12 --- /dev/null +++ b/BikeRental/BikeRental.Application/Services/LeaseService.cs @@ -0,0 +1,79 @@ +using BikeRental.Application.Contracts.Dtos; +using BikeRental.Application.Interfaces; +using BikeRental.Application.Mappings; +using BikeRental.Domain.Interfaces; +using BikeRental.Domain.Models; + +namespace BikeRental.Application.Services; + +/// +/// Application-сервис для работы с договорами аренды велосипедов. +/// Инкапсулирует бизнес-логику и доступ к репозиторию. +/// На текущем этапе является тонкой обёрткой над ILeaseRepository. +/// +public sealed class LeaseService( + ILeaseRepository leaseRepository, + IBikeRepository bikeRepository, + IRenterRepository renterRepository) : ILeaseService +{ + public async Task> GetAll() + { + return (await leaseRepository.GetAll()).Select(l => l.ToDto()); + } + + public async Task GetById(int id) + { + return (await leaseRepository.GetById(id))?.ToDto(); + } + + public async Task Create(LeaseCreateUpdateDto dto) + { + Bike bike = await bikeRepository.GetById(dto.BikeId) + ?? throw new ArgumentException($"Bike with id {dto.BikeId} not found."); + + Renter renter = await renterRepository.GetById(dto.RenterId) + ?? throw new ArgumentException($"Renter with id {dto.RenterId} not found."); + + var id = await leaseRepository.Add(dto.ToEntity(bike, renter)); + if (id > 0) + { + Lease? createdEntity = await leaseRepository.GetById(id); + if (createdEntity != null) + { + return createdEntity.ToDto(); + } + } + + throw new InvalidOperationException("Failed to create entity."); + } + + public async Task Update(int id, LeaseCreateUpdateDto dto) + { + _ = await leaseRepository.GetById(id) + ?? throw new KeyNotFoundException($"Lease with id {id} not found."); + + Bike bike = await bikeRepository.GetById(dto.BikeId) + ?? throw new ArgumentException($"Bike with id {dto.BikeId} not found."); + + Renter renter = await renterRepository.GetById(dto.RenterId) + ?? throw new ArgumentException($"Renter with id {dto.RenterId} not found."); + + Lease entityToUpdate = dto.ToEntity(bike, renter); + entityToUpdate.Id = id; + await leaseRepository.Update(entityToUpdate); + Lease? updatedEntity = await leaseRepository.GetById(id); + return updatedEntity!.ToDto(); + } + + public async Task Delete(int id) + { + Lease? entity = await leaseRepository.GetById(id); + if (entity == null) + { + return false; + } + + await leaseRepository.Delete(entity); + return true; + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Application/Services/RenterService.cs b/BikeRental/BikeRental.Application/Services/RenterService.cs new file mode 100644 index 000000000..8912db5bf --- /dev/null +++ b/BikeRental/BikeRental.Application/Services/RenterService.cs @@ -0,0 +1,63 @@ +using BikeRental.Application.Contracts.Dtos; +using BikeRental.Application.Interfaces; +using BikeRental.Application.Mappings; +using BikeRental.Domain.Interfaces; +using BikeRental.Domain.Models; + +namespace BikeRental.Application.Services; + +/// +/// Application-сервис для работы с арендаторами. Инкапсулирует бизнес-логику и доступ к репозиторию. +/// На текущем этапе является тонкой обёрткой над IRenterRepository. +/// +public sealed class RenterService(IRenterRepository renterRepository) : IRenterService +{ + public async Task> GetAll() + { + return (await renterRepository.GetAll()).Select(r => r.ToDto()); + } + + public async Task GetById(int id) + { + return (await renterRepository.GetById(id))?.ToDto(); + } + + public async Task Create(RenterCreateUpdateDto dto) + { + var id = await renterRepository.Add(dto.ToEntity()); + if (id > 0) + { + Renter? createdEntity = await renterRepository.GetById(id); + if (createdEntity != null) + { + return createdEntity.ToDto(); + } + } + + throw new InvalidOperationException("Failed to create entity."); + } + + public async Task Update(int id, RenterCreateUpdateDto dto) + { + _ = await renterRepository.GetById(id) + ?? throw new KeyNotFoundException($"Entity with id {id} not found."); + + Renter entityToUpdate = dto.ToEntity(); + entityToUpdate.Id = id; + await renterRepository.Update(entityToUpdate); + Renter? updatedEntity = await renterRepository.GetById(id); + return updatedEntity!.ToDto(); + } + + public async Task Delete(int id) + { + Renter? entity = await renterRepository.GetById(id); + if (entity == null) + { + return false; + } + + await renterRepository.Delete(entity); + return true; + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Domain/BikeRental.Domain.csproj b/BikeRental/BikeRental.Domain/BikeRental.Domain.csproj new file mode 100644 index 000000000..3a6353295 --- /dev/null +++ b/BikeRental/BikeRental.Domain/BikeRental.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/BikeRental/BikeRental.Domain/Enum/BikeType.cs b/BikeRental/BikeRental.Domain/Enum/BikeType.cs new file mode 100644 index 000000000..593baac91 --- /dev/null +++ b/BikeRental/BikeRental.Domain/Enum/BikeType.cs @@ -0,0 +1,27 @@ +namespace BikeRental.Domain.Enum; + +/// +/// A class describing the types of bikes that can be rented +/// +public enum BikeType +{ + /// + /// Road bike + /// + Road, + + /// + /// Sports bike + /// + Sport, + + /// + /// Mountain bike + /// + Mountain, + + /// + /// Hybrid bike - a bicycle that combines the qualities of a mountain bike and a road bike + /// + Hybrid +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Domain/Interfaces/IBikeModelRepository.cs b/BikeRental/BikeRental.Domain/Interfaces/IBikeModelRepository.cs new file mode 100644 index 000000000..e5ee0c9d7 --- /dev/null +++ b/BikeRental/BikeRental.Domain/Interfaces/IBikeModelRepository.cs @@ -0,0 +1,8 @@ +using BikeRental.Domain.Models; + +namespace BikeRental.Domain.Interfaces; + +/// +/// Интерфейс репозитория описывает контракт для работы с моделями велосипедов +/// +public interface IBikeModelRepository : IRepository; \ No newline at end of file diff --git a/BikeRental/BikeRental.Domain/Interfaces/IBikeRepository.cs b/BikeRental/BikeRental.Domain/Interfaces/IBikeRepository.cs new file mode 100644 index 000000000..97cdc72b8 --- /dev/null +++ b/BikeRental/BikeRental.Domain/Interfaces/IBikeRepository.cs @@ -0,0 +1,8 @@ +using BikeRental.Domain.Models; + +namespace BikeRental.Domain.Interfaces; + +/// +/// Интерфейс репозитория описывает контракт для работы с велосипедами +/// +public interface IBikeRepository : IRepository; \ No newline at end of file diff --git a/BikeRental/BikeRental.Domain/Interfaces/ILeaseRepository.cs b/BikeRental/BikeRental.Domain/Interfaces/ILeaseRepository.cs new file mode 100644 index 000000000..cdd3a5155 --- /dev/null +++ b/BikeRental/BikeRental.Domain/Interfaces/ILeaseRepository.cs @@ -0,0 +1,8 @@ +using BikeRental.Domain.Models; + +namespace BikeRental.Domain.Interfaces; + +/// +/// Интерфейс репозитория описывает контракт для работы с договорами на аренду велосипедов +/// +public interface ILeaseRepository : IRepository; \ No newline at end of file diff --git a/BikeRental/BikeRental.Domain/Interfaces/IRenterRepository.cs b/BikeRental/BikeRental.Domain/Interfaces/IRenterRepository.cs new file mode 100644 index 000000000..d0720e85b --- /dev/null +++ b/BikeRental/BikeRental.Domain/Interfaces/IRenterRepository.cs @@ -0,0 +1,8 @@ +using BikeRental.Domain.Models; + +namespace BikeRental.Domain.Interfaces; + +/// +/// Интерфейс репозитория описывает контракт для работы с арендаторами +/// +public interface IRenterRepository : IRepository; \ No newline at end of file diff --git a/BikeRental/BikeRental.Domain/Interfaces/IRepository.cs b/BikeRental/BikeRental.Domain/Interfaces/IRepository.cs new file mode 100644 index 000000000..6423f5af1 --- /dev/null +++ b/BikeRental/BikeRental.Domain/Interfaces/IRepository.cs @@ -0,0 +1,33 @@ +namespace BikeRental.Domain.Interfaces; + +/// +/// Generic repository interface that defines basic CRUD operations. +/// +public interface IRepository + where TEntity : class +{ + /// + /// Returns all entities. + /// + public Task> GetAll(); + + /// + /// Returns entity by id. + /// + public Task GetById(int id); + + /// + /// Adds a new entity and returns its generated id. + /// + public Task Add(TEntity entity); + + /// + /// Updates existing entity. + /// + public Task Update(TEntity entity); + + /// + /// Deletes existing entity. + /// + public Task Delete(TEntity entity); +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Domain/Models/Bike.cs b/BikeRental/BikeRental.Domain/Models/Bike.cs new file mode 100644 index 000000000..6df10dd3c --- /dev/null +++ b/BikeRental/BikeRental.Domain/Models/Bike.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace BikeRental.Domain.Models; + +/// +/// A class describing a bike for rent +/// +public class Bike +{ + /// + /// Bike's unique id + /// + public int Id { get; set; } + + [ForeignKey(nameof(Model))] public required int ModelId { get; set; } + + /// + /// Bike's serial number + /// + public required string SerialNumber { get; set; } + + /// + /// Bike's color + /// + public required string Color { get; set; } + + /// + /// Bike's model + /// + public required BikeModel Model { get; init; } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Domain/Models/BikeModel.cs b/BikeRental/BikeRental.Domain/Models/BikeModel.cs new file mode 100644 index 000000000..209d2e311 --- /dev/null +++ b/BikeRental/BikeRental.Domain/Models/BikeModel.cs @@ -0,0 +1,49 @@ +using BikeRental.Domain.Enum; + +namespace BikeRental.Domain.Models; + +/// +/// A class describing the models of bikes that can be rented +/// +public class BikeModel +{ + /// + /// The unique id for bike model + /// + public int Id { get; set; } + + /// + /// The type of bicycle: road, sport, mountain, hybrid + /// + public required BikeType Type { get; set; } + + /// + /// The size of the bicycle's wheels + /// + public required int WheelSize { get; set; } + + /// + /// Maximum permissible cyclist weight + /// + public required int MaxCyclistWeight { get; set; } + + /// + /// Weight of the bike model + /// + public required double Weight { get; set; } + + /// + /// The type of braking system used in this model of bike + /// + public required string BrakeType { get; set; } + + /// + /// Year of manufacture of the bicycle model + /// + public required string YearOfManufacture { get; set; } + + /// + /// Cost per hour rental + /// + public required decimal RentPrice { get; set; } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Domain/Models/Lease.cs b/BikeRental/BikeRental.Domain/Models/Lease.cs new file mode 100644 index 000000000..dea90ee75 --- /dev/null +++ b/BikeRental/BikeRental.Domain/Models/Lease.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace BikeRental.Domain.Models; + +/// +/// A class describing a lease agreement +/// +public class Lease +{ + /// + /// Lease ID + /// + public int Id { get; set; } + + [ForeignKey(nameof(Bike))] public int BikeId { get; set; } + + [ForeignKey(nameof(Renter))] public int RenterId { get; set; } + + /// + /// Rental start time + /// + public required DateTime RentalStartTime { get; set; } + + /// + /// Rental duration in hours + /// + public required int RentalDuration { get; set; } + + /// + /// Person who rents a bike + /// + public required Renter Renter { get; set; } + + /// + /// Bike for rent + /// + public required Bike Bike { get; set; } + // сделала required тогда их айди автоматически должны установиться EF core +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Domain/Models/Renter.cs b/BikeRental/BikeRental.Domain/Models/Renter.cs new file mode 100644 index 000000000..e3acb0ad5 --- /dev/null +++ b/BikeRental/BikeRental.Domain/Models/Renter.cs @@ -0,0 +1,22 @@ +namespace BikeRental.Domain.Models; + +/// +/// A class describing a renter +/// +public class Renter +{ + /// + /// Renter's id + /// + public int Id { get; set; } + + /// + /// Renter's full name + /// + public required string FullName { get; set; } + + /// + /// Renter's phone number + /// + public required string PhoneNumber { get; set; } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Generator.Nats.Host/BikeRental.Generator.Nats.Host.csproj b/BikeRental/BikeRental.Generator.Nats.Host/BikeRental.Generator.Nats.Host.csproj new file mode 100644 index 000000000..1a06d9934 --- /dev/null +++ b/BikeRental/BikeRental.Generator.Nats.Host/BikeRental.Generator.Nats.Host.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + dotnet-BikeRental.Generator.Nats.Host-bb308bf8-660f-4f89-810e-494ba2f57c24 + + + + + + + + + + + + + diff --git a/BikeRental/BikeRental.Generator.Nats.Host/BikeRentalNatsProducer.cs b/BikeRental/BikeRental.Generator.Nats.Host/BikeRentalNatsProducer.cs new file mode 100644 index 000000000..9e7363ac2 --- /dev/null +++ b/BikeRental/BikeRental.Generator.Nats.Host/BikeRentalNatsProducer.cs @@ -0,0 +1,147 @@ +using System.Text.Json; +using BikeRental.Application.Contracts.Dtos; +using Microsoft.Extensions.Options; +using NATS.Client.Core; +using NATS.Client.JetStream; +using NATS.Client.JetStream.Models; +using NATS.Net; + +namespace BikeRental.Generator.Nats.Host; + +public sealed class BikeRentalNatsProducer( + IOptions settings, + INatsConnection connection, + ILogger logger) +{ + // для настройки повторных попыток - ретраи + private readonly int _connectRetryAttempts = Math.Max(1, settings.Value.ConnectRetryAttempts); + + private readonly TimeSpan _connectRetryDelay = + TimeSpan.FromMilliseconds(Math.Max(0, settings.Value.ConnectRetryDelayMs)); + + private readonly int _publishRetryAttempts = Math.Max(1, settings.Value.PublishRetryAttempts); + + private readonly TimeSpan _publishRetryDelay = + TimeSpan.FromMilliseconds(Math.Max(0, settings.Value.PublishRetryDelayMs)); + + private readonly double _retryBackoffFactor = + settings.Value.RetryBackoffFactor <= 0 ? 2 : settings.Value.RetryBackoffFactor; + + private readonly string _streamName = GetRequired(settings.Value.StreamName, "StreamName"); + private readonly string _subjectName = GetRequired(settings.Value.SubjectName, "SubjectName"); + + + public async Task SendAsync(IList batch, CancellationToken cancellationToken) + { + if (batch.Count == 0) + { + logger.LogInformation("Skipping empty lease batch."); + return; + } + + try + { + // await connection.ConnectAsync(); + // вызов с повторными попытками + await ExecuteWithRetryAsync( + "connect to NATS", + _connectRetryAttempts, // сколько раз пытаться + _connectRetryDelay, // начальная задержка + async () => await connection.ConnectAsync(), + cancellationToken); + + INatsJSContext context = connection.CreateJetStreamContext(); + + var streamConfig = new StreamConfig(_streamName, [_subjectName]); + + + // await context.CreateOrUpdateStreamAsync(streamConfig, cancellationToken); + await ExecuteWithRetryAsync( + "create/update stream", + _publishRetryAttempts, + _publishRetryDelay, + async () => await context.CreateOrUpdateStreamAsync(streamConfig, cancellationToken), + cancellationToken); + + var payload = JsonSerializer.SerializeToUtf8Bytes(batch); + + // await context.PublishAsync(subject: _subjectName, data: payload, cancellationToken: cancellationToken); + await ExecuteWithRetryAsync( + "publish batch", + _publishRetryAttempts, + _publishRetryDelay, + async () => await context.PublishAsync( + _subjectName, + payload, + cancellationToken: cancellationToken), + cancellationToken); + + logger.LogInformation( + "Sent a batch of {count} leases to {subject} of {stream}", + batch.Count, _subjectName, _streamName); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Exception occurred during sending a batch of {count} leases to {stream}/{subject}", + batch.Count, _streamName, _subjectName); + } + } + + // механизм повторных попыток + private async Task ExecuteWithRetryAsync( + string operation, + int attempts, // Максимальное количество попыток + TimeSpan baseDelay, // Начальная задержка + Func action, // Операция для выполнения + CancellationToken cancellationToken) // Токен отмены + { + TimeSpan delay = baseDelay; + for (var attempt = 1; attempt <= attempts; attempt++) + { + try + { + await action(); + return; + } + catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + { + // есть еще попытки, операцию не отменили + if (delay > TimeSpan.Zero) + { + logger.LogWarning( + ex, + "Failed to {operation} (attempt {attempt}/{attempts}). Retrying in {delay}.", + operation, + attempt, + attempts, + delay); + await Task.Delay(delay, cancellationToken); + + // увеличить задержку + delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * _retryBackoffFactor); + } + else + { + logger.LogWarning( + ex, + "Failed to {operation} (attempt {attempt}/{attempts}). Retrying immediately.", + operation, + attempt, + attempts); + } + } + } + } + + private static string GetRequired(string? value, string key) //мини проверка конфигов + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new KeyNotFoundException($"{key} is not configured in Nats section."); + } + + return value; + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Generator.Nats.Host/Generator/LeaseBatchGenerator.cs b/BikeRental/BikeRental.Generator.Nats.Host/Generator/LeaseBatchGenerator.cs new file mode 100644 index 000000000..b9b66501b --- /dev/null +++ b/BikeRental/BikeRental.Generator.Nats.Host/Generator/LeaseBatchGenerator.cs @@ -0,0 +1,78 @@ +using BikeRental.Application.Contracts.Dtos; +using Bogus; + +namespace BikeRental.Generator.Nats.Host.Generator; + +public sealed class LeaseBatchGenerator +{ + public static IList GenerateBatch(LeaseGenerationOptions settings) + { + Validate(settings); + + Faker faker = CreateFaker(settings); + return faker.Generate(settings.BatchSize); + } + + private static Faker CreateFaker( + LeaseGenerationOptions settings) + { + return new Faker() + .RuleFor(x => x.RenterId, f => + f.Random.Int(settings.RenterIdMin, settings.RenterIdMax)) + .RuleFor(x => x.BikeId, f => + f.Random.Int(settings.BikeIdMin, settings.BikeIdMax)) + .RuleFor(x => x.RentalDuration, f => + f.Random.Int( + settings.RentalDurationMinHours, + settings.RentalDurationMaxHours)) + .RuleFor(x => x.RentalStartTime, f => + GeneratePastStartTime( + settings.RentalStartDaysBackMax, + f)); + } + + private static DateTime GeneratePastStartTime( + int maxDaysBack, + Faker f) + { + var daysBack = f.Random.Int(0, maxDaysBack); + var hoursBack = f.Random.Int(0, 23); + + return DateTime.UtcNow + .AddDays(-daysBack) + .AddHours(-hoursBack); + } + + private static void Validate(LeaseGenerationOptions settings) + { + if (settings.BatchSize <= 0) + { + throw new InvalidOperationException("BatchSize must be > 0."); + } + + if (settings.BikeIdMin > settings.BikeIdMax) + { + throw new InvalidOperationException( + "BikeIdMin must be <= BikeIdMax."); + } + + if (settings.RenterIdMin > settings.RenterIdMax) + { + throw new InvalidOperationException( + "RenterIdMin must be <= RenterIdMax."); + } + + if (settings.RentalDurationMinHours > + settings.RentalDurationMaxHours) + { + throw new InvalidOperationException( + "RentalDurationMinHours must be <= RentalDurationMaxHours."); + } + + if (settings.RentalStartDaysBackMax < 0) + { + throw new InvalidOperationException( + "RentalStartDaysBackMax must be >= 0."); + } + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Generator.Nats.Host/Generator/LeaseGenerationOptions.cs b/BikeRental/BikeRental.Generator.Nats.Host/Generator/LeaseGenerationOptions.cs new file mode 100644 index 000000000..cadccb3a6 --- /dev/null +++ b/BikeRental/BikeRental.Generator.Nats.Host/Generator/LeaseGenerationOptions.cs @@ -0,0 +1,14 @@ +namespace BikeRental.Generator.Nats.Host.Generator; + +public sealed class LeaseGenerationOptions +{ + public int BatchSize { get; init; } = 10; + public int IntervalSeconds { get; init; } = 30; + public int BikeIdMin { get; init; } = 1; + public int BikeIdMax { get; init; } = 30; + public int RenterIdMin { get; init; } = 1; + public int RenterIdMax { get; init; } = 20; + public int RentalDurationMinHours { get; init; } = 1; + public int RentalDurationMaxHours { get; init; } = 72; + public int RentalStartDaysBackMax { get; init; } = 10; +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Generator.Nats.Host/LeaseBatchWorker.cs b/BikeRental/BikeRental.Generator.Nats.Host/LeaseBatchWorker.cs new file mode 100644 index 000000000..f24215af2 --- /dev/null +++ b/BikeRental/BikeRental.Generator.Nats.Host/LeaseBatchWorker.cs @@ -0,0 +1,39 @@ +using BikeRental.Application.Contracts.Dtos; +using BikeRental.Generator.Nats.Host.Generator; +using Microsoft.Extensions.Options; + +namespace BikeRental.Generator.Nats.Host; + +public sealed class LeaseBatchWorker( + BikeRentalNatsProducer producer, + IOptions options, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + LeaseGenerationOptions settings = options.Value; + if (settings.BatchSize <= 0) + { + logger.LogError("LeaseGeneration.BatchSize must be greater than 0."); + return; + } + + if (settings.IntervalSeconds <= 0) + { + await SendBatchAsync(settings, stoppingToken); + return; + } + + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(settings.IntervalSeconds)); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await SendBatchAsync(settings, stoppingToken); + } + } + + private async Task SendBatchAsync(LeaseGenerationOptions settings, CancellationToken stoppingToken) + { + IList batch = LeaseBatchGenerator.GenerateBatch(settings); + await producer.SendAsync(batch, stoppingToken); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Generator.Nats.Host/NatsSettings.cs b/BikeRental/BikeRental.Generator.Nats.Host/NatsSettings.cs new file mode 100644 index 000000000..303b92fa8 --- /dev/null +++ b/BikeRental/BikeRental.Generator.Nats.Host/NatsSettings.cs @@ -0,0 +1,16 @@ +namespace BikeRental.Generator.Nats.Host; + +/// +/// Класс для типизированной конфигурации +/// +public sealed class NatsSettings +{ + public string Url { get; init; } = "nats://localhost:4222"; + public string StreamName { get; init; } = string.Empty; + public string SubjectName { get; init; } = string.Empty; + public int ConnectRetryAttempts { get; init; } = 5; + public int ConnectRetryDelayMs { get; init; } = 2000; + public int PublishRetryAttempts { get; init; } = 3; + public int PublishRetryDelayMs { get; init; } = 1000; + public double RetryBackoffFactor { get; init; } = 2; +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Generator.Nats.Host/Program.cs b/BikeRental/BikeRental.Generator.Nats.Host/Program.cs new file mode 100644 index 000000000..e01c9a705 --- /dev/null +++ b/BikeRental/BikeRental.Generator.Nats.Host/Program.cs @@ -0,0 +1,45 @@ +using BikeRental.Generator.Nats.Host; +using BikeRental.Generator.Nats.Host.Generator; +using Microsoft.Extensions.Options; +using NATS.Client.Core; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + + +IConfigurationSection natsSettingsSection = builder.Configuration.GetSection("NatsSettings"); + +if (natsSettingsSection.Exists()) +{ + Console.WriteLine($"Nats.Url: {natsSettingsSection["Url"]}"); + Console.WriteLine($"Nats.StreamName: {natsSettingsSection["StreamName"]}"); +} + +builder.Services.Configure(builder.Configuration.GetSection("NatsSettings")); +builder.Services.Configure(builder.Configuration.GetSection("LeaseGeneration")); + +builder.Services.AddSingleton(sp => +{ + NatsSettings settings = sp.GetRequiredService>().Value; + var connectRetryDelayMs = Math.Max(0, settings.ConnectRetryDelayMs); + var reconnectWaitMin = TimeSpan.FromMilliseconds(connectRetryDelayMs); + var reconnectWaitMax = TimeSpan.FromMilliseconds( + Math.Max(connectRetryDelayMs, connectRetryDelayMs * Math.Max(settings.RetryBackoffFactor, 1))); + + var options = new NatsOpts + { + Url = settings.Url, + RetryOnInitialConnect = settings.ConnectRetryAttempts > 1, + MaxReconnectRetry = Math.Max(0, settings.ConnectRetryAttempts), + ReconnectWaitMin = reconnectWaitMin, + ReconnectWaitMax = reconnectWaitMax, + ReconnectJitter = TimeSpan.FromMilliseconds(connectRetryDelayMs * 0.2) + }; + return new NatsConnection(options); +}); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +IHost host = builder.Build(); +await host.RunAsync(); \ No newline at end of file diff --git a/BikeRental/BikeRental.Generator.Nats.Host/Properties/launchSettings.json b/BikeRental/BikeRental.Generator.Nats.Host/Properties/launchSettings.json new file mode 100644 index 000000000..f7da70958 --- /dev/null +++ b/BikeRental/BikeRental.Generator.Nats.Host/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "BikeRental.Generator.Nats.Host": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/BikeRental/BikeRental.Generator.Nats.Host/appsettings.Development.json b/BikeRental/BikeRental.Generator.Nats.Host/appsettings.Development.json new file mode 100644 index 000000000..b2dcdb674 --- /dev/null +++ b/BikeRental/BikeRental.Generator.Nats.Host/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/BikeRental/BikeRental.Generator.Nats.Host/appsettings.json b/BikeRental/BikeRental.Generator.Nats.Host/appsettings.json new file mode 100644 index 000000000..fcc2ddf05 --- /dev/null +++ b/BikeRental/BikeRental.Generator.Nats.Host/appsettings.json @@ -0,0 +1,32 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "NatsSettings": { + "Url": "nats://localhost:4222", + "StreamName": "bike-rental-stream", + "SubjectName": "bike-rental.leases", + "ConnectRetryAttempts": 5, + "ConnectRetryDelayMs": 2000, + "PublishRetryAttempts": 3, + "PublishRetryDelayMs": 1000, + "RetryBackoffFactor": 2 + }, + "LeaseGeneration": { + "BatchSize": 10, + "IntervalSeconds": 0, + "BikeIdMin": 28, + "BikeIdMax": 30, + "RenterIdMin": 18, + "RenterIdMax": 20, + "RentalDurationMinHours": 1, + "RentalDurationMaxHours": 72, + "RentalStartDaysBackMax": 10, + "BikeIds": [], + "RenterIds": [], + "LogBatchSampleCount": 0 + } +} diff --git a/BikeRental/BikeRental.Infrastructure/BikeRental.Infrastructure.csproj b/BikeRental/BikeRental.Infrastructure/BikeRental.Infrastructure.csproj new file mode 100644 index 000000000..cb259a19e --- /dev/null +++ b/BikeRental/BikeRental.Infrastructure/BikeRental.Infrastructure.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/BikeRental/BikeRental.Infrastructure/Database/ApplicationDbContext.cs b/BikeRental/BikeRental.Infrastructure/Database/ApplicationDbContext.cs new file mode 100644 index 000000000..811cb6e05 --- /dev/null +++ b/BikeRental/BikeRental.Infrastructure/Database/ApplicationDbContext.cs @@ -0,0 +1,37 @@ +using BikeRental.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace BikeRental.Infrastructure.Database; + +/// +/// Контекст базы данных приложения +/// +/// +public sealed class ApplicationDbContext(DbContextOptions options) : DbContext(options) +{ + /// + /// Набор сущностей "BikeModel" (Модель велосипеда) + /// + public DbSet BikeModels { get; set; } + + /// + /// Набор сущностей "Bike" (Велосипед) + /// + public DbSet Bikes { get; set; } + + /// + /// Набор сущностей "Renter" (Арендатор) + /// + public DbSet Renters { get; set; } + + /// + /// Набор сущностей "Lease" (Договор аренды) + /// + public DbSet Leases { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Применить конфигурации из текущей сборки + modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Infrastructure/Database/Configurations/BikeConfiguration.cs b/BikeRental/BikeRental.Infrastructure/Database/Configurations/BikeConfiguration.cs new file mode 100644 index 000000000..e38e019a6 --- /dev/null +++ b/BikeRental/BikeRental.Infrastructure/Database/Configurations/BikeConfiguration.cs @@ -0,0 +1,43 @@ +using BikeRental.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BikeRental.Infrastructure.Database.Configurations; + +/// +/// Конфигурация сущности "Bike" +/// +public class BikeConfiguration : IEntityTypeConfiguration +{ + /// + /// Настройка сущности "Bike" + /// + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Bikes"); + + builder.HasKey(b => b.Id); + builder.Property(b => b.Id) + .ValueGeneratedOnAdd(); + + builder.Property(b => b.SerialNumber) + .IsRequired() + .HasMaxLength(64); + + builder.Property(b => b.Color) + .IsRequired() + .HasMaxLength(32); + + builder.Property(b => b.ModelId) + .IsRequired(); + + builder.HasOne(b => b.Model) + .WithMany() + .HasForeignKey(b => b.ModelId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(b => b.SerialNumber) + .IsUnique(); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Infrastructure/Database/Configurations/BikeModelConfiguration.cs b/BikeRental/BikeRental.Infrastructure/Database/Configurations/BikeModelConfiguration.cs new file mode 100644 index 000000000..51405335e --- /dev/null +++ b/BikeRental/BikeRental.Infrastructure/Database/Configurations/BikeModelConfiguration.cs @@ -0,0 +1,55 @@ +using BikeRental.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BikeRental.Infrastructure.Database.Configurations; + +/// +/// Конфигурация сущности "BikeModel" +/// +public class BikeModelConfiguration : IEntityTypeConfiguration +{ + /// + /// Настройка сущности "BikeModel" + /// + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("BikeModels"); + + builder.HasKey(b => b.Id); + builder.Property(b => b.Id) + .ValueGeneratedOnAdd(); + + builder.Property(b => b.Type) + .IsRequired(); + + builder.Property(b => b.WheelSize) + .IsRequired(); + + builder.Property(b => b.MaxCyclistWeight) + .IsRequired(); + + builder.Property(b => b.Weight) + .IsRequired(); + + builder.Property(b => b.BrakeType) + .IsRequired() + .HasMaxLength(50); + + builder.Property(b => b.YearOfManufacture) + .IsRequired() + .HasMaxLength(4); + + builder.Property(b => b.RentPrice) + .IsRequired() + .HasColumnType("decimal(10,2)"); + + // Индексы для типичных сценариев выборки + + // Индекс по типу велосипеда + builder.HasIndex(b => b.Type); + // Индекс по комбинации типа велосипеда и размера колеса + builder.HasIndex(b => new { b.Type, b.WheelSize }); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Infrastructure/Database/Configurations/LeaseConfiguration.cs b/BikeRental/BikeRental.Infrastructure/Database/Configurations/LeaseConfiguration.cs new file mode 100644 index 000000000..2fdb8e89b --- /dev/null +++ b/BikeRental/BikeRental.Infrastructure/Database/Configurations/LeaseConfiguration.cs @@ -0,0 +1,50 @@ +using BikeRental.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BikeRental.Infrastructure.Database.Configurations; + +/// +/// Конфигурация сущности "Lease" +/// +public class LeaseConfiguration : IEntityTypeConfiguration +{ + /// + /// Настройка сущности "Lease" + /// + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Leases"); + + // Первичный ключ + builder.HasKey(l => l.Id); + builder.Property(l => l.Id) + .ValueGeneratedOnAdd(); + + builder.Property(l => l.RenterId) + .IsRequired(); + + // Связь с арендатором + builder.HasOne(l => l.Renter) + .WithMany() + .HasForeignKey(l => l.RenterId) + .IsRequired(); + + + builder.Property(l => l.BikeId) + .IsRequired(); + + // Связь с велосипедом + builder.HasOne(l => l.Bike) + .WithMany() + .HasForeignKey(l => l.BikeId) + .IsRequired(); + + builder.Property(l => l.RentalStartTime) + .IsRequired(); + + builder.Property(l => l.RentalDuration) + .IsRequired(); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Infrastructure/Database/Configurations/RenterConfiguration.cs b/BikeRental/BikeRental.Infrastructure/Database/Configurations/RenterConfiguration.cs new file mode 100644 index 000000000..985b99829 --- /dev/null +++ b/BikeRental/BikeRental.Infrastructure/Database/Configurations/RenterConfiguration.cs @@ -0,0 +1,36 @@ +using BikeRental.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BikeRental.Infrastructure.Database.Configurations; + +/// +/// Конфигурация сущности "Renter" +/// +public class RenterConfiguration : IEntityTypeConfiguration +{ + /// + /// Настройка сущности "Renter" + /// + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Renters"); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id) + .ValueGeneratedOnAdd(); + + builder.Property(r => r.FullName) + .IsRequired() + .HasMaxLength(200); + + builder.Property(r => r.PhoneNumber) + .IsRequired() + .HasMaxLength(32); + + // Уникальный индекс по номеру телефона + builder.HasIndex(r => r.PhoneNumber) + .IsUnique(); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Infrastructure/Database/Migrations/20251215170139_Initial.Designer.cs b/BikeRental/BikeRental.Infrastructure/Database/Migrations/20251215170139_Initial.Designer.cs new file mode 100644 index 000000000..7f851f994 --- /dev/null +++ b/BikeRental/BikeRental.Infrastructure/Database/Migrations/20251215170139_Initial.Designer.cs @@ -0,0 +1,211 @@ +// +using System; +using BikeRental.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BikeRental.Infrastructure.Database.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20251215170139_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.22") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("BikeRental.Domain.Models.Bike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)") + .HasColumnName("color"); + + b.Property("ModelId") + .HasColumnType("int") + .HasColumnName("model_id"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)") + .HasColumnName("serial_number"); + + b.HasKey("Id") + .HasName("pk_bikes"); + + b.HasIndex("ModelId") + .HasDatabaseName("ix_bikes_model_id"); + + b.HasIndex("SerialNumber") + .IsUnique() + .HasDatabaseName("ix_bikes_serial_number"); + + b.ToTable("Bikes", (string)null); + }); + + modelBuilder.Entity("BikeRental.Domain.Models.BikeModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("BrakeType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("brake_type"); + + b.Property("MaxCyclistWeight") + .HasColumnType("int") + .HasColumnName("max_cyclist_weight"); + + b.Property("RentPrice") + .HasColumnType("decimal(10,2)") + .HasColumnName("rent_price"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.Property("Weight") + .HasColumnType("double") + .HasColumnName("weight"); + + b.Property("WheelSize") + .HasColumnType("int") + .HasColumnName("wheel_size"); + + b.Property("YearOfManufacture") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("varchar(4)") + .HasColumnName("year_of_manufacture"); + + b.HasKey("Id") + .HasName("pk_bike_models"); + + b.HasIndex("Type") + .HasDatabaseName("ix_bike_models_type"); + + b.HasIndex("Type", "WheelSize") + .HasDatabaseName("ix_bike_models_type_wheel_size"); + + b.ToTable("BikeModels", (string)null); + }); + + modelBuilder.Entity("BikeRental.Domain.Models.Lease", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("BikeId") + .HasColumnType("int") + .HasColumnName("bike_id"); + + b.Property("RentalDuration") + .HasColumnType("int") + .HasColumnName("rental_duration"); + + b.Property("RentalStartTime") + .HasColumnType("datetime(6)") + .HasColumnName("rental_start_time"); + + b.Property("RenterId") + .HasColumnType("int") + .HasColumnName("renter_id"); + + b.HasKey("Id") + .HasName("pk_leases"); + + b.HasIndex("BikeId") + .HasDatabaseName("ix_leases_bike_id"); + + b.HasIndex("RenterId") + .HasDatabaseName("ix_leases_renter_id"); + + b.ToTable("Leases", (string)null); + }); + + modelBuilder.Entity("BikeRental.Domain.Models.Renter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)") + .HasColumnName("full_name"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)") + .HasColumnName("phone_number"); + + b.HasKey("Id") + .HasName("pk_renters"); + + b.HasIndex("PhoneNumber") + .IsUnique() + .HasDatabaseName("ix_renters_phone_number"); + + b.ToTable("Renters", (string)null); + }); + + modelBuilder.Entity("BikeRental.Domain.Models.Bike", b => + { + b.HasOne("BikeRental.Domain.Models.BikeModel", "Model") + .WithMany() + .HasForeignKey("ModelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bikes_bike_models_model_id"); + + b.Navigation("Model"); + }); + + modelBuilder.Entity("BikeRental.Domain.Models.Lease", b => + { + b.HasOne("BikeRental.Domain.Models.Bike", "Bike") + .WithMany() + .HasForeignKey("BikeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_leases_bikes_bike_id"); + + b.HasOne("BikeRental.Domain.Models.Renter", "Renter") + .WithMany() + .HasForeignKey("RenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_leases_renters_renter_id"); + + b.Navigation("Bike"); + + b.Navigation("Renter"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BikeRental/BikeRental.Infrastructure/Database/Migrations/20251215170139_Initial.cs b/BikeRental/BikeRental.Infrastructure/Database/Migrations/20251215170139_Initial.cs new file mode 100644 index 000000000..ed4379f90 --- /dev/null +++ b/BikeRental/BikeRental.Infrastructure/Database/Migrations/20251215170139_Initial.cs @@ -0,0 +1,159 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using MySql.EntityFrameworkCore.Metadata; + +#nullable disable + +namespace BikeRental.Infrastructure.Database.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "BikeModels", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn), + type = table.Column(type: "int", nullable: false), + wheel_size = table.Column(type: "int", nullable: false), + max_cyclist_weight = table.Column(type: "int", nullable: false), + weight = table.Column(type: "double", nullable: false), + brake_type = table.Column(type: "varchar(50)", maxLength: 50, nullable: false), + year_of_manufacture = table.Column(type: "varchar(4)", maxLength: 4, nullable: false), + rent_price = table.Column(type: "decimal(10,2)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_bike_models", x => x.id); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Renters", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn), + full_name = table.Column(type: "varchar(200)", maxLength: 200, nullable: false), + phone_number = table.Column(type: "varchar(32)", maxLength: 32, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_renters", x => x.id); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Bikes", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn), + model_id = table.Column(type: "int", nullable: false), + serial_number = table.Column(type: "varchar(64)", maxLength: 64, nullable: false), + color = table.Column(type: "varchar(32)", maxLength: 32, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_bikes", x => x.id); + table.ForeignKey( + name: "fk_bikes_bike_models_model_id", + column: x => x.model_id, + principalTable: "BikeModels", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Leases", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn), + renter_id = table.Column(type: "int", nullable: false), + bike_id = table.Column(type: "int", nullable: false), + rental_start_time = table.Column(type: "datetime(6)", nullable: false), + rental_duration = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_leases", x => x.id); + table.ForeignKey( + name: "fk_leases_bikes_bike_id", + column: x => x.bike_id, + principalTable: "Bikes", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_leases_renters_renter_id", + column: x => x.renter_id, + principalTable: "Renters", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "ix_bike_models_type", + table: "BikeModels", + column: "type"); + + migrationBuilder.CreateIndex( + name: "ix_bike_models_type_wheel_size", + table: "BikeModels", + columns: new[] { "type", "wheel_size" }); + + migrationBuilder.CreateIndex( + name: "ix_bikes_model_id", + table: "Bikes", + column: "model_id"); + + migrationBuilder.CreateIndex( + name: "ix_bikes_serial_number", + table: "Bikes", + column: "serial_number", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_leases_bike_id", + table: "Leases", + column: "bike_id"); + + migrationBuilder.CreateIndex( + name: "ix_leases_renter_id", + table: "Leases", + column: "renter_id"); + + migrationBuilder.CreateIndex( + name: "ix_renters_phone_number", + table: "Renters", + column: "phone_number", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Leases"); + + migrationBuilder.DropTable( + name: "Bikes"); + + migrationBuilder.DropTable( + name: "Renters"); + + migrationBuilder.DropTable( + name: "BikeModels"); + } + } +} diff --git a/BikeRental/BikeRental.Infrastructure/Database/Migrations/ApplicationDbContextModelSnapshot.cs b/BikeRental/BikeRental.Infrastructure/Database/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 000000000..c1905d249 --- /dev/null +++ b/BikeRental/BikeRental.Infrastructure/Database/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,208 @@ +// +using System; +using BikeRental.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BikeRental.Infrastructure.Database.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.22") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("BikeRental.Domain.Models.Bike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)") + .HasColumnName("color"); + + b.Property("ModelId") + .HasColumnType("int") + .HasColumnName("model_id"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)") + .HasColumnName("serial_number"); + + b.HasKey("Id") + .HasName("pk_bikes"); + + b.HasIndex("ModelId") + .HasDatabaseName("ix_bikes_model_id"); + + b.HasIndex("SerialNumber") + .IsUnique() + .HasDatabaseName("ix_bikes_serial_number"); + + b.ToTable("Bikes", (string)null); + }); + + modelBuilder.Entity("BikeRental.Domain.Models.BikeModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("BrakeType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("brake_type"); + + b.Property("MaxCyclistWeight") + .HasColumnType("int") + .HasColumnName("max_cyclist_weight"); + + b.Property("RentPrice") + .HasColumnType("decimal(10,2)") + .HasColumnName("rent_price"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.Property("Weight") + .HasColumnType("double") + .HasColumnName("weight"); + + b.Property("WheelSize") + .HasColumnType("int") + .HasColumnName("wheel_size"); + + b.Property("YearOfManufacture") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("varchar(4)") + .HasColumnName("year_of_manufacture"); + + b.HasKey("Id") + .HasName("pk_bike_models"); + + b.HasIndex("Type") + .HasDatabaseName("ix_bike_models_type"); + + b.HasIndex("Type", "WheelSize") + .HasDatabaseName("ix_bike_models_type_wheel_size"); + + b.ToTable("BikeModels", (string)null); + }); + + modelBuilder.Entity("BikeRental.Domain.Models.Lease", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("BikeId") + .HasColumnType("int") + .HasColumnName("bike_id"); + + b.Property("RentalDuration") + .HasColumnType("int") + .HasColumnName("rental_duration"); + + b.Property("RentalStartTime") + .HasColumnType("datetime(6)") + .HasColumnName("rental_start_time"); + + b.Property("RenterId") + .HasColumnType("int") + .HasColumnName("renter_id"); + + b.HasKey("Id") + .HasName("pk_leases"); + + b.HasIndex("BikeId") + .HasDatabaseName("ix_leases_bike_id"); + + b.HasIndex("RenterId") + .HasDatabaseName("ix_leases_renter_id"); + + b.ToTable("Leases", (string)null); + }); + + modelBuilder.Entity("BikeRental.Domain.Models.Renter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)") + .HasColumnName("full_name"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)") + .HasColumnName("phone_number"); + + b.HasKey("Id") + .HasName("pk_renters"); + + b.HasIndex("PhoneNumber") + .IsUnique() + .HasDatabaseName("ix_renters_phone_number"); + + b.ToTable("Renters", (string)null); + }); + + modelBuilder.Entity("BikeRental.Domain.Models.Bike", b => + { + b.HasOne("BikeRental.Domain.Models.BikeModel", "Model") + .WithMany() + .HasForeignKey("ModelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bikes_bike_models_model_id"); + + b.Navigation("Model"); + }); + + modelBuilder.Entity("BikeRental.Domain.Models.Lease", b => + { + b.HasOne("BikeRental.Domain.Models.Bike", "Bike") + .WithMany() + .HasForeignKey("BikeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_leases_bikes_bike_id"); + + b.HasOne("BikeRental.Domain.Models.Renter", "Renter") + .WithMany() + .HasForeignKey("RenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_leases_renters_renter_id"); + + b.Navigation("Bike"); + + b.Navigation("Renter"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BikeRental/BikeRental.Infrastructure/Repositories/BikeModelRepository.cs b/BikeRental/BikeRental.Infrastructure/Repositories/BikeModelRepository.cs new file mode 100644 index 000000000..945f27636 --- /dev/null +++ b/BikeRental/BikeRental.Infrastructure/Repositories/BikeModelRepository.cs @@ -0,0 +1,47 @@ +using BikeRental.Domain.Interfaces; +using BikeRental.Domain.Models; +using BikeRental.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace BikeRental.Infrastructure.Repositories; + +/// +/// Репозиторий для работы с моделями велосипедов. +/// +public sealed class BikeModelRepository(ApplicationDbContext dbContext) : IBikeModelRepository +{ + public async Task> GetAll() + { + return await dbContext.BikeModels + .ToListAsync(); + } + + public async Task GetById(int id) + { + return await dbContext.BikeModels + .FirstOrDefaultAsync(x => x.Id == id); + } + + public async Task Add(BikeModel entity) + { + dbContext.BikeModels.Add(entity); + await dbContext.SaveChangesAsync(); + return entity.Id; + } + + public async Task Update(BikeModel entity) + { + BikeModel existing = await dbContext.BikeModels.FindAsync(entity.Id) + ?? throw new KeyNotFoundException($"Model with id {entity.Id} not found."); + + dbContext.Entry(existing).CurrentValues.SetValues(entity); + + await dbContext.SaveChangesAsync(); + } + + public async Task Delete(BikeModel entity) + { + dbContext.BikeModels.Remove(entity); + await dbContext.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Infrastructure/Repositories/BikeRepository.cs b/BikeRental/BikeRental.Infrastructure/Repositories/BikeRepository.cs new file mode 100644 index 000000000..8d6c7322b --- /dev/null +++ b/BikeRental/BikeRental.Infrastructure/Repositories/BikeRepository.cs @@ -0,0 +1,46 @@ +using BikeRental.Domain.Interfaces; +using BikeRental.Domain.Models; +using BikeRental.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace BikeRental.Infrastructure.Repositories; + +public sealed class BikeRepository(ApplicationDbContext dbContext) : IBikeRepository +{ + public async Task> GetAll() + { + return await dbContext.Bikes + .Include(l => l.Model) + .ToListAsync(); + } + + public async Task GetById(int id) + { + return await dbContext.Bikes + .Include(l => l.Model) + .FirstOrDefaultAsync(x => x.Id == id); + } + + public async Task Add(Bike entity) + { + dbContext.Bikes.Add(entity); + await dbContext.SaveChangesAsync(); + return entity.Id; + } + + public async Task Update(Bike entity) + { + Bike existing = await dbContext.Bikes.FindAsync(entity.Id) + ?? throw new KeyNotFoundException($"Bike with id {entity.Id} not found."); + + dbContext.Entry(existing).CurrentValues.SetValues(entity); + + await dbContext.SaveChangesAsync(); + } + + public async Task Delete(Bike entity) + { + dbContext.Bikes.Remove(entity); + await dbContext.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Infrastructure/Repositories/LeaseRepository.cs b/BikeRental/BikeRental.Infrastructure/Repositories/LeaseRepository.cs new file mode 100644 index 000000000..6b31f93b4 --- /dev/null +++ b/BikeRental/BikeRental.Infrastructure/Repositories/LeaseRepository.cs @@ -0,0 +1,46 @@ +using BikeRental.Domain.Interfaces; +using BikeRental.Domain.Models; +using BikeRental.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace BikeRental.Infrastructure.Repositories; + +public sealed class LeaseRepository(ApplicationDbContext dbContext) : ILeaseRepository +{ + public async Task> GetAll() + { + return await dbContext.Leases + .Include(l => l.Bike) + .Include(l => l.Renter) + .ToListAsync(); + } + + public async Task GetById(int id) + { + return await dbContext.Leases + .Include(l => l.Bike) + .Include(l => l.Renter) + .FirstOrDefaultAsync(x => x.Id == id); + } + + public async Task Add(Lease entity) + { + dbContext.Leases.Add(entity); + await dbContext.SaveChangesAsync(); + return entity.Id; + } + + public async Task Update(Lease entity) + { + Lease existing = await dbContext.Leases.FindAsync(entity.Id) + ?? throw new KeyNotFoundException($"Lease with id {entity.Id} not found."); + dbContext.Entry(existing).CurrentValues.SetValues(entity); + await dbContext.SaveChangesAsync(); + } + + public async Task Delete(Lease entity) + { + dbContext.Leases.Remove(entity); + await dbContext.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Infrastructure/Repositories/RenterRepository.cs b/BikeRental/BikeRental.Infrastructure/Repositories/RenterRepository.cs new file mode 100644 index 000000000..4f4740508 --- /dev/null +++ b/BikeRental/BikeRental.Infrastructure/Repositories/RenterRepository.cs @@ -0,0 +1,44 @@ +using BikeRental.Domain.Interfaces; +using BikeRental.Domain.Models; +using BikeRental.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace BikeRental.Infrastructure.Repositories; + +public sealed class RenterRepository(ApplicationDbContext dbContext) : IRenterRepository +{ + public async Task> GetAll() + { + return await dbContext.Renters + .ToListAsync(); + } + + public async Task GetById(int id) + { + return await dbContext.Renters + .FirstOrDefaultAsync(x => x.Id == id); + } + + public async Task Add(Renter entity) + { + dbContext.Renters.Add(entity); + await dbContext.SaveChangesAsync(); + return entity.Id; + } + + public async Task Update(Renter entity) + { + Renter existing = await dbContext.Renters.FindAsync(entity.Id) + ?? throw new KeyNotFoundException($"Renter with id {entity.Id} not found."); + + dbContext.Entry(existing).CurrentValues.SetValues(entity); + + await dbContext.SaveChangesAsync(); + } + + public async Task Delete(Renter entity) + { + dbContext.Renters.Remove(entity); + await dbContext.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Infrastructure/Services/ISeedDataService.cs b/BikeRental/BikeRental.Infrastructure/Services/ISeedDataService.cs new file mode 100644 index 000000000..47fcb7720 --- /dev/null +++ b/BikeRental/BikeRental.Infrastructure/Services/ISeedDataService.cs @@ -0,0 +1,13 @@ +namespace BikeRental.Infrastructure.Services; + +/// +/// Интерфейс описывает сервис инициализации данных +/// +public interface ISeedDataService +{ + /// + /// Выполнить инициализацию данных + /// + /// + public Task SeedDataAsync(); +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Infrastructure/Services/Impl/SeedDataService.cs b/BikeRental/BikeRental.Infrastructure/Services/Impl/SeedDataService.cs new file mode 100644 index 000000000..85dfeac43 --- /dev/null +++ b/BikeRental/BikeRental.Infrastructure/Services/Impl/SeedDataService.cs @@ -0,0 +1,109 @@ +using BikeRental.Domain.Enum; +using BikeRental.Domain.Models; +using BikeRental.Infrastructure.Database; +using Bogus; +using Microsoft.EntityFrameworkCore; + +namespace BikeRental.Infrastructure.Services.Impl; + +/// +/// Сервис инициализации данных +/// +/// +public class SeedDataService(ApplicationDbContext dbContext) : ISeedDataService +{ + /// + /// Выполнить инициализацию данных + /// + public async Task SeedDataAsync() + { + // Подготовить генератор фейковых данных + var faker = new Faker("ru"); + + // Создать модели велосипедов, если они отсутствуют + if (!await dbContext.BikeModels.AnyAsync()) + { + var bikeModels = new List(); + for (var i = 0; i < 10; i++) + { + var bikeModel = new BikeModel + { + Type = faker.PickRandom(), + WheelSize = faker.Random.Int(20, 29), + MaxCyclistWeight = faker.Random.Int(60, 120), + Weight = Math.Round(faker.Random.Double(7.0, 15.0), 2), + BrakeType = faker.PickRandom("Road", "Sport", "Mountain", "Hybrid"), + YearOfManufacture = faker.Date.Past(10).Year.ToString(), + RentPrice = Math.Round(faker.Random.Decimal(5.0m, 20.0m), 2) + }; + bikeModels.Add(bikeModel); + } + + dbContext.BikeModels.AddRange(bikeModels); + await dbContext.SaveChangesAsync(); + } + + // Создать арендаторов, если они отсутствуют + if (!await dbContext.Renters.AnyAsync()) + { + var renters = new List(); + for (var i = 0; i < 20; i++) + { + var renter = new Renter + { + FullName = faker.Name.FullName(), + PhoneNumber = faker.Phone.PhoneNumber() + }; + renters.Add(renter); + } + + dbContext.Renters.AddRange(renters); + await dbContext.SaveChangesAsync(); + } + + // Создать велосипеды, если они отсутствуют + if (!await dbContext.Bikes.AnyAsync()) + { + List bikeModels = await dbContext.BikeModels.ToListAsync(); + var bikes = new List(); + for (var i = 0; i < 30; i++) + { + var bike = new Bike + { + ModelId = faker.PickRandom(bikeModels).Id, + SerialNumber = faker.Random.AlphaNumeric(10).ToUpper(), + Color = faker.Commerce.Color(), + Model = faker.PickRandom(bikeModels) + }; + bikes.Add(bike); + } + + dbContext.Bikes.AddRange(bikes); + await dbContext.SaveChangesAsync(); + } + + // Создать договора аренды, если они отсутствуют для + // некоторых велосипедов + if (!await dbContext.Leases.AnyAsync()) + { + List renters = await dbContext.Renters.ToListAsync(); + List bikes = await dbContext.Bikes.ToListAsync(); + var leases = new List(); + + for (var i = 0; i < 15; i++) + { + var lease = new Lease + { + Renter = faker.PickRandom(renters), + Bike = faker.PickRandom(bikes), + RentalStartTime = faker.Date.Recent(10), + RentalDuration = faker.Random.Int(1, 72) + }; + leases.Add(lease); + } + + dbContext.Leases.AddRange(leases); + await dbContext.SaveChangesAsync(); + } + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Tests/BikeRental.Tests.csproj b/BikeRental/BikeRental.Tests/BikeRental.Tests.csproj new file mode 100644 index 000000000..abce81240 --- /dev/null +++ b/BikeRental/BikeRental.Tests/BikeRental.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/BikeRental/BikeRental.Tests/BikeRentalTests.cs b/BikeRental/BikeRental.Tests/BikeRentalTests.cs new file mode 100644 index 000000000..e0da93d90 --- /dev/null +++ b/BikeRental/BikeRental.Tests/BikeRentalTests.cs @@ -0,0 +1,123 @@ +using BikeRental.Domain.Enum; + +namespace BikeRental.Tests; + +/// +/// Class for unit-tests +/// +public class BikeRentalTests(RentalFixture fixture) : IClassFixture +{ + /// + /// Displays information about all sports bikes + /// + [Fact] + public void InfoAboutSportBikes() + { + var expected = new List { 3, 6, 9 }; + + var actual = fixture.Bikes + .Where(b => b.Model.Type == BikeType.Sport) + .Select(b => b.Id) + .ToList(); + + Assert.Equal(expected, actual); + } + + /// + /// Displays the top 5 bike models ranked by rental revenue + /// + [Fact] + public void TopFiveModelsIncome() + { + var expected = new List { 5, 8, 2, 9, 3 }; /// 9,7,3 have same result (= 60) + + var actual = fixture.Lease + .GroupBy(lease => lease.Bike.Model.Id) + .Select(modelsGroup => new + { + ModelId = modelsGroup.Key, + SumOfIncomes = modelsGroup.Sum(lease => lease.Bike.Model.RentPrice * lease.RentalDuration) + }) + .OrderByDescending(models => models.SumOfIncomes) + .Select(models => models.ModelId) + .Take(5) + .ToList(); + Assert.Equal(expected, actual); + } + + /// + /// Displays the top 5 bike models ranked by rental duration + /// { 5, 8, 2, 7, 3 }; + + var actual = fixture.Lease + .GroupBy(lease => lease.Bike.Model.Id) + .Select(modelsGroup => new + { + ModelId = modelsGroup.Key, + SumOfDurations = modelsGroup.Sum(lease => lease.RentalDuration) + }) + .OrderByDescending(models => models.SumOfDurations) + .Select(models => models.ModelId) + .Take(5) + .ToList(); + + Assert.Equal(expected, actual); + } + + /// + /// Displays information about the minimum, maximum, and average rental time + /// + [Fact] + public void MinMaxAvgRental() + { + var expectedMinimum = 1; + var expectedMaximum = 8; + var expectedAverage = 4.4; + + var durations = fixture.Lease.Select(rent => rent.RentalDuration).ToList(); + + Assert.Equal(expectedMinimum, durations.Min()); + Assert.Equal(expectedMaximum, durations.Max()); + Assert.Equal(expectedAverage, durations.Average()); + } + + + /// + /// Displays the total rental time for each bike type + /// + [Theory] + [InlineData(BikeType.Road, 12)] + [InlineData(BikeType.Sport, 9)] + [InlineData(BikeType.Mountain, 8)] + [InlineData(BikeType.Hybrid, 15)] + public void TotalRentalTimeByType(BikeType type, int expected) + { + var actual = fixture.Lease + .Where(lease => lease.Bike.Model.Type == type) + .Sum(lease => lease.RentalDuration); + + Assert.Equal(expected, actual); + } + + /// + /// Displays information about customers who have rented bikes the most times + /// + [Fact] + public void TopThreeRenters() + { + var expected = new List { 6, 7, 1 }; + + var actual = fixture.Lease + .GroupBy(lease => lease.Renter.Id) + .OrderByDescending(group => group.Count()) + .Select(group => group.Key) + .Take(3) + .ToList(); + + Assert.Equal(expected, actual); + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.Tests/RentalFixture.cs b/BikeRental/BikeRental.Tests/RentalFixture.cs new file mode 100644 index 000000000..617bc80db --- /dev/null +++ b/BikeRental/BikeRental.Tests/RentalFixture.cs @@ -0,0 +1,306 @@ +using BikeRental.Domain.Enum; +using BikeRental.Domain.Models; + +namespace BikeRental.Tests; + +public class RentalFixture +{ + /// + /// A class for creating the data for testing + /// + public RentalFixture() + { + Models = GetBikeModels(); + Renters = GetRenters(); + Bikes = GetBikes(Models); + Lease = GetLeases(Bikes, Renters); + } + + /// + /// A list of all bike models + /// + public List Models { get; } + + /// + /// /// A list of all bikes for rent + /// + public List Bikes { get; } + + /// + /// A list of all registered renters + /// + public List Renters { get; } + + /// + /// A list of all leases + /// + public List Lease { get; } + + private static List GetBikeModels() + { + return + [ + new() + { + Id = 1, Type = BikeType.Mountain, WheelSize = 26, MaxCyclistWeight = 95, Weight = 8.2, + BrakeType = "Carbon", YearOfManufacture = "2024", RentPrice = 18 + }, + new() + { + Id = 2, Type = BikeType.Road, WheelSize = 27, MaxCyclistWeight = 115, Weight = 12.8, + BrakeType = "Hydraulic", YearOfManufacture = "2023", RentPrice = 25 + }, + new() + { + Id = 3, Type = BikeType.Sport, WheelSize = 28, MaxCyclistWeight = 85, Weight = 7.9, + BrakeType = "V-Brake", YearOfManufacture = "2024", RentPrice = 22 + }, + new() + { + Id = 4, Type = BikeType.Road, WheelSize = 29, MaxCyclistWeight = 105, Weight = 14.7, + BrakeType = "Mechanical", YearOfManufacture = "2023", RentPrice = 20 + }, + new() + { + Id = 5, Type = BikeType.Hybrid, WheelSize = 26, MaxCyclistWeight = 90, Weight = 6.8, + BrakeType = "Hydraulic", YearOfManufacture = "2024", RentPrice = 35 + }, + new() + { + Id = 6, Type = BikeType.Sport, WheelSize = 28, MaxCyclistWeight = 125, Weight = 13.5, + BrakeType = "Disc", YearOfManufacture = "2023", RentPrice = 28 + }, + new() + { + Id = 7, Type = BikeType.Mountain, WheelSize = 27, MaxCyclistWeight = 110, Weight = 12.2, + BrakeType = "V-Brake", YearOfManufacture = "2022", RentPrice = 16 + }, + new() + { + Id = 8, Type = BikeType.Hybrid, WheelSize = 29, MaxCyclistWeight = 100, Weight = 7.5, + BrakeType = "Carbon", YearOfManufacture = "2023", RentPrice = 32 + }, + new() + { + Id = 9, Type = BikeType.Sport, WheelSize = 26, MaxCyclistWeight = 130, Weight = 15.8, + BrakeType = "Hydraulic", YearOfManufacture = "2024", RentPrice = 24 + }, + new() + { + Id = 10, Type = BikeType.Road, WheelSize = 28, MaxCyclistWeight = 80, Weight = 9.3, + BrakeType = "Mechanical", YearOfManufacture = "2022", RentPrice = 19 + } + ]; + } + + private static List GetRenters() + { + return + [ + new() { Id = 1, FullName = "Алексеев Алексей", PhoneNumber = "+7 912 345 67 89" }, + new() { Id = 2, FullName = "Васильев Василий", PhoneNumber = "+7 923 456 78 90" }, + new() { Id = 3, FullName = "Григорьев Григорий", PhoneNumber = "+7 934 567 89 01" }, + new() { Id = 4, FullName = "Дмитриева Ольга", PhoneNumber = "+7 945 678 90 12" }, + new() { Id = 5, FullName = "Николаева Светлана", PhoneNumber = "+7 956 789 01 23" }, + new() { Id = 6, FullName = "Михайлов Сергей", PhoneNumber = "+7 967 890 12 34" }, + new() { Id = 7, FullName = "Романова Татьяна", PhoneNumber = "+7 978 901 23 45" }, + new() { Id = 8, FullName = "Павлов Дмитрий", PhoneNumber = "+7 989 012 34 56" }, + new() { Id = 9, FullName = "Фёдорова Екатерина", PhoneNumber = "+7 990 123 45 67" }, + new() { Id = 10, FullName = "Андреева Наталья", PhoneNumber = "+7 901 234 56 78" } + ]; + } + + private static List GetBikes(List models) + { + return + [ + new() + { + Id = 1, + SerialNumber = "R001", + Color = "Silver", + Model = models[0], + ModelId = 0 + }, + new() + { + Id = 2, + SerialNumber = "R002", + Color = "Navy", + Model = models[1], + ModelId = 0 + }, + new() + { + Id = 3, + SerialNumber = "R003", + Color = "Charcoal", + Model = models[2], + ModelId = 0 + }, + new() + { + Id = 4, + SerialNumber = "R004", + Color = "Beige", + Model = models[3], + ModelId = 0 + }, + new() + { + Id = 5, + SerialNumber = "R005", + Color = "Burgundy", + Model = models[4], + ModelId = 0 + }, + new() + { + Id = 6, + SerialNumber = "R006", + Color = "Teal", + Model = models[5], + ModelId = 0 + }, + new() + { + Id = 7, + SerialNumber = "R007", + Color = "Coral", + Model = models[6], + ModelId = 0 + }, + new() + { + Id = 8, + SerialNumber = "R008", + Color = "Indigo", + Model = models[7], + ModelId = 0 + }, + new() + { + Id = 9, + SerialNumber = "R009", + Color = "Bronze", + Model = models[8], + ModelId = 0 + }, + new() + { + Id = 10, + SerialNumber = "R010", + Color = "Lavender", + Model = models[9], + ModelId = 0 + } + ]; + } + + private static List GetLeases(List bikes, List renters) + { + return + [ + new() + { + Id = 1, + Bike = bikes[0], + Renter = renters[0], + RentalStartTime = DateTime.Now.AddHours(-12), + RentalDuration = 3, + BikeId = 0, + RenterId = 0 + }, + new() + { + Id = 2, + Bike = bikes[1], + Renter = renters[1], + RentalStartTime = DateTime.Now.AddHours(-8), + RentalDuration = 6, + BikeId = 0, + RenterId = 0 + }, + new() + { + Id = 3, + Bike = bikes[2], + Renter = renters[5], + RentalStartTime = DateTime.Now.AddHours(-15), + RentalDuration = 4, + BikeId = 0, + RenterId = 0 + }, + new() + { + Id = 4, + Bike = bikes[3], + Renter = renters[5], + RentalStartTime = DateTime.Now.AddHours(-5), + RentalDuration = 2, + BikeId = 0, + RenterId = 0 + }, + new() + { + Id = 5, + Bike = bikes[4], + Renter = renters[4], + RentalStartTime = DateTime.Now.AddHours(-20), + RentalDuration = 8, + BikeId = 0, + RenterId = 0 + }, + new() + { + Id = 6, + Bike = bikes[5], + Renter = renters[5], + RentalStartTime = DateTime.Now.AddHours(-3), + RentalDuration = 1, + BikeId = 0, + RenterId = 0 + }, + new() + { + Id = 7, + Bike = bikes[6], + Renter = renters[6], + RentalStartTime = DateTime.Now.AddHours(-18), + RentalDuration = 5, + BikeId = 0, + RenterId = 0 + }, + new() + { + Id = 8, + Bike = bikes[7], + Renter = renters[6], + RentalStartTime = DateTime.Now.AddHours(-7), + RentalDuration = 7, + BikeId = 0, + RenterId = 0 + }, + new() + { + Id = 9, + Bike = bikes[8], + Renter = renters[8], + RentalStartTime = DateTime.Now.AddHours(-10), + RentalDuration = 4, + BikeId = 0, + RenterId = 0 + }, + new() + { + Id = 10, + Bike = bikes[9], + Renter = renters[9], + RentalStartTime = DateTime.Now.AddHours(-2), + RentalDuration = 4, + BikeId = 0, + RenterId = 0 + } + ]; + } +} \ No newline at end of file diff --git a/BikeRental/BikeRental.sln b/BikeRental/BikeRental.sln new file mode 100644 index 000000000..33f144c78 --- /dev/null +++ b/BikeRental/BikeRental.sln @@ -0,0 +1,58 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BikeRental.Domain", "BikeRental.Domain\BikeRental.Domain.csproj", "{1004CD79-296D-41B4-99B7-6D1AEF3000C5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BikeRental.Tests", "BikeRental.Tests\BikeRental.Tests.csproj", "{383A3622-AE13-45D6-88D3-E8559613718E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BikeRental.Application", "BikeRental.Application\BikeRental.Application.csproj", "{B68A0E95-AEB2-4C87-A527-9F89EB8B0032}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BikeRental.Api", "BikeRental.Api\BikeRental.Api.csproj", "{F6A23387-A682-4FFC-A33F-E6354A4839CA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BikeRental.Infrastructure", "BikeRental.Infrastructure\BikeRental.Infrastructure.csproj", "{1EABEEC1-1941-44AC-B46F-42CD2381899C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BikeRental.Application.Contracts", "BikeRental.Application.Contracts\BikeRental.Application.Contracts.csproj", "{A637BFD7-2BE5-4B58-AB34-8330FE4D6B43}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppHost", "AppHost\AppHost.csproj", "{FFC8D593-B70B-430D-8FC5-A9F3EE1DDE17}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BikeRental.Generator.Nats.Host", "BikeRental.Generator.Nats.Host\BikeRental.Generator.Nats.Host.csproj", "{3292CC61-FAAA-47C4-87BE-76E6CA5BB1C9}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1004CD79-296D-41B4-99B7-6D1AEF3000C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1004CD79-296D-41B4-99B7-6D1AEF3000C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1004CD79-296D-41B4-99B7-6D1AEF3000C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1004CD79-296D-41B4-99B7-6D1AEF3000C5}.Release|Any CPU.Build.0 = Release|Any CPU + {383A3622-AE13-45D6-88D3-E8559613718E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {383A3622-AE13-45D6-88D3-E8559613718E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {383A3622-AE13-45D6-88D3-E8559613718E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {383A3622-AE13-45D6-88D3-E8559613718E}.Release|Any CPU.Build.0 = Release|Any CPU + {B68A0E95-AEB2-4C87-A527-9F89EB8B0032}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B68A0E95-AEB2-4C87-A527-9F89EB8B0032}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B68A0E95-AEB2-4C87-A527-9F89EB8B0032}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B68A0E95-AEB2-4C87-A527-9F89EB8B0032}.Release|Any CPU.Build.0 = Release|Any CPU + {F6A23387-A682-4FFC-A33F-E6354A4839CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F6A23387-A682-4FFC-A33F-E6354A4839CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F6A23387-A682-4FFC-A33F-E6354A4839CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F6A23387-A682-4FFC-A33F-E6354A4839CA}.Release|Any CPU.Build.0 = Release|Any CPU + {1EABEEC1-1941-44AC-B46F-42CD2381899C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1EABEEC1-1941-44AC-B46F-42CD2381899C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1EABEEC1-1941-44AC-B46F-42CD2381899C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1EABEEC1-1941-44AC-B46F-42CD2381899C}.Release|Any CPU.Build.0 = Release|Any CPU + {A637BFD7-2BE5-4B58-AB34-8330FE4D6B43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A637BFD7-2BE5-4B58-AB34-8330FE4D6B43}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A637BFD7-2BE5-4B58-AB34-8330FE4D6B43}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A637BFD7-2BE5-4B58-AB34-8330FE4D6B43}.Release|Any CPU.Build.0 = Release|Any CPU + {FFC8D593-B70B-430D-8FC5-A9F3EE1DDE17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FFC8D593-B70B-430D-8FC5-A9F3EE1DDE17}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FFC8D593-B70B-430D-8FC5-A9F3EE1DDE17}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FFC8D593-B70B-430D-8FC5-A9F3EE1DDE17}.Release|Any CPU.Build.0 = Release|Any CPU + {3292CC61-FAAA-47C4-87BE-76E6CA5BB1C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3292CC61-FAAA-47C4-87BE-76E6CA5BB1C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3292CC61-FAAA-47C4-87BE-76E6CA5BB1C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3292CC61-FAAA-47C4-87BE-76E6CA5BB1C9}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 39c9a8443..013504d3e 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,196 @@ # Разработка корпоративных приложений -[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1JD6aiOG6r7GrA79oJncjgUHWtfeW4g_YZ9ayNgxb_w0/edit?usp=sharing) ## Задание + ### Цель Реализация проекта сервисно-ориентированного приложения. -### Задачи -* Реализация объектно-ориентированной модели данных, -* Изучение реализации серверных приложений на базе WebAPI/OpenAPI, -* Изучение работы с брокерами сообщений, -* Изучение паттернов проектирования, -* Изучение работы со средствами оркестрации на примере .NET Aspire, -* Повторение основ работы с системами контроля версий, -* Unit-тестирование. - -### Лабораторные работы -
-1. «Классы» - Реализация объектной модели данных и unit-тестов -
-В рамках первой лабораторной работы необходимо подготовить структуру классов, описывающих предметную область, определяемую в задании. В каждом из заданий присутствует часть, связанная с обработкой данных, представленная в разделе «Unit-тесты». Данную часть необходимо реализовать в виде unit-тестов: подготовить тестовые данные, выполнить запрос с использованием LINQ, проверить результаты. - -Хранение данных на этом этапе допускается осуществлять в памяти в виде коллекций. -Необходимо включить **как минимум 10** экземпляров каждого класса в датасид. - -
-
-2. «Сервер» - Реализация серверного приложения с использованием REST API -
-Во второй лабораторной работе необходимо реализовать серверное приложение, которое должно: -- Осуществлять базовые CRUD-операции с реализованными в первой лабораторной сущностями -- Предоставлять результаты аналитических запросов (раздел «Unit-тесты» задания) - -Хранение данных на этом этапе допускается осуществлять в памяти в виде коллекций. -
-
-
-3. «ORM» - Реализация объектно-реляционной модели. Подключение к базе данных и настройка оркестрации -
-В третьей лабораторной работе хранение должно быть переделано c инмемори коллекций на базу данных. -Должны быть созданы миграции для создания таблиц в бд и их первоначального заполнения. -
-Также необходимо настроить оркестратор Aspire на запуск сервера и базы данных. -
-
-
-4. «Инфраструктура» - Реализация сервиса генерации данных и его интеграция с сервером -
-В четвертой лабораторной работе необходимо имплементировать сервис, который генерировал бы контракты. Контракты далее передаются в сервер и сохраняются в бд. -Сервис должен представлять из себя отдельное приложение без референсов к серверным проектам за исключением библиотеки с контрактами. -Отправка контрактов при помощи gRPC должна выполняться в потоковом виде. -При использовании брокеров сообщений, необходимо предусмотреть ретраи при подключении к брокеру. - -Также необходимо добавить в конфигурацию Aspire запуск генератора и (если того требует вариант) брокера сообщений. -
-
-
-5. «Клиент» - Интеграция клиентского приложения с оркестратором -
-В пятой лабораторной необходимо добавить в конфигурацию Aspire запуск клиентского приложения для написанного ранее сервера. Клиент создается в рамках курса "Веб разработка". -
-
- -## Задание. Общая часть -**Обязательно**: -* Реализация серверной части на [.NET 8](https://learn.microsoft.com/ru-ru/dotnet/core/whats-new/dotnet-8/overview). -* Реализация серверной части на [ASP.NET](https://dotnet.microsoft.com/ru-ru/apps/aspnet). -* Реализация unit-тестов с использованием [xUnit](https://xunit.net/?tabs=cs). -* Использование хранения данных в базе данных согласно варианту задания. -* Оркестрация проектов при помощи [.NET Aspire](https://learn.microsoft.com/ru-ru/dotnet/aspire/get-started/aspire-overview) -* Реализация сервиса генерации данных при помощи [Bogus](https://github.com/bchavez/Bogus) и его взаимодейсвие с сервером согласно варианту задания. -* Автоматизация тестирования на уровне репозитория через [GitHub Actions](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions). -* Создание минимальной документации к проекту: страница на GitHub с информацией о задании, скриншоты приложения и прочая информация. - -**Факультативно**: -* Реализация авторизации/аутентификации. -* Реализация atomic batch publishing/atomic batch consumption для брокеров, поддерживающих такой функционал. -* Реализация интеграционных тестов при помощи .NET Aspire. -* Реализация клиента на Blazor WASM. - -Внимательно прочитайте [дискуссии](https://github.com/itsecd/enterprise-development/discussions/1) о том, как работает автоматическое распределение на ревью. -Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю. - -По итогу работы в семестре должна получиться следующая информационная система: -
-C4 диаграмма - -image1 - -
- -## Варианты заданий -Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи. - -[Список вариантов](https://docs.google.com/document/d/1Wc8AvsKS_1JptpsxHO-cwfAxz2ghxvQRQ0fy4el2ZOc/edit?usp=sharing) -[Список предметных областей](https://docs.google.com/document/d/15jWhXMwd2K8giFMKku_yrY_s2uQNEu4ugJXLYPvYJAE/edit?usp=sharing) - -## Схема сдачи - -На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests). - -Общая схема: -1. Сделать форк данного репозитория -2. Выполнить задание -3. Сделать PR в данный репозиторий -4. Исправить замечания после code review -5. Получить approve -6. Прийти на занятие и защитить работу - -## Критерии оценивания - -Конкурентный принцип. -Так как задания в первой лабораторной будут повторяться между студентами, то выделяются следующие показатели для оценки: -1. Скорость разработки -2. Качество разработки -3. Полнота выполнения задания - -Быстрее делаете PR - у вас преимущество. -Быстрее получаете Approve - у вас преимущество. -Выполните нечто немного выходящее за рамки проекта - у вас преимущество. - -### Шкала оценивания - -- **3 балла** за качество кода, из них: - - 2 балла - базовая оценка - - 1 балл (но не более) можно получить за выполнение любого из следующих пунктов: - - Реализация факультативного функционала - - Выполнение работы раньше других: первые 5 человек из каждой группы, которые сделали PR и получили approve, получают дополнительный балл -- **3 балла** за защиту: при сдаче лабораторной работы вам задается 3 вопроса, за каждый правильный ответ - 1 балл - -У вас 2 попытки пройти ревью (первичное ревью, ревью по результатам исправления). Если замечания по итогу не исправлены, то снимается один балл за код лабораторной работы. - -## Вопросы и обратная связь по курсу - -Чтобы задать вопрос по лабораторной, воспользуйтесь [соотвествующим разделом дискуссий](https://github.com/itsecd/enterprise-development/discussions/categories/questions) или заведите [ишью](https://github.com/itsecd/enterprise-development/issues/new). -Если у вас появились идеи/пожелания/прочие полезные мысли по преподаваемой дисциплине, их можно оставить [здесь](https://github.com/itsecd/enterprise-development/discussions/categories/ideas). +### Предметная область «Пункт велопроката» +В базе пункта проката хранятся сведения о велосипедах, их арендаторах и выданных в аренду транспортных средствах. + +**Велосипед** характеризуется: +- Серийным номером +- Моделью +- Цветом + +**Модель велосипеда** является справочником, содержащим сведения о: +- Типе велосипеда +- Размере колес +- Предельно допустимом весе пассажира +- Весе велосипеда +- Типе тормозов +- Модельном годе +- Цене часа аренды + +**Тип велосипеда** является перечислением. + +**Арендатор** характеризуется: +- ФИО +- Телефоном + +**Аренда (контракт):** +- При выдаче велосипеда фиксируется время начала аренды +- Отмечается продолжительность аренды в часах + +--- + +## Задание на лабораторную работу №1 + +### Подготовленная структура классов + +#### Класс `Bike` (Велосипед) +| Поле | Описание | +|------|----------| +| `Id` | Уникальный идентификатор | +| `SerialNumber` | Серийный номер | +| `Model` | Модель | +| `Color` | Цвет | + +#### Класс `BikeModel` (Модель велосипеда) +| Поле | Описание | +|------|----------| +| `Id` | Уникальный идентификатор | +| `Type` | Тип велосипеда | +| `WheelSize` | Размер колес | +| `MaxCyclistWeight` | Максимальный вес велосипедиста | +| `Weight` | Вес велосипеда | +| `BrakeType` | Тип тормозов | +| `YearOfManufacture` | Год выпуска модели | +| `RentPrice` | Цена часа аренды | + +#### Класс `Renter` (Арендатор) +| Поле | Описание | +|------|----------| +| `Id` | Уникальный идентификатор | +| `FullName` | ФИО | +| `PhoneNumber` | Номер телефона | + +#### Класс `Lease` (Контракт аренды) +| Поле | Описание | +|------|----------| +| `Id` | Уникальный идентификатор | +| `Renter` | Арендатор | +| `Bike` | Арендованный велосипед | +| `RentalStartTime` | Время начала аренды | +| `RentalDuration` | Продолжительность аренды (в часах) | + +--- + +### Реализованные компоненты + +#### 1. **Датасет** (`RentalFixture`) +- Включено по 10 экземпляров каждого класса + +#### 2. **Юнит-тесты** + +| Название теста | Описание | +|----------------|----------| +| `InfoAboutSportBikes` | Выводит информацию обо всех спортивных велосипедах | +| `TopFiveModelsIncome` | Выводит топ-5 моделей велосипедов по прибыли от аренды | +| `TopFiveModelsDuration` | Выводит топ-5 моделей велосипедов по длительности аренды | +| `MinMaxAvgRental` | Выводит минимальное, максимальное и среднее время аренды | +| `TotalRentalTimeByType` | Выводит суммарное время аренды велосипедов каждого типа | +| `TopThreeRenters` | Выводит информацию о клиентах, бравших велосипеды на прокат больше всего раз | + +--- +- .NET (C#) +- xUnit (для юнит-тестов) + +# Лабораторные работы №2-3: REST API + ORM + Aspire + +### **База данных MySQL + EF Core** +- Доменные модели с внешними ключами: + ```csharp + public class Bike { + public int ModelId { get; set; } // FK to BikeModel + public virtual BikeModel Model { get; init; } + } + +* Репозитории с асинхронными методами +* Миграции EF Core для создания схемы БД +* Контекст ApplicationDbContext с конфигурациями +#### **Генерация данных:** +```csharp + new BikeModel + { + Type = faker.PickRandom(), // Случайный тип из enum + WheelSize = faker.Random.Int(20, 29), // Размер колёс 20-29" + MaxCyclistWeight = faker.Random.Int(60, 120), // Вес 60-120 кг + Weight = Math.Round(faker.Random.Double(7.0, 15.0), 2), // Вес велосипеда + BrakeType = faker.PickRandom("Road", "Sport", "Mountain", "Hybrid"), + YearOfManufacture = faker.Date.Past(10).Year.ToString(), // Год выпуска + RentPrice = Math.Round(faker.Random.Decimal(5.0m, 20.0m), 2) // Цена аренды + } +``` +Bogus генерировал реалистичные тестовые данные: +* Русские ФИО, телефоны +* Параметры велосипедов (размеры, вес, цены) +* Даты аренд +* Связи между таблицами +* `var faker = new Faker("ru");` +### **REST API на ASP.NET Core** +- CRUD-операции для всех сущностей (Bikes, BikeModels, Renters, Leases) +- Тесты xUnit адаптированы + +#### Запуск и порты: +- Запустить проект через AppHost +- **Aspire Dashboard:** автоматически открывается при запуске +- **Adminer для БД:** доступен через Dashboard +- `https://localhost:21053` - для метрик +- `https://localhost:17195` +- `http://localhost:15246` - порты API +- Используется `launchSettings.json` с http и https + +#### Администрирование БД через Adminer +1. в Aspire Dashboard +2. Найти контейнер `bike_rental_db_adminer` +3. Войти с данными: +* Система: MySQL +* Сервер: `bike-rental-db` +* Пользователь: `root` +* Пароль: `1234512345Aa$` +* База данных: bike-rental - не обязательно + +### Swagger +- Для получения доступа к backend: `http://localhost:5043/swagger/index.html` - клиент для тестирования + +# Лабораторная 4 + +## Основные компоненты + +### `BikeRental.Generator.Nats.Host/Program.cs` +- Настройка конфигурации, подключение к NATS, регистрация генератора и воркера в DI +- Использование `NatsConnection` с URL из конфигурации + +### `BikeRental.Generator.Nats.Host/BikeRentalNatsProducer.cs` +- Отправка батчей аренды в NATS JetStream +- Реализация механизма повторных попыток (retry) для подключения и публикации +- Автоматическое создание/обновление stream при необходимости +- Логирование операций и ошибок + +### `BikeRental.Api/Messaging/NatsLeaseConsumer.cs` +- Фоновый сервис для чтения батчей аренды из NATS JetStream +- Механизм подтверждения сообщений: `ack` только после успешной записи всего батча в БД +- Обработка невалидных данных: при несуществующих `BikeId`/`RenterId` сообщение завершается (`AckTerminateAsync`), чтобы избежать зацикливания +- Реализация повторных попыток подключения и обработки сообщений +- Использование транзакций для атомарного сохранения всего батча + +### `BikeRental.Generator.Nats.Host/LeaseBatchGenerator.cs` +- Генерация тестовых данных `LeaseCreateUpdateDto` с использованием Bogus +- Диапазоны ID сделала для наглядности: арендаторы (18-20), велосипеды (28-30) + +### `BikeRental.Generator.Nats.Host/LeaseBatchWorker.cs` +- Фоновый воркер для периодической генерации и отправки данных +- Возможен только 1 запуск, если `IntervalSeconds ≤ 0` в конфигурации + +## Классы для типизированной конфигурации + +- `BikeRental.Generator.Nats.Host/NatsSettings.cs` - настройки подключения к NATS, имена stream/subject для генератора +- `BikeRental.Api/Messaging/NatsConsumerSettings.cs` - настройки потребителя NATS в API +- `BikeRental.Generator.Nats.Host/LeaseGenerationOptions.cs` - параметры генерации данных (диапазоны ID) + +## Конфигурация (`appsettings.json`) +- `IntervalSeconds` = `0` или отрицательное значение - однократная отправка при старте (в воркере) + + +## Проект запускается через Aspire AppHost