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 435e969d..2be45e95 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 @@ -97,16 +97,18 @@ public RecipesResponse getFridgesRecipes(@Parameter(hidden = true) User user, return recipeSearchService.findRecommendedRecipesByUserFridge(user, startAfter, size); } - @Operation(summary = "등록한 레시피 목록 조회 API") + @Operation(summary = "등록한 레시피 목록 조회 API", description = "keyword 를 주면 내 레시피(비공개 포함) 내에서 검색, 없으면 전체 목록") @GetMapping("/users") @LoginCheck public RecipesResponse getUserRecipes(@Parameter(hidden = true) User user, @Parameter(example = "0", name = "마지막 조회 레시피 아이디") @RequestParam(value = "startAfter") long startAfter, @Parameter(example = "20", name = "사이즈") - @RequestParam(value = "size") int size) { + @RequestParam(value = "size") int size, + @Parameter(example = "김치", name = "검색어(선택)") + @RequestParam(value = "keyword", required = false) String keyword) { - return recipeSearchService.findRecipesByUser(user, startAfter, size); + return recipeSearchService.findRecipesByUser(user, keyword, startAfter, size); } @Operation(summary = "레시피 등록 API") 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 71ddf50b..e3d76882 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 @@ -130,17 +130,34 @@ public RecipesResponse findScrapRecipes(User user, long lastRecipeId, int size) } @Transactional(readOnly = true) - public RecipesResponse findRecipesByUser(User user, long lastRecipeId, int size) { + public RecipesResponse findRecipesByUser(User user, String keyword, long lastRecipeId, int size) { - long totalCnt = recipeRepository.countByUserId(user.getUserId()); + Long userId = user.getUserId(); + SearchQuery query = SearchKeywordNormalizer.normalize(keyword); + + long totalCnt; + List foundRecipes; + if (query instanceof SearchQuery.Empty) { + // 검색어 없음 → 내 레시피 전체 (recipeId 역순) + totalCnt = recipeRepository.countByUserId(userId); + foundRecipes = findLimitByUserId(userId, lastRecipeId, size); + } else { + // 검색어 있음 → 내 레시피 검색 (내 비공개 포함, 일치율 → 최신순) + badWordFiltering.check(keyword); + totalCnt = recipeRepository.countByKeywordAndUserId(query, userId); + Double lastRelevance = recipeRepository.findRelevanceScoreByRecipeId(query, lastRecipeId); + Recipe last = recipeRepository.findById(lastRecipeId).orElse(null); + foundRecipes = recipeRepository.findByKeywordAndUserIdLimitOrderByCreatedAtDesc(query, userId, lastRecipeId, lastRelevance, + last != null ? last.getCreatedAt() : null, size); + } - Recipes recipes = new Recipes(findLimitByUserId(user.getUserId(), lastRecipeId, size)); + Recipes recipes = new Recipes(foundRecipes); List recipePostUsers = userService.findByUserIds(recipes.getUserIds()); List recipeScraps = recipeScrapService.findByRecipeIds(recipes.getRecipeIds()); - List ingredientNamesInFridge = fridgeService.findIngredientNamesInFridge(user.getUserId()); + List ingredientNamesInFridge = fridgeService.findIngredientNamesInFridge(userId); return RecipesResponse.from(totalCnt, recipes, recipePostUsers, recipeScraps, user, ingredientNamesInFridge); } diff --git a/src/main/java/com/recipe/app/src/recipe/infra/RecipeCustomRepository.java b/src/main/java/com/recipe/app/src/recipe/infra/RecipeCustomRepository.java index aad7a7e0..d39b4528 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/RecipeCustomRepository.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/RecipeCustomRepository.java @@ -14,12 +14,18 @@ public interface RecipeCustomRepository { Long countByKeyword(SearchQuery query); + // 특정 유저의 레시피(비공개 포함) 내 검색 — 나만의 레시피 검색용 + Long countByKeywordAndUserId(SearchQuery query, Long userId); + // 아래 정렬들은 모두 "검색어 일치율(relevance) 대분류 → 각 정렬키 소분류 → recipeId" 순. // lastRelevance 는 커서(직전 페이지 마지막 레시피)의 현재 검색어 기준 점수. findRelevanceScoreByRecipeId 로 구해 넘긴다. Double findRelevanceScoreByRecipeId(SearchQuery query, Long recipeId); List findByKeywordLimitOrderByCreatedAtDesc(SearchQuery query, Long lastRecipeId, Double lastRelevance, LocalDateTime createdAt, int size); + // 특정 유저의 레시피(비공개 포함) 내 검색, 최신순(일치율→제목우선→createdAt→id) — 나만의 레시피 검색용 + List findByKeywordAndUserIdLimitOrderByCreatedAtDesc(SearchQuery query, Long userId, Long lastRecipeId, Double lastRelevance, LocalDateTime createdAt, int size); + List findByKeywordLimitOrderByRecipeScrapCntDesc(SearchQuery query, Long lastRecipeId, Double lastRelevance, long recipeScrapCnt, int size); List findByKeywordLimitOrderByRecipeViewCntDesc(SearchQuery query, Long lastRecipeId, Double lastRelevance, long recipeViewCnt, int size); diff --git a/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java b/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java index bf199404..9d5d508e 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java @@ -56,6 +56,18 @@ public Long countByKeyword(SearchQuery query) { .fetchOne(); } + @Override + public Long countByKeywordAndUserId(SearchQuery query, Long userId) { + return queryFactory + .select(recipe.recipeId.countDistinct()) + .from(recipe) + .where( + recipe.userId.eq(userId), + keywordMatch(query) + ) + .fetchOne(); + } + @Override public List findByKeywordLimitOrderByCreatedAtDesc(SearchQuery query, Long lastRecipeId, Double lastRelevance, LocalDateTime lastCreatedAt, int size) { @@ -72,6 +84,22 @@ public List findByKeywordLimitOrderByCreatedAtDesc(SearchQuery query, Lo .fetch(); } + @Override + public List findByKeywordAndUserIdLimitOrderByCreatedAtDesc(SearchQuery query, Long userId, Long lastRecipeId, Double lastRelevance, LocalDateTime lastCreatedAt, int size) { + + return queryFactory + .selectFrom(recipe) + .where( + recipe.userId.eq(userId), + keywordMatch(query), + relevanceCursor(query, lastRecipeId, lastRelevance, + () -> recipe.createdAt.lt(lastCreatedAt), () -> recipe.createdAt.eq(lastCreatedAt)) + ) + .orderBy(relevanceOrder(query, recipe.createdAt.desc())) + .limit(size) + .fetch(); + } + @Override public Double findRelevanceScoreByRecipeId(SearchQuery query, Long recipeId) { 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 dbfca9f0..a6bbb919 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 @@ -507,7 +507,7 @@ class RecipeSearchServiceTest extends Specification { fridgeService.findIngredientNamesInFridge(users.get(0).getUserId()) >> [] when: - RecipesResponse result = recipeSearchService.findRecipesByUser(users.get(0), lastRecipeId, size) + RecipesResponse result = recipeSearchService.findRecipesByUser(users.get(0), null, lastRecipeId, size) then: result.totalCnt == 2 @@ -522,6 +522,41 @@ class RecipeSearchServiceTest extends Specification { result.recipes.viewCnt == recipes.viewCnt } + def "등록한 레시피 조회 - keyword 있으면 내 레시피 검색 경로(userId 전달), 목록 경로 안 탐"() { + + given: + User user = User.builder().userId(1).socialId("naver_1").nickname("테스터1").build() + String keyword = "테스트" + long lastRecipeId = 0 + int size = 10 + + recipeRepository.countByKeywordAndUserId(_, user.userId) >> 1 + recipeRepository.findRelevanceScoreByRecipeId(_, lastRecipeId) >> null + recipeRepository.findById(lastRecipeId) >> Optional.empty() + + Recipe recipe = Recipe.builder() + .recipeId(1) + .recipeNm("테스트제목") + .introduction("설명") + .level(RecipeLevel.NORMAL) + .userId(user.userId) + .isHidden(false) + .build() + + userService.findByUserIds(_) >> [user] + recipeScrapService.findByRecipeIds(_) >> [] + fridgeService.findIngredientNamesInFridge(user.userId) >> [] + + when: + RecipesResponse result = recipeSearchService.findRecipesByUser(user, keyword, lastRecipeId, size) + + then: "keyword 있으면 userId 로 내 레시피 검색 쿼리를 호출하고, 전체 목록 경로는 타지 않는다" + 1 * recipeRepository.findByKeywordAndUserIdLimitOrderByCreatedAtDesc(_, user.userId, lastRecipeId, _, _, size) >> [recipe] + 0 * recipeRepository.findLimitByUserId(_, _, _) + result.totalCnt == 1 + result.recipes.recipeId == [recipe.recipeId] + } + def "냉장고 재료 일치하는 레시피 목록 조회"() { given: diff --git a/src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy index a9a27b5d..9ba04065 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy @@ -411,6 +411,47 @@ class RecipeCustomRepositoryTest extends Specification { response.get(1).recipeId == descMatch.recipeId } + def "내 레시피 검색 - userId 로 내 레시피(비공개 포함)만, 남의 레시피는 제외"() { + + given: + Recipe mine = Recipe.builder() + .recipeNm("테스트제목") + .introduction("설명") + .level(RecipeLevel.NORMAL) + .userId(users.get(0).userId) + .isHidden(false) + .build() + Recipe mineHidden = Recipe.builder() + .recipeNm("테스트제목") + .introduction("설명") + .level(RecipeLevel.NORMAL) + .userId(users.get(0).userId) + .isHidden(true) // 내 비공개 → 포함돼야 + .build() + Recipe others = Recipe.builder() + .recipeNm("테스트제목") + .introduction("설명") + .level(RecipeLevel.NORMAL) + .userId(users.get(1).userId) // 남의 레시피 → 제외돼야 + .isHidden(false) + .build() + + committedTx.executeWithoutResult { status -> recipeRepository.saveAll([mine, mineHidden, others]) } + + SearchQuery query = SearchKeywordNormalizer.normalize("테스트") + Long myUserId = users.get(0).userId + + when: + long count = recipeRepository.countByKeywordAndUserId(query, myUserId) + List response = recipeRepository.findByKeywordAndUserIdLimitOrderByCreatedAtDesc(query, myUserId, 0L, null, null, 10) + + then: "내 레시피(공개+비공개) 2건만, 남의 것 제외" + count == 2 + response.size() == 2 + response*.recipeId.toSet() == [mine.recipeId, mineHidden.recipeId].toSet() + !(others.recipeId in response*.recipeId) + } + def "검색어로 레시피 목록 조회 - 스크랩 수 많은 순 정렬"() { given: