From 1e393d82990c84912c64e84ac3dd962fa451ae2e Mon Sep 17 00:00:00 2001 From: "bottlenote-app[bot]" <289617182+bottlenote-app[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:45:48 +0900 Subject: [PATCH] fix(alcohol): hide deleted alcohols from product API --- .../CustomAlcoholQueryRepositoryImpl.java | 12 +++-- .../repository/JpaAlcoholQueryRepository.java | 3 +- .../alcohols/service/AlcoholQueryService.java | 17 ++++--- ...coholExploreControllerIntegrationTest.java | 19 +++++++ .../AlcoholQueryIntegrationTest.java | 49 +++++++++++++++++++ 5 files changed, 88 insertions(+), 12 deletions(-) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java index d2f952ecf..3bab8c43c 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java @@ -53,6 +53,7 @@ public List findAllCategoryItems() { alcohol.engCategory, alcohol.categoryGroup)) .from(alcohol) + .where(alcohol.type.eq(WHISKY), supporter.isNotDeleted()) .groupBy(alcohol.korCategory, alcohol.engCategory, alcohol.categoryGroup) .orderBy(alcohol.korCategory.asc()) .fetch(); @@ -130,6 +131,7 @@ public PageResponse searchAlcohols(AlcoholSearchCriteria .leftJoin(review) .on(alcohol.id.eq(review.alcoholId)) .where( + supporter.isNotDeleted(), supporter.keywordMatch(criteriaDto.keyword()), supporter.eqCurationId(criteriaDto.curationId()), supporter.eqCategory(criteriaDto.category()), @@ -153,6 +155,7 @@ public PageResponse searchAlcohols(AlcoholSearchCriteria .select(alcohol.id.count()) .from(alcohol) .where( + supporter.isNotDeleted(), supporter.keywordMatch(criteriaDto.keyword()), supporter.eqCurationId(criteriaDto.curationId()), supporter.eqCategory(criteriaDto.category()), @@ -214,7 +217,7 @@ public AlcoholDetailItem findAlcoholDetailById(Long alcoholId, Long userId) { .on(alcohol.region.id.eq(region.id)) .join(distillery) .on(alcohol.distillery.id.eq(distillery.id)) - .where(alcohol.id.eq(alcoholId)) + .where(alcohol.id.eq(alcoholId), supporter.isNotDeleted()) .groupBy( alcohol.id, alcohol.imageUrl, @@ -254,7 +257,7 @@ public Optional findAlcoholInfoById(Long alcoholId, Long use .on(alcohol.region.id.eq(region.id)) .join(distillery) .on(alcohol.distillery.id.eq(distillery.id)) - .where(alcohol.id.eq(alcoholId)) + .where(alcohol.id.eq(alcoholId), supporter.isNotDeleted()) .groupBy( alcohol.id, alcohol.korCategory, @@ -336,7 +339,7 @@ public CursorResponse getStandardExplore(ExploreStandardCrite .on(alcohol.region.id.eq(region.id)) .join(distillery) .on(alcohol.distillery.id.eq(distillery.id)) - .where(alcohol.id.in(candidateIds)) + .where(alcohol.id.in(candidateIds), supporter.isNotDeleted()) .groupBy( alcohol.id, alcohol.imageUrl, @@ -395,7 +398,8 @@ private List fetchCandidateIds(ExploreStandardCriteria criteria, int fetch supporter.eqCategory(criteria.category()), supporter.inRegionIds(criteria.regionIds()), supporter.inDistilleryIds(criteria.distilleryIds()), - supporter.eqCurationId(criteria.curationId())); + supporter.eqCurationId(criteria.curationId()), + supporter.isNotDeleted()); if (sortType == SearchSortType.RANDOM) { // seed 는 Service 가 null 방어/생성 후 주입. id.asc() tiebreaker 로 동일 RAND 값 충돌 시에도 결정론적 순서 보장. diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholQueryRepository.java index b249a052d..fe091ada3 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholQueryRepository.java @@ -22,13 +22,14 @@ public interface JpaAlcoholQueryRepository Select new app.bottlenote.alcohols.dto.response.CategoryItem(a.korCategory, a.engCategory,a.categoryGroup) from alcohol a where a.type = :type + and a.deletedAt is null group by a.korCategory, a.engCategory,a.categoryGroup order by case when a.categoryGroup = app.bottlenote.alcohols.constant.AlcoholCategoryGroup.OTHER then 1 else 0 end,a.korCategory """) List findAllCategories(AlcoholType type); - @Query("SELECT COUNT(a) > 0 FROM alcohol a WHERE a.id = :alcoholId") + @Query("SELECT COUNT(a) > 0 FROM alcohol a WHERE a.id = :alcoholId AND a.deletedAt IS NULL") Boolean existsByAlcoholId(@Param("alcoholId") Long alcoholId); @Query("SELECT COUNT(a) > 0 FROM alcohol a WHERE a.distillery.id = :distilleryId") diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java index dc9f50058..3c8a3d0b4 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java @@ -1,5 +1,7 @@ package app.bottlenote.alcohols.service; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.ALCOHOL_NOT_FOUND; + import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; import app.bottlenote.alcohols.constant.SearchSortType; import app.bottlenote.alcohols.domain.Alcohol; @@ -19,7 +21,6 @@ import app.bottlenote.alcohols.dto.response.ExploreStandardResponse; import app.bottlenote.alcohols.dto.response.FriendsDetailResponse; import app.bottlenote.alcohols.exception.AlcoholException; -import app.bottlenote.alcohols.exception.AlcoholExceptionCode; import app.bottlenote.alcohols.repository.CustomAlcoholQueryRepository.AdminAlcoholDetailProjection; import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.global.service.cursor.CursorResponse; @@ -32,6 +33,7 @@ import java.util.EnumMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ThreadLocalRandom; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -73,16 +75,17 @@ public PageResponse searchAlcohols( */ @Transactional(readOnly = true) public AlcoholDetailResponse findAlcoholDetailById(Long alcoholId, Long userId) { - AlcoholDetailItem alcoholDetail = - alcoholQueryRepository.findAlcoholDetailById(alcoholId, userId); + AlcoholDetailItem alcoholDetailItem = + Optional.ofNullable(alcoholQueryRepository.findAlcoholDetailById(alcoholId, userId)) + .orElseThrow(() -> new AlcoholException(ALCOHOL_NOT_FOUND)); // 조회 기록 저장 (게스트 사용자 제외) - if (userId > 0 && alcoholDetail != null) viewHistoryService.recordView(userId, alcoholDetail); + if (userId > 0) viewHistoryService.recordView(userId, alcoholDetailItem); FriendsDetailResponse friendInfos = getFriendInfos(alcoholId, userId); return AlcoholDetailResponse.builder() - .alcohols(alcoholDetail) + .alcohols(alcoholDetailItem) .friendsInfo(friendInfos) .reviewInfo(reviewFacade.getReviewInfoList(alcoholId, userId)) .build(); @@ -146,12 +149,12 @@ public AdminAlcoholDetailResponse findAdminAlcoholDetailById(Long alcoholId) { AdminAlcoholDetailProjection projection = alcoholQueryRepository .findAdminAlcoholDetailById(alcoholId) - .orElseThrow(() -> new AlcoholException(AlcoholExceptionCode.ALCOHOL_NOT_FOUND)); + .orElseThrow(() -> new AlcoholException(ALCOHOL_NOT_FOUND)); Alcohol alcohol = alcoholQueryRepository .findById(alcoholId) - .orElseThrow(() -> new AlcoholException(AlcoholExceptionCode.ALCOHOL_NOT_FOUND)); + .orElseThrow(() -> new AlcoholException(ALCOHOL_NOT_FOUND)); List tastingTags = alcohol.getAlcoholsTastingTags().stream() diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/AlcoholExploreControllerIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/AlcoholExploreControllerIntegrationTest.java index 533af3819..ea51f5a64 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/AlcoholExploreControllerIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/AlcoholExploreControllerIntegrationTest.java @@ -97,6 +97,25 @@ void explore_empty_result() { result.assertThat().bodyJson().extractingPath("$.meta.pageable.hasNext").isEqualTo(false); } + @Test + @DisplayName("삭제 처리된 알코올은 Product 둘러보기에서 제외된다") + void explore_excludes_deleted_alcohol() { + Alcohol visible = alcoholTestFactory.persistAlcoholWithName("둘러보기 노출", "Explore Visible"); + Alcohol deleted = alcoholTestFactory.persistAlcoholWithName("둘러보기 삭제", "Explore Deleted"); + deleted.delete(); + + MvcTestResult result = exchangeGet(b -> b.param("keywords", "둘러보기").param("size", "10")); + + result + .assertThat() + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.items[*].alcoholId") + .asArray() + .contains(visible.getId().intValue()) + .doesNotContain(deleted.getId().intValue()); + } + @Test @DisplayName("응답 item에 reviewCount, pickCount 필드가 포함된다") void explore_response_includes_count_fields() { 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 2010c89c2..adbb27a71 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 @@ -63,6 +63,35 @@ void test_1() throws Exception { assertEquals(alcohols.size(), alcoholSearchResponse.getTotalCount()); } + @Test + @DisplayName("삭제 처리된 알코올은 Product 목록 검색에서 제외된다.") + void product_search_excludes_deleted_alcohol() throws Exception { + Alcohol visible = alcoholTestFactory.persistAlcoholWithName("노출 위스키", "Visible Whisky"); + Alcohol deleted = alcoholTestFactory.persistAlcoholWithName("삭제 위스키", "Deleted Whisky"); + deleted.delete(); + em.flush(); + em.clear(); + + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/alcohols/search") + .param("keyword", "위스키") + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); + + AlcoholSearchResponse response = extractData(result, AlcoholSearchResponse.class); + Set resultIds = + response.getAlcohols().stream() + .map(AlcoholsSearchItem::getAlcoholId) + .collect(java.util.stream.Collectors.toSet()); + + assertTrue(resultIds.contains(visible.getId())); + assertFalse(resultIds.contains(deleted.getId())); + } + @Test @DisplayName("키워드를 알코올 목록조회를 할 수 있다.") void test_1_1() throws Exception { @@ -126,6 +155,26 @@ void test_2() throws Exception { assertNotNull(alcoholDetail.friendsInfo()); } + @Test + @DisplayName("삭제 처리된 알코올은 Product 상세 조회에서 찾을 수 없다.") + void product_detail_excludes_deleted_alcohol() { + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + alcohol.delete(); + em.flush(); + em.clear(); + + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/alcohols/{alcoholId}", alcohol.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); + + result.assertThat().hasStatus(org.springframework.http.HttpStatus.NOT_FOUND); + } + @Test @DisplayName("알코올 상세 조회 시 해당 알콤올을 마셔본 팔로잉 유저의 정보를 조회할 수 있다.") void test_3() throws Exception {