diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc index 933bf581c..37b6feb38 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc @@ -49,3 +49,113 @@ include::{snippets}/admin/alcohols/detail/http-request.adoc[] [discrete] include::{snippets}/admin/alcohols/detail/response-fields.adoc[] include::{snippets}/admin/alcohols/detail/http-response.adoc[] + +''' + +=== 술(Alcohol) 생성 === + +- 관리자용 술 생성 API입니다. +- 모든 필드는 필수값입니다. + +[source] +---- +POST /admin/api/v1/alcohols +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/create/request-fields.adoc[] +include::{snippets}/admin/alcohols/create/curl-request.adoc[] +include::{snippets}/admin/alcohols/create/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/create/response-fields.adoc[] +include::{snippets}/admin/alcohols/create/http-response.adoc[] + +''' + +=== 술(Alcohol) 수정 === + +- 관리자용 술 수정 API입니다. +- 전체 수정(PUT)이므로 모든 필드를 전달해야 합니다. +- 이미 삭제된 술은 수정할 수 없습니다. + +[source] +---- +PUT /admin/api/v1/alcohols/{alcoholId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/update/path-parameters.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/update/request-fields.adoc[] +include::{snippets}/admin/alcohols/update/curl-request.adoc[] +include::{snippets}/admin/alcohols/update/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/update/response-fields.adoc[] +include::{snippets}/admin/alcohols/update/http-response.adoc[] + +''' + +=== 술(Alcohol) 삭제 === + +- 관리자용 술 삭제 API입니다. +- 소프트 삭제로 처리되며, 실제 데이터는 삭제되지 않습니다. +- 리뷰 또는 평점이 존재하는 술은 삭제할 수 없습니다. +- 이미 삭제된 술은 재삭제할 수 없습니다. + +[source] +---- +DELETE /admin/api/v1/alcohols/{alcoholId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/delete/path-parameters.adoc[] +include::{snippets}/admin/alcohols/delete/curl-request.adoc[] +include::{snippets}/admin/alcohols/delete/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/delete/response-fields.adoc[] +include::{snippets}/admin/alcohols/delete/http-response.adoc[] + +''' + +=== 카테고리 레퍼런스 조회 === + +- 현재 DB에 등록된 모든 카테고리 페어(한글/영문) 목록을 조회합니다. +- 술 생성/수정 시 기존 카테고리를 참조하기 위한 API입니다. +- 동일 한글 카테고리라도 영문 카테고리가 다르면 별도 항목으로 조회됩니다. + +[source] +---- +GET /admin/api/v1/alcohols/categories/reference +---- + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/category-reference/response-fields.adoc[] +include::{snippets}/admin/alcohols/category-reference/http-response.adoc[] 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 b6bb2a87c..b890f5a22 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 @@ -1,12 +1,19 @@ package app.bottlenote.alcohols.presentation import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest +import app.bottlenote.alcohols.dto.request.AdminAlcoholUpsertRequest +import app.bottlenote.alcohols.service.AdminAlcoholCommandService import app.bottlenote.alcohols.service.AlcoholQueryService import app.bottlenote.global.data.response.GlobalResponse +import jakarta.validation.Valid import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping 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.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -14,7 +21,8 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/alcohols") class AdminAlcoholsController( - private val alcoholQueryService: AlcoholQueryService + private val alcoholQueryService: AlcoholQueryService, + private val adminAlcoholCommandService: AdminAlcoholCommandService ) { @GetMapping @@ -26,4 +34,29 @@ class AdminAlcoholsController( fun getAlcoholDetail(@PathVariable alcoholId: Long): ResponseEntity<*> { return 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) + } + + @PostMapping + fun createAlcohol(@RequestBody @Valid request: AdminAlcoholUpsertRequest): ResponseEntity<*> { + return GlobalResponse.ok(adminAlcoholCommandService.createAlcohol(request)) + } + + @PutMapping("/{alcoholId}") + fun updateAlcohol( + @PathVariable alcoholId: Long, + @RequestBody @Valid request: AdminAlcoholUpsertRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(adminAlcoholCommandService.updateAlcohol(alcoholId, request)) + } + + @DeleteMapping("/{alcoholId}") + fun deleteAlcohol(@PathVariable alcoholId: Long): ResponseEntity<*> { + return GlobalResponse.ok(adminAlcoholCommandService.deleteAlcohol(alcoholId)) + } } 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 f67ae168d..868a46f43 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 @@ -3,13 +3,18 @@ package app.docs.alcohols 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.presentation.AdminAlcoholsController +import app.bottlenote.alcohols.service.AdminAlcoholCommandService import app.bottlenote.alcohols.service.AlcoholQueryService +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.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.apache.commons.lang3.tuple.Pair import org.mockito.BDDMockito.given import org.mockito.Mockito.any import org.mockito.Mockito.anyLong @@ -17,11 +22,11 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.http.MediaType import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document import org.springframework.restdocs.operation.preprocess.Preprocessors.* import org.springframework.restdocs.payload.JsonFieldType -import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath -import org.springframework.restdocs.payload.PayloadDocumentation.responseFields +import org.springframework.restdocs.payload.PayloadDocumentation.* import org.springframework.restdocs.request.RequestDocumentation.* import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.assertj.MockMvcTester @@ -37,9 +42,15 @@ class AdminAlcoholsControllerDocsTest { @Autowired private lateinit var mvc: MockMvcTester + @Autowired + private lateinit var mapper: ObjectMapper + @MockitoBean private lateinit var alcoholQueryService: AlcoholQueryService + @MockitoBean + private lateinit var adminAlcoholCommandService: AdminAlcoholCommandService + @Test @DisplayName("관리자용 술 목록을 조회할 수 있다") fun searchAdminAlcohols() { @@ -183,4 +194,214 @@ class AdminAlcoholsControllerDocsTest { ) ) } + + @Test + @DisplayName("관리자용 술을 생성할 수 있다") + fun createAlcohol() { + // given + val response = AlcoholsHelper.createAdminResultResponse( + code = AdminResultResponse.ResultCode.ALCOHOL_CREATED, + targetId = 1L + ) + val request = AlcoholsHelper.createAlcoholUpsertRequestMap() + + given(adminAlcoholCommandService.createAlcohol(any(AdminAlcoholUpsertRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.post().uri("/alcohols") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/alcohols/create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("korName").type(JsonFieldType.STRING).description("한글 이름"), + fieldWithPath("engName").type(JsonFieldType.STRING).description("영문 이름"), + fieldWithPath("abv").type(JsonFieldType.STRING).description("도수"), + fieldWithPath("type").type(JsonFieldType.STRING).description("술 타입 (WHISKY 등)"), + fieldWithPath("korCategory").type(JsonFieldType.STRING).description("카테고리 한글명"), + fieldWithPath("engCategory").type(JsonFieldType.STRING).description("카테고리 영문명"), + fieldWithPath("categoryGroup").type(JsonFieldType.STRING).description("카테고리 그룹 (SINGLE_MALT 등)"), + fieldWithPath("regionId").type(JsonFieldType.NUMBER).description("지역 ID"), + fieldWithPath("distilleryId").type(JsonFieldType.NUMBER).description("증류소 ID"), + fieldWithPath("age").type(JsonFieldType.STRING).description("숙성년도"), + fieldWithPath("cask").type(JsonFieldType.STRING).description("캐스크 타입"), + fieldWithPath("imageUrl").type(JsonFieldType.STRING).description("이미지 URL"), + fieldWithPath("description").type(JsonFieldType.STRING).description("설명"), + fieldWithPath("volume").type(JsonFieldType.STRING).description("용량") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드 (ALCOHOL_CREATED)"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("생성된 술 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).description("서버 응답 시간").ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("API 경로 버전").ignored() + ) + ) + ) + } + + @Test + @DisplayName("관리자용 술을 수정할 수 있다") + fun updateAlcohol() { + // given + val response = AlcoholsHelper.createAdminResultResponse( + code = AdminResultResponse.ResultCode.ALCOHOL_UPDATED, + targetId = 1L + ) + val request = AlcoholsHelper.createAlcoholUpsertRequestMap( + korName = "수정된 위스키", + engName = "Updated Whisky" + ) + + given(adminAlcoholCommandService.updateAlcohol(anyLong(), any(AdminAlcoholUpsertRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.put().uri("/alcohols/{alcoholId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/alcohols/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("alcoholId").description("수정할 술 ID") + ), + requestFields( + fieldWithPath("korName").type(JsonFieldType.STRING).description("한글 이름"), + fieldWithPath("engName").type(JsonFieldType.STRING).description("영문 이름"), + fieldWithPath("abv").type(JsonFieldType.STRING).description("도수"), + fieldWithPath("type").type(JsonFieldType.STRING).description("술 타입 (WHISKY 등)"), + fieldWithPath("korCategory").type(JsonFieldType.STRING).description("카테고리 한글명"), + fieldWithPath("engCategory").type(JsonFieldType.STRING).description("카테고리 영문명"), + fieldWithPath("categoryGroup").type(JsonFieldType.STRING).description("카테고리 그룹 (SINGLE_MALT 등)"), + fieldWithPath("regionId").type(JsonFieldType.NUMBER).description("지역 ID"), + fieldWithPath("distilleryId").type(JsonFieldType.NUMBER).description("증류소 ID"), + fieldWithPath("age").type(JsonFieldType.STRING).description("숙성년도"), + fieldWithPath("cask").type(JsonFieldType.STRING).description("캐스크 타입"), + fieldWithPath("imageUrl").type(JsonFieldType.STRING).description("이미지 URL"), + fieldWithPath("description").type(JsonFieldType.STRING).description("설명"), + fieldWithPath("volume").type(JsonFieldType.STRING).description("용량") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드 (ALCOHOL_UPDATED)"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("수정된 술 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).description("서버 응답 시간").ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("API 경로 버전").ignored() + ) + ) + ) + } + + @Test + @DisplayName("카테고리 레퍼런스를 조회할 수 있다") + fun getCategoryReference() { + // given + val categoryPairs = listOf( + Pair.of("싱글 몰트", "Single Malt"), + Pair.of("블렌디드", "Blended"), + Pair.of("버번", "Bourbon") + ) + + given(alcoholQueryService.findAllCategoryPairs()) + .willReturn(categoryPairs) + + // when & then + assertThat( + mvc.get().uri("/alcohols/categories/reference") + ) + .hasStatusOk() + .apply( + document( + "admin/alcohols/category-reference", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + 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("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).description("서버 응답 시간").ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("API 경로 버전").ignored() + ) + ) + ) + } + + @Test + @DisplayName("관리자용 술을 삭제할 수 있다") + fun deleteAlcohol() { + // given + val response = AlcoholsHelper.createAdminResultResponse( + code = AdminResultResponse.ResultCode.ALCOHOL_DELETED, + targetId = 1L + ) + + given(adminAlcoholCommandService.deleteAlcohol(anyLong())) + .willReturn(response) + + // when & then + assertThat( + mvc.delete().uri("/alcohols/{alcoholId}", 1L) + ) + .hasStatusOk() + .apply( + document( + "admin/alcohols/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("alcoholId").description("삭제할 술 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드 (ALCOHOL_DELETED)"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("삭제된 술 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).description("서버 응답 시간").ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("API 경로 버전").ignored() + ) + ) + ) + } } 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 a549b93df..1ec30f963 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 @@ -6,7 +6,10 @@ 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.AdminTastingTagItem +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup +import app.bottlenote.alcohols.constant.AlcoholType import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.global.dto.response.AdminResultResponse import java.time.LocalDateTime object AlcoholsHelper { @@ -154,4 +157,41 @@ object AlcoholsHelper { ) ) .build() + + fun createAdminResultResponse( + code: AdminResultResponse.ResultCode = AdminResultResponse.ResultCode.ALCOHOL_CREATED, + targetId: Long = 1L + ): AdminResultResponse = AdminResultResponse.of(code, targetId) + + fun createAlcoholUpsertRequestMap( + korName: String = "테스트 위스키", + engName: String = "Test Whisky", + abv: String = "40%", + type: AlcoholType = AlcoholType.WHISKY, + korCategory: String = "싱글 몰트", + engCategory: String = "Single Malt", + categoryGroup: AlcoholCategoryGroup = AlcoholCategoryGroup.SINGLE_MALT, + regionId: Long = 1L, + distilleryId: Long = 1L, + age: String = "12", + cask: String = "American Oak", + imageUrl: String = "https://example.com/test.jpg", + description: String = "테스트 설명", + volume: String = "700ml" + ): Map = mapOf( + "korName" to korName, + "engName" to engName, + "abv" to abv, + "type" to type.name, + "korCategory" to korCategory, + "engCategory" to engCategory, + "categoryGroup" to categoryGroup.name, + "regionId" to regionId, + "distilleryId" to distilleryId, + "age" to age, + "cask" to cask, + "imageUrl" to imageUrl, + "description" to description, + "volume" to volume + ) } 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 064488f8b..f3458f787 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 @@ -3,8 +3,12 @@ package app.integration.alcohols import app.IntegrationTestSupport import app.bottlenote.alcohols.constant.AdminAlcoholSortType import app.bottlenote.alcohols.constant.AlcoholCategoryGroup +import app.bottlenote.alcohols.constant.AlcoholType import app.bottlenote.alcohols.fixture.AlcoholTestFactory import app.bottlenote.global.service.cursor.SortOrder +import app.bottlenote.rating.fixture.RatingTestFactory +import app.bottlenote.review.fixture.ReviewTestFactory +import app.bottlenote.user.fixture.UserTestFactory import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -17,6 +21,7 @@ import org.junit.jupiter.params.provider.CsvSource import org.junit.jupiter.params.provider.EnumSource import org.junit.jupiter.params.provider.MethodSource import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType import java.util.stream.Stream @Tag("admin_integration") @@ -26,6 +31,15 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var alcoholTestFactory: AlcoholTestFactory + @Autowired + private lateinit var userTestFactory: UserTestFactory + + @Autowired + private lateinit var reviewTestFactory: ReviewTestFactory + + @Autowired + private lateinit var ratingTestFactory: RatingTestFactory + private lateinit var accessToken: String @BeforeEach @@ -150,6 +164,46 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { .extractingPath("$.meta.size").isEqualTo(size) } + @Nested + @DisplayName("카테고리 레퍼런스 조회 API") + inner class GetCategoryReference { + + @Test + @DisplayName("기존 카테고리 페어 목록을 조회할 수 있다") + fun getCategoryReferenceSuccess() { + // given - 다양한 카테고리를 가진 술 생성 + alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "Single Malt") + alcoholTestFactory.persistAlcoholWithCategory("블렌디드", "Blended") + alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "Single Malt") // 중복 + + // when & then + assertThat( + mockMvcTester.get().uri("/alcohols/categories/reference") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("동일 한글 카테고리에 다른 영문 카테고리가 별도로 조회된다") + fun differentEngCategoriesAreSeparate() { + // given - 같은 한글 카테고리, 다른 영문 카테고리 + alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "Single Malt") + alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "-") + + // when & then - 2개의 다른 페어가 반환됨 + assertThat( + mockMvcTester.get().uri("/alcohols/categories/reference") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.length()").isEqualTo(2) + } + } + @Nested @DisplayName("술 단건 상세 조회 API") inner class GetAlcoholDetail { @@ -202,4 +256,236 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { .hasStatus4xxClientError() } } + + @Nested + @DisplayName("술 생성 API") + inner class CreateAlcohol { + + @Test + @DisplayName("위스키를 생성할 수 있다") + fun createAlcoholSuccess() { + // given + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + + val request = mapOf( + "korName" to "테스트 위스키", + "engName" to "Test Whisky", + "abv" to "40%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "12", + "cask" to "American Oak", + "imageUrl" to "https://example.com/test.jpg", + "description" to "테스트 설명", + "volume" to "700ml" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("ALCOHOL_CREATED") + } + + @Test + @DisplayName("필수 필드 누락 시 실패한다") + fun createAlcoholWithMissingFields() { + // given + val request = mapOf( + "korName" to "테스트 위스키" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("존재하지 않는 regionId로 생성 시 실패한다") + fun createAlcoholWithInvalidRegion() { + // given + val distillery = alcoholTestFactory.persistDistillery() + + val request = mapOf( + "korName" to "테스트 위스키", + "engName" to "Test Whisky", + "abv" to "40%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to 999999L, + "distilleryId" to distillery.id, + "age" to "12", + "cask" to "American Oak", + "imageUrl" to "https://example.com/test.jpg", + "description" to "테스트 설명", + "volume" to "700ml" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("술 수정 API") + inner class UpdateAlcohol { + + @Test + @DisplayName("위스키를 수정할 수 있다") + fun updateAlcoholSuccess() { + // given + val alcohol = alcoholTestFactory.persistAlcohol() + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + + val request = mapOf( + "korName" to "수정된 위스키", + "engName" to "Updated Whisky", + "abv" to "43%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "18", + "cask" to "Sherry Oak", + "imageUrl" to "https://example.com/updated.jpg", + "description" to "수정된 설명", + "volume" to "750ml" + ) + + // when & then + assertThat( + mockMvcTester.put().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("ALCOHOL_UPDATED") + } + + @Test + @DisplayName("존재하지 않는 alcoholId로 수정 시 실패한다") + fun updateAlcoholNotFound() { + // given + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + + val request = mapOf( + "korName" to "수정된 위스키", + "engName" to "Updated Whisky", + "abv" to "43%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "18", + "cask" to "Sherry Oak", + "imageUrl" to "https://example.com/updated.jpg", + "description" to "수정된 설명", + "volume" to "750ml" + ) + + // when & then + assertThat( + mockMvcTester.put().uri("/alcohols/999999") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("술 삭제 API") + inner class DeleteAlcohol { + + @Test + @DisplayName("위스키를 삭제할 수 있다") + fun deleteAlcoholSuccess() { + // given + val alcohol = alcoholTestFactory.persistAlcohol() + + // when & then + assertThat( + mockMvcTester.delete().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("ALCOHOL_DELETED") + } + + @Test + @DisplayName("존재하지 않는 alcoholId로 삭제 시 실패한다") + fun deleteAlcoholNotFound() { + // when & then + assertThat( + mockMvcTester.delete().uri("/alcohols/999999") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("리뷰가 존재하는 위스키는 삭제할 수 없다") + fun deleteAlcoholWithReviews() { + // given + val user = userTestFactory.persistUser() + val alcohol = alcoholTestFactory.persistAlcohol() + reviewTestFactory.persistReview(user.id, alcohol.id) + + // when & then + assertThat( + mockMvcTester.delete().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("평점이 존재하는 위스키는 삭제할 수 없다") + fun deleteAlcoholWithRatings() { + // given + val user = userTestFactory.persistUser() + val alcohol = alcoholTestFactory.persistAlcohol() + ratingTestFactory.persistRating(user.id, alcohol.id, 5) + + // when & then + assertThat( + mockMvcTester.delete().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Alcohol.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Alcohol.java index 042e52d2f..d18f2ca5a 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Alcohol.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Alcohol.java @@ -15,6 +15,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import java.time.LocalDateTime; import java.util.HashSet; import java.util.Set; import lombok.AccessLevel; @@ -95,7 +96,50 @@ public class Alcohol extends BaseEntity { @Column(name = "volume") private String volume; + @Comment("삭제일시") + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + @Builder.Default @OneToMany(mappedBy = "alcohol", fetch = FetchType.LAZY) private Set alcoholsTastingTags = new HashSet<>(); + + public void delete() { + this.deletedAt = LocalDateTime.now(); + } + + public boolean isDeleted() { + return this.deletedAt != null; + } + + public void update( + String korName, + String engName, + String abv, + AlcoholType type, + String korCategory, + String engCategory, + AlcoholCategoryGroup categoryGroup, + Region region, + Distillery distillery, + String age, + String cask, + String imageUrl, + String description, + String volume) { + if (korName != null && !korName.isBlank()) this.korName = korName; + if (engName != null && !engName.isBlank()) this.engName = engName; + if (abv != null && !abv.isBlank()) this.abv = abv; + if (type != null) this.type = type; + if (korCategory != null && !korCategory.isBlank()) this.korCategory = korCategory; + if (engCategory != null && !engCategory.isBlank()) this.engCategory = engCategory; + if (categoryGroup != null) this.categoryGroup = categoryGroup; + if (region != null) this.region = region; + if (distillery != null) this.distillery = distillery; + if (age != null && !age.isBlank()) this.age = age; + if (cask != null && !cask.isBlank()) this.cask = cask; + if (imageUrl != null && !imageUrl.isBlank()) this.imageUrl = imageUrl; + if (description != null && !description.isBlank()) this.description = description; + if (volume != null && !volume.isBlank()) this.volume = volume; + } } 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 43aaab210..46db2b9a8 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 @@ -36,6 +36,8 @@ public interface AlcoholQueryRepository { List findAllCategories(AlcoholType type); + List> findAllCategoryPairs(); + Boolean existsByAlcoholId(Long alcoholId); Pair> getStandardExplore( diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/DistilleryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/DistilleryRepository.java index e43d2cd38..22e3df4c4 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/DistilleryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/DistilleryRepository.java @@ -1,10 +1,13 @@ package app.bottlenote.alcohols.domain; import app.bottlenote.alcohols.dto.response.AdminDistilleryItem; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface DistilleryRepository { + Optional findById(Long id); + Page findAllDistilleries(String keyword, Pageable pageable); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java index 276f14b0a..c147bf11f 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java @@ -3,11 +3,14 @@ import app.bottlenote.alcohols.dto.response.AdminRegionItem; import app.bottlenote.alcohols.dto.response.RegionsItem; import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface RegionRepository { + Optional findById(Long id); + List findAllRegionsResponse(); Page findAllRegions(String keyword, Pageable pageable); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholUpsertRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholUpsertRequest.java new file mode 100644 index 000000000..0bdc9cced --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholUpsertRequest.java @@ -0,0 +1,22 @@ +package app.bottlenote.alcohols.dto.request; + +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; +import app.bottlenote.alcohols.constant.AlcoholType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AdminAlcoholUpsertRequest( + @NotBlank(message = "한글 이름은 필수입니다.") String korName, + @NotBlank(message = "영문 이름은 필수입니다.") String engName, + @NotBlank(message = "도수는 필수입니다.") String abv, + @NotNull(message = "주류 타입은 필수입니다.") AlcoholType type, + @NotBlank(message = "한글 카테고리는 필수입니다.") String korCategory, + @NotBlank(message = "영문 카테고리는 필수입니다.") String engCategory, + @NotNull(message = "카테고리 그룹은 필수입니다.") AlcoholCategoryGroup categoryGroup, + @NotNull(message = "지역 ID는 필수입니다.") Long regionId, + @NotNull(message = "증류소 ID는 필수입니다.") Long distilleryId, + @NotBlank(message = "숙성년도는 필수입니다.") String age, + @NotBlank(message = "캐스크 타입은 필수입니다.") String cask, + @NotBlank(message = "이미지 URL은 필수입니다.") String imageUrl, + @NotBlank(message = "설명은 필수입니다.") String description, + @NotBlank(message = "용량은 필수입니다.") String volume) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java index 7764aec58..5101c39ab 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java @@ -4,7 +4,12 @@ import org.springframework.http.HttpStatus; public enum AlcoholExceptionCode implements ExceptionCode { - ALCOHOL_NOT_FOUND(HttpStatus.NOT_FOUND, "위스키를 찾을 수 없습니다."); + ALCOHOL_NOT_FOUND(HttpStatus.NOT_FOUND, "위스키를 찾을 수 없습니다."), + REGION_NOT_FOUND(HttpStatus.NOT_FOUND, "지역을 찾을 수 없습니다."), + DISTILLERY_NOT_FOUND(HttpStatus.NOT_FOUND, "증류소를 찾을 수 없습니다."), + ALCOHOL_HAS_REVIEWS(HttpStatus.CONFLICT, "리뷰가 존재하는 위스키는 삭제할 수 없습니다."), + ALCOHOL_HAS_RATINGS(HttpStatus.CONFLICT, "평점이 존재하는 위스키는 삭제할 수 없습니다."), + ALCOHOL_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 위스키입니다."); private final HttpStatus httpStatus; private final String message; 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 2178a09db..7a61c8818 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 @@ -16,6 +16,8 @@ public interface CustomAlcoholQueryRepository { + List> findAllCategoryPairs(); + PageResponse searchAlcohols(AlcoholSearchCriteria criteriaDto); AlcoholDetailItem findAlcoholDetailById(Long alcoholId, Long userId); 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 c390c610f..3999bea5c 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 @@ -36,6 +36,20 @@ public class CustomAlcoholQueryRepositoryImpl implements CustomAlcoholQueryRepos private final JPAQueryFactory queryFactory; private final AlcoholQuerySupporter supporter; + /** 모든 카테고리 페어(한글, 영문) 조회 */ + @Override + public List> findAllCategoryPairs() { + return queryFactory + .select(alcohol.korCategory, alcohol.engCategory) + .from(alcohol) + .groupBy(alcohol.korCategory, alcohol.engCategory) + .orderBy(alcohol.korCategory.asc()) + .fetch() + .stream() + .map(tuple -> Pair.of(tuple.get(alcohol.korCategory), tuple.get(alcohol.engCategory))) + .toList(); + } + /** queryDSL 알코올 검색 */ @Override public PageResponse searchAlcohols(AlcoholSearchCriteria criteriaDto) { diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminAlcoholCommandService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminAlcoholCommandService.java new file mode 100644 index 000000000..f6547aaf4 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminAlcoholCommandService.java @@ -0,0 +1,172 @@ +package app.bottlenote.alcohols.service; + +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.ALCOHOL_ALREADY_DELETED; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.ALCOHOL_HAS_RATINGS; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.ALCOHOL_HAS_REVIEWS; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.ALCOHOL_NOT_FOUND; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.DISTILLERY_NOT_FOUND; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.REGION_NOT_FOUND; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.ALCOHOL_CREATED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.ALCOHOL_DELETED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.ALCOHOL_UPDATED; + +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.domain.AlcoholQueryRepository; +import app.bottlenote.alcohols.domain.Distillery; +import app.bottlenote.alcohols.domain.DistilleryRepository; +import app.bottlenote.alcohols.domain.Region; +import app.bottlenote.alcohols.domain.RegionRepository; +import app.bottlenote.alcohols.dto.request.AdminAlcoholUpsertRequest; +import app.bottlenote.alcohols.exception.AlcoholException; +import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import app.bottlenote.common.file.event.payload.ImageResourceInvalidatedEvent; +import app.bottlenote.common.image.ImageUtil; +import app.bottlenote.global.dto.response.AdminResultResponse; +import app.bottlenote.rating.domain.RatingRepository; +import app.bottlenote.review.domain.ReviewRepository; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminAlcoholCommandService { + + private static final String REFERENCE_TYPE_ALCOHOL = "ALCOHOL"; + + private final AlcoholQueryRepository alcoholQueryRepository; + private final RegionRepository regionRepository; + private final DistilleryRepository distilleryRepository; + private final ReviewRepository reviewRepository; + private final RatingRepository ratingRepository; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public AdminResultResponse createAlcohol(AdminAlcoholUpsertRequest request) { + Region region = + regionRepository + .findById(request.regionId()) + .orElseThrow(() -> new AlcoholException(REGION_NOT_FOUND)); + Distillery distillery = + distilleryRepository + .findById(request.distilleryId()) + .orElseThrow(() -> new AlcoholException(DISTILLERY_NOT_FOUND)); + + Alcohol alcohol = + Alcohol.builder() + .korName(request.korName()) + .engName(request.engName()) + .abv(request.abv()) + .type(request.type()) + .korCategory(request.korCategory()) + .engCategory(request.engCategory()) + .categoryGroup(request.categoryGroup()) + .region(region) + .distillery(distillery) + .age(request.age()) + .cask(request.cask()) + .imageUrl(request.imageUrl()) + .description(request.description()) + .volume(request.volume()) + .build(); + + Alcohol saved = alcoholQueryRepository.save(alcohol); + publishImageActivatedEvent(request.imageUrl(), saved.getId()); + + return AdminResultResponse.of(ALCOHOL_CREATED, saved.getId()); + } + + @Transactional + public AdminResultResponse updateAlcohol(Long alcoholId, AdminAlcoholUpsertRequest request) { + Alcohol alcohol = + alcoholQueryRepository + .findById(alcoholId) + .orElseThrow(() -> new AlcoholException(ALCOHOL_NOT_FOUND)); + + if (alcohol.isDeleted()) { + throw new AlcoholException(ALCOHOL_ALREADY_DELETED); + } + + Region region = + regionRepository + .findById(request.regionId()) + .orElseThrow(() -> new AlcoholException(REGION_NOT_FOUND)); + Distillery distillery = + distilleryRepository + .findById(request.distilleryId()) + .orElseThrow(() -> new AlcoholException(DISTILLERY_NOT_FOUND)); + + String oldImageUrl = alcohol.getImageUrl(); + + alcohol.update( + request.korName(), + request.engName(), + request.abv(), + request.type(), + request.korCategory(), + request.engCategory(), + request.categoryGroup(), + region, + distillery, + request.age(), + request.cask(), + request.imageUrl(), + request.description(), + request.volume()); + + handleImageChange(oldImageUrl, request.imageUrl(), alcoholId); + + return AdminResultResponse.of(ALCOHOL_UPDATED, alcoholId); + } + + @Transactional + public AdminResultResponse deleteAlcohol(Long alcoholId) { + Alcohol alcohol = + alcoholQueryRepository + .findById(alcoholId) + .orElseThrow(() -> new AlcoholException(ALCOHOL_NOT_FOUND)); + + if (alcohol.isDeleted()) { + throw new AlcoholException(ALCOHOL_ALREADY_DELETED); + } + + if (reviewRepository.existsByAlcoholId(alcoholId)) { + throw new AlcoholException(ALCOHOL_HAS_REVIEWS); + } + + if (ratingRepository.existsByAlcoholId(alcoholId)) { + throw new AlcoholException(ALCOHOL_HAS_RATINGS); + } + + alcohol.delete(); + return AdminResultResponse.of(ALCOHOL_DELETED, alcoholId); + } + + private void publishImageActivatedEvent(String imageUrl, Long alcoholId) { + if (imageUrl == null || imageUrl.isBlank()) return; + + String resourceKey = ImageUtil.extractResourceKey(imageUrl); + if (resourceKey != null) { + eventPublisher.publishEvent( + ImageResourceActivatedEvent.of(List.of(resourceKey), alcoholId, REFERENCE_TYPE_ALCOHOL)); + } + } + + private void handleImageChange(String oldImageUrl, String newImageUrl, Long alcoholId) { + if (Objects.equals(oldImageUrl, newImageUrl)) return; + + if (oldImageUrl != null && !oldImageUrl.isBlank()) { + String oldResourceKey = ImageUtil.extractResourceKey(oldImageUrl); + if (oldResourceKey != null) { + eventPublisher.publishEvent( + ImageResourceInvalidatedEvent.of( + List.of(oldResourceKey), alcoholId, REFERENCE_TYPE_ALCOHOL)); + } + } + + publishImageActivatedEvent(newImageUrl, alcoholId); + } +} 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 4f6394a97..cb0edc9d6 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 @@ -103,6 +103,11 @@ public GlobalResponse searchAdminAlcohols(AdminAlcoholSearchRequest request) { return GlobalResponse.fromPage(alcoholQueryRepository.searchAdminAlcohols(request)); } + @Transactional(readOnly = true) + public List> findAllCategoryPairs() { + return alcoholQueryRepository.findAllCategoryPairs(); + } + @Transactional(readOnly = true) public AdminAlcoholDetailResponse findAdminAlcoholDetailById(Long alcoholId) { AdminAlcoholDetailProjection projection = diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java new file mode 100644 index 000000000..398bebcbe --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java @@ -0,0 +1,27 @@ +package app.bottlenote.global.dto.response; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +public record AdminResultResponse(String code, String message, Long targetId, String responseAt) { + public static AdminResultResponse of(ResultCode code, Long targetId) { + return new AdminResultResponse( + code.name(), + code.getMessage(), + targetId, + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + } + + @Getter + @RequiredArgsConstructor + public enum ResultCode { + ALCOHOL_CREATED("위스키가 등록되었습니다."), + ALCOHOL_UPDATED("위스키가 수정되었습니다."), + ALCOHOL_DELETED("위스키가 삭제되었습니다."), + ; + + private final String message; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/rating/domain/RatingRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/rating/domain/RatingRepository.java index 0c92c6b08..d3639db6d 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/rating/domain/RatingRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/rating/domain/RatingRepository.java @@ -22,4 +22,6 @@ public interface RatingRepository { PageResponse fetchRatingList(RatingListFetchCriteria criteria); Optional fetchUserRating(Long alcoholId, Long userId); + + boolean existsByAlcoholId(Long alcoholId); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/JpaRatingRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/JpaRatingRepository.java index 180adf762..fe8cf809f 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/JpaRatingRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/JpaRatingRepository.java @@ -31,4 +31,9 @@ Optional findByAlcoholIdAndUserId( """) Optional fetchUserRating( @Param("alcoholId") Long alcoholId, @Param("userId") Long userId); + + @Override + @Query( + "select case when count(r) > 0 then true else false end from rating r where r.id.alcoholId = :alcoholId") + boolean existsByAlcoholId(@Param("alcoholId") Long alcoholId); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/domain/ReviewRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/review/domain/ReviewRepository.java index 4b58fbab0..436725c1c 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/review/domain/ReviewRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/domain/ReviewRepository.java @@ -32,6 +32,8 @@ PageResponse getReviewsByMe( boolean existsById(Long reviewId); + boolean existsByAlcoholId(Long alcoholId); + Pair> getStandardExplore( Long userId, List keywords, Long cursor, Integer size); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/JpaReviewRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/JpaReviewRepository.java index 8a05c9dd7..b39f64f23 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/JpaReviewRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/JpaReviewRepository.java @@ -22,4 +22,9 @@ public interface JpaReviewRepository @Override @Query("select r from review r where r.userId = :userId") List findByUserId(@Param("userId") Long userId); + + @Override + @Query( + "select case when count(r) > 0 then true else false end from review r where r.alcoholId = :alcoholId") + boolean existsByAlcoholId(@Param("alcoholId") Long alcoholId); } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholTestFactory.java index f2399b803..8cba806d2 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholTestFactory.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholTestFactory.java @@ -226,6 +226,33 @@ public Alcohol persistAlcoholWithName(@NotNull String korName, @NotNull String e return alcohol; } + /** 특정 카테고리로 Alcohol 생성 - 연관 엔티티 자동 생성 */ + @Transactional + @NotNull + public Alcohol persistAlcoholWithCategory( + @NotNull String korCategory, @NotNull String engCategory) { + Region region = persistRegionInternal(); + Distillery distillery = persistDistilleryInternal(); + + Alcohol alcohol = + Alcohol.builder() + .korName("테스트 위스키-" + generateRandomSuffix()) + .engName("Test Whisky-" + generateRandomSuffix()) + .abv("40%") + .type(AlcoholType.WHISKY) + .korCategory(korCategory) + .engCategory(engCategory) + .categoryGroup(AlcoholCategoryGroup.SINGLE_MALT) + .region(region) + .distillery(distillery) + .cask("Oak") + .imageUrl("https://example.com/custom.jpg") + .build(); + em.persist(alcohol); + em.flush(); + return alcohol; + } + /** 연관 엔티티와 함께 Alcohol 생성 */ @Transactional @NotNull 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 53532ec6b..f47848ce8 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 @@ -73,6 +73,14 @@ public List findAllCategories(AlcoholType type) { return List.of(); } + @Override + public List> findAllCategoryPairs() { + return alcohols.values().stream() + .map(a -> Pair.of(a.getKorCategory(), a.getEngCategory())) + .distinct() + .toList(); + } + @Override public Boolean existsByAlcoholId(Long alcoholId) { return alcohols.containsKey(alcoholId); diff --git a/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java index 6661a0b07..99642e7a0 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java @@ -84,4 +84,9 @@ public Pair> getStandardExplore( Long userId, List keywords, Long cursor, Integer size) { return null; } + + @Override + public boolean existsByAlcoholId(Long alcoholId) { + return database.values().stream().anyMatch(review -> review.getAlcoholId().equals(alcoholId)); + } } 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 ede921a9f..0b2a8bb8f 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 @@ -64,6 +64,14 @@ public List findAllCategories(AlcoholType type) { return List.of(); } + @Override + public List> findAllCategoryPairs() { + return alcohols.values().stream() + .map(a -> Pair.of(a.getKorCategory(), a.getEngCategory())) + .distinct() + .toList(); + } + @Override public Boolean existsByAlcoholId(Long alcoholId) { return null; diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/rating/fixture/InMemoryRatingRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/rating/fixture/InMemoryRatingRepository.java index 56049d327..d5c56166f 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/rating/fixture/InMemoryRatingRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/rating/fixture/InMemoryRatingRepository.java @@ -56,4 +56,10 @@ public PageResponse fetchRatingList(RatingListFetchCrit public Optional fetchUserRating(Long alcoholId, Long userId) { return Optional.empty(); } + + @Override + public boolean existsByAlcoholId(Long alcoholId) { + return ratings.values().stream() + .anyMatch(rating -> rating.getId().getAlcoholId().equals(alcoholId)); + } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java index d27d6379f..980e2538f 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java @@ -86,4 +86,9 @@ public Pair> getStandardExplore( Long userId, List keywords, Long cursor, Integer size) { return null; } + + @Override + public boolean existsByAlcoholId(Long alcoholId) { + return database.values().stream().anyMatch(review -> review.getAlcoholId().equals(alcoholId)); + } } diff --git a/git.environment-variables b/git.environment-variables index 162e3ce0f..5317a5e1d 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 162e3ce0f30e7ccfcbdb348c50549272d0c92917 +Subproject commit 5317a5e1d26d83a49e869f3b895aca8ca356cc38