From 67c0b34ca40145bcc27f1adb92640880892739c2 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 17 Feb 2026 16:22:47 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20Quartz=20=ED=81=B4=EB=9F=AC?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=EB=A7=81=20=EC=84=A4=EC=A0=95=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 --- .../src/main/resources/application.yml | 20 +++++++++++++++++++ git.environment-variables | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/bottlenote-product-api/src/main/resources/application.yml b/bottlenote-product-api/src/main/resources/application.yml index d16be02ac..9f6246e9b 100644 --- a/bottlenote-product-api/src/main/resources/application.yml +++ b/bottlenote-product-api/src/main/resources/application.yml @@ -29,6 +29,26 @@ spring: jackson: time-zone: Asia/Seoul + # Quartz 설정 (ViewHistorySyncJob 클러스터링) + quartz: + overwrite-existing-jobs: true + job-store-type: jdbc + jdbc: + initialize-schema: never + properties: + org: + quartz: + scheduler: + instanceName: bottle_note_product_quartz_scheduler + instanceId: AUTO + threadPool: + threadCount: 5 + threadPriority: 5 + jobStore: + driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate + isClustered: true + clusterCheckinInterval: 20000 + # Spring Security security: jwt: diff --git a/git.environment-variables b/git.environment-variables index 1581bb6e2..d0c877774 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 1581bb6e200e599be9eed5c86a4506b69bf90399 +Subproject commit d0c87777470857ab730d25dc4ef091cfbd611c73 From c1d7a6afa385a377402a2f6f92e2689e7fcf77e3 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 17 Feb 2026 17:10:41 +0900 Subject: [PATCH 02/15] =?UTF-8?q?fix:=20Testcontainers=201.21.4=20?= =?UTF-8?q?=EC=97=85=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C=20=EB=B0=8F=20Doc?= =?UTF-8?q?ker=20Engine=2029=20=ED=98=B8=ED=99=98=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../src/test/resources/docker-java.properties | 1 + .../src/test/resources/docker-java.properties | 1 + bottlenote-mono/src/test/resources/docker-java.properties | 1 + .../src/test/resources/docker-java.properties | 1 + gradle/libs.versions.toml | 8 ++++---- 5 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 bottlenote-admin-api/src/test/resources/docker-java.properties create mode 100644 bottlenote-batch/src/test/resources/docker-java.properties create mode 100644 bottlenote-mono/src/test/resources/docker-java.properties create mode 100644 bottlenote-product-api/src/test/resources/docker-java.properties diff --git a/bottlenote-admin-api/src/test/resources/docker-java.properties b/bottlenote-admin-api/src/test/resources/docker-java.properties new file mode 100644 index 000000000..d06ebb926 --- /dev/null +++ b/bottlenote-admin-api/src/test/resources/docker-java.properties @@ -0,0 +1 @@ +api.version=1.44 diff --git a/bottlenote-batch/src/test/resources/docker-java.properties b/bottlenote-batch/src/test/resources/docker-java.properties new file mode 100644 index 000000000..d06ebb926 --- /dev/null +++ b/bottlenote-batch/src/test/resources/docker-java.properties @@ -0,0 +1 @@ +api.version=1.44 diff --git a/bottlenote-mono/src/test/resources/docker-java.properties b/bottlenote-mono/src/test/resources/docker-java.properties new file mode 100644 index 000000000..d06ebb926 --- /dev/null +++ b/bottlenote-mono/src/test/resources/docker-java.properties @@ -0,0 +1 @@ +api.version=1.44 diff --git a/bottlenote-product-api/src/test/resources/docker-java.properties b/bottlenote-product-api/src/test/resources/docker-java.properties new file mode 100644 index 000000000..d06ebb926 --- /dev/null +++ b/bottlenote-product-api/src/test/resources/docker-java.properties @@ -0,0 +1 @@ +api.version=1.44 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 930899f51..f79363e72 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,10 +39,10 @@ firebase-admin = "9.4.3" # Testing testng = "7.7.0" -testcontainers = "1.19.8" -testcontainers-junit = "1.19.8" -testcontainers-mysql = "1.19.8" -testcontainers-minio = "1.19.8" +testcontainers = "1.21.4" +testcontainers-junit = "1.21.4" +testcontainers-mysql = "1.21.4" +testcontainers-minio = "1.21.4" testcontainers-redis = "2.2.4" mockito-inline = "5.2.0" archunit = "1.4.0" From 0891a9c9976794e0476b8a765388db9666b7f46f Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 18 Feb 2026 04:11:04 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=20Quartz=20=EB=B9=84?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94=20=EC=84=A4=EC=A0=95=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 --- .../src/test/resources/application-test.yml | 5 +++++ git.environment-variables | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/bottlenote-product-api/src/test/resources/application-test.yml b/bottlenote-product-api/src/test/resources/application-test.yml index 7cb838067..851f81b44 100644 --- a/bottlenote-product-api/src/test/resources/application-test.yml +++ b/bottlenote-product-api/src/test/resources/application-test.yml @@ -3,6 +3,11 @@ spring: allow-bean-definition-overriding: true application: name: bottle-note-test + + # Quartz 비활성화 (테스트 환경) + quartz: + auto-startup: false + job-store-type: memory test: database: replace: none diff --git a/git.environment-variables b/git.environment-variables index d0c877774..d49f55cc2 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit d0c87777470857ab730d25dc4ef091cfbd611c73 +Subproject commit d49f55cc2da2725a7689994ef40f40273ef603a4 From f1b93a2eb78c17970099aa2ba91bc15d16bb384f Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 18 Feb 2026 04:11:18 +0900 Subject: [PATCH 04/15] =?UTF-8?q?chore:=20=EB=B2=84=EC=A0=84=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=201.0.8=EB=A1=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=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 3cd22829f..b0f3d96f8 100644 --- a/bottlenote-product-api/VERSION +++ b/bottlenote-product-api/VERSION @@ -1 +1 @@ -1.0.7-3 +1.0.8 From c4de714644a7215d316c8f15bd868719b0603d73 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 20 Feb 2026 10:17:19 +0900 Subject: [PATCH 05/15] feat: refactor dto formatting and improve review sorting logic --- .../dto/request/AdminBannerCreateRequest.java | 3 ++- .../request/AdminBannerSortOrderRequest.java | 3 ++- .../dto/request/AdminBannerStatusRequest.java | 3 ++- .../dto/request/AdminBannerUpdateRequest.java | 6 ++++-- .../review/repository/ReviewQuerySupporter.java | 17 ++++++++++------- git.environment-variables | 2 +- 6 files changed, 21 insertions(+), 13 deletions(-) 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 63ff407f2..150d85681 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 @@ -12,7 +12,8 @@ public record AdminBannerCreateRequest( @NotBlank(message = "BANNER_NAME_REQUIRED") String name, - @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "INVALID_HEX_COLOR_FORMAT") String nameFontColor, + @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") 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 6a43f8b22..3704cdef8 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,6 @@ import jakarta.validation.constraints.NotNull; public record AdminBannerSortOrderRequest( - @NotNull(message = "BANNER_SORT_ORDER_REQUIRED") @Min(value = 0, message = "BANNER_SORT_ORDER_MINIMUM") + @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 cd9367f46..961055e8b 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,5 @@ import jakarta.validation.constraints.NotNull; -public record AdminBannerStatusRequest(@NotNull(message = "BANNER_IS_ACTIVE_REQUIRED") 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 c374a358c..39c4a2251 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 @@ -11,7 +11,8 @@ public record AdminBannerUpdateRequest( @NotBlank(message = "BANNER_NAME_REQUIRED") String name, - @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "INVALID_HEX_COLOR_FORMAT") String nameFontColor, + @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") @@ -21,7 +22,8 @@ public record AdminBannerUpdateRequest( Boolean isExternalUrl, String targetUrl, @NotNull(message = "BANNER_TYPE_REQUIRED") BannerType bannerType, - @NotNull(message = "BANNER_SORT_ORDER_REQUIRED") @Min(value = 0, message = "BANNER_SORT_ORDER_MINIMUM") + @NotNull(message = "BANNER_SORT_ORDER_REQUIRED") + @Min(value = 0, message = "BANNER_SORT_ORDER_MINIMUM") Integer sortOrder, LocalDateTime startDate, LocalDateTime endDate, diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/ReviewQuerySupporter.java b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/ReviewQuerySupporter.java index 36be51b12..cd86fd4f3 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/ReviewQuerySupporter.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/ReviewQuerySupporter.java @@ -32,7 +32,6 @@ import com.querydsl.core.util.StringUtils; import com.querydsl.jpa.JPAExpressions; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Objects; import org.springframework.stereotype.Component; @@ -129,6 +128,8 @@ public static boolean isHasNext( public static List> sortBy(ReviewSortType reviewSortType, SortOrder sortOrder) { NumberExpression likesCount = likes.id.count(); + // 동일 순위 리뷰 간 최신순 정렬을 위한 타이브레이커 + OrderSpecifier createAtDesc = review.createAt.desc(); return switch (reviewSortType) { // 인기순 -> 임시로 좋아요 순으로 구현 case POPULAR -> @@ -136,17 +137,19 @@ public static List> sortBy(ReviewSortType reviewSortType, Sort new OrderSpecifier<>(sortOrder == DESC ? Order.DESC : Order.ASC, review.isBest) .nullsLast(), new OrderSpecifier<>(sortOrder == DESC ? Order.DESC : Order.ASC, likesCount) - .nullsLast()); + .nullsLast(), + createAtDesc); // 좋아요 순 case LIKES -> - Collections.singletonList(sortOrder == DESC ? likesCount.desc() : likesCount.asc()); + Arrays.asList(sortOrder == DESC ? likesCount.desc() : likesCount.asc(), createAtDesc); // 별점 순 case RATING -> - Collections.singletonList( + Arrays.asList( sortOrder == DESC ? rating.ratingPoint.rating.desc() - : rating.ratingPoint.rating.asc()); + : rating.ratingPoint.rating.asc(), + createAtDesc); // 병 기준 가격 순 case BOTTLE_PRICE -> { @@ -155,7 +158,7 @@ public static List> sortBy(ReviewSortType reviewSortType, Sort OrderSpecifier priceOrderSpecifier = new OrderSpecifier<>(sortOrder == DESC ? Order.DESC : Order.ASC, review.price); - yield Arrays.asList(sizeOrderSpecifier, priceOrderSpecifier); + yield Arrays.asList(sizeOrderSpecifier, priceOrderSpecifier, createAtDesc); } // 잔 기준 가격 순 @@ -165,7 +168,7 @@ public static List> sortBy(ReviewSortType reviewSortType, Sort OrderSpecifier priceOrderSpecifier = new OrderSpecifier<>(sortOrder == DESC ? Order.DESC : Order.ASC, review.price); - yield Arrays.asList(sizeOrderSpecifier, priceOrderSpecifier); + yield Arrays.asList(sizeOrderSpecifier, priceOrderSpecifier, createAtDesc); } }; } diff --git a/git.environment-variables b/git.environment-variables index d49f55cc2..832b48017 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit d49f55cc2da2725a7689994ef40f40273ef603a4 +Subproject commit 832b48017f94c960be42c888b5086aaa5a20099f From d37ebc6a5749f528f9f878f5758e02ca7e99ab3a Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 20 Feb 2026 10:33:56 +0900 Subject: [PATCH 06/15] =?UTF-8?q?fix:=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=EC=84=9C=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EB=B9=84=EC=86=8D=EC=96=B4=20=ED=95=84=ED=84=B0=20API=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../operation/utils/TestContainersConfig.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java index 1bbdf0941..3f2a294dc 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java @@ -1,11 +1,15 @@ package app.bottlenote.operation.utils; +import app.bottlenote.common.profanity.ProfanityClient; +import app.bottlenote.common.profanity.dto.response.ProfanityResponse; import com.amazonaws.auth.AWSStaticCredentialsProvider; import com.amazonaws.auth.BasicAWSCredentials; import com.amazonaws.client.builder.AwsClientBuilder; import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.AmazonS3ClientBuilder; import com.redis.testcontainers.RedisContainer; +import java.util.Collections; +import java.util.UUID; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; @@ -91,4 +95,37 @@ AmazonS3 amazonS3(MinIOContainer minioContainer) { public static String getTestBucket() { return TEST_BUCKET; } + + // 외부 비속어 필터 API 대신 Fake 구현체 사용 + @Bean + @Primary + ProfanityClient fakeProfanityClient() { + return new ProfanityClient() { + @Override + public ProfanityResponse requestVerificationProfanity(String text) { + return ProfanityResponse.builder() + .trackingId(UUID.randomUUID().toString()) + .status(new ProfanityResponse.Status(200, "OK", "Fake", null)) + .detected(Collections.emptyList()) + .filtered(text) + .elapsed("0.0") + .build(); + } + + @Override + public String getFilteredText(String text) { + return text == null ? "" : text; + } + + @Override + public String filter(String content) { + return content == null || content.isBlank() ? "" : content; + } + + @Override + public void validateProfanity(String text) { + // 테스트 환경에서는 비속어 검증 생략 + } + }; + } } From 979f1ba311c5791eb7570c498a870dc3e325232d Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 20 Feb 2026 17:45:36 +0900 Subject: [PATCH 07/15] =?UTF-8?q?feat:=20AdminAlcoholItem=EC=97=90=20delet?= =?UTF-8?q?edAt=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../app/docs/alcohols/AdminTastingTagControllerDocsTest.kt | 2 +- .../src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt | 5 +++-- .../bottlenote/alcohols/dto/response/AdminAlcoholItem.java | 3 ++- .../bottlenote/alcohols/service/AdminCurationService.java | 3 ++- .../app/bottlenote/alcohols/service/TastingTagService.java | 3 ++- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt index 642367bc7..a7b267ea2 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt @@ -130,7 +130,7 @@ class AdminTastingTagControllerDocsTest { val alcoholItem = AdminAlcoholItem( 1L, "글렌피딕 12년", "Glenfiddich 12", "싱글몰트", "Single Malt", "https://example.com/image.jpg", - LocalDateTime.of(2024, 1, 1, 0, 0), LocalDateTime.of(2024, 6, 1, 0, 0) + LocalDateTime.of(2024, 1, 1, 0, 0), LocalDateTime.of(2024, 6, 1, 0, 0), null ) val response = AdminTastingTagDetailResponse.of(tagNode, listOf(alcoholItem)) diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt index 6a6bcc994..4e186b706 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt @@ -25,9 +25,10 @@ object AlcoholsHelper { engCategoryName: String = "Single Malt", imageUrl: String = "https://example.com/image.jpg", createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0), - modifiedAt: LocalDateTime = LocalDateTime.of(2024, 6, 1, 0, 0) + modifiedAt: LocalDateTime = LocalDateTime.of(2024, 6, 1, 0, 0), + deletedAt: LocalDateTime? = null ): AdminAlcoholItem = AdminAlcoholItem( - id, korName, engName, korCategoryName, engCategoryName, imageUrl, createdAt, modifiedAt + id, korName, engName, korCategoryName, engCategoryName, imageUrl, createdAt, modifiedAt, deletedAt ) fun createAdminAlcoholItems(count: Int = 2): List = diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholItem.java index d8cdfa75b..462083d07 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholItem.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholItem.java @@ -10,4 +10,5 @@ public record AdminAlcoholItem( String engCategoryName, String imageUrl, LocalDateTime createdAt, - LocalDateTime modifiedAt) {} + LocalDateTime modifiedAt, + LocalDateTime deletedAt) {} 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 3b35c5af9..fdb4b3db9 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 @@ -67,7 +67,8 @@ public AdminCurationDetailResponse getDetail(Long curationId) { alcohol.getEngCategory(), alcohol.getImageUrl(), alcohol.getCreateAt(), - alcohol.getLastModifyAt())) + alcohol.getLastModifyAt(), + alcohol.getDeletedAt())) .toList(); return AdminCurationDetailResponse.of( diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java index b69386fa8..4f6c6df55 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java @@ -278,6 +278,7 @@ private AdminAlcoholItem toAdminAlcoholItem(Alcohol alcohol) { alcohol.getEngCategory(), alcohol.getImageUrl(), alcohol.getCreateAt(), - alcohol.getLastModifyAt()); + alcohol.getLastModifyAt(), + alcohol.getDeletedAt()); } } From eaa84c3d55a0ed518932c0f0d88b94d854227e36 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 20 Feb 2026 17:45:45 +0900 Subject: [PATCH 08/15] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=84=EC=8A=A4=ED=82=A4=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20=EC=82=AD=EC=A0=9C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=95=84=ED=84=B0=EB=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../alcohols/dto/request/AdminAlcoholSearchRequest.java | 5 ++++- .../alcohols/repository/AlcoholQuerySupporter.java | 5 +++++ .../repository/CustomAlcoholQueryRepositoryImpl.java | 9 ++++++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholSearchRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholSearchRequest.java index 86dcce9b9..116758ae0 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholSearchRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholSearchRequest.java @@ -13,6 +13,7 @@ * @param sortOrder 정렬 방향 * @param page 페이지 번호 (0부터) * @param size 페이지 크기 + * @param includeDeleted 삭제 데이터 포함 여부 (기본값: false) */ public record AdminAlcoholSearchRequest( String keyword, @@ -21,12 +22,14 @@ public record AdminAlcoholSearchRequest( AdminAlcoholSortType sortType, SortOrder sortOrder, Integer page, - Integer size) { + Integer size, + Boolean includeDeleted) { @Builder public AdminAlcoholSearchRequest { sortType = sortType != null ? sortType : AdminAlcoholSortType.KOR_NAME; sortOrder = sortOrder != null ? sortOrder : SortOrder.ASC; page = page != null ? page : 0; size = size != null ? size : 20; + includeDeleted = includeDeleted != null ? includeDeleted : Boolean.FALSE; } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/AlcoholQuerySupporter.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/AlcoholQuerySupporter.java index 5649dbbb2..a0b82d139 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/AlcoholQuerySupporter.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/AlcoholQuerySupporter.java @@ -178,6 +178,11 @@ public OrderSpecifier sortByRandom() { return Expressions.numberTemplate(Double.class, "function('rand')").asc(); } + /** 삭제되지 않은 데이터 필터 조건 */ + public BooleanExpression isNotDeleted() { + return alcohol.deletedAt.isNull(); + } + /** 이름 포함 여부 조건 생성 */ public BooleanExpression eqName(String name) { if (StringUtils.isNullOrEmpty(name)) return null; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java index 3999bea5c..314cfb8ec 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java @@ -320,12 +320,14 @@ public Page searchAdminAlcohols(AdminAlcoholSearchRequest requ alcohol.engCategory, alcohol.imageUrl, alcohol.createAt, - alcohol.lastModifyAt)) + alcohol.lastModifyAt, + alcohol.deletedAt)) .from(alcohol) .where( supporter.keywordMatch(request.keyword()), supporter.eqCategory(request.category()), - supporter.eqRegion(request.regionId())) + supporter.eqRegion(request.regionId()), + Boolean.TRUE.equals(request.includeDeleted()) ? null : supporter.isNotDeleted()) .orderBy(supporter.sortByAdmin(request.sortType(), request.sortOrder())) .offset((long) request.page() * request.size()) .limit(request.size()) @@ -338,7 +340,8 @@ public Page searchAdminAlcohols(AdminAlcoholSearchRequest requ .where( supporter.keywordMatch(request.keyword()), supporter.eqCategory(request.category()), - supporter.eqRegion(request.regionId())) + supporter.eqRegion(request.regionId()), + Boolean.TRUE.equals(request.includeDeleted()) ? null : supporter.isNotDeleted()) .fetchOne(); return new PageImpl<>( From 46c8b478f978ec2d65b037d88b11b568b011b31d Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 20 Feb 2026 17:45:53 +0900 Subject: [PATCH 09/15] =?UTF-8?q?test:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=84=EC=8A=A4=ED=82=A4=20=EC=82=AD=EC=A0=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=ED=95=84=ED=84=B0=EB=A7=81=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../alcohols/AdminAlcoholsIntegrationTest.kt | 40 +++++++++++++++++++ .../alcohols/fixture/AlcoholTestFactory.java | 10 +++++ 2 files changed, 50 insertions(+) diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt index f3458f787..c6622d558 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt @@ -164,6 +164,46 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { .extractingPath("$.meta.size").isEqualTo(size) } + @Nested + @DisplayName("삭제 데이터 필터링") + inner class DeletedAlcoholFiltering { + + @Test + @DisplayName("기본 조회 시 삭제된 위스키는 제외된다") + fun excludeDeletedByDefault() { + // given + alcoholTestFactory.persistAlcohols(3) + alcoholTestFactory.persistDeletedAlcohol() + + // when & then + assertThat( + mockMvcTester.get().uri("/alcohols") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.length()").isEqualTo(3) + } + + @Test + @DisplayName("includeDeleted=true 시 삭제된 위스키도 포함된다") + fun includeDeletedWhenFlagIsTrue() { + // given + alcoholTestFactory.persistAlcohols(3) + alcoholTestFactory.persistDeletedAlcohol() + + // when & then + assertThat( + mockMvcTester.get().uri("/alcohols") + .header("Authorization", "Bearer $accessToken") + .param("includeDeleted", "true") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.length()").isEqualTo(4) + } + } + @Nested @DisplayName("카테고리 레퍼런스 조회 API") inner class GetCategoryReference { 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 a986723d7..28b58ad71 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 @@ -225,6 +225,16 @@ public Alcohol persistAlcoholWithName(@NotNull String korName, @NotNull String e return alcohol; } + /** 삭제된 Alcohol 생성 (Soft Delete) - 연관 엔티티 자동 생성 */ + @Transactional + @NotNull + public Alcohol persistDeletedAlcohol() { + Alcohol alcohol = persistAlcohol(); + alcohol.delete(); + em.flush(); + return alcohol; + } + /** 특정 카테고리로 Alcohol 생성 - 연관 엔티티 자동 생성 */ @Transactional @NotNull From a20f092b5d0ef5c761041c45ff1e23cb26ee2061 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 20 Feb 2026 18:13:34 +0900 Subject: [PATCH 10/15] =?UTF-8?q?docs:=20RestDocs=20=EC=8A=A4=EB=8B=88?= =?UTF-8?q?=ED=8E=AB=EC=97=90=20deletedAt=20=ED=95=84=EB=93=9C=20=EB=AC=B8?= =?UTF-8?q?=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 Co-Authored-By: Claude Opus 4.6 --- .../kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt | 1 + .../app/docs/alcohols/AdminTastingTagControllerDocsTest.kt | 1 + .../kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt | 1 + 3 files changed, 3 insertions(+) diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt index 868a46f43..5fe63cf25 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt @@ -113,6 +113,7 @@ class AdminAlcoholsControllerDocsTest { fieldWithPath("data[].imageUrl").type(JsonFieldType.STRING).description("술 이미지 URL"), fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), fieldWithPath("data[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data[].deletedAt").type(JsonFieldType.STRING).description("삭제일시").optional(), fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt index a7b267ea2..eb3f79728 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt @@ -176,6 +176,7 @@ class AdminTastingTagControllerDocsTest { fieldWithPath("data.alcohols[].imageUrl").type(JsonFieldType.STRING).description("이미지 URL").optional(), fieldWithPath("data.alcohols[].createdAt").type(JsonFieldType.STRING).description("생성일시"), fieldWithPath("data.alcohols[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.alcohols[].deletedAt").type(JsonFieldType.STRING).description("삭제일시").optional(), fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt index b64f1250d..85e56874f 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 @@ -145,6 +145,7 @@ class AdminCurationControllerDocsTest { 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.alcohols[].deletedAt").type(JsonFieldType.STRING).description("삭제일시").optional(), fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("생성일시"), fieldWithPath("data.modifiedAt").type(JsonFieldType.STRING).description("수정일시"), fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), From 2b868d2646a7efb22cd9f572778746c40442ed29 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 24 Feb 2026 10:58:19 +0900 Subject: [PATCH 11/15] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=84=EC=8A=A4=ED=82=A4=20=EB=93=B1=EB=A1=9D/=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20=ED=85=8C=EC=9D=B4=EC=8A=A4=ED=8C=85=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EC=A7=80=EC=9B=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../domain/AlcoholsTastingTagsRepository.java | 2 ++ .../request/AdminAlcoholUpsertRequest.java | 4 ++- .../JpaAlcoholsTastingTagsRepository.java | 5 ++++ .../service/AdminAlcoholCommandService.java | 29 +++++++++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholsTastingTagsRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholsTastingTagsRepository.java index 97383ad96..77c540c8b 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholsTastingTagsRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholsTastingTagsRepository.java @@ -11,4 +11,6 @@ public interface AlcoholsTastingTagsRepository { void deleteByTastingTagIdAndAlcoholIdIn(Long tastingTagId, List alcoholIds); boolean existsByTastingTagId(Long tastingTagId); + + void deleteByAlcoholId(Long alcoholId); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholUpsertRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholUpsertRequest.java index 0bdc9cced..d9659bc45 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholUpsertRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholUpsertRequest.java @@ -4,6 +4,7 @@ import app.bottlenote.alcohols.constant.AlcoholType; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import java.util.List; public record AdminAlcoholUpsertRequest( @NotBlank(message = "한글 이름은 필수입니다.") String korName, @@ -19,4 +20,5 @@ public record AdminAlcoholUpsertRequest( @NotBlank(message = "캐스크 타입은 필수입니다.") String cask, @NotBlank(message = "이미지 URL은 필수입니다.") String imageUrl, @NotBlank(message = "설명은 필수입니다.") String description, - @NotBlank(message = "용량은 필수입니다.") String volume) {} + @NotBlank(message = "용량은 필수입니다.") String volume, + List tastingTagIds) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholsTastingTagsRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholsTastingTagsRepository.java index 3489a32e4..9f9020289 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholsTastingTagsRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholsTastingTagsRepository.java @@ -31,4 +31,9 @@ void deleteByTastingTagIdAndAlcoholIdIn( @Query( "select case when count(att) > 0 then true else false end from alcohol_tasting_tags att where att.tastingTag.id = :tastingTagId") boolean existsByTastingTagId(@Param("tastingTagId") Long tastingTagId); + + @Override + @Modifying + @Query("delete from alcohol_tasting_tags att where att.alcohol.id = :alcoholId") + void deleteByAlcoholId(@Param("alcoholId") Long alcoholId); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminAlcoholCommandService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminAlcoholCommandService.java index f6547aaf4..b5bfcede4 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminAlcoholCommandService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminAlcoholCommandService.java @@ -6,16 +6,20 @@ import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.ALCOHOL_NOT_FOUND; import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.DISTILLERY_NOT_FOUND; import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.REGION_NOT_FOUND; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.TASTING_TAG_NOT_FOUND; import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.ALCOHOL_CREATED; import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.ALCOHOL_DELETED; import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.ALCOHOL_UPDATED; import app.bottlenote.alcohols.domain.Alcohol; import app.bottlenote.alcohols.domain.AlcoholQueryRepository; +import app.bottlenote.alcohols.domain.AlcoholsTastingTags; +import app.bottlenote.alcohols.domain.AlcoholsTastingTagsRepository; import app.bottlenote.alcohols.domain.Distillery; import app.bottlenote.alcohols.domain.DistilleryRepository; import app.bottlenote.alcohols.domain.Region; import app.bottlenote.alcohols.domain.RegionRepository; +import app.bottlenote.alcohols.domain.TastingTagRepository; import app.bottlenote.alcohols.dto.request.AdminAlcoholUpsertRequest; import app.bottlenote.alcohols.exception.AlcoholException; import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; @@ -42,6 +46,8 @@ public class AdminAlcoholCommandService { private final DistilleryRepository distilleryRepository; private final ReviewRepository reviewRepository; private final RatingRepository ratingRepository; + private final AlcoholsTastingTagsRepository alcoholsTastingTagsRepository; + private final TastingTagRepository tastingTagRepository; private final ApplicationEventPublisher eventPublisher; @Transactional @@ -74,6 +80,9 @@ public AdminResultResponse createAlcohol(AdminAlcoholUpsertRequest request) { .build(); Alcohol saved = alcoholQueryRepository.save(alcohol); + if (request.tastingTagIds() != null && !request.tastingTagIds().isEmpty()) { + saveTastingTags(saved, request.tastingTagIds()); + } publishImageActivatedEvent(request.imageUrl(), saved.getId()); return AdminResultResponse.of(ALCOHOL_CREATED, saved.getId()); @@ -117,6 +126,13 @@ public AdminResultResponse updateAlcohol(Long alcoholId, AdminAlcoholUpsertReque request.description(), request.volume()); + if (request.tastingTagIds() != null) { + alcoholsTastingTagsRepository.deleteByAlcoholId(alcoholId); + if (!request.tastingTagIds().isEmpty()) { + saveTastingTags(alcohol, request.tastingTagIds()); + } + } + handleImageChange(oldImageUrl, request.imageUrl(), alcoholId); return AdminResultResponse.of(ALCOHOL_UPDATED, alcoholId); @@ -145,6 +161,19 @@ public AdminResultResponse deleteAlcohol(Long alcoholId) { return AdminResultResponse.of(ALCOHOL_DELETED, alcoholId); } + private void saveTastingTags(Alcohol alcohol, List tagIds) { + List mappings = + tagIds.stream() + .map( + tagId -> + tastingTagRepository + .findById(tagId) + .orElseThrow(() -> new AlcoholException(TASTING_TAG_NOT_FOUND))) + .map(tag -> AlcoholsTastingTags.of(alcohol, tag)) + .toList(); + alcoholsTastingTagsRepository.saveAll(mappings); + } + private void publishImageActivatedEvent(String imageUrl, Long alcoholId) { if (imageUrl == null || imageUrl.isBlank()) return; From 6e918eb1c5cb4cc147d43d6f5b682835db56a742 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 24 Feb 2026 10:58:29 +0900 Subject: [PATCH 12/15] =?UTF-8?q?docs:=20RestDocs=20=EC=8A=A4=EB=8B=88?= =?UTF-8?q?=ED=8E=AB=EC=97=90=20tastingTagIds=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=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 Co-Authored-By: Claude Opus 4.6 --- .../AdminAlcoholsControllerDocsTest.kt | 11 +++--- .../app/helper/alcohols/AlcoholsHelper.kt | 36 ++++++++++--------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt index 5fe63cf25..3f6f74465 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt @@ -204,7 +204,7 @@ class AdminAlcoholsControllerDocsTest { code = AdminResultResponse.ResultCode.ALCOHOL_CREATED, targetId = 1L ) - val request = AlcoholsHelper.createAlcoholUpsertRequestMap() + val request = AlcoholsHelper.createAlcoholUpsertRequestMap(tastingTagIds = listOf(1L, 2L)) given(adminAlcoholCommandService.createAlcohol(any(AdminAlcoholUpsertRequest::class.java))) .willReturn(response) @@ -235,7 +235,8 @@ class AdminAlcoholsControllerDocsTest { fieldWithPath("cask").type(JsonFieldType.STRING).description("캐스크 타입"), fieldWithPath("imageUrl").type(JsonFieldType.STRING).description("이미지 URL"), fieldWithPath("description").type(JsonFieldType.STRING).description("설명"), - fieldWithPath("volume").type(JsonFieldType.STRING).description("용량") + fieldWithPath("volume").type(JsonFieldType.STRING).description("용량"), + fieldWithPath("tastingTagIds").type(JsonFieldType.ARRAY).optional().description("테이스팅 태그 ID 목록") ), responseFields( fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), @@ -266,7 +267,8 @@ class AdminAlcoholsControllerDocsTest { ) val request = AlcoholsHelper.createAlcoholUpsertRequestMap( korName = "수정된 위스키", - engName = "Updated Whisky" + engName = "Updated Whisky", + tastingTagIds = listOf(1L, 2L) ) given(adminAlcoholCommandService.updateAlcohol(anyLong(), any(AdminAlcoholUpsertRequest::class.java))) @@ -301,7 +303,8 @@ class AdminAlcoholsControllerDocsTest { fieldWithPath("cask").type(JsonFieldType.STRING).description("캐스크 타입"), fieldWithPath("imageUrl").type(JsonFieldType.STRING).description("이미지 URL"), fieldWithPath("description").type(JsonFieldType.STRING).description("설명"), - fieldWithPath("volume").type(JsonFieldType.STRING).description("용량") + fieldWithPath("volume").type(JsonFieldType.STRING).description("용량"), + fieldWithPath("tastingTagIds").type(JsonFieldType.ARRAY).optional().description("테이스팅 태그 ID 목록") ), responseFields( fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt index 4e186b706..ce726b43a 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt @@ -191,21 +191,23 @@ object AlcoholsHelper { cask: String = "American Oak", imageUrl: String = "https://example.com/test.jpg", description: String = "테스트 설명", - volume: String = "700ml" - ): Map = mapOf( - "korName" to korName, - "engName" to engName, - "abv" to abv, - "type" to type.name, - "korCategory" to korCategory, - "engCategory" to engCategory, - "categoryGroup" to categoryGroup.name, - "regionId" to regionId, - "distilleryId" to distilleryId, - "age" to age, - "cask" to cask, - "imageUrl" to imageUrl, - "description" to description, - "volume" to volume - ) + volume: String = "700ml", + tastingTagIds: List? = null + ): Map = buildMap { + put("korName", korName) + put("engName", engName) + put("abv", abv) + put("type", type.name) + put("korCategory", korCategory) + put("engCategory", engCategory) + put("categoryGroup", categoryGroup.name) + put("regionId", regionId) + put("distilleryId", distilleryId) + put("age", age) + put("cask", cask) + put("imageUrl", imageUrl) + put("description", description) + put("volume", volume) + tastingTagIds?.let { put("tastingTagIds", it) } + } } From 43d4c2c1acd4574d23a0b377c80ddb5d9dcc7a63 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 24 Feb 2026 10:58:36 +0900 Subject: [PATCH 13/15] =?UTF-8?q?test:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=84=EC=8A=A4=ED=82=A4=20=ED=85=8C=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=ED=8C=85=20=ED=83=9C=EA=B7=B8=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../alcohols/AdminAlcoholsIntegrationTest.kt | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt index c6622d558..75f51746f 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt @@ -5,6 +5,7 @@ import app.bottlenote.alcohols.constant.AdminAlcoholSortType import app.bottlenote.alcohols.constant.AlcoholCategoryGroup import app.bottlenote.alcohols.constant.AlcoholType import app.bottlenote.alcohols.fixture.AlcoholTestFactory +import app.bottlenote.alcohols.fixture.TastingTagTestFactory import app.bottlenote.global.service.cursor.SortOrder import app.bottlenote.rating.fixture.RatingTestFactory import app.bottlenote.review.fixture.ReviewTestFactory @@ -40,6 +41,9 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var ratingTestFactory: RatingTestFactory + @Autowired + private lateinit var tastingTagTestFactory: TastingTagTestFactory + private lateinit var accessToken: String @BeforeEach @@ -337,6 +341,91 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { .extractingPath("$.data.code").isEqualTo("ALCOHOL_CREATED") } + @Test + @DisplayName("테이스팅 태그와 함께 위스키를 생성할 수 있다") + fun createAlcoholWithTastingTags() { + // given + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + val tag1 = tastingTagTestFactory.persistTastingTag("바닐라", "Vanilla") + val tag2 = tastingTagTestFactory.persistTastingTag("꿀", "Honey") + + val request = mapOf( + "korName" to "테스트 위스키", + "engName" to "Test Whisky", + "abv" to "40%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "12", + "cask" to "American Oak", + "imageUrl" to "https://example.com/test.jpg", + "description" to "테스트 설명", + "volume" to "700ml", + "tastingTagIds" to listOf(tag1.id, tag2.id) + ) + + // when + val createResult = mockMvcTester.post().uri("/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + .exchange() + + assertThat(createResult) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("ALCOHOL_CREATED") + + // then - 상세 조회로 태그 확인 + val alcoholId = extractData(createResult, Map::class.java)["targetId"] + assertThat( + mockMvcTester.get().uri("/alcohols/$alcoholId") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.tastingTags.length()").isEqualTo(2) + } + + @Test + @DisplayName("존재하지 않는 태그 ID로 생성 시 실패한다") + fun createAlcoholWithInvalidTastingTag() { + // given + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + + val request = mapOf( + "korName" to "테스트 위스키", + "engName" to "Test Whisky", + "abv" to "40%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "12", + "cask" to "American Oak", + "imageUrl" to "https://example.com/test.jpg", + "description" to "테스트 설명", + "volume" to "700ml", + "tastingTagIds" to listOf(999999L) + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + @Test @DisplayName("필수 필드 누락 시 실패한다") fun createAlcoholWithMissingFields() { @@ -430,6 +519,150 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { .extractingPath("$.data.code").isEqualTo("ALCOHOL_UPDATED") } + @Test + @DisplayName("수정 시 태그를 교체할 수 있다") + fun updateAlcoholReplaceTastingTags() { + // given + val alcohol = alcoholTestFactory.persistAlcohol() + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + val oldTag = tastingTagTestFactory.persistTastingTag("바닐라", "Vanilla") + val newTag1 = tastingTagTestFactory.persistTastingTag("피트", "Peat") + val newTag2 = tastingTagTestFactory.persistTastingTag("스모키", "Smoky") + tastingTagTestFactory.linkAlcoholToTag(alcohol, oldTag) + + val request = mapOf( + "korName" to "수정된 위스키", + "engName" to "Updated Whisky", + "abv" to "43%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "18", + "cask" to "Sherry Oak", + "imageUrl" to "https://example.com/updated.jpg", + "description" to "수정된 설명", + "volume" to "750ml", + "tastingTagIds" to listOf(newTag1.id, newTag2.id) + ) + + // when + assertThat( + mockMvcTester.put().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("ALCOHOL_UPDATED") + + // then - 상세 조회로 태그 교체 확인 + assertThat( + mockMvcTester.get().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.tastingTags.length()").isEqualTo(2) + } + + @Test + @DisplayName("tastingTagIds 미포함 시 기존 태그가 유지된다") + fun updateAlcoholKeepExistingTags() { + // given + val alcohol = alcoholTestFactory.persistAlcohol() + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + val tag = tastingTagTestFactory.persistTastingTag("바닐라", "Vanilla") + tastingTagTestFactory.linkAlcoholToTag(alcohol, tag) + + val request = mapOf( + "korName" to "수정된 위스키", + "engName" to "Updated Whisky", + "abv" to "43%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "18", + "cask" to "Sherry Oak", + "imageUrl" to "https://example.com/updated.jpg", + "description" to "수정된 설명", + "volume" to "750ml" + ) + + // when + assertThat( + mockMvcTester.put().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + + // then - 기존 태그 유지 확인 + assertThat( + mockMvcTester.get().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.tastingTags.length()").isEqualTo(1) + } + + @Test + @DisplayName("tastingTagIds가 빈 배열이면 태그가 전부 삭제된다") + fun updateAlcoholRemoveAllTags() { + // given + val alcohol = alcoholTestFactory.persistAlcohol() + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + val tag = tastingTagTestFactory.persistTastingTag("바닐라", "Vanilla") + tastingTagTestFactory.linkAlcoholToTag(alcohol, tag) + + val request = mapOf( + "korName" to "수정된 위스키", + "engName" to "Updated Whisky", + "abv" to "43%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "18", + "cask" to "Sherry Oak", + "imageUrl" to "https://example.com/updated.jpg", + "description" to "수정된 설명", + "volume" to "750ml", + "tastingTagIds" to emptyList() + ) + + // when + assertThat( + mockMvcTester.put().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + + // then - 태그 전부 삭제 확인 + assertThat( + mockMvcTester.get().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.tastingTags.length()").isEqualTo(0) + } + @Test @DisplayName("존재하지 않는 alcoholId로 수정 시 실패한다") fun updateAlcoholNotFound() { From 293b1f1826e04b23957380468d965202799a7bd6 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 24 Feb 2026 11:00:59 +0900 Subject: [PATCH 14/15] =?UTF-8?q?docs:=20=ED=85=8C=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=ED=8C=85=20=ED=83=9C=EA=B7=B8=20=EC=A7=80=EC=9B=90=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B3=84=ED=9A=8D=20=EB=AC=B8=EC=84=9C=20=EC=99=84?= =?UTF-8?q?=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 Co-Authored-By: Claude Opus 4.6 --- .../admin-alcohol-tasting-tag-support.md | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 plan/complete/admin-alcohol-tasting-tag-support.md diff --git a/plan/complete/admin-alcohol-tasting-tag-support.md b/plan/complete/admin-alcohol-tasting-tag-support.md new file mode 100644 index 000000000..2d359d4c0 --- /dev/null +++ b/plan/complete/admin-alcohol-tasting-tag-support.md @@ -0,0 +1,244 @@ +# 어드민 위스키 등록/수정 API - 테이스팅 태그 지원 추가 + +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2026-02-24 + +** Core Achievements ** +- 어드민 위스키 등록/수정 API에 테이스팅 태그 동기화 기능 추가 +- null vs 빈 리스트 의미 분리로 하위 호환성 보장 (null=유지, []=전부삭제, [1,2]=교체) +- mono 모듈 4개, admin-api 모듈 3개 총 7개 파일 수정 +- 통합 테스트 5개 케이스 추가, 전체 빌드 및 테스트 통과 확인 + +** Key Components ** +- `AdminAlcoholCommandService.saveTastingTags()`: replace 전략 기반 태그 저장/교체 로직 +- `AlcoholsTastingTagsRepository.deleteByAlcoholId()`: alcoholId 기준 일괄 삭제 +- `AdminAlcoholUpsertRequest.tastingTagIds`: nullable 태그 ID 목록 필드 +================================================================================ +``` + +## 이슈 + +어드민 위스키 정보 관리 시 바로 태그 동기화도 추가 + +--- + +## 수정 파일 목록 + +| # | 모듈 | 파일 | 변경 내용 | +|---|-----------|-----------------------------------------|---------------------------------------------| +| 1 | mono | `AlcoholsTastingTagsRepository.java` | `deleteByAlcoholId()` 메서드 추가 | +| 2 | mono | `JpaAlcoholsTastingTagsRepository.java` | 위 메서드 JPQL 구현 | +| 3 | mono | `AdminAlcoholUpsertRequest.java` | `tastingTagIds` 필드 추가 | +| 4 | mono | `AdminAlcoholCommandService.java` | 태그 저장/교체 로직 추가 | +| 5 | admin-api | `AlcoholsHelper.kt` | `createAlcoholUpsertRequestMap`에 태그 파라미터 추가 | +| 6 | admin-api | `AdminAlcoholsControllerDocsTest.kt` | RestDocs 요청 필드 문서화 | +| 7 | admin-api | `AdminAlcoholsIntegrationTest.kt` | 태그 관련 통합 테스트 추가 | + +--- + +## 구현 순서 + +### Step 1. 도메인 레포지토리 - alcoholId 기반 삭제 메서드 추가 + +**파일**: `bottlenote-mono/.../alcohols/domain/AlcoholsTastingTagsRepository.java` + +```java +void deleteByAlcoholId(Long alcoholId); +``` + +현재는 `deleteByTastingTagIdAndAlcoholIdIn`(태그 기준 삭제)만 존재. +수정 시 "해당 위스키의 기존 태그 전부 삭제 후 신규 태그 등록"(replace 전략)을 위해 alcoholId 기준 삭제가 필요. + +### Step 2. JPA 구현체 - JPQL 쿼리 추가 + +**파일**: `bottlenote-mono/.../alcohols/repository/JpaAlcoholsTastingTagsRepository.java` + +```java + +@Override +@Modifying +@Query("delete from alcohol_tasting_tags att where att.alcohol.id = :alcoholId") +void deleteByAlcoholId(@Param("alcoholId") Long alcoholId); +``` + +기존 `deleteByTastingTagIdAndAlcoholIdIn`과 동일한 `@Modifying` JPQL 패턴 사용. + +### Step 3. 요청 DTO - tastingTagIds 필드 추가 + +**파일**: `bottlenote-mono/.../alcohols/dto/request/AdminAlcoholUpsertRequest.java` + +기존 14개 필드 뒤에 추가: + +```java +List tastingTagIds // validation 어노테이션 없음 (nullable) +``` + +**null vs 빈 리스트 의미 분리**: + +- `null` (필드 미포함) = 수정 시 기존 태그 유지 (하위 호환성) +- `[]` (빈 배열) = 태그 전부 제거 +- `[1, 2, 3]` = 해당 태그로 교체 + +### Step 4. 서비스 로직 - 태그 처리 추가 + +**파일**: `bottlenote-mono/.../alcohols/service/AdminAlcoholCommandService.java` + +**4-1. 의존성 추가** (생성자 주입, `@RequiredArgsConstructor`): + +- `AlcoholsTastingTagsRepository` +- `TastingTagRepository` + +**4-2. `createAlcohol()` 수정** - `save()` 후, `tastingTagIds`가 null이 아니고 비어있지 않으면 태그 연결: + +```java +Alcohol saved = alcoholQueryRepository.save(alcohol); +if(request. + +tastingTagIds() !=null&&!request. + +tastingTagIds(). + +isEmpty()){ + +saveTastingTags(saved, request.tastingTagIds()); + } + +publishImageActivatedEvent(request.imageUrl(),saved. + +getId()); +``` + +**4-3. `updateAlcohol()` 수정** - `alcohol.update()` 후, `tastingTagIds`가 null이 아닐 때만 태그 교체: + +```java +alcohol.update(...); + +if(request. + +tastingTagIds() !=null){ + alcoholsTastingTagsRepository. + +deleteByAlcoholId(alcoholId); + if(!request. + +tastingTagIds(). + +isEmpty()){ + +saveTastingTags(alcohol, request.tastingTagIds()); + } + } + +handleImageChange(oldImageUrl, request.imageUrl(),alcoholId); +``` + +**4-4. private 헬퍼 메서드 추가**: + +```java +private void saveTastingTags(Alcohol alcohol, List tagIds) { + List mappings = tagIds.stream() + .map(tagId -> tastingTagRepository.findById(tagId) + .orElseThrow(() -> new AlcoholException(TASTING_TAG_NOT_FOUND))) + .map(tag -> AlcoholsTastingTags.of(alcohol, tag)) + .toList(); + alcoholsTastingTagsRepository.saveAll(mappings); +} +``` + +기존 `TastingTagService.addAlcoholsToTag()`의 개별 조회 패턴과 동일. + +### Step 5. 테스트 헬퍼 수정 + +**파일**: `bottlenote-admin-api/.../helper/alcohols/AlcoholsHelper.kt` + +`createAlcoholUpsertRequestMap`에 `tastingTagIds` 파라미터 추가: + +```kotlin +fun createAlcoholUpsertRequestMap( + // ... 기존 파라미터 유지 ... + tastingTagIds: List? = null // 기본값 null로 기존 테스트 호환 +): Map = buildMap { + // 기존 필드 put + put("korName", korName) + // ... + tastingTagIds?.let { put("tastingTagIds", it) } +} +``` + +반환 타입 `Map` 유지 (null인 경우 map에 미포함). + +### Step 6. RestDocs 테스트 수정 + +**파일**: `bottlenote-admin-api/.../docs/alcohols/AdminAlcoholsControllerDocsTest.kt` + +`createAlcohol()`, `updateAlcohol()` 테스트의 `requestFields`에 추가: + +```kotlin +fieldWithPath("tastingTagIds").type(JsonFieldType.ARRAY) + .optional().description("테이스팅 태그 ID 목록") +``` + +헬퍼 호출 시 `tastingTagIds = listOf(1L, 2L)` 전달하여 스니펫에 포함. + +### Step 7. 통합 테스트 추가 + +**파일**: `bottlenote-admin-api/.../integration/alcohols/AdminAlcoholsIntegrationTest.kt` + +`TastingTagTestFactory` 의존성 추가 후, 기존 `CreateAlcohol`, `UpdateAlcohol` inner class에 테스트 케이스 추가: + +**CreateAlcohol 추가**: + +- 테이스팅 태그와 함께 위스키를 생성할 수 있다 +- 존재하지 않는 태그 ID로 생성 시 실패한다 + +**UpdateAlcohol 추가**: + +- 수정 시 태그를 교체할 수 있다 (기존 태그 제거 + 새 태그 연결) +- `tastingTagIds` 미포함(null) 시 기존 태그가 유지된다 +- `tastingTagIds=[]` 시 태그가 전부 삭제된다 + +검증 방식: 등록/수정 후 상세 조회 API(`GET /alcohols/{id}`)로 `tastingTags` 배열 확인. + +--- + +## 검증 체크리스트 + +```bash +# 1. 코드 포맷팅 (mono Java만 대상) +./gradlew :bottlenote-mono:spotlessApply + +# 2. Java 컴파일 +./gradlew :bottlenote-mono:compileJava + +# 3. Kotlin 컴파일 +./gradlew :bottlenote-admin-api:compileKotlin +./gradlew :bottlenote-admin-api:compileTestKotlin + +# 4. 단위 테스트 +./gradlew unit_test + +# 5. 아키텍처 규칙 테스트 +./gradlew check_rule_test + +# 6. 통합 테스트 +./gradlew admin_integration_test +./gradlew integration_test + +# 7. RestDocs 문서 생성 +./gradlew :bottlenote-admin-api:asciidoctor + +# 8. 전체 빌드 +./gradlew build +``` + +--- + +## 주의사항 + +- `@Modifying` JPQL `deleteByAlcoholId` 실행 시 1차 캐시 정합성: 동일 트랜잭션에서 삭제 후 `saveAll`은 신규 엔티티 생성이므로 문제 없음 +- `AdminAlcoholUpsertRequest`는 Jackson이 JSON 역직렬화 시 누락 필드를 `null`로 처리하므로 하위 호환성 보장됨 +- `AlcoholsHelper.createAlcoholUpsertRequestMap` 반환 타입 `Map` 유지: `tastingTagIds`가 null이면 map에 미포함 From e5c8e3abe66de771153027f6c2632c39ddc655df Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 11 Mar 2026 17:02:07 +0900 Subject: [PATCH 15/15] =?UTF-8?q?feat:=20=EC=9C=84=EC=8A=A4=ED=82=A4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=88=98=EC=A0=95=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20=EB=B2=84=EC=A0=84=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bottlenote-admin-api/VERSION | 2 +- git.environment-variables | 2 +- ...\354\234\204\354\212\244\355\202\244.http" | 50 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/bottlenote-admin-api/VERSION b/bottlenote-admin-api/VERSION index b0f3d96f8..66c4c2263 100644 --- a/bottlenote-admin-api/VERSION +++ b/bottlenote-admin-api/VERSION @@ -1 +1 @@ -1.0.8 +1.0.9 diff --git a/git.environment-variables b/git.environment-variables index 832b48017..cc43c2e4f 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 832b48017f94c960be42c888b5086aaa5a20099f +Subproject commit cc43c2e4f88c987deb6208330e5911ed8d458e7a diff --git "a/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\234\204\354\212\244\355\202\244.http" "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\234\204\354\212\244\355\202\244.http" index 18ba5f086..1df6af8ca 100644 --- "a/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\234\204\354\212\244\355\202\244.http" +++ "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\234\204\354\212\244\355\202\244.http" @@ -6,3 +6,53 @@ Authorization: Bearer {{accessToken}} @alcoholId = 1 GET {{host}}/alcohols/{{alcoholId}} Authorization: Bearer {{accessToken}} + +### 위스키 생성 +POST {{host}}/alcohols +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "korName": "글렌피딕 12년", + "engName": "Glenfiddich 12 Year", + "abv": "40%", + "type": "WHISKY", + "korCategory": "싱글 몰트", + "engCategory": "Single Malt", + "categoryGroup": "SINGLE_MALT", + "regionId": 1, + "distilleryId": 1, + "age": "12", + "cask": "American Oak", + "imageUrl": "https://example.com/glenfiddich12.jpg", + "description": "스코틀랜드의 대표적인 싱글몰트 위스키", + "volume": "700ml", + "tastingTagIds": [1, 2, 3] +} + +### 위스키 수정 +PUT {{host}}/alcohols/{{alcoholId}} +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "korName": "글렌피딕 18년", + "engName": "Glenfiddich 18 Year", + "abv": "43%", + "type": "WHISKY", + "korCategory": "싱글 몰트", + "engCategory": "Single Malt", + "categoryGroup": "SINGLE_MALT", + "regionId": 1, + "distilleryId": 1, + "age": "18", + "cask": "Sherry Oak", + "imageUrl": "https://example.com/glenfiddich18.jpg", + "description": "18년 숙성 싱글몰트 위스키", + "volume": "700ml", + "tastingTagIds": [1, 2] +} + +### 위스키 삭제 +DELETE {{host}}/alcohols/{{alcoholId}} +Authorization: Bearer {{accessToken}}