diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/ProductSpecBasedCurationService.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/ProductSpecBasedCurationService.java index 75b6d2192..97041e4d2 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/ProductSpecBasedCurationService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/ProductSpecBasedCurationService.java @@ -54,21 +54,33 @@ public List listActiveCurations() { @Transactional(readOnly = true) public CursorResponse searchFeed( Long cursor, Integer size) { + return searchFeed(null, null, cursor, size); + } + + @Transactional(readOnly = true) + public CursorResponse searchFeed( + String keyword, String code, Long cursor, Integer size) { int pageSize = normalizeFeedSize(size); long currentCursor = cursor != null && cursor > 0 ? cursor : 0L; List visibleCurations = curationRepository.findAllVisibleOn(LocalDate.now()); - int fromIndex = Math.min(Math.toIntExact(currentCursor), visibleCurations.size()); - int toIndex = Math.min(fromIndex + pageSize + 1, visibleCurations.size()); - List pageContent = new ArrayList<>(visibleCurations.subList(fromIndex, toIndex)); - Map specMap = + Map allSpecMap = curationSpecRepository .findAllByIdIn( - pageContent.stream().map(Curation::getSpecId).collect(Collectors.toSet())) + visibleCurations.stream().map(Curation::getSpecId).collect(Collectors.toSet())) .stream() .collect(Collectors.toMap(CurationSpec::getId, Function.identity())); + List filteredCurations = + visibleCurations.stream() + .filter(curation -> matchesCode(allSpecMap.get(curation.getSpecId()), code)) + .filter( + curation -> matchesKeyword(curation, allSpecMap.get(curation.getSpecId()), keyword)) + .toList(); + int fromIndex = Math.min(Math.toIntExact(currentCursor), filteredCurations.size()); + int toIndex = Math.min(fromIndex + pageSize + 1, filteredCurations.size()); + List pageContent = new ArrayList<>(filteredCurations.subList(fromIndex, toIndex)); List items = pageContent.stream() - .map(curation -> toFeedResponse(curation, specMap.get(curation.getSpecId()))) + .map(curation -> toFeedResponse(curation, allSpecMap.get(curation.getSpecId()))) .toList(); return CursorResponse.of(items, currentCursor, pageSize); } @@ -157,6 +169,33 @@ private List imageUrls(Curation curation) { .toList(); } + private boolean matchesCode(CurationSpec spec, String code) { + if (isBlank(code)) { + return true; + } + return spec != null && spec.getCode().equals(code.trim()); + } + + private boolean matchesKeyword(Curation curation, CurationSpec spec, String keyword) { + if (isBlank(keyword)) { + return true; + } + String normalizedKeyword = keyword.trim(); + return contains(curation.getName(), normalizedKeyword) + || contains(curation.getDescription(), normalizedKeyword) + || (spec != null + && (contains(spec.getName(), normalizedKeyword) + || contains(spec.getDescription(), normalizedKeyword))); + } + + private boolean contains(String value, String keyword) { + return value != null && value.contains(keyword); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + private int normalizeFeedSize(Integer size) { if (size == null || size < 1) { return MAX_FEED_SIZE; diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/ProductSpecBasedCurationServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/ProductSpecBasedCurationServiceTest.java index 6e8760ea7..56298aad3 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/ProductSpecBasedCurationServiceTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/ProductSpecBasedCurationServiceTest.java @@ -176,7 +176,7 @@ void searchFeed_whenSizeExceedsLimit_capsToTenAndKeepsVisibility() throws IOExce createCuration( spec.getId(), "미노출", 1, true, LocalDate.now().plusDays(1), LocalDate.now().plusDays(5)); - var result = productService.searchFeed(0L, 30); + var result = productService.searchFeed(null, null, 0L, 30); assertThat(result.items()).hasSize(10); assertThat(result.pageable().getPageSize()).isEqualTo(10L); @@ -185,6 +185,44 @@ void searchFeed_whenSizeExceedsLimit_capsToTenAndKeepsVisibility() throws IOExce assertThat(result.pageable().getHasNext()).isTrue(); } + @Test + @DisplayName( + "Product feed 검색은 keyword를 큐레이션 제목/설명과 스펙 제목/설명에 LIKE 적용하고 code는 별도 exact match로 필터링한다") + void searchFeed_whenKeywordAndCodeProvided_filtersBeforeCursorPagination() throws IOException { + CurationSpec recommendedSpec = createSpec(); + CurationSpec pairingSpec = + curationFixtureFactory.saveSpec( + "WHISKY_PAIRING", + "위스키 페어링", + "안주 조합 스펙 설명", + schema("whisky_pairing.json", "Request"), + schema("whisky_pairing.json", "Response"), + "alcohol", + 1); + createCuration(recommendedSpec.getId(), "큐레이션 제목 매치", "일반 설명", 1, true); + createCuration(recommendedSpec.getId(), "일반 제목", "큐레이션 설명 매치", 2, true); + createCuration(pairingSpec.getId(), "일반 제목", "일반 설명", 3, true); + createCuration(pairingSpec.getId(), "큐레이션 제목 매치 페어링", "일반 설명", 4, true); + + var keywordResult = productService.searchFeed("큐레이션", null, 0L, 10); + var specKeywordResult = productService.searchFeed("안주", null, 0L, 10); + var codeResult = productService.searchFeed(null, "WHISKY_PAIRING", 0L, 10); + var combinedResult = productService.searchFeed("큐레이션", "WHISKY_PAIRING", 0L, 10); + var negativeResult = productService.searchFeed("큐레이션", "UNKNOWN_CODE", 0L, 10); + var boundaryResult = productService.searchFeed(" ", " ", 0L, 10); + + assertThat(keywordResult.items()) + .extracting("name") + .containsExactly("큐레이션 제목 매치", "일반 제목", "큐레이션 제목 매치 페어링"); + assertThat(specKeywordResult.items()) + .extracting("name") + .containsExactly("일반 제목", "큐레이션 제목 매치 페어링"); + assertThat(codeResult.items()).extracting("name").containsExactly("일반 제목", "큐레이션 제목 매치 페어링"); + assertThat(combinedResult.items()).extracting("name").containsExactly("큐레이션 제목 매치 페어링"); + assertThat(negativeResult.items()).isEmpty(); + assertThat(boundaryResult.items()).hasSize(4); + } + @Test @DisplayName("노출 기간 밖 큐레이션 상세 조회는 Product v2에서 찾을 수 없다") void getDetail_whenCurationOutsideExposureWindow_throwsNotFound() throws IOException { @@ -252,12 +290,36 @@ private Long createCuration( boolean active, LocalDate exposureStartDate, LocalDate exposureEndDate) { + return createCuration( + specId, name, "설명", displayOrder, active, exposureStartDate, exposureEndDate); + } + + private Long createCuration( + Long specId, String name, String description, int displayOrder, boolean active) { + return createCuration( + specId, + name, + description, + displayOrder, + active, + LocalDate.now().minusDays(1), + LocalDate.now().plusDays(1)); + } + + private Long createCuration( + Long specId, + String name, + String description, + int displayOrder, + boolean active, + LocalDate exposureStartDate, + LocalDate exposureEndDate) { return curationFixtureFactory .saveCuration( new CurationCreateRequest( specId, name, - "설명", + description, List.of("https://cdn.example.com/cover.jpg"), exposureStartDate, exposureEndDate, diff --git a/bottlenote-product-api/src/docs/asciidoc/api/curation/v2.adoc b/bottlenote-product-api/src/docs/asciidoc/api/curation/v2.adoc index c4dc113d0..04d27d0e2 100644 --- a/bottlenote-product-api/src/docs/asciidoc/api/curation/v2.adoc +++ b/bottlenote-product-api/src/docs/asciidoc/api/curation/v2.adoc @@ -91,6 +91,9 @@ include::{snippets}/curation/v2/list/response-body.adoc[] === Spec 기반 큐레이션 v2 피드 조회 === - Product 피드 화면에서 사용할 spec 기반 큐레이션 목록을 cursor 방식으로 조회합니다. +- `keyword`는 단일 문자열이며 큐레이션 제목/설명, 스펙 제목/설명에 LIKE 조건으로 적용합니다. +- `code`는 `keyword`와 별개의 큐레이션 스펙 코드 exact match 필터입니다. +- `code` 값은 큐레이션 목록 조회 API(`GET /api/v2/curations`)의 `specCode` 또는 상세 조회 API(`GET /api/v2/curations/{curationId}`)의 `spec.code`에서 확인할 수 있습니다. - 각 item은 상세 응답 형태에서 `spec`을 제외한 구조입니다. - `payload`는 상세 payload와 동일한 중첩/배열 구조를 유지하되, responseSpec의 `x-feed.enabled = true` 필드만 포함합니다. @@ -105,6 +108,11 @@ GET /api/v2/curations/feed include::{snippets}/curation/v2/feed/curl-request.adoc[] include::{snippets}/curation/v2/feed/http-request.adoc[] +[discrete] +==== 요청 파라미터 ==== + +include::{snippets}/curation/v2/feed/query-parameters.adoc[] + [discrete] ==== 응답 파라미터 ==== diff --git a/bottlenote-product-api/src/main/java/app/bottlenote/curation/controller/ProductSpecBasedCurationController.java b/bottlenote-product-api/src/main/java/app/bottlenote/curation/controller/ProductSpecBasedCurationController.java index fb36da01c..035209420 100644 --- a/bottlenote-product-api/src/main/java/app/bottlenote/curation/controller/ProductSpecBasedCurationController.java +++ b/bottlenote-product-api/src/main/java/app/bottlenote/curation/controller/ProductSpecBasedCurationController.java @@ -28,9 +28,12 @@ public ResponseEntity getCurations() { @GetMapping("/feed") public ResponseEntity getCurationFeed( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String code, @RequestParam(required = false, defaultValue = "0") Long cursor, @RequestParam(required = false, defaultValue = "10") Integer size) { - return GlobalResponse.ok(productSpecBasedCurationService.searchFeed(cursor, size)); + return GlobalResponse.ok( + productSpecBasedCurationService.searchFeed(keyword, code, cursor, size)); } @GetMapping("/{curationId}") diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/curation/integration/ProductSpecBasedCurationIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/curation/integration/ProductSpecBasedCurationIntegrationTest.java index ead131a64..cc83a3006 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/curation/integration/ProductSpecBasedCurationIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/curation/integration/ProductSpecBasedCurationIntegrationTest.java @@ -209,6 +209,50 @@ void searchFeed_whenTastingEventAlcoholsHasNoXFeed_excludesAlcoholsPayload() thr assertThat(payload.path("isRecruiting").asBoolean()).isTrue(); assertThat(payload.path("applicationLink").asText()).isEqualTo("https://example.com/apply"); } + + @Test + @DisplayName("Product feed는 keyword와 code 조건을 분리해 필터링하고 빈 조건은 전체 조건으로 처리한다") + void searchFeed_whenKeywordAndCodeProvided_filtersBySearchContract() throws Exception { + // given + createCuration("제목 매치 큐레이션", "일반 설명", 1, true, List.of(manualItem("제목"))); + createCuration("일반 큐레이션", "설명 매치 큐레이션", 2, true, List.of(manualItem("설명"))); + Long tastingId = createTastingEventCuration(); + + // when + MvcTestResult keywordResult = + mockMvcTester + .get() + .uri("/api/v2/curations/feed?keyword=큐레이션&size=10") + .contentType(APPLICATION_JSON) + .exchange(); + MvcTestResult codeResult = + mockMvcTester + .get() + .uri("/api/v2/curations/feed?code=WHISKY_TASTING_EVENT&size=10") + .contentType(APPLICATION_JSON) + .exchange(); + MvcTestResult negativeResult = + mockMvcTester + .get() + .uri("/api/v2/curations/feed?keyword=큐레이션&code=UNKNOWN_CODE&size=10") + .contentType(APPLICATION_JSON) + .exchange(); + MvcTestResult boundaryResult = + mockMvcTester + .get() + .uri("/api/v2/curations/feed?keyword=%20%20%20&code=%20%20%20&size=10") + .contentType(APPLICATION_JSON) + .exchange(); + + // then + assertThat(dataNode(keywordResult).path("items")).hasSize(3); + assertThat(dataNode(codeResult).path("items")).hasSize(1); + assertThat(dataNode(codeResult).path("items").get(0).path("id").asLong()) + .isEqualTo(tastingId); + assertThat(dataNode(negativeResult).path("items")).isEmpty(); + assertThat(dataNode(boundaryResult).path("items").isArray()).isTrue(); + assertThat(dataNode(boundaryResult).path("pageable").path("pageSize").asInt()).isEqualTo(10); + } } @Nested @@ -340,8 +384,18 @@ void getDetail_whenMissingCuration_returnsNotFound() { private Long createCuration( String name, int displayOrder, boolean active, List> payload) { + return createCuration(name, "통합 테스트 큐레이션", displayOrder, active, payload); + } + + private Long createCuration( + String name, + String description, + int displayOrder, + boolean active, + List> payload) { return createCuration( name, + description, displayOrder, active, LocalDate.now().minusDays(1), @@ -356,12 +410,24 @@ private Long createCuration( LocalDate exposureStartDate, LocalDate exposureEndDate, List> payload) { + return createCuration( + name, "통합 테스트 큐레이션", displayOrder, active, exposureStartDate, exposureEndDate, payload); + } + + private Long createCuration( + String name, + String description, + int displayOrder, + boolean active, + LocalDate exposureStartDate, + LocalDate exposureEndDate, + List> payload) { return adminSpecBasedCurationService .create( new CurationCreateRequest( recommendedSpec.getId(), name, - "통합 테스트 큐레이션", + description, List.of("https://cdn.example.com/cover.jpg"), exposureStartDate, exposureEndDate, diff --git a/bottlenote-product-api/src/test/java/app/docs/curation/RestProductSpecBasedCurationControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/curation/RestProductSpecBasedCurationControllerTest.java index 6ee3a84b8..de4da5882 100644 --- a/bottlenote-product-api/src/test/java/app/docs/curation/RestProductSpecBasedCurationControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/curation/RestProductSpecBasedCurationControllerTest.java @@ -13,6 +13,7 @@ import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; 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.curation.controller.ProductSpecBasedCurationController; @@ -102,7 +103,7 @@ void getCurations() throws Exception { @Test @DisplayName("spec 기반 큐레이션 v2 피드를 상세 응답 기반 payload로 조회할 수 있다") void getCurationFeed() throws Exception { - when(productSpecBasedCurationService.searchFeed(0L, 10)) + when(productSpecBasedCurationService.searchFeed("페어링", "WHISKY_PAIRING", 0L, 10)) .thenReturn( CursorResponse.of( List.of( @@ -132,11 +133,21 @@ void getCurationFeed() throws Exception { 10)); mockMvc - .perform(get("/api/v2/curations/feed")) + .perform(get("/api/v2/curations/feed?keyword=페어링&code=WHISKY_PAIRING&cursor=0&size=10")) .andExpect(status().isOk()) .andDo( document( "curation/v2/feed", + queryParameters( + parameterWithName("keyword") + .description("검색어. 큐레이션 제목/설명, 스펙 제목/설명에 LIKE 조건으로 적용됩니다.") + .optional(), + parameterWithName("code") + .description( + "큐레이션 스펙 코드 exact match 필터. 사용 가능한 값은 큐레이션 목록 조회의 specCode 또는 상세 조회의 spec.code에서 확인합니다.") + .optional(), + parameterWithName("cursor").description("조회 시작 커서. 기본값 0").optional(), + parameterWithName("size").description("페이지 크기. 최대 10, 기본값 10").optional()), responseFields( fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), fieldWithPath("code").type(NUMBER).description("응답 코드"),