diff --git a/bottlenote-admin-api/VERSION b/bottlenote-admin-api/VERSION index 9084fa2f7..524cb5524 100644 --- a/bottlenote-admin-api/VERSION +++ b/bottlenote-admin-api/VERSION @@ -1 +1 @@ -1.1.0 +1.1.1 diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt index 8efa8ac68..19d22bc9f 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt @@ -26,11 +26,7 @@ class AdminAlcoholsController( ): ResponseEntity<*> = GlobalResponse.ok(alcoholQueryService.findAdminAlcoholDetailById(alcoholId)) @GetMapping("/categories/reference") - fun getCategoryReference(): ResponseEntity<*> { - val pairs = alcoholQueryService.findAllCategoryPairs() - val response = pairs.map { mapOf("korCategory" to it.left, "engCategory" to it.right) } - return GlobalResponse.ok(response) - } + fun getCategoryReference(): ResponseEntity<*> = GlobalResponse.ok(alcoholQueryService.findAllCategoryReferenceMap()) @PostMapping fun createAlcohol( diff --git a/bottlenote-admin-api/src/main/resources/application.yml b/bottlenote-admin-api/src/main/resources/application.yml index 07ecf73a4..91a07ec1e 100644 --- a/bottlenote-admin-api/src/main/resources/application.yml +++ b/bottlenote-admin-api/src/main/resources/application.yml @@ -13,6 +13,10 @@ server: context-path: /admin/api/v1 encoding: charset: UTF-8 + compression: + enabled: true + mime-types: application/json,application/xml,text/html,text/xml,text/plain,application/javascript,text/css + min-response-size: 1024 tomcat: threads: max: 15 diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt index f9c593058..dfe76ceb2 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt @@ -4,6 +4,7 @@ import app.bottlenote.alcohols.constant.AdminAlcoholSortType import app.bottlenote.alcohols.constant.AlcoholCategoryGroup import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest import app.bottlenote.alcohols.dto.request.AdminAlcoholUpsertRequest +import app.bottlenote.alcohols.dto.response.CategoryPairItem import app.bottlenote.alcohols.presentation.AdminAlcoholsController import app.bottlenote.alcohols.service.AdminAlcoholCommandService import app.bottlenote.alcohols.service.AlcoholQueryService @@ -11,7 +12,6 @@ import app.bottlenote.global.dto.response.AdminResultResponse import app.bottlenote.global.service.cursor.SortOrder import app.helper.alcohols.AlcoholsHelper import com.fasterxml.jackson.databind.ObjectMapper -import org.apache.commons.lang3.tuple.Pair import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -328,15 +328,29 @@ class AdminAlcoholsControllerDocsTest { @Test @DisplayName("카테고리 레퍼런스를 조회할 수 있다") fun getCategoryReference() { - // given - val categoryPairs = listOf( - Pair.of("싱글 몰트", "Single Malt"), - Pair.of("블렌디드", "Blended"), - Pair.of("버번", "Bourbon") + // given — categoryGroup을 키로 갖는 grouped Map. enum 선언 순서로 LinkedHashMap/EnumMap에 채움 + val grouped = linkedMapOf( + AlcoholCategoryGroup.SINGLE_MALT to listOf( + CategoryPairItem("싱글 몰트", "Single Malt") + ), + AlcoholCategoryGroup.BLEND to listOf( + CategoryPairItem("블렌디드", "Blend") + ), + AlcoholCategoryGroup.BLENDED_MALT to listOf( + CategoryPairItem("블렌디드 몰트", "Blended Malt") + ), + AlcoholCategoryGroup.BOURBON to listOf( + CategoryPairItem("버번", "Bourbon") + ), + AlcoholCategoryGroup.RYE to emptyList(), + AlcoholCategoryGroup.OTHER to listOf( + CategoryPairItem("테네시", "Tennessee"), + CategoryPairItem("콘", "Corn") + ) ) - given(alcoholQueryService.findAllCategoryPairs()) - .willReturn(categoryPairs) + given(alcoholQueryService.findAllCategoryReferenceMap()) + .willReturn(grouped) // when & then assertThat( @@ -351,9 +365,23 @@ class AdminAlcoholsControllerDocsTest { responseFields( fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.ARRAY).description("카테고리 페어 목록"), - fieldWithPath("data[].korCategory").type(JsonFieldType.STRING).description("한글 카테고리"), - fieldWithPath("data[].engCategory").type(JsonFieldType.STRING).description("영문 카테고리"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("categoryGroup별 카테고리 목록 (SINGLE_MALT, BLEND, BLENDED_MALT, BOURBON, RYE, OTHER)"), + fieldWithPath("data.SINGLE_MALT").type(JsonFieldType.ARRAY).description("싱글 몰트 그룹 카테고리 목록"), + fieldWithPath("data.SINGLE_MALT[].korCategory").type(JsonFieldType.STRING).description("한글 카테고리").optional(), + fieldWithPath("data.SINGLE_MALT[].engCategory").type(JsonFieldType.STRING).description("영문 카테고리").optional(), + fieldWithPath("data.BLEND").type(JsonFieldType.ARRAY).description("블렌디드 그룹 카테고리 목록"), + fieldWithPath("data.BLEND[].korCategory").type(JsonFieldType.STRING).description("한글 카테고리").optional(), + fieldWithPath("data.BLEND[].engCategory").type(JsonFieldType.STRING).description("영문 카테고리").optional(), + fieldWithPath("data.BLENDED_MALT").type(JsonFieldType.ARRAY).description("블렌디드 몰트 그룹 카테고리 목록"), + fieldWithPath("data.BLENDED_MALT[].korCategory").type(JsonFieldType.STRING).description("한글 카테고리").optional(), + fieldWithPath("data.BLENDED_MALT[].engCategory").type(JsonFieldType.STRING).description("영문 카테고리").optional(), + fieldWithPath("data.BOURBON").type(JsonFieldType.ARRAY).description("버번 그룹 카테고리 목록"), + fieldWithPath("data.BOURBON[].korCategory").type(JsonFieldType.STRING).description("한글 카테고리").optional(), + fieldWithPath("data.BOURBON[].engCategory").type(JsonFieldType.STRING).description("영문 카테고리").optional(), + fieldWithPath("data.RYE").type(JsonFieldType.ARRAY).description("라이 그룹 카테고리 목록 (데이터 없으면 빈 배열)"), + fieldWithPath("data.OTHER").type(JsonFieldType.ARRAY).description("기타 그룹 카테고리 목록"), + fieldWithPath("data.OTHER[].korCategory").type(JsonFieldType.STRING).description("한글 카테고리").optional(), + fieldWithPath("data.OTHER[].engCategory").type(JsonFieldType.STRING).description("영문 카테고리").optional(), fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt index 410ee27a1..359df4d04 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt @@ -76,6 +76,7 @@ class AdminDistilleryControllerDocsTest { fieldWithPath("data[].logoImgUrl").type(JsonFieldType.STRING).description("로고 이미지 URL"), fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), fieldWithPath("data[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data[].sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서 (미설정: 9999)"), fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt index 337ced4fa..17ec747ff 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt @@ -78,6 +78,7 @@ class AdminRegionControllerDocsTest { fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), fieldWithPath("data[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), fieldWithPath("data[].parentId").type(JsonFieldType.NUMBER).description("상위 지역 ID").optional(), + fieldWithPath("data[].sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서 (미설정: 9999)"), fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt index 9f85a2c4e..e7d47ceae 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt @@ -2,12 +2,8 @@ package app.helper.alcohols import app.bottlenote.alcohols.constant.AlcoholCategoryGroup import app.bottlenote.alcohols.constant.AlcoholType -import app.bottlenote.alcohols.dto.response.AdminAlcoholDetailResponse +import app.bottlenote.alcohols.dto.response.* import app.bottlenote.alcohols.dto.response.AdminAlcoholDetailResponse.TastingTagInfo -import app.bottlenote.alcohols.dto.response.AdminAlcoholItem -import app.bottlenote.alcohols.dto.response.AdminDistilleryItem -import app.bottlenote.alcohols.dto.response.AdminRegionItem -import app.bottlenote.alcohols.dto.response.TastingTagNodeItem import app.bottlenote.global.data.response.GlobalResponse import app.bottlenote.global.dto.response.AdminResultResponse import java.time.LocalDateTime @@ -151,7 +147,8 @@ object AlcoholsHelper { "지역 설명 $i", LocalDateTime.of(2024, 1, i, 0, 0), LocalDateTime.of(2024, 6, i, 0, 0), - null + null, + 9999 ) } @@ -162,7 +159,8 @@ object AlcoholsHelper { listOf("Glenfiddich", "Macallan", "Yamazaki")[i - 1], "https://example.com/logo$i.png", LocalDateTime.of(2024, 1, i, 0, 0), - LocalDateTime.of(2024, 6, i, 0, 0) + LocalDateTime.of(2024, 6, i, 0, 0), + 9999 ) } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt index 69a775054..a678a6f1f 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt @@ -256,11 +256,11 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { @Test @DisplayName("동일 한글 카테고리에 다른 영문 카테고리가 별도로 조회된다") fun differentEngCategoriesAreSeparate() { - // given - 같은 한글 카테고리, 다른 영문 카테고리 + // given - 같은 한글 카테고리, 다른 영문 카테고리 (둘 다 SINGLE_MALT 그룹) alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "Single Malt") alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "-") - // when & then - 2개의 다른 페어가 반환됨 + // when & then - 그룹된 응답에서 SINGLE_MALT 그룹에 2개의 다른 페어가 반환됨 assertThat( mockMvcTester .get() @@ -268,7 +268,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() - .extractingPath("$.data.length()") + .extractingPath("$.data.SINGLE_MALT.length()") .isEqualTo(2) } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt index 0f568561e..0853ac063 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt @@ -3,11 +3,7 @@ package app.integration.alcohols import app.IntegrationTestSupport import app.bottlenote.alcohols.fixture.AlcoholTestFactory import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Tag -import org.junit.jupiter.api.Test +import org.junit.jupiter.api.* import org.springframework.beans.factory.annotation.Autowired @Tag("admin_integration") @@ -113,6 +109,24 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { mockMvcTester.get().uri("/regions") ).hasStatus4xxClientError() } + + @Test + @DisplayName("지역 응답에 sortOrder 필드가 포함되며 미설정 시 9999가 반환된다") + fun getRegionsResponseIncludesSortOrderDefault() { + // given - 시드는 sort_order 미지정으로 INSERT, DB default 9999 적용 + alcoholTestFactory.persistAlcohols(1) + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/regions?page=0&size=10") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data[0].sortOrder") + .isEqualTo(9999) + } } @Nested @@ -160,5 +174,23 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { mockMvcTester.get().uri("/distilleries") ).hasStatus4xxClientError() } + + @Test + @DisplayName("증류소 응답에 sortOrder 필드가 포함되며 미설정 시 9999가 반환된다") + fun getDistilleriesResponseIncludesSortOrderDefault() { + // given - 시드는 sort_order 미지정으로 INSERT, DB default 9999 적용 + alcoholTestFactory.persistAlcohols(1) + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/distilleries?page=0&size=20") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data[0].sortOrder") + .isEqualTo(9999) + } } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java index c2f64614f..35c46c192 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java @@ -15,7 +15,6 @@ import app.bottlenote.global.service.cursor.PageResponse; import java.util.List; import java.util.Optional; -import org.apache.commons.lang3.tuple.Pair; import org.springframework.data.domain.Page; /** 알코올 조회 질의에 관한 애그리거트를 정의합니다. */ @@ -37,7 +36,7 @@ public interface AlcoholQueryRepository { List findAllCategories(AlcoholType type); - List> findAllCategoryPairs(); + List findAllCategoryItems(); Boolean existsByAlcoholId(Long alcoholId); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Distillery.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Distillery.java index 9743db4df..2ef479865 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Distillery.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Distillery.java @@ -8,8 +8,6 @@ import jakarta.persistence.Id; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; -import java.util.ArrayList; -import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -17,6 +15,9 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.Comment; +import java.util.ArrayList; +import java.util.List; + @Getter @Builder @AllArgsConstructor @@ -43,6 +44,11 @@ public class Distillery extends BaseEntity { @Column(name = "logo_img_url") private String logoImgPath; + @Builder.Default + @Comment("정렬 순서 (미설정: 9999)") + @Column(name = "sort_order", nullable = false) + private Integer sortOrder = 9999; + @Builder.Default @OneToMany(mappedBy = "distillery") private List alcohol = new ArrayList<>(); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Region.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Region.java index 537e3258e..fee6da819 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Region.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Region.java @@ -45,6 +45,11 @@ public class Region extends BaseEntity { @Column(name = "description", nullable = true) private String description; + @Builder.Default + @Comment("정렬 순서 (미설정: 9999)") + @Column(name = "sort_order", nullable = false) + private Integer sortOrder = 9999; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_id") @Comment("상위 지역") diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminDistilleryItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminDistilleryItem.java index 8e1c5503c..8257273a4 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminDistilleryItem.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminDistilleryItem.java @@ -8,4 +8,5 @@ public record AdminDistilleryItem( String engName, String logoImgUrl, LocalDateTime createdAt, - LocalDateTime modifiedAt) {} + LocalDateTime modifiedAt, + Integer sortOrder) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminRegionItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminRegionItem.java index f310ac599..3cc2dda8b 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminRegionItem.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminRegionItem.java @@ -10,4 +10,5 @@ public record AdminRegionItem( String description, LocalDateTime createdAt, LocalDateTime modifiedAt, - Long parentId) {} + Long parentId, + Integer sortOrder) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/CategoryPairItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/CategoryPairItem.java new file mode 100644 index 000000000..53273ad06 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/CategoryPairItem.java @@ -0,0 +1,3 @@ +package app.bottlenote.alcohols.dto.response; + +public record CategoryPairItem(String korCategory, String engCategory) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/RegionsItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/RegionsItem.java index 613c778f3..02ca304d1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/RegionsItem.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/RegionsItem.java @@ -11,4 +11,5 @@ public class RegionsItem { private final String engName; private final String description; private final Long parentId; + private final Integer sortOrder; } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java index f573d83eb..8aaea82d0 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java @@ -6,18 +6,18 @@ import app.bottlenote.alcohols.dto.response.AdminAlcoholItem; import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; +import app.bottlenote.alcohols.dto.response.CategoryItem; import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem; import app.bottlenote.global.service.cursor.CursorResponse; import app.bottlenote.global.service.cursor.PageResponse; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; -import org.apache.commons.lang3.tuple.Pair; import org.springframework.data.domain.Page; public interface CustomAlcoholQueryRepository { - List> findAllCategoryPairs(); + List findAllCategoryItems(); PageResponse searchAlcohols(AlcoholSearchCriteria criteriaDto); 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 0e2430d1c..cf9f1ba7d 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 @@ -16,6 +16,7 @@ import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; +import app.bottlenote.alcohols.dto.response.CategoryItem; import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem; import app.bottlenote.global.service.cursor.CursorPageable; import app.bottlenote.global.service.cursor.CursorResponse; @@ -30,7 +31,6 @@ import java.util.function.Function; import java.util.stream.Collectors; import lombok.AllArgsConstructor; -import org.apache.commons.lang3.tuple.Pair; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -40,18 +40,20 @@ public class CustomAlcoholQueryRepositoryImpl implements CustomAlcoholQueryRepos private final JPAQueryFactory queryFactory; private final AlcoholQuerySupporter supporter; - /** 모든 카테고리 페어(한글, 영문) 조회 */ + /** 모든 카테고리(한글, 영문, 그룹) 조회 — 카테고리 레퍼런스 응답용 */ @Override - public List> findAllCategoryPairs() { + public List findAllCategoryItems() { return queryFactory - .select(alcohol.korCategory, alcohol.engCategory) + .select( + Projections.constructor( + CategoryItem.class, + alcohol.korCategory, + alcohol.engCategory, + alcohol.categoryGroup)) .from(alcohol) - .groupBy(alcohol.korCategory, alcohol.engCategory) + .groupBy(alcohol.korCategory, alcohol.engCategory, alcohol.categoryGroup) .orderBy(alcohol.korCategory.asc()) - .fetch() - .stream() - .map(tuple -> Pair.of(tuple.get(alcohol.korCategory), tuple.get(alcohol.engCategory))) - .toList(); + .fetch(); } /** queryDSL 알코올 검색 */ diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java index 16f4d35eb..ae3b9a6e1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java @@ -18,7 +18,7 @@ public interface JpaDistilleryRepository @Query( """ select new app.bottlenote.alcohols.dto.response.AdminDistilleryItem( - d.id, d.korName, d.engName, d.logoImgPath, d.createAt, d.lastModifyAt + d.id, d.korName, d.engName, d.logoImgPath, d.createAt, d.lastModifyAt, d.sortOrder ) from distillery d where (:keyword is null or :keyword = '' diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java index ef4c3d300..bd96716fe 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java @@ -5,21 +5,22 @@ import app.bottlenote.alcohols.dto.response.AdminRegionItem; import app.bottlenote.alcohols.dto.response.RegionsItem; import app.bottlenote.common.annotation.JpaRepositoryImpl; -import java.util.Collection; -import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.query.Param; +import java.util.Collection; +import java.util.List; + @JpaRepositoryImpl public interface JpaRegionQueryRepository extends RegionRepository, CrudRepository { @Override @Query( """ - select new app.bottlenote.alcohols.dto.response.RegionsItem(r.id, r.korName, r.engName, r.description, r.parent.id) + select new app.bottlenote.alcohols.dto.response.RegionsItem(r.id, r.korName, r.engName, r.description, r.parent.id, r.sortOrder) from region r order by r.id asc """) List findAllRegionsResponse(); @@ -28,7 +29,7 @@ public interface JpaRegionQueryRepository extends RegionRepository, CrudReposito @Query( """ select new app.bottlenote.alcohols.dto.response.AdminRegionItem( - r.id, r.korName, r.engName, r.continent, r.description, r.createAt, r.lastModifyAt, r.parent.id + r.id, r.korName, r.engName, r.continent, r.description, r.createAt, r.lastModifyAt, r.parent.id, r.sortOrder ) from region r where (:keyword is null or :keyword = '' 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 0fbc2b56c..dc9f50058 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,6 @@ package app.bottlenote.alcohols.service; +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; import app.bottlenote.alcohols.constant.SearchSortType; import app.bottlenote.alcohols.domain.Alcohol; import app.bottlenote.alcohols.domain.AlcoholQueryRepository; @@ -13,6 +14,8 @@ import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; import app.bottlenote.alcohols.dto.response.AlcoholDetailResponse; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; +import app.bottlenote.alcohols.dto.response.CategoryItem; +import app.bottlenote.alcohols.dto.response.CategoryPairItem; import app.bottlenote.alcohols.dto.response.ExploreStandardResponse; import app.bottlenote.alcohols.dto.response.FriendsDetailResponse; import app.bottlenote.alcohols.exception.AlcoholException; @@ -25,11 +28,13 @@ import app.bottlenote.review.facade.ReviewFacade; import app.bottlenote.user.facade.FollowFacade; import app.bottlenote.user.facade.payload.FriendItem; +import java.util.ArrayList; +import java.util.EnumMap; import java.util.List; +import java.util.Map; import java.util.concurrent.ThreadLocalRandom; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -118,9 +123,22 @@ public GlobalResponse searchAdminAlcohols(AdminAlcoholSearchRequest request) { return GlobalResponse.fromPage(alcoholQueryRepository.searchAdminAlcohols(request)); } + /** 카테고리 레퍼런스를 categoryGroup 기준으로 묶어 반환한다. enum 선언 순서로 키가 고정되며, 데이터가 없는 그룹도 빈 리스트로 포함된다. */ @Transactional(readOnly = true) - public List> findAllCategoryPairs() { - return alcoholQueryRepository.findAllCategoryPairs(); + public Map> findAllCategoryReferenceMap() { + Map> grouped = + new EnumMap<>(AlcoholCategoryGroup.class); + for (AlcoholCategoryGroup group : AlcoholCategoryGroup.values()) { + grouped.put(group, new ArrayList<>()); + } + + for (CategoryItem item : alcoholQueryRepository.findAllCategoryItems()) { + AlcoholCategoryGroup group = item.categoryGroup(); + if (group == null) continue; + grouped.get(group).add(new CategoryPairItem(item.korCategory(), item.engCategory())); + } + + return grouped; } @Transactional(readOnly = true) diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java index 7e1f0bafb..5f8b96aa1 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java @@ -19,7 +19,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; -import org.apache.commons.lang3.tuple.Pair; +import java.util.stream.Collectors; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.test.util.ReflectionTestUtils; @@ -75,11 +75,11 @@ public List findAllCategories(AlcoholType type) { } @Override - public List> findAllCategoryPairs() { + public List findAllCategoryItems() { return alcohols.values().stream() - .map(a -> Pair.of(a.getKorCategory(), a.getEngCategory())) + .map(a -> new CategoryItem(a.getKorCategory(), a.getEngCategory(), a.getCategoryGroup())) .distinct() - .toList(); + .collect(Collectors.toList()); } @Override diff --git a/bottlenote-product-api/VERSION b/bottlenote-product-api/VERSION index 9084fa2f7..524cb5524 100644 --- a/bottlenote-product-api/VERSION +++ b/bottlenote-product-api/VERSION @@ -1 +1 @@ -1.1.0 +1.1.1 diff --git a/bottlenote-product-api/src/main/resources/application.yml b/bottlenote-product-api/src/main/resources/application.yml index 9f6246e9b..f878262c4 100644 --- a/bottlenote-product-api/src/main/resources/application.yml +++ b/bottlenote-product-api/src/main/resources/application.yml @@ -14,6 +14,10 @@ server: servlet: encoding: charset: UTF-8 + compression: + enabled: true + mime-types: application/json,application/xml,text/html,text/xml,text/plain,application/javascript,text/css + min-response-size: 1024 tomcat: threads: max: 15 diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java index be8431fd5..f8cdbf7d5 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java @@ -18,7 +18,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import org.apache.commons.lang3.tuple.Pair; +import java.util.stream.Collectors; import org.springframework.data.domain.Page; public class InMemoryAlcoholQueryRepository implements AlcoholQueryRepository { @@ -66,11 +66,11 @@ public List findAllCategories(AlcoholType type) { } @Override - public List> findAllCategoryPairs() { + public List findAllCategoryItems() { return alcohols.values().stream() - .map(a -> Pair.of(a.getKorCategory(), a.getEngCategory())) + .map(a -> new CategoryItem(a.getKorCategory(), a.getEngCategory(), a.getCategoryGroup())) .distinct() - .toList(); + .collect(Collectors.toList()); } @Override diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/service/RegionServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/service/RegionServiceTest.java index 67c8aff38..b651c90a6 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/service/RegionServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/service/RegionServiceTest.java @@ -1,10 +1,7 @@ package app.bottlenote.alcohols.service; -import static org.mockito.Mockito.when; - import app.bottlenote.alcohols.dto.response.RegionsItem; import app.bottlenote.alcohols.repository.JpaRegionQueryRepository; -import java.util.List; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; @@ -14,6 +11,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; + +import static org.mockito.Mockito.when; + @Tag("unit") @DisplayName("[unit] [service] RegionService") @ExtendWith(MockitoExtension.class) @@ -29,16 +30,17 @@ void testFindAll() { // given List response = List.of( - RegionsItem.of(1L, "스코틀랜드/로우랜드", "Scotland/Lowlands", "가벼운 맛이 특징인 로우랜드 위스키", 19L), + RegionsItem.of(1L, "스코틀랜드/로우랜드", "Scotland/Lowlands", "가벼운 맛이 특징인 로우랜드 위스키", 19L, 9999), RegionsItem.of( 2L, "스코틀랜드/하이랜드", "Scotland/Highlands", "맛의 다양성이 특징인 하이랜드 위스키, 해안의 짠맛부터 달콤하고 과일 맛까지", - 19L), - RegionsItem.of(3L, "스코틀랜드/아일랜드", "Scotland/Ireland", "부드러운 맛이 특징인 아일랜드 위스키", 19L), - RegionsItem.of(11L, "프랑스", "France", "주로 브랜디와 와인 생산지로 유명하지만 위스키도 생산", null), - RegionsItem.of(12L, "스웨덴", "Sweden", "실험적인 방법으로 만드는 스웨덴 위스키", null)); + 19L, + 9999), + RegionsItem.of(3L, "스코틀랜드/아일랜드", "Scotland/Ireland", "부드러운 맛이 특징인 아일랜드 위스키", 19L, 9999), + RegionsItem.of(11L, "프랑스", "France", "주로 브랜디와 와인 생산지로 유명하지만 위스키도 생산", null, 9999), + RegionsItem.of(12L, "스웨덴", "Sweden", "실험적인 방법으로 만드는 스웨덴 위스키", null, 9999)); // When when(regionQueryRepository.findAllRegionsResponse()).thenReturn(response); @@ -47,5 +49,6 @@ void testFindAll() { List regions = regionService.findAllRegion(); Assertions.assertEquals(response.size(), regions.size()); Assertions.assertEquals(response.get(0).getRegionId(), regions.get(0).getRegionId()); + Assertions.assertEquals(9999, regions.get(0).getSortOrder()); } } diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java index d37ca0bd7..590150dde 100644 --- a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java @@ -1,5 +1,18 @@ package app.docs.alcohols; +import app.bottlenote.alcohols.controller.AlcoholReferenceController; +import app.bottlenote.alcohols.dto.response.CategoryItem; +import app.bottlenote.alcohols.dto.response.RegionsItem; +import app.bottlenote.alcohols.fixture.AlcoholQueryFixture; +import app.bottlenote.alcohols.service.AlcoholReferenceService; +import app.docs.AbstractRestDocs; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import java.util.List; + import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -12,18 +25,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import app.bottlenote.alcohols.controller.AlcoholReferenceController; -import app.bottlenote.alcohols.dto.response.CategoryItem; -import app.bottlenote.alcohols.dto.response.RegionsItem; -import app.bottlenote.alcohols.fixture.AlcoholQueryFixture; -import app.bottlenote.alcohols.service.AlcoholReferenceService; -import app.docs.AbstractRestDocs; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.restdocs.payload.JsonFieldType; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; - @DisplayName("알코올 참조 컨트롤러 RestDocs용 테스트") class RestReferenceControllerTest extends AbstractRestDocs { @@ -41,16 +42,17 @@ void docs_1() throws Exception { // given List response = List.of( - RegionsItem.of(1L, "스코틀랜드/로우랜드", "Scotland/Lowlands", "가벼운 맛이 특징인 로우랜드 위스키", 19L), + RegionsItem.of(1L, "스코틀랜드/로우랜드", "Scotland/Lowlands", "가벼운 맛이 특징인 로우랜드 위스키", 19L, 9999), RegionsItem.of( 2L, "스코틀랜드/하이랜드", "Scotland/Highlands", "맛의 다양성이 특징인 하이랜드 위스키, 해안의 짠맛부터 달콤하고 과일 맛까지", - 19L), - RegionsItem.of(3L, "스코틀랜드/아일랜드", "Scotland/Ireland", "부드러운 맛이 특징인 아일랜드 위스키", 19L), - RegionsItem.of(11L, "프랑스", "France", "주로 브랜디와 와인 생산지로 유명하지만 위스키도 생산", null), - RegionsItem.of(12L, "스웨덴", "Sweden", "실험적인 방법으로 만드는 스웨덴 위스키", null)); + 19L, + 9999), + RegionsItem.of(3L, "스코틀랜드/아일랜드", "Scotland/Ireland", "부드러운 맛이 특징인 아일랜드 위스키", 19L, 9999), + RegionsItem.of(11L, "프랑스", "France", "주로 브랜디와 와인 생산지로 유명하지만 위스키도 생산", null, 9999), + RegionsItem.of(12L, "스웨덴", "Sweden", "실험적인 방법으로 만드는 스웨덴 위스키", null, 9999)); // when when(referenceService.findAllRegion()).thenReturn(response); @@ -72,6 +74,9 @@ void docs_1() throws Exception { .type(JsonFieldType.NUMBER) .description("상위 지역 ID") .optional(), + fieldWithPath("data[].sortOrder") + .type(JsonFieldType.NUMBER) + .description("정렬 순서 (미설정: 9999)"), fieldWithPath("errors") .type(JsonFieldType.ARRAY) .description("응답 성공 여부가 false일 경우 에러 메시지(없을 경우 null)"), diff --git a/git.environment-variables b/git.environment-variables index 57cd5c5bb..a1d2ce555 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 57cd5c5bbeb473be1c2b1036e6ea718a2f10227b +Subproject commit a1d2ce555d7aa84ed0a08fe92a73f6e67f5a9a14 diff --git "a/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\271\264\355\205\214\352\263\240\353\246\254_\353\240\210\355\215\274\353\237\260\354\212\244.http" "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\271\264\355\205\214\352\263\240\353\246\254_\353\240\210\355\215\274\353\237\260\354\212\244.http" new file mode 100644 index 000000000..13cc5f30d --- /dev/null +++ "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\271\264\355\205\214\352\263\240\353\246\254_\353\240\210\355\215\274\353\237\260\354\212\244.http" @@ -0,0 +1,35 @@ +### 로그인 (accessToken 발급 — 카테고리 레퍼런스 조회 전에 먼저 실행) +POST {{host}}/auth/login +Content-Type: application/json + +{ + "email": "{{email}}", + "password": "{{password}}" +} + +> {% + client.global.set("accessToken", response.body.data.accessToken); + client.global.set("refreshToken", response.body.data.refreshToken); +%} + +### 카테고리 레퍼런스 조회 (categoryGroup별 grouped Map) +# 응답은 AlcoholCategoryGroup enum(SINGLE_MALT, BLEND, BLENDED_MALT, BOURBON, RYE, OTHER)을 +# key로 갖고, 각 그룹에 속한 {korCategory, engCategory} 리스트를 value로 갖는다. +# 데이터가 없는 그룹도 빈 배열([])로 응답에 포함된다. +# +# 위스키 등록/수정(POST/PUT /alcohols) 시 사용해야 하는 categoryGroup 값을 +# 이 응답에서 단일 소스로 참조한다. +# +# 예시 응답: +# { +# "data": { +# "SINGLE_MALT": [{ "korCategory": "싱글 몰트", "engCategory": "Single Malt" }], +# "BLEND": [{ "korCategory": "블렌디드", "engCategory": "Blend" }], +# "BLENDED_MALT":[{ "korCategory": "블렌디드 몰트", "engCategory": "Blended Malt" }], +# "BOURBON": [{ "korCategory": "버번", "engCategory": "Bourbon" }], +# "RYE": [], +# "OTHER": [{ "korCategory": "테네시", "engCategory": "Tennessee" }] +# } +# } +GET {{host}}/alcohols/categories/reference +Authorization: Bearer {{accessToken}} diff --git a/plan/admin-category-reference-grouped.md b/plan/admin-category-reference-grouped.md new file mode 100644 index 000000000..c6b1fe2dd --- /dev/null +++ b/plan/admin-category-reference-grouped.md @@ -0,0 +1,201 @@ +# Plan: 어드민 카테고리 레퍼런스 응답 구조 grouped Map 변경 (issue #221) + +## Overview + +어드민 위스키 등록/수정(`POST/PUT /admin/api/v1/alcohols`)이 필수로 요구하는 `categoryGroup`(SINGLE_MALT, BLEND, BLENDED_MALT, BOURBON, RYE, OTHER)을 카테고리 레퍼런스 API에서 함께 내려주도록 응답 구조를 변경한다. + +현재 `GET /admin/api/v1/alcohols/categories/reference` 응답은 `[{korCategory, engCategory}, ...]` 형태이며, 프론트엔드에서 `korCategory ↔ categoryGroup` 매핑을 하드코딩하고 있어 카테고리 추가/변경 시 동기화 누락 문제가 발생한다 (#220 위스키 등록 오류 원인). + +응답 구조는 1안(flat array) / 2안(grouped map) 중 **2안**으로 결정되었다. `korCategory`/`engCategory`가 서버 입장에서 자유 입력이라 같은 그룹에 여러 표기가 들어올 수 있으므로, `categoryGroup` 1:N 관계를 자료구조로 명확히 표현한다. + +### 결정된 응답 형식 + +```json +{ + "SINGLE_MALT": [ + { "korCategory": "싱글 몰트", "engCategory": "Single Malt" } + ], + "BLEND": [ + { "korCategory": "블렌디드", "engCategory": "Blend" } + ], + "BLENDED_MALT": [ + { "korCategory": "블렌디드 몰트", "engCategory": "Blended Malt" } + ], + "BOURBON": [ + { "korCategory": "버번", "engCategory": "Bourbon" } + ], + "RYE": [], + "OTHER": [ + { "korCategory": "테네시", "engCategory": "Tennessee" }, + { "korCategory": "콘", "engCategory": "Corn" } + ] +} +``` + +### Assumptions + +1. **응답 타입**: `Map>` 형태로 변경한다 (CategoryPairItem은 `{korCategory, engCategory}` record). +2. **빈 그룹 포함**: 데이터가 0건인 그룹도 응답에 빈 배열 `[]`로 포함하여 6개 enum 키 모두 항상 응답에 존재한다. +3. **키 순서 보장**: 응답 키 순서는 `AlcoholCategoryGroup` enum 선언 순서(SINGLE_MALT → BLEND → BLENDED_MALT → BOURBON → RYE → OTHER)를 따른다. `LinkedHashMap` + enum 순회로 구현한다. +4. **Breaking Change 허용**: 기존 List 응답 클라이언트는 프론트엔드 어드민 페이지 단일이며, 이슈 본문에 따라 백엔드 변경 후 프론트도 동시에 마이그레이션되므로 응답 형식 변경(breaking change)을 허용한다. v2 신규 엔드포인트를 만들지 않는다. +5. **type 파라미터 없음**: 기존 엔드포인트는 type 필터 없이 전체 카테고리를 반환한다. 본 변경에서도 이를 유지한다. +6. **데이터 소스**: 기존 QueryDSL 메서드(`findAllCategoryPairs`)를 확장하여 `alcohol.categoryGroup` 컬럼을 함께 select한다. 별도 마스터 테이블이나 schema 변경은 없다. +7. **DTO 신규 정의**: 응답 내부 항목은 기존 `CategoryItem(korCategory, engCategory, categoryGroup)`이 아닌, `categoryGroup`이 빠진 새 record(`CategoryPairItem` 또는 유사 명칭)를 정의한다 — 그룹핑 후 항목 안에 `categoryGroup`을 또 넣을 필요가 없기 때문. +8. **캐싱**: 본 엔드포인트는 현재 캐싱되지 않는다(어드민 전용). 캐시 정책 변경 없음. +9. **인증/인가**: 기존 admin-api 보안 설정(`/admin/api/v1` context-path, 어드민 RBAC)을 유지한다. + +→ 위 가정 9가지 확인 완료 (사용자 승인). + +### 결정 사항 (2026-05-06 확정) + +| # | 결정 | 적용 | +|---|------|------| +| 1 | 기존 `findAllCategoryPairs()` 시그니처 변경 허용 | 신규 메서드 추가 X. 다만 영향받는 테스트(`AdminAlcoholsControllerDocsTest`, `InMemoryAlcoholQueryRepository` 2곳)를 빠짐없이 동기화한다. | +| 2 | grouping 로직 위치 | **Service 레이어**(`AlcoholQueryService`)에서 `Map>` 형태로 반환. 컨트롤러는 단순 위임. | +| 3 | 응답 래퍼 클래스 | 도입하지 않음. 컨트롤러에서 `Map`을 `GlobalResponse.ok()`로 그대로 감싸 반환. **최종 JSON 응답 key가 enum 선언 순서로 정확히 직렬화되는 것만 보장**한다. | + +### Success Criteria + +1. `GET /admin/api/v1/alcohols/categories/reference` 응답이 `Map>` 형태의 JSON 객체로 반환된다. +2. 응답에 6개 키(SINGLE_MALT, BLEND, BLENDED_MALT, BOURBON, RYE, OTHER)가 enum 선언 순서대로 항상 포함된다. +3. DB에 데이터가 0건인 그룹은 빈 배열 `[]`로 응답된다 (키 자체는 누락되지 않음). +4. 응답 항목은 `korCategory` 오름차순으로 정렬된다 (그룹 내 순서 안정성). +5. `JpaAlcoholQueryRepository.findAllCategories(AlcoholType)`(상품 API용)는 기존 동작과 시그니처를 유지한다. +6. `AdminAlcoholsControllerDocsTest`가 새 응답 구조 기준으로 통과한다. +7. `InMemoryAlcoholQueryRepository`(admin/product 양쪽) Fake 구현이 새 메서드 시그니처에 맞춰 동기화된다. +8. RestDocs 문서가 새 응답 구조를 반영한다. +9. `./gradlew :bottlenote-admin-api:test`와 mono 단위 테스트가 모두 통과한다. + +### Impact Scope + +#### 모듈 +- **bottlenote-admin-api** (Kotlin, presentation) +- **bottlenote-mono** (Java, domain/service/repository/dto) +- **bottlenote-product-api** (테스트 픽스처 영향만, 컨트롤러 변경 없음) + +#### 변경 파일 + +**bottlenote-mono (main)** +- `dto/response/CategoryItem.java` — 유지 (product-api `findAllCategories(type)`에서 계속 사용) +- `dto/response/CategoryPairItem.java` (신규) — `{korCategory, engCategory}` record +- `dto/response/CategoryReferenceResponse.java` (신규, 선택) — Map 응답 래퍼 또는 typealias +- `domain/AlcoholQueryRepository.java` — `findAllCategoryPairs()` 시그니처 변경 또는 신규 메서드 추가 +- `repository/CustomAlcoholQueryRepository.java` — 동일 +- `repository/CustomAlcoholQueryRepositoryImpl.java` — QueryDSL select에 `categoryGroup` 추가, 반환 타입 변경 +- `service/AlcoholQueryService.java` — `findAllCategoryPairs()` 반환 타입 변경, grouping 로직 추가 (또는 컨트롤러 위임) + +**bottlenote-admin-api (main)** +- `presentation/AdminAlcoholsController.kt` — `getCategoryReference()` 응답 변환 로직 변경 (`mapOf` 제거, Map 직접 반환) + +**bottlenote-mono (test)** +- `fixture/InMemoryAlcoholQueryRepository.java` — 시그니처 동기화 + +**bottlenote-product-api (test)** +- `fixture/InMemoryAlcoholQueryRepository.java` — 시그니처 동기화 + +**bottlenote-admin-api (test)** +- `app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt` — 응답 구조 변경, RestDocs descriptor 갱신 + +#### 비변경 영역 +- 엔티티 / 스키마 / Liquibase 마이그레이션: 변경 없음 (`alcohol.category_group` 컬럼 이미 존재) +- 도메인 이벤트: 영향 없음 +- 캐시: 영향 없음 (해당 엔드포인트 미캐싱) +- 보안 설정: 영향 없음 + +#### 테스트 종류 +- 단위 테스트: `CustomAlcoholQueryRepositoryImpl` grouping 로직 (필요 시) +- 통합 테스트: `AdminAlcoholsControllerDocsTest`에서 응답 구조 검증 + RestDocs +- 아키텍처 규칙 테스트: 영향 없음 + +--- + +### 외부 영향 (참고) +- 프론트엔드 어드민(`alcohol.api.ts`)의 `GROUP_TO_CATEGORY`, `CATEGORY_TO_GROUP_MAP` 하드코딩 제거 후속 작업 예정 (별도 PR/이슈) +- #220 위스키 등록 오류는 프론트가 기존 하드코딩으로 우선 수정한 상태이며, 본 변경 후 단일 소스화로 재발 방지 + +--- + +## Tasks + +### Task 1: 응답 항목 DTO 추가 +- **목적**: 그룹 내부 항목용 record 정의 +- **파일**: `bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/CategoryPairItem.java` (신규) +- **내용**: `public record CategoryPairItem(String korCategory, String engCategory) {}` +- **검증**: 컴파일 통과 + +### Task 2: 도메인/QueryDSL 레포지토리 시그니처 변경 +- **목적**: 레포지토리는 grouping 책임 없이 `categoryGroup` 포함한 raw 데이터만 반환 +- **결정**: 메서드명 `findAllCategoryPairs` → `findAllCategoryItems`로 의도 명확화. 반환 타입 `List>` → `List` (기존 `CategoryItem(korCategory, engCategory, categoryGroup)` 재사용 — type 파라미터 없는 전체 조회 용도) +- **파일**: + - `bottlenote-mono/.../domain/AlcoholQueryRepository.java` — 시그니처 변경 + - `bottlenote-mono/.../repository/CustomAlcoholQueryRepository.java` — 시그니처 변경 + - `bottlenote-mono/.../repository/CustomAlcoholQueryRepositoryImpl.java` — QueryDSL select에 `alcohol.categoryGroup` 추가, `Projections.constructor(CategoryItem.class, ...)` 또는 tuple → CategoryItem 매핑, group by에 categoryGroup 추가, order by `korCategory.asc()` 유지 +- **검증**: 단위 테스트로 grouping 입력 데이터 형태 확인 가능 + +### Task 3: Service 계층 grouping 로직 추가 +- **목적**: enum 선언 순서 + 빈 그룹 `[]` 보장하는 `LinkedHashMap` 구성 +- **파일**: `bottlenote-mono/.../service/AlcoholQueryService.java` +- **메서드 변경**: `findAllCategoryPairs()` → `findAllCategoryReferenceMap()` (또는 적절한 명칭). 반환 타입 `Map>` +- **구현**: + 1. `repository.findAllCategoryItems()` 호출 + 2. `LinkedHashMap>` 생성 + 3. `AlcoholCategoryGroup.values()` 순회하며 빈 `ArrayList` 초기화 (키 순서 + 빈 그룹 `[]` 보장) + 4. 조회 결과를 `categoryGroup` 기준으로 해당 리스트에 `new CategoryPairItem(korCategory, engCategory)` 추가 + 5. 그룹 내 `korCategory` 오름차순 정렬 (DB order by가 이미 보장하지만 방어적으로) +- **검증**: 단위 테스트 (Fake 레포지토리로 grouping 결과 검증) + +### Task 4: Admin 컨트롤러 응답 변경 +- **파일**: `bottlenote-admin-api/.../presentation/AdminAlcoholsController.kt` +- **변경**: + - `getCategoryReference()` 내부의 `mapOf` 변환 제거 + - `alcoholQueryService.findAllCategoryReferenceMap()` 결과를 `GlobalResponse.ok()`에 그대로 전달 +- **검증**: Jackson 직렬화 시 `LinkedHashMap` 순서 유지 확인 (enum 선언 순) + +### Task 5: 테스트 Fake 구현 동기화 +- **목적**: 시그니처 변경된 메서드를 InMemory 구현체에 반영 +- **파일**: + - `bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java` + - `bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java` +- **구현**: 기존 `findAllCategoryPairs` 메서드를 새 시그니처로 교체. `alcohols.values().stream().map(a -> new CategoryItem(a.getKorCategory(), a.getEngCategory(), a.getCategoryGroup())).distinct().toList()` + +### Task 6: AdminAlcoholsControllerDocsTest 갱신 +- **파일**: `bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt` +- **변경**: + - `given(alcoholQueryService.findAllCategoryReferenceMap())` stub을 `LinkedHashMap` 형태로 구성 (모든 enum 키 포함, 일부는 빈 리스트) + - RestDocs `responseFields` 갱신: `data[].korCategory` 형태 → `data.SINGLE_MALT[].korCategory` / `data.BLEND[].korCategory` 형태로 각 enum 키 6개에 대해 documenting (또는 동적 패턴) + - 응답 키 순서 검증 추가 가능 시 추가 +- **검증**: `./gradlew :bottlenote-admin-api:asciidoctor` 통과 + +### Task 7: 검증 (/verify full) +- **명령**: + - `./gradlew :bottlenote-mono:test` (단위/통합) + - `./gradlew :bottlenote-admin-api:test` + - `./gradlew :bottlenote-product-api:test` (Fake 동기화 영향 확인) + - `./gradlew :bottlenote-admin-api:asciidoctor` +- **성공 기준**: 모든 테스트 통과, RestDocs 빌드 성공 + +## Implementation Order +Task 1 → Task 2 → Task 3 → Task 5 (Fake 먼저 컴파일 깨짐 방지) → Task 4 → Task 6 → Task 7 + +> 주의: Task 2에서 시그니처를 바꾸면 Task 3, 4, 5가 모두 컴파일 에러 상태가 됨. Task 5(Fake 동기화)를 먼저 끝내고 Task 4(컨트롤러)로 넘어가야 admin-api 모듈 컴파일이 복구됨. + +## Progress Log + +### 2026-05-06 +- Task 1 완료: `CategoryPairItem` record 신규 (`bottlenote-mono/.../dto/response/CategoryPairItem.java`) +- Task 2 완료: 레포지토리 시그니처 변경 + - `AlcoholQueryRepository`, `CustomAlcoholQueryRepository`: `findAllCategoryPairs(): List>` → `findAllCategoryItems(): List` + - `CustomAlcoholQueryRepositoryImpl`: QueryDSL select에 `alcohol.categoryGroup` 추가, `Projections.constructor(CategoryItem.class, ...)`로 매핑 +- Task 3 완료: `AlcoholQueryService.findAllCategoryReferenceMap()` 추가 + - `EnumMap>`로 enum 선언 순서 + 빈 그룹 `[]` 보장 +- Task 5 완료: Fake 동기화 (`bottlenote-mono/.../fixture/InMemoryAlcoholQueryRepository.java`, `bottlenote-product-api/.../fixture/InMemoryAlcoholQueryRepository.java`) +- Task 4 완료: `AdminAlcoholsController.kt`의 `getCategoryReference()`를 단순 위임으로 변경 (`mapOf` 제거) +- Task 6 완료: `AdminAlcoholsControllerDocsTest`의 stub과 RestDocs `responseFields` 갱신 +- Task 7 완료: 검증 + - `:bottlenote-mono:compileJava`, `:bottlenote-admin-api:compileKotlin` BUILD SUCCESSFUL + - `:bottlenote-admin-api:test` 전체 통과 (카테고리 레퍼런스 테스트 포함) + - `:bottlenote-mono:unit_test`, `:bottlenote-product-api:unit_test` BUILD SUCCESSFUL + - 실제 RestDocs 응답 결과(`build/generated-snippets/admin/alcohols/category-reference/response-body.adoc`)가 의도한 grouped 구조와 정확히 일치 (enum 선언 순서, `RYE: []` 포함) + - `:bottlenote-admin-api:asciidoctor` BUILD SUCCESSFUL + - `:bottlenote-mono:integration_test` BUILD SUCCESSFUL + - `:bottlenote-product-api:integration_test` BUILD SUCCESSFUL (4m 6s) diff --git a/plan/distillery-region-sort-order-response.md b/plan/distillery-region-sort-order-response.md new file mode 100644 index 000000000..300ccbf79 --- /dev/null +++ b/plan/distillery-region-sort-order-response.md @@ -0,0 +1,75 @@ +# Plan: Distillery/Region 응답에 sortOrder 노출 + +## Overview + +이미 DB 컬럼 및 엔티티 필드까지 추가가 완료된 `Distillery.sortOrder`, `Region.sortOrder` 값을 +**조회 API 응답**에 노출시킨다. 본 작업은 **응답값 확장만** 다루며, +어드민의 sortOrder 수정 기능은 별도 PR로 분리한다. + +### 선행 완료 사항 (참고) +- Liquibase changeset `hgkim:20260506-1`, `hgkim:20260506-2` 적용 완료 (DEV/PROD) +- `Distillery.sortOrder: Integer` (default 9999, NOT NULL) 추가 완료 +- `Region.sortOrder: Integer` (default 9999, NOT NULL) 추가 완료 + +### Assumptions + +1. **응답 노출 대상은 "distillery/region 자체 조회 엔드포인트"에 한정한다.** + - product-api: `GET /api/v1/regions` (`RegionsItem`) + - admin-api: `GET /distilleries` (`AdminDistilleryItem`) + - admin-api: `GET /regions` (`AdminRegionItem`) + - **product-api의 `Alcohol` 조회 응답 내 중첩된 distillery/region 정보(korName/engName만 노출 중)에는 추가하지 않는다.** + -> 별도 요구가 있을 때 확장 +2. product-api에는 distillery 자체 조회 엔드포인트가 존재하지 않으므로 distillery는 admin-api에서만 노출된다. +3. `RegionsItem`은 product-api 응답에 한 번만 사용되므로 필드 추가 시 다른 호출처 영향이 없다 (확인 완료). +4. 캐시 키 `local_cache_alcohol_region_information`(`AlcoholReferenceService.findAllRegion`)은 + 응답 구조가 바뀌므로 **무효화/구버전 직렬화 충돌 방지 필요** + -> 로컬(Caffeine) 캐시이므로 애플리케이션 재시작 시 자동 갱신 가정. 별도 조치 불필요. +5. 정렬 순서를 응답에 추가할 뿐이며, **DB 정렬(ORDER BY sort_order) 적용은 본 PR 범위 외**. + -> 정렬 적용은 후속 PR (또는 별도 의사결정) 대상. +6. 필드명은 응답에서 **`sortOrder`** (camelCase, JSON 직렬화 동일). + +-> 위 가정들 중 의도와 어긋나는 항목이 있으면 알려주세요. + 특히 **5번(정렬을 적용까지 할지 vs 노출만 할지)** 확인이 필요합니다. + +### Success Criteria + +- [ ] `GET /api/v1/regions` 응답 각 항목에 `sortOrder: number` 필드가 포함된다 (default 9999). +- [ ] 어드민 `GET /distilleries` 응답 각 항목에 `sortOrder: number` 필드가 포함된다. +- [ ] 어드민 `GET /regions` 응답 각 항목에 `sortOrder: number` 필드가 포함된다. +- [ ] 기존 응답 필드는 모두 유지된다 (BC 보장). +- [ ] 단위 테스트 / RestDocs 통합 테스트가 신규 필드를 검증한다. +- [ ] `./gradlew compileJava :bottlenote-admin-api:compileKotlin` 통과. +- [ ] `./gradlew test`(기본 태그) 통과. + +### Impact Scope + +**모듈** +- `bottlenote-mono` (DTO + Repository JPQL 수정) +- `bottlenote-product-api` (RestDocs 테스트 수정) +- `bottlenote-admin-api` (RestDocs 테스트 수정) + +**파일 (수정 예정)** +| 파일 | 변경 내용 | +|------|----------| +| `mono/.../alcohols/dto/response/RegionsItem.java` | `sortOrder` 필드 추가 | +| `mono/.../alcohols/dto/response/AdminRegionItem.java` | `sortOrder` 필드 추가 | +| `mono/.../alcohols/dto/response/AdminDistilleryItem.java` | `sortOrder` 필드 추가 | +| `mono/.../alcohols/repository/JpaRegionQueryRepository.java` | `findAllRegionsResponse` JPQL select 절에 `r.sortOrder` 추가 / `findAllRegions` 도 동일 | +| `mono/.../alcohols/repository/JpaDistilleryRepository.java` | `findAllDistilleries` JPQL select 절에 `d.sortOrder` 추가 | +| `product-api/.../alcohols/controller/AlcoholReferenceControllerTest.java` (또는 RestDocs 테스트) | 신규 필드 문서화 + 검증 | +| `admin-api/.../alcohols/presentation/AdminDistilleryControllerTest.kt` | 신규 필드 문서화 + 검증 | +| `admin-api/.../alcohols/presentation/AdminRegionControllerTest.kt` | 신규 필드 문서화 + 검증 | + +**도메인 / 이벤트 / 캐시** +- 도메인: `alcohols` +- 이벤트: 영향 없음 +- 캐시: `local_cache_alcohol_region_information` (Caffeine 로컬 캐시) — 앱 재기동 후 자동 무효화 + +**테스트 종류** +- 통합 테스트(RestDocs) 보강: 응답 필드 문서화 + 값 검증 +- 단위 테스트: DTO 생성자/필드 추가에 따른 기존 테스트가 깨지지 않는지 확인 + +**고려하지 않는 것 (Out of Scope)** +- 어드민의 sortOrder **수정 엔드포인트** (별도 PR) +- 응답을 sort_order **기준으로 ORDER BY 적용**하는 정렬 동작 (Assumption #5 참조) +- product-api의 Alcohol 조회 응답에 중첩된 distillery/region 정보로 sortOrder 전파 (Assumption #1 참조)