From d22fcb7854ac7d88043ae7a67f61d2d9f8f09ce1 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 6 Feb 2026 23:50:20 +0900 Subject: [PATCH 01/27] =?UTF-8?q?[refactor]=20=ED=81=90=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=83=81=EC=84=B8=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=EC=84=9C=20alcoholIds=EB=A5=BC=20alcohols=20=EA=B0=9D=EC=B2=B4?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminCurationDetailResponse: Set alcoholIds -> List alcohols - AdminCurationService: AlcoholQueryRepository 주입 및 인라인 변환 로직 추가 - CurationHelper: 테스트 헬퍼 응답 형식 변경 - AdminCurationControllerDocsTest: RestDocs 필드 문서화 업데이트 Co-Authored-By: Claude Opus 4.5 --- .../AdminCurationControllerDocsTest.kt | 10 ++++++++- .../app/helper/curation/CurationHelper.kt | 6 +++-- .../response/AdminCurationDetailResponse.java | 10 ++++----- .../service/AdminCurationService.java | 22 ++++++++++++++++++- 4 files changed, 39 insertions(+), 9 deletions(-) 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/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-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()); } From 1b2ed77c3b9df0318cd88e18aa67b9b7dcc9dda7 Mon Sep 17 00:00:00 2001 From: hgkim Date: Sat, 7 Feb 2026 00:22:23 +0900 Subject: [PATCH 02/27] =?UTF-8?q?chore:=20pre-commit=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 22 ---------------------- git.environment-variables | 2 +- 2 files changed, 1 insertion(+), 23 deletions(-) delete mode 100644 .pre-commit-config.yaml 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/git.environment-variables b/git.environment-variables index ad7d23c90..93295092b 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit ad7d23c9051f40cffea32f6a404adc639e095e3a +Subproject commit 93295092bae016b86c8acd835f4a23e397a79f6f From 7b5672fe392d62720da8bdf88b189f2bdc154cb1 Mon Sep 17 00:00:00 2001 From: hgkim Date: Sat, 7 Feb 2026 00:36:02 +0900 Subject: [PATCH 03/27] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88=20CRUD=20=EA=B8=B0=EB=8A=A5=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EB=B0=8F=20=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0\353\212\245 \352\265\254\355\230\204.md" | 401 ++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 "plan/\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" diff --git "a/plan/\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/\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..744e6449c --- /dev/null +++ "b/plan/\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,401 @@ +# 어드민 배너 CRUD 기능 구현 계획 + +## 개요 + +어드민 API에 배너 관리(CRUD) 기능을 추가한다. +기존 큐레이션(Curation) Admin 패턴을 그대로 따르며, product-api의 배너 조회 전용 구조를 admin용으로 확장한다. + +## 현재 상태 + +- **Banner 엔티티**: 구현 완료 (`Banner.java`, 16개 필드) +- **Product API**: 조회 전용 (`GET /api/v1/banners`) - `BannerQueryService`, `BannerQueryController` +- **BannerRepository**: `findById`, `findAllByIsActiveTrue` 2개 메서드만 존재 (조회 전용) +- **Admin API**: 배너 관련 코드 없음 +- **Banner 엔티티 update 메서드**: 없음 (현재 Builder만 존재) + +## 엔드포인트 설계 + +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` 메서드 추가 + +```java +Banner save(Banner banner); +void delete(Banner banner); +boolean existsByName(String name); +Page searchForAdmin(AdminBannerSearchRequest request, Pageable pageable); +``` + +#### 1-3. JpaBannerRepository에 메서드 추가 +- **파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/repository/JpaBannerRepository.java` +- **작업**: `JpaRepository`이 이미 상속하므로 `save`, `delete`는 자동 제공. `existsByName` 추가. + +#### 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 +) {} +``` + +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerCreateRequest.java` + +```java +public record AdminBannerCreateRequest( + @NotBlank String name, + String nameFontColor, // 기본값 "#ffffff" + @Size(max = 50) String descriptionA, + @Size(max = 50) String descriptionB, + String descriptionFontColor, // 기본값 "#ffffff" + @NotBlank String imageUrl, + TextPosition textPosition, // 기본값 RT + Boolean isExternalUrl, // 기본값 false + String targetUrl, + @NotNull BannerType bannerType, + Integer sortOrder, // 기본값 0 + LocalDateTime startDate, + LocalDateTime endDate +) {} +``` + +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerUpdateRequest.java` + +```java +public record AdminBannerUpdateRequest( + @NotBlank String name, + String nameFontColor, + @Size(max = 50) String descriptionA, + @Size(max = 50) String descriptionB, + String descriptionFontColor, + @NotBlank String imageUrl, + TextPosition textPosition, + Boolean isExternalUrl, + String targetUrl, + @NotNull BannerType bannerType, + @NotNull Integer sortOrder, + LocalDateTime startDate, + LocalDateTime endDate, + @NotNull Boolean isActive +) {} +``` + +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerStatusRequest.java` + +```java +public record AdminBannerStatusRequest( + @NotNull Boolean isActive +) {} +``` + +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerSortOrderRequest.java` + +```java +public record AdminBannerSortOrderRequest( + @NotNull @Min(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) { ... } +} +``` + +### Phase 3: mono 모듈 - 서비스 + 예외 + +#### 3-1. AdminBannerService 생성 +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/service/AdminBannerService.java` + +```java +@Service +@RequiredArgsConstructor +public class AdminBannerService { + + private final BannerRepository bannerRepository; + + // 목록 조회 (페이징) + @Transactional(readOnly = true) + public GlobalResponse search(AdminBannerSearchRequest request) { ... } + + // 단건 상세 조회 + @Transactional(readOnly = true) + public AdminBannerDetailResponse getDetail(Long bannerId) { ... } + + // 생성 + @Transactional + public AdminResultResponse create(AdminBannerCreateRequest request) { ... } + + // 수정 + @Transactional + public AdminResultResponse update(Long bannerId, AdminBannerUpdateRequest request) { ... } + + // 삭제 + @Transactional + public AdminResultResponse delete(Long bannerId) { ... } + + // 활성화 상태 변경 + @Transactional + public AdminResultResponse updateStatus(Long bannerId, AdminBannerStatusRequest request) { ... } + + // 정렬 순서 변경 + @Transactional + public AdminResultResponse updateSortOrder(Long bannerId, AdminBannerSortOrderRequest request) { ... } +} +``` + +#### 3-2. BannerExceptionCode 생성 +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/exception/BannerExceptionCode.java` + +```java +public enum BannerExceptionCode implements ExceptionCode { + BANNER_NOT_FOUND(HttpStatus.NOT_FOUND, "배너를 찾을 수 없습니다."), + BANNER_DUPLICATE_NAME(HttpStatus.CONFLICT, "동일한 이름의 배너가 이미 존재합니다."); +} +``` + +- **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/exception/BannerException.java` + +```java +public class BannerException extends CustomException { + 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` + +```kotlin +@RestController +@RequestMapping("/banners") +class AdminBannerController( + private val adminBannerService: AdminBannerService +) { + @GetMapping + fun list(@ModelAttribute request: AdminBannerSearchRequest): ResponseEntity { ... } + + @GetMapping("/{bannerId}") + fun detail(@PathVariable bannerId: Long): ResponseEntity<*> { ... } + + @PostMapping + fun create(@RequestBody @Valid request: AdminBannerCreateRequest): ResponseEntity<*> { ... } + + @PutMapping("/{bannerId}") + fun update(@PathVariable bannerId: Long, @RequestBody @Valid request: AdminBannerUpdateRequest): ResponseEntity<*> { ... } + + @DeleteMapping("/{bannerId}") + fun delete(@PathVariable bannerId: Long): ResponseEntity<*> { ... } + + @PatchMapping("/{bannerId}/status") + fun updateStatus(@PathVariable bannerId: Long, @RequestBody @Valid request: AdminBannerStatusRequest): ResponseEntity<*> { ... } + + @PatchMapping("/{bannerId}/sort-order") + fun updateSortOrder(@PathVariable bannerId: Long, @RequestBody @Valid request: AdminBannerSortOrderRequest): ResponseEntity<*> { ... } +} +``` + +### Phase 5: 테스트 + +#### 5-1. 테스트 헬퍼 +- **신규 파일**: `bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt` +- **역할**: 테스트용 요청/응답 데이터 생성 (`object` 싱글톤) + +#### 5-2. 통합 테스트 +- **신규 파일**: `bottlenote-admin-api/src/test/kotlin/app/integration/banner/AdminBannerIntegrationTest.kt` +- **태그**: `@Tag("admin_integration")` +- **베이스**: `IntegrationTestSupport` 상속 +- **시나리오**: + - 목록 조회 (기본, 키워드 필터, 활성화 필터, bannerType 필터) + - 상세 조회 (성공, 404) + - 생성 (성공, 필수값 누락 검증) + - 수정 (성공, 404) + - 삭제 (성공, 404) + - 상태 변경 (성공) + - 정렬 순서 변경 (성공) + - 인증 실패 테스트 + +#### 5-3. RestDocs 테스트 +- **신규 파일**: `bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt` +- **설정**: `@WebMvcTest`, `@AutoConfigureRestDocs`, `@MockitoBean` +- **문서화**: 각 API별 요청/응답 필드 문서화 + +## 파일 변경 요약 + +### 수정 파일 (4개) +| 파일 | 작업 | +|------|------| +| `Banner.java` | update, updateStatus, updateSortOrder 메서드 추가 | +| `BannerRepository.java` | save, delete, existsByName, searchForAdmin 메서드 추가 | +| `JpaBannerRepository.java` | CustomBannerRepository 상속 추가, existsByName 추가 | +| `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` + +## 참고 패턴 + +- 큐레이션 Admin CRUD (`AdminCurationController.kt` + `AdminCurationService.java`) 패턴과 동일한 구조 +- 응답 래핑: `GlobalResponse.ok()`, `GlobalResponse.fromPage()` +- 결과 응답: `AdminResultResponse.of(ResultCode, targetId)` +- 예외 처리: 도메인 전용 Exception + ExceptionCode enum From 067813261652d583b3054be08a0f3c5ef72fbe57 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 9 Feb 2026 10:39:27 +0900 Subject: [PATCH 04/27] =?UTF-8?q?feat:=20=EB=B0=B0=EB=84=88=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20Admin=EC=9A=A9?= =?UTF-8?q?=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Banner 엔티티에 update, updateStatus, updateSortOrder 메서드 추가 - BannerRepository에 save, delete, existsByName 등 Admin CRUD 메서드 추가 - CustomBannerRepository + QueryDSL 구현체 생성 (searchForAdmin) - JpaBannerRepository에 CustomBannerRepository 상속 및 메서드 추가 - InMemoryBannerRepository 테스트 픽스처 동기화 Co-Authored-By: Claude Opus 4.6 --- .../app/bottlenote/banner/domain/Banner.java | 39 ++++++++++ .../banner/domain/BannerRepository.java | 19 +++-- .../repository/CustomBannerRepository.java | 11 +++ .../CustomBannerRepositoryImpl.java | 75 +++++++++++++++++++ .../repository/JpaBannerRepository.java | 7 +- .../fixture/InMemoryBannerRepository.java | 59 ++++++++++++++- 6 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepository.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepositoryImpl.java 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/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/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"); From 251e13782486a6653b2a53293c77ce24afe54d77 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 9 Feb 2026 10:40:36 +0900 Subject: [PATCH 05/27] =?UTF-8?q?feat:=20=EB=B0=B0=EB=84=88=20Admin=20API?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD/=EC=9D=91=EB=8B=B5=20DTO=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminBannerSearchRequest: 목록 조회용 (keyword, isActive, bannerType 필터) - AdminBannerCreateRequest: 생성용 (Bean Validation 포함) - AdminBannerUpdateRequest: 수정용 - AdminBannerStatusRequest: 활성화 상태 변경용 - AdminBannerSortOrderRequest: 정렬 순서 변경용 - AdminBannerListResponse: 목록 조회 응답 - AdminBannerDetailResponse: 상세 조회 응답 Co-Authored-By: Claude Opus 4.6 --- .../dto/request/AdminBannerCreateRequest.java | 37 +++++++++++++++++++ .../dto/request/AdminBannerSearchRequest.java | 12 ++++++ .../request/AdminBannerSortOrderRequest.java | 8 ++++ .../dto/request/AdminBannerStatusRequest.java | 5 +++ .../dto/request/AdminBannerUpdateRequest.java | 28 ++++++++++++++ .../response/AdminBannerDetailResponse.java | 24 ++++++++++++ .../dto/response/AdminBannerListResponse.java | 14 +++++++ 7 files changed, 128 insertions(+) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerCreateRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerSearchRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerSortOrderRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerStatusRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerUpdateRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerDetailResponse.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerListResponse.java 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) {} From 8fbd6536da021d2239a7322e7926f63f4150a43a Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 9 Feb 2026 10:41:00 +0900 Subject: [PATCH 06/27] =?UTF-8?q?feat:=20=EB=B0=B0=EB=84=88=20Admin=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminBannerService: CRUD + 상태변경 + 정렬순서 변경 + sortOrder 리오더링 - BannerException, BannerExceptionCode: 배너 도메인 예외 계층 추가 - AdminResultResponse.ResultCode에 BANNER_* 5개 코드 추가 Co-Authored-By: Claude Opus 4.6 --- .../banner/exception/BannerException.java | 12 ++ .../banner/exception/BannerExceptionCode.java | 29 +++ .../banner/service/AdminBannerService.java | 204 ++++++++++++++++++ .../dto/response/AdminResultResponse.java | 5 + 4 files changed, 250 insertions(+) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/banner/exception/BannerException.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/banner/exception/BannerExceptionCode.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/banner/service/AdminBannerService.java 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/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; From b371adc1b7efec0841c8bfefb96f38aa5451c29c Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 9 Feb 2026 10:41:17 +0900 Subject: [PATCH 07/27] =?UTF-8?q?docs:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88=20CRUD=20=EA=B5=AC=ED=98=84=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=20=EC=83=81=EC=84=B8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 1~5 단계별 구현 파일 목록 및 코드 명세 추가 - 비즈니스 규칙, 검증 정책, sortOrder 리오더링 정책 정의 - 테스트 시나리오 및 참고 패턴 문서화 Co-Authored-By: Claude Opus 4.6 --- ...0\353\212\245 \352\265\254\355\230\204.md" | 560 ++++++++++++++++-- 1 file changed, 500 insertions(+), 60 deletions(-) diff --git "a/plan/\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/\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" index 744e6449c..e99a42e7e 100644 --- "a/plan/\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/\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" @@ -10,9 +10,50 @@ - **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` @@ -67,18 +108,29 @@ public void updateSortOrder(Integer sortOrder) { #### 1-2. BannerRepository에 Admin용 메서드 추가 - **파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/domain/BannerRepository.java` -- **작업**: `save`, `delete`, `existsByName` 메서드 추가 +- **작업**: `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` -- **작업**: `JpaRepository`이 이미 상속하므로 `save`, `delete`는 자동 제공. `existsByName` 추가. +- **작업**: `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` @@ -109,47 +161,65 @@ public record AdminBannerSearchRequest( 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 String name, - String nameFontColor, // 기본값 "#ffffff" - @Size(max = 50) String descriptionA, - @Size(max = 50) String descriptionB, - String descriptionFontColor, // 기본값 "#ffffff" - @NotBlank String imageUrl, - TextPosition textPosition, // 기본값 RT - Boolean isExternalUrl, // 기본값 false + @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 BannerType bannerType, - Integer sortOrder, // 기본값 0 + @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 String name, + @NotBlank(message = "배너명은 필수입니다.") String name, + @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "HEX 색상 형식이 올바르지 않습니다.") String nameFontColor, - @Size(max = 50) String descriptionA, - @Size(max = 50) String descriptionB, + @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 String imageUrl, + @NotBlank(message = "이미지 URL은 필수입니다.") String imageUrl, TextPosition textPosition, Boolean isExternalUrl, String targetUrl, - @NotNull BannerType bannerType, - @NotNull Integer sortOrder, + @NotNull(message = "배너 유형은 필수입니다.") BannerType bannerType, + @NotNull(message = "정렬 순서는 필수입니다.") @Min(value = 0, message = "정렬 순서는 0 이상이어야 합니다.") Integer sortOrder, LocalDateTime startDate, LocalDateTime endDate, - @NotNull Boolean isActive + @NotNull(message = "활성화 상태는 필수입니다.") Boolean isActive ) {} ``` @@ -157,7 +227,7 @@ public record AdminBannerUpdateRequest( ```java public record AdminBannerStatusRequest( - @NotNull Boolean isActive + @NotNull(message = "활성화 상태는 필수입니다.") Boolean isActive ) {} ``` @@ -165,7 +235,7 @@ public record AdminBannerStatusRequest( ```java public record AdminBannerSortOrderRequest( - @NotNull @Min(0) Integer sortOrder + @NotNull(message = "정렬 순서는 필수입니다.") @Min(value = 0, message = "정렬 순서는 0 이상이어야 합니다.") Integer sortOrder ) {} ``` @@ -207,7 +277,27 @@ public record AdminBannerDetailResponse( LocalDateTime createdAt, LocalDateTime modifiedAt ) { - public static AdminBannerDetailResponse of(Banner banner) { ... } + 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() + ); + } } ``` @@ -215,8 +305,10 @@ public record AdminBannerDetailResponse( #### 3-1. AdminBannerService 생성 - **신규 파일**: `bottlenote-mono/src/main/java/app/bottlenote/banner/service/AdminBannerService.java` +- **참고 패턴**: `AdminCurationService.java` (동일 구조) ```java +@Slf4j @Service @RequiredArgsConstructor public class AdminBannerService { @@ -225,49 +317,188 @@ public class AdminBannerService { // 목록 조회 (페이징) @Transactional(readOnly = true) - public GlobalResponse search(AdminBannerSearchRequest request) { ... } + 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) { ... } + 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) { ... } + 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) { ... } + 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) { ... } + 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) { ... } - - // 정렬 순서 변경 + 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) { ... } + 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_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 -public class BannerException extends CustomException { - public BannerException(BannerExceptionCode code) { super(code); } +@Getter +public class BannerException extends AbstractCustomException { + public BannerException(BannerExceptionCode code) { + super(code); + } } ``` @@ -287,6 +518,7 @@ BANNER_SORT_ORDER_UPDATED("배너 정렬 순서가 변경되었습니다."), #### 4-1. AdminBannerController 생성 - **신규 파일**: `bottlenote-admin-api/src/main/kotlin/app/bottlenote/banner/presentation/AdminBannerController.kt` +- **참고 패턴**: `AdminCurationController.kt` (동일 구조) ```kotlin @RestController @@ -295,25 +527,48 @@ class AdminBannerController( private val adminBannerService: AdminBannerService ) { @GetMapping - fun list(@ModelAttribute request: AdminBannerSearchRequest): ResponseEntity { ... } + fun list(@ModelAttribute request: AdminBannerSearchRequest): ResponseEntity { + return ResponseEntity.ok(adminBannerService.search(request)) + } @GetMapping("/{bannerId}") - fun detail(@PathVariable bannerId: Long): ResponseEntity<*> { ... } + fun detail(@PathVariable bannerId: Long): ResponseEntity<*> { + return GlobalResponse.ok(adminBannerService.getDetail(bannerId)) + } @PostMapping - fun create(@RequestBody @Valid request: AdminBannerCreateRequest): ResponseEntity<*> { ... } + 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<*> { ... } + 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<*> { ... } + 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<*> { ... } + 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<*> { ... } + fun updateSortOrder( + @PathVariable bannerId: Long, + @RequestBody @Valid request: AdminBannerSortOrderRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(adminBannerService.updateSortOrder(bannerId, request)) + } } ``` @@ -321,26 +576,189 @@ class AdminBannerController( #### 5-1. 테스트 헬퍼 - **신규 파일**: `bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt` -- **역할**: 테스트용 요청/응답 데이터 생성 (`object` 싱글톤) +- **참고 패턴**: `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` 상속 - **시나리오**: - - 목록 조회 (기본, 키워드 필터, 활성화 필터, bannerType 필터) - - 상세 조회 (성공, 404) - - 생성 (성공, 필수값 누락 검증) - - 수정 (성공, 404) - - 삭제 (성공, 404) - - 상태 변경 (성공) - - 정렬 순서 변경 (성공) - - 인증 실패 테스트 + +``` +목록 조회: + - 기본 목록 조회 + - 키워드 필터링 + - 활성화 상태 필터링 + - 배너 유형 필터링 + - 인증 없이 요청 시 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을 전달해야 합니다" 명시 ## 파일 변경 요약 @@ -348,8 +766,8 @@ class AdminBannerController( | 파일 | 작업 | |------|------| | `Banner.java` | update, updateStatus, updateSortOrder 메서드 추가 | -| `BannerRepository.java` | save, delete, existsByName, searchForAdmin 메서드 추가 | -| `JpaBannerRepository.java` | CustomBannerRepository 상속 추가, existsByName 추가 | +| `BannerRepository.java` | save, delete, existsByName, findAllBySortOrderGreaterThanEqual, searchForAdmin 메서드 추가 | +| `JpaBannerRepository.java` | CustomBannerRepository 상속 추가, existsByName, findAllBySortOrderGreaterThanEqual 추가 | | `AdminResultResponse.java` | ResultCode에 BANNER_* 5개 코드 추가 | ### 신규 파일 (15개) @@ -385,7 +803,7 @@ class AdminBannerController( ## 구현 순서 ``` -Phase 1 (도메인/리포지토리) → Phase 2 (DTO) → Phase 3 (서비스/예외) → Phase 4 (컨트롤러) → Phase 5 (테스트) +Phase 1 (도메인/리포지토리) -> Phase 2 (DTO) -> Phase 3 (서비스/예외) -> Phase 4 (컨트롤러) -> Phase 5 (테스트) ``` 각 Phase 완료 후 컴파일 확인: @@ -395,7 +813,29 @@ Phase 1 (도메인/리포지토리) → Phase 2 (DTO) → Phase 3 (서비스/예 ## 참고 패턴 -- 큐레이션 Admin CRUD (`AdminCurationController.kt` + `AdminCurationService.java`) 패턴과 동일한 구조 -- 응답 래핑: `GlobalResponse.ok()`, `GlobalResponse.fromPage()` -- 결과 응답: `AdminResultResponse.of(ResultCode, targetId)` -- 예외 처리: 도메인 전용 Exception + ExceptionCode enum +### 기존 어드민 구현체 참조 +| 구현체 | 위치 | 참고 포인트 | +|--------|------|------------| +| `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))` From abf97c289dde6b413a3fa5d3905d6bb0bc641fc9 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 9 Feb 2026 10:44:02 +0900 Subject: [PATCH 08/27] deps: version update --- git.environment-variables | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git.environment-variables b/git.environment-variables index 93295092b..602b9b530 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 93295092bae016b86c8acd835f4a23e397a79f6f +Subproject commit 602b9b53057e712d067ba8a2a8c9487fc09076ba From 307ac9101c245b9f3f077afca1b39e45d015d9d1 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 9 Feb 2026 11:07:42 +0900 Subject: [PATCH 09/27] =?UTF-8?q?feat:=20=EB=B0=B0=EB=84=88=20Admin=20API?= =?UTF-8?q?=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminBannerController: 7개 엔드포인트 (목록/상세/생성/수정/삭제/상태변경/정렬순서변경) - 기존 큐레이션 컨트롤러 패턴과 동일한 구조 Co-Authored-By: Claude Opus 4.6 --- .../presentation/AdminBannerController.kt | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/banner/presentation/AdminBannerController.kt 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)) + } +} From c9c21623e2c2a78457c1876fbcef4c697440869d Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 9 Feb 2026 11:07:53 +0900 Subject: [PATCH 10/27] =?UTF-8?q?test:=20=EB=B0=B0=EB=84=88=20Admin=20API?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B0=8F?= =?UTF-8?q?=20RestDocs=20=EB=AC=B8=EC=84=9C=ED=99=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BannerHelper: 테스트 데이터 생성 헬퍼 (object 싱글톤) - AdminBannerIntegrationTest: 통합 테스트 16개 시나리오 (CRUD, 검증, 인증) - AdminBannerControllerDocsTest: RestDocs 문서화 테스트 7개 API Co-Authored-By: Claude Opus 4.6 --- .../banner/AdminBannerControllerDocsTest.kt | 441 ++++++++++++++++++ .../kotlin/app/helper/banner/BannerHelper.kt | 131 ++++++ .../banner/AdminBannerIntegrationTest.kt | 425 +++++++++++++++++ 3 files changed, 997 insertions(+) create mode 100644 bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt create mode 100644 bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt create mode 100644 bottlenote-admin-api/src/test/kotlin/app/integration/banner/AdminBannerIntegrationTest.kt 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/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/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() + } + } +} From b922267103f408a5c8f45a664111b08e98a7d76f Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 9 Feb 2026 11:13:41 +0900 Subject: [PATCH 11/27] =?UTF-8?q?docs:=20=EB=B0=B0=EB=84=88=20Admin=20API?= =?UTF-8?q?=20RestDocs=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - banners.adoc: 7개 API 엔드포인트 문서화 - admin-api.adoc: Banner API 섹션 include 추가 Co-Authored-By: Claude Opus 4.6 --- .../src/docs/asciidoc/admin-api.adoc | 6 + .../asciidoc/api/admin-banners/banners.adoc | 202 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 bottlenote-admin-api/src/docs/asciidoc/api/admin-banners/banners.adoc 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..4ac64137c --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-banners/banners.adoc @@ -0,0 +1,202 @@ +=== 배너 목록 조회 === + +- 배너 목록을 페이지네이션으로 조회합니다. +- 키워드, 활성화 상태, 배너 유형으로 필터링이 가능합니다. + +[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[] From b80f60345dd9add72ba62c031a388cb6409bfba6 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 9 Feb 2026 11:44:26 +0900 Subject: [PATCH 12/27] =?UTF-8?q?docs:=20=EB=B0=B0=EB=84=88=20API=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=EC=97=90=20BannerType,=20TextPosition=20enum?= =?UTF-8?q?=20=EC=B0=B8=EC=A1=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - banner-type.adoc: CURATION, AD, SURVEY, PARTNERSHIP, ETC - text-position.adoc: LT, LB, RT, RB, CENTER - banners.adoc 목록 조회 섹션에 enum include 추가 Co-Authored-By: Claude Opus 4.6 --- .../src/docs/asciidoc/api/admin-banners/banners.adoc | 3 +++ .../docs/asciidoc/api/common/enums/banner-type.adoc | 12 ++++++++++++ .../asciidoc/api/common/enums/text-position.adoc | 12 ++++++++++++ 3 files changed, 27 insertions(+) create mode 100644 bottlenote-admin-api/src/docs/asciidoc/api/common/enums/banner-type.adoc create mode 100644 bottlenote-admin-api/src/docs/asciidoc/api/common/enums/text-position.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 index 4ac64137c..dcae9aac0 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/api/admin-banners/banners.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-banners/banners.adoc @@ -3,6 +3,9 @@ - 배너 목록을 페이지네이션으로 조회합니다. - 키워드, 활성화 상태, 배너 유형으로 필터링이 가능합니다. +include::../common/enums/banner-type.adoc[] +include::../common/enums/text-position.adoc[] + [source] ---- GET /admin/api/v1/banners 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` |중앙 +|=== From a82c53f11f033bf9d348ccd655b694f94c51e2a5 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 9 Feb 2026 11:46:13 +0900 Subject: [PATCH 13/27] =?UTF-8?q?docs:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88=20=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - COMPLETION STAMP 추가 (2026-02-09) - plan/ -> plan/complete/ 이동 Co-Authored-By: Claude Opus 4.6 --- ...0\353\212\245 \352\265\254\355\230\204.md" | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) rename "plan/\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" => "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" (95%) diff --git "a/plan/\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" similarity index 95% rename from "plan/\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" rename to "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" index e99a42e7e..836256d12 100644 --- "a/plan/\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" @@ -1,3 +1,30 @@ +``` +================================================================================ + 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 기능 구현 계획 ## 개요 From 93b6c31f168a5c3a57d6634e93b0e30ed1884a15 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 9 Feb 2026 11:53:35 +0900 Subject: [PATCH 14/27] =?UTF-8?q?docs:=20Admin=20API=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 1 체크리스트에 엔티티 수정 메서드, InMemoryRepository 동기화 주의사항 추가 - Phase 3 문서화 절차 상세화 (enum adoc, domain adoc, admin-api.adoc include, asciidoctor 빌드) - DTO 변환 패턴 수정: Service에서 직접 생성자 호출도 허용 - 예외 계층 구조 섹션 신규 추가 (AbstractCustomException -> {Domain}Exception) - 참고 구현 파일에 배너(Banner) 추가 - 검증 절차에 integration_test(product) 추가 및 CI 파이프라인 주의사항 보강 Co-Authored-By: Claude Opus 4.6 --- .claude/docs/ADMIN-API-GUIDE.md | 69 ++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 13 deletions(-) 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 ``` From 97487ae6f8f3f66e0c99072f02cf9815ada159bd Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 9 Feb 2026 12:35:41 +0900 Subject: [PATCH 15/27] =?UTF-8?q?fix:=20TestFactory=20generateRandomSuffix?= =?UTF-8?q?=20=EC=B6=A9=EB=8F=8C=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20flaky=20t?= =?UTF-8?q?est=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 9개 TestFactory의 random.nextInt(10000)을 AtomicInteger 카운터로 교체 - Birthday Problem에 의한 unique 제약조건 충돌 제거 (CI 약 1/30 확률 실패) - 영향 파일: UserTestFactory, AdminUserTestFactory, AlcoholTestFactory, AlcoholMetadataTestFactory, TastingTagTestFactory, RatingTestFactory, HistoryTestFactory, ReviewTestFactory, LikesTestFactory Co-Authored-By: Claude Opus 4.6 --- .../fixture/AlcoholMetadataTestFactory.java | 7 +- .../alcohols/fixture/AlcoholTestFactory.java | 7 +- .../fixture/TastingTagTestFactory.java | 7 +- .../history/fixture/HistoryTestFactory.java | 6 +- .../like/fixture/LikesTestFactory.java | 6 +- .../rating/fixture/RatingTestFactory.java | 6 +- .../review/fixture/ReviewTestFactory.java | 7 +- .../user/fixture/AdminUserTestFactory.java | 7 +- .../user/fixture/UserTestFactory.java | 7 +- plan/complete/testfactory-flaky-suffix-fix.md | 194 ++++++++++++++++++ 10 files changed, 221 insertions(+), 33 deletions(-) create mode 100644 plan/complete/testfactory-flaky-suffix-fix.md 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/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/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` 카운터로 생성 안정성을 확보하는 것이 선행 조건 From 88f07f471b16e3220507223084374d8b38c74dc7 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 9 Feb 2026 15:52:45 +0900 Subject: [PATCH 16/27] =?UTF-8?q?feat:=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EB=B0=8F=20=EC=95=88=EC=A0=95=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EntityManager flush/clear로 트랜잭션별 오류 격리 - 성공 항목 삭제를 트랜잭션 커밋 후로 조정 - Quartz 작업에 동시 실행 방지 추가 (DisallowConcurrentExecution) --- .../history/scheduled/ViewHistorySyncJob.java | 2 ++ .../service/AlcoholViewHistoryService.java | 22 +++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) 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()); + } + }); } } From 86a9963ad516c0fa16d1fd4ad5aea781789758ef Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 9 Feb 2026 15:52:54 +0900 Subject: [PATCH 17/27] =?UTF-8?q?chore:=20=EB=B2=84=EC=A0=84=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bottlenote-product-api/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 54e68d9ece8b832d10ee5e4c20687b229701ac4b Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 10 Feb 2026 13:25:51 +0900 Subject: [PATCH 18/27] chore: version update @v1.0.6 --- bottlenote-product-api/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bottlenote-product-api/VERSION b/bottlenote-product-api/VERSION index 90a27f9ce..af0b7ddbf 100644 --- a/bottlenote-product-api/VERSION +++ b/bottlenote-product-api/VERSION @@ -1 +1 @@ -1.0.5 +1.0.6 From 5fd93e1d4168e5663ba095fb75c7751d597bbc10 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 10 Feb 2026 13:26:11 +0900 Subject: [PATCH 19/27] refactor: remove unused rating joins in CustomReviewRepositoryImpl --- .../repository/CustomReviewRepositoryImpl.java | 13 +------------ git.environment-variables | 2 +- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepositoryImpl.java index fb4f0e369..9ce329991 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepositoryImpl.java @@ -2,7 +2,6 @@ import static app.bottlenote.alcohols.domain.QAlcohol.alcohol; import static app.bottlenote.like.domain.QLikes.likes; -import static app.bottlenote.rating.domain.QRating.rating; import static app.bottlenote.review.constant.ReviewActiveStatus.ACTIVE; import static app.bottlenote.review.constant.ReviewDisplayStatus.PUBLIC; import static app.bottlenote.review.domain.QReview.review; @@ -70,7 +69,7 @@ private static ConstructorExpression composeReviewInfoResult(Long us // 가격 및 평점 정보 review.price, - rating.ratingPoint.rating, + review.reviewRating, // 좋아요 및 댓글 정보 likes.countDistinct(), @@ -94,8 +93,6 @@ public ReviewInfo getReview(Long reviewId, Long userId) { .on(review.id.eq(likes.reviewId).and(likes.status.eq(LikeStatus.LIKE))) .leftJoin(alcohol) .on(alcohol.id.eq(review.alcoholId)) - .leftJoin(rating) - .on(rating.id.alcoholId.eq(review.alcoholId).and(rating.id.userId.eq(review.userId))) .leftJoin(reviewImage) .on(review.id.eq(reviewImage.review.id)) .leftJoin(reviewReply) @@ -123,8 +120,6 @@ public PageResponse getReviews( .on(review.id.eq(likes.reviewId).and(likes.status.eq(LikeStatus.LIKE))) .leftJoin(alcohol) .on(alcohol.id.eq(review.alcoholId)) - .leftJoin(rating) - .on(rating.id.alcoholId.eq(review.alcoholId).and(rating.id.userId.eq(review.userId))) .leftJoin(reviewReply) .on(review.id.eq(reviewReply.reviewId)) .leftJoin(reviewImage) @@ -181,8 +176,6 @@ public PageResponse getReviewsByMe( .on(review.userId.eq(user.id)) .leftJoin(likes) .on(review.id.eq(likes.reviewId).and(likes.status.eq(LikeStatus.LIKE))) - .leftJoin(rating) - .on(rating.id.alcoholId.eq(review.alcoholId).and(rating.id.userId.eq(review.userId))) .leftJoin(reviewReply) .on(review.id.eq(reviewReply.reviewId)) .leftJoin(reviewImage) @@ -264,8 +257,6 @@ public Pair> getStandardExplore( .on(alcohol.id.eq(review.alcoholId)) .leftJoin(likes) .on(review.id.eq(likes.reviewId).and(likes.status.eq(LikeStatus.LIKE))) - .leftJoin(rating) - .on(rating.id.alcoholId.eq(review.alcoholId).and(rating.id.userId.eq(review.userId))) .leftJoin(reviewImage) .on(review.id.eq(reviewImage.review.id)) .leftJoin(reviewReply) @@ -346,8 +337,6 @@ public Pair> getStandardExplore( .on(alcohol.id.eq(review.alcoholId)) .leftJoin(likes) .on(review.id.eq(likes.reviewId).and(likes.status.eq(LikeStatus.LIKE))) - .leftJoin(rating) - .on(rating.id.alcoholId.eq(review.alcoholId).and(rating.id.userId.eq(review.userId))) .leftJoin(reviewImage) .on(review.id.eq(reviewImage.review.id)) .leftJoin(reviewReply) diff --git a/git.environment-variables b/git.environment-variables index 602b9b530..9a3e14d6f 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 602b9b53057e712d067ba8a2a8c9487fc09076ba +Subproject commit 9a3e14d6fa2057c6ef3570016840ef249422ffa7 From 2d6d36bbe2500cd1b69a4e110464549e6c4fba79 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 10 Feb 2026 15:44:44 +0900 Subject: [PATCH 20/27] chore: update version to 1.0.7 and add git tag creation step --- .github/workflows/version-check.yml | 17 ++++++++++++++++- bottlenote-product-api/VERSION | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index 7a60c385b..b4fb72322 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -1,4 +1,4 @@ -name: Version Check & Release PR +name: Version Tag Sync on: push: @@ -50,6 +50,21 @@ jobs: echo "${{ matrix.module }} VERSION not changed, skipping" fi + - name: Create git tag + if: steps.version-change.outputs.changed == 'true' + run: | + VERSION="${{ steps.version-change.outputs.version }}" + MODULE="${{ matrix.module }}" + TAG_NAME="${MODULE}/v${VERSION}" + + if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "Tag $TAG_NAME already exists, skipping" + else + git tag -a "$TAG_NAME" -m "Release ${MODULE} v${VERSION}" + git push origin "$TAG_NAME" + echo "Created and pushed tag: $TAG_NAME" + fi + - name: Check existing PR if: steps.version-change.outputs.changed == 'true' id: existing-pr diff --git a/bottlenote-product-api/VERSION b/bottlenote-product-api/VERSION index af0b7ddbf..238d6e882 100644 --- a/bottlenote-product-api/VERSION +++ b/bottlenote-product-api/VERSION @@ -1 +1 @@ -1.0.6 +1.0.7 From 28f8ac608c28962ca3f1b7aaee2e3deb5022070c Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 10 Feb 2026 15:48:14 +0900 Subject: [PATCH 21/27] chore: add Version Tag Sync workflow and refactor version-check logic --- .github/workflows/version-check.yml | 17 +------ .github/workflows/version-tag-sync.yml | 61 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/version-tag-sync.yml diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index b4fb72322..7a60c385b 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -1,4 +1,4 @@ -name: Version Tag Sync +name: Version Check & Release PR on: push: @@ -50,21 +50,6 @@ jobs: echo "${{ matrix.module }} VERSION not changed, skipping" fi - - name: Create git tag - if: steps.version-change.outputs.changed == 'true' - run: | - VERSION="${{ steps.version-change.outputs.version }}" - MODULE="${{ matrix.module }}" - TAG_NAME="${MODULE}/v${VERSION}" - - if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then - echo "Tag $TAG_NAME already exists, skipping" - else - git tag -a "$TAG_NAME" -m "Release ${MODULE} v${VERSION}" - git push origin "$TAG_NAME" - echo "Created and pushed tag: $TAG_NAME" - fi - - name: Check existing PR if: steps.version-change.outputs.changed == 'true' id: existing-pr diff --git a/.github/workflows/version-tag-sync.yml b/.github/workflows/version-tag-sync.yml new file mode 100644 index 000000000..b241453a9 --- /dev/null +++ b/.github/workflows/version-tag-sync.yml @@ -0,0 +1,61 @@ +name: Version Tag Sync + +on: + push: + branches: [main] + paths: + - '**/VERSION' + +permissions: + contents: write + +jobs: + create-tag: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - module: product + version_file: bottlenote-product-api/VERSION + - module: admin + version_file: bottlenote-admin-api/VERSION + - module: batch + version_file: bottlenote-batch/VERSION + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Check VERSION file changed + id: version-change + run: | + if git diff --name-only HEAD~1 HEAD | grep -q "${{ matrix.version_file }}"; then + VERSION=$(cat "${{ matrix.version_file }}" | tr -d '[:space:]') + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "${{ matrix.module }} VERSION changed: $VERSION" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "${{ matrix.module }} VERSION not changed, skipping" + fi + + - name: Create and push tag + if: steps.version-change.outputs.changed == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + VERSION="${{ steps.version-change.outputs.version }}" + MODULE="${{ matrix.module }}" + TAG_NAME="${MODULE}/v${VERSION}" + + if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "Tag $TAG_NAME already exists, skipping" + else + git tag -a "$TAG_NAME" -m "Release ${MODULE} v${VERSION}" + git push origin "$TAG_NAME" + echo "Created and pushed tag: $TAG_NAME" + fi \ No newline at end of file From aff91c4352b64ea8a31ad0547ed3992c3f91b0bd Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 10 Feb 2026 15:49:45 +0900 Subject: [PATCH 22/27] deps: version patch --- bottlenote-product-api/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bottlenote-product-api/VERSION b/bottlenote-product-api/VERSION index 238d6e882..f9ae32243 100644 --- a/bottlenote-product-api/VERSION +++ b/bottlenote-product-api/VERSION @@ -1 +1 @@ -1.0.7 +1.0.7-1 From 4a0ae380ec7ebe0758f8be51ec502f4d70410207 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 10 Feb 2026 15:56:02 +0900 Subject: [PATCH 23/27] chore: merge Version Tag Sync workflow with version-check logic and remove standalone workflow --- .github/workflows/version-check.yml | 62 +++++++++++++++++++++++--- .github/workflows/version-tag-sync.yml | 61 ------------------------- 2 files changed, 57 insertions(+), 66 deletions(-) delete mode 100644 .github/workflows/version-tag-sync.yml diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index 7a60c385b..8abf3b30d 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -1,11 +1,13 @@ -name: Version Check & Release PR +name: Version Tag Sync on: push: branches: [main] + paths: + - '**/VERSION' concurrency: - group: version-check + group: version-tag-sync cancel-in-progress: true permissions: @@ -13,7 +15,57 @@ permissions: pull-requests: write jobs: - check-and-create-pr: + create-tag: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - module: product + version_file: bottlenote-product-api/VERSION + - module: admin + version_file: bottlenote-admin-api/VERSION + - module: batch + version_file: bottlenote-batch/VERSION + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Check VERSION file changed + id: version-change + run: | + if git diff --name-only HEAD~1 HEAD | grep -q "${{ matrix.version_file }}"; then + VERSION=$(cat "${{ matrix.version_file }}" | tr -d '[:space:]') + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "${{ matrix.module }} VERSION changed: $VERSION" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "${{ matrix.module }} VERSION not changed, skipping" + fi + + - name: Create and push tag + if: steps.version-change.outputs.changed == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + VERSION="${{ steps.version-change.outputs.version }}" + MODULE="${{ matrix.module }}" + TAG_NAME="${MODULE}/v${VERSION}" + + if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "Tag $TAG_NAME already exists, skipping" + else + git tag -a "$TAG_NAME" -m "Release ${MODULE} v${VERSION}" + git push origin "$TAG_NAME" + echo "Created and pushed tag: $TAG_NAME" + fi + + create-release-pr: runs-on: ubuntu-latest strategy: fail-fast: false @@ -142,7 +194,7 @@ jobs: - **Auto-generated release PR** --- - This PR was automatically created by version-check workflow. + This PR was automatically created by version-tag-sync workflow. EOF ) @@ -173,7 +225,7 @@ jobs: - **Auto-generated release PR** --- - This PR was automatically created by version-check workflow. + This PR was automatically created by version-tag-sync workflow. EOF ) diff --git a/.github/workflows/version-tag-sync.yml b/.github/workflows/version-tag-sync.yml deleted file mode 100644 index b241453a9..000000000 --- a/.github/workflows/version-tag-sync.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Version Tag Sync - -on: - push: - branches: [main] - paths: - - '**/VERSION' - -permissions: - contents: write - -jobs: - create-tag: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - module: product - version_file: bottlenote-product-api/VERSION - - module: admin - version_file: bottlenote-admin-api/VERSION - - module: batch - version_file: bottlenote-batch/VERSION - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Check VERSION file changed - id: version-change - run: | - if git diff --name-only HEAD~1 HEAD | grep -q "${{ matrix.version_file }}"; then - VERSION=$(cat "${{ matrix.version_file }}" | tr -d '[:space:]') - echo "changed=true" >> "$GITHUB_OUTPUT" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "${{ matrix.module }} VERSION changed: $VERSION" - else - echo "changed=false" >> "$GITHUB_OUTPUT" - echo "${{ matrix.module }} VERSION not changed, skipping" - fi - - - name: Create and push tag - if: steps.version-change.outputs.changed == 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - VERSION="${{ steps.version-change.outputs.version }}" - MODULE="${{ matrix.module }}" - TAG_NAME="${MODULE}/v${VERSION}" - - if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then - echo "Tag $TAG_NAME already exists, skipping" - else - git tag -a "$TAG_NAME" -m "Release ${MODULE} v${VERSION}" - git push origin "$TAG_NAME" - echo "Created and pushed tag: $TAG_NAME" - fi \ No newline at end of file From 58862cad1db31710af7a4a102d33f2108d3b8120 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 10 Feb 2026 15:56:12 +0900 Subject: [PATCH 24/27] deps: version sync --- bottlenote-product-api/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bottlenote-product-api/VERSION b/bottlenote-product-api/VERSION index f9ae32243..db5e8e22b 100644 --- a/bottlenote-product-api/VERSION +++ b/bottlenote-product-api/VERSION @@ -1 +1 @@ -1.0.7-1 +1.0.7-2 From ac7982cf38135494da96f1c290950fac4ce2f28b Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 10 Feb 2026 16:00:03 +0900 Subject: [PATCH 25/27] deps: update version to 1.0.7-3 and enhance version parsing in workflow --- .github/workflows/version-check.yml | 4 ++-- bottlenote-product-api/VERSION | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index 8abf3b30d..470c7463c 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -134,7 +134,7 @@ jobs: id: existing-version run: | PR_TITLE="${{ steps.existing-pr.outputs.pr_title }}" - EXISTING_VERSION=$(echo "$PR_TITLE" | grep -oP 'v\K[0-9]+\.[0-9]+\.[0-9]+' || echo "0.0.0") + EXISTING_VERSION=$(echo "$PR_TITLE" | grep -oP 'v\K[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?' || echo "0.0.0") echo "version=$EXISTING_VERSION" >> "$GITHUB_OUTPUT" echo "Existing PR version: $EXISTING_VERSION" @@ -146,7 +146,7 @@ jobs: OLD_VERSION="${{ steps.existing-version.outputs.version }}" version_to_int() { - echo "$1" | awk -F. '{ printf("%d%03d%03d", $1, $2, $3) }' + echo "$1" | awk -F'[-.]' '{ printf("%d%03d%03d%03d", $1, $2, $3, (NF>=4 ? $4 : 0)) }' } NEW_INT=$(version_to_int "$NEW_VERSION") diff --git a/bottlenote-product-api/VERSION b/bottlenote-product-api/VERSION index db5e8e22b..3cd22829f 100644 --- a/bottlenote-product-api/VERSION +++ b/bottlenote-product-api/VERSION @@ -1 +1 @@ -1.0.7-2 +1.0.7-3 From 0479812a842416956536a1c1fd409d5137d8e133 Mon Sep 17 00:00:00 2001 From: rlagu Date: Wed, 11 Feb 2026 19:51:33 +0900 Subject: [PATCH 26/27] refactor: update validation messages in AdminBanner request classes --- .../dto/request/AdminBannerCreateRequest.java | 16 ++-- .../request/AdminBannerSortOrderRequest.java | 2 +- .../dto/request/AdminBannerStatusRequest.java | 2 +- .../dto/request/AdminBannerUpdateRequest.java | 18 ++--- .../custom/code/ValidExceptionCode.java | 12 ++- git.environment-variables | 2 +- .../\353\260\260\353\204\210.http" | 77 +++++++++++++++++++ 7 files changed, 108 insertions(+), 21 deletions(-) create mode 100644 "http/admin/04_\353\260\260\353\204\210\352\264\200\353\246\254/\353\260\260\353\204\210.http" 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 index b4a78a64c..63ff407f2 100644 --- 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 @@ -11,18 +11,18 @@ 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 색상 형식이 올바르지 않습니다.") + @NotBlank(message = "BANNER_NAME_REQUIRED") String name, + @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "INVALID_HEX_COLOR_FORMAT") String nameFontColor, + @Size(max = 50, message = "BANNER_DESCRIPTION_MAX_SIZE") String descriptionA, + @Size(max = 50, message = "BANNER_DESCRIPTION_MAX_SIZE") String descriptionB, + @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "INVALID_HEX_COLOR_FORMAT") String descriptionFontColor, - @NotBlank(message = "이미지 URL은 필수입니다.") String imageUrl, + @NotBlank(message = "BANNER_IMAGE_URL_REQUIRED") String imageUrl, TextPosition textPosition, Boolean isExternalUrl, String targetUrl, - @NotNull(message = "배너 유형은 필수입니다.") BannerType bannerType, - @Min(value = 0, message = "정렬 순서는 0 이상이어야 합니다.") Integer sortOrder, + @NotNull(message = "BANNER_TYPE_REQUIRED") BannerType bannerType, + @Min(value = 0, message = "BANNER_SORT_ORDER_MINIMUM") Integer sortOrder, LocalDateTime startDate, LocalDateTime endDate) { 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 index c6bbf3bdd..6a43f8b22 100644 --- 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 @@ -4,5 +4,5 @@ import jakarta.validation.constraints.NotNull; public record AdminBannerSortOrderRequest( - @NotNull(message = "정렬 순서는 필수입니다.") @Min(value = 0, message = "정렬 순서는 0 이상이어야 합니다.") + @NotNull(message = "BANNER_SORT_ORDER_REQUIRED") @Min(value = 0, message = "BANNER_SORT_ORDER_MINIMUM") 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 index 4106ba841..cd9367f46 100644 --- 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 @@ -2,4 +2,4 @@ import jakarta.validation.constraints.NotNull; -public record AdminBannerStatusRequest(@NotNull(message = "활성화 상태는 필수입니다.") Boolean isActive) {} +public record AdminBannerStatusRequest(@NotNull(message = "BANNER_IS_ACTIVE_REQUIRED") 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 index 73394f4ae..c374a358c 100644 --- 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 @@ -10,19 +10,19 @@ 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 색상 형식이 올바르지 않습니다.") + @NotBlank(message = "BANNER_NAME_REQUIRED") String name, + @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "INVALID_HEX_COLOR_FORMAT") String nameFontColor, + @Size(max = 50, message = "BANNER_DESCRIPTION_MAX_SIZE") String descriptionA, + @Size(max = 50, message = "BANNER_DESCRIPTION_MAX_SIZE") String descriptionB, + @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "INVALID_HEX_COLOR_FORMAT") String descriptionFontColor, - @NotBlank(message = "이미지 URL은 필수입니다.") String imageUrl, + @NotBlank(message = "BANNER_IMAGE_URL_REQUIRED") String imageUrl, TextPosition textPosition, Boolean isExternalUrl, String targetUrl, - @NotNull(message = "배너 유형은 필수입니다.") BannerType bannerType, - @NotNull(message = "정렬 순서는 필수입니다.") @Min(value = 0, message = "정렬 순서는 0 이상이어야 합니다.") + @NotNull(message = "BANNER_TYPE_REQUIRED") BannerType bannerType, + @NotNull(message = "BANNER_SORT_ORDER_REQUIRED") @Min(value = 0, message = "BANNER_SORT_ORDER_MINIMUM") Integer sortOrder, LocalDateTime startDate, LocalDateTime endDate, - @NotNull(message = "활성화 상태는 필수입니다.") Boolean isActive) {} + @NotNull(message = "BANNER_IS_ACTIVE_REQUIRED") Boolean isActive) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/exception/custom/code/ValidExceptionCode.java b/bottlenote-mono/src/main/java/app/bottlenote/global/exception/custom/code/ValidExceptionCode.java index 2bf73c495..aeaf63a1b 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/exception/custom/code/ValidExceptionCode.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/exception/custom/code/ValidExceptionCode.java @@ -80,7 +80,17 @@ public enum ValidExceptionCode implements ExceptionCode { // HELP HELP_TITLE_REQUIRED(HttpStatus.BAD_REQUEST, "문의글 제목은 필수입니다."), HELP_CONTENT_REQUIRED(HttpStatus.BAD_REQUEST, "문의글 내용은 필수입니다."), - REQUIRED_HELP_TYPE(HttpStatus.BAD_REQUEST, "문의 유형은 필수입니다.(WHISKEY, REVIEW, USER, ETC)"); + REQUIRED_HELP_TYPE(HttpStatus.BAD_REQUEST, "문의 유형은 필수입니다.(WHISKEY, REVIEW, USER, ETC)"), + + // BANNER + BANNER_NAME_REQUIRED(HttpStatus.BAD_REQUEST, "배너명은 필수입니다."), + INVALID_HEX_COLOR_FORMAT(HttpStatus.BAD_REQUEST, "HEX 색상 형식이 올바르지 않습니다."), + BANNER_DESCRIPTION_MAX_SIZE(HttpStatus.BAD_REQUEST, "배너 설명은 최대 50자까지 가능합니다."), + BANNER_IMAGE_URL_REQUIRED(HttpStatus.BAD_REQUEST, "이미지 URL은 필수입니다."), + BANNER_TYPE_REQUIRED(HttpStatus.BAD_REQUEST, "배너 유형은 필수입니다."), + BANNER_SORT_ORDER_REQUIRED(HttpStatus.BAD_REQUEST, "정렬 순서는 필수입니다."), + BANNER_SORT_ORDER_MINIMUM(HttpStatus.BAD_REQUEST, "정렬 순서는 0 이상이어야 합니다."), + BANNER_IS_ACTIVE_REQUIRED(HttpStatus.BAD_REQUEST, "활성화 상태는 필수입니다."); private final HttpStatus httpStatus; private String message; diff --git a/git.environment-variables b/git.environment-variables index 9a3e14d6f..37d2fa1d9 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 9a3e14d6fa2057c6ef3570016840ef249422ffa7 +Subproject commit 37d2fa1d96836777fad9245ac9e6388b628c71c4 diff --git "a/http/admin/04_\353\260\260\353\204\210\352\264\200\353\246\254/\353\260\260\353\204\210.http" "b/http/admin/04_\353\260\260\353\204\210\352\264\200\353\246\254/\353\260\260\353\204\210.http" new file mode 100644 index 000000000..d720e612f --- /dev/null +++ "b/http/admin/04_\353\260\260\353\204\210\352\264\200\353\246\254/\353\260\260\353\204\210.http" @@ -0,0 +1,77 @@ +### 배너 목록 조회 +GET {{host}}/banners?cursor=0&pageSize=10 +Authorization: Bearer {{accessToken}} + +### 배너 상세 조회 +@bannerId = 1 +GET {{host}}/banners/{{bannerId}} +Authorization: Bearer {{accessToken}} + +### 배너 생성 +POST {{host}}/banners +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "name": "새 배너", + "nameFontColor": "#ffffff", + "descriptionA": "배너 설명A", + "descriptionB": "배너 설명B", + "descriptionFontColor": "#ffffff", + "imageUrl": "https://example.com/banner.jpg", + "textPosition": "RT", + "isExternalUrl": false, + "targetUrl": "https://www.instagram.com/bottle_note_official", + "bannerType": "CURATION", + "sortOrder": 0, + "startDate": "2026-02-11T00:00:00", + "endDate": "2026-02-12T00:00:00" +} + +### 배너 수정 +@bannerId = 1 +PUT {{host}}/banners/{{bannerId}} +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "name": "수정된 배너", + "nameFontColor": "#000000", + "descriptionA": "수정된 설명A", + "descriptionB": "수정된 설명B", + "descriptionFontColor": "#000000", + "imageUrl": "https://example.com/banner-updated.jpg", + "textPosition": "RT", + "isExternalUrl": false, + "targetUrl": "https://www.instagram.com/bottle_note_official", + "bannerType": "CURATION", + "sortOrder": 1, + "startDate": "2026-02-11T00:00:00", + "endDate": "2026-02-28T00:00:00", + "isActive": true +} + +### 배너 삭제 +@bannerId = 1 +DELETE {{host}}/banners/{{bannerId}} +Authorization: Bearer {{accessToken}} + +### 배너 활성화 상태 변경 +@bannerId = 1 +PATCH {{host}}/banners/{{bannerId}}/status +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "isActive": true +} + +### 배너 정렬 순서 변경 +@bannerId = 1 +PATCH {{host}}/banners/{{bannerId}}/sort-order +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "sortOrder": 0 +} From ef5e1935158f0faa52ef58bca728fc2e4bfff8ed Mon Sep 17 00:00:00 2001 From: rlagu Date: Wed, 11 Feb 2026 20:27:01 +0900 Subject: [PATCH 27/27] chore: update environment variables and increment version to 1.0.8 --- bottlenote-admin-api/VERSION | 2 +- git.environment-variables | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bottlenote-admin-api/VERSION b/bottlenote-admin-api/VERSION index 3cd22829f..b0f3d96f8 100644 --- a/bottlenote-admin-api/VERSION +++ b/bottlenote-admin-api/VERSION @@ -1 +1 @@ -1.0.7-3 +1.0.8 diff --git a/git.environment-variables b/git.environment-variables index 37d2fa1d9..1581bb6e2 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 37d2fa1d96836777fad9245ac9e6388b628c71c4 +Subproject commit 1581bb6e200e599be9eed5c86a4506b69bf90399