From 7aa6a1041ac8d917d416eca34ebf3efde4c8b4ec Mon Sep 17 00:00:00 2001 From: Maksim Kuchko Date: Sun, 10 May 2026 00:26:32 +0300 Subject: [PATCH 1/3] Added meilisearch service --- .../Controllers/SearchController.cs | 20 +++++ .../CookifyAPI/CookifyAPI/CookifyAPI.csproj | 1 + .../ApplicationServiceExtensions.cs | 14 ++- .../Extensions/MigrationExtensions.cs | 86 +++++++++++++------ .../DTOs/Search/IngredientSearchDocument.cs | 6 ++ .../Models/DTOs/Search/TagSearchDocument.cs | 6 ++ .../Models/Settings/MeilishSettings.cs | 7 ++ backend/CookifyAPI/CookifyAPI/Program.cs | 4 +- .../Implementations/MeilisearchService.cs | 41 +++++++++ .../Services/Interfaces/ISearchService.cs | 17 ++++ .../CookifyAPI/CookifyAPI/appsettings.json | 5 ++ infrastructure/.env.example | 3 + infrastructure/docker-compose.yml | 27 +++++- 13 files changed, 207 insertions(+), 30 deletions(-) create mode 100644 backend/CookifyAPI/CookifyAPI/Controllers/SearchController.cs create mode 100644 backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/IngredientSearchDocument.cs create mode 100644 backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/TagSearchDocument.cs create mode 100644 backend/CookifyAPI/CookifyAPI/Models/Settings/MeilishSettings.cs create mode 100644 backend/CookifyAPI/CookifyAPI/Services/Implementations/MeilisearchService.cs create mode 100644 backend/CookifyAPI/CookifyAPI/Services/Interfaces/ISearchService.cs diff --git a/backend/CookifyAPI/CookifyAPI/Controllers/SearchController.cs b/backend/CookifyAPI/CookifyAPI/Controllers/SearchController.cs new file mode 100644 index 0000000..49ad795 --- /dev/null +++ b/backend/CookifyAPI/CookifyAPI/Controllers/SearchController.cs @@ -0,0 +1,20 @@ +using CookifyAPI.Models.DTOs.Search; +using CookifyAPI.Services; +using Microsoft.AspNetCore.Mvc; + +namespace CookifyAPI.Controllers; + +[ApiController] +[Route("api/search")] +public class SearchController(ISearchService searchService) : 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); + } +} \ 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..bc762a3 100644 --- a/backend/CookifyAPI/CookifyAPI/Extensions/MigrationExtensions.cs +++ b/backend/CookifyAPI/CookifyAPI/Extensions/MigrationExtensions.cs @@ -1,38 +1,76 @@ 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(); + + // Синхронизируем Ингредиенты (AsNoTracking для скорости) + + // var ingredients = await db.Ingredients + // .AsNoTracking() + // .Select(i => new IngredientSearchDocument(i.Id, i.Name)) + // .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); + + 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/Search/IngredientSearchDocument.cs b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/IngredientSearchDocument.cs new file mode 100644 index 0000000..1117341 --- /dev/null +++ b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/IngredientSearchDocument.cs @@ -0,0 +1,6 @@ +namespace CookifyAPI.Models.DTOs.Search; + +public record IngredientSearchDocument( + int Id, + string Name +); \ 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..ca7448c --- /dev/null +++ b/backend/CookifyAPI/CookifyAPI/Services/Implementations/MeilisearchService.cs @@ -0,0 +1,41 @@ +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"; + + + 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 Task> SearchIngredientsAsync(string query, int limit = 20) + { + throw new NotImplementedException(); + } + + public async Task IndexTagsAsync(IEnumerable tags) + { + var index = client.Index(TagsIndex); + await index.AddDocumentsAsync(tags); + } + + public Task IndexIngredientsAsync(IEnumerable ingredients) + { + throw new NotImplementedException(); + } + + public async Task SetupIndicesAsync() + { + await client.Index(TagsIndex).UpdateSearchableAttributesAsync(new[] { "name" }); + } +} \ 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..c9c2f70 --- /dev/null +++ b/backend/CookifyAPI/CookifyAPI/Services/Interfaces/ISearchService.cs @@ -0,0 +1,17 @@ +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 IndexTagsAsync(IEnumerable tags); + Task IndexIngredientsAsync(IEnumerable ingredients); + + // Первоначальная настройка индексов + 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 From 1bf2d0a5bd6ab952b545e88e5a034dfc1e0672a5 Mon Sep 17 00:00:00 2001 From: Maksim Kuchko Date: Sun, 10 May 2026 14:13:51 +0300 Subject: [PATCH 2/3] Added search ingredients --- .../CookifyAPI/Controllers/SearchController.cs | 9 +++++++++ .../CookifyAPI/Extensions/MigrationExtensions.cs | 15 +++++++-------- .../DTOs/Search/IngredientSearchDocument.cs | 6 +++++- .../Implementations/MeilisearchService.cs | 12 +++++++++--- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/backend/CookifyAPI/CookifyAPI/Controllers/SearchController.cs b/backend/CookifyAPI/CookifyAPI/Controllers/SearchController.cs index 49ad795..10c46aa 100644 --- a/backend/CookifyAPI/CookifyAPI/Controllers/SearchController.cs +++ b/backend/CookifyAPI/CookifyAPI/Controllers/SearchController.cs @@ -17,4 +17,13 @@ public async Task SearchTags([FromQuery] string? name, [FromQuery 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); + } } \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/Extensions/MigrationExtensions.cs b/backend/CookifyAPI/CookifyAPI/Extensions/MigrationExtensions.cs index bc762a3..597797e 100644 --- a/backend/CookifyAPI/CookifyAPI/Extensions/MigrationExtensions.cs +++ b/backend/CookifyAPI/CookifyAPI/Extensions/MigrationExtensions.cs @@ -46,15 +46,14 @@ public static async Task ApplyMigrations(this IApplicationBuilder app) Console.WriteLine("Starting Meilisearch index synchronization..."); await searchService.SetupIndicesAsync(); - // Синхронизируем Ингредиенты (AsNoTracking для скорости) + // Синхронизируем Ингредиенты + var ingredients = await db.Ingredients + .AsNoTracking() + .Select(i => new IngredientSearchDocument(i.Id, i.Name, i.Calories100g, i.Protein100g, i.Fat100g, i.Carb100g)) + .ToListAsync(); - // var ingredients = await db.Ingredients - // .AsNoTracking() - // .Select(i => new IngredientSearchDocument(i.Id, i.Name)) - // .ToListAsync(); - // - // if (ingredients.Count != 0) - // await searchService.IndexIngredientsAsync(ingredients); + if (ingredients.Count != 0) + await searchService.IndexIngredientsAsync(ingredients); // Синхронизируем Теги var tags = await db.Tags diff --git a/backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/IngredientSearchDocument.cs b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/IngredientSearchDocument.cs index 1117341..bf30c15 100644 --- a/backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/IngredientSearchDocument.cs +++ b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/IngredientSearchDocument.cs @@ -2,5 +2,9 @@ public record IngredientSearchDocument( int Id, - string Name + string Name, + float? Calories100g, + float? Protein100g, + float? Fat100g, + float? Carb100g ); \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/Services/Implementations/MeilisearchService.cs b/backend/CookifyAPI/CookifyAPI/Services/Implementations/MeilisearchService.cs index ca7448c..d52ae47 100644 --- a/backend/CookifyAPI/CookifyAPI/Services/Implementations/MeilisearchService.cs +++ b/backend/CookifyAPI/CookifyAPI/Services/Implementations/MeilisearchService.cs @@ -18,9 +18,13 @@ public async Task> SearchTagsAsync(string return result.Hits; } - public Task> SearchIngredientsAsync(string query, int limit = 20) + public async Task> SearchIngredientsAsync(string query, int limit = 20) { - throw new NotImplementedException(); + var index = client.Index(IngredientsIndex); + var searchQuery = new SearchQuery { Limit = limit }; + + var result = await index.SearchAsync(query, searchQuery); + return result.Hits; } public async Task IndexTagsAsync(IEnumerable tags) @@ -31,11 +35,13 @@ public async Task IndexTagsAsync(IEnumerable tags) public Task IndexIngredientsAsync(IEnumerable ingredients) { - throw new NotImplementedException(); + var index = client.Index(IngredientsIndex); + return index.AddDocumentsAsync(ingredients); } public async Task SetupIndicesAsync() { await client.Index(TagsIndex).UpdateSearchableAttributesAsync(new[] { "name" }); + await client.Index(IngredientsIndex).UpdateSearchableAttributesAsync(new[] { "name" }); } } \ No newline at end of file From 7eadda97670b8196ef490f86d8c4b245456e9949 Mon Sep 17 00:00:00 2001 From: Maksim Kuchko Date: Sun, 10 May 2026 23:31:36 +0300 Subject: [PATCH 3/3] Added Search recipes --- .../CookifyAPI.sln.DotSettings.user | 1 + .../Controllers/SearchController.cs | 16 ++- .../Extensions/MigrationExtensions.cs | 9 ++ .../DTOs/Requests/RecipeSearchRequest.cs | 15 +++ .../DTOs/Search/RecipeSearchDocument.cs | 6 ++ .../Implementations/MeilisearchService.cs | 17 +++- .../Services/Implementations/RecipeService.cs | 98 ++++++++++++++++++- .../Services/Interfaces/IRecipeService.cs | 2 + .../Services/Interfaces/ISearchService.cs | 4 +- 9 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 backend/CookifyAPI/CookifyAPI/Models/DTOs/Requests/RecipeSearchRequest.cs create mode 100644 backend/CookifyAPI/CookifyAPI/Models/DTOs/Search/RecipeSearchDocument.cs 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 index 10c46aa..eb0382b 100644 --- a/backend/CookifyAPI/CookifyAPI/Controllers/SearchController.cs +++ b/backend/CookifyAPI/CookifyAPI/Controllers/SearchController.cs @@ -1,4 +1,5 @@ -using CookifyAPI.Models.DTOs.Search; +using CookifyAPI.Models.DTOs.Requests; +using CookifyAPI.Models.DTOs.Search; using CookifyAPI.Services; using Microsoft.AspNetCore.Mvc; @@ -6,7 +7,9 @@ namespace CookifyAPI.Controllers; [ApiController] [Route("api/search")] -public class SearchController(ISearchService searchService) : ControllerBase +public class SearchController( + ISearchService searchService, + IRecipeService recipeService) : ControllerBase { [HttpGet("tags")] public async Task SearchTags([FromQuery] string? name, [FromQuery] int limit = 20) @@ -22,8 +25,15 @@ public async Task SearchTags([FromQuery] string? name, [FromQuery public async Task SearchIngredients([FromQuery] string? name, [FromQuery] int limit = 20) { if (string.IsNullOrWhiteSpace(name)) - return Ok(Array.Empty()); + 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/Extensions/MigrationExtensions.cs b/backend/CookifyAPI/CookifyAPI/Extensions/MigrationExtensions.cs index 597797e..916e306 100644 --- a/backend/CookifyAPI/CookifyAPI/Extensions/MigrationExtensions.cs +++ b/backend/CookifyAPI/CookifyAPI/Extensions/MigrationExtensions.cs @@ -63,6 +63,15 @@ public static async Task ApplyMigrations(this IApplicationBuilder app) 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."); 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/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/Services/Implementations/MeilisearchService.cs b/backend/CookifyAPI/CookifyAPI/Services/Implementations/MeilisearchService.cs index d52ae47..6129f67 100644 --- a/backend/CookifyAPI/CookifyAPI/Services/Implementations/MeilisearchService.cs +++ b/backend/CookifyAPI/CookifyAPI/Services/Implementations/MeilisearchService.cs @@ -7,7 +7,7 @@ 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) { @@ -27,6 +27,15 @@ public async Task> SearchIngredien 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); @@ -39,9 +48,15 @@ public Task IndexIngredientsAsync(IEnumerable ingredie 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 index c9c2f70..18689e7 100644 --- a/backend/CookifyAPI/CookifyAPI/Services/Interfaces/ISearchService.cs +++ b/backend/CookifyAPI/CookifyAPI/Services/Interfaces/ISearchService.cs @@ -7,10 +7,12 @@ 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();