diff --git a/backend/CookifyAPI/CookifyAPI.sln.DotSettings.user b/backend/CookifyAPI/CookifyAPI.sln.DotSettings.user index 7bffe5d..d2f57e3 100644 --- a/backend/CookifyAPI/CookifyAPI.sln.DotSettings.user +++ b/backend/CookifyAPI/CookifyAPI.sln.DotSettings.user @@ -1,4 +1,5 @@  + ForceIncluded ForceIncluded ForceIncluded ForceIncluded \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/Controllers/SearchController.cs b/backend/CookifyAPI/CookifyAPI/Controllers/SearchController.cs new file mode 100644 index 0000000..eb0382b --- /dev/null +++ b/backend/CookifyAPI/CookifyAPI/Controllers/SearchController.cs @@ -0,0 +1,39 @@ +using CookifyAPI.Models.DTOs.Requests; +using CookifyAPI.Models.DTOs.Search; +using CookifyAPI.Services; +using Microsoft.AspNetCore.Mvc; + +namespace CookifyAPI.Controllers; + +[ApiController] +[Route("api/search")] +public class SearchController( + ISearchService searchService, + IRecipeService recipeService) : ControllerBase +{ + [HttpGet("tags")] + public async Task SearchTags([FromQuery] string? name, [FromQuery] int limit = 20) + { + if (string.IsNullOrWhiteSpace(name)) + return Ok(Array.Empty()); // Пустой массив, если нет запроса + + var results = await searchService.SearchTagsAsync(name, limit); + return Ok(results); + } + + [HttpGet("ingredients")] + public async Task SearchIngredients([FromQuery] string? name, [FromQuery] int limit = 20) + { + if (string.IsNullOrWhiteSpace(name)) + return Ok(Array.Empty()); + var results = await searchService.SearchIngredientsAsync(name, limit); + return Ok(results); + } + + [HttpGet("recipes")] + public async Task SearchRecipes([FromQuery] RecipeSearchRequest request) + { + var results = await recipeService.SearchRecipesDetailedAsync(request); + return Ok(results); + } +} \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/CookifyAPI.csproj b/backend/CookifyAPI/CookifyAPI/CookifyAPI.csproj index db6946e..153941d 100644 --- a/backend/CookifyAPI/CookifyAPI/CookifyAPI.csproj +++ b/backend/CookifyAPI/CookifyAPI/CookifyAPI.csproj @@ -14,6 +14,7 @@ + diff --git a/backend/CookifyAPI/CookifyAPI/Extensions/ApplicationServiceExtensions.cs b/backend/CookifyAPI/CookifyAPI/Extensions/ApplicationServiceExtensions.cs index af4f11c..c2561b1 100644 --- a/backend/CookifyAPI/CookifyAPI/Extensions/ApplicationServiceExtensions.cs +++ b/backend/CookifyAPI/CookifyAPI/Extensions/ApplicationServiceExtensions.cs @@ -1,11 +1,12 @@ using System.Reflection; using CookifyAPI.Services; +using Meilisearch; namespace CookifyAPI.Extensions; public static class ApplicationServiceExtensions { - public static IServiceCollection AddApplicationServices(this IServiceCollection services) + public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration) { services.AddScoped(); services.AddScoped(); @@ -14,7 +15,16 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); - + + var meiliUrl = configuration["MeilisearchSettings:Url"]; + var meiliKey = configuration["MeilisearchSettings:MasterKey"]; + + if (string.IsNullOrEmpty(meiliUrl) || string.IsNullOrEmpty(meiliKey)) + throw new InvalidOperationException("Meilisearch Url or MasterKey is not configured."); + + // Регистрируем клиент (Singleton, т.к. он держит HTTP-соединения открытыми) + services.AddSingleton(new MeilisearchClient(meiliUrl, meiliKey)); + services.AddScoped(); // Автоматический поиск профилей AutoMapper //services.AddAutoMapper(Assembly.GetExecutingAssembly()); diff --git a/backend/CookifyAPI/CookifyAPI/Extensions/MigrationExtensions.cs b/backend/CookifyAPI/CookifyAPI/Extensions/MigrationExtensions.cs index de133a4..916e306 100644 --- a/backend/CookifyAPI/CookifyAPI/Extensions/MigrationExtensions.cs +++ b/backend/CookifyAPI/CookifyAPI/Extensions/MigrationExtensions.cs @@ -1,38 +1,84 @@ using CookifyAPI.Data; +using CookifyAPI.Models.DTOs.Search; +using CookifyAPI.Services; using Microsoft.EntityFrameworkCore; namespace CookifyAPI.Extensions; public static class MigrationExtensions { - public static void ApplyMigrations(this IApplicationBuilder app) + public static async Task ApplyMigrations(this IApplicationBuilder app) { - using (var scope = app.ApplicationServices.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - - // Попытка применить миграции несколько раз (защита от медленного старта БД) - int retries = 20; - while (retries > 0) + using var scope = app.ApplicationServices.CreateScope(); + var services = scope.ServiceProvider; + + var db = services.GetRequiredService(); + var searchService = services.GetRequiredService(); + + // Попытка применить миграции несколько раз (защита от медленного старта БД) + var retries = 20; + var dbReady = false; + + while (retries > 0) + try { - try - { - db.Database.Migrate(); - Console.WriteLine("Database check/migration completed successfully."); - break; - } - catch (Exception ex) + db.Database.Migrate(); + Console.WriteLine("Database check/migration completed successfully."); + dbReady = true; + break; + } + catch (Exception ex) + { + retries--; + if (retries == 0) { - retries--; - if (retries == 0) - { - Console.WriteLine("CRITICAL ERROR: Database is not ready after multiple retries."); - throw; // Приложение упадет, и Docker его перезапустит - } - Console.WriteLine($"WARNING: Database is starting up... waiting. ({retries} attempts left)"); - Thread.Sleep(10000); + Console.WriteLine("CRITICAL ERROR: Database is not ready after multiple retries."); + throw; // Приложение упадет, и Docker его перезапустит } + + Console.WriteLine($"WARNING: Database is starting up... waiting. ({retries} attempts left)"); + Thread.Sleep(10000); + } + + if (dbReady) + try + { + Console.WriteLine("Starting Meilisearch index synchronization..."); + await searchService.SetupIndicesAsync(); + + // Синхронизируем Ингредиенты + var ingredients = await db.Ingredients + .AsNoTracking() + .Select(i => new IngredientSearchDocument(i.Id, i.Name, i.Calories100g, i.Protein100g, i.Fat100g, i.Carb100g)) + .ToListAsync(); + + if (ingredients.Count != 0) + await searchService.IndexIngredientsAsync(ingredients); + + // Синхронизируем Теги + var tags = await db.Tags + .AsNoTracking() + .Select(t => new TagSearchDocument(t.Id, t.Name)) + .ToListAsync(); + + if (tags.Count != 0) + await searchService.IndexTagsAsync(tags); + + // Синхронизируем Рецепты + var recipes = await db.Recipes + .AsNoTracking() + .Select(r => new RecipeSearchDocument(r.Id, r.Title)) + .ToListAsync(); + + if (recipes.Count != 0) + await searchService.IndexRecipesAsync(recipes); + + Console.WriteLine("Meilisearch synchronization completed successfully."); + + } + catch (Exception ex) + { + Console.WriteLine($"ERROR: Failed to sync Meilisearch: {ex.Message}"); } - } } } \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/Models/DTOs/Requests/RecipeSearchRequest.cs b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Requests/RecipeSearchRequest.cs new file mode 100644 index 0000000..afe567a --- /dev/null +++ b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Requests/RecipeSearchRequest.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; + +namespace CookifyAPI.Models.DTOs.Requests; + +public record RecipeSearchRequest( + string? Title, + int? MaxCookingTime, + float? MinCarb, float? MaxCarb, + float? MinProtein, float? MaxProtein, + float? MinFat, float? MaxFat, + float? MinCalories, float? MaxCalories, + int? Difficulty, + [FromQuery] int[]? TagIds, + [FromQuery] int[]? IngredientIds +); \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/IngredientSearchDocument.cs b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/IngredientSearchDocument.cs new file mode 100644 index 0000000..bf30c15 --- /dev/null +++ b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/IngredientSearchDocument.cs @@ -0,0 +1,10 @@ +namespace CookifyAPI.Models.DTOs.Search; + +public record IngredientSearchDocument( + int Id, + string Name, + float? Calories100g, + float? Protein100g, + float? Fat100g, + float? Carb100g +); \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/RecipeSearchDocument.cs b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/RecipeSearchDocument.cs new file mode 100644 index 0000000..5c35088 --- /dev/null +++ b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/RecipeSearchDocument.cs @@ -0,0 +1,6 @@ +namespace CookifyAPI.Models.DTOs.Search; + +public record RecipeSearchDocument( + int Id, + string Title +); \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/TagSearchDocument.cs b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/TagSearchDocument.cs new file mode 100644 index 0000000..dadb928 --- /dev/null +++ b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/TagSearchDocument.cs @@ -0,0 +1,6 @@ +namespace CookifyAPI.Models.DTOs.Search; + +public record TagSearchDocument( + int Id, + string Name +); \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/Models/Settings/MeilishSettings.cs b/backend/CookifyAPI/CookifyAPI/Models/Settings/MeilishSettings.cs new file mode 100644 index 0000000..68e7f92 --- /dev/null +++ b/backend/CookifyAPI/CookifyAPI/Models/Settings/MeilishSettings.cs @@ -0,0 +1,7 @@ +namespace CookifyAPI.Models.Settings; + +public record MeilisearchSettings +{ + public string Url { get; init; } = string.Empty; + public string MasterKey { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/Program.cs b/backend/CookifyAPI/CookifyAPI/Program.cs index c3a9db6..bf8d688 100644 --- a/backend/CookifyAPI/CookifyAPI/Program.cs +++ b/backend/CookifyAPI/CookifyAPI/Program.cs @@ -6,7 +6,7 @@ builder.Services.AddIdentityServices(builder.Configuration); builder.Services.AddSwaggerServices(); -builder.Services.AddApplicationServices(); +builder.Services.AddApplicationServices(builder.Configuration); builder.Services.AddWebServices(); var app = builder.Build(); @@ -22,7 +22,7 @@ }); } -app.ApplyMigrations(); +await app.ApplyMigrations(); app.UseCors("AllowAll"); app.UseAuthentication(); diff --git a/backend/CookifyAPI/CookifyAPI/Services/Implementations/MeilisearchService.cs b/backend/CookifyAPI/CookifyAPI/Services/Implementations/MeilisearchService.cs new file mode 100644 index 0000000..6129f67 --- /dev/null +++ b/backend/CookifyAPI/CookifyAPI/Services/Implementations/MeilisearchService.cs @@ -0,0 +1,62 @@ +using CookifyAPI.Models.DTOs.Search; +using Meilisearch; + +namespace CookifyAPI.Services; + +public class MeilisearchService(MeilisearchClient client) : ISearchService +{ + private const string TagsIndex = "tags"; + private const string IngredientsIndex = "ingredients"; + private const string RecipesIndex = "recipes"; + + public async Task> SearchTagsAsync(string query, int limit = 20) + { + var index = client.Index(TagsIndex); + var searchQuery = new SearchQuery { Limit = limit }; + + var result = await index.SearchAsync(query, searchQuery); + return result.Hits; + } + + public async Task> SearchIngredientsAsync(string query, int limit = 20) + { + var index = client.Index(IngredientsIndex); + var searchQuery = new SearchQuery { Limit = limit }; + + var result = await index.SearchAsync(query, searchQuery); + return result.Hits; + } + + public async Task SearchRecipeIdsAsync(string query, int limit = 30) + { + var index = client.Index(RecipesIndex); + var searchQuery = new SearchQuery { Limit = limit }; + + var result = await index.SearchAsync(query, searchQuery); + return result.Hits.Select(x => x.Id).ToArray(); + } + + public async Task IndexTagsAsync(IEnumerable tags) + { + var index = client.Index(TagsIndex); + await index.AddDocumentsAsync(tags); + } + + public Task IndexIngredientsAsync(IEnumerable ingredients) + { + var index = client.Index(IngredientsIndex); + return index.AddDocumentsAsync(ingredients); + } + + public async Task IndexRecipesAsync(IEnumerable recipes) + { + await client.Index("recipes").AddDocumentsAsync(recipes); + } + + public async Task SetupIndicesAsync() + { + await client.Index(TagsIndex).UpdateSearchableAttributesAsync(new[] { "name" }); + await client.Index(IngredientsIndex).UpdateSearchableAttributesAsync(new[] { "name" }); + await client.Index(RecipesIndex).UpdateSearchableAttributesAsync(new[] { "title" }); + } +} \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/Services/Implementations/RecipeService.cs b/backend/CookifyAPI/CookifyAPI/Services/Implementations/RecipeService.cs index 5b1fa73..eea8793 100644 --- a/backend/CookifyAPI/CookifyAPI/Services/Implementations/RecipeService.cs +++ b/backend/CookifyAPI/CookifyAPI/Services/Implementations/RecipeService.cs @@ -2,13 +2,16 @@ using CookifyAPI.Models.DTOs; using CookifyAPI.Models.DTOs.Pagination; using CookifyAPI.Models.DTOs.Recipes; +using CookifyAPI.Models.DTOs.Requests; using CookifyAPI.Models.Entities; using CookifyAPI.Services; using Microsoft.EntityFrameworkCore; namespace CookifyAPI.Services; -public class RecipeService(AppDbContext context) : IRecipeService +public class RecipeService( + AppDbContext context, + ISearchService searchService) : IRecipeService { public async Task> GetRecipesListAsync() { @@ -29,8 +32,6 @@ public async Task> GetRecipesListAsync() .FirstOrDefault() }) .ToListAsync(); - - } public async Task GetRecipeByIdAsync(int id) @@ -175,4 +176,95 @@ public async Task> GetRecipesKeysetAsync(int? l LastId = newLastId }; } + + public async Task> SearchRecipesDetailedAsync(RecipeSearchRequest request) + { + var query = context.Recipes.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(request.Title)) + { + var recipeIds = await searchService.SearchRecipeIdsAsync(request.Title); + + // Если Meili ничего не нашел, возвращаем пустой список, чтобы не делать запрос к БД + if (recipeIds.Length == 0) + { + Console.WriteLine($"No recipes found for {request.Title}"); + return new List(); + } + + query = query.Where(r => recipeIds.Contains(r.Id)); + } + + if (request.MaxCookingTime.HasValue) query = query.Where(r => r.CookingTimeMin <= request.MaxCookingTime); + if (request.Difficulty.HasValue) query = query.Where(r => r.Difficulty == request.Difficulty); + + // Фильтры по БЖУ и Калориям + if (request.MinCalories.HasValue) query = query.Where(r => r.Calories100g >= request.MinCalories); + if (request.MaxCalories.HasValue) query = query.Where(r => r.Calories100g <= request.MaxCalories); + + if (request.MinProtein.HasValue) query = query.Where(r => r.Protein100g >= request.MinProtein); + if (request.MaxProtein.HasValue) query = query.Where(r => r.Protein100g <= request.MaxProtein); + + if (request.MinFat.HasValue) query = query.Where(r => r.Fat100g >= request.MinFat); + if (request.MaxFat.HasValue) query = query.Where(r => r.Fat100g <= request.MaxFat); + + if (request.MinCarb.HasValue) query = query.Where(r => r.Carb100g >= request.MinCarb); + if (request.MaxCarb.HasValue) query = query.Where(r => r.Carb100g <= request.MaxCarb); + + if (request.TagIds is { Length: > 0 }) + { + query = query.Where(r => r.Tags.Any(t => request.TagIds.Contains(t.TagId))); + } + + if (request.IngredientIds is { Length: > 0 }) + { + query = query.Where(r => r.Ingredients.Any(i => request.IngredientIds.Contains(i.IngredientId))); + } + + var recipes = await query + .AsSplitQuery() + .Select(r => new RecipeDetailDto + { + Id = r.Id, + Title = r.Title, + CookingTimeMinutes = r.CookingTimeMin, + Servings = r.Servings, + AuthorId = r.AuthorId, + Calories100g = r.Calories100g, + Protein100g = r.Protein100g, + Fat100g = r.Fat100g, + Carb100g = r.Carb100g, + CreatedAt = r.CreatedAt, + Description = r.Description, + Difficulty = r.Difficulty, + + // Маппим связанные списки (Коллекции) + Images = r.Images.Select(img => new RecipeImageDto + { + Id = img.Id, + Url = img.Url // Подставьте свои поля + }).ToList(), + + Steps = r.Steps.Select(step => new RecipeStepDto + { + Id = step.Id, + StepNumber = step.StepNumber, + Description = step.Description + }).ToList(), + + // Для M2M: Достаем Name из связанной таблицы Tag + Tags = r.Tags.Select(m2m => m2m.Tag.Name).ToList(), + + // Для M2M: Создаем IngredientDto из связанной таблицы Ingredient + Ingredients = r.Ingredients.Select(m2m => new IngredientDto + { + Id = m2m.Ingredient.Id, + Name = m2m.Ingredient.Name, + Amount = m2m.Amount // Предположим, количество хранится в M2M таблице + }).ToList() + }) + .ToListAsync(); + + return recipes; + } } \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/Services/Interfaces/IRecipeService.cs b/backend/CookifyAPI/CookifyAPI/Services/Interfaces/IRecipeService.cs index f6f7812..43a0f8c 100644 --- a/backend/CookifyAPI/CookifyAPI/Services/Interfaces/IRecipeService.cs +++ b/backend/CookifyAPI/CookifyAPI/Services/Interfaces/IRecipeService.cs @@ -1,5 +1,6 @@ using CookifyAPI.Models.DTOs.Pagination; using CookifyAPI.Models.DTOs.Recipes; +using CookifyAPI.Models.DTOs.Requests; namespace CookifyAPI.Services; @@ -9,4 +10,5 @@ public interface IRecipeService Task GetRecipeByIdAsync(int id); Task> GetRecipesOffsetAsync(int page); Task> GetRecipesKeysetAsync(int? lastId); + Task> SearchRecipesDetailedAsync(RecipeSearchRequest request); } \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/Services/Interfaces/ISearchService.cs b/backend/CookifyAPI/CookifyAPI/Services/Interfaces/ISearchService.cs new file mode 100644 index 0000000..18689e7 --- /dev/null +++ b/backend/CookifyAPI/CookifyAPI/Services/Interfaces/ISearchService.cs @@ -0,0 +1,19 @@ +using CookifyAPI.Models.DTOs.Search; + +namespace CookifyAPI.Services; + +public interface ISearchService +{ + // Методы поиска (возвращают готовые списки) + Task> SearchTagsAsync(string query, int limit = 20); + Task> SearchIngredientsAsync(string query, int limit = 20); + Task SearchRecipeIdsAsync(string query, int limit = 100); + + // Методы синхронизации (добавление пачками для производительности) + Task IndexTagsAsync(IEnumerable tags); + Task IndexIngredientsAsync(IEnumerable ingredients); + Task IndexRecipesAsync(IEnumerable recipes); + + // Первоначальная настройка индексов + Task SetupIndicesAsync(); +} \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/appsettings.json b/backend/CookifyAPI/CookifyAPI/appsettings.json index e3e13f3..5825fb3 100644 --- a/backend/CookifyAPI/CookifyAPI/appsettings.json +++ b/backend/CookifyAPI/CookifyAPI/appsettings.json @@ -48,5 +48,10 @@ "SignIn": { "RequireConfirmedEmail": true } + }, + + "MeilisearchSettings": { + "Url": "http://localhost:7700", + "MasterKey": "replace-me" } } diff --git a/infrastructure/.env.example b/infrastructure/.env.example index a3b5aa8..7afe545 100644 --- a/infrastructure/.env.example +++ b/infrastructure/.env.example @@ -8,6 +8,9 @@ JWT_KEY=GenerateSomeLongRandomStringHereForDevelopment SMTP_PASSWORD=get_app_password_from_google SENDER_EMAIL=your_dev_email@gmail.com +# Meilisearch +MEILI_MASTER_KEY=YourSuperSecretMasterKey123! + # Cloudinary CLOUDINARY_CLOUD_NAME= CLOUDINARY_API_KEY= diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index ff47054..e1f99eb 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -18,7 +18,23 @@ services: interval: 10s timeout: 5s retries: 15 - + + meilisearch: + image: getmeili/meilisearch:latest + container_name: meilisearch + environment: + - MEILI_MASTER_KEY=${MEILI_MASTER_KEY} + - MEILI_ENV=development # Включает красивый UI для отладки + - MEILI_NO_ANALYTICS=true # Отключаем сбор телеметрии + ports: + - "7700:7700" + volumes: + - meili_data:/meili_data + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:7700/health" ] + interval: 10s + timeout: 5s + retries: 5 cookifyapi: build: @@ -28,6 +44,8 @@ services: depends_on: db: condition: service_healthy + meilisearch: + condition: service_healthy environment: - ASPNETCORE_ENVIRONMENT=Development # Включаем поддержку HTTPS @@ -45,10 +63,15 @@ services: - Cloudinary__CloudName=${CLOUDINARY_CLOUD_NAME} - Cloudinary__ApiKey=${CLOUDINARY_API_KEY} - Cloudinary__ApiSecret=${CLOUDINARY_API_SECRET} + + # Передаем ключи Meilisearch в ваше API + - MeilisearchSettings__Url=http://meilisearch:7700 + - MeilisearchSettings__MasterKey=${MEILI_MASTER_KEY} ports: - "5022:443" volumes: - ./dev-certs:/https:ro volumes: - mssql_data: \ No newline at end of file + mssql_data: + meili_data: \ No newline at end of file