From 0871163733f278f004d358878b827b4531f8e717 Mon Sep 17 00:00:00 2001 From: Maksim Kuchko Date: Mon, 11 May 2026 23:36:32 +0300 Subject: [PATCH] Added publishing recipe --- .../Controllers/RecipesController.cs | 23 +- .../Models/DTOs/Recipes/RecipeStepDto.cs | 3 +- .../DTOs/Requests/RecipePublichRequest.cs | 18 + .../RecipePublishIngredientRequest.cs | 10 + .../DTOs/Requests/RecipePublishStepRequest.cs | 9 + .../Implementations/CloudinaryImageService.cs | 20 + .../Services/Implementations/RecipeService.cs | 345 ++++++++++-------- .../Services/Interfaces/IImageService.cs | 2 + .../Services/Interfaces/IRecipeService.cs | 1 + 9 files changed, 280 insertions(+), 151 deletions(-) create mode 100644 backend/CookifyAPI/CookifyAPI/Models/DTOs/Requests/RecipePublichRequest.cs create mode 100644 backend/CookifyAPI/CookifyAPI/Models/DTOs/Requests/RecipePublishIngredientRequest.cs create mode 100644 backend/CookifyAPI/CookifyAPI/Models/DTOs/Requests/RecipePublishStepRequest.cs diff --git a/backend/CookifyAPI/CookifyAPI/Controllers/RecipesController.cs b/backend/CookifyAPI/CookifyAPI/Controllers/RecipesController.cs index 689d464..7d4affe 100644 --- a/backend/CookifyAPI/CookifyAPI/Controllers/RecipesController.cs +++ b/backend/CookifyAPI/CookifyAPI/Controllers/RecipesController.cs @@ -1,12 +1,16 @@ -using CookifyAPI.Models.DTOs.Recipes; +using System.Security.Claims; +using CookifyAPI.Models.DTOs.Recipes; +using CookifyAPI.Models.DTOs.Requests; +using CookifyAPI.Models.Entities; using CookifyAPI.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace CookifyAPI.Controllers; [ApiController] [Route("api/[controller]")] -public class RecipesController(IRecipeService service) : ControllerBase +public class RecipesController(IRecipeService service) : AuthBaseController { // GET: api/recipes /// @@ -31,6 +35,21 @@ public async Task> GetRecipe(int id) return Ok(recipe); } + + [HttpPost] + [Authorize] + public async Task CreateRecipe([FromBody] RecipePublishRequest request) + { + try + { + var id = await service.CreateRecipeAsync(CurrentUserId, request); + return Ok(new { id }); + } + catch (Exception ex) + { + return BadRequest(new { error = ex.Message }); + } + } // [HttpPost] diff --git a/backend/CookifyAPI/CookifyAPI/Models/DTOs/Recipes/RecipeStepDto.cs b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Recipes/RecipeStepDto.cs index 6c04157..e49933b 100644 --- a/backend/CookifyAPI/CookifyAPI/Models/DTOs/Recipes/RecipeStepDto.cs +++ b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Recipes/RecipeStepDto.cs @@ -3,8 +3,9 @@ public class RecipeStepDto { public int Id { get; set; } - public string Title {get; set;} + public string Title { get; set; } = null!; public int StepNumber { get; set; } public string Description { get; set; } = null!; public string? ImageUrl { get; set; } + } \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/Models/DTOs/Requests/RecipePublichRequest.cs b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Requests/RecipePublichRequest.cs new file mode 100644 index 0000000..fe83e3e --- /dev/null +++ b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Requests/RecipePublichRequest.cs @@ -0,0 +1,18 @@ +namespace CookifyAPI.Models.DTOs.Requests; + +public record RecipePublishRequest( + string Title, + int CookingTimeMinutes, + int Servings, + float Calories100g, + float Protein100g, + float Fat100g, + float Carb100g, + string Description, + int Difficulty, + + string? MainImageBase64, + List Steps, + List Tags, + List Ingredients +); diff --git a/backend/CookifyAPI/CookifyAPI/Models/DTOs/Requests/RecipePublishIngredientRequest.cs b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Requests/RecipePublishIngredientRequest.cs new file mode 100644 index 0000000..7db73d4 --- /dev/null +++ b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Requests/RecipePublishIngredientRequest.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace CookifyAPI.Models.DTOs.Requests; + +public record RecipePublishIngredientRequest +( + int Id, + float Amount, + string Unit +); \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/Models/DTOs/Requests/RecipePublishStepRequest.cs b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Requests/RecipePublishStepRequest.cs new file mode 100644 index 0000000..8a990ca --- /dev/null +++ b/backend/CookifyAPI/CookifyAPI/Models/DTOs/Requests/RecipePublishStepRequest.cs @@ -0,0 +1,9 @@ +namespace CookifyAPI.Models.DTOs.Requests; + +public record RecipePublishStepRequest +( + string Title, + int StepNumber, + string Description, + string? ImageBase64 +); \ No newline at end of file diff --git a/backend/CookifyAPI/CookifyAPI/Services/Implementations/CloudinaryImageService.cs b/backend/CookifyAPI/CookifyAPI/Services/Implementations/CloudinaryImageService.cs index db8eb87..2817b18 100644 --- a/backend/CookifyAPI/CookifyAPI/Services/Implementations/CloudinaryImageService.cs +++ b/backend/CookifyAPI/CookifyAPI/Services/Implementations/CloudinaryImageService.cs @@ -53,6 +53,26 @@ public async Task UploadAsync(IFormFile file, string folder, int maxFile return result.SecureUrl.ToString(); } + + public async Task UploadImageBase64Async(string base64String, string folder) + { + if (string.IsNullOrWhiteSpace(base64String)) return null; + + // Cloudinary умеет принимать Data URI (data:image/png;base64,...) + // Если клиент присылает чистый Base64, добавим префикс + var prefix = "data:image/png;base64,"; + var imageData = base64String.StartsWith("data:image") ? base64String : prefix + base64String; + + var uploadParams = new ImageUploadParams + { + File = new FileDescription(imageData), + Folder = folder, + Transformation = new Transformation().Quality("auto").FetchFormat("auto") // Оптимизация + }; + + var uploadResult = await _cloudinary.UploadAsync(uploadParams); + return uploadResult.SecureUrl?.ToString(); + } } public class PayloadTooLargeException : Exception diff --git a/backend/CookifyAPI/CookifyAPI/Services/Implementations/RecipeService.cs b/backend/CookifyAPI/CookifyAPI/Services/Implementations/RecipeService.cs index f344f36..1487f26 100644 --- a/backend/CookifyAPI/CookifyAPI/Services/Implementations/RecipeService.cs +++ b/backend/CookifyAPI/CookifyAPI/Services/Implementations/RecipeService.cs @@ -3,37 +3,19 @@ using CookifyAPI.Models.DTOs.Pagination; using CookifyAPI.Models.DTOs.Recipes; using CookifyAPI.Models.DTOs.Requests; +using CookifyAPI.Models.DTOs.Search; using CookifyAPI.Models.Entities; -using CookifyAPI.Services; using Microsoft.EntityFrameworkCore; namespace CookifyAPI.Services; public class RecipeService( AppDbContext context, - ISearchService searchService) : IRecipeService + ISearchService searchService, + IImageService imageService) : IRecipeService { - public async Task> GetRecipesListAsync() - { - return await context.Recipes - .Select(r => new RecipeListDto - { - Id = r.Id, - Title = r.Title, - CookingTimeMin = r.CookingTimeMin, - Servings = r.Servings, - Difficulty = r.Difficulty, - Tags = r.Tags - .Select(rt => rt.Tag.Name) - .ToList(), - PreviewImageUrl = r.Images - .OrderBy(i => i.Order) - .Select(i => i.Url) - .FirstOrDefault() - }) - .ToListAsync(); - } - + private readonly int _pageSize = 15; + public async Task GetRecipeByIdAsync(int id) { return await context.Recipes @@ -94,49 +76,7 @@ public async Task> GetRecipesListAsync() }) .FirstOrDefaultAsync(); } - - public async Task> GetRecipesOffsetAsync(int page) - { - //page = Math.Max(page, 1); - //pageSize = Math.Clamp(pageSize, 1, 50); - var query = context.Recipes - .AsNoTracking() - .AsSplitQuery(); - var total = await query.CountAsync(); - - var result = new OffsetPagedResult - { - TotalCount = total, - Page = page - }; - - int pageSize = result.PageSize; - - var items = await query - .OrderBy(r => r.Id) // обязательно - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(r => new RecipeListDto - { - Id = r.Id, - Title = r.Title, - CookingTimeMin = r.CookingTimeMin, - Servings = r.Servings, - Difficulty = r.Difficulty, - Tags = r.Tags.Select(t => t.Tag.Name).ToList(), - PreviewImageUrl = r.Images - .OrderBy(i => i.Order) - .Select(i => i.Url) - .FirstOrDefault() - }) - .ToListAsync(); - - result.Items = items; - return result; - } - - private int _pageSize = 15; public async Task> GetRecipesKeysetAsync(int? lastId) { //_pageSize = Math.Clamp(_pageSize, 1, 50); @@ -146,10 +86,7 @@ public async Task> GetRecipesKeysetAsync(int? l .AsSplitQuery() .OrderBy(r => r.Id); - if (lastId.HasValue) - { - query = query.Where(r => r.Id > lastId.Value); - } + if (lastId.HasValue) query = query.Where(r => r.Id > lastId.Value); var items = await query .Take(_pageSize) @@ -177,170 +114,282 @@ public async Task> GetRecipesKeysetAsync(int? l }; } - public async Task> SearchRecipesDetailedAsync(RecipeSearchRequest request) + public async Task> SearchRecipesAsync(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(); + 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.Difficulty is { Length: > 0 }) query = query.Where(r => request.Difficulty.Contains(r.Difficulty)); + if (request.TagIds is { Length: > 0 }) { - query = query.Where(r => r.Tags.Any(t => request.TagIds.Contains(t.TagId))); + var targetIds = request.TagIds.Distinct().ToList(); + var targetCount = targetIds.Count; + query = query.Where(r => r.Tags.Count(i => targetIds.Contains(i.TagId)) == targetCount); } if (request.IngredientIds is { Length: > 0 }) { - query = query.Where(r => r.Ingredients.Any(i => request.IngredientIds.Contains(i.IngredientId))); - } + var targetIds = request.IngredientIds.Distinct().ToList(); + var targetCount = targetIds.Count; - if (request.Difficulty is { Length: > 0 }) - { - query = query.Where(r => request.Difficulty.Contains(r.Difficulty)); + query = query.Where(r => r.Ingredients.Count(i => targetIds.Contains(i.IngredientId)) == targetCount); } var recipes = await query .AsSplitQuery() - .Select(r => new RecipeDetailDto + .Select(r => new RecipeListDto { Id = r.Id, Title = r.Title, - CookingTimeMinutes = r.CookingTimeMin, + CookingTimeMin = 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, + PreviewImageUrl = r.Images + .OrderBy(i => i.Order) + .Select(i => i.Url) + .FirstOrDefault(), + Tags = r.Tags.Select(m2m => m2m.Tag.Name).ToList() + }) + .ToListAsync(); - // Маппим связанные списки (Коллекции) - Images = r.Images.Select(img => new RecipeImageDto - { - Id = img.Id, - Url = img.Url // Подставьте свои поля - }).ToList(), + return recipes; + } - Steps = r.Steps.Select(step => new RecipeStepDto - { - Id = step.Id, - StepNumber = step.StepNumber, - Description = step.Description - }).ToList(), + public async Task CreateRecipeAsync(int authorId, RecipePublishRequest req) + { + // 1. Главное фото + string? mainImageUrl = null; + if (!string.IsNullOrEmpty(req.MainImageBase64)) + { + mainImageUrl = await imageService.UploadImageBase64Async(req.MainImageBase64, "CookifyApp/recipes"); + } - // Для M2M: Достаем Name из связанной таблицы Tag - Tags = r.Tags.Select(m2m => m2m.Tag.Name).ToList(), + var recipe = new Recipe + { + AuthorId = authorId, + Title = req.Title, + Description = req.Description, + CookingTimeMin = req.CookingTimeMinutes, + Servings = req.Servings, + Difficulty = req.Difficulty, + Calories100g = req.Calories100g, + Protein100g = req.Protein100g, + Fat100g = req.Fat100g, + Carb100g = req.Carb100g, + CreatedAt = DateTime.UtcNow, + Images = mainImageUrl != null ? [new RecipeImage { Url = mainImageUrl }] : [] + }; - // Для M2M: Создаем IngredientDto из связанной таблицы Ingredient - Ingredients = r.Ingredients.Select(m2m => new IngredientDto - { - Id = m2m.Ingredient.Id, - Name = m2m.Ingredient.Name, - Amount = m2m.Amount // Предположим, количество хранится в M2M таблице - }).ToList() + // 2. Шаги + foreach (var s in req.Steps) + { + string? stepImgUrl = null; + if (!string.IsNullOrEmpty(s.ImageBase64)) + { + stepImgUrl = await imageService.UploadImageBase64Async(s.ImageBase64, ""); + } + + recipe.Steps.Add(new RecipeStep + { + StepNumber = s.StepNumber, + Title = s.Title, + Description = s.Description, + ImageUrl = stepImgUrl + }); + } + + // 3. Теги и Ингредиенты + recipe.Tags = req.Tags.Select(id => new M2MRecipeTag { TagId = id }).ToList(); + recipe.Ingredients = req.Ingredients.Select(i => new M2MRecipeIngredient + { + IngredientId = i.Id, + Amount = i.Amount, + Unit = i.Unit + }).ToList(); + + await context.Recipes.AddAsync(recipe); + await context.SaveChangesAsync(); + + await searchService.IndexRecipesAsync([new RecipeSearchDocument(recipe.Id, recipe.Title)]); + + return recipe.Id; + } + + public async Task> GetRecipesListAsync() + { + return await context.Recipes + .Select(r => new RecipeListDto + { + Id = r.Id, + Title = r.Title, + CookingTimeMin = r.CookingTimeMin, + Servings = r.Servings, + Difficulty = r.Difficulty, + Tags = r.Tags + .Select(rt => rt.Tag.Name) + .ToList(), + PreviewImageUrl = r.Images + .OrderBy(i => i.Order) + .Select(i => i.Url) + .FirstOrDefault() }) .ToListAsync(); + } - return recipes; + public async Task> GetRecipesOffsetAsync(int page) + { + //page = Math.Max(page, 1); + //pageSize = Math.Clamp(pageSize, 1, 50); + var query = context.Recipes + .AsNoTracking() + .AsSplitQuery(); + + var total = await query.CountAsync(); + + var result = new OffsetPagedResult + { + TotalCount = total, + Page = page + }; + + var pageSize = result.PageSize; + + var items = await query + .OrderBy(r => r.Id) // обязательно + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(r => new RecipeListDto + { + Id = r.Id, + Title = r.Title, + CookingTimeMin = r.CookingTimeMin, + Servings = r.Servings, + Difficulty = r.Difficulty, + Tags = r.Tags.Select(t => t.Tag.Name).ToList(), + PreviewImageUrl = r.Images + .OrderBy(i => i.Order) + .Select(i => i.Url) + .FirstOrDefault() + }) + .ToListAsync(); + + result.Items = items; + return result; } - public async Task> SearchRecipesAsync(RecipeSearchRequest request) + 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(); + 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.Difficulty is { Length: > 0 }) - { - query = query.Where(r => request.Difficulty.Contains(r.Difficulty)); - } - + if (request.TagIds is { Length: > 0 }) - { - var targetIds = request.TagIds.Distinct().ToList(); - int targetCount = targetIds.Count; - query = query.Where(r => r.Tags.Count(i => targetIds.Contains(i.TagId)) == targetCount); - } + query = query.Where(r => r.Tags.Any(t => request.TagIds.Contains(t.TagId))); if (request.IngredientIds is { Length: > 0 }) - { - var targetIds = request.IngredientIds.Distinct().ToList(); - int targetCount = targetIds.Count; - - query = query.Where(r => r.Ingredients.Count(i => targetIds.Contains(i.IngredientId)) == targetCount); - } + query = query.Where(r => r.Ingredients.Any(i => request.IngredientIds.Contains(i.IngredientId))); + + if (request.Difficulty is { Length: > 0 }) query = query.Where(r => request.Difficulty.Contains(r.Difficulty)); var recipes = await query .AsSplitQuery() - .Select(r => new RecipeListDto() + .Select(r => new RecipeDetailDto { Id = r.Id, Title = r.Title, - CookingTimeMin = r.CookingTimeMin, + 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, - PreviewImageUrl = r.Images - .OrderBy(i => i.Order) - .Select(i => i.Url) - .FirstOrDefault(), + + // Маппим связанные списки (Коллекции) + 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(); diff --git a/backend/CookifyAPI/CookifyAPI/Services/Interfaces/IImageService.cs b/backend/CookifyAPI/CookifyAPI/Services/Interfaces/IImageService.cs index dc4beac..8817851 100644 --- a/backend/CookifyAPI/CookifyAPI/Services/Interfaces/IImageService.cs +++ b/backend/CookifyAPI/CookifyAPI/Services/Interfaces/IImageService.cs @@ -10,4 +10,6 @@ public interface IImageService /// Максимальный размер файла в МБ /// Публичный URL картинки Task UploadAsync(IFormFile file, string folder, int maxFileSizeMb = 10); + + Task UploadImageBase64Async(string base64String, string folder); } \ 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 4dc9c00..489db3f 100644 --- a/backend/CookifyAPI/CookifyAPI/Services/Interfaces/IRecipeService.cs +++ b/backend/CookifyAPI/CookifyAPI/Services/Interfaces/IRecipeService.cs @@ -12,4 +12,5 @@ public interface IRecipeService Task> GetRecipesKeysetAsync(int? lastId); //Task> SearchRecipesDetailedAsync(RecipeSearchRequest request); Task> SearchRecipesAsync(RecipeSearchRequest request); + Task CreateRecipeAsync(int authorId, RecipePublishRequest publishRequest); } \ No newline at end of file