diff --git a/.claude/docs/ADMIN-API-GUIDE.md b/.claude/docs/ADMIN-API-GUIDE.md index cd02d4be0..4dc99a448 100644 --- a/.claude/docs/ADMIN-API-GUIDE.md +++ b/.claude/docs/ADMIN-API-GUIDE.md @@ -27,12 +27,15 @@ admin-api (Kotlin) → mono (Java) | 순서 | 작업 | 모듈 | 위치 | |------|------|------|------| -| 1 | Request/Response DTO | mono | `{domain}/dto/request/`, `{domain}/dto/response/` | -| 2 | ExceptionCode 추가 | mono | `{domain}/exception/{Domain}ExceptionCode.java` | -| 3 | ResultCode 추가 | mono | `global/dto/response/AdminResultResponse.java` | -| 4 | Repository 확장 (필요 시) | mono | `{domain}/repository/` | -| 5 | Service 작성 | mono | `{domain}/service/Admin{Domain}Service.java` | -| 6 | Controller 작성 | admin-api | `{domain}/presentation/Admin{Domain}Controller.kt` | +| 1 | 엔티티 수정 메서드 추가 (필요 시) | mono | `{domain}/domain/{Domain}.java` | +| 2 | Repository 확장 (필요 시) | mono | `{domain}/repository/` | +| 3 | Request/Response DTO | mono | `{domain}/dto/request/`, `{domain}/dto/response/` | +| 4 | ExceptionCode + Exception 추가 | mono | `{domain}/exception/{Domain}ExceptionCode.java`, `{Domain}Exception.java` | +| 5 | ResultCode 추가 | mono | `global/dto/response/AdminResultResponse.java` | +| 6 | Service 작성 | mono | `{domain}/service/Admin{Domain}Service.java` | +| 7 | Controller 작성 | admin-api | `{domain}/presentation/Admin{Domain}Controller.kt` | + +> [주의] Repository 인터페이스에 메서드를 추가하면 `InMemory{Domain}Repository` 테스트 픽스처도 반드시 동기화해야 한다. ### Phase 2: 테스트 @@ -46,7 +49,12 @@ admin-api (Kotlin) → mono (Java) | 순서 | 작업 | 위치 | |------|------|------| | 1 | RestDocs Test | `app/docs/{domain}/Admin{Domain}ControllerDocsTest.kt` | -| 2 | AsciiDoc | `src/docs/asciidoc/api/{domain}/` | +| 2 | Enum adoc 생성 | `src/docs/asciidoc/api/common/enums/{enum-name}.adoc` | +| 3 | Domain adoc 생성 | `src/docs/asciidoc/api/admin-{domain}/{domain}.adoc` | +| 4 | 메인 문서에 include 추가 | `src/docs/asciidoc/admin-api.adoc` | +| 5 | asciidoctor 빌드 검증 | `./gradlew :bottlenote-admin-api:asciidoctor` | + +> Domain adoc에서 enum adoc을 include할 때 상대 경로 사용: `include::../common/enums/{enum-name}.adoc[]` --- @@ -57,7 +65,7 @@ admin-api (Kotlin) → mono (Java) | 규칙 | 설명 | |------|------| | **DTO-Entity 분리** | Response DTO는 Entity를 직접 참조하면 안 됨 (아키텍처 규칙 위반) | -| **팩토리 메서드** | `from(Entity)` 금지 → `of(...)` 사용, 변환 로직은 Service에서 처리 | +| **변환 로직** | `from(Entity)` 금지 → Service에서 직접 생성자 호출 또는 `of(...)` 팩토리 사용 | | **record 사용** | Java record로 작성, `@Builder` 생성자에서 기본값 설정 | | **Validation** | `@NotBlank`, `@NotNull` 등 Bean Validation 사용 | @@ -66,7 +74,7 @@ admin-api (Kotlin) → mono (Java) | 규칙 | 설명 | |------|------| | **목록 조회 반환** | `Page` 직접 반환 금지 → `GlobalResponse.fromPage()` 사용 | -| **상세 조회 반환** | Response DTO 반환, `of()` 팩토리로 변환 | +| **상세 조회 반환** | Response DTO 반환, Service에서 변환 (직접 생성자 또는 `of()`) | | **CUD 반환** | `AdminResultResponse.of(ResultCode, targetId)` 통일 | | **트랜잭션** | 조회는 `@Transactional(readOnly = true)`, CUD는 `@Transactional` | @@ -104,6 +112,20 @@ admin-api (Kotlin) → mono (Java) --- +## 예외 계층 구조 + +``` +RuntimeException + → AbstractCustomException (ExceptionCode 보유) + → {Domain}Exception ({Domain}ExceptionCode) +``` + +- `{Domain}ExceptionCode`: `ExceptionCode` 인터페이스 구현 (`getMessage()`, `getHttpStatus()`) +- `{Domain}Exception`: `AbstractCustomException` 상속, 생성자에서 `ExceptionCode` 전달 +- 참고: `AlcoholExceptionCode.java`, `BannerExceptionCode.java` + +--- + ## 테스트 규칙 ### Integration Test @@ -138,6 +160,8 @@ admin-api (Kotlin) → mono (Java) ## 참고 구현 파일 +### Curation (큐레이션) - 하위 리소스 관리 포함 + | 항목 | 파일 경로 | |------|----------| | Controller | `admin-api/.../alcohols/presentation/AdminCurationController.kt` | @@ -148,6 +172,22 @@ admin-api (Kotlin) → mono (Java) | RestDocs Test | `admin-api/.../docs/curation/AdminCurationControllerDocsTest.kt` | | Helper | `admin-api/.../helper/curation/CurationHelper.kt` | +### Banner (배너) - QueryDSL 검색, 비즈니스 검증, sortOrder 리오더링 포함 + +| 항목 | 파일 경로 | +|------|----------| +| Controller | `admin-api/.../banner/presentation/AdminBannerController.kt` | +| Service | `mono/.../banner/service/AdminBannerService.java` | +| Response DTO | `mono/.../banner/dto/response/AdminBanner*Response.java` | +| Request DTO | `mono/.../banner/dto/request/AdminBanner*Request.java` | +| Exception | `mono/.../banner/exception/BannerException.java`, `BannerExceptionCode.java` | +| QueryDSL | `mono/.../banner/repository/CustomBannerRepository*.java` | +| Integration Test | `admin-api/.../integration/banner/AdminBannerIntegrationTest.kt` | +| RestDocs Test | `admin-api/.../docs/banner/AdminBannerControllerDocsTest.kt` | +| Helper | `admin-api/.../helper/banner/BannerHelper.kt` | +| AsciiDoc | `admin-api/src/docs/asciidoc/api/admin-banners/banners.adoc` | +| Enum adoc | `admin-api/src/docs/asciidoc/api/common/enums/banner-type.adoc`, `text-position.adoc` | + --- ## 검증 절차 @@ -158,12 +198,15 @@ Admin API 구현 완료 후 아래 순서대로 검증: |------|----------|--------|-----------| | 1 | 컴파일 | `./gradlew :bottlenote-admin-api:compileKotlin` | - | | 2 | 코드 포맷팅 | `./gradlew :bottlenote-mono:spotlessCheck` | mono만 적용 | -| 3 | 아키텍처 규칙 | `./gradlew :bottlenote-mono:check_rule_test` | `@Tag("rule")` | +| 3 | 아키텍처 규칙 | `./gradlew check_rule_test` | `@Tag("rule")` | | 4 | 단위 테스트 | `./gradlew unit_test` | `@Tag("unit")` | -| 5 | 어드민 통합 테스트 | `./gradlew admin_integration_test` | `@Tag("admin_integration")` | -| 6 | REST Docs 생성 | `./gradlew :bottlenote-admin-api:restDocsTest` | `app.docs.*` | +| 5 | Product 통합 테스트 | `./gradlew integration_test` | `@Tag("integration")` | +| 6 | Admin 통합 테스트 | `./gradlew admin_integration_test` | `@Tag("admin_integration")` | +| 7 | REST Docs 빌드 | `./gradlew :bottlenote-admin-api:asciidoctor` | 문서화 시 | + +> CI 파이프라인은 3~6번을 병렬 실행한다. mono 모듈 변경이 product-api에 영향을 줄 수 있으므로 `integration_test`도 반드시 확인해야 한다. -**전체 검증 (위 1-5 포함):** +**전체 검증 (위 1-6 포함):** ```bash ./gradlew :bottlenote-admin-api:build ``` diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index a3f462363..000000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,22 +0,0 @@ ---- -fail_fast: false -exclude: | - (?x)^( - .*\.sops\.toml - )$ -repos: - - repo: https://github.com/compilerla/conventional-pre-commit - rev: v3.1.0 - hooks: - - id: conventional-pre-commit - stages: [ - commit-msg] - args: [feat, fix, docs, style, refactor, test, chore, remove] - - repo: local - hooks: - - id: gradle spotlessApply - name: gradle spotlessApply - entry: sh -c './gradlew spotlessApply && git add -A' - language: system - types: [java] - pass_filenames: false diff --git a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc index 624fd8d2a..246125c82 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc @@ -67,3 +67,9 @@ include::api/admin-reference/reference.adoc[] == Curation API include::api/admin-curations/curations.adoc[] + +''' + +== Banner API + +include::api/admin-banners/banners.adoc[] diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-banners/banners.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-banners/banners.adoc new file mode 100644 index 000000000..dcae9aac0 --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-banners/banners.adoc @@ -0,0 +1,205 @@ +=== 배너 목록 조회 === + +- 배너 목록을 페이지네이션으로 조회합니다. +- 키워드, 활성화 상태, 배너 유형으로 필터링이 가능합니다. + +include::../common/enums/banner-type.adoc[] +include::../common/enums/text-position.adoc[] + +[source] +---- +GET /admin/api/v1/banners +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/list/query-parameters.adoc[] +include::{snippets}/admin/banners/list/curl-request.adoc[] +include::{snippets}/admin/banners/list/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/list/response-fields.adoc[] +include::{snippets}/admin/banners/list/http-response.adoc[] + +''' + +=== 배너 상세 조회 === + +- 특정 배너의 상세 정보를 조회합니다. + +[source] +---- +GET /admin/api/v1/banners/{bannerId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/detail/path-parameters.adoc[] +include::{snippets}/admin/banners/detail/curl-request.adoc[] +include::{snippets}/admin/banners/detail/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/detail/response-fields.adoc[] +include::{snippets}/admin/banners/detail/http-response.adoc[] + +''' + +=== 배너 생성 === + +- 새로운 배너를 생성합니다. +- URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다. + +[source] +---- +POST /admin/api/v1/banners +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/create/request-fields.adoc[] +include::{snippets}/admin/banners/create/curl-request.adoc[] +include::{snippets}/admin/banners/create/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/create/response-fields.adoc[] +include::{snippets}/admin/banners/create/http-response.adoc[] + +''' + +=== 배너 수정 === + +- 기존 배너 정보를 수정합니다. + +[source] +---- +PUT /admin/api/v1/banners/{bannerId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/update/path-parameters.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/update/request-fields.adoc[] +include::{snippets}/admin/banners/update/curl-request.adoc[] +include::{snippets}/admin/banners/update/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/update/response-fields.adoc[] +include::{snippets}/admin/banners/update/http-response.adoc[] + +''' + +=== 배너 삭제 === + +- 배너를 삭제합니다. (물리 삭제) + +[source] +---- +DELETE /admin/api/v1/banners/{bannerId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/delete/path-parameters.adoc[] +include::{snippets}/admin/banners/delete/curl-request.adoc[] +include::{snippets}/admin/banners/delete/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/delete/response-fields.adoc[] +include::{snippets}/admin/banners/delete/http-response.adoc[] + +''' + +=== 배너 활성화 상태 변경 === + +- 배너의 활성화 상태를 변경합니다. +- 비활성화된 배너는 클라이언트에 노출되지 않습니다. + +[source] +---- +PATCH /admin/api/v1/banners/{bannerId}/status +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/update-status/path-parameters.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/update-status/request-fields.adoc[] +include::{snippets}/admin/banners/update-status/curl-request.adoc[] +include::{snippets}/admin/banners/update-status/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/update-status/response-fields.adoc[] +include::{snippets}/admin/banners/update-status/http-response.adoc[] + +''' + +=== 배너 정렬 순서 변경 === + +- 배너의 정렬 순서를 변경합니다. +- 숫자가 작을수록 먼저 노출됩니다. +- 동일 순서 충돌 시 기존 배너들이 자동으로 리오더링됩니다. + +[source] +---- +PATCH /admin/api/v1/banners/{bannerId}/sort-order +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/update-sort-order/path-parameters.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/update-sort-order/request-fields.adoc[] +include::{snippets}/admin/banners/update-sort-order/curl-request.adoc[] +include::{snippets}/admin/banners/update-sort-order/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/banners/update-sort-order/response-fields.adoc[] +include::{snippets}/admin/banners/update-sort-order/http-response.adoc[] diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/banner-type.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/banner-type.adoc new file mode 100644 index 000000000..c0d7e30f1 --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/banner-type.adoc @@ -0,0 +1,12 @@ +[discrete] +==== BannerType (배너 유형) ==== + +[cols="2,3", options="header"] +|=== +|값 |설명 +|`CURATION` |큐레이션 +|`AD` |광고 +|`SURVEY` |설문지 +|`PARTNERSHIP` |제휴 +|`ETC` |기타 +|=== diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/text-position.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/text-position.adoc new file mode 100644 index 000000000..f2aac5c29 --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/text-position.adoc @@ -0,0 +1,12 @@ +[discrete] +==== TextPosition (텍스트 위치) ==== + +[cols="2,3", options="header"] +|=== +|값 |설명 +|`LT` |좌상단 +|`LB` |좌하단 +|`RT` |우상단 +|`RB` |우하단 +|`CENTER` |중앙 +|=== diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/banner/presentation/AdminBannerController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/banner/presentation/AdminBannerController.kt new file mode 100644 index 000000000..26ad02b5e --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/banner/presentation/AdminBannerController.kt @@ -0,0 +1,72 @@ +package app.bottlenote.banner.presentation + +import app.bottlenote.banner.dto.request.AdminBannerCreateRequest +import app.bottlenote.banner.dto.request.AdminBannerSearchRequest +import app.bottlenote.banner.dto.request.AdminBannerSortOrderRequest +import app.bottlenote.banner.dto.request.AdminBannerStatusRequest +import app.bottlenote.banner.dto.request.AdminBannerUpdateRequest +import app.bottlenote.banner.service.AdminBannerService +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.PatchMapping +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 + +@RestController +@RequestMapping("/banners") +class AdminBannerController( + private val adminBannerService: AdminBannerService +) { + + @GetMapping + fun list(@ModelAttribute request: AdminBannerSearchRequest): ResponseEntity { + return ResponseEntity.ok(adminBannerService.search(request)) + } + + @GetMapping("/{bannerId}") + fun detail(@PathVariable bannerId: Long): ResponseEntity<*> { + return GlobalResponse.ok(adminBannerService.getDetail(bannerId)) + } + + @PostMapping + fun create(@RequestBody @Valid request: AdminBannerCreateRequest): ResponseEntity<*> { + return GlobalResponse.ok(adminBannerService.create(request)) + } + + @PutMapping("/{bannerId}") + fun update( + @PathVariable bannerId: Long, + @RequestBody @Valid request: AdminBannerUpdateRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(adminBannerService.update(bannerId, request)) + } + + @DeleteMapping("/{bannerId}") + fun delete(@PathVariable bannerId: Long): ResponseEntity<*> { + return GlobalResponse.ok(adminBannerService.delete(bannerId)) + } + + @PatchMapping("/{bannerId}/status") + fun updateStatus( + @PathVariable bannerId: Long, + @RequestBody @Valid request: AdminBannerStatusRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(adminBannerService.updateStatus(bannerId, request)) + } + + @PatchMapping("/{bannerId}/sort-order") + fun updateSortOrder( + @PathVariable bannerId: Long, + @RequestBody @Valid request: AdminBannerSortOrderRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(adminBannerService.updateSortOrder(bannerId, request)) + } +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt new file mode 100644 index 000000000..3e1d08ac5 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt @@ -0,0 +1,441 @@ +package app.docs.banner + +import app.bottlenote.banner.dto.request.AdminBannerCreateRequest +import app.bottlenote.banner.dto.request.AdminBannerSearchRequest +import app.bottlenote.banner.dto.request.AdminBannerSortOrderRequest +import app.bottlenote.banner.dto.request.AdminBannerStatusRequest +import app.bottlenote.banner.dto.request.AdminBannerUpdateRequest +import app.bottlenote.banner.presentation.AdminBannerController +import app.bottlenote.banner.service.AdminBannerService +import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.global.dto.response.AdminResultResponse +import app.helper.banner.BannerHelper +import com.fasterxml.jackson.databind.ObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.BDDMockito.given +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.data.domain.PageImpl +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.* +import org.springframework.restdocs.request.RequestDocumentation.* +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.assertj.MockMvcTester + +@WebMvcTest( + controllers = [AdminBannerController::class], + excludeAutoConfiguration = [SecurityAutoConfiguration::class] +) +@AutoConfigureRestDocs +@DisplayName("Admin Banner 컨트롤러 RestDocs 테스트") +class AdminBannerControllerDocsTest { + + @Autowired + private lateinit var mvc: MockMvcTester + + @Autowired + private lateinit var mapper: ObjectMapper + + @MockitoBean + private lateinit var adminBannerService: AdminBannerService + + @Nested + @DisplayName("배너 목록 조회") + inner class ListBanners { + + @Test + @DisplayName("배너 목록을 조회할 수 있다") + fun listBanners() { + // given + val items = BannerHelper.createAdminBannerListResponses(3) + val page = PageImpl(items) + val response = GlobalResponse.fromPage(page) + + given(adminBannerService.search(any(AdminBannerSearchRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.get().uri("/banners?keyword=&isActive=true&bannerType=CURATION&page=0&size=20") + ) + .hasStatusOk() + .apply( + document( + "admin/banners/list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("keyword").description("검색어 (배너명)").optional(), + parameterWithName("isActive").description("활성화 상태 필터 (true/false/null)").optional(), + parameterWithName("bannerType").description("배너 유형 필터 (CURATION/AD/SURVEY/PARTNERSHIP/ETC)").optional(), + parameterWithName("page").description("페이지 번호 (0부터 시작, 기본값: 0)").optional(), + parameterWithName("size").description("페이지 크기 (기본값: 20)").optional() + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data[]").type(JsonFieldType.ARRAY).description("배너 목록"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("배너 ID"), + fieldWithPath("data[].name").type(JsonFieldType.STRING).description("배너명"), + fieldWithPath("data[].bannerType").type(JsonFieldType.STRING).description("배너 유형"), + fieldWithPath("data[].sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서"), + fieldWithPath("data[].isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), + fieldWithPath("data[].startDate").type(JsonFieldType.VARIES).description("시작일시").optional(), + fieldWithPath("data[].endDate").type(JsonFieldType.VARIES).description("종료일시").optional(), + fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("meta.size").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("meta.totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("meta.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("meta.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("배너 상세 조회") + inner class GetBannerDetail { + + @Test + @DisplayName("배너 상세 정보를 조회할 수 있다") + fun getBannerDetail() { + // given + val response = BannerHelper.createAdminBannerDetailResponse() + + given(adminBannerService.getDetail(anyLong())).willReturn(response) + + // when & then + assertThat(mvc.get().uri("/banners/{bannerId}", 1L)) + .hasStatusOk() + .apply( + document( + "admin/banners/detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("bannerId").description("배너 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("배너 상세 정보"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("배너 ID"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("배너명"), + fieldWithPath("data.nameFontColor").type(JsonFieldType.STRING).description("배너명 폰트 색상 (HEX)"), + fieldWithPath("data.descriptionA").type(JsonFieldType.STRING).description("배너 설명A").optional(), + fieldWithPath("data.descriptionB").type(JsonFieldType.STRING).description("배너 설명B").optional(), + fieldWithPath("data.descriptionFontColor").type(JsonFieldType.STRING).description("설명 폰트 색상 (HEX)"), + fieldWithPath("data.imageUrl").type(JsonFieldType.STRING).description("이미지 URL. [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다"), + fieldWithPath("data.textPosition").type(JsonFieldType.STRING).description("텍스트 위치 (RT/CENTER/LB 등)"), + fieldWithPath("data.isExternalUrl").type(JsonFieldType.BOOLEAN).description("외부 URL 여부"), + fieldWithPath("data.targetUrl").type(JsonFieldType.VARIES).description("이동 URL. [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다").optional(), + fieldWithPath("data.bannerType").type(JsonFieldType.STRING).description("배너 유형"), + fieldWithPath("data.sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서"), + fieldWithPath("data.startDate").type(JsonFieldType.VARIES).description("시작일시").optional(), + fieldWithPath("data.endDate").type(JsonFieldType.VARIES).description("종료일시").optional(), + fieldWithPath("data.isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), + fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("배너 생성") + inner class CreateBanner { + + @Test + @DisplayName("배너를 생성할 수 있다") + fun createBanner() { + // given + val request = BannerHelper.createBannerCreateRequest() + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.BANNER_CREATED, 1L) + + given(adminBannerService.create(any(AdminBannerCreateRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.post().uri("/banners") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/banners/create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("배너명 (필수)"), + fieldWithPath("nameFontColor").type(JsonFieldType.STRING).description("배너명 폰트 색상 (HEX, 기본값: #ffffff)").optional(), + fieldWithPath("descriptionA").type(JsonFieldType.STRING).description("배너 설명A (최대 50자)").optional(), + fieldWithPath("descriptionB").type(JsonFieldType.STRING).description("배너 설명B (최대 50자)").optional(), + fieldWithPath("descriptionFontColor").type(JsonFieldType.STRING).description("설명 폰트 색상 (HEX, 기본값: #ffffff)").optional(), + fieldWithPath("imageUrl").type(JsonFieldType.STRING).description("이미지 URL (필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다"), + fieldWithPath("textPosition").type(JsonFieldType.STRING).description("텍스트 위치 (RT/CENTER/LB 등, 기본값: RT)").optional(), + fieldWithPath("isExternalUrl").type(JsonFieldType.BOOLEAN).description("외부 URL 여부 (기본값: false)").optional(), + fieldWithPath("targetUrl").type(JsonFieldType.VARIES).description("이동 URL (isExternalUrl=true 시 필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다").optional(), + fieldWithPath("bannerType").type(JsonFieldType.STRING).description("배너 유형 (필수: CURATION/AD/SURVEY/PARTNERSHIP/ETC)"), + fieldWithPath("sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서 (0 이상, 기본값: 0)").optional(), + fieldWithPath("startDate").type(JsonFieldType.VARIES).description("시작일시").optional(), + fieldWithPath("endDate").type(JsonFieldType.VARIES).description("종료일시").optional() + ), + 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("결과 코드"), + 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).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("배너 수정") + inner class UpdateBanner { + + @Test + @DisplayName("배너를 수정할 수 있다") + fun updateBanner() { + // given + val request = BannerHelper.createBannerUpdateRequest() + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.BANNER_UPDATED, 1L) + + given(adminBannerService.update(anyLong(), any(AdminBannerUpdateRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.put().uri("/banners/{bannerId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/banners/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("bannerId").description("배너 ID") + ), + requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("배너명 (필수)"), + fieldWithPath("nameFontColor").type(JsonFieldType.STRING).description("배너명 폰트 색상 (HEX)"), + fieldWithPath("descriptionA").type(JsonFieldType.STRING).description("배너 설명A (최대 50자)").optional(), + fieldWithPath("descriptionB").type(JsonFieldType.STRING).description("배너 설명B (최대 50자)").optional(), + fieldWithPath("descriptionFontColor").type(JsonFieldType.STRING).description("설명 폰트 색상 (HEX)"), + fieldWithPath("imageUrl").type(JsonFieldType.STRING).description("이미지 URL (필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다"), + fieldWithPath("textPosition").type(JsonFieldType.STRING).description("텍스트 위치 (RT/CENTER/LB 등)"), + fieldWithPath("isExternalUrl").type(JsonFieldType.BOOLEAN).description("외부 URL 여부"), + fieldWithPath("targetUrl").type(JsonFieldType.VARIES).description("이동 URL (isExternalUrl=true 시 필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다").optional(), + fieldWithPath("bannerType").type(JsonFieldType.STRING).description("배너 유형 (필수: CURATION/AD/SURVEY/PARTNERSHIP/ETC)"), + fieldWithPath("sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서 (0 이상, 필수)"), + fieldWithPath("startDate").type(JsonFieldType.VARIES).description("시작일시").optional(), + fieldWithPath("endDate").type(JsonFieldType.VARIES).description("종료일시").optional(), + fieldWithPath("isActive").type(JsonFieldType.BOOLEAN).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("결과 코드"), + 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).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("배너 삭제") + inner class DeleteBanner { + + @Test + @DisplayName("배너를 삭제할 수 있다") + fun deleteBanner() { + // given + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.BANNER_DELETED, 1L) + + given(adminBannerService.delete(anyLong())).willReturn(response) + + // when & then + assertThat(mvc.delete().uri("/banners/{bannerId}", 1L)) + .hasStatusOk() + .apply( + document( + "admin/banners/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("bannerId").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("결과 코드"), + 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).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("배너 활성화 상태 변경") + inner class UpdateBannerStatus { + + @Test + @DisplayName("배너 활성화 상태를 변경할 수 있다") + fun updateStatus() { + // given + val request = BannerHelper.createBannerStatusRequest(isActive = false) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.BANNER_STATUS_UPDATED, 1L) + + given(adminBannerService.updateStatus(anyLong(), any(AdminBannerStatusRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.patch().uri("/banners/{bannerId}/status", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/banners/update-status", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("bannerId").description("배너 ID") + ), + requestFields( + fieldWithPath("isActive").type(JsonFieldType.BOOLEAN).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("결과 코드"), + 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).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("배너 정렬 순서 변경") + inner class UpdateBannerSortOrder { + + @Test + @DisplayName("배너 정렬 순서를 변경할 수 있다") + fun updateSortOrder() { + // given + val request = BannerHelper.createBannerSortOrderRequest(sortOrder = 5) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.BANNER_SORT_ORDER_UPDATED, 1L) + + given(adminBannerService.updateSortOrder(anyLong(), any(AdminBannerSortOrderRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.patch().uri("/banners/{bannerId}/sort-order", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/banners/update-sort-order", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("bannerId").description("배너 ID") + ), + requestFields( + fieldWithPath("sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서 (0 이상, 필수)") + ), + 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("결과 코드"), + 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).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt index 3b9e9a87d..b64f1250d 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt @@ -136,7 +136,15 @@ class AdminCurationControllerDocsTest { fieldWithPath("data.coverImageUrl").type(JsonFieldType.STRING).description("커버 이미지 URL").optional(), fieldWithPath("data.displayOrder").type(JsonFieldType.NUMBER).description("노출 순서"), fieldWithPath("data.isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), - fieldWithPath("data.alcoholIds").type(JsonFieldType.ARRAY).description("포함된 위스키 ID 목록"), + fieldWithPath("data.alcohols").type(JsonFieldType.ARRAY).description("포함된 위스키 목록"), + fieldWithPath("data.alcohols[].alcoholId").type(JsonFieldType.NUMBER).description("위스키 ID"), + fieldWithPath("data.alcohols[].korName").type(JsonFieldType.STRING).description("한글명"), + fieldWithPath("data.alcohols[].engName").type(JsonFieldType.STRING).description("영문명"), + fieldWithPath("data.alcohols[].korCategoryName").type(JsonFieldType.STRING).description("한글 카테고리명"), + fieldWithPath("data.alcohols[].engCategoryName").type(JsonFieldType.STRING).description("영문 카테고리명"), + fieldWithPath("data.alcohols[].imageUrl").type(JsonFieldType.STRING).description("이미지 URL"), + fieldWithPath("data.alcohols[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.alcohols[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("생성일시"), fieldWithPath("data.modifiedAt").type(JsonFieldType.STRING).description("수정일시"), fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt new file mode 100644 index 000000000..3057e8e42 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt @@ -0,0 +1,131 @@ +package app.helper.banner + +import app.bottlenote.banner.constant.BannerType +import app.bottlenote.banner.constant.TextPosition +import app.bottlenote.banner.dto.response.AdminBannerDetailResponse +import app.bottlenote.banner.dto.response.AdminBannerListResponse +import app.bottlenote.global.dto.response.AdminResultResponse +import java.time.LocalDateTime + +object BannerHelper { + + fun createAdminBannerListResponse( + id: Long = 1L, + name: String = "테스트 배너", + bannerType: BannerType = BannerType.CURATION, + sortOrder: Int = 0, + isActive: Boolean = true, + startDate: LocalDateTime? = null, + endDate: LocalDateTime? = null, + createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0) + ): AdminBannerListResponse = AdminBannerListResponse( + id, name, bannerType, sortOrder, isActive, startDate, endDate, createdAt + ) + + fun createAdminBannerListResponses(count: Int = 3): List = + (1..count).map { i -> + createAdminBannerListResponse( + id = i.toLong(), + name = "배너 $i", + sortOrder = i - 1, + createdAt = LocalDateTime.of(2024, i, 1, 0, 0) + ) + } + + fun createAdminBannerDetailResponse( + id: Long = 1L, + name: String = "테스트 배너", + nameFontColor: String = "#ffffff", + descriptionA: String? = "배너 설명A", + descriptionB: String? = "배너 설명B", + descriptionFontColor: String = "#ffffff", + imageUrl: String = "https://example.com/banner.jpg", + textPosition: TextPosition = TextPosition.RT, + isExternalUrl: Boolean = false, + targetUrl: String? = null, + bannerType: BannerType = BannerType.CURATION, + sortOrder: Int = 0, + startDate: LocalDateTime? = null, + endDate: LocalDateTime? = null, + isActive: Boolean = true, + createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0), + modifiedAt: LocalDateTime = LocalDateTime.of(2024, 6, 1, 0, 0) + ): AdminBannerDetailResponse = AdminBannerDetailResponse( + id, name, nameFontColor, descriptionA, descriptionB, descriptionFontColor, + imageUrl, textPosition, isExternalUrl, targetUrl, bannerType, sortOrder, + startDate, endDate, isActive, createdAt, modifiedAt + ) + + fun createBannerCreateRequest( + name: String = "새 배너", + nameFontColor: String = "#ffffff", + descriptionA: String? = "배너 설명A", + descriptionB: String? = "배너 설명B", + descriptionFontColor: String = "#ffffff", + imageUrl: String = "https://example.com/banner.jpg", + textPosition: String = "RT", + isExternalUrl: Boolean = false, + targetUrl: String? = null, + bannerType: String = "CURATION", + sortOrder: Int = 0, + startDate: String? = null, + endDate: String? = null + ): Map = mapOf( + "name" to name, + "nameFontColor" to nameFontColor, + "descriptionA" to descriptionA, + "descriptionB" to descriptionB, + "descriptionFontColor" to descriptionFontColor, + "imageUrl" to imageUrl, + "textPosition" to textPosition, + "isExternalUrl" to isExternalUrl, + "targetUrl" to targetUrl, + "bannerType" to bannerType, + "sortOrder" to sortOrder, + "startDate" to startDate, + "endDate" to endDate + ) + + fun createBannerUpdateRequest( + name: String = "수정된 배너", + nameFontColor: String = "#000000", + descriptionA: String? = "수정된 설명A", + descriptionB: String? = "수정된 설명B", + descriptionFontColor: String = "#000000", + imageUrl: String = "https://example.com/updated.jpg", + textPosition: String = "CENTER", + isExternalUrl: Boolean = false, + targetUrl: String? = null, + bannerType: String = "CURATION", + sortOrder: Int = 1, + startDate: String? = null, + endDate: String? = null, + isActive: Boolean = true + ): Map = mapOf( + "name" to name, + "nameFontColor" to nameFontColor, + "descriptionA" to descriptionA, + "descriptionB" to descriptionB, + "descriptionFontColor" to descriptionFontColor, + "imageUrl" to imageUrl, + "textPosition" to textPosition, + "isExternalUrl" to isExternalUrl, + "targetUrl" to targetUrl, + "bannerType" to bannerType, + "sortOrder" to sortOrder, + "startDate" to startDate, + "endDate" to endDate, + "isActive" to isActive + ) + + fun createBannerStatusRequest(isActive: Boolean = true): Map = + mapOf("isActive" to isActive) + + fun createBannerSortOrderRequest(sortOrder: Int = 1): Map = + mapOf("sortOrder" to sortOrder) + + fun createAdminResultResponse( + code: AdminResultResponse.ResultCode = AdminResultResponse.ResultCode.BANNER_CREATED, + targetId: Long = 1L + ): AdminResultResponse = AdminResultResponse.of(code, targetId) +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/curation/CurationHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/curation/CurationHelper.kt index 243b021ec..0900680a5 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/curation/CurationHelper.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/curation/CurationHelper.kt @@ -1,7 +1,9 @@ package app.helper.curation +import app.bottlenote.alcohols.dto.response.AdminAlcoholItem import app.bottlenote.alcohols.dto.response.AdminCurationDetailResponse import app.bottlenote.alcohols.dto.response.AdminCurationListResponse +import app.helper.alcohols.AlcoholsHelper import app.bottlenote.global.dto.response.AdminResultResponse import java.time.LocalDateTime @@ -36,11 +38,11 @@ object CurationHelper { coverImageUrl: String = "https://example.com/cover.jpg", displayOrder: Int = 1, isActive: Boolean = true, - alcoholIds: Set = setOf(1L, 2L, 3L), + alcohols: List = AlcoholsHelper.createAdminAlcoholItems(3), createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0), modifiedAt: LocalDateTime = LocalDateTime.of(2024, 6, 1, 0, 0) ): AdminCurationDetailResponse = AdminCurationDetailResponse( - id, name, description, coverImageUrl, displayOrder, isActive, alcoholIds, createdAt, modifiedAt + id, name, description, coverImageUrl, displayOrder, isActive, alcohols, createdAt, modifiedAt ) fun createCurationCreateRequest( diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/banner/AdminBannerIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/banner/AdminBannerIntegrationTest.kt new file mode 100644 index 000000000..442f2ea4c --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/banner/AdminBannerIntegrationTest.kt @@ -0,0 +1,425 @@ +package app.integration.banner + +import app.IntegrationTestSupport +import app.bottlenote.banner.fixture.BannerTestFactory +import app.helper.banner.BannerHelper +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.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType + +@Tag("admin_integration") +@DisplayName("[integration] Admin Banner API 통합 테스트") +class AdminBannerIntegrationTest : IntegrationTestSupport() { + + @Autowired + private lateinit var bannerTestFactory: BannerTestFactory + + private lateinit var accessToken: String + + @BeforeEach + fun setUp() { + val admin = adminUserTestFactory.persistRootAdmin() + accessToken = getAccessToken(admin) + } + + @Nested + @DisplayName("배너 목록 조회 API") + inner class ListBanners { + + @Test + @DisplayName("배너 목록을 조회할 수 있다") + fun listSuccess() { + // given + bannerTestFactory.persistMultipleBanners(3) + + // when & then + assertThat( + mockMvcTester.get().uri("/banners") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("키워드로 필터링하여 조회할 수 있다") + fun listWithKeywordFilter() { + // given + bannerTestFactory.persistBanner("특별 배너", "https://example.com/special.jpg") + + // when & then + assertThat( + mockMvcTester.get().uri("/banners") + .param("keyword", "특별") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("활성화 상태로 필터링하여 조회할 수 있다") + fun listWithIsActiveFilter() { + // given + bannerTestFactory.persistMixedActiveBanners(2, 1) + + // when & then + assertThat( + mockMvcTester.get().uri("/banners") + .param("isActive", "true") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("배너 유형으로 필터링하여 조회할 수 있다") + fun listWithBannerTypeFilter() { + // given + bannerTestFactory.persistMixedActiveBanners(2, 1) + + // when & then + assertThat( + mockMvcTester.get().uri("/banners") + .param("bannerType", "CURATION") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("인증 없이 요청하면 401을 반환한다") + fun listUnauthorized() { + assertThat( + mockMvcTester.get().uri("/banners") + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("배너 상세 조회 API") + inner class GetBannerDetail { + + @Test + @DisplayName("배너 상세 정보를 조회할 수 있다") + fun getDetailSuccess() { + // given + val banner = bannerTestFactory.persistBanner("상세 배너", "https://example.com/detail.jpg") + + // when & then + assertThat( + mockMvcTester.get().uri("/banners/${banner.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("존재하지 않는 배너 조회 시 404를 반환한다") + fun getDetailNotFound() { + // when & then + assertThat( + mockMvcTester.get().uri("/banners/999999") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("배너 생성 API") + inner class CreateBanner { + + @Test + @DisplayName("배너를 생성할 수 있다") + fun createSuccess() { + // given + val request = BannerHelper.createBannerCreateRequest( + name = "새로운 배너" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/banners") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("BANNER_CREATED") + } + + @Test + @DisplayName("이름이 중복되면 409를 반환한다") + fun createDuplicateName() { + // given + bannerTestFactory.persistBanner("중복 배너", "https://example.com/dup.jpg") + val request = BannerHelper.createBannerCreateRequest( + name = "중복 배너" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/banners") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("필수 필드 누락 시 400을 반환한다") + fun createValidationFail() { + // given + val request = mapOf( + "name" to "", + "imageUrl" to "" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/banners") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("startDate가 endDate보다 이후이면 400을 반환한다") + fun createInvalidDateRange() { + // given + val request = BannerHelper.createBannerCreateRequest( + name = "날짜 오류 배너", + startDate = "2025-12-31T00:00:00", + endDate = "2025-01-01T00:00:00" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/banners") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("isExternalUrl이 true이고 targetUrl이 없으면 400을 반환한다") + fun createExternalUrlWithoutTarget() { + // given + val request = BannerHelper.createBannerCreateRequest( + name = "외부 URL 오류 배너", + isExternalUrl = true, + targetUrl = null + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/banners") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("잘못된 HEX 색상 형식이면 400을 반환한다") + fun createInvalidHexColor() { + // given + val request = BannerHelper.createBannerCreateRequest( + name = "색상 오류 배너", + nameFontColor = "invalid" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/banners") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("배너 수정 API") + inner class UpdateBanner { + + @Test + @DisplayName("배너를 수정할 수 있다") + fun updateSuccess() { + // given + val banner = bannerTestFactory.persistBanner("수정 대상 배너", "https://example.com/edit.jpg") + val request = BannerHelper.createBannerUpdateRequest( + name = "수정된 배너" + ) + + // when & then + assertThat( + mockMvcTester.put().uri("/banners/${banner.id}") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("BANNER_UPDATED") + } + + @Test + @DisplayName("존재하지 않는 배너 수정 시 404를 반환한다") + fun updateNotFound() { + // given + val request = BannerHelper.createBannerUpdateRequest() + + // when & then + assertThat( + mockMvcTester.put().uri("/banners/999999") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("배너 삭제 API") + inner class DeleteBanner { + + @Test + @DisplayName("배너를 삭제할 수 있다") + fun deleteSuccess() { + // given + val banner = bannerTestFactory.persistBanner("삭제 대상 배너", "https://example.com/del.jpg") + + // when & then + assertThat( + mockMvcTester.delete().uri("/banners/${banner.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("BANNER_DELETED") + } + + @Test + @DisplayName("존재하지 않는 배너 삭제 시 404를 반환한다") + fun deleteNotFound() { + // when & then + assertThat( + mockMvcTester.delete().uri("/banners/999999") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("배너 활성화 상태 변경 API") + inner class UpdateBannerStatus { + + @Test + @DisplayName("배너 활성화 상태를 변경할 수 있다") + fun updateStatusSuccess() { + // given + val banner = bannerTestFactory.persistBanner("상태 변경 배너", "https://example.com/status.jpg") + val request = BannerHelper.createBannerStatusRequest(isActive = false) + + // when & then + assertThat( + mockMvcTester.patch().uri("/banners/${banner.id}/status") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("BANNER_STATUS_UPDATED") + } + + @Test + @DisplayName("존재하지 않는 배너의 상태 변경 시 404를 반환한다") + fun updateStatusNotFound() { + // given + val request = BannerHelper.createBannerStatusRequest(isActive = false) + + // when & then + assertThat( + mockMvcTester.patch().uri("/banners/999999/status") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("배너 정렬 순서 변경 API") + inner class UpdateBannerSortOrder { + + @Test + @DisplayName("배너 정렬 순서를 변경할 수 있다") + fun updateSortOrderSuccess() { + // given + val banner = bannerTestFactory.persistBanner("정렬 변경 배너", "https://example.com/sort.jpg") + val request = BannerHelper.createBannerSortOrderRequest(sortOrder = 5) + + // when & then + assertThat( + mockMvcTester.patch().uri("/banners/${banner.id}/sort-order") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("BANNER_SORT_ORDER_UPDATED") + } + } + + @Nested + @DisplayName("인증 테스트") + inner class AuthenticationTest { + + @Test + @DisplayName("인증 없이 요청 시 실패한다") + fun requestWithoutAuth() { + // when & then + assertThat(mockMvcTester.get().uri("/banners")) + .hasStatus4xxClientError() + + assertThat(mockMvcTester.get().uri("/banners/1")) + .hasStatus4xxClientError() + + assertThat( + mockMvcTester.post().uri("/banners") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(BannerHelper.createBannerCreateRequest())) + ) + .hasStatus4xxClientError() + } + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminCurationDetailResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminCurationDetailResponse.java index 9d016276e..88fa759ec 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminCurationDetailResponse.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminCurationDetailResponse.java @@ -1,7 +1,7 @@ package app.bottlenote.alcohols.dto.response; import java.time.LocalDateTime; -import java.util.Set; +import java.util.List; /** * Admin 큐레이션 상세 조회 응답 @@ -12,7 +12,7 @@ * @param coverImageUrl 커버 이미지 URL * @param displayOrder 노출 순서 * @param isActive 활성화 상태 - * @param alcoholIds 포함된 위스키 ID 목록 + * @param alcohols 포함된 위스키 목록 * @param createdAt 생성일시 * @param modifiedAt 수정일시 */ @@ -23,7 +23,7 @@ public record AdminCurationDetailResponse( String coverImageUrl, Integer displayOrder, Boolean isActive, - Set alcoholIds, + List alcohols, LocalDateTime createdAt, LocalDateTime modifiedAt) { @@ -34,7 +34,7 @@ public static AdminCurationDetailResponse of( String coverImageUrl, Integer displayOrder, Boolean isActive, - Set alcoholIds, + List alcohols, LocalDateTime createdAt, LocalDateTime modifiedAt) { return new AdminCurationDetailResponse( @@ -44,7 +44,7 @@ public static AdminCurationDetailResponse of( coverImageUrl, displayOrder, isActive, - alcoholIds, + alcohols, createdAt, modifiedAt); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminCurationService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminCurationService.java index e6e93f9f9..3b35c5af9 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminCurationService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminCurationService.java @@ -11,6 +11,7 @@ import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.CURATION_STATUS_UPDATED; import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.CURATION_UPDATED; +import app.bottlenote.alcohols.domain.AlcoholQueryRepository; import app.bottlenote.alcohols.domain.CurationKeyword; import app.bottlenote.alcohols.domain.CurationKeywordRepository; import app.bottlenote.alcohols.dto.request.AdminCurationAlcoholRequest; @@ -19,11 +20,14 @@ import app.bottlenote.alcohols.dto.request.AdminCurationSearchRequest; import app.bottlenote.alcohols.dto.request.AdminCurationStatusRequest; import app.bottlenote.alcohols.dto.request.AdminCurationUpdateRequest; +import app.bottlenote.alcohols.dto.response.AdminAlcoholItem; import app.bottlenote.alcohols.dto.response.AdminCurationDetailResponse; import app.bottlenote.alcohols.exception.AlcoholException; import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.global.dto.response.AdminResultResponse; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; @@ -36,6 +40,7 @@ public class AdminCurationService { private final CurationKeywordRepository curationKeywordRepository; + private final AlcoholQueryRepository alcoholQueryRepository; @Transactional(readOnly = true) public GlobalResponse search(AdminCurationSearchRequest request) { @@ -50,6 +55,21 @@ public AdminCurationDetailResponse getDetail(Long curationId) { .findById(curationId) .orElseThrow(() -> new AlcoholException(CURATION_NOT_FOUND)); + List alcohols = + alcoholQueryRepository.findAllByIdIn(new ArrayList<>(curation.getAlcoholIds())).stream() + .map( + alcohol -> + new AdminAlcoholItem( + alcohol.getId(), + alcohol.getKorName(), + alcohol.getEngName(), + alcohol.getKorCategory(), + alcohol.getEngCategory(), + alcohol.getImageUrl(), + alcohol.getCreateAt(), + alcohol.getLastModifyAt())) + .toList(); + return AdminCurationDetailResponse.of( curation.getId(), curation.getName(), @@ -57,7 +77,7 @@ public AdminCurationDetailResponse getDetail(Long curationId) { curation.getCoverImageUrl(), curation.getDisplayOrder(), curation.getIsActive(), - curation.getAlcoholIds(), + alcohols, curation.getCreateAt(), curation.getLastModifyAt()); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/domain/Banner.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/domain/Banner.java index 3ef06b9ba..504022d77 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/domain/Banner.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/domain/Banner.java @@ -95,4 +95,43 @@ public class Banner extends BaseEntity { @Column(name = "is_active", nullable = false) @Builder.Default private Boolean isActive = true; + + public void update( + String name, + String nameFontColor, + String descriptionA, + String descriptionB, + String descriptionFontColor, + String imageUrl, + TextPosition textPosition, + Boolean isExternalUrl, + String targetUrl, + BannerType bannerType, + Integer sortOrder, + LocalDateTime startDate, + LocalDateTime endDate, + Boolean isActive) { + this.name = name; + this.nameFontColor = nameFontColor; + this.descriptionA = descriptionA; + this.descriptionB = descriptionB; + this.descriptionFontColor = descriptionFontColor; + this.imageUrl = imageUrl; + this.textPosition = textPosition; + this.isExternalUrl = isExternalUrl; + this.targetUrl = targetUrl; + this.bannerType = bannerType; + this.sortOrder = sortOrder; + this.startDate = startDate; + this.endDate = endDate; + this.isActive = isActive; + } + + public void updateStatus(Boolean isActive) { + this.isActive = isActive; + } + + public void updateSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/domain/BannerRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/domain/BannerRepository.java index 2fa59f4a3..30ba3c13d 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/domain/BannerRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/domain/BannerRepository.java @@ -1,16 +1,25 @@ package app.bottlenote.banner.domain; +import app.bottlenote.banner.dto.request.AdminBannerSearchRequest; +import app.bottlenote.banner.dto.response.AdminBannerListResponse; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; -/** - * 배너 도메인 레포지토리 (조회 전용) - * - *

