Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.recipe.app.src.recipe.api;

import com.recipe.app.src.recipe.application.keyword.SearchKeywordService;
import com.recipe.app.src.recipe.application.keyword.KeywordService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
Expand All @@ -12,20 +12,18 @@
@Tag(name = "검색어 Controller")
@RestController
@RequestMapping("/recipes")
public class SearchKeywordController {
public class KeywordController {

private final SearchKeywordService searchKeywordService;
private final KeywordService keywordService;

public SearchKeywordController(SearchKeywordService searchKeywordService) {
this.searchKeywordService = searchKeywordService;
public KeywordController(KeywordService keywordService) {
this.keywordService = keywordService;
}

@Operation(summary = "검색어 추천 목록 조회 API")
@GetMapping("/best-keywords")
public List<String> getRecipeBestKeywords() {

return searchKeywordService.retrieveRecipesBestKeyword();
return keywordService.retrieveRecipesBestKeyword();
}


}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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.keyword.SearchKeywordService;
import com.recipe.app.src.recipe.domain.Recipe;
import com.recipe.app.src.recipe.domain.RecipeScrap;
import com.recipe.app.src.recipe.domain.Recipes;
Expand All @@ -29,16 +30,19 @@ public class RecipeSearchService {
private final RecipeScrapService recipeScrapService;
private final RecipeViewService recipeViewService;
private final IngredientSynonymCache ingredientSynonymCache;
private final SearchKeywordService searchKeywordService;

public RecipeSearchService(RecipeRepository recipeRepository, FridgeService fridgeService, UserService userService, BadWordFiltering badWordFiltering,
RecipeScrapService recipeScrapService, RecipeViewService recipeViewService, IngredientSynonymCache ingredientSynonymCache) {
RecipeScrapService recipeScrapService, RecipeViewService recipeViewService, IngredientSynonymCache ingredientSynonymCache,
SearchKeywordService searchKeywordService) {
this.recipeRepository = recipeRepository;
this.fridgeService = fridgeService;
this.userService = userService;
this.badWordFiltering = badWordFiltering;
this.recipeScrapService = recipeScrapService;
this.recipeViewService = recipeViewService;
this.ingredientSynonymCache = ingredientSynonymCache;
this.searchKeywordService = searchKeywordService;
}

@Transactional(readOnly = true)
Expand All @@ -62,6 +66,9 @@ public RecipesResponse findRecipesByKeywordOrderBy(User user, String keyword, lo
recipes = findByKeywordOrderByCreatedAt(query, lastRecipeId, size);
}

// 검색 로그 적재 (비동기·fire-and-forget). 욕설/빈 검색어는 위에서 이미 걸러진 상태.
searchKeywordService.record(keyword, user != null ? user.getUserId() : null);

return getRecipes(user, totalCnt, new Recipes(recipes));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.recipe.app.src.recipe.application.keyword;

import com.recipe.app.src.recipe.domain.keyword.Keyword;
import com.recipe.app.src.recipe.infra.keyword.KeywordRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;

@Service
public class KeywordService {

private final KeywordRepository keywordRepository;

public KeywordService(KeywordRepository keywordRepository) {
this.keywordRepository = keywordRepository;
}

private static final int BEST_KEYWORD_COUNT = 10;

@Transactional(readOnly = true)
public List<String> retrieveRecipesBestKeyword() {
List<String> recipeKeywords = keywordRepository.findAll().stream()
.map(Keyword::getKeyword)
.distinct()
.collect(Collectors.toList());

// 검색어가 없으면 빈 목록 (random.nextInt(0) 예외 방지)
if (recipeKeywords.isEmpty()) {
return List.of();
}

// 고유 검색어 수가 10개 미만이어도 끝나도록 목표치를 보유 수로 제한 (무한 루프 방지)
int target = Math.min(BEST_KEYWORD_COUNT, recipeKeywords.size());

Random random = new Random();
List<String> keywords = new ArrayList<>();
int cnt = 0;
while (keywords.size() < target) {
long seed = LocalDateTime.now().withMinute(cnt % 60).withSecond(0).withNano(0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
random.setSeed(seed);

String keyword = recipeKeywords.get(random.nextInt(recipeKeywords.size()));
if (!keywords.contains(keyword)) {
keywords.add(keyword);
}
cnt++;
}

return keywords;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,56 +2,37 @@

import com.recipe.app.src.recipe.domain.keyword.SearchKeyword;
import com.recipe.app.src.recipe.infra.keyword.SearchKeywordRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;

/**
* 사용자 검색어를 로그 테이블(SearchKeyword)에 쌓는다.
* 검색 응답을 막지 않도록 비동기(@Async)로 기록하며, 기록 실패가 검색을 깨뜨리지 않게 예외를 삼킨다.
*/
@Service
public class SearchKeywordService {

private static final Logger log = LoggerFactory.getLogger(SearchKeywordService.class);

private final SearchKeywordRepository searchKeywordRepository;

public SearchKeywordService(SearchKeywordRepository searchKeywordRepository) {
this.searchKeywordRepository = searchKeywordRepository;
}

private static final int BEST_KEYWORD_COUNT = 10;

@Transactional(readOnly = true)
public List<String> retrieveRecipesBestKeyword() {
List<String> recipeKeywords = searchKeywordRepository.findAll().stream()
.map(SearchKeyword::getKeyword)
.distinct()
.collect(Collectors.toList());

// 검색어가 없으면 빈 목록 (random.nextInt(0) 예외 방지)
if (recipeKeywords.isEmpty()) {
return List.of();
@Async
@Transactional
public void record(String keyword, Long userId) {
try {
searchKeywordRepository.save(SearchKeyword.builder()
.keyword(keyword)
.userId(userId)
.build());
} catch (Exception e) {
log.warn("검색어 기록 실패. keyword={}, userId={}", keyword, userId, e);
}

// 고유 검색어 수가 10개 미만이어도 끝나도록 목표치를 보유 수로 제한 (무한 루프 방지)
int target = Math.min(BEST_KEYWORD_COUNT, recipeKeywords.size());

Random random = new Random();
List<String> keywords = new ArrayList<>();
int cnt = 0;
while (keywords.size() < target) {
long seed = LocalDateTime.now().withMinute(cnt % 60).withSecond(0).withNano(0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
random.setSeed(seed);

String keyword = recipeKeywords.get(random.nextInt(recipeKeywords.size()));
if (!keywords.contains(keyword)) {
keywords.add(keyword);
}
cnt++;
}

return keywords;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.recipe.app.src.recipe.domain.keyword;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

/**
* 인기 검색어(추천 검색어) 후보 풀. Keywords 테이블에 매핑된다.
* 실제 사용자 검색 로그는 {@link SearchKeyword} 에 별도로 쌓인다.
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "Keywords")
public class Keyword {

@Id
@Column(name = "keyword", nullable = false, updatable = false)
private String keyword;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,50 @@

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

/**
* 사용자 검색 로그. 검색이 일어날 때마다 1건씩 쌓인다 (원본 append-log).
* 인기 검색어 승격/집계는 이 로그를 나중에 가공해서 처리한다. (지금은 쌓기만)
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "Keywords")
@EntityListeners(AuditingEntityListener.class)
@Table(name = "SearchKeyword")
public class SearchKeyword {

@Id
@Column(name = "keyword", nullable = false, updatable = false)
@Column(name = "searchKeywordId", nullable = false, updatable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long searchKeywordId;

@Column(name = "keyword", nullable = false)
private String keyword;

@Column(name = "userId")
private Long userId;

@CreatedDate
@Column(name = "createdAt", nullable = false, updatable = false)
private LocalDateTime createdAt;

@Builder
public SearchKeyword(Long searchKeywordId, String keyword, Long userId) {
this.searchKeywordId = searchKeywordId;
this.keyword = keyword;
this.userId = userId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.recipe.app.src.recipe.infra.keyword;

import com.recipe.app.src.recipe.domain.keyword.Keyword;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface KeywordRepository extends JpaRepository<Keyword, String> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
import org.springframework.stereotype.Repository;

@Repository
public interface SearchKeywordRepository extends JpaRepository<SearchKeyword, String> {
public interface SearchKeywordRepository extends JpaRepository<SearchKeyword, Long> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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.keyword.SearchKeywordService
import com.recipe.app.src.recipe.domain.*
import com.recipe.app.src.recipe.exception.NotFoundRecipeException
import com.recipe.app.src.recipe.infra.RecipeRepository
Expand All @@ -24,8 +25,9 @@ class RecipeSearchServiceTest extends Specification {
private RecipeScrapService recipeScrapService = Mock()
private RecipeViewService recipeViewService = Mock()
private IngredientSynonymCache ingredientSynonymCache = Mock()
private SearchKeywordService searchKeywordService = Mock()
private RecipeSearchService recipeSearchService = new RecipeSearchService(recipeRepository, fridgeService, userService, badWordService,
recipeScrapService, recipeViewService, ingredientSynonymCache)
recipeScrapService, recipeViewService, ingredientSynonymCache, searchKeywordService)

def "레시피 키워드 검색 - 스크랩 수 정렬"() {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.recipe.app.src.recipe.application.keyword

import com.recipe.app.src.recipe.domain.keyword.Keyword
import com.recipe.app.src.recipe.infra.keyword.KeywordRepository
import spock.lang.Specification
import spock.lang.Timeout

import java.util.concurrent.TimeUnit

class KeywordServiceTest extends Specification {

KeywordRepository keywordRepository = Mock()
KeywordService keywordService = new KeywordService(keywordRepository)

def kw(String k) {
Stub(Keyword) { getKeyword() >> k }
}

def "검색어가 없으면 빈 목록을 반환한다 (예외 없이)"() {

given:
keywordRepository.findAll() >> []

expect:
keywordService.retrieveRecipesBestKeyword() == []
}

@Timeout(value = 5, unit = TimeUnit.SECONDS)
def "고유 검색어가 10개 미만이면 무한루프 없이 보유 개수만 반환한다"() {

given:
keywordRepository.findAll() >> [kw("감자"), kw("양파"), kw("당근")]

when:
List<String> result = keywordService.retrieveRecipesBestKeyword()

then:
result.size() == 3
result.toSet() == ["감자", "양파", "당근"].toSet()
}

@Timeout(value = 5, unit = TimeUnit.SECONDS)
def "고유 검색어가 10개 이상이면 10개를 반환한다"() {

given:
keywordRepository.findAll() >> (1..15).collect { kw("키워드" + it) }

when:
List<String> result = keywordService.retrieveRecipesBestKeyword()

then:
result.size() == 10
result.unique().size() == 10
}
}
Loading