diff --git a/src/main/java/com/recipe/app/src/common/config/GeneralExceptionHandler.java b/src/main/java/com/recipe/app/src/common/config/GeneralExceptionHandler.java index 4de6d5aa..0ec13c69 100644 --- a/src/main/java/com/recipe/app/src/common/config/GeneralExceptionHandler.java +++ b/src/main/java/com/recipe/app/src/common/config/GeneralExceptionHandler.java @@ -9,6 +9,7 @@ import com.recipe.app.src.recipe.exception.NotFoundRecipeException; import com.recipe.app.src.recipe.exception.NotFoundRecipeLevelException; import com.recipe.app.src.common.utils.DiscordAlertService; +import com.recipe.app.src.common.utils.ScanAttackMonitor; import com.recipe.app.src.user.exception.ForbiddenAccessException; import com.recipe.app.src.user.exception.NotFoundUserException; import com.recipe.app.src.user.exception.UserTokenNotExistException; @@ -18,8 +19,11 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.web.firewall.RequestRejectedException; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.servlet.resource.NoResourceFoundException; import java.util.Map; @@ -30,9 +34,12 @@ public class GeneralExceptionHandler { // DiscordAlertService 는 prod 프로필에서만 빈으로 등록되므로 ObjectProvider 로 안전하게 주입한다. private final ObjectProvider discordAlertServiceProvider; + private final ScanAttackMonitor scanAttackMonitor; - public GeneralExceptionHandler(ObjectProvider discordAlertServiceProvider) { + public GeneralExceptionHandler(ObjectProvider discordAlertServiceProvider, + ScanAttackMonitor scanAttackMonitor) { this.discordAlertServiceProvider = discordAlertServiceProvider; + this.scanAttackMonitor = scanAttackMonitor; } @ExceptionHandler({NotFoundFridgeException.class, NotFoundIngredientException.class, NotFoundFridgeBasketException.class, @@ -55,17 +62,46 @@ public ResponseEntity handleBadRequestException(Exception e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e); } - // 위에서 처리되지 않은 모든 예외 = 500. 로그를 남기고 (prod 한정) Discord 로 알림을 보낸다. + // 존재하지 않는 경로 요청(취약점 스캐너/봇 등). 404 로 조용히 처리하고 알림은 보내지 않는다. + // 대량 공격 관찰용으로 IP·경로는 WARN 으로 남긴다. + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFound(NoResourceFoundException e, HttpServletRequest request) { + String clientIp = clientIp(request); + log.warn("No static resource. {} {} from {}", request.getMethod(), request.getRequestURI(), clientIp); + scanAttackMonitor.record(clientIp); + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + + // 방화벽(StrictHttpFirewall)이 막은 비정상 요청(잘못된 파라미터/경로 등). 400 으로 처리하고 알림은 보내지 않는다. + @ExceptionHandler(RequestRejectedException.class) + public ResponseEntity handleRequestRejected(RequestRejectedException e, HttpServletRequest request) { + String clientIp = clientIp(request); + log.warn("Request rejected by firewall. {} {} from {} - {}", request.getMethod(), request.getRequestURI(), clientIp, e.getMessage()); + scanAttackMonitor.record(clientIp); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("message", "잘못된 요청입니다.")); + } + + // 위에서 처리되지 않은 모든 예외 = 500. 로그를 남기고 (webhook 설정 시) Discord 로 알림을 보낸다. @ExceptionHandler(Exception.class) public ResponseEntity handleInternalServerError(Exception e, HttpServletRequest request) { - log.error("Internal Server Error. {} {}", request.getMethod(), request.getRequestURI(), e); + String clientIp = clientIp(request); + log.error("Internal Server Error. {} {} from {}", request.getMethod(), request.getRequestURI(), clientIp, e); DiscordAlertService discordAlertService = discordAlertServiceProvider.getIfAvailable(); if (discordAlertService != null) { - discordAlertService.sendErrorAlert(request.getMethod(), request.getRequestURI(), e); + discordAlertService.sendErrorAlert(request.getMethod(), request.getRequestURI(), clientIp, e); } return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(Map.of("message", "서버 오류가 발생했습니다.")); } + + // 프록시(nginx/ELB) 뒤에 있을 수 있으므로 X-Forwarded-For 를 우선 확인한다. + private String clientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (StringUtils.hasText(forwarded)) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } } diff --git a/src/main/java/com/recipe/app/src/common/utils/DiscordAlertService.java b/src/main/java/com/recipe/app/src/common/utils/DiscordAlertService.java index 13b37600..a4283889 100644 --- a/src/main/java/com/recipe/app/src/common/utils/DiscordAlertService.java +++ b/src/main/java/com/recipe/app/src/common/utils/DiscordAlertService.java @@ -42,12 +42,13 @@ public class DiscordAlertService { /** * 처리되지 않은 예외(500) 알림. */ - public void sendErrorAlert(String httpMethod, String requestUri, Throwable throwable) { + public void sendErrorAlert(String httpMethod, String requestUri, String clientIp, Throwable throwable) { Map embed = Map.of( "title", "🚨 500 Internal Server Error", "color", RED, "fields", List.of( field("Endpoint", httpMethod + " " + requestUri), + field("Client IP", clientIp), field("Exception", throwable.getClass().getSimpleName()), field("Message", truncate(throwable.getMessage())), field("Stacktrace", "```" + stackTrace(throwable) + "```") @@ -74,6 +75,22 @@ public void sendReportAlert(long recipeId, long reporterUserId, long reportCount send(reportWebhookUrl, embed); } + /** + * 스캔성 요청 급증 감지 알림 (개별 요청이 아니라 집계 결과 1회 전송). + * 드물게 발생하므로 별도 채널 없이 500 에러 채널로 보내되 제목/색으로 구분한다. + */ + public void sendScanSurgeAlert(int count, long windowMinutes, List topIps) { + Map embed = Map.of( + "title", "⚠️ 스캔성 요청 급증 감지", + "color", ORANGE, + "fields", List.of( + field("감지", "최근 " + windowMinutes + "분간 " + count + "건 (임계치 초과)"), + field("Top IP", topIps.isEmpty() ? "(none)" : String.join("\n", topIps)) + ) + ); + send(errorWebhookUrl, embed); + } + private void send(String webhookUrl, Map embed) { if (!StringUtils.hasText(webhookUrl)) { log.warn("Discord webhook URL 이 설정되지 않아 알림을 건너뜁니다."); diff --git a/src/main/java/com/recipe/app/src/common/utils/ScanAttackMonitor.java b/src/main/java/com/recipe/app/src/common/utils/ScanAttackMonitor.java new file mode 100644 index 00000000..dd3e53ef --- /dev/null +++ b/src/main/java/com/recipe/app/src/common/utils/ScanAttackMonitor.java @@ -0,0 +1,66 @@ +package com.recipe.app.src.common.utils; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 스캔성(존재하지 않는 경로 / 방화벽 차단) 요청을 집계해, 일정 시간 안에 임계치를 넘으면 + * Discord 로 "급증" 알림을 1회 보낸다. 개별 요청마다 알림을 보내지 않으므로 채널이 노이즈로 덮이지 않는다. + * + * 스케줄러 없이 요청 스레드에서 슬라이딩(tumbling) 윈도우를 갱신하며, 재알림은 쿨다운으로 억제한다. + */ +@Component +public class ScanAttackMonitor { + + private static final long WINDOW_MS = 5 * 60 * 1000L; // 집계 윈도우 5분 + private static final int THRESHOLD = 100; // 윈도우당 임계치 + private static final long COOLDOWN_MS = 10 * 60 * 1000L; // 재알림 최소 간격 10분 + private static final int TOP_IP_COUNT = 5; + + // DiscordAlertService 는 webhook 미설정/비활성 환경에서 없을 수 있으므로 ObjectProvider 로 안전하게 주입한다. + private final ObjectProvider discordAlertServiceProvider; + + private long windowStart = System.currentTimeMillis(); + private int count = 0; + private final Map ipCounts = new HashMap<>(); + private long lastAlertAt = 0L; + + public ScanAttackMonitor(ObjectProvider discordAlertServiceProvider) { + this.discordAlertServiceProvider = discordAlertServiceProvider; + } + + public synchronized void record(String clientIp) { + long now = System.currentTimeMillis(); + + // 윈도우가 지났으면 초기화 + if (now - windowStart > WINDOW_MS) { + windowStart = now; + count = 0; + ipCounts.clear(); + } + + count++; + ipCounts.merge(clientIp != null ? clientIp : "unknown", 1, Integer::sum); + + if (count >= THRESHOLD && now - lastAlertAt > COOLDOWN_MS) { + lastAlertAt = now; + DiscordAlertService discordAlertService = discordAlertServiceProvider.getIfAvailable(); + if (discordAlertService != null) { + discordAlertService.sendScanSurgeAlert(count, WINDOW_MS / 60000, topIps()); + } + } + } + + private List topIps() { + return ipCounts.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(TOP_IP_COUNT) + .map(e -> e.getKey() + " (" + e.getValue() + "건)") + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/recipe/app/src/recipe/api/PublicRecipeController.java b/src/main/java/com/recipe/app/src/recipe/api/PublicRecipeController.java index 70fa0791..0e055dac 100644 --- a/src/main/java/com/recipe/app/src/recipe/api/PublicRecipeController.java +++ b/src/main/java/com/recipe/app/src/recipe/api/PublicRecipeController.java @@ -2,7 +2,7 @@ import com.recipe.app.src.recipe.application.RecipeSearchService; import com.recipe.app.src.recipe.application.dto.RecipeDetailResponse; -import com.recipe.app.src.recipe.application.dto.RecommendedRecipesResponse; +import com.recipe.app.src.recipe.application.dto.RecipesResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -35,7 +35,7 @@ public RecipeDetailResponse getPublicRecipe(@PathVariable long recipeId) { @Operation(summary = "공개 추천 레시피 목록 조회 API (로그인 불필요)") @GetMapping("/recommendation") - public RecommendedRecipesResponse getPublicRecipesByIngredients( + public RecipesResponse getPublicRecipesByIngredients( @Parameter(example = "감자,당근,양파", name = "재료 목록 (쉼표로 구분)") @RequestParam(value = "ingredients") String ingredients, @Parameter(example = "0", name = "마지막 조회 레시피 아이디") diff --git a/src/main/java/com/recipe/app/src/recipe/api/RecipeController.java b/src/main/java/com/recipe/app/src/recipe/api/RecipeController.java index f1532d27..435e969d 100644 --- a/src/main/java/com/recipe/app/src/recipe/api/RecipeController.java +++ b/src/main/java/com/recipe/app/src/recipe/api/RecipeController.java @@ -6,7 +6,6 @@ import com.recipe.app.src.recipe.application.dto.RecipeDetailResponse; import com.recipe.app.src.recipe.application.dto.RecipeRequest; import com.recipe.app.src.recipe.application.dto.RecipesResponse; -import com.recipe.app.src.recipe.application.dto.RecommendedRecipesResponse; import com.recipe.app.src.user.domain.User; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -89,7 +88,7 @@ public RecipesResponse getScrapRecipes(@Parameter(hidden = true) User user, @Operation(summary = "냉장고 파먹기 레시피 목록 조회 API") @GetMapping("/fridges-recommendation") @LoginCheck - public RecommendedRecipesResponse getFridgesRecipes(@Parameter(hidden = true) User user, + public RecipesResponse getFridgesRecipes(@Parameter(hidden = true) User user, @Parameter(example = "0", name = "마지막 조회 레시피 아이디") @RequestParam(value = "startAfter") long startAfter, @Parameter(example = "20", name = "사이즈") diff --git a/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java b/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java index bdcec9ba..7566b0ea 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java @@ -7,7 +7,6 @@ import com.recipe.app.src.ingredient.application.IngredientSynonymCache; import com.recipe.app.src.recipe.application.dto.RecipeDetailResponse; import com.recipe.app.src.recipe.application.dto.RecipesResponse; -import com.recipe.app.src.recipe.application.dto.RecommendedRecipesResponse; import com.recipe.app.src.recipe.domain.Recipe; import com.recipe.app.src.recipe.domain.RecipeScrap; import com.recipe.app.src.recipe.domain.Recipes; @@ -125,9 +124,15 @@ public RecipesResponse findRecipesByUser(User user, long lastRecipeId, int size) long totalCnt = recipeRepository.countByUserId(user.getUserId()); - List recipes = findLimitByUserId(user.getUserId(), lastRecipeId, size); + Recipes recipes = new Recipes(findLimitByUserId(user.getUserId(), lastRecipeId, size)); - return getRecipes(user, totalCnt, new Recipes(recipes)); + List recipePostUsers = userService.findByUserIds(recipes.getUserIds()); + + List recipeScraps = recipeScrapService.findByRecipeIds(recipes.getRecipeIds()); + + List ingredientNamesInFridge = fridgeService.findIngredientNamesInFridge(user.getUserId()); + + return RecipesResponse.from(totalCnt, recipes, recipePostUsers, recipeScraps, user, ingredientNamesInFridge); } @Transactional(readOnly = true) @@ -146,7 +151,7 @@ private RecipesResponse getRecipes(User user, long totalCnt, Recipes recipes) { } @Transactional(readOnly = true) - public RecommendedRecipesResponse findRecommendedRecipesByUserFridge(User user, long lastRecipeId, int size) { + public RecipesResponse findRecommendedRecipesByUserFridge(User user, long lastRecipeId, int size) { List ingredientNamesInFridge = fridgeService.findIngredientNamesInFridge(user.getUserId()); @@ -158,11 +163,11 @@ public RecommendedRecipesResponse findRecommendedRecipesByUserFridge(User user, Recipe lastRecipe = recipeRepository.findById(lastRecipeId).orElse(null); - return RecommendedRecipesResponse.from(recipes, recipePostUsers, recipeScraps, user, ingredientNamesInFridge, lastRecipe, size); + return RecipesResponse.from(recipes, recipePostUsers, recipeScraps, user, ingredientNamesInFridge, lastRecipe, size); } @Transactional(readOnly = true) - public RecommendedRecipesResponse findPublicRecommendedRecipesByIngredients(List ingredientNames, long lastRecipeId, int size) { + public RecipesResponse findPublicRecommendedRecipesByIngredients(List ingredientNames, long lastRecipeId, int size) { List expandedIngredientNames = List.copyOf(ingredientSynonymCache.expand(ingredientNames)); @@ -176,6 +181,6 @@ public RecommendedRecipesResponse findPublicRecommendedRecipesByIngredients(List User anonymousUser = new User(); - return RecommendedRecipesResponse.from(recipes, recipePostUsers, recipeScraps, anonymousUser, expandedIngredientNames, lastRecipe, size); + return RecipesResponse.from(recipes, recipePostUsers, recipeScraps, anonymousUser, expandedIngredientNames, lastRecipe, size); } } diff --git a/src/main/java/com/recipe/app/src/recipe/application/dto/RecipeResponse.java b/src/main/java/com/recipe/app/src/recipe/application/dto/RecipeResponse.java index ae268951..f9a1769a 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/dto/RecipeResponse.java +++ b/src/main/java/com/recipe/app/src/recipe/application/dto/RecipeResponse.java @@ -38,10 +38,12 @@ public class RecipeResponse { private final long scrapCnt; @Schema(description = "조회수") private final long viewCnt; + @Schema(description = "재료 일치도") + private final long ingredientsMatchRate; @Builder public RecipeResponse(Long recipeId, String recipeName, String introduction, String thumbnailImgUrl, String postUserName, String postDate, - String linkUrl, Boolean isUserScrap, long scrapCnt, long viewCnt) { + String linkUrl, Boolean isUserScrap, long scrapCnt, long viewCnt, long ingredientsMatchRate) { this.recipeId = recipeId; this.recipeName = recipeName; @@ -53,10 +55,16 @@ public RecipeResponse(Long recipeId, String recipeName, String introduction, Str this.isUserScrap = isUserScrap; this.scrapCnt = scrapCnt; this.viewCnt = viewCnt; + this.ingredientsMatchRate = ingredientsMatchRate; } public static RecipeResponse from(Recipe recipe, User recipePostUser, List recipeScraps, User user) { + return from(recipe, recipePostUser, recipeScraps, user, 0L); + } + + public static RecipeResponse from(Recipe recipe, User recipePostUser, List recipeScraps, User user, long ingredientsMatchRate) { + return RecipeResponse.builder() .recipeId(recipe.getRecipeId()) .recipeName(recipe.getRecipeNm()) @@ -70,6 +78,7 @@ public static RecipeResponse from(Recipe recipe, User recipePostUser, List re .build(); } + public static RecipesResponse from(long totalCnt, Recipes recipes, List recipePostUsers, List recipeScraps, User user, + List ingredientNamesInFridge) { + + Set normalizedFridge = ingredientNamesInFridge.stream() + .filter(Objects::nonNull) + .map(s -> s.toLowerCase().trim()) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + + Map recipePostUserMapByUserId = recipePostUsers.stream() + .collect(Collectors.toMap(User::getUserId, Function.identity())); + + return RecipesResponse.builder() + .totalCnt(totalCnt) + .recipes(recipes.getRecipes().stream() + .map((recipe) -> RecipeResponse.from(recipe, + recipePostUserMapByUserId.get(recipe.getUserId()), + recipeScraps, + user, + recipe.calculateIngredientMatchRate(normalizedFridge))) + .collect(Collectors.toList())) + .build(); + } + + // 냉장고 추천용: 재료 일치도 계산 후 일치도(내림차순)로 정렬하고 keyset 페이지네이션을 적용한다. + public static RecipesResponse from(Recipes recipes, List recipePostUsers, List recipeScraps, User user, + List ingredientNamesInFridge, Recipe lastRecipe, int size) { + + Set normalizedFridge = ingredientNamesInFridge.stream() + .filter(Objects::nonNull) + .map(s -> s.toLowerCase().trim()) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + + Map recipePostUserMapByUserId = recipePostUsers.stream() + .collect(Collectors.toMap(User::getUserId, Function.identity())); + + return RecipesResponse.builder() + .totalCnt(recipes.size()) + .recipes(recipes.getRecipes().stream() + .map((recipe) -> RecipeResponse.from(recipe, + recipePostUserMapByUserId.get(recipe.getUserId()), + recipeScraps, + user, + recipe.calculateIngredientMatchRate(normalizedFridge))) + .sorted(Comparator.comparing(RecipeResponse::getIngredientsMatchRate).thenComparing(RecipeResponse::getRecipeId).reversed()) + .filter(recommendedRecipe -> { + if (lastRecipe == null) { + return true; + } + + return recommendedRecipe.getRecipeId() < lastRecipe.getRecipeId() + && recommendedRecipe.getIngredientsMatchRate() <= lastRecipe.calculateIngredientMatchRate(normalizedFridge); + }) + .limit(size) + .collect(Collectors.toList())) + .build(); + } + public static RecipesResponse from(long totalCnt, BlogRecipes recipes, List recipeScraps, User user) { return RecipesResponse.builder() diff --git a/src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipeResponse.java b/src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipeResponse.java deleted file mode 100644 index c78e7044..00000000 --- a/src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipeResponse.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.recipe.app.src.recipe.application.dto; - -import com.recipe.app.src.recipe.domain.Recipe; -import com.recipe.app.src.recipe.domain.RecipeScrap; -import com.recipe.app.src.user.domain.User; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Builder; -import lombok.Getter; - -import java.time.format.DateTimeFormatter; -import java.util.List; - -@Schema(description = "냉장고 추천 레시피 응답 DTO") -@Getter -public class RecommendedRecipeResponse { - - @Schema(description = "레시피 고유 번호") - private final Long recipeId; - @Schema(description = "레시피명") - private final String recipeName; - @Schema(description = "소개글") - private final String introduction; - @Schema(description = "썸네일 이미지 Url") - private final String thumbnailImgUrl; - @Schema(description = "게시자") - private final String postUserName; - @Schema(description = "게시일시") - private final String postDate; - @Schema(description = "연결 링크 Url") - private final String linkUrl; - @Schema(description = "스크랩 여부") - private final Boolean isUserScrap; - @Schema(description = "스크랩 갯수") - private final long scrapCnt; - @Schema(description = "조회수") - private final long viewCnt; - @Schema(description = "재료 일치도") - private final long ingredientsMatchRate; - - @Builder - public RecommendedRecipeResponse(Long recipeId, String recipeName, String introduction, String thumbnailImgUrl, String postUserName, String postDate, - String linkUrl, Boolean isUserScrap, long scrapCnt, long viewCnt, long ingredientsMatchRate) { - - this.recipeId = recipeId; - this.recipeName = recipeName; - this.introduction = introduction; - this.thumbnailImgUrl = thumbnailImgUrl; - this.postUserName = postUserName; - this.postDate = postDate; - this.linkUrl = linkUrl; - this.isUserScrap = isUserScrap; - this.scrapCnt = scrapCnt; - this.viewCnt = viewCnt; - this.ingredientsMatchRate = ingredientsMatchRate; - } - - public static RecommendedRecipeResponse from(Recipe recipe, User recipePostUser, long ingredientsMatchRate, List recipeScraps, User user) { - - return RecommendedRecipeResponse.builder() - .recipeId(recipe.getRecipeId()) - .recipeName(recipe.getRecipeNm()) - .introduction(recipe.getIntroduction()) - .thumbnailImgUrl(recipe.getImgUrl()) - .postUserName(recipePostUser != null ? recipePostUser.getNickname() : null) - .postDate(recipe.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy.M.d"))) - .isUserScrap(recipeScraps.stream() - .anyMatch(recipeScrap -> - recipeScrap.getRecipeId().equals(recipe.getRecipeId()) - && recipeScrap.getUserId().equals(user.getUserId()))) - .scrapCnt(recipe.getScrapCnt()) - .viewCnt(recipe.getViewCnt()) - .ingredientsMatchRate(ingredientsMatchRate) - .build(); - } -} diff --git a/src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipesResponse.java b/src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipesResponse.java deleted file mode 100644 index 496ca81b..00000000 --- a/src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipesResponse.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.recipe.app.src.recipe.application.dto; - -import com.recipe.app.src.recipe.domain.Recipe; -import com.recipe.app.src.recipe.domain.RecipeScrap; -import com.recipe.app.src.recipe.domain.Recipes; -import com.recipe.app.src.user.domain.User; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Builder; -import lombok.Getter; - -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.function.Function; -import java.util.stream.Collectors; - -@Schema(description = "냉장고 추천 레시피 목록 응답 DTO") -@Getter -public class RecommendedRecipesResponse { - - @Schema(description = "레시피 전체 갯수") - private final long totalCnt; - @Schema(description = "레시피 목록") - private final List recipes; - - @Builder - public RecommendedRecipesResponse(long totalCnt, List recipes) { - - this.totalCnt = totalCnt; - this.recipes = recipes; - } - - public static RecommendedRecipesResponse from(Recipes recipes, List recipePostUsers, List recipeScraps, User user, - List ingredientNamesInFridge, Recipe lastRecipe, int size) { - - Set normalizedFridge = ingredientNamesInFridge.stream() - .filter(Objects::nonNull) - .map(s -> s.toLowerCase().trim()) - .filter(s -> !s.isEmpty()) - .collect(Collectors.toSet()); - - Map recipePostUserMapByUserId = recipePostUsers.stream() - .collect(Collectors.toMap(User::getUserId, Function.identity())); - - return RecommendedRecipesResponse.builder() - .totalCnt(recipes.size()) - .recipes(recipes.getRecipes().stream() - .map((recipe) -> RecommendedRecipeResponse.from(recipe, - recipePostUserMapByUserId.get(recipe.getUserId()), - recipe.calculateIngredientMatchRate(normalizedFridge), - recipeScraps, - user)) - .sorted(Comparator.comparing(RecommendedRecipeResponse::getIngredientsMatchRate).thenComparing(RecommendedRecipeResponse::getRecipeId).reversed()) - .filter(recommendedRecipe -> { - if (lastRecipe == null) { - return true; - } - - return recommendedRecipe.getRecipeId() < lastRecipe.getRecipeId() - && recommendedRecipe.getIngredientsMatchRate() <= lastRecipe.calculateIngredientMatchRate(normalizedFridge); - }) - .limit(size) - .collect(Collectors.toList())) - .build(); - } -} diff --git a/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy index dc1c9256..13bd6552 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy @@ -6,7 +6,6 @@ import com.recipe.app.src.fridge.application.FridgeService import com.recipe.app.src.ingredient.application.IngredientSynonymCache import com.recipe.app.src.recipe.application.dto.RecipeDetailResponse import com.recipe.app.src.recipe.application.dto.RecipesResponse -import com.recipe.app.src.recipe.application.dto.RecommendedRecipesResponse import com.recipe.app.src.recipe.domain.* import com.recipe.app.src.recipe.exception.NotFoundRecipeException import com.recipe.app.src.recipe.infra.RecipeRepository @@ -503,6 +502,8 @@ class RecipeSearchServiceTest extends Specification { .build() ] + fridgeService.findIngredientNamesInFridge(users.get(0).getUserId()) >> [] + when: RecipesResponse result = recipeSearchService.findRecipesByUser(users.get(0), lastRecipeId, size) @@ -602,7 +603,7 @@ class RecipeSearchServiceTest extends Specification { recipeRepository.findById(lastRecipeId) >> Optional.empty() when: - RecommendedRecipesResponse result = recipeSearchService.findRecommendedRecipesByUserFridge(users.get(0), lastRecipeId, size) + RecipesResponse result = recipeSearchService.findRecommendedRecipesByUserFridge(users.get(0), lastRecipeId, size) then: result.totalCnt == 2 @@ -643,7 +644,7 @@ class RecipeSearchServiceTest extends Specification { recipeRepository.findById(lastRecipeId) >> Optional.empty() when: - RecommendedRecipesResponse result = recipeSearchService.findPublicRecommendedRecipesByIngredients(inputIngredientNames, lastRecipeId, size) + RecipesResponse result = recipeSearchService.findPublicRecommendedRecipesByIngredients(inputIngredientNames, lastRecipeId, size) then: "동의어 expand 결과(새우+대하)가 그대로 검색 인자로 전달된다" 1 * recipeRepository.findRecipesInFridge({ Collection arg -> arg as Set == expandedSet }) >> recipes @@ -664,7 +665,7 @@ class RecipeSearchServiceTest extends Specification { recipeRepository.findById(lastRecipeId) >> Optional.empty() when: - RecommendedRecipesResponse result = recipeSearchService.findPublicRecommendedRecipesByIngredients([], lastRecipeId, size) + RecipesResponse result = recipeSearchService.findPublicRecommendedRecipesByIngredients([], lastRecipeId, size) then: result.totalCnt == 0