diff --git a/src/main/java/com/back/domain/cocktail/controller/CocktailController.java b/src/main/java/com/back/domain/cocktail/controller/CocktailController.java new file mode 100644 index 00000000..62db435e --- /dev/null +++ b/src/main/java/com/back/domain/cocktail/controller/CocktailController.java @@ -0,0 +1,31 @@ +package com.back.domain.cocktail.controller; + +import com.back.domain.cocktail.dto.CocktailDetailDto; +import com.back.domain.cocktail.service.CocktailService; +import com.back.domain.user.service.UserService; +import com.back.global.rsData.RsData; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("api/cocktails") +@RequiredArgsConstructor +public class CocktailController { + + private final CocktailService cocktailService; + private final UserService userService; + + @GetMapping("/{id}") + @Transactional + @Operation(summary = "칵테일 단건 조회") + public RsData getCocktailDetailById(@PathVariable long id) { + + CocktailDetailDto cocktailDetailDto = cocktailService.getCocktailDetailById(id); + return RsData.successOf(cocktailDetailDto); + } +} diff --git a/src/main/java/com/back/domain/cocktail/dto/CocktailDetailDto.java b/src/main/java/com/back/domain/cocktail/dto/CocktailDetailDto.java new file mode 100644 index 00000000..033ceb8a --- /dev/null +++ b/src/main/java/com/back/domain/cocktail/dto/CocktailDetailDto.java @@ -0,0 +1,32 @@ +package com.back.domain.cocktail.dto; + +import com.back.domain.cocktail.entity.Cocktail; +import com.back.domain.cocktail.enums.AlcoholBaseType; +import com.back.domain.cocktail.enums.AlcoholStrength; +import com.back.domain.cocktail.enums.CocktailType; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class CocktailDetailDto { + private Long cocktailId; + private String cocktailName; + private String ingredient; + private AlcoholStrength alcoholStrength; + private CocktailType cocktailType; + private AlcoholBaseType alcoholBaseType; + private String cocktailImgUrl; + private String cocktailStory; + + public CocktailDetailDto(Cocktail c) { + this.cocktailId = c.getCocktailId(); + this.cocktailName = c.getCocktailName(); + this.ingredient = c.getIngredient(); + this.alcoholStrength = c.getAlcoholStrength(); + this.cocktailType = c.getCocktailType(); + this.alcoholBaseType = c.getAlcoholBaseType(); + this.cocktailImgUrl = c.getCocktailImgUrl(); + this.cocktailStory = c.getCocktailStory(); + } +} diff --git a/src/main/java/com/back/domain/cocktail/dto/CocktailFilterRequestDto.java b/src/main/java/com/back/domain/cocktail/dto/CocktailFilterRequestDto.java new file mode 100644 index 00000000..8b092451 --- /dev/null +++ b/src/main/java/com/back/domain/cocktail/dto/CocktailFilterRequestDto.java @@ -0,0 +1,42 @@ +package com.back.domain.cocktail.dto; + +import com.back.domain.cocktail.enums.AlcoholBaseType; +import com.back.domain.cocktail.enums.AlcoholStrength; +import com.back.domain.cocktail.enums.CocktailType; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +public class CocktailFilterRequestDto { + + private String keyword; // 검색 키워드 + + private List alcoholStrengths; + + private List cocktailTypes; + + private List alcoholBaseTypes; + + // 페이징/정렬 추가하고 싶으면 여기 옵션 추가 + private Integer page; // 0-based 페이지 번호 + private Integer size; // 페이지 사이즈 + + // 생성자 + public CocktailFilterRequestDto(String keyword, + List alcoholStrengths, + List cocktailTypes, + List alcoholBaseTypes, + Integer page, Integer size) { + this.keyword = keyword; + this.alcoholStrengths = alcoholStrengths; + this.cocktailTypes = cocktailTypes; + this.alcoholBaseTypes = alcoholBaseTypes; + this.page = page; + this.size = size; + } +} diff --git a/src/main/java/com/back/domain/cocktail/dto/CocktailResponseDto.java b/src/main/java/com/back/domain/cocktail/dto/CocktailResponseDto.java index 3fb8b929..a89c642e 100644 --- a/src/main/java/com/back/domain/cocktail/dto/CocktailResponseDto.java +++ b/src/main/java/com/back/domain/cocktail/dto/CocktailResponseDto.java @@ -26,4 +26,17 @@ public class CocktailResponseDto { private LocalDateTime createdAt; private LocalDateTime updatedAt; + public CocktailResponseDto(long cocktailId, String cocktailName, + AlcoholStrength alcoholStrength, CocktailType cocktailType, + AlcoholBaseType alcoholBaseType, String cocktailImgUrl, + String cocktailStory, LocalDateTime createdAt) { + this.cocktailId = cocktailId; + this.cocktailName = cocktailName; + this.alcoholStrength = alcoholStrength; + this.cocktailType = cocktailType; + this.alcoholBaseType = alcoholBaseType; + this.cocktailImgUrl = cocktailImgUrl; + this.cocktailStory = cocktailStory; + this.createdAt = createdAt; + } } \ No newline at end of file diff --git a/src/main/java/com/back/domain/cocktail/entity/Cocktail.java b/src/main/java/com/back/domain/cocktail/entity/Cocktail.java index d1761922..918a8d4b 100644 --- a/src/main/java/com/back/domain/cocktail/entity/Cocktail.java +++ b/src/main/java/com/back/domain/cocktail/entity/Cocktail.java @@ -5,9 +5,7 @@ import com.back.domain.cocktail.enums.CocktailType; import jakarta.persistence.*; import lombok.*; - import java.time.LocalDateTime; - import static jakarta.persistence.GenerationType.IDENTITY; diff --git a/src/main/java/com/back/domain/cocktail/repository/CocktailRepository.java b/src/main/java/com/back/domain/cocktail/repository/CocktailRepository.java index 9aa69286..285457f9 100644 --- a/src/main/java/com/back/domain/cocktail/repository/CocktailRepository.java +++ b/src/main/java/com/back/domain/cocktail/repository/CocktailRepository.java @@ -1,8 +1,14 @@ package com.back.domain.cocktail.repository; import com.back.domain.cocktail.entity.Cocktail; +import com.back.domain.cocktail.enums.AlcoholBaseType; +import com.back.domain.cocktail.enums.AlcoholStrength; +import com.back.domain.cocktail.enums.CocktailType; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -17,4 +23,17 @@ public interface CocktailRepository extends JpaRepository { List findByCocktailIdLessThanOrderByCocktailIdDesc(Long lastId, Pageable pageable); List findByCocktailNameContainingIgnoreCaseOrIngredientContainingIgnoreCase(String cocktailName, String ingredient); + + @Query("SELECT c FROM Cocktail c " + + "WHERE (:keyword IS NULL OR :keyword = '' OR " + + " LOWER(c.cocktailName) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " + + " LOWER(c.ingredient) LIKE LOWER(CONCAT('%', :keyword, '%')))" + + " AND (:strengths IS NULL OR c.alcoholStrength IN :strengths) " + // 알코올 도수 필터를 담당 + " AND (:types IS NULL OR c.cocktailType IN :types) " + // 칵테일 타입 필터를 담당 + " AND (:bases IS NULL OR c.alcoholBaseType IN :bases) ") // 알코올 베이스 필터를 담당 + Page searchWithFilters(@Param("keyword") String keyword, + @Param("strengths") List strengths, + @Param("types") List types, + @Param("bases") List bases, + Pageable pageable); } diff --git a/src/main/java/com/back/domain/cocktail/service/CocktailService.java b/src/main/java/com/back/domain/cocktail/service/CocktailService.java index 61d4ca9f..dc610593 100644 --- a/src/main/java/com/back/domain/cocktail/service/CocktailService.java +++ b/src/main/java/com/back/domain/cocktail/service/CocktailService.java @@ -1,13 +1,24 @@ package com.back.domain.cocktail.service; +import com.back.domain.cocktail.dto.CocktailDetailDto; +import com.back.domain.cocktail.dto.CocktailFilterRequestDto; +import com.back.domain.cocktail.dto.CocktailResponseDto; import com.back.domain.cocktail.dto.CocktailSummaryDto; import com.back.domain.cocktail.entity.Cocktail; +import com.back.domain.cocktail.enums.AlcoholBaseType; +import com.back.domain.cocktail.enums.AlcoholStrength; +import com.back.domain.cocktail.enums.CocktailType; import com.back.domain.cocktail.repository.CocktailRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; + import java.util.List; +import java.util.NoSuchElementException; import java.util.stream.Collectors; @Service @@ -20,38 +31,103 @@ public class CocktailService { @Transactional(readOnly = true) public Cocktail getCocktailById(Long id) { - return cocktailRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("User not found. id=" + id)); - } + return cocktailRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("User not found. id=" + id)); + } - // 칵테일 무한스크롤 조회 - @Transactional(readOnly = true) - public List getCocktails(Long lastId, Integer size) { // 무한스크롤 조회, 클라이언트 쪽에서 lastId와 size 정보를 받음.(스크롤 이벤트) - int fetchSize = (size != null) ? size : DEFAULT_SIZE; - - List cocktails; - if (lastId == null) { - // 첫 요청 → 최신 데이터부터 - cocktails = cocktailRepository.findAllByOrderByCocktailIdDesc(PageRequest.of(0, fetchSize)); - } else { - // 무한스크롤 → 마지막 ID보다 작은 데이터 조회 - cocktails = cocktailRepository.findByCocktailIdLessThanOrderByCocktailIdDesc(lastId, PageRequest.of(0, fetchSize)); + // 칵테일 무한스크롤 조회 + @Transactional(readOnly = true) + public List getCocktails (Long lastId, Integer size) + { // 무한스크롤 조회, 클라이언트 쪽에서 lastId와 size 정보를 받음.(스크롤 이벤트) + int fetchSize = (size != null) ? size : DEFAULT_SIZE; + + List cocktails; + if (lastId == null) { + // 첫 요청 → 최신 데이터부터 + cocktails = cocktailRepository.findAllByOrderByCocktailIdDesc(PageRequest.of(0, fetchSize)); + } else { + // 무한스크롤 → 마지막 ID보다 작은 데이터 조회 + cocktails = cocktailRepository.findByCocktailIdLessThanOrderByCocktailIdDesc(lastId, PageRequest.of(0, fetchSize)); + } + return cocktails.stream() + .map(c -> new CocktailSummaryDto(c.getCocktailId(), c.getCocktailName(), c.getCocktailImgUrl())) + .collect(Collectors.toList()); } - return cocktails.stream() - .map(c -> new CocktailSummaryDto(c.getCocktailId(), c.getCocktailName(), c.getCocktailImgUrl())) - .collect(Collectors.toList()); - } + // 칵테일 검색기능 + @Transactional(readOnly = true) + public List cocktailSearch (String keyword){ + // cockTailName, ingredient이 하나만 있을 수도 있고 둘 다 있을 수도 있음 + if (keyword == null || keyword.trim().isEmpty()) { + // 아무 검색어 없으면 전체 반환 처리 + return cocktailRepository.findAll(); + } else { + // 이름 또는 재료 둘 중 하나라도 매칭되면 결과 반환 + return cocktailRepository.findByCocktailNameContainingIgnoreCaseOrIngredientContainingIgnoreCase(keyword, keyword); + } + } + + // 칵테일 검색,필터기능 + @Transactional(readOnly = true) + public List searchAndFilter (CocktailFilterRequestDto cocktailFilterRequestDto){ + // 기본값 페이지/사이즈 정하기(PAGE 기본값 0, 사이즈 10) + int page = cocktailFilterRequestDto.getPage() != null && cocktailFilterRequestDto.getPage() >= 0 + ? cocktailFilterRequestDto.getPage() : 0; + + int size = cocktailFilterRequestDto.getSize() != null && cocktailFilterRequestDto.getSize() > 0 + ? cocktailFilterRequestDto.getSize() : DEFAULT_SIZE; + + // searchWithFilters에서 조회한 결과값을 pageResult에 저장. + Pageable pageable = PageRequest.of(page, size); + + // 빈 리스트(null 또는 [])는 null로 변환 + List strengths = CollectionUtils.isEmpty(cocktailFilterRequestDto.getAlcoholStrengths()) + ? null + : cocktailFilterRequestDto.getAlcoholStrengths(); + + List types = CollectionUtils.isEmpty(cocktailFilterRequestDto.getCocktailTypes()) + ? null + : cocktailFilterRequestDto.getCocktailTypes(); + + List bases = CollectionUtils.isEmpty(cocktailFilterRequestDto.getAlcoholBaseTypes()) + ? null + : cocktailFilterRequestDto.getAlcoholBaseTypes(); + + // Repository 호출 + Page pageResult = cocktailRepository.searchWithFilters( + cocktailFilterRequestDto.getKeyword(), + strengths, // List + types, // List + bases, // List + pageable + ); + + //Cocktail 엔티티 → CocktailResponseDto 응답 DTO로 바꿔주는 과정 + List resultDtos = pageResult.stream() + .map(c -> new CocktailResponseDto( + c.getCocktailId(), + c.getCocktailName(), + c.getAlcoholStrength(), + c.getCocktailType(), + c.getAlcoholBaseType(), + c.getCocktailImgUrl(), + c.getCocktailStory(), + c.getCreatedAt() + )) + .collect(Collectors.toList()); + + return resultDtos; + } + +// private List nullIfEmpty(List list) { +// return CollectionUtils.isEmpty(list) ? null : list; +// } - // 칵테일 검색기능 - public List cocktailSearch(String keyword) { - // cockTailName, ingredient이 하나만 있을 수도 있고 둘 다 있을 수도 있음 - if (keyword == null || keyword.trim().isEmpty()) { - // 아무 검색어 없으면 전체 반환 처리 - return cocktailRepository.findAll(); - } else { - // 이름 또는 재료 둘 중 하나라도 매칭되면 결과 반환 - return cocktailRepository.findByCocktailNameContainingIgnoreCaseOrIngredientContainingIgnoreCase(keyword, keyword); + // 칵테일 상세조회 + @Transactional(readOnly = true) + public CocktailDetailDto getCocktailDetailById (Long cocktailId){ + Cocktail cocktail = cocktailRepository.findById(cocktailId) + .orElseThrow(() -> new NoSuchElementException("칵테일을 찾을 수 없습니다. id: " + cocktailId)); + return new CocktailDetailDto(cocktail); } } -} diff --git a/src/main/java/com/back/global/init/DevInitData.java b/src/main/java/com/back/global/init/DevInitData.java index fd7e71c4..69d0c462 100644 --- a/src/main/java/com/back/global/init/DevInitData.java +++ b/src/main/java/com/back/global/init/DevInitData.java @@ -1,5 +1,6 @@ package com.back.global.init; +import com.back.domain.cocktail.dto.CocktailFilterRequestDto; import com.back.domain.cocktail.entity.Cocktail; import com.back.domain.cocktail.enums.AlcoholStrength; import com.back.domain.cocktail.repository.CocktailRepository; @@ -13,6 +14,8 @@ import org.springframework.context.annotation.Profile; import org.springframework.transaction.annotation.Transactional; +import java.util.Arrays; + @Configuration @Profile("dev") @RequiredArgsConstructor @@ -47,10 +50,16 @@ public void cocktailInit() { .alcoholStrength(AlcoholStrength.NON_ALCOHOLIC) .build()); } + + CocktailFilterRequestDto filterDto = new CocktailFilterRequestDto(); + filterDto.setKeyword("cocktail 4"); // 검색 키워드 설정 + filterDto.setAlcoholStrengths(Arrays.asList(AlcoholStrength.NON_ALCOHOLIC)); + System.out.println("DevInitData: 테스트 칵테일 20개 삽입"); System.out.println(cocktailService.getCocktailById(2l)); System.out.println(cocktailService.cocktailSearch("cocktail 3")); System.out.println(cocktailService.cocktailSearch("Ingredient 4")); + System.out.println("filterDTO 결과값"+cocktailService.searchAndFilter(filterDto)); } } diff --git a/src/main/java/com/back/global/security/SecurityConfig.java b/src/main/java/com/back/global/security/SecurityConfig.java index dc469757..a0cf9f55 100644 --- a/src/main/java/com/back/global/security/SecurityConfig.java +++ b/src/main/java/com/back/global/security/SecurityConfig.java @@ -46,7 +46,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/login/oauth2/**").permitAll() .requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll() .requestMatchers("/api/user/**").permitAll() - .requestMatchers("/api/cocktail/**").permitAll() + .requestMatchers("/api/cocktails/**").permitAll() // 회원 or 인증된 사용자만 가능 diff --git a/src/test/java/com/back/domain/cocktail/controller/CocktailControllerTest.java b/src/test/java/com/back/domain/cocktail/controller/CocktailControllerTest.java new file mode 100644 index 00000000..fc44f4d2 --- /dev/null +++ b/src/test/java/com/back/domain/cocktail/controller/CocktailControllerTest.java @@ -0,0 +1,92 @@ +package com.back.domain.cocktail.controller; + +import com.back.domain.cocktail.entity.Cocktail; +import com.back.domain.cocktail.enums.AlcoholBaseType; +import com.back.domain.cocktail.enums.AlcoholStrength; +import com.back.domain.cocktail.enums.CocktailType; +import com.back.domain.cocktail.repository.CocktailRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@Transactional +public class CocktailControllerTest { + + @Autowired + private MockMvc mvc; + @Autowired + private CocktailRepository cocktailRepository; + + +// @Autowired +// private UserService userService; + + @Test + @DisplayName("칵테일 단건 조회 - 로그인 없이 성공") + void t1() throws Exception { + Cocktail savedCocktail = cocktailRepository.save( + Cocktail.builder() + .cocktailName("모히토") + .alcoholStrength(AlcoholStrength.WEAK) + .cocktailType(CocktailType.SHORT) + .alcoholBaseType(AlcoholBaseType.RUM) + .cocktailImgUrl("https://example.com/image.jpg") + .cocktailStory("상쾌한 라임과 민트") + .ingredient("라임, 민트, 럼, 설탕, 탄산수") + .recipe("라임과 민트를 섞고 럼을 넣고 탄산수로 완성") + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build() + ); + + // when: GET 요청 + ResultActions resultActions = mvc.perform( + get("/api/cocktails/{id}", savedCocktail.getCocktailId()) + .contentType(MediaType.APPLICATION_JSON) + ).andDo(print()); + + // then: 상태코드, JSON 구조 검증 + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.message").value("success")) + .andExpect(jsonPath("$.data.cocktailName").value("모히토")) + .andExpect(jsonPath("$.data.alcoholStrength").value("WEAK")) + .andExpect(jsonPath("$.data.cocktailType").value("SHORT")) + .andExpect(jsonPath("$.data.alcoholBaseType").value("RUM")); + } + + @Test + @DisplayName("칵테일 단건 조회 - 실패 (존재하지 않는 ID)") + void t2() throws Exception { + long nonExistentId = 9999L; + + ResultActions resultActions = mvc.perform( + get("/api/cocktails/{id}", nonExistentId) + .contentType(MediaType.APPLICATION_JSON) + ).andDo(print()); + + resultActions + .andExpect(status().isNotFound()) // 전역 예외 처리기에서 404 반환 + .andExpect(jsonPath("$.code").value(404)) + .andExpect(jsonPath("$.message").value("해당 데이터가 존재하지 않습니다")) + .andExpect(jsonPath("$.data").isEmpty()); + } +}