From 9de2434684ebcc464558824aeb4078cdd75d8d3c Mon Sep 17 00:00:00 2001 From: "bottlenote-app[bot]" <289617182+bottlenote-app[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:18:50 +0900 Subject: [PATCH] refactor: remove product curation v1 API --- .../domain/CurationKeywordRepository.java | 9 - .../request/CurationKeywordSearchRequest.java | 18 -- .../dto/response/CurationKeywordResponse.java | 19 -- .../CustomCurationKeywordRepository.java | 9 - .../CustomCurationKeywordRepositoryImpl.java | 126 ----------- .../service/AlcoholReferenceService.java | 17 -- .../docs/asciidoc/api/alcohols/curations.adoc | 112 ---------- .../docs/asciidoc/api/alcohols/search.adoc | 2 - .../src/docs/asciidoc/product-api.adoc | 3 - .../AlcoholReferenceController.java | 26 --- .../docs/AlcoholReferenceApiDocs.java | 48 ---- .../AlcoholReferenceControllerTest.java | 28 +++ .../AlcoholQueryIntegrationTest.java | 39 ---- .../RestAlcoholQueryControllerTest.java | 3 +- .../RestAlcoholReferenceControllerTest.java | 208 ------------------ .../InMemoryCurationKeywordRepository.java | 15 +- 16 files changed, 30 insertions(+), 652 deletions(-) delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/CurationKeywordSearchRequest.java delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/CurationKeywordResponse.java delete mode 100644 bottlenote-product-api/src/docs/asciidoc/api/alcohols/curations.adoc create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/AlcoholReferenceControllerTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholReferenceControllerTest.java diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/CurationKeywordRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/CurationKeywordRepository.java index 8bd595dbc..875df863e 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/CurationKeywordRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/CurationKeywordRepository.java @@ -2,9 +2,6 @@ import app.bottlenote.alcohols.dto.request.AdminCurationSearchRequest; import app.bottlenote.alcohols.dto.response.AdminCurationListResponse; -import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; -import app.bottlenote.alcohols.dto.response.CurationKeywordResponse; -import app.bottlenote.global.service.cursor.CursorResponse; import java.util.List; import java.util.Optional; import java.util.Set; @@ -18,12 +15,6 @@ public interface CurationKeywordRepository { Optional findByNameContainingAndIsActiveTrue(String name); - CursorResponse searchCurationKeywords( - String keyword, Long alcoholId, Long cursor, Integer pageSize); - - CursorResponse getCurationAlcohols( - Long curationId, Long cursor, Integer pageSize); - Optional> findAlcoholIdsByKeyword(String keyword); // Admin용 메서드 diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/CurationKeywordSearchRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/CurationKeywordSearchRequest.java deleted file mode 100644 index 95a7e0046..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/CurationKeywordSearchRequest.java +++ /dev/null @@ -1,18 +0,0 @@ -package app.bottlenote.alcohols.dto.request; - -public record CurationKeywordSearchRequest( - String keyword, Long alcoholId, Long cursor, Long pageSize) { - public CurationKeywordSearchRequest { - if (cursor == null) { - cursor = 0L; - } - if (pageSize == null) { - pageSize = 10L; - } - } - - public static CurationKeywordSearchRequest of( - String keyword, Long alcoholId, Long cursor, Long pageSize) { - return new CurationKeywordSearchRequest(keyword, alcoholId, cursor, pageSize); - } -} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/CurationKeywordResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/CurationKeywordResponse.java deleted file mode 100644 index f533f7023..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/CurationKeywordResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package app.bottlenote.alcohols.dto.response; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class CurationKeywordResponse { - private Long id; - private String name; - private String description; - private String coverImageUrl; - private Integer alcoholCount; - private Integer displayOrder; -} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomCurationKeywordRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomCurationKeywordRepository.java index 6023b1623..e974adc30 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomCurationKeywordRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomCurationKeywordRepository.java @@ -2,9 +2,6 @@ import app.bottlenote.alcohols.dto.request.AdminCurationSearchRequest; import app.bottlenote.alcohols.dto.response.AdminCurationListResponse; -import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; -import app.bottlenote.alcohols.dto.response.CurationKeywordResponse; -import app.bottlenote.global.service.cursor.CursorResponse; import java.util.Optional; import java.util.Set; import org.springframework.data.domain.Page; @@ -12,12 +9,6 @@ public interface CustomCurationKeywordRepository { - CursorResponse searchCurationKeywords( - String keyword, Long alcoholId, Long cursor, Integer pageSize); - - CursorResponse getCurationAlcohols( - Long curationId, Long cursor, Integer pageSize); - Optional> findAlcoholIdsByKeyword(String keyword); Page searchForAdmin( diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomCurationKeywordRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomCurationKeywordRepositoryImpl.java index debb91a86..0bc8d5678 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomCurationKeywordRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomCurationKeywordRepositoryImpl.java @@ -1,21 +1,12 @@ package app.bottlenote.alcohols.repository; -import static app.bottlenote.alcohols.domain.QAlcohol.alcohol; import static app.bottlenote.alcohols.domain.QCurationKeyword.curationKeyword; -import static app.bottlenote.picks.domain.QPicks.picks; -import static app.bottlenote.rating.domain.QRating.rating; -import static app.bottlenote.review.domain.QReview.review; import app.bottlenote.alcohols.domain.CurationKeyword; import app.bottlenote.alcohols.dto.request.AdminCurationSearchRequest; import app.bottlenote.alcohols.dto.response.AdminCurationListResponse; -import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; -import app.bottlenote.alcohols.dto.response.CurationKeywordResponse; -import app.bottlenote.global.service.cursor.CursorPageable; -import app.bottlenote.global.service.cursor.CursorResponse; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; import lombok.RequiredArgsConstructor; @@ -30,110 +21,6 @@ public class CustomCurationKeywordRepositoryImpl implements CustomCurationKeywor private final JPAQueryFactory queryFactory; - @Override - public CursorResponse searchCurationKeywords( - String keyword, Long alcoholId, Long cursor, Integer pageSize) { - List results = - queryFactory - .select( - Projections.fields( - CurationKeywordResponse.class, - curationKeyword.id.as("id"), - curationKeyword.name.as("name"), - curationKeyword.description.as("description"), - curationKeyword.coverImageUrl.as("coverImageUrl"), - curationKeyword.alcoholIds.size().as("alcoholCount"), - curationKeyword.displayOrder.as("displayOrder"))) - .from(curationKeyword) - .where( - curationKeyword.isActive.isTrue(), - keywordContains(keyword), - alcoholIdIn(alcoholId), - curationKeyword.id.gt(cursor)) - .orderBy(curationKeyword.displayOrder.asc(), curationKeyword.id.desc()) - .limit(pageSize + 1) - .fetch(); - - CursorPageable pageable = CursorPageable.of(results, cursor, pageSize); - List content = - results.size() > pageSize ? results.subList(0, pageSize) : results; - - return CursorResponse.of(content, pageable); - } - - @Override - public CursorResponse getCurationAlcohols( - Long curationId, Long cursor, Integer pageSize) { - CurationKeyword curation = - queryFactory - .selectFrom(curationKeyword) - .where(curationKeyword.id.eq(curationId)) - .fetchOne(); - - if (curation == null || curation.getAlcoholIds().isEmpty()) { - return CursorResponse.of( - List.of(), - CursorPageable.builder() - .currentCursor(cursor) - .cursor(cursor) - .pageSize((long) pageSize) - .hasNext(false) - .build()); - } - - List alcoholIdsList = curation.getAlcoholIds().stream().toList(); - - List results = - queryFactory - .select( - Projections.fields( - AlcoholsSearchItem.class, - alcohol.id.as("alcoholId"), - alcohol.korName.as("korName"), - alcohol.engName.as("engName"), - alcohol.korCategory.as("korCategoryName"), - alcohol.engCategory.as("engCategoryName"), - alcohol.imageUrl.as("imageUrl"), - rating - .ratingPoint - .rating - .avg() - .multiply(2) - .castToNum(Double.class) - .round() - .divide(2) - .coalesce(0.0) - .as("rating"), - rating.id.countDistinct().as("ratingCount"), - review.id.countDistinct().as("reviewCount"), - picks.id.countDistinct().as("pickCount"), - Expressions.asBoolean(false).as("isPicked"))) - .from(alcohol) - .leftJoin(rating) - .on(alcohol.id.eq(rating.id.alcoholId)) - .leftJoin(review) - .on(alcohol.id.eq(review.alcoholId)) - .leftJoin(picks) - .on(alcohol.id.eq(picks.alcoholId)) - .where(alcohol.id.in(alcoholIdsList), alcohol.id.gt(cursor), alcohol.deletedAt.isNull()) - .groupBy( - alcohol.id, - alcohol.korName, - alcohol.engName, - alcohol.korCategory, - alcohol.engCategory, - alcohol.imageUrl) - // .orderBy() - .limit(pageSize + 1) - .fetch(); - - CursorPageable pageable = CursorPageable.of(results, cursor, pageSize); - List content = - results.size() > pageSize ? results.subList(0, pageSize) : results; - - return CursorResponse.of(content, pageable); - } - @Override public java.util.Optional> findAlcoholIdsByKeyword(String keyword) { if (keyword == null || keyword.isBlank()) { @@ -156,19 +43,6 @@ private BooleanExpression keywordContains(String keyword) { : null; } - private BooleanExpression alcoholIdIn(Long alcoholId) { - if (alcoholId == null) { - return null; - } - - return Expressions.numberTemplate( - Long.class, - "CASE WHEN {0} MEMBER OF {1} THEN 1 ELSE 0 END", - alcoholId, - curationKeyword.alcoholIds) - .eq(1L); - } - @Override public Page searchForAdmin( AdminCurationSearchRequest request, Pageable pageable) { diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholReferenceService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholReferenceService.java index 855760ed8..101ab32d5 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholReferenceService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholReferenceService.java @@ -9,13 +9,9 @@ import app.bottlenote.alcohols.domain.RegionRepository; import app.bottlenote.alcohols.domain.TastingTagRepository; import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest; -import app.bottlenote.alcohols.dto.request.CurationKeywordSearchRequest; -import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; import app.bottlenote.alcohols.dto.response.CategoryItem; -import app.bottlenote.alcohols.dto.response.CurationKeywordResponse; import app.bottlenote.alcohols.dto.response.RegionsItem; import app.bottlenote.global.data.response.GlobalResponse; -import app.bottlenote.global.service.cursor.CursorResponse; import java.util.List; import java.util.Optional; import java.util.Set; @@ -48,19 +44,6 @@ public List getAlcoholCategory(AlcoholType type) { return alcoholQueryRepository.findAllCategories(type); } - @Transactional(readOnly = true) - public CursorResponse searchCurationKeywords( - CurationKeywordSearchRequest request) { - return curationKeywordRepository.searchCurationKeywords( - request.keyword(), request.alcoholId(), request.cursor(), request.pageSize().intValue()); - } - - @Transactional(readOnly = true) - public CursorResponse getCurationAlcohols( - Long curationId, Long cursor, Long pageSize) { - return curationKeywordRepository.getCurationAlcohols(curationId, cursor, pageSize.intValue()); - } - @Transactional(readOnly = true) public Optional> getCurationAlcoholIds(String keyword) { return curationKeywordRepository.findAlcoholIdsByKeyword(keyword); diff --git a/bottlenote-product-api/src/docs/asciidoc/api/alcohols/curations.adoc b/bottlenote-product-api/src/docs/asciidoc/api/alcohols/curations.adoc deleted file mode 100644 index 810d7929f..000000000 --- a/bottlenote-product-api/src/docs/asciidoc/api/alcohols/curations.adoc +++ /dev/null @@ -1,112 +0,0 @@ -=== 큐레이션 키워드 조회 === - -큐레이션 키워드는 특정 주제나 상황에 맞는 위스키를 모아놓은 컬렉션입니다. - -예를 들어 "봄 추천 위스키"라는 큐레이션은 봄철에 어울리는 위스키들을 미리 선정해둔 목록이며, -"비 오는 날 추천 위스키"는 비오는 날 분위기에 맞는 위스키들을 모아놓은 컬렉션입니다. - -기존에는 시즌별 추천 키워드(봄/여름/가을/겨울/비오는날)가 코드에 고정되어 있었지만, -이제는 데이터베이스에서 동적으로 관리되어 새로운 큐레이션을 자유롭게 추가하거나 수정할 수 있습니다. - -각 큐레이션은 이름, 설명과 함께 커버 이미지(coverImageUrl)를 가지고 있어 시각적으로 매력적인 UI 구성이 가능합니다. - -NOTE: **커버 이미지 권장 규격** + -16:9 와이드 비율 (예: 800x450px 또는 1600x900px)을 권장합니다. + -위스키 병이나 분위기를 효과적으로 표현할 수 있으며, 데스크톱과 모바일 환경 모두에 적합합니다. - -[source] ----- -GET /api/v1/curations ----- - -[discrete] -==== 요청 파라미터 ==== - -include::{snippets}/curation-keywords-search/query-parameters.adoc[] - -[discrete] -==== 응답 파라미터 ==== - -include::{snippets}/curation-keywords-search/response-fields.adoc[] - -[discrete] -==== 응답 예시 ==== - -include::{snippets}/curation-keywords-search/response-body.adoc[] - -[discrete] -==== 사용 예시 ==== - -* *전체 큐레이션 목록 조회* -+ -[source] ----- -GET /api/v1/curations?cursor=0&pageSize=10 ----- -+ -봄/여름/가을/겨울 추천 위스키 등 모든 큐레이션 목록을 조회합니다. - -* *특정 키워드로 검색* -+ -[source] ----- -GET /api/v1/curations?keyword=봄&cursor=0&pageSize=10 ----- -+ -"봄"이라는 키워드가 포함된 큐레이션만 필터링하여 조회합니다. - -* *특정 위스키가 포함된 큐레이션 검색* -+ -[source] ----- -GET /api/v1/curations?alcoholId=123&cursor=0&pageSize=10 ----- -+ -ID가 123인 위스키가 포함된 모든 큐레이션을 조회합니다. -예를 들어 "글렌피딕 15년"이 "봄 추천 위스키"와 "비오는 날 추천 위스키"에 모두 포함되어 있다면 두 큐레이션이 모두 반환됩니다. - -''' - -=== 큐레이션 위스키 목록 조회 === - -특정 큐레이션에 포함된 위스키 목록을 조회합니다. - -큐레이션 목록 조회 API에서 받은 큐레이션 ID를 사용하여 -해당 큐레이션에 속한 위스키들의 상세 정보(평점, 리뷰 수, 찜 횟수 등)를 함께 조회할 수 있습니다. - -[source] ----- -GET /api/v1/curations/{curationId}/alcohols ----- - -[discrete] -==== 경로 파라미터 ==== - -include::{snippets}/curation-alcohols-get/path-parameters.adoc[] - -[discrete] -==== 요청 파라미터 ==== - -include::{snippets}/curation-alcohols-get/query-parameters.adoc[] - -[discrete] -==== 응답 파라미터 ==== - -include::{snippets}/curation-alcohols-get/response-fields.adoc[] - -[discrete] -==== 응답 예시 ==== - -include::{snippets}/curation-alcohols-get/response-body.adoc[] - -[discrete] -==== 사용 예시 ==== - -"봄 추천 위스키" 큐레이션의 ID가 1번이라면 다음과 같이 요청할 수 있습니다: - -[source] ----- -GET /api/v1/curations/1/alcohols?cursor=0&pageSize=10 ----- - -이 요청은 봄철에 어울리는 위스키 목록과 함께 각 위스키의 평점, 리뷰 수, 찜 횟수 등의 정보를 반환합니다. diff --git a/bottlenote-product-api/src/docs/asciidoc/api/alcohols/search.adoc b/bottlenote-product-api/src/docs/asciidoc/api/alcohols/search.adoc index 48515cbe3..d01827ab5 100644 --- a/bottlenote-product-api/src/docs/asciidoc/api/alcohols/search.adoc +++ b/bottlenote-product-api/src/docs/asciidoc/api/alcohols/search.adoc @@ -12,8 +12,6 @@ - 큐레이션 키워드로 검색 시, 해당 큐레이션에 포함된 위스키 목록이 반환됩니다 - 예: `봄 추천 위스키`, `여름 추천 위스키`, `비 오는 날 추천 위스키` 등 - 큐레이션 키워드는 동적으로 관리되므로, 새로운 큐레이션이 추가되거나 기존 큐레이션이 수정될 수 있습니다 -- 사용 가능한 큐레이션 키워드 목록은 <<_큐레이션_키워드_조회,큐레이션 키워드 조회 API>>를 통해 확인할 수 있습니다 -- 특정 큐레이션의 위스키만 조회하려면 <<_큐레이션_위스키_목록_조회,큐레이션 위스키 목록 조회 API>>를 사용하는 것을 권장합니다 [discrete] ==== 요청 파라미터 ==== diff --git a/bottlenote-product-api/src/docs/asciidoc/product-api.adoc b/bottlenote-product-api/src/docs/asciidoc/product-api.adoc index 4f8b168e1..2d5c7631a 100644 --- a/bottlenote-product-api/src/docs/asciidoc/product-api.adoc +++ b/bottlenote-product-api/src/docs/asciidoc/product-api.adoc @@ -95,9 +95,6 @@ include::api/alcohols/region.adoc[] ''' include::api/alcohols/categories.adoc[] -''' -include::api/alcohols/curations.adoc[] - ''' include::api/curation/v2.adoc[] diff --git a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholReferenceController.java b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholReferenceController.java index 2db352e46..9b1230aa5 100644 --- a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholReferenceController.java +++ b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholReferenceController.java @@ -4,18 +4,12 @@ import app.bottlenote.alcohols.constant.AlcoholType; import app.bottlenote.alcohols.controller.docs.AlcoholReferenceApiDocs; -import app.bottlenote.alcohols.dto.request.CurationKeywordSearchRequest; -import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; -import app.bottlenote.alcohols.dto.response.CurationKeywordResponse; import app.bottlenote.alcohols.service.AlcoholReferenceService; import app.bottlenote.global.annotation.SecurityPolicy; import app.bottlenote.global.data.response.GlobalResponse; -import app.bottlenote.global.service.cursor.CursorResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -41,24 +35,4 @@ public ResponseEntity getAlcoholCategory( @RequestParam(required = false, defaultValue = "WHISKY") AlcoholType type) { return GlobalResponse.ok(alcoholReferenceService.getAlcoholCategory(type)); } - - @AlcoholReferenceApiDocs.SearchCurationKeywords - @GetMapping("/curations") - public ResponseEntity searchCurationKeywords( - @ModelAttribute CurationKeywordSearchRequest request) { - CursorResponse response = - alcoholReferenceService.searchCurationKeywords(request); - return GlobalResponse.ok(response); - } - - @AlcoholReferenceApiDocs.GetCurationAlcohols - @GetMapping("/curations/{curationId}/alcohols") - public ResponseEntity getCurationAlcohols( - @PathVariable Long curationId, - @RequestParam(required = false, defaultValue = "0") Long cursor, - @RequestParam(required = false, defaultValue = "10") Long pageSize) { - CursorResponse response = - alcoholReferenceService.getCurationAlcohols(curationId, cursor, pageSize); - return GlobalResponse.ok(response); - } } diff --git a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/docs/AlcoholReferenceApiDocs.java b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/docs/AlcoholReferenceApiDocs.java index f0ca46d72..61594cb9a 100644 --- a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/docs/AlcoholReferenceApiDocs.java +++ b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/docs/AlcoholReferenceApiDocs.java @@ -1,10 +1,7 @@ package app.bottlenote.alcohols.controller.docs; -import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; import app.bottlenote.alcohols.dto.response.CategoryItem; -import app.bottlenote.alcohols.dto.response.CurationKeywordResponse; import app.bottlenote.alcohols.dto.response.RegionsItem; -import app.bottlenote.global.service.cursor.CursorPageable; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; @@ -15,7 +12,6 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import java.util.List; /** 위스키 기준 정보와 큐레이션 조회 엔드포인트의 문서 설명. */ public final class AlcoholReferenceApiDocs { @@ -54,48 +50,4 @@ private AlcoholReferenceApiDocs() {} @Content( array = @ArraySchema(schema = @Schema(implementation = CategoryItem.class))))) public @interface GetCategories {} - - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @Operation( - summary = "큐레이션 키워드를 검색한다", - description = "노출 중인 큐레이션을 커서 방식으로 가져옵니다. 응답의 pageable로 다음 페이지를 이어 요청합니다.", - responses = - @ApiResponse( - responseCode = "200", - description = "큐레이션 키워드 목록", - content = @Content(schema = @Schema(implementation = CurationKeywordPage.class)))) - public @interface SearchCurationKeywords {} - - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @Operation( - summary = "큐레이션에 담긴 위스키를 조회한다", - description = "해당 큐레이션에 편성된 위스키를 커서 방식으로 가져옵니다. 삭제된 위스키는 제외됩니다.", - responses = - @ApiResponse( - responseCode = "200", - description = "큐레이션에 담긴 위스키 목록", - content = @Content(schema = @Schema(implementation = CurationAlcoholPage.class)))) - public @interface GetCurationAlcohols {} - - /** 실제로는 {@code CursorResponse}다. */ - @Schema( - name = "CurationKeywordPage", - title = "큐레이션 키워드 페이지", - description = "이번 페이지의 큐레이션과 다음 페이지 정보") - private record CurationKeywordPage( - @ArraySchema(schema = @Schema(implementation = CurationKeywordResponse.class)) - List items, - CursorPageable pageable) {} - - /** 실제로는 {@code CursorResponse}다. */ - @Schema( - name = "CurationAlcoholPage", - title = "큐레이션 위스키 페이지", - description = "이번 페이지의 위스키와 다음 페이지 정보") - private record CurationAlcoholPage( - @ArraySchema(schema = @Schema(implementation = AlcoholsSearchItem.class)) - List items, - CursorPageable pageable) {} } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/AlcoholReferenceControllerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/AlcoholReferenceControllerTest.java new file mode 100644 index 000000000..33ca6164d --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/AlcoholReferenceControllerTest.java @@ -0,0 +1,28 @@ +package app.bottlenote.alcohols.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import app.bottlenote.alcohols.service.AlcoholReferenceService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@Tag("unit") +class AlcoholReferenceControllerTest { + + @Test + @DisplayName("레거시 큐레이션 v1 경로는 더 이상 노출하지 않는다") + void doesNotExposeLegacyCurationV1Endpoints() throws Exception { + AlcoholReferenceService service = org.mockito.Mockito.mock(AlcoholReferenceService.class); + MockMvc mockMvc = + MockMvcBuilders.standaloneSetup(new AlcoholReferenceController(service)).build(); + + mockMvc.perform(get("/api/v1/curations")).andExpect(status().isNotFound()); + mockMvc + .perform(get("/api/v1/curations/{curationId}/alcohols", 1L)) + .andExpect(status().isNotFound()); + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java index 2a441a987..45aadf8c4 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java @@ -12,7 +12,6 @@ import app.bottlenote.alcohols.domain.Alcohol; import app.bottlenote.alcohols.domain.AlcoholQueryRepository; import app.bottlenote.alcohols.domain.AlcoholsTastingTags; -import app.bottlenote.alcohols.domain.CurationKeyword; import app.bottlenote.alcohols.domain.Distillery; import app.bottlenote.alcohols.domain.Region; import app.bottlenote.alcohols.domain.TastingTag; @@ -20,11 +19,9 @@ import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; import app.bottlenote.alcohols.fixture.AlcoholTestFactory; -import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.rating.fixture.RatingTestFactory; import app.bottlenote.user.domain.User; import app.bottlenote.user.fixture.UserTestFactory; -import com.fasterxml.jackson.databind.JsonNode; import jakarta.persistence.EntityManager; import java.util.List; import java.util.Set; @@ -461,42 +458,6 @@ void test_12() throws Exception { assertFalse(resultIds.contains(alcohol3.getId())); // alcohol3은 큐레이션에 없음 } - @Test - @DisplayName("삭제 처리된 알코올은 Product 큐레이션 위스키 목록에서 제외된다.") - void product_curation_alcohols_excludes_deleted_alcohol() throws Exception { - Alcohol visible = alcoholTestFactory.persistAlcoholWithName("큐레이션 노출", "Curation Visible"); - Alcohol deleted = alcoholTestFactory.persistAlcoholWithName("큐레이션 삭제", "Curation Deleted"); - deleted.delete(); - alcoholQueryRepository.save(deleted); - CurationKeyword curation = - alcoholTestFactory.persistCurationKeyword("삭제 제외 큐레이션", List.of(visible, deleted)); - - MvcTestResult result = - mockMvcTester - .get() - .uri("/api/v1/curations/{curationId}/alcohols", curation.getId()) - .param("cursor", "0") - .param("pageSize", "10") - .contentType(APPLICATION_JSON) - .with(csrf()) - .exchange(); - - GlobalResponse response = parseResponse(result); - JsonNode items = mapper.convertValue(response.getData(), JsonNode.class).path("items"); - - assertTrue(containsAlcoholId(items, visible.getId())); - assertFalse(containsAlcoholId(items, deleted.getId())); - } - - private boolean containsAlcoholId(JsonNode items, Long alcoholId) { - for (JsonNode item : items) { - if (item.path("alcoholId").asLong() == alcoholId) { - return true; - } - } - return false; - } - @Test @DisplayName("커서 기반 페이징 시 페이지 간 중복 데이터가 발생하지 않는다.") void test_13() throws Exception { diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholQueryControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholQueryControllerTest.java index 9bc86ebde..85697b2b5 100644 --- a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholQueryControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholQueryControllerTest.java @@ -168,8 +168,7 @@ void docs_1() throws Exception { .optional() .description( "큐레이션 ID (특정 큐레이션에 포함된 알코올만 필터링)\n" - + "- keyword와 함께 사용 시 curationId가 우선 적용\n" - + "- 큐레이션 목록은 /api/v1/curations API에서 조회 가능"), + + "- keyword와 함께 사용 시 curationId가 우선 적용"), parameterWithName("category") .optional() .description("카테고리 필터 (category API 참조)\n" + "- 예: SINGLE_MALT, BLEND 등"), diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholReferenceControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholReferenceControllerTest.java deleted file mode 100644 index 6f7eaf33b..000000000 --- a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholReferenceControllerTest.java +++ /dev/null @@ -1,208 +0,0 @@ -package app.docs.alcohols; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; -import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; -import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; -import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; -import static org.springframework.restdocs.payload.JsonFieldType.OBJECT; -import static org.springframework.restdocs.payload.JsonFieldType.STRING; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; -import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; -import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; -import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.alcohols.controller.AlcoholReferenceController; -import app.bottlenote.alcohols.dto.request.CurationKeywordSearchRequest; -import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; -import app.bottlenote.alcohols.dto.response.CurationKeywordResponse; -import app.bottlenote.alcohols.service.AlcoholReferenceService; -import app.bottlenote.global.service.cursor.CursorPageable; -import app.bottlenote.global.service.cursor.CursorResponse; -import app.external.docs.AbstractRestDocs; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.springframework.test.web.servlet.ResultActions; - -@Tag("restdocs") -@DisplayName("큐레이션 키워드 API 문서화 테스트") -class RestAlcoholReferenceControllerTest extends AbstractRestDocs { - - private final AlcoholReferenceService alcoholReferenceService = - mock(AlcoholReferenceService.class); - - @Override - protected Object initController() { - return new AlcoholReferenceController(alcoholReferenceService); - } - - @Test - @DisplayName("큐레이션 키워드 목록을 조회할 수 있다") - void searchCurationKeywords() throws Exception { - // given - CurationKeywordResponse dto1 = - CurationKeywordResponse.builder() - .id(1L) - .name("봄 추천 위스키") - .description("봄에 어울리는 위스키 모음") - .coverImageUrl("https://example.com/spring-whisky.jpg") - .alcoholCount(10) - .displayOrder(1) - .build(); - - CurationKeywordResponse dto2 = - CurationKeywordResponse.builder() - .id(2L) - .name("여름 추천 위스키") - .description("여름에 어울리는 위스키 모음") - .coverImageUrl("https://example.com/summer-whisky.jpg") - .alcoholCount(8) - .displayOrder(2) - .build(); - - CursorPageable pageable = - CursorPageable.builder().currentCursor(0L).cursor(10L).pageSize(10L).hasNext(false).build(); - - CursorResponse response = - CursorResponse.of(List.of(dto1, dto2), pageable); - - when(alcoholReferenceService.searchCurationKeywords(any(CurationKeywordSearchRequest.class))) - .thenReturn(response); - - // when - ResultActions resultActions = - mockMvc.perform( - get("/api/v1/curations") - .param("keyword", "봄") - .param("cursor", "0") - .param("pageSize", "10")); - - // then - resultActions - .andExpect(status().isOk()) - .andDo( - document( - "curation-keywords-search", - queryParameters( - parameterWithName("keyword").description("큐레이션 키워드 이름 검색 (부분 일치)").optional(), - parameterWithName("alcoholId") - .description( - "위스키 식별자로 검색 - 해당 ID의 위스키가 포함된 큐레이션 조회 (예: alcoholId=123 검색 시 ID가 123인 위스키가 포함된 모든 큐레이션 반환)") - .optional(), - parameterWithName("cursor").description("커서 페이징 (기본값: 0)").optional(), - parameterWithName("pageSize").description("페이지 크기 (기본값: 10)").optional()), - responseFields( - fieldWithPath("success").type(BOOLEAN).description("성공 여부"), - fieldWithPath("code").type(NUMBER).description("응답 코드"), - fieldWithPath("data").type(OBJECT).description("응답 데이터"), - fieldWithPath("data.items").type(ARRAY).description("큐레이션 키워드 목록"), - fieldWithPath("data.items[].id").type(NUMBER).description("큐레이션 ID"), - fieldWithPath("data.items[].name").type(STRING).description("큐레이션 이름"), - fieldWithPath("data.items[].description").type(STRING).description("큐레이션 설명"), - fieldWithPath("data.items[].coverImageUrl") - .type(STRING) - .description("큐레이션 커버 이미지 URL"), - fieldWithPath("data.items[].alcoholCount") - .type(NUMBER) - .description("포함된 위스키 개수"), - fieldWithPath("data.items[].displayOrder").type(NUMBER).description("노출 순서"), - fieldWithPath("data.pageable").type(OBJECT).description("페이징 정보"), - fieldWithPath("data.pageable.currentCursor").type(NUMBER).description("현재 커서"), - fieldWithPath("data.pageable.cursor").type(NUMBER).description("다음 커서"), - fieldWithPath("data.pageable.pageSize").type(NUMBER).description("페이지 크기"), - fieldWithPath("data.pageable.hasNext") - .type(BOOLEAN) - .description("다음 페이지 존재 여부"), - fieldWithPath("meta").type(OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(STRING).description("서버 버전"), - fieldWithPath("meta.serverEncoding").type(STRING).description("서버 인코딩"), - fieldWithPath("meta.serverResponseTime").type(ARRAY).description("응답 시간"), - fieldWithPath("meta.serverPathVersion").type(STRING).description("API 경로 버전"), - fieldWithPath("errors").description("에러 정보").optional()))); - } - - @Test - @DisplayName("특정 큐레이션의 위스키 목록을 조회할 수 있다") - void getCurationAlcohols() throws Exception { - // given - AlcoholsSearchItem item = - AlcoholsSearchItem.builder() - .alcoholId(1L) - .korName("글렌피딕 15년") - .engName("Glenfiddich 15") - .korCategoryName("싱글 몰트") - .engCategoryName("Single Malt") - .imageUrl("https://example.com/image.jpg") - .rating(4.5) - .ratingCount(100L) - .reviewCount(50L) - .pickCount(30L) - .isPicked(false) - .build(); - - CursorPageable pageable = - CursorPageable.builder().currentCursor(0L).cursor(10L).pageSize(10L).hasNext(false).build(); - - CursorResponse response = CursorResponse.of(List.of(item), pageable); - - when(alcoholReferenceService.getCurationAlcohols(any(), any(), any())).thenReturn(response); - - // when - ResultActions resultActions = - mockMvc.perform( - get("/api/v1/curations/{curationId}/alcohols", 1L) - .param("cursor", "0") - .param("pageSize", "10")); - - // then - resultActions - .andExpect(status().isOk()) - .andDo( - document( - "curation-alcohols-get", - pathParameters(parameterWithName("curationId").description("큐레이션 ID")), - queryParameters( - parameterWithName("cursor").description("커서 페이징 (기본값: 0)").optional(), - parameterWithName("pageSize").description("페이지 크기 (기본값: 10)").optional()), - responseFields( - fieldWithPath("success").type(BOOLEAN).description("성공 여부"), - fieldWithPath("code").type(NUMBER).description("응답 코드"), - fieldWithPath("data").type(OBJECT).description("응답 데이터"), - fieldWithPath("data.items").type(ARRAY).description("위스키 목록"), - fieldWithPath("data.items[].alcoholId").type(NUMBER).description("위스키 ID"), - fieldWithPath("data.items[].korName").type(STRING).description("위스키 한글명"), - fieldWithPath("data.items[].engName").type(STRING).description("위스키 영문명"), - fieldWithPath("data.items[].korCategoryName") - .type(STRING) - .description("카테고리 한글명"), - fieldWithPath("data.items[].engCategoryName") - .type(STRING) - .description("카테고리 영문명"), - fieldWithPath("data.items[].imageUrl").type(STRING).description("이미지 URL"), - fieldWithPath("data.items[].rating").type(NUMBER).description("평점"), - fieldWithPath("data.items[].ratingCount").type(NUMBER).description("평점 개수"), - fieldWithPath("data.items[].reviewCount").type(NUMBER).description("리뷰 개수"), - fieldWithPath("data.items[].pickCount").type(NUMBER).description("찜 개수"), - fieldWithPath("data.items[].isPicked").type(BOOLEAN).description("찜 여부"), - fieldWithPath("data.pageable").type(OBJECT).description("페이징 정보"), - fieldWithPath("data.pageable.currentCursor").type(NUMBER).description("현재 커서"), - fieldWithPath("data.pageable.cursor").type(NUMBER).description("다음 커서"), - fieldWithPath("data.pageable.pageSize").type(NUMBER).description("페이지 크기"), - fieldWithPath("data.pageable.hasNext") - .type(BOOLEAN) - .description("다음 페이지 존재 여부"), - fieldWithPath("meta").type(OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(STRING).description("서버 버전"), - fieldWithPath("meta.serverEncoding").type(STRING).description("서버 인코딩"), - fieldWithPath("meta.serverResponseTime").type(ARRAY).description("응답 시간"), - fieldWithPath("meta.serverPathVersion").type(STRING).description("API 경로 버전"), - fieldWithPath("errors").description("에러 정보").optional()))); - } -} diff --git a/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/InMemoryCurationKeywordRepository.java b/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/InMemoryCurationKeywordRepository.java index af1b034e1..3d297c186 100644 --- a/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/InMemoryCurationKeywordRepository.java +++ b/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/InMemoryCurationKeywordRepository.java @@ -4,9 +4,7 @@ import app.bottlenote.alcohols.domain.CurationKeywordRepository; import app.bottlenote.alcohols.dto.request.AdminCurationSearchRequest; import app.bottlenote.alcohols.dto.response.AdminCurationListResponse; -import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; -import app.bottlenote.alcohols.dto.response.CurationKeywordResponse; -import app.bottlenote.global.service.cursor.CursorResponse; + import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -31,17 +29,6 @@ public Optional findByNameContainingAndIsActiveTrue(String name return Optional.empty(); } - @Override - public CursorResponse searchCurationKeywords( - String keyword, Long alcoholId, Long cursor, Integer pageSize) { - return null; - } - - @Override - public CursorResponse getCurationAlcohols( - Long curationId, Long cursor, Integer pageSize) { - return null; - } @Override public Optional> findAlcoholIdsByKeyword(String keyword) {