이 모듈은 사용자용 API로 조회만 가능합니다. 배너 생성/수정/삭제는 Admin 모듈에서 처리합니다. - */ public interface BannerRepository { Optional findById(Long id); List findAllByIsActiveTrue(); + + Banner save(Banner banner); + + void delete(Banner banner); + + boolean existsByName(String name); + + List findAllBySortOrderGreaterThanEqual(Integer sortOrder); + + Page searchForAdmin(AdminBannerSearchRequest request, Pageable pageable); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerCreateRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerCreateRequest.java new file mode 100644 index 000000000..b4a78a64c --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerCreateRequest.java @@ -0,0 +1,37 @@ +package app.bottlenote.banner.dto.request; + +import app.bottlenote.banner.constant.BannerType; +import app.bottlenote.banner.constant.TextPosition; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; +import lombok.Builder; + +public record AdminBannerCreateRequest( + @NotBlank(message = "배너명은 필수입니다.") String name, + @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "HEX 색상 형식이 올바르지 않습니다.") String nameFontColor, + @Size(max = 50, message = "배너 설명은 최대 50자까지 가능합니다.") String descriptionA, + @Size(max = 50, message = "배너 설명은 최대 50자까지 가능합니다.") String descriptionB, + @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "HEX 색상 형식이 올바르지 않습니다.") + String descriptionFontColor, + @NotBlank(message = "이미지 URL은 필수입니다.") String imageUrl, + TextPosition textPosition, + Boolean isExternalUrl, + String targetUrl, + @NotNull(message = "배너 유형은 필수입니다.") BannerType bannerType, + @Min(value = 0, message = "정렬 순서는 0 이상이어야 합니다.") Integer sortOrder, + LocalDateTime startDate, + LocalDateTime endDate) { + + @Builder + public AdminBannerCreateRequest { + nameFontColor = nameFontColor != null ? nameFontColor : "#ffffff"; + descriptionFontColor = descriptionFontColor != null ? descriptionFontColor : "#ffffff"; + textPosition = textPosition != null ? textPosition : TextPosition.RT; + isExternalUrl = isExternalUrl != null ? isExternalUrl : false; + sortOrder = sortOrder != null ? sortOrder : 0; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerSearchRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerSearchRequest.java new file mode 100644 index 000000000..a7acfbfe1 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerSearchRequest.java @@ -0,0 +1,12 @@ +package app.bottlenote.banner.dto.request; + +import app.bottlenote.banner.constant.BannerType; + +public record AdminBannerSearchRequest( + String keyword, Boolean isActive, BannerType bannerType, Integer page, Integer size) { + + public AdminBannerSearchRequest { + page = page != null ? page : 0; + size = size != null ? size : 20; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerSortOrderRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerSortOrderRequest.java new file mode 100644 index 000000000..c6bbf3bdd --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerSortOrderRequest.java @@ -0,0 +1,8 @@ +package app.bottlenote.banner.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public record AdminBannerSortOrderRequest( + @NotNull(message = "정렬 순서는 필수입니다.") @Min(value = 0, message = "정렬 순서는 0 이상이어야 합니다.") + Integer sortOrder) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerStatusRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerStatusRequest.java new file mode 100644 index 000000000..4106ba841 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerStatusRequest.java @@ -0,0 +1,5 @@ +package app.bottlenote.banner.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record AdminBannerStatusRequest(@NotNull(message = "활성화 상태는 필수입니다.") Boolean isActive) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerUpdateRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerUpdateRequest.java new file mode 100644 index 000000000..73394f4ae --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerUpdateRequest.java @@ -0,0 +1,28 @@ +package app.bottlenote.banner.dto.request; + +import app.bottlenote.banner.constant.BannerType; +import app.bottlenote.banner.constant.TextPosition; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; + +public record AdminBannerUpdateRequest( + @NotBlank(message = "배너명은 필수입니다.") String name, + @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "HEX 색상 형식이 올바르지 않습니다.") String nameFontColor, + @Size(max = 50, message = "배너 설명은 최대 50자까지 가능합니다.") String descriptionA, + @Size(max = 50, message = "배너 설명은 최대 50자까지 가능합니다.") String descriptionB, + @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "HEX 색상 형식이 올바르지 않습니다.") + String descriptionFontColor, + @NotBlank(message = "이미지 URL은 필수입니다.") String imageUrl, + TextPosition textPosition, + Boolean isExternalUrl, + String targetUrl, + @NotNull(message = "배너 유형은 필수입니다.") BannerType bannerType, + @NotNull(message = "정렬 순서는 필수입니다.") @Min(value = 0, message = "정렬 순서는 0 이상이어야 합니다.") + Integer sortOrder, + LocalDateTime startDate, + LocalDateTime endDate, + @NotNull(message = "활성화 상태는 필수입니다.") Boolean isActive) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerDetailResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerDetailResponse.java new file mode 100644 index 000000000..4d00249ac --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerDetailResponse.java @@ -0,0 +1,24 @@ +package app.bottlenote.banner.dto.response; + +import app.bottlenote.banner.constant.BannerType; +import app.bottlenote.banner.constant.TextPosition; +import java.time.LocalDateTime; + +public record AdminBannerDetailResponse( + Long id, + String name, + String nameFontColor, + String descriptionA, + String descriptionB, + String descriptionFontColor, + String imageUrl, + TextPosition textPosition, + Boolean isExternalUrl, + String targetUrl, + BannerType bannerType, + Integer sortOrder, + LocalDateTime startDate, + LocalDateTime endDate, + Boolean isActive, + LocalDateTime createdAt, + LocalDateTime modifiedAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerListResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerListResponse.java new file mode 100644 index 000000000..cdf7daa47 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerListResponse.java @@ -0,0 +1,14 @@ +package app.bottlenote.banner.dto.response; + +import app.bottlenote.banner.constant.BannerType; +import java.time.LocalDateTime; + +public record AdminBannerListResponse( + Long id, + String name, + BannerType bannerType, + Integer sortOrder, + Boolean isActive, + LocalDateTime startDate, + LocalDateTime endDate, + LocalDateTime createdAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/exception/BannerException.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/exception/BannerException.java new file mode 100644 index 000000000..bf2e671e3 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/exception/BannerException.java @@ -0,0 +1,12 @@ +package app.bottlenote.banner.exception; + +import app.bottlenote.global.exception.custom.AbstractCustomException; +import lombok.Getter; + +@Getter +public class BannerException extends AbstractCustomException { + + public BannerException(BannerExceptionCode code) { + super(code); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/exception/BannerExceptionCode.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/exception/BannerExceptionCode.java new file mode 100644 index 000000000..b2d5c0156 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/exception/BannerExceptionCode.java @@ -0,0 +1,29 @@ +package app.bottlenote.banner.exception; + +import app.bottlenote.global.exception.custom.code.ExceptionCode; +import org.springframework.http.HttpStatus; + +public enum BannerExceptionCode implements ExceptionCode { + BANNER_NOT_FOUND(HttpStatus.NOT_FOUND, "배너를 찾을 수 없습니다."), + BANNER_DUPLICATE_NAME(HttpStatus.CONFLICT, "동일한 이름의 배너가 이미 존재합니다."), + BANNER_INVALID_DATE_RANGE(HttpStatus.BAD_REQUEST, "시작일은 종료일보다 이전이어야 합니다."), + BANNER_TARGET_URL_REQUIRED(HttpStatus.BAD_REQUEST, "외부 URL 사용 시 이동 URL은 필수입니다."); + + private final HttpStatus httpStatus; + private final String message; + + BannerExceptionCode(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepository.java new file mode 100644 index 000000000..b4ab94d56 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepository.java @@ -0,0 +1,11 @@ +package app.bottlenote.banner.repository; + +import app.bottlenote.banner.dto.request.AdminBannerSearchRequest; +import app.bottlenote.banner.dto.response.AdminBannerListResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface CustomBannerRepository { + + Page searchForAdmin(AdminBannerSearchRequest request, Pageable pageable); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepositoryImpl.java new file mode 100644 index 000000000..b46542b33 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepositoryImpl.java @@ -0,0 +1,75 @@ +package app.bottlenote.banner.repository; + +import static app.bottlenote.banner.domain.QBanner.banner; + +import app.bottlenote.banner.constant.BannerType; +import app.bottlenote.banner.dto.request.AdminBannerSearchRequest; +import app.bottlenote.banner.dto.response.AdminBannerListResponse; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +@Slf4j +@RequiredArgsConstructor +public class CustomBannerRepositoryImpl implements CustomBannerRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public Page searchForAdmin( + AdminBannerSearchRequest request, Pageable pageable) { + + List content = + queryFactory + .select( + Projections.constructor( + AdminBannerListResponse.class, + banner.id, + banner.name, + banner.bannerType, + banner.sortOrder, + banner.isActive, + banner.startDate, + banner.endDate, + banner.createAt)) + .from(banner) + .where( + keywordContains(request.keyword()), + isActiveEq(request.isActive()), + bannerTypeEq(request.bannerType())) + .orderBy(banner.sortOrder.asc(), banner.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = + queryFactory + .select(banner.count()) + .from(banner) + .where( + keywordContains(request.keyword()), + isActiveEq(request.isActive()), + bannerTypeEq(request.bannerType())) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0L); + } + + private BooleanExpression keywordContains(String keyword) { + return keyword != null && !keyword.isBlank() ? banner.name.containsIgnoreCase(keyword) : null; + } + + private BooleanExpression isActiveEq(Boolean isActive) { + return isActive != null ? banner.isActive.eq(isActive) : null; + } + + private BooleanExpression bannerTypeEq(BannerType bannerType) { + return bannerType != null ? banner.bannerType.eq(bannerType) : null; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/JpaBannerRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/JpaBannerRepository.java index ae0e27bd3..5c04dda7e 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/JpaBannerRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/JpaBannerRepository.java @@ -7,7 +7,12 @@ import org.springframework.data.jpa.repository.JpaRepository; @JpaRepositoryImpl -public interface JpaBannerRepository extends BannerRepository, JpaRepository { +public interface JpaBannerRepository + extends BannerRepository, JpaRepository, CustomBannerRepository { List findAllByIsActiveTrue(); + + boolean existsByName(String name); + + List findAllBySortOrderGreaterThanEqual(Integer sortOrder); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/service/AdminBannerService.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/service/AdminBannerService.java new file mode 100644 index 000000000..cdb06871d --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/service/AdminBannerService.java @@ -0,0 +1,204 @@ +package app.bottlenote.banner.service; + +import static app.bottlenote.banner.exception.BannerExceptionCode.BANNER_DUPLICATE_NAME; +import static app.bottlenote.banner.exception.BannerExceptionCode.BANNER_INVALID_DATE_RANGE; +import static app.bottlenote.banner.exception.BannerExceptionCode.BANNER_NOT_FOUND; +import static app.bottlenote.banner.exception.BannerExceptionCode.BANNER_TARGET_URL_REQUIRED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.BANNER_CREATED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.BANNER_DELETED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.BANNER_SORT_ORDER_UPDATED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.BANNER_STATUS_UPDATED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.BANNER_UPDATED; + +import app.bottlenote.banner.domain.Banner; +import app.bottlenote.banner.domain.BannerRepository; +import app.bottlenote.banner.dto.request.AdminBannerCreateRequest; +import app.bottlenote.banner.dto.request.AdminBannerSearchRequest; +import app.bottlenote.banner.dto.request.AdminBannerSortOrderRequest; +import app.bottlenote.banner.dto.request.AdminBannerStatusRequest; +import app.bottlenote.banner.dto.request.AdminBannerUpdateRequest; +import app.bottlenote.banner.dto.response.AdminBannerDetailResponse; +import app.bottlenote.banner.exception.BannerException; +import app.bottlenote.global.data.response.GlobalResponse; +import app.bottlenote.global.dto.response.AdminResultResponse; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminBannerService { + + private final BannerRepository bannerRepository; + + @Transactional(readOnly = true) + public GlobalResponse search(AdminBannerSearchRequest request) { + PageRequest pageable = PageRequest.of(request.page(), request.size()); + return GlobalResponse.fromPage(bannerRepository.searchForAdmin(request, pageable)); + } + + @Transactional(readOnly = true) + public AdminBannerDetailResponse getDetail(Long bannerId) { + Banner banner = + bannerRepository + .findById(bannerId) + .orElseThrow(() -> new BannerException(BANNER_NOT_FOUND)); + + return new AdminBannerDetailResponse( + banner.getId(), + banner.getName(), + banner.getNameFontColor(), + banner.getDescriptionA(), + banner.getDescriptionB(), + banner.getDescriptionFontColor(), + banner.getImageUrl(), + banner.getTextPosition(), + banner.getIsExternalUrl(), + banner.getTargetUrl(), + banner.getBannerType(), + banner.getSortOrder(), + banner.getStartDate(), + banner.getEndDate(), + banner.getIsActive(), + banner.getCreateAt(), + banner.getLastModifyAt()); + } + + @Transactional + public AdminResultResponse create(AdminBannerCreateRequest request) { + if (bannerRepository.existsByName(request.name())) { + throw new BannerException(BANNER_DUPLICATE_NAME); + } + + validateDateRange(request.startDate(), request.endDate()); + validateExternalUrl(request.isExternalUrl(), request.targetUrl()); + + boolean isActive = determineActiveStatus(true, request.endDate()); + + reorderSortOrders(request.sortOrder(), null); + + Banner banner = + Banner.builder() + .name(request.name()) + .nameFontColor(request.nameFontColor()) + .descriptionA(request.descriptionA()) + .descriptionB(request.descriptionB()) + .descriptionFontColor(request.descriptionFontColor()) + .imageUrl(request.imageUrl()) + .textPosition(request.textPosition()) + .isExternalUrl(request.isExternalUrl()) + .targetUrl(request.targetUrl()) + .bannerType(request.bannerType()) + .sortOrder(request.sortOrder()) + .startDate(request.startDate()) + .endDate(request.endDate()) + .isActive(isActive) + .build(); + + Banner saved = bannerRepository.save(banner); + return AdminResultResponse.of(BANNER_CREATED, saved.getId()); + } + + @Transactional + public AdminResultResponse update(Long bannerId, AdminBannerUpdateRequest request) { + Banner banner = + bannerRepository + .findById(bannerId) + .orElseThrow(() -> new BannerException(BANNER_NOT_FOUND)); + + validateDateRange(request.startDate(), request.endDate()); + validateExternalUrl(request.isExternalUrl(), request.targetUrl()); + + boolean isActive = determineActiveStatus(request.isActive(), request.endDate()); + + if (!banner.getSortOrder().equals(request.sortOrder())) { + reorderSortOrders(request.sortOrder(), banner.getId()); + } + + banner.update( + request.name(), + request.nameFontColor(), + request.descriptionA(), + request.descriptionB(), + request.descriptionFontColor(), + request.imageUrl(), + request.textPosition(), + request.isExternalUrl(), + request.targetUrl(), + request.bannerType(), + request.sortOrder(), + request.startDate(), + request.endDate(), + isActive); + + return AdminResultResponse.of(BANNER_UPDATED, bannerId); + } + + @Transactional + public AdminResultResponse delete(Long bannerId) { + Banner banner = + bannerRepository + .findById(bannerId) + .orElseThrow(() -> new BannerException(BANNER_NOT_FOUND)); + + bannerRepository.delete(banner); + return AdminResultResponse.of(BANNER_DELETED, bannerId); + } + + @Transactional + public AdminResultResponse updateStatus(Long bannerId, AdminBannerStatusRequest request) { + Banner banner = + bannerRepository + .findById(bannerId) + .orElseThrow(() -> new BannerException(BANNER_NOT_FOUND)); + + banner.updateStatus(request.isActive()); + return AdminResultResponse.of(BANNER_STATUS_UPDATED, bannerId); + } + + @Transactional + public AdminResultResponse updateSortOrder(Long bannerId, AdminBannerSortOrderRequest request) { + Banner banner = + bannerRepository + .findById(bannerId) + .orElseThrow(() -> new BannerException(BANNER_NOT_FOUND)); + + if (!banner.getSortOrder().equals(request.sortOrder())) { + reorderSortOrders(request.sortOrder(), banner.getId()); + } + + banner.updateSortOrder(request.sortOrder()); + return AdminResultResponse.of(BANNER_SORT_ORDER_UPDATED, bannerId); + } + + private void validateDateRange(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate != null && endDate != null && startDate.isAfter(endDate)) { + throw new BannerException(BANNER_INVALID_DATE_RANGE); + } + } + + private void validateExternalUrl(Boolean isExternalUrl, String targetUrl) { + if (Boolean.TRUE.equals(isExternalUrl) && (targetUrl == null || targetUrl.isBlank())) { + throw new BannerException(BANNER_TARGET_URL_REQUIRED); + } + } + + private boolean determineActiveStatus(boolean requestedIsActive, LocalDateTime endDate) { + if (endDate != null && endDate.isBefore(LocalDateTime.now())) { + return false; + } + return requestedIsActive; + } + + private void reorderSortOrders(Integer newSortOrder, Long excludeBannerId) { + List conflicting = bannerRepository.findAllBySortOrderGreaterThanEqual(newSortOrder); + conflicting.stream() + .filter(b -> !b.getId().equals(excludeBannerId)) + .forEach(b -> b.updateSortOrder(b.getSortOrder() + 1)); + } +} 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 index 39cb7bc22..abb64d056 100644 --- 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 @@ -32,6 +32,11 @@ public enum ResultCode { CURATION_DISPLAY_ORDER_UPDATED("큐레이션 노출 순서가 변경되었습니다."), CURATION_ALCOHOL_ADDED("큐레이션에 위스키가 추가되었습니다."), CURATION_ALCOHOL_REMOVED("큐레이션에서 위스키가 제거되었습니다."), + BANNER_CREATED("배너가 등록되었습니다."), + BANNER_UPDATED("배너가 수정되었습니다."), + BANNER_DELETED("배너가 삭제되었습니다."), + BANNER_STATUS_UPDATED("배너 활성화 상태가 변경되었습니다."), + BANNER_SORT_ORDER_UPDATED("배너 정렬 순서가 변경되었습니다."), ; private final String message; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/history/scheduled/ViewHistorySyncJob.java b/bottlenote-mono/src/main/java/app/bottlenote/history/scheduled/ViewHistorySyncJob.java index bef31b33c..fad86490c 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/history/scheduled/ViewHistorySyncJob.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/history/scheduled/ViewHistorySyncJob.java @@ -3,6 +3,7 @@ import app.bottlenote.history.service.AlcoholViewHistoryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.quartz.DisallowConcurrentExecution; import org.quartz.Job; import org.quartz.JobExecutionContext; import org.quartz.JobExecutionException; @@ -11,6 +12,7 @@ @Slf4j @Component +@DisallowConcurrentExecution @RequiredArgsConstructor public class ViewHistorySyncJob implements Job { private final AlcoholViewHistoryService viewHistorySyncService; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/history/service/AlcoholViewHistoryService.java b/bottlenote-mono/src/main/java/app/bottlenote/history/service/AlcoholViewHistoryService.java index d98e54cf3..8f9b8d209 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/history/service/AlcoholViewHistoryService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/history/service/AlcoholViewHistoryService.java @@ -9,6 +9,7 @@ import app.bottlenote.history.domain.AlcoholsViewHistory.AlcoholsViewHistoryId; import app.bottlenote.history.domain.AlcoholsViewHistoryRepository; import app.bottlenote.observability.annotation.SkipTrace; +import jakarta.persistence.EntityManager; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; @@ -23,6 +24,8 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; @Slf4j @Service @@ -33,6 +36,7 @@ public class AlcoholViewHistoryService { private final RedisAlcoholViewHistoryRepository redisViewHistoryRepository; private final AlcoholsViewHistoryRepository historyRepository; private final RedisTemplate redisTemplate; + private final EntityManager entityManager; /** 사용자의 주류 조회 기록 저장 */ @Transactional @@ -98,7 +102,6 @@ public void syncViewHistoryFromRedisToDb() { List successKeys = new ArrayList<>(); - // 개별 처리로 에러 격리 for (var entry : latestEntries.entrySet()) { var compositeKey = entry.getKey(); var redisEntry = entry.getValue(); @@ -114,8 +117,12 @@ public void syncViewHistoryFromRedisToDb() { compositeKey.getUserId(), compositeKey.getAlcoholId(), redisEntry.viewTime))); + // flush로 즉시 SQL 실행하여 에러를 개별 catch로 격리 + entityManager.flush(); successKeys.add(redisEntry.redisId); } catch (Exception e) { + // flush 시점에 발생한 에러를 격리하고 해당 엔트리의 영속성 컨텍스트를 초기화 + entityManager.clear(); log.error( "조회 기록 동기화 실패: userId={}, alcoholId={}, error={}", compositeKey.getUserId(), @@ -124,10 +131,17 @@ public void syncViewHistoryFromRedisToDb() { } } - // 성공한 항목만 Redis에서 삭제 + // DB 커밋 성공 후에만 Redis에서 삭제 if (!successKeys.isEmpty()) { - redisViewHistoryRepository.deleteAllById(successKeys); - log.info("Redis에서 {}개 처리된 조회 기록 삭제 완료", successKeys.size()); + List keysToDelete = List.copyOf(successKeys); + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + redisViewHistoryRepository.deleteAllById(keysToDelete); + log.info("Redis에서 {}개 처리된 조회 기록 삭제 완료", keysToDelete.size()); + } + }); } } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholMetadataTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholMetadataTestFactory.java index ed50b6f03..be5ee4be6 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholMetadataTestFactory.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholMetadataTestFactory.java @@ -4,9 +4,8 @@ import app.bottlenote.alcohols.domain.Region; import app.bottlenote.alcohols.domain.TastingTag; import jakarta.persistence.EntityManager; -import java.security.SecureRandom; import java.util.List; -import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; @@ -17,7 +16,7 @@ @Component public class AlcoholMetadataTestFactory { - private final Random random = new SecureRandom(); + private static final AtomicInteger counter = new AtomicInteger(0); @Autowired private EntityManager em; @@ -104,6 +103,6 @@ public Distillery persistDistillery(@NotNull String korName, @NotNull String eng } private String generateRandomSuffix() { - return String.valueOf(random.nextInt(10000)); + return String.valueOf(counter.incrementAndGet()); } } 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 8cba806d2..a986723d7 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 @@ -11,12 +11,11 @@ import app.bottlenote.alcohols.domain.TastingTag; import jakarta.persistence.EntityManager; import java.math.BigDecimal; -import java.security.SecureRandom; import java.time.LocalDate; import java.util.HashSet; import java.util.List; -import java.util.Random; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; @@ -27,7 +26,7 @@ @Component public class AlcoholTestFactory { - private final Random random = new SecureRandom(); + private static final AtomicInteger counter = new AtomicInteger(0); @Autowired private EntityManager em; @@ -303,7 +302,7 @@ public Alcohol persistAndFlushAlcohol(@NotNull Alcohol.AlcoholBuilder builder) { /** 랜덤 접미사 생성 헬퍼 메서드 */ private String generateRandomSuffix() { - return String.valueOf(random.nextInt(10000)); + return String.valueOf(counter.incrementAndGet()); } /** 내부용 Region 생성 (트랜잭션 전파 없음) */ diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/TastingTagTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/TastingTagTestFactory.java index 150cd3f90..5c54d84c8 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/TastingTagTestFactory.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/TastingTagTestFactory.java @@ -4,10 +4,9 @@ import app.bottlenote.alcohols.domain.AlcoholsTastingTags; import app.bottlenote.alcohols.domain.TastingTag; import jakarta.persistence.EntityManager; -import java.security.SecureRandom; import java.util.ArrayList; import java.util.List; -import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; @@ -18,7 +17,7 @@ @Component public class TastingTagTestFactory { - private final Random random = new SecureRandom(); + private static final AtomicInteger counter = new AtomicInteger(0); @Autowired private EntityManager em; @@ -126,6 +125,6 @@ public AlcoholsTastingTags linkAlcoholToTag(@NotNull Alcohol alcohol, @NotNull T } private String generateRandomSuffix() { - return String.valueOf(random.nextInt(10000)); + return String.valueOf(counter.incrementAndGet()); } } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/banner/fixture/InMemoryBannerRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/banner/fixture/InMemoryBannerRepository.java index 61352a3d0..bd554b5c8 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/banner/fixture/InMemoryBannerRepository.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/banner/fixture/InMemoryBannerRepository.java @@ -2,12 +2,17 @@ import app.bottlenote.banner.domain.Banner; import app.bottlenote.banner.domain.BannerRepository; +import app.bottlenote.banner.dto.request.AdminBannerSearchRequest; +import app.bottlenote.banner.dto.response.AdminBannerListResponse; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; public class InMemoryBannerRepository implements BannerRepository { @@ -32,7 +37,7 @@ public List findAllByIsActiveTrue() { return result; } - /** 테스트용 저장 메서드 */ + @Override public Banner save(Banner banner) { Long id = (Long) ReflectionTestUtils.getField(banner, "id"); if (id != null && database.containsKey(id)) { @@ -46,12 +51,60 @@ public Banner save(Banner banner) { return banner; } - /** 테스트용 전체 조회 메서드 */ + @Override + public void delete(Banner banner) { + Long id = banner.getId(); + database.remove(id); + log.info("[InMemory] banner repository delete = {}", banner.getName()); + } + + @Override + public boolean existsByName(String name) { + return database.values().stream().anyMatch(banner -> banner.getName().equals(name)); + } + + @Override + public List findAllBySortOrderGreaterThanEqual(Integer sortOrder) { + return database.values().stream().filter(banner -> banner.getSortOrder() >= sortOrder).toList(); + } + + @Override + public Page searchForAdmin( + AdminBannerSearchRequest request, Pageable pageable) { + List all = + database.values().stream() + .filter( + b -> + request.keyword() == null + || request.keyword().isBlank() + || b.getName().contains(request.keyword())) + .filter(b -> request.isActive() == null || b.getIsActive().equals(request.isActive())) + .filter( + b -> request.bannerType() == null || b.getBannerType().equals(request.bannerType())) + .map( + b -> + new AdminBannerListResponse( + b.getId(), + b.getName(), + b.getBannerType(), + b.getSortOrder(), + b.getIsActive(), + b.getStartDate(), + b.getEndDate(), + b.getCreateAt())) + .toList(); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), all.size()); + List content = + start < all.size() ? all.subList(start, end) : List.of(); + return new PageImpl<>(content, pageable, all.size()); + } + public List findAll() { return List.copyOf(database.values()); } - /** 테스트용 초기화 메서드 */ public void clear() { database.clear(); log.info("[InMemory] banner repository cleared"); diff --git a/bottlenote-mono/src/test/java/app/bottlenote/history/fixture/HistoryTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/history/fixture/HistoryTestFactory.java index 6a1fb0fb1..37b4d6b1c 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/history/fixture/HistoryTestFactory.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/history/fixture/HistoryTestFactory.java @@ -9,7 +9,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.springframework.stereotype.Component; @@ -27,7 +27,7 @@ public class HistoryTestFactory { @PersistenceContext private EntityManager em; - private final Random random = new Random(); + private static final AtomicInteger counter = new AtomicInteger(0); /** 기본 UserHistory 생성 (userId, eventType 지정) */ @Transactional @@ -187,7 +187,7 @@ public UserHistory persistUserHistoryWithDynamicMessage( } private String generateRandomSuffix() { - return String.valueOf(random.nextInt(10000)); + return String.valueOf(counter.incrementAndGet()); } private UserHistory.UserHistoryBuilder fillMissingHistoryFields( diff --git a/bottlenote-mono/src/test/java/app/bottlenote/like/fixture/LikesTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/like/fixture/LikesTestFactory.java index 3e909d24e..8b17e4299 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/like/fixture/LikesTestFactory.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/like/fixture/LikesTestFactory.java @@ -8,7 +8,7 @@ import jakarta.transaction.Transactional; import java.util.ArrayList; import java.util.List; -import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Component; @@ -25,7 +25,7 @@ public class LikesTestFactory { @PersistenceContext private EntityManager em; - private final Random random = new Random(); + private static final AtomicInteger counter = new AtomicInteger(0); /** 기본 Likes 생성 (reviewId, userId 지정) */ @Transactional @@ -98,7 +98,7 @@ public List persistMultipleLikesByUser(@NotNull Long userId, int count) { } private String generateRandomSuffix() { - return String.valueOf(random.nextInt(10000)); + return String.valueOf(counter.incrementAndGet()); } private Likes.LikesBuilder fillMissingLikesFields(Likes tempLikes, Likes.LikesBuilder builder) { diff --git a/bottlenote-mono/src/test/java/app/bottlenote/rating/fixture/RatingTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/rating/fixture/RatingTestFactory.java index 63ee0100d..3a6d5686c 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/rating/fixture/RatingTestFactory.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/rating/fixture/RatingTestFactory.java @@ -6,7 +6,7 @@ import app.bottlenote.rating.domain.RatingPoint; import app.bottlenote.user.domain.User; import jakarta.persistence.EntityManager; -import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; @@ -17,7 +17,7 @@ @RequiredArgsConstructor public class RatingTestFactory { - private final Random random = new Random(); + private static final AtomicInteger counter = new AtomicInteger(0); @Autowired private EntityManager em; @@ -79,7 +79,7 @@ public Rating persistAndFlushRating(@NotNull Rating.RatingBuilder builder) { /** 랜덤 접미사 생성 헬퍼 메서드 */ private String generateRandomSuffix() { - return String.valueOf(random.nextInt(10000)); + return String.valueOf(counter.incrementAndGet()); } /** Rating 빌더의 누락 필드 채우기 */ diff --git a/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/ReviewTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/ReviewTestFactory.java index 6c24bb20e..3121442f4 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/ReviewTestFactory.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/ReviewTestFactory.java @@ -12,9 +12,8 @@ import app.bottlenote.user.domain.User; import jakarta.persistence.EntityManager; import java.math.BigDecimal; -import java.security.SecureRandom; import java.util.List; -import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; @@ -25,7 +24,7 @@ @Component public class ReviewTestFactory { - private final Random random = new SecureRandom(); + private static final AtomicInteger counter = new AtomicInteger(0); @Autowired private EntityManager em; @@ -254,7 +253,7 @@ public List persistReviewImages(@NotNull Review review, int count) /** 랜덤 접미사 생성 헬퍼 메서드 */ private String generateRandomSuffix() { - return String.valueOf(random.nextInt(10000)); + return String.valueOf(counter.incrementAndGet()); } /** Review 빌더의 누락 필드 채우기 */ diff --git a/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/AdminUserTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/AdminUserTestFactory.java index d288144ba..8064441d0 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/AdminUserTestFactory.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/AdminUserTestFactory.java @@ -4,9 +4,8 @@ import app.bottlenote.user.constant.UserStatus; import app.bottlenote.user.domain.AdminUser; import jakarta.persistence.EntityManager; -import java.security.SecureRandom; import java.util.List; -import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; @@ -19,14 +18,14 @@ @Component public class AdminUserTestFactory { - private final Random random = new SecureRandom(); + private static final AtomicInteger counter = new AtomicInteger(0); @Autowired private EntityManager em; @Autowired private BCryptPasswordEncoder passwordEncoder; private String generateRandomSuffix() { - return String.valueOf(random.nextInt(10000)); + return String.valueOf(counter.incrementAndGet()); } /** 기본 ROOT_ADMIN 생성 */ diff --git a/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/UserTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/UserTestFactory.java index a1d2a4d6d..b912ca7a9 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/UserTestFactory.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/UserTestFactory.java @@ -7,9 +7,8 @@ import app.bottlenote.user.domain.Follow; import app.bottlenote.user.domain.User; import jakarta.persistence.EntityManager; -import java.security.SecureRandom; import java.util.List; -import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; @@ -20,7 +19,7 @@ @Component public class UserTestFactory { - private final Random random = new SecureRandom(); + private static final AtomicInteger counter = new AtomicInteger(0); @Autowired private EntityManager em; @@ -189,7 +188,7 @@ public List persistFollowings(@NotNull User follower, int count) { /** 랜덤 접미사 생성 헬퍼 메서드 */ private String generateRandomSuffix() { - return String.valueOf(random.nextInt(10000)); + return String.valueOf(counter.incrementAndGet()); } /** 내부용 User 생성 (트랜잭션 전파 없음) */ diff --git a/bottlenote-product-api/VERSION b/bottlenote-product-api/VERSION index ee90284c2..90a27f9ce 100644 --- a/bottlenote-product-api/VERSION +++ b/bottlenote-product-api/VERSION @@ -1 +1 @@ -1.0.4 +1.0.5 diff --git a/git.environment-variables b/git.environment-variables index ad7d23c90..602b9b530 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit ad7d23c9051f40cffea32f6a404adc639e095e3a +Subproject commit 602b9b53057e712d067ba8a2a8c9487fc09076ba diff --git a/plan/complete/testfactory-flaky-suffix-fix.md b/plan/complete/testfactory-flaky-suffix-fix.md new file mode 100644 index 000000000..c924a029a --- /dev/null +++ b/plan/complete/testfactory-flaky-suffix-fix.md @@ -0,0 +1,194 @@ +# TestFactory generateRandomSuffix 충돌 수정 + +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2026-02-09 + +** Core Achievements ** +- 9개 TestFactory의 generateRandomSuffix()를 AtomicInteger 카운터로 교체 +- Birthday Problem에 의한 CI flaky test (약 1/30 확률) 완전 제거 +- CI 4개 테스트 전체 통과 확인 (check_rule_test, unit_test, integration_test, admin_integration_test) + +** Key Components ** +- UserTestFactory, AdminUserTestFactory (bottlenote-mono/.../user/fixture/) +- AlcoholTestFactory, AlcoholMetadataTestFactory, TastingTagTestFactory (bottlenote-mono/.../alcohols/fixture/) +- RatingTestFactory (bottlenote-mono/.../rating/fixture/) +- HistoryTestFactory (bottlenote-mono/.../history/fixture/) +- ReviewTestFactory (bottlenote-mono/.../review/fixture/) +- LikesTestFactory (bottlenote-mono/.../like/fixture/) +================================================================================ +``` + +> CI flaky test 원인인 `random.nextInt(10000)` 기반 suffix 생성을 `AtomicInteger` 카운터로 교체하여 유니크 제약조건 충돌을 제거한다. + +--- + +## 1. 문제 분석 + +### 발생 현상 + +- **CI Run**: https://github.com/bottle-note/bottle-note-api-server/actions/runs/21810567316 +- **실패 Job**: `integration-tests (product, integration_test)` - 177개 중 1개 실패 +- **실패 테스트**: `UserHistoryIntegrationTest.test_6` ("리뷰 필터 조건으로 유저 히스토리를 조회할 수 있다.") +- **예외**: `SQLIntegrityConstraintViolationException` at line 228 (`setupHistoryTestData()`) + +### 근본 원인 + +```java +// 9개 TestFactory가 동일 패턴 사용 +private final Random random = new SecureRandom(); + +private String generateRandomSuffix() { + return String.valueOf(random.nextInt(10000)); // 범위: 0~9999 +} +``` + +- `User.email`과 `User.nickName`에 `unique = true` DB 제약조건 존재 +- `random.nextInt(10000)` 범위(0~9999)에서 **생일 문제(Birthday Problem)**에 의해 충돌 확률이 직관보다 훨씬 높음 +- CI에서 반복 실행될수록 발생 빈도 증가하는 전형적인 flaky test + +### 충돌 확률 분석 (Birthday Problem) + +`nextInt(10000)` 범위에서 N개의 값을 뽑을 때, 아무 2개가 충돌할 확률: +`P ≈ 1 - e^(-N(N-1) / (2 * 10000))` + +**단일 테스트 (`setupHistoryTestData()` 1회 호출 = 유저 8명):** + +| 대상 | 생성 수 | 충돌 확률 | +|------|---------|----------| +| email suffix (8개) | 8 | ~0.28% (1/357) | +| nickName suffix (8개) | 8 | ~0.28% (1/357) | +| 둘 중 하나라도 충돌 | - | **~0.56% (1/180)** | + +**테스트 클래스 전체 (`UserHistoryIntegrationTest` = 6개 테스트):** + +| 시나리오 | 계산 | 충돌 확률 | +|----------|------|----------| +| 6개 테스트 중 1개 이상 실패 | `1 - (1 - 0.0056)^6` | **~3.3% (약 1/30)** | + +> CI가 약 30회 실행될 때마다 1번은 이 테스트가 실패하는 빈도다. +> `UserHistoryIntegrationTest` 외에도 `setupHistoryTestData()`나 `persistUser()`를 호출하는 다른 테스트 클래스까지 합산하면 체감 빈도는 더 높아진다. + +### 재현 조건 + +1. `UserHistoryIntegrationTest` 6개 테스트가 순차 실행 +2. `@AfterEach`에서 `DataInitializer.deleteAll()` (TRUNCATE) → 테스트 간 격리는 정상 +3. **한 테스트 내** `setupHistoryTestData()`가 8명의 유저를 생성할 때 suffix 충돌 발생 가능 +4. email 또는 nickName의 랜덤 suffix가 동일 → `SQLIntegrityConstraintViolationException` + +--- + +## 2. 영향 범위 + +### 수정 대상 파일 (9개) + +모든 파일이 `bottlenote-mono/src/test/java/app/bottlenote/` 하위에 위치한다. + +| # | 파일 | 위치 | unique 필드 충돌 위험 | +|---|------|------|---------------------| +| 1 | `UserTestFactory.java` | `user/fixture/` | email, nickName (직접 원인) | +| 2 | `AdminUserTestFactory.java` | `user/fixture/` | email, nickName | +| 3 | `AlcoholTestFactory.java` | `alcohols/fixture/` | Region/Distillery/Alcohol korName, engName | +| 4 | `AlcoholMetadataTestFactory.java` | `alcohols/fixture/` | Region, Distillery 이름 | +| 5 | `TastingTagTestFactory.java` | `alcohols/fixture/` | TastingTag 이름 | +| 6 | `RatingTestFactory.java` | `rating/fixture/` | 직접 위험 낮음 (suffix 내부용) | +| 7 | `HistoryTestFactory.java` | `history/fixture/` | 직접 위험 낮음 | +| 8 | `ReviewTestFactory.java` | `review/fixture/` | Review 내부 필드 | +| 9 | `LikesTestFactory.java` | `like/fixture/` | 직접 위험 낮음 | + +### 수정하지 않는 것 + +- `DataInitializer.java`: TRUNCATE 기반 cleanup은 정상 동작 중 +- `TestDataSetupHelper.java`: Factory를 호출하는 쪽이므로 변경 불필요 +- `IntegrationTestSupport.java`: `@AfterEach` cleanup 구조는 정상 + +--- + +## 3. 해결 방안 + +### 방안 비교 + +| 방안 | 유니크 보장 | 가독성 | 구현 복잡도 | 비고 | +|------|:----------:|:------:|:----------:|------| +| **A. `AtomicInteger` 카운터** | 보장 | 높음 | 낮음 | JVM 내 유일성 보장 | +| B. `UUID.randomUUID()` | 사실상 보장 | 낮음 (긴 문자열) | 낮음 | DB 필드 길이 초과 가능 | +| C. `System.nanoTime()` | 거의 보장 | 보통 | 낮음 | 동일 나노초 내 충돌 가능 | +| D. 범위 확대 (`nextInt(Integer.MAX_VALUE)`) | 거의 보장 | 높음 | 낮음 | 확률 감소만, 제거 아님 | + +### 선택: A. `AtomicInteger` 카운터 + +**이유**: JVM 내에서 유일성이 **확정적으로** 보장되며, 생성된 suffix가 짧고 예측 가능하여 디버깅에도 유리하다. + +### 변경 내용 + +```java +// Before (9개 파일 공통) +private final Random random = new SecureRandom(); + +private String generateRandomSuffix() { + return String.valueOf(random.nextInt(10000)); +} + +// After +private static final AtomicInteger counter = new AtomicInteger(0); + +private String generateRandomSuffix() { + return String.valueOf(counter.incrementAndGet()); +} +``` + +**핵심 포인트:** +- `static`: 동일 Factory 클래스의 모든 인스턴스에서 카운터 공유 +- `AtomicInteger`: 멀티스레드 환경에서도 안전 +- 각 Factory별 독립 카운터 → Factory 간 suffix 번호 독립 +- `Random` / `SecureRandom` 필드가 더 이상 필요 없으면 삭제 + +--- + +## 4. 구현 순서 + +### Phase 1: 수정 (mono 모듈만) + +| 순서 | 작업 | 파일 | +|------|------|------| +| 1 | `UserTestFactory` 수정 | `user/fixture/UserTestFactory.java` | +| 2 | `AdminUserTestFactory` 수정 | `user/fixture/AdminUserTestFactory.java` | +| 3 | `AlcoholTestFactory` 수정 | `alcohols/fixture/AlcoholTestFactory.java` | +| 4 | `AlcoholMetadataTestFactory` 수정 | `alcohols/fixture/AlcoholMetadataTestFactory.java` | +| 5 | `TastingTagTestFactory` 수정 | `alcohols/fixture/TastingTagTestFactory.java` | +| 6 | `RatingTestFactory` 수정 | `rating/fixture/RatingTestFactory.java` | +| 7 | `HistoryTestFactory` 수정 | `history/fixture/HistoryTestFactory.java` | +| 8 | `ReviewTestFactory` 수정 | `review/fixture/ReviewTestFactory.java` | +| 9 | `LikesTestFactory` 수정 | `like/fixture/LikesTestFactory.java` | + +> 각 파일에서: (1) `Random`/`SecureRandom` 필드 → `AtomicInteger counter` 교체, (2) `generateRandomSuffix()` 메서드 본문 변경, (3) 불필요한 `Random` import 제거 + `AtomicInteger` import 추가 + +### Phase 2: 검증 + +| 순서 | 검증 항목 | 명령어 | +|------|----------|--------| +| 1 | 컴파일 | `./gradlew :bottlenote-mono:compileTestJava` | +| 2 | 코드 포맷팅 | `./gradlew :bottlenote-mono:spotlessCheck` | +| 3 | 아키텍처 규칙 | `./gradlew check_rule_test` | +| 4 | 단위 테스트 | `./gradlew unit_test` | +| 5 | Product 통합 테스트 | `./gradlew integration_test` | +| 6 | Admin 통합 테스트 | `./gradlew admin_integration_test` | + +--- + +## 5. 참고 + +### 관련 문서 + +- `plan/complete/testfactory-improvement.md`: TestFactory 구조 개선 (2025-11-19 완료) + - 해당 계획의 코드 예시에서 `System.nanoTime()` 방식을 제안했으나 실제 적용은 미완 + +### 기존 TestFactory 철학과의 관계 + +이번 수정은 TestFactory 5가지 철학 중 **격리(Isolation)** 원칙 보강에 해당한다: +- 격리 원칙: "팩토리 메서드 밖에서는 엔티티가 완전히 영속화된 상태" +- suffix 충돌로 persist 자체가 실패하면 격리 원칙 이전에 생성 자체가 불가능 +- `AtomicInteger` 카운터로 생성 안정성을 확보하는 것이 선행 조건 diff --git "a/plan/complete/\354\226\264\353\223\234\353\257\274 \353\260\260\353\204\210 \352\270\260\353\212\245 \352\265\254\355\230\204.md" "b/plan/complete/\354\226\264\353\223\234\353\257\274 \353\260\260\353\204\210 \352\270\260\353\212\245 \352\265\254\355\230\204.md" new file mode 100644 index 000000000..836256d12 --- /dev/null +++ "b/plan/complete/\354\226\264\353\223\234\353\257\274 \353\260\260\353\204\210 \352\270\260\353\212\245 \352\265\254\355\230\204.md" @@ -0,0 +1,868 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2026-02-09 + +** Core Achievements ** +- 배너 Admin CRUD API 7개 엔드포인트 구현 (목록/상세/생성/수정/삭제/상태변경/정렬순서변경) +- 비즈니스 규칙 검증 (날짜 선후관계, 외부 URL 연관, HEX 색상, 이름 중복, sortOrder 리오더링) +- 통합 테스트 16개 시나리오, RestDocs 문서화 7개 API, AsciiDoc 문서 + enum 참조 포함 +- CI 4개 테스트 (unit, rule, integration, admin_integration) 전체 통과 + +** Key Components ** +- AdminBannerService.java: mono 모듈, CRUD + 검증 + sortOrder 리오더링 로직 +- AdminBannerController.kt: admin-api 모듈, 7개 엔드포인트 프레젠테이션 계층 +- CustomBannerRepositoryImpl.java: QueryDSL 기반 Admin 검색 (키워드, isActive, bannerType 필터) +- BannerExceptionCode.java: 5개 배너 도메인 예외 코드 + +** Key Files (19개) ** +- 수정 4개: Banner.java, BannerRepository.java, JpaBannerRepository.java, AdminResultResponse.java +- 신규 12개 (mono): DTO 7개, Service 1개, Exception 2개, Repository 2개 +- 신규 4개 (admin-api): Controller 1개, 테스트 헬퍼 1개, 통합 테스트 1개, RestDocs 테스트 1개 +- 문서 3개: banners.adoc, banner-type.adoc, text-position.adoc +================================================================================ +``` + +# 어드민 배너 CRUD 기능 구현 계획 + +## 개요 + +어드민 API에 배너 관리(CRUD) 기능을 추가한다. +기존 큐레이션(Curation) Admin 패턴을 그대로 따르며, product-api의 배너 조회 전용 구조를 admin용으로 확장한다. + +## 현재 상태 + +- **Banner 엔티티**: 구현 완료 (`Banner.java`, 16개 필드) +- **Product API**: 조회 전용 (`GET /api/v1/banners`) - `BannerQueryService`, `BannerQueryController` +- **BannerRepository**: `findById`, `findAllByIsActiveTrue` 2개 메서드만 존재 (조회 전용) +- **JpaBannerRepository**: `JpaRepository` + `BannerRepository` 상속, `@JpaRepositoryImpl` +- **BannerTestFactory**: 테스트용 엔티티 영속화 헬퍼 (mono 모듈, `@Component`) +- **Admin API**: 배너 관련 코드 없음 +- **Banner 엔티티 update 메서드**: 없음 (현재 Builder만 존재) + +## 비즈니스 규칙 및 검증 정책 + +### 필수 검증 (서비스 레벨) + +| 규칙 | 설명 | 예외 코드 | +|------|------|-----------| +| 날짜 선후관계 | `startDate > endDate`이면 거부 | `BANNER_INVALID_DATE_RANGE` | +| 외부 URL + targetUrl 연관 | `isExternalUrl = true`일 때 `targetUrl`은 필수 (null/빈값 불가) | `BANNER_TARGET_URL_REQUIRED` | +| HEX 색상 형식 | `nameFontColor`, `descriptionFontColor`는 `#` + 6자리 HEX 패턴 (`^#[0-9a-fA-F]{6}$`) | `BANNER_INVALID_HEX_COLOR` | +| sortOrder 양수 | 생성/수정 시 `sortOrder`는 0 이상 (`@Min(0)`) | Bean Validation | +| 이름 중복 | `existsByName` 체크 (하드 삭제 기준, 삭제된 배너와의 중복은 고려하지 않음) | `BANNER_DUPLICATE_NAME` | + +### 날짜 정책 + +| 케이스 | 처리 | +|--------|------| +| `startDate`만 있고 `endDate = null` | 허용 ("~부터 무기한 노출") | +| `endDate`만 있고 `startDate = null` | 허용 ("~까지만 노출") | +| 둘 다 `null` | 허용 (상시 노출, `isActive`로만 제어) | +| `endDate`가 이미 과거 | 저장은 허용하되 `isActive`를 `false`로 자동 조정 | +| `startDate > endDate` | 거부 (`BANNER_INVALID_DATE_RANGE`) | + +### sortOrder 정책 + +| 케이스 | 처리 | +|--------|------| +| 새 배너의 `sortOrder`가 기존 배너와 중복 | 중복 불가: 해당 순서 이후의 기존 배너들을 +1씩 리오더링 | +| 예시: [1,2,3,4] 에 3 삽입 | 기존 3->4, 4->5 → 결과: [1,2,3(new),4,5] | +| 정렬 순서 변경(PATCH) 시에도 동일 | 대상 제외 후 나머지 리오더링 | + +### URL 형식 검증 + +- `targetUrl`, `imageUrl`에 대한 URL 형식 검증은 수행하지 않음 +- RestDocs 문서에 "[주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다" 명시 + +### 삭제 정책 + +- 하드 삭제 (DB에서 물리 삭제, 큐레이션 패턴과 동일) + +## 엔드포인트 설계 + +context-path: `/admin/api/v1` + +| HTTP | URL | 설명 | 요청 | +|------|-----|------|------| +| GET | `/banners` | 배너 목록 조회 (페이징, 필터) | `@ModelAttribute AdminBannerSearchRequest` | +| GET | `/banners/{bannerId}` | 배너 단건 상세 조회 | `@PathVariable` | +| POST | `/banners` | 배너 생성 | `@RequestBody @Valid AdminBannerCreateRequest` | +| PUT | `/banners/{bannerId}` | 배너 수정 | `@RequestBody @Valid AdminBannerUpdateRequest` | +| DELETE | `/banners/{bannerId}` | 배너 삭제 | `@PathVariable` | +| PATCH | `/banners/{bannerId}/status` | 활성화 상태 변경 | `@RequestBody AdminBannerStatusRequest` | +| PATCH | `/banners/{bannerId}/sort-order` | 정렬 순서 변경 | `@RequestBody AdminBannerSortOrderRequest` | + +## 구현 파일 목록 + +### Phase 1: mono 모듈 - 도메인/리포지토리 확장 + +#### 1-1. Banner 엔티티 수정 메서드 추가 +- **파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/domain/Banner.java` +- **작업**: `update()`, `updateStatus()`, `updateSortOrder()` 메서드 추가 + +```java +public void update(String name, String nameFontColor, String descriptionA, String descriptionB, + String descriptionFontColor, String imageUrl, TextPosition textPosition, + Boolean isExternalUrl, String targetUrl, BannerType bannerType, + Integer sortOrder, LocalDateTime startDate, LocalDateTime endDate, Boolean isActive) { + this.name = name; + this.nameFontColor = nameFontColor; + this.descriptionA = descriptionA; + this.descriptionB = descriptionB; + this.descriptionFontColor = descriptionFontColor; + this.imageUrl = imageUrl; + this.textPosition = textPosition; + this.isExternalUrl = isExternalUrl; + this.targetUrl = targetUrl; + this.bannerType = bannerType; + this.sortOrder = sortOrder; + this.startDate = startDate; + this.endDate = endDate; + this.isActive = isActive; +} + +public void updateStatus(Boolean isActive) { + this.isActive = isActive; +} + +public void updateSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; +} +``` + +#### 1-2. BannerRepository에 Admin용 메서드 추가 +- **파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/domain/BannerRepository.java` +- **작업**: `save`, `delete`, `existsByName`, `searchForAdmin`, `findAllBySortOrderGreaterThanEqual` 메서드 추가 + +```java +Banner save(Banner banner); +void delete(Banner banner); +boolean existsByName(String name); +List findAllBySortOrderGreaterThanEqual(Integer sortOrder); +Page searchForAdmin(AdminBannerSearchRequest request, Pageable pageable); +``` + +#### 1-3. JpaBannerRepository에 메서드 추가 +- **파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/repository/JpaBannerRepository.java` +- **작업**: `CustomBannerRepository` 상속 추가, `existsByName`, `findAllBySortOrderGreaterThanEqual` 추가 +- `JpaRepository` 상속으로 `save`, `delete`는 자동 제공 + +```java +@JpaRepositoryImpl +public interface JpaBannerRepository extends BannerRepository, JpaRepository, CustomBannerRepository { + List findAllByIsActiveTrue(); + boolean existsByName(String name); + List findAllBySortOrderGreaterThanEqual(Integer sortOrder); +} +``` + +#### 1-4. CustomBannerRepository + Impl 생성 (QueryDSL) +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepository.java` +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepositoryImpl.java` +- **작업**: `searchForAdmin` QueryDSL 구현 (키워드 검색, isActive 필터, bannerType 필터, 페이징) + +```java +// CustomBannerRepository.java +public interface CustomBannerRepository { + Page searchForAdmin(AdminBannerSearchRequest request, Pageable pageable); +} + +// CustomBannerRepositoryImpl.java - QueryDSL 구현 +// 검색 조건: keyword(name LIKE), isActive, bannerType +// 정렬: sortOrder ASC, id DESC +// 페이징: offset 기반 +``` + +### Phase 2: mono 모듈 - DTO + +#### 2-1. 요청 DTO (record) +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerSearchRequest.java` + +```java +public record AdminBannerSearchRequest( + String keyword, // 배너명 검색 + Boolean isActive, // 활성화 상태 필터 (null: 전체) + BannerType bannerType, // 배너 유형 필터 (null: 전체) + Integer page, // 기본값 0 + Integer size // 기본값 20 +) { + public AdminBannerSearchRequest { + page = page != null ? page : 0; + size = size != null ? size : 20; + } +} +``` + +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerCreateRequest.java` + +```java +public record AdminBannerCreateRequest( + @NotBlank(message = "배너명은 필수입니다.") String name, + @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "HEX 색상 형식이 올바르지 않습니다.") + String nameFontColor, + @Size(max = 50, message = "배너 설명은 최대 50자까지 가능합니다.") String descriptionA, + @Size(max = 50, message = "배너 설명은 최대 50자까지 가능합니다.") String descriptionB, + @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "HEX 색상 형식이 올바르지 않습니다.") + String descriptionFontColor, + @NotBlank(message = "이미지 URL은 필수입니다.") String imageUrl, + TextPosition textPosition, + Boolean isExternalUrl, + String targetUrl, + @NotNull(message = "배너 유형은 필수입니다.") BannerType bannerType, + @Min(value = 0, message = "정렬 순서는 0 이상이어야 합니다.") Integer sortOrder, + LocalDateTime startDate, + LocalDateTime endDate +) { + @Builder + public AdminBannerCreateRequest { + nameFontColor = nameFontColor != null ? nameFontColor : "#ffffff"; + descriptionFontColor = descriptionFontColor != null ? descriptionFontColor : "#ffffff"; + textPosition = textPosition != null ? textPosition : TextPosition.RT; + isExternalUrl = isExternalUrl != null ? isExternalUrl : false; + sortOrder = sortOrder != null ? sortOrder : 0; + } +} +``` + +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerUpdateRequest.java` + +```java +public record AdminBannerUpdateRequest( + @NotBlank(message = "배너명은 필수입니다.") String name, + @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "HEX 색상 형식이 올바르지 않습니다.") + String nameFontColor, + @Size(max = 50, message = "배너 설명은 최대 50자까지 가능합니다.") String descriptionA, + @Size(max = 50, message = "배너 설명은 최대 50자까지 가능합니다.") String descriptionB, + @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "HEX 색상 형식이 올바르지 않습니다.") + String descriptionFontColor, + @NotBlank(message = "이미지 URL은 필수입니다.") String imageUrl, + TextPosition textPosition, + Boolean isExternalUrl, + String targetUrl, + @NotNull(message = "배너 유형은 필수입니다.") BannerType bannerType, + @NotNull(message = "정렬 순서는 필수입니다.") @Min(value = 0, message = "정렬 순서는 0 이상이어야 합니다.") Integer sortOrder, + LocalDateTime startDate, + LocalDateTime endDate, + @NotNull(message = "활성화 상태는 필수입니다.") Boolean isActive +) {} +``` + +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerStatusRequest.java` + +```java +public record AdminBannerStatusRequest( + @NotNull(message = "활성화 상태는 필수입니다.") Boolean isActive +) {} +``` + +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerSortOrderRequest.java` + +```java +public record AdminBannerSortOrderRequest( + @NotNull(message = "정렬 순서는 필수입니다.") @Min(value = 0, message = "정렬 순서는 0 이상이어야 합니다.") Integer sortOrder +) {} +``` + +#### 2-2. 응답 DTO (record) +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerListResponse.java` + +```java +public record AdminBannerListResponse( + Long id, + String name, + BannerType bannerType, + Integer sortOrder, + Boolean isActive, + LocalDateTime startDate, + LocalDateTime endDate, + LocalDateTime createdAt +) {} +``` + +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerDetailResponse.java` + +```java +public record AdminBannerDetailResponse( + Long id, + String name, + String nameFontColor, + String descriptionA, + String descriptionB, + String descriptionFontColor, + String imageUrl, + TextPosition textPosition, + Boolean isExternalUrl, + String targetUrl, + BannerType bannerType, + Integer sortOrder, + LocalDateTime startDate, + LocalDateTime endDate, + Boolean isActive, + LocalDateTime createdAt, + LocalDateTime modifiedAt +) { + public static AdminBannerDetailResponse of(Banner banner) { + return new AdminBannerDetailResponse( + banner.getId(), + banner.getName(), + banner.getNameFontColor(), + banner.getDescriptionA(), + banner.getDescriptionB(), + banner.getDescriptionFontColor(), + banner.getImageUrl(), + banner.getTextPosition(), + banner.getIsExternalUrl(), + banner.getTargetUrl(), + banner.getBannerType(), + banner.getSortOrder(), + banner.getStartDate(), + banner.getEndDate(), + banner.getIsActive(), + banner.getCreateAt(), + banner.getLastModifyAt() + ); + } +} +``` + +### Phase 3: mono 모듈 - 서비스 + 예외 + +#### 3-1. AdminBannerService 생성 +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/service/AdminBannerService.java` +- **참고 패턴**: `AdminCurationService.java` (동일 구조) + +```java +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminBannerService { + + private final BannerRepository bannerRepository; + + // 목록 조회 (페이징) + @Transactional(readOnly = true) + public GlobalResponse search(AdminBannerSearchRequest request) { + PageRequest pageable = PageRequest.of(request.page(), request.size()); + return GlobalResponse.fromPage(bannerRepository.searchForAdmin(request, pageable)); + } + + // 단건 상세 조회 + @Transactional(readOnly = true) + public AdminBannerDetailResponse getDetail(Long bannerId) { + Banner banner = bannerRepository.findById(bannerId) + .orElseThrow(() -> new BannerException(BANNER_NOT_FOUND)); + return AdminBannerDetailResponse.of(banner); + } + + // 생성 + @Transactional + public AdminResultResponse create(AdminBannerCreateRequest request) { + // 이름 중복 체크 + if (bannerRepository.existsByName(request.name())) { + throw new BannerException(BANNER_DUPLICATE_NAME); + } + + // 날짜 검증: startDate > endDate + validateDateRange(request.startDate(), request.endDate()); + + // 외부 URL 연관 검증: isExternalUrl=true 시 targetUrl 필수 + validateExternalUrl(request.isExternalUrl(), request.targetUrl()); + + // 만료 날짜 자동 비활성화: endDate가 과거이면 isActive=false + boolean isActive = determineActiveStatus(true, request.endDate()); + + // sortOrder 리오더링: 동일 순서 이후 배너들 +1 + reorderSortOrders(request.sortOrder(), null); + + Banner banner = Banner.builder() + .name(request.name()) + .nameFontColor(request.nameFontColor()) + .descriptionA(request.descriptionA()) + .descriptionB(request.descriptionB()) + .descriptionFontColor(request.descriptionFontColor()) + .imageUrl(request.imageUrl()) + .textPosition(request.textPosition()) + .isExternalUrl(request.isExternalUrl()) + .targetUrl(request.targetUrl()) + .bannerType(request.bannerType()) + .sortOrder(request.sortOrder()) + .startDate(request.startDate()) + .endDate(request.endDate()) + .isActive(isActive) + .build(); + + Banner saved = bannerRepository.save(banner); + return AdminResultResponse.of(BANNER_CREATED, saved.getId()); + } + + // 수정 + @Transactional + public AdminResultResponse update(Long bannerId, AdminBannerUpdateRequest request) { + Banner banner = bannerRepository.findById(bannerId) + .orElseThrow(() -> new BannerException(BANNER_NOT_FOUND)); + + validateDateRange(request.startDate(), request.endDate()); + validateExternalUrl(request.isExternalUrl(), request.targetUrl()); + + boolean isActive = determineActiveStatus(request.isActive(), request.endDate()); + + // sortOrder 변경 시 리오더링 (자신은 제외) + if (!banner.getSortOrder().equals(request.sortOrder())) { + reorderSortOrders(request.sortOrder(), banner.getId()); + } + + banner.update( + request.name(), request.nameFontColor(), + request.descriptionA(), request.descriptionB(), request.descriptionFontColor(), + request.imageUrl(), request.textPosition(), + request.isExternalUrl(), request.targetUrl(), + request.bannerType(), request.sortOrder(), + request.startDate(), request.endDate(), isActive + ); + + return AdminResultResponse.of(BANNER_UPDATED, bannerId); + } + + // 삭제 (하드 삭제) + @Transactional + public AdminResultResponse delete(Long bannerId) { + Banner banner = bannerRepository.findById(bannerId) + .orElseThrow(() -> new BannerException(BANNER_NOT_FOUND)); + bannerRepository.delete(banner); + return AdminResultResponse.of(BANNER_DELETED, bannerId); + } + + // 활성화 상태 변경 + @Transactional + public AdminResultResponse updateStatus(Long bannerId, AdminBannerStatusRequest request) { + Banner banner = bannerRepository.findById(bannerId) + .orElseThrow(() -> new BannerException(BANNER_NOT_FOUND)); + banner.updateStatus(request.isActive()); + return AdminResultResponse.of(BANNER_STATUS_UPDATED, bannerId); + } + + // 정렬 순서 변경 (리오더링 포함) + @Transactional + public AdminResultResponse updateSortOrder(Long bannerId, AdminBannerSortOrderRequest request) { + Banner banner = bannerRepository.findById(bannerId) + .orElseThrow(() -> new BannerException(BANNER_NOT_FOUND)); + + if (!banner.getSortOrder().equals(request.sortOrder())) { + reorderSortOrders(request.sortOrder(), banner.getId()); + } + + banner.updateSortOrder(request.sortOrder()); + return AdminResultResponse.of(BANNER_SORT_ORDER_UPDATED, bannerId); + } + + // -- private methods -- + + private void validateDateRange(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate != null && endDate != null && startDate.isAfter(endDate)) { + throw new BannerException(BANNER_INVALID_DATE_RANGE); + } + } + + private void validateExternalUrl(Boolean isExternalUrl, String targetUrl) { + if (Boolean.TRUE.equals(isExternalUrl) + && (targetUrl == null || targetUrl.isBlank())) { + throw new BannerException(BANNER_TARGET_URL_REQUIRED); + } + } + + private boolean determineActiveStatus(boolean requestedIsActive, LocalDateTime endDate) { + if (endDate != null && endDate.isBefore(LocalDateTime.now())) { + return false; + } + return requestedIsActive; + } + + private void reorderSortOrders(Integer newSortOrder, Long excludeBannerId) { + List conflicting = bannerRepository.findAllBySortOrderGreaterThanEqual(newSortOrder); + conflicting.stream() + .filter(b -> !b.getId().equals(excludeBannerId)) + .forEach(b -> b.updateSortOrder(b.getSortOrder() + 1)); + } +} +``` + +#### 3-2. BannerExceptionCode 생성 +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/exception/BannerExceptionCode.java` +- **참고 패턴**: `AlcoholExceptionCode.java` (동일 구조: `ExceptionCode` 인터페이스 구현) + +```java +public enum BannerExceptionCode implements ExceptionCode { + BANNER_NOT_FOUND(HttpStatus.NOT_FOUND, "배너를 찾을 수 없습니다."), + BANNER_DUPLICATE_NAME(HttpStatus.CONFLICT, "동일한 이름의 배너가 이미 존재합니다."), + BANNER_INVALID_DATE_RANGE(HttpStatus.BAD_REQUEST, "시작일은 종료일보다 이전이어야 합니다."), + BANNER_TARGET_URL_REQUIRED(HttpStatus.BAD_REQUEST, "외부 URL 사용 시 이동 URL은 필수입니다."), + BANNER_INVALID_HEX_COLOR(HttpStatus.BAD_REQUEST, "HEX 색상 형식이 올바르지 않습니다."); + + private final HttpStatus httpStatus; + private final String message; + + BannerExceptionCode(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } + + @Override + public String getMessage() { return message; } + + @Override + public HttpStatus getHttpStatus() { return httpStatus; } +} +``` + +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/exception/BannerException.java` +- **참고 패턴**: `AlcoholException.java` (동일 구조: `AbstractCustomException` 상속) + +```java +@Getter +public class BannerException extends AbstractCustomException { + public BannerException(BannerExceptionCode code) { + super(code); + } +} +``` + +#### 3-3. AdminResultResponse.ResultCode에 배너 코드 추가 +- **파일**: `bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java` +- **작업**: ResultCode enum에 추가 + +```java +BANNER_CREATED("배너가 등록되었습니다."), +BANNER_UPDATED("배너가 수정되었습니다."), +BANNER_DELETED("배너가 삭제되었습니다."), +BANNER_STATUS_UPDATED("배너 활성화 상태가 변경되었습니다."), +BANNER_SORT_ORDER_UPDATED("배너 정렬 순서가 변경되었습니다."), +``` + +### Phase 4: admin-api 모듈 - 컨트롤러 + +#### 4-1. AdminBannerController 생성 +- **신규 파일**: `bottlenote-admin-api/src/main/kotlin/app/bottlenote/banner/presentation/AdminBannerController.kt` +- **참고 패턴**: `AdminCurationController.kt` (동일 구조) + +```kotlin +@RestController +@RequestMapping("/banners") +class AdminBannerController( + private val adminBannerService: AdminBannerService +) { + @GetMapping + fun list(@ModelAttribute request: AdminBannerSearchRequest): ResponseEntity { + return ResponseEntity.ok(adminBannerService.search(request)) + } + + @GetMapping("/{bannerId}") + fun detail(@PathVariable bannerId: Long): ResponseEntity<*> { + return GlobalResponse.ok(adminBannerService.getDetail(bannerId)) + } + + @PostMapping + fun create(@RequestBody @Valid request: AdminBannerCreateRequest): ResponseEntity<*> { + return GlobalResponse.ok(adminBannerService.create(request)) + } + + @PutMapping("/{bannerId}") + fun update( + @PathVariable bannerId: Long, + @RequestBody @Valid request: AdminBannerUpdateRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(adminBannerService.update(bannerId, request)) + } + + @DeleteMapping("/{bannerId}") + fun delete(@PathVariable bannerId: Long): ResponseEntity<*> { + return GlobalResponse.ok(adminBannerService.delete(bannerId)) + } + + @PatchMapping("/{bannerId}/status") + fun updateStatus( + @PathVariable bannerId: Long, + @RequestBody @Valid request: AdminBannerStatusRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(adminBannerService.updateStatus(bannerId, request)) + } + + @PatchMapping("/{bannerId}/sort-order") + fun updateSortOrder( + @PathVariable bannerId: Long, + @RequestBody @Valid request: AdminBannerSortOrderRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(adminBannerService.updateSortOrder(bannerId, request)) + } +} +``` + +### Phase 5: 테스트 + +#### 5-1. 테스트 헬퍼 +- **신규 파일**: `bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt` +- **참고 패턴**: `CurationHelper.kt` (`object` 싱글톤, `Map` 반환) + +```kotlin +object BannerHelper { + + fun createAdminBannerListResponse( + id: Long = 1L, + name: String = "테스트 배너", + bannerType: BannerType = BannerType.CURATION, + sortOrder: Int = 0, + isActive: Boolean = true, + startDate: LocalDateTime? = null, + endDate: LocalDateTime? = null, + createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0) + ): AdminBannerListResponse = AdminBannerListResponse( + id, name, bannerType, sortOrder, isActive, startDate, endDate, createdAt + ) + + fun createAdminBannerListResponses(count: Int = 3): List = + (1..count).map { i -> + createAdminBannerListResponse( + id = i.toLong(), + name = "배너 $i", + sortOrder = i - 1, + createdAt = LocalDateTime.of(2024, i, 1, 0, 0) + ) + } + + fun createAdminBannerDetailResponse( + id: Long = 1L, + name: String = "테스트 배너", + nameFontColor: String = "#ffffff", + descriptionA: String? = "배너 설명A", + descriptionB: String? = "배너 설명B", + descriptionFontColor: String = "#ffffff", + imageUrl: String = "https://example.com/banner.jpg", + textPosition: TextPosition = TextPosition.RT, + isExternalUrl: Boolean = false, + targetUrl: String? = null, + bannerType: BannerType = BannerType.CURATION, + sortOrder: Int = 0, + startDate: LocalDateTime? = null, + endDate: LocalDateTime? = null, + isActive: Boolean = true, + createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0), + modifiedAt: LocalDateTime = LocalDateTime.of(2024, 6, 1, 0, 0) + ): AdminBannerDetailResponse = AdminBannerDetailResponse( + id, name, nameFontColor, descriptionA, descriptionB, descriptionFontColor, + imageUrl, textPosition, isExternalUrl, targetUrl, bannerType, sortOrder, + startDate, endDate, isActive, createdAt, modifiedAt + ) + + fun createBannerCreateRequest( + name: String = "새 배너", + nameFontColor: String = "#ffffff", + descriptionA: String? = "배너 설명A", + descriptionB: String? = "배너 설명B", + descriptionFontColor: String = "#ffffff", + imageUrl: String = "https://example.com/banner.jpg", + textPosition: String = "RT", + isExternalUrl: Boolean = false, + targetUrl: String? = null, + bannerType: String = "CURATION", + sortOrder: Int = 0, + startDate: String? = null, + endDate: String? = null + ): Map = mapOf( + "name" to name, + "nameFontColor" to nameFontColor, + "descriptionA" to descriptionA, + "descriptionB" to descriptionB, + "descriptionFontColor" to descriptionFontColor, + "imageUrl" to imageUrl, + "textPosition" to textPosition, + "isExternalUrl" to isExternalUrl, + "targetUrl" to targetUrl, + "bannerType" to bannerType, + "sortOrder" to sortOrder, + "startDate" to startDate, + "endDate" to endDate + ) + + fun createBannerUpdateRequest( + name: String = "수정된 배너", + nameFontColor: String = "#000000", + descriptionA: String? = "수정된 설명A", + descriptionB: String? = "수정된 설명B", + descriptionFontColor: String = "#000000", + imageUrl: String = "https://example.com/updated.jpg", + textPosition: String = "CENTER", + isExternalUrl: Boolean = false, + targetUrl: String? = null, + bannerType: String = "CURATION", + sortOrder: Int = 1, + startDate: String? = null, + endDate: String? = null, + isActive: Boolean = true + ): Map = mapOf( + "name" to name, + "nameFontColor" to nameFontColor, + "descriptionA" to descriptionA, + "descriptionB" to descriptionB, + "descriptionFontColor" to descriptionFontColor, + "imageUrl" to imageUrl, + "textPosition" to textPosition, + "isExternalUrl" to isExternalUrl, + "targetUrl" to targetUrl, + "bannerType" to bannerType, + "sortOrder" to sortOrder, + "startDate" to startDate, + "endDate" to endDate, + "isActive" to isActive + ) + + fun createBannerStatusRequest(isActive: Boolean = true): Map = + mapOf("isActive" to isActive) + + fun createBannerSortOrderRequest(sortOrder: Int = 1): Map = + mapOf("sortOrder" to sortOrder) + + fun createAdminResultResponse( + code: AdminResultResponse.ResultCode = AdminResultResponse.ResultCode.BANNER_CREATED, + targetId: Long = 1L + ): AdminResultResponse = AdminResultResponse.of(code, targetId) +} +``` + +#### 5-2. 통합 테스트 +- **신규 파일**: `bottlenote-admin-api/src/test/kotlin/app/integration/banner/AdminBannerIntegrationTest.kt` +- **참고 패턴**: `AdminCurationIntegrationTest.kt` +- **태그**: `@Tag("admin_integration")` +- **베이스**: `IntegrationTestSupport` 상속 +- **시나리오**: + +``` +목록 조회: + - 기본 목록 조회 + - 키워드 필터링 + - 활성화 상태 필터링 + - 배너 유형 필터링 + - 인증 없이 요청 시 401 + +상세 조회: + - 성공 + - 존재하지 않는 배너 404 + +생성: + - 성공 + - 이름 중복 409 + - 필수값 누락 400 + - startDate > endDate 400 + - isExternalUrl=true + targetUrl 누락 400 + - 잘못된 HEX 색상 400 + - endDate가 과거일 때 isActive 자동 false + - sortOrder 중복 시 리오더링 검증 + +수정: + - 성공 + - 존재하지 않는 배너 404 + - 날짜/URL/HEX 검증 동일 적용 + +삭제: + - 성공 + - 존재하지 않는 배너 404 + +상태 변경: + - 성공 + - 존재하지 않는 배너 404 + +정렬 순서 변경: + - 성공 + - 리오더링 검증 + +인증: + - 인증 없이 요청 시 실패 +``` + +#### 5-3. RestDocs 테스트 +- **신규 파일**: `bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt` +- **참고 패턴**: `AdminCurationControllerDocsTest.kt` +- **설정**: `@WebMvcTest`, `@AutoConfigureRestDocs`, `@MockitoBean` +- **문서화**: 각 API별 요청/응답 필드 문서화 +- **특이사항**: `targetUrl`, `imageUrl` 필드 description에 "[주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다" 명시 + +## 파일 변경 요약 + +### 수정 파일 (4개) +| 파일 | 작업 | +|------|------| +| `Banner.java` | update, updateStatus, updateSortOrder 메서드 추가 | +| `BannerRepository.java` | save, delete, existsByName, findAllBySortOrderGreaterThanEqual, searchForAdmin 메서드 추가 | +| `JpaBannerRepository.java` | CustomBannerRepository 상속 추가, existsByName, findAllBySortOrderGreaterThanEqual 추가 | +| `AdminResultResponse.java` | ResultCode에 BANNER_* 5개 코드 추가 | + +### 신규 파일 (15개) + +**mono 모듈 (12개)**: +| 파일 | 위치 | +|------|------| +| `CustomBannerRepository.java` | banner/repository/ | +| `CustomBannerRepositoryImpl.java` | banner/repository/ | +| `AdminBannerSearchRequest.java` | banner/dto/request/ | +| `AdminBannerCreateRequest.java` | banner/dto/request/ | +| `AdminBannerUpdateRequest.java` | banner/dto/request/ | +| `AdminBannerStatusRequest.java` | banner/dto/request/ | +| `AdminBannerSortOrderRequest.java` | banner/dto/request/ | +| `AdminBannerListResponse.java` | banner/dto/response/ | +| `AdminBannerDetailResponse.java` | banner/dto/response/ | +| `AdminBannerService.java` | banner/service/ | +| `BannerExceptionCode.java` | banner/exception/ | +| `BannerException.java` | banner/exception/ | + +**admin-api 모듈 (3개)**: +| 파일 | 위치 | +|------|------| +| `AdminBannerController.kt` | banner/presentation/ | +| `AdminBannerIntegrationTest.kt` | integration/banner/ | +| `AdminBannerControllerDocsTest.kt` | docs/banner/ | + +**admin-api 테스트 헬퍼 (1개)**: +| 파일 | 위치 | +|------|------| +| `BannerHelper.kt` | helper/banner/ | + +## 구현 순서 + +``` +Phase 1 (도메인/리포지토리) -> Phase 2 (DTO) -> Phase 3 (서비스/예외) -> Phase 4 (컨트롤러) -> Phase 5 (테스트) +``` + +각 Phase 완료 후 컴파일 확인: +- Phase 1-3: `./gradlew :bottlenote-mono:compileJava` +- Phase 4: `./gradlew :bottlenote-admin-api:compileKotlin` +- Phase 5: `./gradlew :bottlenote-admin-api:admin_integration_test` + +## 참고 패턴 + +### 기존 어드민 구현체 참조 +| 구현체 | 위치 | 참고 포인트 | +|--------|------|------------| +| `AdminCurationController.kt` | admin-api | 컨트롤러 구조, 응답 래핑 방식 | +| `AdminCurationService.java` | mono | 서비스 CRUD 패턴, 예외 처리, 트랜잭션 | +| `AdminCurationCreateRequest.java` | mono | record compact constructor 기본값 설정 | +| `AdminCurationDisplayOrderRequest.java` | mono | `@NotNull` + `@Min(0)` 검증 패턴 | +| `AlcoholExceptionCode.java` | mono | `ExceptionCode` 인터페이스 구현 패턴 | +| `AlcoholException.java` | mono | `AbstractCustomException` 상속 패턴 | +| `CurationHelper.kt` | admin-api test | `object` 싱글톤 헬퍼, `Map` 반환 | +| `AdminCurationIntegrationTest.kt` | admin-api test | 통합 테스트 구조, `@Nested`, `mockMvcTester` | +| `AdminCurationControllerDocsTest.kt` | admin-api test | RestDocs 문서화 패턴 | +| `BannerTestFactory.java` | mono test | 테스트 데이터 영속화 헬퍼 (`@Component`) | + +### 예외 계층 구조 +``` +RuntimeException + -> AbstractCustomException (ExceptionCode 보유) + -> AlcoholException (AlcoholExceptionCode) + -> BannerException (BannerExceptionCode) <-- 신규 +``` + +### 응답 패턴 +- 목록 조회: `ResponseEntity.ok(adminBannerService.search(request))` -> `GlobalResponse.fromPage()` +- 단건 조회: `GlobalResponse.ok(response)` +- CUD 작업: `GlobalResponse.ok(AdminResultResponse.of(ResultCode, targetId))`