From f24238e59a8a90b39453e64961a71467128887a2 Mon Sep 17 00:00:00 2001 From: krSeonghyeon <149303551+krSeonghyeon@users.noreply.github.com> Date: Tue, 12 Nov 2024 14:40:08 +0900 Subject: [PATCH 01/14] =?UTF-8?q?=08feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=83=81=EC=A0=90=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1,=20=EC=88=98=EC=A0=95=EC=8B=9C=20=EC=83=81?= =?UTF-8?q?=EC=9C=84=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#1010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feature: main에서 parent로 명칭 변경 및 모든 상위카테고리 조회 api 추가 * feature: 어드민 카테고리 생성, 수정, 조회 필드 추가 및 로직 변경 * test: 모든 상위 카테고리를 조회하는 테스트코드 작성 및 기존 테스트코드 수정 * chore: 변수명 변경 * chore: static import 적용 --- .../admin/shop/controller/AdminShopApi.java | 15 +++++ .../shop/controller/AdminShopController.java | 10 +++ .../dto/AdminCreateShopCategoryRequest.java | 13 +++- .../dto/AdminModifyShopCategoryRequest.java | 9 ++- ...inModifyShopReviewReportStatusRequest.java | 4 +- .../shop/dto/AdminShopCategoryResponse.java | 8 ++- .../dto/AdminShopParentCategoryResponse.java | 25 +++++++ .../shop/dto/AdminShopsReviewsResponse.java | 4 +- .../ShopParentCategoryNotFoundException.java | 20 ++++++ .../AdminShopCategoryRepository.java | 2 + ...dminShopNotificationMessageRepository.java | 10 +++ .../AdminShopParentCategoryRepository.java | 23 +++++++ .../admin/shop/service/AdminShopService.java | 28 ++++++-- .../domain/shop/model/shop/ShopCategory.java | 10 +-- .../model/shop/ShopNotificationMessage.java | 8 +++ ...nCategory.java => ShopParentCategory.java} | 12 +++- .../ShopNotificationBufferRepository.java | 2 +- .../service/NotificationScheduleService.java | 2 +- ..._alter_shop_main_categories_table_name.sql | 15 +++++ .../koin/acceptance/OwnerShopApiTest.java | 18 ++++- .../koin/acceptance/ShopApiTest.java | 20 +++++- .../admin/acceptance/AdminShopApiTest.java | 67 ++++++++++++++++--- .../koin/fixture/ShopCategoryFixture.java | 7 +- .../ShopNotificationMessageFixture.java | 37 ++++++++++ .../fixture/ShopParentCategoryFixture.java | 36 ++++++++++ 25 files changed, 368 insertions(+), 37 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopParentCategoryResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/exception/ShopParentCategoryNotFoundException.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopNotificationMessageRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopParentCategoryRepository.java rename src/main/java/in/koreatech/koin/domain/shop/model/shop/{ShopMainCategory.java => ShopParentCategory.java} (75%) create mode 100644 src/main/resources/db/migration/V93__alter_shop_main_categories_table_name.sql create mode 100644 src/test/java/in/koreatech/koin/fixture/ShopNotificationMessageFixture.java create mode 100644 src/test/java/in/koreatech/koin/fixture/ShopParentCategoryFixture.java diff --git a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java index cf16ce094..0348b173d 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java +++ b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java @@ -3,6 +3,8 @@ import static in.koreatech.koin.domain.user.model.UserType.ADMIN; import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; +import java.util.List; + import in.koreatech.koin.admin.shop.dto.*; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -89,6 +91,19 @@ ResponseEntity getShopCategory( @Auth(permit = {ADMIN}) Integer adminId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점의 모든 상위 카테고리 조회") + @GetMapping("/admin/shops/parent-categories") + ResponseEntity> getShopParentCategories( + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), diff --git a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java index da6807a72..8f2c7b70c 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java +++ b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java @@ -3,6 +3,8 @@ import static in.koreatech.koin.domain.user.model.UserType.ADMIN; import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; +import java.util.List; + import in.koreatech.koin.admin.shop.dto.*; import io.swagger.v3.oas.annotations.Operation; import org.springframework.http.HttpStatus; @@ -67,6 +69,14 @@ public ResponseEntity getShopCategory( return ResponseEntity.ok(response); } + @GetMapping("/admin/shops/parent-categories") + public ResponseEntity> getShopParentCategories( + @Auth(permit = {ADMIN}) Integer adminId + ) { + List responses = adminShopService.getShopParentCategories(); + return ResponseEntity.ok(responses); + } + @GetMapping("/admin/shops/{id}/menus") public ResponseEntity getAllMenus( @Parameter(in = PATH) @PathVariable("id") Integer shopId, diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java index f9f146345..1d892d5bc 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java @@ -1,12 +1,16 @@ package in.koreatech.koin.admin.shop.dto; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; import in.koreatech.koin.domain.shop.model.shop.ShopCategory; +import in.koreatech.koin.domain.shop.model.shop.ShopParentCategory; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @JsonNaming(SnakeCaseStrategy.class) @@ -19,13 +23,18 @@ public record AdminCreateShopCategoryRequest( @Schema(description = "이름", example = "햄버거", requiredMode = RequiredMode.REQUIRED) @NotBlank(message = "카테고리명은 필수입니다.") @Size(min = 1, max = 25, message = "이름은 1자 이상, 25자 이하로 입력해주세요.") - String name + String name, + + @Schema(description = "상위 카테고리 id", example = "1", requiredMode = REQUIRED) + @NotNull(message = "상위 카테고리는 필수입니다.") + Integer parentCategoryId ) { - public ShopCategory toShopCategory() { + public ShopCategory toShopCategory(ShopParentCategory shopParentCategory) { return ShopCategory.builder() .imageUrl(imageUrl) .name(name) + .parentCategory(shopParentCategory) .build(); } } diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoryRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoryRequest.java index 0ff53f5ce..554dd7187 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoryRequest.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoryRequest.java @@ -1,11 +1,14 @@ package in.koreatech.koin.admin.shop.dto; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @JsonNaming(SnakeCaseStrategy.class) @@ -18,7 +21,11 @@ public record AdminModifyShopCategoryRequest( @Schema(description = "이름", example = "햄버거", requiredMode = RequiredMode.REQUIRED) @NotBlank(message = "카테고리명은 필수입니다.") @Size(min = 1, max = 25, message = "이름은 1자 이상, 25자 이하로 입력해주세요.") - String name + String name, + + @Schema(description = "상위 카테고리 id", example = "1", requiredMode = REQUIRED) + @NotNull(message = "상위 카테고리는 필수입니다.") + Integer parentCategoryId ) { } diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopReviewReportStatusRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopReviewReportStatusRequest.java index 9c4bf71f0..89599cce8 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopReviewReportStatusRequest.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopReviewReportStatusRequest.java @@ -2,14 +2,14 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; -import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; import in.koreatech.koin.domain.shop.model.review.ReportStatus; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; -@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonNaming(SnakeCaseStrategy.class) public record AdminModifyShopReviewReportStatusRequest( @Schema(description = "신고 상태", example = "DISMISSED", requiredMode = REQUIRED) @NotNull(message = "변경하려는 상태는 필수입니다.") diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoryResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoryResponse.java index 874e8e6cb..521b5046b 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoryResponse.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoryResponse.java @@ -15,14 +15,18 @@ public record AdminShopCategoryResponse( String imageUrl, @Schema(description = "카테고리 이름", example = "string") - String name + String name, + + @Schema(description = "상위 카테고리 ID", example = "1") + Integer parentCategoryId ) { public static AdminShopCategoryResponse from(ShopCategory shopCategory) { return new AdminShopCategoryResponse( shopCategory.getId(), shopCategory.getImageUrl(), - shopCategory.getName() + shopCategory.getName(), + shopCategory.getParentCategory().getId() ); } } diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopParentCategoryResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopParentCategoryResponse.java new file mode 100644 index 000000000..a2276ba5a --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopParentCategoryResponse.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.admin.shop.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.*; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.shop.ShopParentCategory; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminShopParentCategoryResponse( + @Schema(description = "상위 카테고리 고유 ID", example = "1") + int id, + + @Schema(description = "상위 카테고리 이름", example = "가게") + String name +) { + + public static AdminShopParentCategoryResponse from(ShopParentCategory shopParentCategory) { + return new AdminShopParentCategoryResponse( + shopParentCategory.getId(), + shopParentCategory.getName() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopsReviewsResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopsReviewsResponse.java index f6a0e6696..183431470 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopsReviewsResponse.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopsReviewsResponse.java @@ -1,7 +1,6 @@ package in.koreatech.koin.admin.shop.dto; import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; import in.koreatech.koin.domain.shop.model.review.ShopReview; @@ -17,10 +16,11 @@ import java.util.List; import java.util.Optional; +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.*; import static in.koreatech.koin.domain.shop.model.review.ReportStatus.UNHANDLED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; -@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonNaming(value = SnakeCaseStrategy.class) public record AdminShopsReviewsResponse( @Schema(description = "총 상점의 수", example = "57", requiredMode = REQUIRED) diff --git a/src/main/java/in/koreatech/koin/admin/shop/exception/ShopParentCategoryNotFoundException.java b/src/main/java/in/koreatech/koin/admin/shop/exception/ShopParentCategoryNotFoundException.java new file mode 100644 index 000000000..975431ee4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/exception/ShopParentCategoryNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.shop.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class ShopParentCategoryNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 상위 카테고리입니다."; + + public ShopParentCategoryNotFoundException(String message) { + super(message); + } + + public ShopParentCategoryNotFoundException(String message, String detail) { + super(message, detail); + } + + public static ShopParentCategoryNotFoundException withDetail(String detail) { + return new ShopParentCategoryNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java index 12e68ada2..a8804081c 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java @@ -14,6 +14,8 @@ public interface AdminShopCategoryRepository extends Repository { + boolean existsByNameAndIdNot(String name, Integer shopCategoryId); + Page findAll(Pageable pageable); @Query(value = "SELECT * FROM shop_categories WHERE id = :shopCategoryId", nativeQuery = true) diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopNotificationMessageRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopNotificationMessageRepository.java new file mode 100644 index 000000000..a1fb048cd --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopNotificationMessageRepository.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.admin.shop.repository; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationMessage; + +public interface AdminShopNotificationMessageRepository extends Repository { + + ShopNotificationMessage save(ShopNotificationMessage shopNotificationMessage); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopParentCategoryRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopParentCategoryRepository.java new file mode 100644 index 000000000..50d8aa866 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopParentCategoryRepository.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.admin.shop.exception.ShopParentCategoryNotFoundException; +import in.koreatech.koin.domain.shop.model.shop.ShopParentCategory; + +public interface AdminShopParentCategoryRepository extends Repository { + + ShopParentCategory save(ShopParentCategory shopParentCategory); + + Optional findById(Integer shopParentCategoryId); + + default ShopParentCategory getById(Integer shopParentCategoryId) { + return findById(shopParentCategoryId) + .orElseThrow(() -> ShopParentCategoryNotFoundException.withDetail("shopParentCategoryId: " + shopParentCategoryId)); + } + + List findAll(); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java index 359d3eb50..3442f415c 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java +++ b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java @@ -36,6 +36,7 @@ import in.koreatech.koin.domain.shop.model.shop.ShopCategoryMap; import in.koreatech.koin.domain.shop.model.shop.ShopImage; import in.koreatech.koin.domain.shop.model.shop.ShopOpen; +import in.koreatech.koin.domain.shop.model.shop.ShopParentCategory; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import in.koreatech.koin.global.model.Criteria; import jakarta.persistence.EntityManager; @@ -52,6 +53,7 @@ public class AdminShopService { private final AdminMenuCategoryRepository adminMenuCategoryRepository; private final AdminShopCategoryMapRepository adminShopCategoryMapRepository; private final AdminShopCategoryRepository adminShopCategoryRepository; + private final AdminShopParentCategoryRepository adminShopParentCategoryRepository; private final AdminShopImageRepository adminShopImageRepository; private final AdminShopOpenRepository adminShopOpenRepository; private final AdminShopRepository adminShopRepository; @@ -91,6 +93,13 @@ public AdminShopCategoryResponse getShopCategory(Integer categoryId) { return AdminShopCategoryResponse.from(shopCategory); } + public List getShopParentCategories() { + List shopParentCategories = adminShopParentCategoryRepository.findAll(); + return shopParentCategories.stream() + .map(AdminShopParentCategoryResponse::from) + .toList(); + } + public AdminShopMenuResponse getAllMenus(Integer shopId) { Shop shop = adminShopRepository.getById(shopId); List menuCategories = adminMenuCategoryRepository.findAllByShopId(shop.getId()); @@ -159,7 +168,9 @@ public void createShopCategory(AdminCreateShopCategoryRequest adminCreateShopCat if (adminShopCategoryRepository.findByName(adminCreateShopCategoryRequest.name()).isPresent()) { throw ShopCategoryDuplicationException.withDetail("name: " + adminCreateShopCategoryRequest.name()); } - ShopCategory shopCategory = adminCreateShopCategoryRequest.toShopCategory(); + ShopParentCategory shopParentCategory = + adminShopParentCategoryRepository.getById(adminCreateShopCategoryRequest.parentCategoryId()); + ShopCategory shopCategory = adminCreateShopCategoryRequest.toShopCategory(shopParentCategory); adminShopCategoryRepository.save(shopCategory); } @@ -245,16 +256,23 @@ public void modifyShop(Integer shopId, AdminModifyShopRequest adminModifyShopReq @Transactional public void modifyShopCategory(Integer categoryId, AdminModifyShopCategoryRequest adminModifyShopCategoryRequest) { - if (adminShopCategoryRepository.findByName(adminModifyShopCategoryRequest.name()).isPresent()) { - throw ShopCategoryDuplicationException.withDetail("name: " + adminModifyShopCategoryRequest.name()); - } + validateExistCategoryName(adminModifyShopCategoryRequest.name(), categoryId); ShopCategory shopCategory = adminShopCategoryRepository.getById(categoryId); + ShopParentCategory shopParentCategory = + adminShopParentCategoryRepository.getById(adminModifyShopCategoryRequest.parentCategoryId()); shopCategory.modifyShopCategory( adminModifyShopCategoryRequest.name(), - adminModifyShopCategoryRequest.imageUrl() + adminModifyShopCategoryRequest.imageUrl(), + shopParentCategory ); } + private void validateExistCategoryName(String name, Integer categoryId) { + if (adminShopCategoryRepository.existsByNameAndIdNot(name, categoryId)) { + throw ShopCategoryDuplicationException.withDetail("name: " + name); + } + } + @Transactional public void modifyMenuCategory(Integer shopId, AdminModifyMenuCategoryRequest adminModifyMenuCategoryRequest) { adminShopRepository.getById(shopId); diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java index 05a4de29e..39ed53da1 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java @@ -45,17 +45,19 @@ public class ShopCategory extends BaseEntity { private List shopCategoryMaps = new ArrayList<>(); @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "main_category_id", referencedColumnName = "id") - private ShopMainCategory mainCategory; + @JoinColumn(name = "parent_category_id", referencedColumnName = "id") + private ShopParentCategory parentCategory; @Builder - private ShopCategory(String name, String imageUrl) { + private ShopCategory(String name, String imageUrl, ShopParentCategory parentCategory) { this.name = name; this.imageUrl = imageUrl; + this.parentCategory = parentCategory; } - public void modifyShopCategory(String name, String imageUrl) { + public void modifyShopCategory(String name, String imageUrl, ShopParentCategory parentCategory) { this.name = name; this.imageUrl = imageUrl; + this.parentCategory = parentCategory; } } diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationMessage.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationMessage.java index 0f629e0fb..0b3d504cb 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationMessage.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationMessage.java @@ -10,6 +10,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import jakarta.validation.constraints.Size; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -30,4 +31,11 @@ public class ShopNotificationMessage extends BaseEntity { @Size(max = 255) @Column(name = "content", nullable = false) private String content; + + @Builder + private ShopNotificationMessage(Integer id, String title, String content) { + this.id = id; + this.title = title; + this.content = content; + } } diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopMainCategory.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopParentCategory.java similarity index 75% rename from src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopMainCategory.java rename to src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopParentCategory.java index 3602b96a4..6fabc7ee3 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopMainCategory.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopParentCategory.java @@ -14,14 +14,15 @@ import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @Entity @NoArgsConstructor(access = PROTECTED) -@Table(name = "shop_main_categories") -public class ShopMainCategory extends BaseEntity { +@Table(name = "shop_parent_categories") +public class ShopParentCategory extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -35,4 +36,11 @@ public class ShopMainCategory extends BaseEntity { @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "notification_message_id", referencedColumnName = "id", nullable = false) private ShopNotificationMessage notificationMessage; + + @Builder + private ShopParentCategory(Integer id, String name, ShopNotificationMessage notificationMessage) { + this.id = id; + this.name = name; + this.notificationMessage = notificationMessage; + } } diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopNotificationBufferRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopNotificationBufferRepository.java index f4ccd8c24..2ae1ed267 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopNotificationBufferRepository.java +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopNotificationBufferRepository.java @@ -19,7 +19,7 @@ public interface ShopNotificationBufferRepository extends Repository shopCategory.getMainCategory().getNotificationMessage()) + .map(shopCategory -> shopCategory.getParentCategory().getNotificationMessage()) .orElseThrow(() -> NotificationMessageNotFoundException.withDetail("shopId: " + shop.getId())); return notificationFactory.generateReviewPromptNotification( diff --git a/src/main/resources/db/migration/V93__alter_shop_main_categories_table_name.sql b/src/main/resources/db/migration/V93__alter_shop_main_categories_table_name.sql new file mode 100644 index 000000000..2c34d3625 --- /dev/null +++ b/src/main/resources/db/migration/V93__alter_shop_main_categories_table_name.sql @@ -0,0 +1,15 @@ +RENAME TABLE `shop_main_categories` TO `shop_parent_categories`; + +ALTER TABLE `shop_categories` + CHANGE `main_category_id` `parent_category_id` INT UNSIGNED COMMENT '상위 카테고리 id'; + +ALTER TABLE `shop_categories` +DROP FOREIGN KEY `FK_SHOP_CATEGORIES_ON_SHOP_MAIN_CATEGORIES`; + +ALTER TABLE `shop_categories` + ADD CONSTRAINT `FK_SHOP_CATEGORIES_ON_SHOP_PARENT_CATEGORIES` + FOREIGN KEY (`parent_category_id`) + REFERENCES `shop_parent_categories` (`id`); + +ALTER TABLE `shop_parent_categories` + MODIFY `id` INT UNSIGNED AUTO_INCREMENT COMMENT 'shop_parent_categories 고유 id'; diff --git a/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java index bbe5cb785..86244c6a3 100644 --- a/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java @@ -34,7 +34,9 @@ import in.koreatech.koin.domain.shop.model.shop.ShopCategory; import in.koreatech.koin.domain.shop.model.shop.ShopCategoryMap; import in.koreatech.koin.domain.shop.model.shop.ShopImage; +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationMessage; import in.koreatech.koin.domain.shop.model.shop.ShopOpen; +import in.koreatech.koin.domain.shop.model.shop.ShopParentCategory; import in.koreatech.koin.domain.shop.repository.event.EventArticleRepository; import in.koreatech.koin.domain.shop.repository.menu.MenuCategoryRepository; import in.koreatech.koin.domain.shop.repository.menu.MenuRepository; @@ -44,6 +46,8 @@ import in.koreatech.koin.fixture.MenuFixture; import in.koreatech.koin.fixture.ShopCategoryFixture; import in.koreatech.koin.fixture.ShopFixture; +import in.koreatech.koin.fixture.ShopNotificationMessageFixture; +import in.koreatech.koin.fixture.ShopParentCategoryFixture; import in.koreatech.koin.fixture.UserFixture; import jakarta.transaction.Transactional; @@ -79,6 +83,12 @@ class OwnerShopApiTest extends AcceptanceTest { @Autowired private ShopCategoryFixture shopCategoryFixture; + @Autowired + private ShopParentCategoryFixture shopParentCategoryFixture; + + @Autowired + private ShopNotificationMessageFixture shopNotificationMessageFixture;; + @Autowired private MenuCategoryFixture menuCategoryFixture; @@ -94,6 +104,8 @@ class OwnerShopApiTest extends AcceptanceTest { private ShopCategory shopCategory_일반; private MenuCategory menuCategory_메인; private MenuCategory menuCategory_사이드; + private ShopParentCategory shopParentCategory_가게; + private ShopNotificationMessage notificationMessage_가게; @BeforeAll void setUp() { @@ -103,10 +115,12 @@ void setUp() { owner_준영 = userFixture.준영_사장님(); token_준영 = userFixture.getToken(owner_준영.getUser()); shop_마슬랜 = shopFixture.마슬랜(owner_현수); - shopCategory_치킨 = shopCategoryFixture.카테고리_치킨(); - shopCategory_일반 = shopCategoryFixture.카테고리_일반음식(); menuCategory_메인 = menuCategoryFixture.메인메뉴(shop_마슬랜); menuCategory_사이드 = menuCategoryFixture.사이드메뉴(shop_마슬랜); + notificationMessage_가게 = shopNotificationMessageFixture.알림메시지_가게(); + shopParentCategory_가게 = shopParentCategoryFixture.상위_카테고리_가게(notificationMessage_가게); + shopCategory_치킨 = shopCategoryFixture.카테고리_치킨(shopParentCategory_가게); + shopCategory_일반 = shopCategoryFixture.카테고리_일반음식(shopParentCategory_가게); } @Test diff --git a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java index ff291d402..235423f90 100644 --- a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java @@ -20,12 +20,16 @@ import in.koreatech.koin.domain.shop.model.menu.Menu; import in.koreatech.koin.domain.shop.model.review.ShopReview; import in.koreatech.koin.domain.shop.model.shop.Shop; +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationMessage; +import in.koreatech.koin.domain.shop.model.shop.ShopParentCategory; import in.koreatech.koin.domain.student.model.Student; import in.koreatech.koin.fixture.EventArticleFixture; import in.koreatech.koin.fixture.MenuCategoryFixture; import in.koreatech.koin.fixture.MenuFixture; import in.koreatech.koin.fixture.ShopCategoryFixture; import in.koreatech.koin.fixture.ShopFixture; +import in.koreatech.koin.fixture.ShopNotificationMessageFixture; +import in.koreatech.koin.fixture.ShopParentCategoryFixture; import in.koreatech.koin.fixture.ShopReviewFixture; import in.koreatech.koin.fixture.ShopReviewReportFixture; import in.koreatech.koin.fixture.UserFixture; @@ -59,12 +63,21 @@ class ShopApiTest extends AcceptanceTest { @Autowired private ShopCategoryFixture shopCategoryFixture; + @Autowired + private ShopParentCategoryFixture shopParentCategoryFixture; + + @Autowired + private ShopNotificationMessageFixture shopNotificationMessageFixture; + private Shop 마슬랜; private Owner owner; private Student 익명_학생; private String token_익명; + private ShopParentCategory shopParentCategory_가게; + private ShopNotificationMessage notificationMessage_가게; + @BeforeAll void setUp() { clear(); @@ -72,6 +85,9 @@ void setUp() { 마슬랜 = shopFixture.마슬랜(owner); 익명_학생 = userFixture.익명_학생(); token_익명 = userFixture.getToken(익명_학생.getUser()); + + notificationMessage_가게 = shopNotificationMessageFixture.알림메시지_가게(); + shopParentCategory_가게 = shopParentCategoryFixture.상위_카테고리_가게(notificationMessage_가게); } @Test @@ -403,8 +419,8 @@ void setUp() { @Test void 상점들의_모든_카테고리를_조회한다() throws Exception { - shopCategoryFixture.카테고리_일반음식(); - shopCategoryFixture.카테고리_치킨(); + shopCategoryFixture.카테고리_일반음식(shopParentCategory_가게); + shopCategoryFixture.카테고리_치킨(shopParentCategory_가게); mockMvc.perform( get("/shops/categories") diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java index c72b8434b..49960ff5f 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java @@ -21,6 +21,7 @@ import in.koreatech.koin.admin.shop.repository.AdminMenuCategoryRepository; import in.koreatech.koin.admin.shop.repository.AdminMenuRepository; import in.koreatech.koin.admin.shop.repository.AdminShopCategoryRepository; +import in.koreatech.koin.admin.shop.repository.AdminShopParentCategoryRepository; import in.koreatech.koin.admin.shop.repository.AdminShopRepository; import in.koreatech.koin.admin.user.model.Admin; import in.koreatech.koin.domain.owner.model.Owner; @@ -33,11 +34,15 @@ import in.koreatech.koin.domain.shop.model.shop.ShopCategory; import in.koreatech.koin.domain.shop.model.shop.ShopCategoryMap; import in.koreatech.koin.domain.shop.model.shop.ShopImage; +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationMessage; import in.koreatech.koin.domain.shop.model.shop.ShopOpen; +import in.koreatech.koin.domain.shop.model.shop.ShopParentCategory; import in.koreatech.koin.fixture.MenuCategoryFixture; import in.koreatech.koin.fixture.MenuFixture; import in.koreatech.koin.fixture.ShopCategoryFixture; import in.koreatech.koin.fixture.ShopFixture; +import in.koreatech.koin.fixture.ShopNotificationMessageFixture; +import in.koreatech.koin.fixture.ShopParentCategoryFixture; import in.koreatech.koin.fixture.UserFixture; import jakarta.persistence.EntityManager; @@ -55,6 +60,9 @@ class AdminShopApiTest extends AcceptanceTest { @Autowired private AdminShopCategoryRepository adminShopCategoryRepository; + @Autowired + private AdminShopParentCategoryRepository adminShopParentCategoryRepository; + @Autowired private AdminShopRepository adminShopRepository; @@ -76,6 +84,12 @@ class AdminShopApiTest extends AcceptanceTest { @Autowired private ShopCategoryFixture shopCategoryFixture; + @Autowired + private ShopParentCategoryFixture shopParentCategoryFixture; + + @Autowired + private ShopNotificationMessageFixture shopNotificationMessageFixture; + @Autowired private MenuCategoryFixture menuCategoryFixture; @@ -88,6 +102,10 @@ class AdminShopApiTest extends AcceptanceTest { private ShopCategory shopCategory_일반; private MenuCategory menuCategory_메인; private MenuCategory menuCategory_사이드; + private ShopParentCategory shopParentCategory_가게; + private ShopParentCategory shopParentCategory_콜벤; + private ShopNotificationMessage notificationMessage_가게; + private ShopNotificationMessage notificationMessage_콜벤; @BeforeAll void setUp() { @@ -97,10 +115,14 @@ void setUp() { owner_현수 = userFixture.현수_사장님(); owner_준영 = userFixture.준영_사장님(); shop_마슬랜 = shopFixture.마슬랜(owner_현수); - shopCategory_치킨 = shopCategoryFixture.카테고리_치킨(); - shopCategory_일반 = shopCategoryFixture.카테고리_일반음식(); menuCategory_메인 = menuCategoryFixture.메인메뉴(shop_마슬랜); menuCategory_사이드 = menuCategoryFixture.사이드메뉴(shop_마슬랜); + notificationMessage_가게 = shopNotificationMessageFixture.알림메시지_가게(); + notificationMessage_콜벤 = shopNotificationMessageFixture.알림메시지_콜벤(); + shopParentCategory_가게 = shopParentCategoryFixture.상위_카테고리_가게(notificationMessage_가게); + shopParentCategory_콜벤 = shopParentCategoryFixture.상위_카테고리_콜벤(notificationMessage_콜벤); + shopCategory_치킨 = shopCategoryFixture.카테고리_치킨(shopParentCategory_가게); + shopCategory_일반 = shopCategoryFixture.카테고리_일반음식(shopParentCategory_콜벤); } @Test @@ -210,7 +232,6 @@ void setUp() { get("/admin/shops/categories") .header("Authorization", "Bearer " + token_admin) .param("page", "1") - .param("is_deleted", "false") ) .andExpect(status().isOk()) .andExpect(jsonPath("$.total_count").value(14)) @@ -222,7 +243,7 @@ void setUp() { @Test void 어드민이_상점의_특정_카테고리를_조회한다() throws Exception { - ShopCategory shopCategory = shopCategoryFixture.카테고리_치킨(); + ShopCategory shopCategory = shopCategoryFixture.카테고리_치킨(shopParentCategory_가게); mockMvc.perform( get("/admin/shops/categories/{id}", shopCategory.getId()) @@ -233,11 +254,33 @@ void setUp() { { "id": 3, "image_url": "https://test-image.com/ckicken.jpg", - "name": "치킨" + "name": "치킨", + "parent_category_id": 1 } """)); } + @Test + void 어드민이_상점의_모든_상위_카테고리를_조회한다() throws Exception { + mockMvc.perform( + get("/admin/shops/parent-categories") + .header("Authorization", "Bearer " + token_admin) + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" + [ + { + "id": 1, + "name": "가게" + }, + { + "id": 2, + "name": "콜벤" + } + ] + """)); + } + @Test void 어드민이_특정_상점의_모든_메뉴를_조회한다() throws Exception { // given @@ -445,7 +488,8 @@ void setUp() { .content(""" { "image_url": "https://image.png", - "name": "새로운 카테고리" + "name": "새로운 카테고리", + "parent_category_id": "1" } """) ) @@ -453,10 +497,12 @@ void setUp() { transactionTemplate.executeWithoutResult(status -> { ShopCategory result = adminShopCategoryRepository.getById(3); + ShopParentCategory shopParentCategory = adminShopParentCategoryRepository.getById(1); assertSoftly( softly -> { softly.assertThat(result.getImageUrl()).isEqualTo("https://image.png"); softly.assertThat(result.getName()).isEqualTo("새로운 카테고리"); + softly.assertThat(result.getParentCategory()).isEqualTo(shopParentCategory); } ); }); @@ -697,7 +743,7 @@ void setUp() { @Test void 어드민이_상점_카테고리를_수정한다() throws Exception { - ShopCategory shopCategory = shopCategoryFixture.카테고리_일반음식(); + ShopCategory shopCategory = shopCategoryFixture.카테고리_일반음식(shopParentCategory_가게); mockMvc.perform( put("/admin/shops/categories/{id}", shopCategory.getId()) @@ -706,7 +752,8 @@ void setUp() { .content(""" { "image_url": "http://image.png", - "name": "수정된 카테고리 이름" + "name": "수정된 카테고리 이름", + "parent_category_id": "2" } """) ) @@ -714,11 +761,13 @@ void setUp() { transactionTemplate.executeWithoutResult(status -> { ShopCategory updatedCategory = adminShopCategoryRepository.getById(shopCategory.getId()); + ShopParentCategory shopParentCategory = adminShopParentCategoryRepository.getById(2); assertSoftly( softly -> { softly.assertThat(updatedCategory.getId()).isEqualTo(shopCategory.getId()); softly.assertThat(updatedCategory.getImageUrl()).isEqualTo("http://image.png"); softly.assertThat(updatedCategory.getName()).isEqualTo("수정된 카테고리 이름"); + softly.assertThat(updatedCategory.getParentCategory()).isEqualTo(shopParentCategory); } ); }); @@ -887,7 +936,7 @@ void setUp() { @Test void 어드민이_상점_카테고리를_삭제한다() throws Exception { - ShopCategory shopCategory = shopCategoryFixture.카테고리_치킨(); + ShopCategory shopCategory = shopCategoryFixture.카테고리_치킨(shopParentCategory_가게); mockMvc.perform( delete("/admin/shops/categories/{id}", shopCategory.getId()) diff --git a/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java b/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java index c02fb73e2..5b4f02cea 100644 --- a/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java @@ -3,6 +3,7 @@ import org.springframework.stereotype.Component; import in.koreatech.koin.domain.shop.model.shop.ShopCategory; +import in.koreatech.koin.domain.shop.model.shop.ShopParentCategory; import in.koreatech.koin.domain.shop.repository.shop.ShopCategoryRepository; @Component @@ -15,20 +16,22 @@ public ShopCategoryFixture(ShopCategoryRepository categoryRepository) { this.categoryRepository = categoryRepository; } - public ShopCategory 카테고리_치킨() { + public ShopCategory 카테고리_치킨(ShopParentCategory parentCategory) { return categoryRepository.save( ShopCategory.builder() .name("치킨") .imageUrl("https://test-image.com/ckicken.jpg") + .parentCategory(parentCategory) .build() ); } - public ShopCategory 카테고리_일반음식() { + public ShopCategory 카테고리_일반음식(ShopParentCategory parentCategory) { return categoryRepository.save( ShopCategory.builder() .name("일반음식점") .imageUrl("https://test-image.com/normal.jpg") + .parentCategory(parentCategory) .build() ); } diff --git a/src/test/java/in/koreatech/koin/fixture/ShopNotificationMessageFixture.java b/src/test/java/in/koreatech/koin/fixture/ShopNotificationMessageFixture.java new file mode 100644 index 000000000..e59530269 --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/ShopNotificationMessageFixture.java @@ -0,0 +1,37 @@ +package in.koreatech.koin.fixture; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.admin.shop.repository.AdminShopNotificationMessageRepository; +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationMessage; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public class ShopNotificationMessageFixture { + + private final AdminShopNotificationMessageRepository adminShopNotificationMessageRepository; + + public ShopNotificationMessageFixture( + AdminShopNotificationMessageRepository adminShopNotificationMessageRepository + ) { + this.adminShopNotificationMessageRepository = adminShopNotificationMessageRepository; + } + + public ShopNotificationMessage 알림메시지_가게() { + return adminShopNotificationMessageRepository.save( + ShopNotificationMessage.builder() + .title(",맛있게 드셨나요?") + .content("가게에 리뷰를 남겨보세요!") + .build() + ); + } + + public ShopNotificationMessage 알림메시지_콜벤() { + return adminShopNotificationMessageRepository.save( + ShopNotificationMessage.builder() + .title(",편안히 이용하셨나요?") + .content("리뷰를 남겨보세요!") + .build() + ); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/ShopParentCategoryFixture.java b/src/test/java/in/koreatech/koin/fixture/ShopParentCategoryFixture.java new file mode 100644 index 000000000..5579214c7 --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/ShopParentCategoryFixture.java @@ -0,0 +1,36 @@ +package in.koreatech.koin.fixture; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.admin.shop.repository.AdminShopParentCategoryRepository; +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationMessage; +import in.koreatech.koin.domain.shop.model.shop.ShopParentCategory; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public class ShopParentCategoryFixture { + + private final AdminShopParentCategoryRepository shopParentCategoryRepository; + + public ShopParentCategoryFixture(AdminShopParentCategoryRepository shopParentCategoryRepository) { + this.shopParentCategoryRepository = shopParentCategoryRepository; + } + + public ShopParentCategory 상위_카테고리_가게(ShopNotificationMessage notificationMessage) { + return shopParentCategoryRepository.save( + ShopParentCategory.builder() + .name("가게") + .notificationMessage(notificationMessage) + .build() + ); + } + + public ShopParentCategory 상위_카테고리_콜벤(ShopNotificationMessage notificationMessage) { + return shopParentCategoryRepository.save( + ShopParentCategory.builder() + .name("콜벤") + .notificationMessage(notificationMessage) + .build() + ); + } +} From ecd99e7ef5c7c2a9e2f779156cca7caf144d1640 Mon Sep 17 00:00:00 2001 From: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Date: Tue, 12 Nov 2024 14:51:24 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20=EC=A3=BC=EB=B3=80=EC=83=81?= =?UTF-8?q?=EC=A0=90=20=EB=A9=94=EB=89=B4=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20API=20=EC=9E=91=EC=84=B1=20(#999)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Shop모델을 캐싱 * feat: 메뉴 연관검색어를 저장하는 Entity작성 * feat: 메뉴명, 상점명과 관련된 연관검색어 조회기능 작성 * feat: 메뉴 키워드 테이블 생성 sql작성 * feat: 상점 캐싱 및 입력 query 공백제거 * refactor: Menu의 Shop을 shopId로 변경 * refactor: Repository @Parm없는것들 붙여줌 * refactor: jpql Param 붙임 * refactor: 리뷰 반영 * refactor: 리뷰 반영 * refactor: 요구사항 변경에 따라 검색 키워드에 해당하는 상점들 반환하도록 변경 * chore: 돌 해결 * chore: 테스트 어노테이션 추가 * chore: flyway충돌 해결 * refactor: api경로 수정 * refactor: api설명 추가 * chore: q클래스 삭제 * refactor: DTO에 SnakeCaseStrategy추가 * test: 테스트의 응답을 스네이크케이스로 변경 * test: 테스트의 응답을 스네이크케이스로 변경 * test: 테스트 api 경로 수정 --------- Co-authored-by: HyeonsuLee --- .../in/koreatech/koin/KoinApplication.java | 2 + .../AdminBenefitCategoryMapRepository.java | 9 +- .../repository/AdminMemberRepository.java | 10 +- .../AdminKoinArticleRepository.java | 3 +- .../AdminKoreatechArticleRepository.java | 3 +- .../shop/dto/AdminCreateMenuRequest.java | 9 +- .../shop/dto/AdminMenuDetailResponse.java | 4 +- .../shop/repository/AdminShopRepository.java | 2 +- .../admin/shop/service/AdminShopService.java | 6 +- .../user/repository/AdminOwnerRepository.java | 2 +- .../user/repository/AdminUserRepository.java | 7 +- .../ownershop/service/OwnerShopService.java | 10 +- .../shop/cache/ShopRedisRepository.java | 32 + .../domain/shop/cache/ShopsCacheService.java | 29 + .../shop/cache/dto/EventArticleCache.java | 20 + .../koin/domain/shop/cache/dto/ShopCache.java | 97 ++ .../shop/cache/dto/ShopCategoryCache.java | 18 + .../domain/shop/cache/dto/ShopOpenCache.java | 21 + .../shop/cache/dto/ShopReviewCache.java | 24 + .../shop/cache/dto/ShopReviewReportCache.java | 21 + .../domain/shop/cache/dto/ShopsCache.java | 13 + .../koin/domain/shop/controller/ShopApi.java | 32 +- .../shop/controller/ShopController.java | 51 +- .../shop/dto/menu/CreateMenuRequest.java | 9 +- .../shop/dto/menu/MenuDetailResponse.java | 4 +- .../shop/dto/search/RelatedKeyword.java | 43 + .../domain/shop/dto/shop/ShopsResponseV2.java | 151 ++- .../koin/domain/shop/model/menu/Menu.java | 28 +- .../shop/model/menu/MenuSearchKeyWord.java | 39 + .../koin/domain/shop/model/shop/Shop.java | 4 + .../shop/repository/menu/MenuRepository.java | 6 +- .../menu/MenuSearchKeywordRepository.java | 15 + .../shop/repository/shop/ShopRepository.java | 11 +- .../domain/shop/service/SearchService.java | 65 + .../koin/domain/shop/service/ShopService.java | 38 +- .../koin/global/config/RedisConfig.java | 6 +- .../global/scheduler/CacheShopsScheduler.java | 17 + .../V93__add_menu_search_keyword_table.sql | 8 + .../V94__add_menu_search_keyword_table.sql | 8 + .../V95__insert_initial_menu_keywords.sql | 1174 +++++++++++++++++ .../koin/acceptance/OwnerShopApiTest.java | 2 +- .../koin/acceptance/ShopApiTest.java | 45 +- .../koin/acceptance/ShopSearchApiTest.java | 124 ++ .../koreatech/koin/fixture/MenuFixture.java | 39 +- 44 files changed, 2086 insertions(+), 175 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/shop/cache/ShopRedisRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/cache/ShopsCacheService.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/cache/dto/EventArticleCache.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopCache.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopCategoryCache.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopOpenCache.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopReviewCache.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopReviewReportCache.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopsCache.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/dto/search/RelatedKeyword.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/model/menu/MenuSearchKeyWord.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/repository/menu/MenuSearchKeywordRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/service/SearchService.java create mode 100644 src/main/java/in/koreatech/koin/global/scheduler/CacheShopsScheduler.java create mode 100644 src/main/resources/db/migration/V93__add_menu_search_keyword_table.sql create mode 100644 src/main/resources/db/migration/V94__add_menu_search_keyword_table.sql create mode 100644 src/main/resources/db/migration/V95__insert_initial_menu_keywords.sql create mode 100644 src/test/java/in/koreatech/koin/acceptance/ShopSearchApiTest.java diff --git a/src/main/java/in/koreatech/koin/KoinApplication.java b/src/main/java/in/koreatech/koin/KoinApplication.java index 6908a96b4..20aa0a74a 100644 --- a/src/main/java/in/koreatech/koin/KoinApplication.java +++ b/src/main/java/in/koreatech/koin/KoinApplication.java @@ -3,11 +3,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.scheduling.annotation.EnableScheduling; @EnableScheduling @SpringBootApplication @ConfigurationPropertiesScan +@EnableCaching public class KoinApplication { public static void main(String[] args) { diff --git a/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java b/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java index ef2ba5f82..8b33c83d3 100644 --- a/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java +++ b/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.repository.Repository; import in.koreatech.koin.domain.benefit.model.BenefitCategoryMap; +import org.springframework.data.repository.query.Param; public interface AdminBenefitCategoryMapRepository extends Repository { @@ -19,7 +20,7 @@ public interface AdminBenefitCategoryMapRepository extends Repository findAllByBenefitCategoryIdOrderByShopName(Integer benefitId); + List findAllByBenefitCategoryIdOrderByShopName(@Param("benefitId") Integer benefitId); @Modifying @Query(""" @@ -27,13 +28,15 @@ public interface AdminBenefitCategoryMapRepository extends Repository shopIds); + void deleteByBenefitCategoryIdAndShopIds( + @Param("benefitId") Integer benefitId, + @Param("shopIds") List shopIds); @Modifying @Query(""" DELETE FROM BenefitCategoryMap bcm WHERE bcm.benefitCategory.id = :benefitId """) - void deleteByBenefitCategoryId(Integer benefitId); + void deleteByBenefitCategoryId(@Param("benefitId") Integer benefitId); } diff --git a/src/main/java/in/koreatech/koin/admin/member/repository/AdminMemberRepository.java b/src/main/java/in/koreatech/koin/admin/member/repository/AdminMemberRepository.java index 2a23b719c..4bd51b56f 100644 --- a/src/main/java/in/koreatech/koin/admin/member/repository/AdminMemberRepository.java +++ b/src/main/java/in/koreatech/koin/admin/member/repository/AdminMemberRepository.java @@ -11,16 +11,22 @@ import in.koreatech.koin.domain.member.exception.MemberNotFoundException; import in.koreatech.koin.domain.member.model.Member; +import org.springframework.data.repository.query.Param; public interface AdminMemberRepository extends Repository { @EntityGraph(attributePaths = {"track"}) @Query("select m from Member m where m.track.name = :trackName and m.isDeleted = :isDeleted") - Page findAllByTrackAndIsDeleted(String trackName, Boolean isDeleted, Pageable pageable); + Page findAllByTrackAndIsDeleted( + @Param("trackName") String trackName, + @Param("isDeleted") Boolean isDeleted, + Pageable pageable); @EntityGraph(attributePaths = {"track"}) @Query("select count(m) from Member m where m.track.name = :trackName and m.isDeleted = :isDeleted") - Integer countAllByTrackAndIsDeleted(String trackName, Boolean isDeleted); + Integer countAllByTrackAndIsDeleted( + @Param("trackName") String trackName, + @Param("isDeleted") Boolean isDeleted); Member save(Member member); diff --git a/src/main/java/in/koreatech/koin/admin/notice/repository/AdminKoinArticleRepository.java b/src/main/java/in/koreatech/koin/admin/notice/repository/AdminKoinArticleRepository.java index 69c6f21fe..53a927615 100644 --- a/src/main/java/in/koreatech/koin/admin/notice/repository/AdminKoinArticleRepository.java +++ b/src/main/java/in/koreatech/koin/admin/notice/repository/AdminKoinArticleRepository.java @@ -6,9 +6,10 @@ import org.springframework.data.repository.Repository; import in.koreatech.koin.domain.community.article.model.KoinArticle; +import org.springframework.data.repository.query.Param; public interface AdminKoinArticleRepository extends Repository { @Query(value = "SELECT * FROM new_koin_articles WHERE article_id = :noticeId", nativeQuery = true) - Optional findByArticleId(Integer noticeId); + Optional findByArticleId(@Param("noticeId") Integer noticeId); } diff --git a/src/main/java/in/koreatech/koin/admin/notice/repository/AdminKoreatechArticleRepository.java b/src/main/java/in/koreatech/koin/admin/notice/repository/AdminKoreatechArticleRepository.java index 27ca8ff66..b619caff8 100644 --- a/src/main/java/in/koreatech/koin/admin/notice/repository/AdminKoreatechArticleRepository.java +++ b/src/main/java/in/koreatech/koin/admin/notice/repository/AdminKoreatechArticleRepository.java @@ -6,9 +6,10 @@ import org.springframework.data.repository.Repository; import in.koreatech.koin.domain.community.article.model.KoreatechArticle; +import org.springframework.data.repository.query.Param; public interface AdminKoreatechArticleRepository extends Repository { @Query(value = "SELECT * FROM new_koreatech_articles WHERE article_id = :noticeId", nativeQuery = true) - Optional findByArticleId(Integer noticeId); + Optional findByArticleId(@Param("noticeId") Integer noticeId); } diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuRequest.java index 525526686..65baa32be 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuRequest.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuRequest.java @@ -4,11 +4,9 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; -import java.util.List; - import com.fasterxml.jackson.databind.annotation.JsonNaming; - import in.koreatech.koin.domain.shop.model.menu.Menu; +import in.koreatech.koin.domain.shop.model.shop.Shop; import in.koreatech.koin.global.validation.NotBlankElement; import in.koreatech.koin.global.validation.SingleMenuPrice; import in.koreatech.koin.global.validation.UniqueId; @@ -18,6 +16,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; +import java.util.List; @JsonNaming(value = SnakeCaseStrategy.class) @SingleMenuPrice @@ -58,10 +57,10 @@ public record AdminCreateMenuRequest( Integer singlePrice ) { - public Menu toEntity(Integer shopId) { + public Menu toEntity(Shop shop) { return Menu.builder() .name(name) - .shopId(shopId) + .shop(shop) .description(description) .build(); } diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuDetailResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuDetailResponse.java index b003b9597..f15854e08 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuDetailResponse.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuDetailResponse.java @@ -58,7 +58,7 @@ public static AdminMenuDetailResponse createForSingleOption(Menu menu, List searchByName(String searchKeyword); + List searchByName(@Param("searchKeyword") String searchKeyword); } diff --git a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java index 3442f415c..e7489afb0 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java +++ b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java @@ -176,8 +176,8 @@ public void createShopCategory(AdminCreateShopCategoryRequest adminCreateShopCat @Transactional public void createMenu(Integer shopId, AdminCreateMenuRequest adminCreateMenuRequest) { - adminShopRepository.getById(shopId); - Menu menu = adminCreateMenuRequest.toEntity(shopId); + Shop shop = adminShopRepository.getById(shopId); + Menu menu = adminCreateMenuRequest.toEntity(shop); Menu savedMenu = adminMenuRepository.save(menu); for (Integer categoryId : adminCreateMenuRequest.categoryIds()) { MenuCategory menuCategory = adminMenuCategoryRepository.getById(categoryId); @@ -329,7 +329,7 @@ public void deleteMenuCategory(Integer shopId, Integer categoryId) { @Transactional public void deleteMenu(Integer shopId, Integer menuId) { Menu menu = adminMenuRepository.getById(menuId); - if (!Objects.equals(menu.getShopId(), shopId)) { + if (!Objects.equals(menu.getShop().getId(), shopId)) { throw new KoinIllegalArgumentException("해당 상점의 카테고리가 아닙니다."); } adminMenuRepository.deleteById(menuId); diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java index 1ebc65c9c..7b4ae2209 100644 --- a/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java +++ b/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java @@ -10,7 +10,7 @@ import in.koreatech.koin.domain.owner.exception.OwnerNotFoundException; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.user.model.UserType; -import io.lettuce.core.dynamic.annotation.Param; +import org.springframework.data.repository.query.Param; public interface AdminOwnerRepository extends Repository { diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminUserRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminUserRepository.java index cb2941451..b11470156 100644 --- a/src/main/java/in/koreatech/koin/admin/user/repository/AdminUserRepository.java +++ b/src/main/java/in/koreatech/koin/admin/user/repository/AdminUserRepository.java @@ -8,12 +8,13 @@ import in.koreatech.koin.domain.user.exception.UserNotFoundException; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.model.UserType; +import org.springframework.data.repository.query.Param; public interface AdminUserRepository extends Repository { User save(User user); - Optional findByEmail(String Email); + Optional findByEmail(String email); Optional findById(Integer id); @@ -22,7 +23,9 @@ SELECT COUNT(u) FROM User u WHERE u.userType = :userType AND u.isAuthed = :isAuthed """) - Integer findUsersCountByUserTypeAndIsAuthed(UserType userType, Boolean isAuthed); + Integer findUsersCountByUserTypeAndIsAuthed( + @Param("userType") UserType userType, + @Param("isAuthed") Boolean isAuthed); default User getByEmail(String email) { return findByEmail(email) diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/service/OwnerShopService.java b/src/main/java/in/koreatech/koin/domain/ownershop/service/OwnerShopService.java index d4715f18a..d96c59240 100644 --- a/src/main/java/in/koreatech/koin/domain/ownershop/service/OwnerShopService.java +++ b/src/main/java/in/koreatech/koin/domain/ownershop/service/OwnerShopService.java @@ -142,7 +142,7 @@ private Shop getOwnerShopById(Integer shopId, Integer ownerId) { public MenuDetailResponse getMenuByMenuId(Integer ownerId, Integer menuId) { Menu menu = menuRepository.getById(menuId); - getOwnerShopById(menu.getShopId(), ownerId); + getOwnerShopById(menu.getShop().getId(), ownerId); List menuCategories = menu.getMenuCategoryMaps() .stream() .map(MenuCategoryMap::getMenuCategory) @@ -166,7 +166,7 @@ public MenuCategoriesResponse getCategories(Integer shopId, Integer ownerId) { @Transactional public void deleteMenuByMenuId(Integer ownerId, Integer menuId) { Menu menu = menuRepository.getById(menuId); - getOwnerShopById(menu.getShopId(), ownerId); + getOwnerShopById(menu.getShop().getId(), ownerId); menuRepository.deleteById(menuId); } @@ -179,8 +179,8 @@ public void deleteCategory(Integer ownerId, Integer categoryId) { @Transactional public void createMenu(Integer shopId, Integer ownerId, CreateMenuRequest createMenuRequest) { - getOwnerShopById(shopId, ownerId); - Menu menu = createMenuRequest.toEntity(shopId); + Shop shop = getOwnerShopById(shopId, ownerId); + Menu menu = createMenuRequest.toEntity(shop); Menu savedMenu = menuRepository.save(menu); for (Integer categoryId : createMenuRequest.categoryIds()) { MenuCategory menuCategory = menuCategoryRepository.getById(categoryId); @@ -229,7 +229,7 @@ public void createMenuCategory(Integer shopId, Integer ownerId, CreateCategoryRe @Transactional public void modifyMenu(Integer ownerId, Integer menuId, ModifyMenuRequest modifyMenuRequest) { Menu menu = menuRepository.getById(menuId); - getOwnerShopById(menu.getShopId(), ownerId); + getOwnerShopById(menu.getShop().getId(), ownerId); menu.modifyMenu( modifyMenuRequest.name(), modifyMenuRequest.description() diff --git a/src/main/java/in/koreatech/koin/domain/shop/cache/ShopRedisRepository.java b/src/main/java/in/koreatech/koin/domain/shop/cache/ShopRedisRepository.java new file mode 100644 index 000000000..61a2a846a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/cache/ShopRedisRepository.java @@ -0,0 +1,32 @@ +package in.koreatech.koin.domain.shop.cache; + +import com.fasterxml.jackson.databind.ObjectMapper; +import in.koreatech.koin.domain.shop.cache.dto.ShopsCache; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Getter +@RequiredArgsConstructor +@Repository +public class ShopRedisRepository { + + private static final String KEY = "Shops"; + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public void save(ShopsCache shopsCache) { + redisTemplate.opsForValue().set(KEY, shopsCache); + } + + public ShopsCache getShopsResponseByRedis() { + Object data = redisTemplate.opsForValue().get(KEY); + return objectMapper.convertValue(data, ShopsCache.class); + } + + public Boolean isCacheAvailable() { + return redisTemplate.hasKey(KEY); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/cache/ShopsCacheService.java b/src/main/java/in/koreatech/koin/domain/shop/cache/ShopsCacheService.java new file mode 100644 index 000000000..9a65d05c2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/cache/ShopsCacheService.java @@ -0,0 +1,29 @@ +package in.koreatech.koin.domain.shop.cache; + +import in.koreatech.koin.domain.shop.cache.dto.ShopsCache; +import in.koreatech.koin.domain.shop.repository.shop.ShopRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class ShopsCacheService { + + private final ShopRepository shopRepository; + private final ShopRedisRepository shopRedisRepository; + + public ShopsCache findAllShopCache() { + if (shopRedisRepository.isCacheAvailable()) { + return shopRedisRepository.getShopsResponseByRedis(); + } + return refreshShopsCache(); + } + + public ShopsCache refreshShopsCache() { + ShopsCache shopsCache = ShopsCache.from(shopRepository.findAll()); + shopRedisRepository.save(shopsCache); + return shopsCache; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/cache/dto/EventArticleCache.java b/src/main/java/in/koreatech/koin/domain/shop/cache/dto/EventArticleCache.java new file mode 100644 index 000000000..e68bb61af --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/cache/dto/EventArticleCache.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.shop.cache.dto; + +import in.koreatech.koin.domain.shop.model.article.EventArticle; +import in.koreatech.koin.domain.shop.model.article.EventArticleImage; +import java.util.List; + +public record EventArticleCache( + String title, + List thumbnailImages +) { + + public static EventArticleCache from(EventArticle eventArticle) { + return new EventArticleCache( + eventArticle.getTitle(), + eventArticle.getThumbnailImages().stream() + .map(EventArticleImage::getThumbnailImage) + .toList() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopCache.java b/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopCache.java new file mode 100644 index 000000000..be5d057b6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopCache.java @@ -0,0 +1,97 @@ +package in.koreatech.koin.domain.shop.cache.dto; + +import in.koreatech.koin.domain.shop.model.menu.Menu; +import in.koreatech.koin.domain.shop.model.shop.Shop; +import in.koreatech.koin.domain.shop.model.shop.ShopImage; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.TextStyle; +import java.util.List; +import java.util.Locale; + +public record ShopCache( + Integer id, + String name, + String internalName, + String chosung, + String phone, + String address, + String description, + Boolean delivery, + Integer deliveryPrice, + boolean payCard, + boolean payBank, + boolean isDeleted, + boolean isEvent, + String remarks, + Integer hit, + List shopCategories, + List shopOpens, + List shopImages, + List eventArticles, + List reviews, + List menuNames, + String bank, + String accountNumber +) { + + public static ShopCache from( + Shop shop + ) { + return new ShopCache( + shop.getId(), + shop.getName(), + shop.getInternalName(), + shop.getChosung(), + shop.getPhone(), + shop.getAddress(), + shop.getDescription(), + shop.getDelivery(), + shop.getDeliveryPrice(), + shop.isPayCard(), + shop.isPayBank(), + shop.isDeleted(), + shop.isEvent(), + shop.getRemarks(), + shop.getHit(), + shop.getShopCategories().stream().map(ShopCategoryCache::from).toList(), + shop.getShopOpens().stream().map(ShopOpenCache::from).toList(), + shop.getShopImages().stream().map(ShopImage::getImageUrl).toList(), + shop.getEventArticles().stream().map(EventArticleCache::from).toList(), + shop.getReviews().stream().map(ShopReviewCache::from).toList(), + shop.getMenus().stream().map(Menu::getName).toList(), + shop.getBank(), + shop.getAccountNumber() + ); + } + + public boolean isOpen(LocalDateTime now) { + String currDayOfWeek = now.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.US).toUpperCase(); + String prevDayOfWeek = now.minusDays(1).getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.US).toUpperCase(); + for (ShopOpenCache shopOpen : shopOpens) { + if (shopOpen.closed()) { + continue; + } + if (shopOpen.dayOfWeek().equals(currDayOfWeek) && + isBetweenDate(now, shopOpen, now.toLocalDate()) + ) { + return true; + } + if (shopOpen.dayOfWeek().equals(prevDayOfWeek) && + isBetweenDate(now, shopOpen, now.minusDays(1).toLocalDate()) + ) { + return true; + } + } + return false; + } + + private boolean isBetweenDate(LocalDateTime now, ShopOpenCache shopOpen, LocalDate criteriaDate) { + LocalDateTime start = LocalDateTime.of(criteriaDate, shopOpen.openTime()); + LocalDateTime end = LocalDateTime.of(criteriaDate, shopOpen.closeTime()); + if (!shopOpen.closeTime().isAfter(shopOpen.openTime())) { + end = end.plusDays(1); + } + return !start.isAfter(now) && !end.isBefore(now); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopCategoryCache.java b/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopCategoryCache.java new file mode 100644 index 000000000..bf06b9d5d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopCategoryCache.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.domain.shop.cache.dto; + +import in.koreatech.koin.domain.shop.model.shop.ShopCategoryMap; + +public record ShopCategoryCache( + Integer id, + String name, + String imageUrl +) { + + public static ShopCategoryCache from(ShopCategoryMap shopCategoryMap) { + return new ShopCategoryCache( + shopCategoryMap.getId(), + shopCategoryMap.getShopCategory().getName(), + shopCategoryMap.getShopCategory().getImageUrl() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopOpenCache.java b/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopOpenCache.java new file mode 100644 index 000000000..e613889b8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopOpenCache.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.domain.shop.cache.dto; + +import in.koreatech.koin.domain.shop.model.shop.ShopOpen; +import java.time.LocalTime; + +public record ShopOpenCache( + String dayOfWeek, + boolean closed, + LocalTime openTime, + LocalTime closeTime +) { + + public static ShopOpenCache from(ShopOpen shopOpen) { + return new ShopOpenCache( + shopOpen.getDayOfWeek(), + shopOpen.isClosed(), + shopOpen.getOpenTime(), + shopOpen.getCloseTime() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopReviewCache.java b/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopReviewCache.java new file mode 100644 index 000000000..e55489b7f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopReviewCache.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.domain.shop.cache.dto; + +import in.koreatech.koin.domain.shop.model.review.ShopReview; +import in.koreatech.koin.domain.shop.model.review.ShopReviewImage; +import java.util.List; + +public record ShopReviewCache( + String content, + Integer rating, + Integer reviewerId, + List images, + List reports +) { + + public static ShopReviewCache from(ShopReview shopReview) { + return new ShopReviewCache( + shopReview.getContent(), + shopReview.getRating(), + shopReview.getReviewer().getId(), + shopReview.getImages().stream().map(ShopReviewImage::getImageUrls).toList(), + shopReview.getReports().stream().map(ShopReviewReportCache::from).toList() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopReviewReportCache.java b/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopReviewReportCache.java new file mode 100644 index 000000000..af13df42a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopReviewReportCache.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.domain.shop.cache.dto; + +import in.koreatech.koin.domain.shop.model.review.ReportStatus; +import in.koreatech.koin.domain.shop.model.review.ShopReviewReport; + +public record ShopReviewReportCache( + String title, + String content, + ReportStatus reportStatus, + Integer userId +) { + + public static ShopReviewReportCache from(ShopReviewReport shopReviewReport) { + return new ShopReviewReportCache( + shopReviewReport.getTitle(), + shopReviewReport.getContent(), + shopReviewReport.getReportStatus(), + shopReviewReport.getUserId().getId() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopsCache.java b/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopsCache.java new file mode 100644 index 000000000..bf4712ed7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopsCache.java @@ -0,0 +1,13 @@ +package in.koreatech.koin.domain.shop.cache.dto; + +import in.koreatech.koin.domain.shop.model.shop.Shop; +import java.util.List; + +public record ShopsCache( + List shopCaches +) { + + public static ShopsCache from(List shops) { + return new ShopsCache(shops.stream().map(ShopCache::from).toList()); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java index b6e4f336e..a2a39b54a 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java +++ b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java @@ -3,6 +3,7 @@ import static in.koreatech.koin.domain.user.model.UserType.STUDENT; import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; +import in.koreatech.koin.domain.shop.dto.search.RelatedKeyword; import java.util.List; import org.springframework.http.ResponseEntity; @@ -283,7 +284,36 @@ ResponseEntity reportReview( @GetMapping("/v2/shops") ResponseEntity getShopsV2( @RequestParam(name = "sorter", defaultValue = "NONE") ShopsSortCriteria sortBy, - @RequestParam(name = "filter") List shopsFilterCriterias + @RequestParam(name = "filter") List shopsFilterCriterias, + @RequestParam(name = "query", required = false) String query + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation( + summary = "주변상점 검색어에 따른 연관검색어 조회", + description = """ + ### 검색어와 유사한 이름의 음식을 조회 + - 검색어와 관련된 음식의 이름을 조회합니다. ex) 김 → 김치찌개, 김치짜글이 + - 이때 shopIds 필드는 해당 음식과 관련된 상점의 ID를 반환합니다.(관련된 = 해당 음식 이름의 메뉴를 판매중 or 상점에 해당 음식 이름이 포함) + - 이 경우 음식과 관련된 것으로 조회하는 것이기 때문에 shopId는 null입니다. + ### 검색어와 유사한 이름의 상점명을 가진 상점 조회 + - 검색어와 유사한 상점 이름이 조회됩니다. ex) 즐 → 즐겨먹기 + - 이 경우 shopIds는 빈 배열, shopId는 해당 상점의 ID를 값으로 가집니다. + + **shopId가 null인 경우: 연관검색어가 음식 이름인 경우** + **shopId가 이 아닌 경우: 연관검색어가 상점 이름인 경우** + """ + ) + @GetMapping("/shops/search/related/{query}") + ResponseEntity getRelatedKeyword( + @PathVariable(name = "query") String query ); @ApiResponses( diff --git a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java index 363a07b01..940df7def 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java +++ b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java @@ -3,45 +3,45 @@ import static in.koreatech.koin.domain.user.model.UserType.STUDENT; import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; -import java.util.Collections; -import java.util.List; - -import org.springframework.http.HttpStatus; -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.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.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import in.koreatech.koin.domain.shop.dto.review.CreateReviewRequest; import in.koreatech.koin.domain.shop.dto.menu.MenuCategoriesResponse; import in.koreatech.koin.domain.shop.dto.menu.MenuDetailResponse; +import in.koreatech.koin.domain.shop.dto.menu.ShopMenuResponse; +import in.koreatech.koin.domain.shop.dto.review.CreateReviewRequest; import in.koreatech.koin.domain.shop.dto.review.ModifyReviewRequest; import in.koreatech.koin.domain.shop.dto.review.ReviewsSortCriteria; -import in.koreatech.koin.domain.shop.dto.shop.ShopCategoriesResponse; -import in.koreatech.koin.domain.shop.dto.shop.ShopEventsResponse; -import in.koreatech.koin.domain.shop.dto.menu.ShopMenuResponse; import in.koreatech.koin.domain.shop.dto.review.ShopMyReviewsResponse; -import in.koreatech.koin.domain.shop.dto.shop.ShopResponse; import in.koreatech.koin.domain.shop.dto.review.ShopReviewReportCategoryResponse; import in.koreatech.koin.domain.shop.dto.review.ShopReviewReportRequest; import in.koreatech.koin.domain.shop.dto.review.ShopReviewResponse; import in.koreatech.koin.domain.shop.dto.review.ShopReviewsResponse; +import in.koreatech.koin.domain.shop.dto.search.RelatedKeyword; +import in.koreatech.koin.domain.shop.dto.shop.ShopCategoriesResponse; +import in.koreatech.koin.domain.shop.dto.shop.ShopEventsResponse; +import in.koreatech.koin.domain.shop.dto.shop.ShopResponse; import in.koreatech.koin.domain.shop.dto.shop.ShopsFilterCriteria; import in.koreatech.koin.domain.shop.dto.shop.ShopsResponse; import in.koreatech.koin.domain.shop.dto.shop.ShopsResponseV2; import in.koreatech.koin.domain.shop.dto.shop.ShopsSortCriteria; +import in.koreatech.koin.domain.shop.service.SearchService; import in.koreatech.koin.domain.shop.service.ShopReviewService; import in.koreatech.koin.domain.shop.service.ShopService; import in.koreatech.koin.global.auth.Auth; import in.koreatech.koin.global.auth.UserId; import io.swagger.v3.oas.annotations.Parameter; import jakarta.validation.Valid; +import java.util.Collections; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.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.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor @@ -49,6 +49,7 @@ public class ShopController implements ShopApi { private final ShopService shopService; private final ShopReviewService shopReviewService; + private final SearchService searchService; @GetMapping("/shops/{shopId}/menus/{menuId}") public ResponseEntity findMenu( @@ -183,12 +184,13 @@ public ResponseEntity reportReview( @GetMapping("/v2/shops") public ResponseEntity getShopsV2( @RequestParam(name = "sorter", defaultValue = "NONE") ShopsSortCriteria sortBy, - @RequestParam(name = "filter", required = false) List shopsFilterCriterias + @RequestParam(name = "filter", required = false) List shopsFilterCriterias, + @RequestParam(name = "query", required = false, defaultValue = "") String query ) { if (shopsFilterCriterias == null) { shopsFilterCriterias = Collections.emptyList(); } - var shops = shopService.getShopsV2(sortBy, shopsFilterCriterias); + var shops = shopService.getShopsV2(sortBy, shopsFilterCriterias, query); return ResponseEntity.ok(shops); } @@ -201,6 +203,13 @@ public ResponseEntity getReview( return ResponseEntity.ok(shopReviewResponse); } + @GetMapping("/shops/search/related/{query}") + public ResponseEntity getRelatedKeyword( + @PathVariable(name = "query") String query + ) { + return ResponseEntity.ok(searchService.getRelatedKeywordByQuery(query)); + } + @PostMapping("/shops/{shopId}/call-notification") public ResponseEntity createCallNotification( @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/menu/CreateMenuRequest.java b/src/main/java/in/koreatech/koin/domain/shop/dto/menu/CreateMenuRequest.java index 16a5ba40f..169062e1e 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/dto/menu/CreateMenuRequest.java +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/menu/CreateMenuRequest.java @@ -4,11 +4,9 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; -import java.util.List; - import com.fasterxml.jackson.databind.annotation.JsonNaming; - import in.koreatech.koin.domain.shop.model.menu.Menu; +import in.koreatech.koin.domain.shop.model.shop.Shop; import in.koreatech.koin.global.validation.NotBlankElement; import in.koreatech.koin.global.validation.SingleMenuPrice; import in.koreatech.koin.global.validation.UniqueId; @@ -18,6 +16,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; +import java.util.List; @JsonNaming(value = SnakeCaseStrategy.class) @SingleMenuPrice @@ -59,10 +58,10 @@ public record CreateMenuRequest( Integer singlePrice ) { - public Menu toEntity(Integer shopId) { + public Menu toEntity(Shop shop) { return Menu.builder() .name(name) - .shopId(shopId) + .shop(shop) .description(description) .build(); } diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/menu/MenuDetailResponse.java b/src/main/java/in/koreatech/koin/domain/shop/dto/menu/MenuDetailResponse.java index a5ddf85a2..8a8353abf 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/dto/menu/MenuDetailResponse.java +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/menu/MenuDetailResponse.java @@ -58,7 +58,7 @@ public static MenuDetailResponse createForSingleOption(Menu menu, List keywords +) { + public static RelatedKeyword of(List menuKeywords, List shopKeywords) { + Set keywords = new TreeSet<>(shopKeywords); + keywords.addAll(menuKeywords); + return new RelatedKeyword( + keywords.stream() + .filter(innerKeyword -> !innerKeyword.shopIds.isEmpty() || innerKeyword.shopId != null) + .limit(5) + .collect(Collectors.toCollection(TreeSet::new)) + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerKeyword( + String keyword, + List shopIds, + Integer shopId + ) implements Comparable { + + @Override + public int compareTo(InnerKeyword other) { + if (this.shopId != null && other.shopId == null) { + return -1; + } + if (this.shopId == null && other.shopId != null) { + return 1; + } + return this.keyword.compareTo(other.keyword); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopsResponseV2.java b/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopsResponseV2.java index 9b13162ea..d1b89e205 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopsResponseV2.java +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopsResponseV2.java @@ -4,117 +4,124 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import in.koreatech.koin.domain.shop.cache.dto.ShopCache; +import in.koreatech.koin.domain.shop.cache.dto.ShopCategoryCache; +import in.koreatech.koin.domain.shop.repository.shop.dto.ShopInfoV2; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; import java.util.Comparator; import java.util.List; import java.util.Map; - -import com.fasterxml.jackson.databind.annotation.JsonNaming; - -import in.koreatech.koin.domain.shop.model.shop.Shop; -import in.koreatech.koin.domain.shop.repository.shop.dto.ShopInfoV2; -import io.swagger.v3.oas.annotations.media.Schema; +import java.util.function.Predicate; @JsonNaming(value = SnakeCaseStrategy.class) public record ShopsResponseV2( - @Schema(example = "100", description = "상점 개수", requiredMode = REQUIRED) - Integer count, + @Schema(example = "100", description = "상점 개수", requiredMode = REQUIRED) + Integer count, - @Schema(description = "상점 정보") - List shops + @Schema(description = "상점 정보") + List shops ) { + private static Predicate queryPredicate(String query) { + String trimmedQuery = query.replaceAll(" ", ""); + return (shop -> + shop.name().contains(trimmedQuery) || shop.menuNames().stream().anyMatch(s -> s.contains(trimmedQuery)) + ); + } + public static ShopsResponseV2 from( - List shops, - Map shopInfoMap, - ShopsSortCriteria sortBy, - List shopsFilterCriterias, - LocalDateTime now + List shops, + Map shopInfoMap, + ShopsSortCriteria sortBy, + List shopsFilterCriterias, + LocalDateTime now, + String query ) { List innerShopResponses = shops.stream() - .map(it -> { - ShopInfoV2 shopInfo = shopInfoMap.get(it.getId()); - return InnerShopResponse.from( - it, - shopInfo.durationEvent(), - it.isOpen(now), - shopInfo.averageRate(), - shopInfo.reviewCount() - ); - }) - .filter(ShopsFilterCriteria.createCombinedFilter(shopsFilterCriterias)) - .sorted(InnerShopResponse.getComparator(sortBy)) - .toList(); + .filter(queryPredicate(query)) + .map(it -> { + ShopInfoV2 shopInfo = shopInfoMap.get(it.id()); + return InnerShopResponse.from( + it, + shopInfo.durationEvent(), + it.isOpen(now), + shopInfo.averageRate(), + shopInfo.reviewCount() + ); + }) + .filter(ShopsFilterCriteria.createCombinedFilter(shopsFilterCriterias)) + .sorted(InnerShopResponse.getComparator(sortBy)) + .toList(); return new ShopsResponseV2( - innerShopResponses.size(), - innerShopResponses + innerShopResponses.size(), + innerShopResponses ); } @JsonNaming(value = SnakeCaseStrategy.class) public record InnerShopResponse( - @Schema(example = "[1, 2, 3]", description = "속해있는 상점 카테고리들의 고유 id 리스트", requiredMode = NOT_REQUIRED) - List categoryIds, + @Schema(example = "[1, 2, 3]", description = "속해있는 상점 카테고리들의 고유 id 리스트", requiredMode = NOT_REQUIRED) + List categoryIds, - @Schema(example = "true", description = "배달 가능 여부", requiredMode = REQUIRED) - boolean delivery, + @Schema(example = "true", description = "배달 가능 여부", requiredMode = REQUIRED) + boolean delivery, - @Schema(example = "1", description = "고유 id", requiredMode = REQUIRED) - Integer id, + @Schema(example = "1", description = "고유 id", requiredMode = REQUIRED) + Integer id, - @Schema(example = "수신반점", description = "이름", requiredMode = REQUIRED) - String name, + @Schema(example = "수신반점", description = "이름", requiredMode = REQUIRED) + String name, - @Schema(example = "true", description = "계좌 이체 가능 여부", requiredMode = REQUIRED) - boolean payBank, + @Schema(example = "true", description = "계좌 이체 가능 여부", requiredMode = REQUIRED) + boolean payBank, - @Schema(example = "true", description = "카드 계산 가능 여부", requiredMode = REQUIRED) - boolean payCard, + @Schema(example = "true", description = "카드 계산 가능 여부", requiredMode = REQUIRED) + boolean payCard, - @Schema(example = "041-000-0000", description = "전화번호", requiredMode = NOT_REQUIRED) - String phone, + @Schema(example = "041-000-0000", description = "전화번호", requiredMode = NOT_REQUIRED) + String phone, - @Schema(example = "true", description = "삭제 여부", requiredMode = REQUIRED) - boolean isEvent, + @Schema(example = "true", description = "삭제 여부", requiredMode = REQUIRED) + boolean isEvent, - @Schema(example = "true", description = "운영중 여부", requiredMode = REQUIRED) - boolean isOpen, + @Schema(example = "true", description = "운영중 여부", requiredMode = REQUIRED) + boolean isOpen, - @Schema(example = "4.9", description = "평균 별점", requiredMode = REQUIRED) - double averageRate, + @Schema(example = "4.9", description = "평균 별점", requiredMode = REQUIRED) + double averageRate, - @Schema(example = "10", description = "리뷰 개수", requiredMode = REQUIRED) - long reviewCount + @Schema(example = "10", description = "리뷰 개수", requiredMode = REQUIRED) + long reviewCount ) { public static InnerShopResponse from( - Shop shop, - Boolean isEvent, - Boolean isOpen, - Double averageRate, - Long reviewCount + ShopCache shop, + Boolean isEvent, + Boolean isOpen, + Double averageRate, + Long reviewCount ) { return new InnerShopResponse( - shop.getShopCategories().stream().map(shopCategoryMap -> - shopCategoryMap.getShopCategory().getId() - ).toList(), - shop.getDelivery(), - shop.getId(), - shop.getName(), - shop.isPayBank(), - shop.isPayCard(), - shop.getPhone(), - isEvent, - isOpen, - averageRate, - reviewCount + shop.shopCategories().stream().map(ShopCategoryCache::id).toList(), + shop.delivery(), + shop.id(), + shop.name(), + shop.payBank(), + shop.payCard(), + shop.phone(), + isEvent, + isOpen, + averageRate, + reviewCount ); } public static Comparator getComparator(ShopsSortCriteria shopsSortCriteria) { return Comparator - .comparing(InnerShopResponse::isOpen, Comparator.reverseOrder()) - .thenComparing(shopsSortCriteria.getComparator()); + .comparing(InnerShopResponse::isOpen, Comparator.reverseOrder()) + .thenComparing(shopsSortCriteria.getComparator()); } } } diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/menu/Menu.java b/src/main/java/in/koreatech/koin/domain/shop/model/menu/Menu.java index 8c1d180a6..7e02af3d3 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/menu/Menu.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/menu/Menu.java @@ -4,22 +4,25 @@ import static jakarta.persistence.GenerationType.IDENTITY; import static lombok.AccessLevel.PROTECTED; -import java.util.ArrayList; -import java.util.List; - import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest; import in.koreatech.koin.domain.shop.dto.menu.ModifyMenuRequest; import in.koreatech.koin.domain.shop.dto.menu.ModifyMenuRequest.InnerOptionPrice; +import in.koreatech.koin.domain.shop.model.shop.Shop; import in.koreatech.koin.global.domain.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EntityManager; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import java.util.ArrayList; +import java.util.List; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -36,9 +39,9 @@ public class Menu extends BaseEntity { @GeneratedValue(strategy = IDENTITY) private Integer id; - @NotNull - @Column(name = "shop_id", nullable = false) - private Integer shopId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_id", referencedColumnName = "id", nullable = false) + private Shop shop; @Size(max = 255) @NotNull @@ -64,11 +67,11 @@ public class Menu extends BaseEntity { @Builder private Menu( - Integer shopId, + Shop shop, String name, String description ) { - this.shopId = shopId; + this.shop = shop; this.name = name; this.description = description; } @@ -77,15 +80,6 @@ public boolean hasMultipleOption() { return menuOptions.size() > SINGLE_OPTION_COUNT; } - @Override - public String toString() { - return "Menu{" + - "id=" + id + - ", shopId=" + shopId + - ", name='" + name + '\'' + - '}'; - } - public void modifyMenu( String name, String description diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/menu/MenuSearchKeyWord.java b/src/main/java/in/koreatech/koin/domain/shop/model/menu/MenuSearchKeyWord.java new file mode 100644 index 000000000..a783431f7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/menu/MenuSearchKeyWord.java @@ -0,0 +1,39 @@ +package in.koreatech.koin.domain.shop.model.menu; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "shop_menu_search_keywords") +@NoArgsConstructor(access = PROTECTED) +public class MenuSearchKeyWord extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @Size(max = 255) + @NotNull + @Column(name = "keyword", nullable = false) + private String keyword; + + @Builder + private MenuSearchKeyWord( + String keyword + ) { + this.keyword = keyword; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/Shop.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/Shop.java index fea68847e..bb6531545 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/shop/Shop.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/Shop.java @@ -7,6 +7,7 @@ import static jakarta.persistence.GenerationType.IDENTITY; import static lombok.AccessLevel.PROTECTED; +import in.koreatech.koin.domain.shop.model.menu.Menu; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.TextStyle; @@ -122,6 +123,9 @@ public class Shop extends BaseEntity { @OneToMany(mappedBy = "shop", orphanRemoval = true, cascade = {PERSIST, REFRESH, MERGE, REMOVE}) private List shopImages = new ArrayList<>(); + @OneToMany(mappedBy = "shop", orphanRemoval = true, cascade = {PERSIST, REFRESH, MERGE, REMOVE}) + private List menus = new ArrayList<>(); + @OneToMany(mappedBy = "shop", orphanRemoval = true, cascade = {PERSIST, REFRESH, MERGE, REMOVE}) private List menuCategories = new ArrayList<>(); diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/menu/MenuRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/menu/MenuRepository.java index 451f12724..7fd23645f 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/repository/menu/MenuRepository.java +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/menu/MenuRepository.java @@ -1,13 +1,11 @@ package in.koreatech.koin.domain.shop.repository.menu; +import in.koreatech.koin.domain.shop.exception.MenuNotFoundException; +import in.koreatech.koin.domain.shop.model.menu.Menu; import java.util.List; import java.util.Optional; - import org.springframework.data.repository.Repository; -import in.koreatech.koin.domain.shop.exception.MenuNotFoundException; -import in.koreatech.koin.domain.shop.model.menu.Menu; - public interface MenuRepository extends Repository { Optional findById(Integer menuId); diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/menu/MenuSearchKeywordRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/menu/MenuSearchKeywordRepository.java new file mode 100644 index 000000000..03c90b0a9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/menu/MenuSearchKeywordRepository.java @@ -0,0 +1,15 @@ +package in.koreatech.koin.domain.shop.repository.menu; + +import in.koreatech.koin.domain.shop.model.menu.MenuSearchKeyWord; +import java.util.List; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +public interface MenuSearchKeywordRepository extends Repository { + + @Query("SELECT DISTINCT m.keyword FROM MenuSearchKeyWord m WHERE m.keyword LIKE %:query%") + List findDistinctNameContains(@Param("query") String query); + + MenuSearchKeyWord save(MenuSearchKeyWord menuSearchKeyWord); +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopRepository.java index 339a3fd1d..f581eaeff 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopRepository.java +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopRepository.java @@ -1,12 +1,12 @@ package in.koreatech.koin.domain.shop.repository.shop; +import in.koreatech.koin.domain.shop.exception.ShopNotFoundException; +import in.koreatech.koin.domain.shop.model.shop.Shop; import java.util.List; import java.util.Optional; - +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; - -import in.koreatech.koin.domain.shop.exception.ShopNotFoundException; -import in.koreatech.koin.domain.shop.model.shop.Shop; +import org.springframework.data.repository.query.Param; public interface ShopRepository extends Repository { @@ -29,4 +29,7 @@ default Shop getByOwnerId(Integer ownerId) { } List findAll(); + + @Query("SELECT s FROM Shop s WHERE s.name LIKE %:query%") + List findDistinctNameContains(@Param("query") String query); } diff --git a/src/main/java/in/koreatech/koin/domain/shop/service/SearchService.java b/src/main/java/in/koreatech/koin/domain/shop/service/SearchService.java new file mode 100644 index 000000000..2a81509ee --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/service/SearchService.java @@ -0,0 +1,65 @@ +package in.koreatech.koin.domain.shop.service; + +import in.koreatech.koin.domain.shop.cache.ShopsCacheService; +import in.koreatech.koin.domain.shop.cache.dto.ShopCache; +import in.koreatech.koin.domain.shop.cache.dto.ShopsCache; +import in.koreatech.koin.domain.shop.dto.search.RelatedKeyword; +import in.koreatech.koin.domain.shop.dto.search.RelatedKeyword.InnerKeyword; +import in.koreatech.koin.domain.shop.repository.menu.MenuSearchKeywordRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class SearchService { + + private final MenuSearchKeywordRepository menuSearchKeywordRepository; + private final ShopsCacheService shopsCacheService; + + private static final String BLANK = " "; + private static final String EMPTY = ""; + + public RelatedKeyword getRelatedKeywordByQuery(String query) { + String trimmedQuery = removeBlank(query); + ShopsCache shops = shopsCacheService.findAllShopCache(); + List relatedMenuKeywords = findAllRelatedMenuKeyword(trimmedQuery, shops); + List relatedShopNames = findAllRelatedShopsKeyword(trimmedQuery, shops); + return RelatedKeyword.of(relatedMenuKeywords, relatedShopNames); + } + + private List findAllRelatedMenuKeyword(String query, ShopsCache shops) { + List menuKeywords = menuSearchKeywordRepository.findDistinctNameContains(query); + return menuKeywords.stream() + .map(keyword -> new InnerKeyword( + keyword, + relatedShopIds(keyword, shops), + null + )).toList(); + } + + private List relatedShopIds(String keyword, ShopsCache shops) { + return shops.shopCaches().stream() + .filter(shop -> + shop.menuNames().stream().anyMatch(menuName -> menuName.contains(keyword)) || + shop.name().contains(keyword)) + .map(ShopCache::id) + .toList(); + } + + private List findAllRelatedShopsKeyword(String query, ShopsCache shops) { + return shops.shopCaches().stream() + .filter(shop -> shop.name().contains(query)) + .map(shop -> new InnerKeyword( + shop.name(), + List.of(), + shop.id() + )).toList(); + } + + private String removeBlank(String query) { + return query.replaceAll(BLANK, EMPTY); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java b/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java index 24feaaad6..458a4b6ac 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java +++ b/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java @@ -2,16 +2,8 @@ import static in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType.REVIEW_PROMPT; -import java.time.Clock; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - +import in.koreatech.koin.domain.shop.cache.ShopsCacheService; +import in.koreatech.koin.domain.shop.cache.dto.ShopsCache; import in.koreatech.koin.domain.shop.dto.menu.MenuCategoriesResponse; import in.koreatech.koin.domain.shop.dto.menu.MenuDetailResponse; import in.koreatech.koin.domain.shop.dto.menu.ShopMenuResponse; @@ -41,7 +33,15 @@ import in.koreatech.koin.domain.user.repository.UserRepository; import in.koreatech.koin.global.domain.notification.repository.NotificationSubscribeRepository; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @Transactional(readOnly = true) @@ -54,6 +54,7 @@ public class ShopService { private final ShopRepository shopRepository; private final ShopCategoryRepository shopCategoryRepository; private final EventArticleRepository eventArticleRepository; + private final ShopsCacheService shopsCache; private final ShopCustomRepository shopCustomRepository; private final NotificationSubscribeRepository notificationSubscribeRepository; private final ShopNotificationBufferRepository shopNotificationBufferRepository; @@ -112,14 +113,25 @@ public ShopEventsResponse getAllEvents() { return ShopEventsResponse.of(shops, clock); } - public ShopsResponseV2 getShopsV2(ShopsSortCriteria sortBy, List shopsFilterCriterias) { + public ShopsResponseV2 getShopsV2( + ShopsSortCriteria sortBy, + List shopsFilterCriterias, + String query + ) { if (shopsFilterCriterias.contains(null)) { throw KoinIllegalArgumentException.withDetail("유효하지 않은 필터입니다."); } - List shops = shopRepository.findAll(); + ShopsCache shopCaches = shopsCache.findAllShopCache(); LocalDateTime now = LocalDateTime.now(clock); Map shopInfoMap = shopCustomRepository.findAllShopInfo(now); - return ShopsResponseV2.from(shops, shopInfoMap, sortBy, shopsFilterCriterias, now); + return ShopsResponseV2.from( + shopCaches.shopCaches(), + shopInfoMap, + sortBy, + shopsFilterCriterias, + now, + query + ); } @Transactional diff --git a/src/main/java/in/koreatech/koin/global/config/RedisConfig.java b/src/main/java/in/koreatech/koin/global/config/RedisConfig.java index 9c701cb02..8f58e0565 100644 --- a/src/main/java/in/koreatech/koin/global/config/RedisConfig.java +++ b/src/main/java/in/koreatech/koin/global/config/RedisConfig.java @@ -2,8 +2,9 @@ import static java.nio.charset.StandardCharsets.UTF_8; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import java.time.Duration; - import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -19,9 +20,6 @@ import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.web.client.RestTemplate; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; - @Configuration @EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP) @Profile("!test") diff --git a/src/main/java/in/koreatech/koin/global/scheduler/CacheShopsScheduler.java b/src/main/java/in/koreatech/koin/global/scheduler/CacheShopsScheduler.java new file mode 100644 index 000000000..f3cc64e53 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/scheduler/CacheShopsScheduler.java @@ -0,0 +1,17 @@ +package in.koreatech.koin.global.scheduler; + +import in.koreatech.koin.domain.shop.cache.ShopsCacheService; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CacheShopsScheduler { + private final ShopsCacheService shopsCacheService; + + @Scheduled(fixedRate = 30000) + public void refreshCache() { + shopsCacheService.refreshShopsCache(); + } +} diff --git a/src/main/resources/db/migration/V93__add_menu_search_keyword_table.sql b/src/main/resources/db/migration/V93__add_menu_search_keyword_table.sql new file mode 100644 index 000000000..058eb5607 --- /dev/null +++ b/src/main/resources/db/migration/V93__add_menu_search_keyword_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE if not exists `shop_menu_search_keywords` +( + id INT UNSIGNED AUTO_INCREMENT NOT NULL, + keyword VARCHAR(255) NOT NULL, + created_at timestamp default CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp default CURRENT_TIMESTAMP NOT NULL on update CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) + ); diff --git a/src/main/resources/db/migration/V94__add_menu_search_keyword_table.sql b/src/main/resources/db/migration/V94__add_menu_search_keyword_table.sql new file mode 100644 index 000000000..058eb5607 --- /dev/null +++ b/src/main/resources/db/migration/V94__add_menu_search_keyword_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE if not exists `shop_menu_search_keywords` +( + id INT UNSIGNED AUTO_INCREMENT NOT NULL, + keyword VARCHAR(255) NOT NULL, + created_at timestamp default CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp default CURRENT_TIMESTAMP NOT NULL on update CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) + ); diff --git a/src/main/resources/db/migration/V95__insert_initial_menu_keywords.sql b/src/main/resources/db/migration/V95__insert_initial_menu_keywords.sql new file mode 100644 index 000000000..46b568b2b --- /dev/null +++ b/src/main/resources/db/migration/V95__insert_initial_menu_keywords.sql @@ -0,0 +1,1174 @@ +INSERT INTO shop_menu_search_keywords (keyword) +VALUES ('흑마늘족발'), + ('허니마늘족발'), + ('바베큐마늘족발'), + ('직화불족발'), + ('보쌈'), + ('가족의족보'), + ('반반족발'), + ('쟁반국수'), + ('냉채족발'), + ('얼큰술국'), + ('보불셋트'), + ('오리엔탈마늘샐러드'), + ('가마보꼬모듬오뎅탕'), + ('떡볶이'), + ('모둠튀김'), + ('순대'), + ('어묵'), + ('볶음밥'), + ('돈까스'), + ('감성떡볶이'), + ('치즈떡볶이'), + ('매운크림떡볶이'), + ('감성김밥'), + ('참치김밥'), + ('새우튀김김밥'), + ('김치돈까스김밥'), + ('참치마요컵밥'), + ('스팸김치컵밥'), + ('감성돈까스'), + ('치즈돈까스'), + ('새우튀김'), + ('오징어튀김'), + ('고구마튀김'), + ('김말이'), + ('치즈스틱'), + ('치즈볼'), + ('어묵튀김'), + ('목살볶음밥'), + ('김치목살볶음밥'), + ('새우볶음밥'), + ('불닭볶음밥'), + ('콜라/사이다'), + ('쿨피스'), + ('뼈해장국'), + ('닭볶음탕'), + ('고추장불고기'), + ('닭갈비'), + ('김치찌개'), + ('간장불고기'), + ('알탕'), + ('알밥'), + ('순두부찌개'), + ('낙지볶음덮밥'), + ('원조김밥'), + ('계란말이김밥'), + ('김치김밥'), + ('치즈김밥'), + ('소고기김밥'), + ('매운참치김밥'), + ('오뎅'), + ('김치만두'), + ('고기만두'), + ('군만두'), + ('물만두'), + ('갈비만두'), + ('매콤만두'), + ('새우만두'), + ('김말이튀김'), + ('우동'), + ('라면'), + ('계란라면'), + ('떡라면'), + ('김치라면'), + ('치즈라면'), + ('만두라면'), + ('짬뽕라면'), + ('짬뽕우동'), + ('라볶이'), + ('치즈라볶이'), + ('모듬떡볶이'), + ('모듬라볶이'), + ('잔치국수'), + ('수제비'), + ('얼큰수제비'), + ('항아리수제비'), + ('항아리칼국수'), + ('항아리칼제비'), + ('칼국수'), + ('얼큰칼국수'), + ('쫄면'), + ('떡만두국'), + ('만두국'), + ('떡국'), + ('비빔밥'), + ('참치비빔밥'), + ('양푼비빔밥'), + ('돌솥비빔밥'), + ('치즈돌솥비빔밥'), + ('김치알밥'), + ('치즈김치알밥'), + ('육개장'), + ('내장탕'), + ('갈비탕'), + ('올갱이해장국'), + ('스페셜정식'), + ('철판햄버그스테이크'), + ('고구마돈까스'), + ('매운돈까스'), + ('생선까스'), + ('김치덮밥'), + ('오징어덮밥'), + ('제육덮밥'), + ('참치덮밥'), + ('뚝배기불고기'), + ('철판낙지볶음'), + ('철판오삼불고기'), + ('꽁치김치조림'), + ('참치김치찌개'), + ('된장찌개'), + ('부대찌개'), + ('치즈부대찌개'), + ('고등어김치조림'), + ('오므라이스'), + ('치즈오므라이스'), + ('카레라이스'), + ('물냉면'), + ('비빔냉면'), + ('막국수'), + ('비빔막국수'), + ('열무국수'), + ('열무냉면'), + ('순살돈까스'), + ('토마토스파게티'), + ('크림스파게티'), + ('맞춤도시락'), + ('즉석떡볶이'), + ('병맥주'), + ('소주'), + ('음료수'), + ('후라이드'), + ('새우버거'), + ('치킨버거'), + ('불고기버거'), + ('양념치킨'), + ('간장치킨'), + ('스노윙치즈'), + ('핫블링'), + ('크리미언'), + ('소이갈릭'), + ('쇼킹핫'), + ('매콤치즈스노윙'), + ('포테이토짜용치킨'), + ('허니갈릭'), + ('오리엔탈파닭'), + ('파닭발'), + ('순살치킨'), + ('닭다리'), + ('닭날개/윙봉'), + ('스노윙치킨'), + ('마늘치킨'), + ('후닭'), + ('핫후닭'), + ('네네볼'), + ('네네스위틱'), + ('감자튀김'), + ('짜용소스'), + ('뚝배기뼈해장국'), + ('김치짜글이'), + ('노걸대왕갈비탕'), + ('콩나물해장국'), + ('황태해장국'), + ('얼큰이내장탕'), + ('막걸리'), + ('맥주'), + ('복분자'), + ('청하'), + ('삼겹살'), + ('막창'), + ('순살간장조림닭볶음'), + ('순살매콤닭볶음'), + ('순살된장닭볶음'), + ('순살닭도리탕'), + ('닭똥집볶음'), + ('닭치찌개'), + ('음료수 큰거'), + ('공기밥'), + ('해물칼국수'), + ('버섯육개장'), + ('찐만두'), + ('콩국수'), + ('냉면'), + ('손짜장'), + ('간짜장'), + ('손우동'), + ('손짬뽕'), + ('불짬뽕'), + ('굴짬뽕'), + ('대왕성특면'), + ('낙지짬뽕'), + ('해물삼선짬뽕'), + ('해물쟁반짜장'), + ('해물쟁반짬뽕'), + ('해물삼선간짜장'), + ('짜장밥'), + ('짬뽕밥'), + ('잡채밥'), + ('삼선볶음밥'), + ('계살볶음밥'), + ('대왕성특밥'), + ('송이덮밥'), + ('잡탕밥'), + ('유산슬밥'), + ('탕수육'), + ('사천탕수육'), + ('고기잡채'), + ('양장피'), + ('난자완스'), + ('깐풍육'), + ('깐풍기'), + ('깐쇼새우'), + ('유산슬'), + ('짬짜면'), + ('볶짜면'), + ('볶짬면'), + ('탕짜면'), + ('탕짬면'), + ('탕볶밥'), + ('물막국수'), + ('이탤리노치즈피자'), + ('콤비네이션피자'), + ('양송이피자'), + ('불고기피자'), + ('웨지감자피자'), + ('웨지고구마피자'), + ('베이컨피자'), + ('베이컨포테이토피자'), + ('오로너비아니피자'), + ('핫소스'), + ('갈릭소스'), + ('피클'), + ('치즈크러스트 엣지'), + ('콜라'), + ('사이다'), + ('치즈치킨'), + ('칠리양념'), + ('마늘간장'), + ('카레양념'), + ('치킨무'), + ('파닭'), + ('속초명가닭강정'), + ('탕수육치킨'), + ('악마치킨'), + ('디디한마리'), + ('빅디디'), + ('후라이드윙'), + ('닭다리후라이드'), + ('핫후라이드'), + ('디디콤보'), + ('리치디디'), + ('치만이순살세트'), + ('치만이세트'), + ('고추장찌개'), + ('콩구수'), + ('김치볶음밥'), + ('피자치탕'), + ('감자피자치탕'), + ('고구마피자치탕'), + ('김치피자치탕'), + ('만두피자치탕'), + ('데리야키치탕'), + ('딥치즈싸이버거'), + ('싸이버거'), + ('언블리버블버거'), + ('쉬림프싸이플렉스버거'), + ('불싸이버거'), + ('화이트갈릭버거'), + ('싸이플렉스버거'), + ('간장마늘싸이버거'), + ('딥치즈버거'), + ('양념치킨싸이버거'), + ('휠렛버거'), + ('인크레더블버거'), + ('등심탕수육'), + ('치킨탕수육'), + ('오뎅탕'), + ('누룽지'), + ('계란찜'), + ('김가루밥'), + ('몽룡이네 닭볶음탕'), + ('몽룡이네 닭찜'), + ('짜장'), + ('짬뽕'), + ('쟁반짜장'), + ('짜장면'), + ('삼선간짜장'), + ('삼선우동'), + ('삼선짬뽕'), + ('차돌짬뽕'), + ('쟁반짬뽕'), + ('삼선짬뽕밥'), + ('아구찜'), + ('돌문어찜'), + ('돌문어볶음'), + ('오리주물럭'), + ('찜닭'), + ('곱창전골'), + ('매운돼지갈비찜'), + ('닭도리탕'), + ('오삼불고기'), + ('낙지볶음'), + ('골뱅이무침'), + ('동태탕'), + ('돼지짜글이'), + ('제육볶음'), + ('오징어볶음'), + ('쭈꾸미볶음'), + ('똥집볶음'), + ('닭발'), + ('뼈없는닭발'), + ('고갈비'), + ('두부김치'), + ('해물김치전'), + ('계란말이'), + ('설렁탕'), + ('사골우거지탕'), + ('사골만두국'), + ('수제돈까스'), + ('청국장찌개'), + ('낙지순두부찌개'), + ('오징어찌개'), + ('참치찌개'), + ('양념주먹밥'), + ('황금올리브치킨'), + ('반반치킨'), + ('간장닭날개'), + ('빠리치킨'), + ('양파닭'), + ('마라핫치킨'), + ('소이갈릭치킨'), + ('허니갈릭스치킨'), + ('치즐링'), + ('자메이카통자리구이'), + ('스모크치킨'), + ('이스탄불'), + ('순살크래커'), + ('순살소이갈릭스'), + ('순살빠리치킨'), + ('순살후라이드'), + ('순살마라핫'), + ('순살치즐링'), + ('순살양념'), + ('순살파닭'), + ('골뱅이 무침'), + ('생맥주'), + ('꽃게탕'), + ('해물탕'), + ('매운뼈없는닭발'), + ('똥집야채볶음'), + ('매운똥집야채볶음'), + ('고추장감자찌개'), + ('동태찌개'), + ('병천순대'), + ('농어'), + ('도미'), + ('우럭'), + ('광어'), + ('놀래미'), + ('홍탁'), + ('능성어'), + ('감성돔'), + ('돗돔'), + ('돌돔'), + ('도다리'), + ('통우럭매운탕'), + ('생선초밥'), + ('방어'), + ('숭어'), + ('전복'), + ('멍게'), + ('개불'), + ('봉구스밥버거'), + ('햄밥버거'), + ('치즈밥버거'), + ('햄치즈밥버거'), + ('김치떡갈비밥버거'), + ('마요떡갈비밥버거'), + ('치즈떡갈비밥버거'), + ('제육밥버거'), + ('김치제육밥버거'), + ('치즈제육밥버거'), + ('카레밥버거'), + ('짜장밥버거'), + ('치즈불닭'), + ('불닭'), + ('불날개'), + ('불닭발'), + ('뼈없는불닭발'), + ('불족발'), + ('불똥집'), + ('불오돌뼈'), + ('야채똥집'), + ('훈제치킨'), + ('주먹밥'), + ('치즈추가'), + ('생왕소금구이'), + ('수입삼겹살'), + ('소갈비살'), + ('생삼겹살'), + ('항정살'), + ('불돈모듬'), + ('생돈모듬'), + ('돼지막창'), + ('돼지껍데기'), + ('오리지날순살'), + ('오리지날 후라이드'), + ('양념'), + ('간장'), + ('매운양념'), + ('크리스피 순살'), + ('모듬튀김'), + ('떡튀김'), + ('도시락세트'), + ('조개칼국수'), + ('얼큰이칼국수'), + ('오리육개장'), + ('미린다'), + ('자장면'), + ('울면'), + ('사천짜장'), + ('유니짜장'), + ('삼선울면'), + ('기스면'), + ('마파두부밥'), + ('고추덮밥'), + ('해삼탕밥'), + ('잡채'), + ('마파두부'), + ('덴뿌라'), + ('팔보채'), + ('잡탕'), + ('고추잡채'), + ('라조기'), + ('라조육'), + ('깐풍새우'), + ('해삼탕'), + ('파고추장삼겹'), + ('소갈비살비빔밥'), + ('소불고기덮밥'), + ('라면사리'), + ('속초원조닭강정'), + ('매운강정'), + ('마늘간장강정'), + ('스위트칠리강정'), + ('까르보나라'), + ('통닭발'), + ('무뼈닭발'), + ('국물떡볶이'), + ('쏘야밥버거'), + ('바삭멸치밥버거'), + ('진미채밥버거'), + ('소불고기밥버거'), + ('김치불고기밥버거'), + ('청양불고기밥버거'), + ('통살돈까스밥버거'), + ('통살돈까스마요밥버거'), + ('칠리치킨밥버거'), + ('치킨마요밥버거'), + ('버터장조림밥버거'), + ('오징어밥버거'), + ('전주비빔밥버거'), + ('추억의도시락밥버거'), + ('고추장삼겹살'), + ('들깨수제비'), + ('해물파전'), + ('감자탕전골'), + ('붉닭발'), + ('뼈없는 불닭발'), + ('국물닭발'), + ('치즈피자'), + ('고구마피자'), + ('통새우피자'), + ('핫치킨새우피자'), + ('불갈비피자'), + ('페파로니피자'), + ('토핑족발'), + ('슈퍼콤비네이션'), + ('토핑보쌈'), + ('핫치킨피자'), + ('포테이토베이컨피자'), + ('불고기새우피자'), + ('감자새우피자'), + ('왕족발'), + ('치즈바이트피자'), + ('야채피자'), + ('포테이토피자'), + ('파인애플피자'), + ('고구마감자피자'), + ('게맛살골드피자'), + ('왕보쌈'), + ('왕족막국수'), + ('해파리냉채'), + ('사골떡만두국'), + ('우거지뼈다귀탕'), + ('사골곰탕'), + ('뼈다귀전골'), + ('육개장전골'), + ('육계닭도리탕'), + ('토종닭도리탕'), + ('묵은지수육찜'), + ('육계장'), + ('소고기덮밥'), + ('왕돈까스'), + ('모듬김밥'), + ('기본김밥'), + ('제육도시락'), + ('숯불고기도시락'), + ('우삼겹도시락'), + ('마스도시락'), + ('왕천파닭'), + ('삼겹살도시락'), + ('순살간장'), + ('특삼겹살도시락'), + ('순살반반'), + ('순살매운양념'), + ('쥬피터도시락'), + ('똥집튀김'), + ('돼지김치찌개'), + ('똥집양념'), + ('똥집간장'), + ('미니김치찜'), + ('똥집반반'), + ('우삼겹된장찌개'), + ('닭떡볶이'), + ('우삼겹떡볶이'), + ('규동'), + ('우삼겹숙주덮밥'), + ('우삼겹볶음밥'), + ('족발'), + ('참기름계란밥'), + ('스팸컵밥'), + ('미니족'), + ('불족'), + ('제육컵밥'), + ('샐러드비빔밥'), + ('붉닭샐비'), + ('안심탕수육'), + ('아나고'), + ('탄산음료'), + ('특전복죽'), + ('해삼'), + ('전복죽'), + ('오징어'), + ('낙지'), + ('게불'), + ('장조림'), + ('자연송이죽'), + ('자연송이전복죽'), + ('전복인삼닭죽'), + ('매생이굴죽'), + ('바다치즈죽'), + ('순한불낙죽'), + ('매콤불낙죽'), + ('브로콜리새우죽'), + ('커리치킨죽'), + ('버섯굴죽'), + ('인삼닭죽'), + ('모듬해물죽'), + ('한우야채죽'), + ('버섯들깨죽'), + ('낙지김치죽'), + ('참치야채죽'), + ('버섯야채죽'), + ('얼큰김치죽'), + ('황태콩나물죽'), + ('홍합미역죽'), + ('야채죽'), + ('치킨데리야끼볶음밥'), + ('불낙지볶음밥'), + ('해물볶음밥'), + ('햄야채볶음밥'), + ('불고기볶음밥'), + ('흑임자죽'), + ('녹두죽'), + ('호박죽'), + ('마죽'), + ('팥죽'), + ('흰죽'), + ('주니어불고기버거'), + ('화이트짬봉'), + ('애니버거'), + ('삼선고추짱뽕'), + ('애니불고기버거'), + ('미친짬뽕'), + ('속풀이짬뽕'), + ('에그불고기버거'), + ('얼큰이짬뽕'), + ('누룽지탕'), + ('애니치즈버거'), + ('핫치킨버거'), + ('황제짬뽕'), + ('왕만두'), + ('떡갈비버거'), + ('수제돈가스버거'), + ('수제 치즈돈가스버거'), + ('수제 피자돈가스버거'), + ('수제핫버거'), + ('핫도그소세지 버거'), + ('칠리소세지 버거'), + ('치즈소세지 버거'), + ('순살강정'), + ('치킨너겟'), + ('아메리카노'), + ('카푸치노'), + ('카페라떼'), + ('우유'), + ('복숭아 아이스티'), + ('블루베리 아이스티'), + ('레몬 에이드'), + ('딸기쥬스'), + ('망고쥬스'), + ('오렌지 쥬스'), + ('키위쥬스'), + ('블루베리 쥬스'), + ('후라이드치킨'), + ('올리고당양념'), + ('순살치폴레양념'), + ('순살양념치킨'), + ('매운불양념치킨'), + ('치폴레양념치킨'), + ('순살매운불양념'), + ('치즈슈프림양념'), + ('순살치즈슈프림양념'), + ('새치 고기고기'), + ('돈치 고기고기'), + ('동백'), + ('돈까스도련님'), + ('치킨제육'), + ('간장한마리치킨'), + ('간장다리치킨'), + ('간장날개치킨'), + ('강정치킨'), + ('순살 양념'), + ('순살/다리/날개'), + ('레몬파닭'), + ('순살레몬파닭'), + ('닭강정'), + ('청국장'), + ('우렁쌈밥정식'), + ('제육정식'), + ('산채나물밥'), + ('민물새우탕'), + ('황태찜'), + ('오골계백숙'), + ('한마리반치킨'), + ('크리스피'), + ('다리치킨'), + ('날개치킨'), + ('카레치킨'), + ('케이준감자튀김'), + ('크림어니언치킨'), + ('청고추드레싱치킨'), + ('까르보나라치킨'), + ('낙곱새'), + ('낙삼새'), + ('떡사리'), + ('오뎅사리'), + ('우동사리'), + ('계란'), + ('참치주먹밥'), + ('비엔나'), + ('치즈떡'), + ('바닐라라떼'), + ('헤이즐넛라떼'), + ('캐러멜마끼아또'), + ('카페모카'), + ('더치커피'), + ('샷추가'), + ('아이스티'), + ('자몽쥬스'), + ('유자깔라만시'), + ('핫초코'), + ('녹차'), + ('홍차'), + ('민트초코라떼'), + ('고구마라떼'), + ('블루베리라떼'), + ('딸기스무디'), + ('키위스무디'), + ('망고피치스무디'), + ('Freddo 조리퐁퐁'), + ('Freddo 블루베리'), + ('쿠키앤크림'), + ('쿠키'), + ('블루베리스콘'), + ('베이글'), + ('크림치즈프렛즐'), + ('케이크'), + ('샌드위치'), + ('옛날통치킨'), + ('소스'), + ('알싸한마늘간장치킨'), + ('고추마늘간장'), + ('치즈스노우퀸'), + ('꿀간장치킨'), + ('마늘빵치킨'), + ('따뜻한족발'), + ('매운족발'), + ('달콤한 탕수육'), + ('족발덮밥'), + ('불닭곱창'), + ('맛초킹'), + ('해바라기후라이드'), + ('뿌링클'), + ('커리퀸'), + ('매운양념치킨'), + ('맵삭치킨'), + ('치즈뿌리오'), + ('페퍼로니피자'), + ('이탈리안치즈피자'), + ('투어고구마피자'), + ('투어콤비네이션피자'), + ('슈퍼콤비네이션피자'), + ('프리미엄수제불고기피자'), + ('통고구마피자'), + ('스페셜고구마샐러드피자'), + ('숯불갈비피자'), + ('칠리핫치킨피자'), + ('스페셜단호박샐러드피자'), + ('스페셜피자'), + ('수블라키피자'), + ('폭립베이컨피자'), + ('스페셜쉬림프골드피자'), + ('숯불갈비바이트피자'), + ('스페셜바이트피자'), + ('베이컨포테이토바이트피자'), + ('폭립베이컨바이트피자'), + ('구운치킨'), + ('치즈스파게티'), + ('감자스낵'), + ('돈까스카레'), + ('새우돈까스덮밥'), + ('돈까스덮밥'), + ('소불고기철판볶음밥'), + ('스팸철판볶음밥'), + ('김치 부대찌개'), + ('묵은지김치찌개'), + ('빅치킨마요'), + ('돈치마요'), + ('치킨마요'), + ('참치마요'), + ('튼튼도시락'), + ('케이준후라이'), + ('오리지널 닭강정'), + ('참치야채 감초고추장'), + ('소불고기 감초고추장 비빔밥'), + ('시골제육 두부강된장 비빔밥'), + ('두부강된장소스'), + ('왕카레돈까스덮밥'), + ('왕치킨마요'), + ('튀김류'), + ('중국당면'), + ('대창'), + ('새우'), + ('탕수육도련님'), + ('칠리 찹쌀탕수육'), + ('미니 찹쌀탕수육'), + ('뉴 감자고로케'), + ('토네이도 소세지'), + ('치킨'), + ('메가 치킨마요'), + ('메가 치킨제육'), + ('김말이피치탕'), + ('닭백숙'), + ('에스프레소'), + ('카라멜마끼야또'), + ('민트카페모카'), + ('아인슈페너'), + ('그린티라떼'), + ('초코라떼'), + ('자색고구마라떼'), + ('밀크티라떼'), + ('흑당라떼'), + ('흑당밀크티'), + ('얼그레이'), + ('잉글리쉬 블랙퍼스트'), + ('허브티'), + ('프룻티'), + ('바나나'), + ('딸바'), + ('딸기'), + ('망고스무디'), + ('블루베리스무디'), + ('플레인요거트'), + ('딸기요거트'), + ('유자요거트'), + ('블루베리요거트'), + ('망고요거트'), + ('새우데리야끼'), + ('해물매콤토마토'), + ('프렌치토스트'), + ('가든샐러드'), + ('치킨샐러드'), + ('허니브레드'), + ('레몬에이드'), + ('자몽에이드'), + ('블루베리에이드'), + ('청포도에이드'), + ('블루레몬에이드'), + ('체리에이드'), + ('유자에이드'), + ('패션후르츠에이드'), + ('나폴리피자'), + ('스테이크피자'), + ('까르보네피자'), + ('아이리쉬포테이토피자'), + ('고르곤졸라피자'), + ('더블갈릭바베큐피자'), + ('깐쇼새우피자'), + ('직화파인애플피자'), + ('닭안심살피자'), + ('퀘사디아피자'), + ('직화홀릭바이트피자'), + ('도이치바이트피자'), + ('멕시칸바이트피자'), + ('미트러버피자'), + ('킹소시지피자'), + ('치킨스틱'), + ('치킨텐더'), + ('새우링'), + ('웨지감자'), + ('치즈오븐스파게티'), + ('갈릭포테이토'), + ('야채곱창'), + ('오돌뼈'), + ('떡 추가'), + ('햇반'), + ('순대국밥'), + ('고기국밥'), + ('곱창볶음'), + ('크림순대볶음'), + ('허니고르곤피자'), + ('육해공골드피자'), + ('불새피자'), + ('바이트골드피자'), + ('농촌피자'), + ('어촌피자'), + ('왕창포테이토피자'), + ('고기농장피자'), + ('스위트골드피자'), + ('단호박피자'), + ('치즈그라인스파게티'), + ('윙봉'), + ('불고기스파게티'), + ('오굿박스'), + ('고구마무스'), + ('치즈크러스트'), + ('고구마크러스트'), + ('체다엣지'), + ('골드엣지'), + ('바이트골드'), + ('흑미도우'), + ('간장바베큐'), + ('고추장바베큐'), + ('매콤후라이드'), + ('깐풍치킨'), + ('웰빙파닭'), + ('앙념치킨'), + ('순살3종세트'), + ('똥집후라이드'), + ('깐풍똥집'), + ('모듬감자튀김'), + ('눈꽃치즈떡볶이'), + ('파절이'), + ('배달소주'), + ('옛날통닭'), + ('쟁반막국수'), + ('무김치'), + ('야채'), + ('닭똥집'), + ('마늘간장치킨'), + ('매운간장치킨'), + ('뼈있는파닭'), + ('순살간장치킨'), + ('순살마늘간장치킨'), + ('순살매운간장'), + ('돼지갈비'), + ('가브리살'), + ('돼지모듬'), + ('목살'), + ('한우버섯죽'), + ('한우미역죽'), + ('삼선짬뽕죽'), + ('모듬해물된장죽'), + ('새우미역죽'), + ('게살날치알죽'), + ('삼계탕'), + ('잣죽'), + ('음료'), + ('철판김치볶음밥'), + ('치즈철판김치볶음밥'), + ('갈치무우조림'), + ('교촌 소이 살살'), + ('교촌 후라이드'), + ('교촌 샐러드'), + ('교촌 웨지 감자'), + ('고량주'), + ('이과두주'), + ('연태고량주'), + ('오리지널 감자'), + ('양념 감자'), + ('통오징어 튀김'), + ('크림 생맥주'), + ('레드락'), + ('진저하이볼'), + ('참이슬'), + ('사과맥주'), + ('포도맥주'), + ('레몬맥주'), + ('딸기맥주'), + ('자몽맥주'), + ('청사과맥주'), + ('더치맥주'), + ('꿀맥주'), + ('망고맥주'), + ('바닐라맥주'), + ('수박맥주'), + ('청포도맥주'), + ('호가든'), + ('코젤'), + ('갈릭칠리 소스'), + ('갈릭치즈 소스'), + ('어니언 소스'), + ('갈릭 소스'), + ('스위트칠리 소스'), + ('사워크림 소스'), + ('허니머스타드 소스'), + ('케찹'), + ('핫칠리 소스'), + ('핫비비큐 소스'), + ('나쵸치즈 소스'), + ('슈퍼슈프림피자'), + ('군고구마피자'), + ('버팔로윙'), + ('해물뼈전골'), + ('해물뼈찜'), + ('뼈다귀해장국'), + ('얼큰이 갈비탕'), + ('우거지탕'), + ('뚝불고기'), + ('해물알탕'), + ('소곱창전골'), + ('뚝불'), + ('햄볶음밥'), + ('비빔만두'), + ('어묵탕'), + ('치킨까스'), + ('냠냠김밥'), + ('냠냠라면'), + ('골뱅이소면'), + ('비빔국수'), + ('물국수'), + ('돈가스'), + ('치즈돈가스'), + ('매운치즈돈가스'), + ('단호박치즈돈가스'), + ('콩나물불고기'), + ('가쓰오탕수육'), + ('허니골드탕수육'), + ('불피자탕수육'), + ('피자탕수육'), + ('눈꽃탕수육'), + ('김치피자탕수육'), + ('쩐더탕수육'), + ('양념탕수육'), + ('간장탕수육'), + ('눈꽃치킨'), + ('후라이드파닭'), + ('쏘핫간장치킨'), + ('간장파닭'), + ('치즈파닭'), + ('꿀맵닭'), + ('멸치국수'), + ('돔베고기'), + ('돼지국밥'), + ('선지해장국'), + ('왕갈비탕'), + ('벌집생삼겹살'), + ('매운간짜장'), + ('백짬뽕'), + ('고추짬뽕'), + ('고추잡채밥'), + ('착한특밥'), + ('삼선누룽지탕'), + ('연태주'), + ('딸기라떼'), + ('딸기에이드'), + ('딸기티'), + ('로제떡볶이'), + ('허니갈릭프라이'), + ('불고기치즈프라이'), + ('왕새우튀김'), + ('홉스순살치킨'), + ('홉스양념치킨'), + ('로제파스타치킨'), + ('크림파스타치킨'), + ('참치폭탄주먹밥'), + ('감자튀김 추가'), + ('음료수 사이즈업'), + ('리얼티라미수찰떡'), + ('리얼꿀 미니호떡'), + ('숯불고기 샐비'), + ('우삼겹 샐비'), + ('달걀 후라이'), + ('치즈'), + ('컵라면'), + ('에그 샌드위치'), + ('햄치즈 샌드위치'), + ('크랩 샌드위치'), + ('크로와상 샌드위치'), + ('밑반찬'), + ('알곱창'), + ('고구마치즈스틱'), + ('양념감자튀김'), + ('데리야끼막창'), + ('쏘방 라면'), + ('김치말이국수'), + ('순살블랙찜닭'), + ('순살레드찜닭'), + ('순살매콤로제찜닭'), + ('중독닭볶음탕'), + ('목삼겹살'), + ('땡초치킨'), + ('땡초 불 파닭'), + ('우삼겹짬뽕순두부탕'), + ('산더미마라전골'), + ('크리미어니언'), + ('햄왕창부대찌개'), + ('물총조개탕'), + ('조선 매운 우육탕'), + ('매운바지락술찜'), + ('땡초어니언치킨'), + ('청양고추마요치킨'), + ('고기듬뿍김치찌개'), + ('조선 우육탕'), + ('얼큰꼬치어묵탕'), + ('돼지김치두루치기'), + ('매콤제육볶음'), + ('조선 매운 우육면'), + ('조선 우육면'), + ('순살 치즈만땅 찜닭'), + ('똥집고금구이'), + ('킬바사소시지구이'), + ('소면'), + ('쫄뱅이'), + ('꿔바로우'), + ('삼치구이'), + ('묵은지 순살 닭볶음탕'), + ('백세주'), + ('치즈계란말이'), + ('우삼겹달걀폭탄떡볶이'), + ('우육면 + 꿔바로우'), + ('먹태구이'), + ('오지치즈후라이'), + ('짜게치'), + ('조개라면'), + ('간장계란밥'), + ('참치마요밥'), + ('마무리볶음밥'), + ('중화면사리'), + ('치즈사리'), + ('관자쇼마이 홍유초수'), + ('왕어혈교 홍유초수'), + ('세모 멘보샤'), + ('야채춘권'), + ('페페로니피자'), + ('청경채'), + ('숙주'), + ('크리스피 새우롤'), + ('마초 떡볶이'), + ('나 홀로 떡볶이'), + ('매콤로제 떡볶이'), + ('뚜껑김치 삼겹살'), + ('김치전골'), + ('팔도비빔면'), + ('냉동순대'), + ('모듬순대'), + ('얼큰순대국밥'), + ('얼큰우동국밥'), + ('순대전골'), + ('토핑추가'), + ('육회비빔밥'), + ('웰빙비빔밥'), + ('삼겹비빔밥'), + ('우삼겹비빔밥'), + ('할라피뇨통살버거'), + ('안동찜닭'), + ('편육'), + ('가자미회국수'), + ('낙지소면'), + ('간장석쇠불고기'), + ('매콤고추장불고기'), + ('춘천닭갈비'), + ('삼겹파전'), + ('순두부짬뽕'), + ('만두짬뽕'), + ('해물오뎅탕'), + ('알짬뽕'), + ('나가사끼짬뽕'), + ('돈코츠라멘'), + ('탄탄멘'), + ('간재미회국수'), + ('메밀소바'), + ('김치국수'), + ('유부초밥'), + ('스프 & 브레드'), + ('장봉뵈르'), + ('바질오일파스타'), + ('베이컨토마토파스타'), + ('햄치즈파니니'), + ('치킨파니니'), + ('머쉬룸파니니'), + ('카야잼버터토스트'), + ('티라미수'), + ('치즈케이크'), + ('크로플'), + ('리얼 티라미수 찰떡'), + ('에그샌드위치'), + ('햄치즈샌드위치'), + ('크랩샌드위치'), + ('크로와상샌드위치'), + ('쏘방라면'), + ('손두부찌개'), + ('묵은지닭볶음탕'), + ('햄 김치 볶음밥'), + ('국수 및 면류'), + ('해장라면'), + ('치킨 및 닭요리'), + ('허니벌꿀치킨'), + ('땡초치즈불닭'), + ('숯불무뼈닭발'), + ('가마솥 치킨 후라이드'), + ('가마솥 치킨 양념치킨'), + ('가마솥 치킨 갈릭소스'), + ('가마솥 치킨 순살 후라이드'), + ('가마솥 치킨 순살 양념치킨'), + ('가마솥 치킨 순살 갈릭소스'), + ('고기류'), + ('돼지양념갈비'), + ('목살소금구이'), + ('소불고기'), + ('오돌뼈볶음'), + ('사이드 및 기타'), + ('타코야끼'), + ('콘소메치킨'), + ('닭똥집튀김'), + ('닭껍질튀김'), + ('염통꼬치'), + ('닭껍질 꼬치구이'), + ('숯불 치즈새우'), + ('달콤채식단피자'), + ('킹새우통치킨피자'), + ('통치킨오믈렛피자'), + ('불고기파스타치킨'), + ('디저트 및 음료'), + ('생과일 파인애플샤베트'), + ('생과일 코코넛샤베트'), + ('요구르트 샤베트'), + ('주류 및 음료수'), + ('후루츠 하이볼'), + ('레몬 하이볼'), + ('깔라만시 하이볼'), + ('자몽 하이볼'), + ('망고하이볼'); diff --git a/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java index 86244c6a3..eeecca650 100644 --- a/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java @@ -381,7 +381,7 @@ void setUp() { ) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(menu.getId())) - .andExpect(jsonPath("$.shop_id").value(menu.getShopId())) + .andExpect(jsonPath("$.shop_id").value(menu.getShop().getId())) .andExpect(jsonPath("$.name").value(menu.getName())) .andExpect(jsonPath("$.is_hidden").value(menu.isHidden())) .andExpect(jsonPath("$.is_single").value(false)) diff --git a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java index 235423f90..d1c6cf2f5 100644 --- a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java @@ -94,7 +94,7 @@ void setUp() { void 옵션이_하나_있는_상점의_메뉴를_조회한다() throws Exception { Menu menu = menuFixture.짜장면_단일메뉴(마슬랜, menuCategoryFixture.메인메뉴(마슬랜)); mockMvc.perform( - get("/shops/{shopId}/menus/{menuId}", menu.getShopId(), menu.getId()) + get("/shops/{shopId}/menus/{menuId}", menu.getShop().getId(), menu.getId()) ) .andExpect(status().isOk()) .andExpect(content().json(""" @@ -124,7 +124,7 @@ void setUp() { Menu menu = menuFixture.짜장면_옵션메뉴(마슬랜, menuCategoryFixture.메인메뉴(마슬랜)); mockMvc.perform( - get("/shops/{shopId}/menus/{menuId}", menu.getShopId(), menu.getId()) + get("/shops/{shopId}/menus/{menuId}", menu.getShop().getId(), menu.getId()) ) .andExpect(status().isOk()) .andExpect(content().json(""" @@ -164,7 +164,7 @@ void setUp() { Menu menu = menuFixture.짜장면_단일메뉴(마슬랜, menuCategoryFixture.추천메뉴(마슬랜)); mockMvc.perform( - get("/shops/{shopId}/menus/categories", menu.getShopId()) + get("/shops/{shopId}/menus/categories", menu.getShop().getId()) ) .andExpect(status().isOk()) .andExpect(content().json(""" @@ -1037,6 +1037,45 @@ void setUp() { """, 티바_영업여부, 마슬랜_영업여부))); } + @Test + void 검색어를_입력해서_상점을_조회한다() throws Exception { + Shop 배달_안되는_신전_떡볶이 = shopFixture.배달_안되는_신전_떡볶이(owner); + ShopReview 리뷰_4점 = shopReviewFixture.리뷰_4점(익명_학생, 배달_안되는_신전_떡볶이); + shopReviewReportFixture.리뷰_신고(익명_학생, 리뷰_4점, DISMISSED); + + shopReviewFixture.리뷰_4점(익명_학생, 마슬랜); + // 2024-01-15 12:00 월요일 기준 + boolean 신전_떡볶이_영업여부 = true; + boolean 마슬랜_영업여부 = true; + mockMvc.perform( + get("/v2/shops") + .queryParam("query", "떡") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" + { + "count": 1, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": false, + "id": 2, + "name": "신전 떡볶이", + "pay_bank": true, + "pay_card": true, + "phone": "010-7788-9900", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + } + ] + } + """, 마슬랜_영업여부, 신전_떡볶이_영업여부))); + } + @Test void 전화하기_발생시_정보가_알림큐에_저장된다() throws Exception { mockMvc.perform( diff --git a/src/test/java/in/koreatech/koin/acceptance/ShopSearchApiTest.java b/src/test/java/in/koreatech/koin/acceptance/ShopSearchApiTest.java new file mode 100644 index 000000000..7a7e56df6 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/ShopSearchApiTest.java @@ -0,0 +1,124 @@ +package in.koreatech.koin.acceptance; + +import static in.koreatech.koin.domain.shop.model.review.ReportStatus.DISMISSED; +import static in.koreatech.koin.domain.shop.model.review.ReportStatus.UNHANDLED; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.owner.model.Owner; +import in.koreatech.koin.domain.shop.model.menu.Menu; +import in.koreatech.koin.domain.shop.model.menu.MenuCategory; +import in.koreatech.koin.domain.shop.model.menu.MenuSearchKeyWord; +import in.koreatech.koin.domain.shop.model.review.ShopReview; +import in.koreatech.koin.domain.shop.model.shop.Shop; +import in.koreatech.koin.domain.shop.repository.menu.MenuSearchKeywordRepository; +import in.koreatech.koin.domain.student.model.Student; +import in.koreatech.koin.fixture.EventArticleFixture; +import in.koreatech.koin.fixture.MenuCategoryFixture; +import in.koreatech.koin.fixture.MenuFixture; +import in.koreatech.koin.fixture.ShopCategoryFixture; +import in.koreatech.koin.fixture.ShopFixture; +import in.koreatech.koin.fixture.ShopReviewFixture; +import in.koreatech.koin.fixture.ShopReviewReportFixture; +import in.koreatech.koin.fixture.UserFixture; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@SuppressWarnings("NonAsciiCharacters") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ShopSearchApiTest extends AcceptanceTest { + + @Autowired + private UserFixture userFixture; + + @Autowired + private ShopFixture shopFixture; + + @Autowired + private MenuFixture menuFixture; + + @Autowired + private MenuCategoryFixture menuCategoryFixture; + + @Autowired + private MenuSearchKeywordRepository menuSearchKeywordRepository; + + private Shop 마슬랜; + private Owner owner; + + @BeforeAll + void setUp() { + clear(); + owner = userFixture.준영_사장님(); + 마슬랜 = shopFixture.마슬랜(owner); + menuSearchKeywordRepository.save(MenuSearchKeyWord.builder() + .keyword("짜장면") + .build()); + menuSearchKeywordRepository.save(MenuSearchKeyWord.builder() + .keyword("마늘치킨") + .build()); + menuSearchKeywordRepository.save(MenuSearchKeyWord.builder() + .keyword("짜장밥") + .build()); + menuSearchKeywordRepository.save(MenuSearchKeyWord.builder() + .keyword("마늘통구이") + .build()); + menuSearchKeywordRepository.save(MenuSearchKeyWord.builder() + .keyword("짜장") + .build()); + menuFixture.짜장면_단일메뉴(마슬랜, menuCategoryFixture.메인메뉴(마슬랜)); + } + + @Test + void 검색_문자와_관련된_키워드를_조회한다() throws Exception { + mockMvc.perform( + get("/shops/search/related/짜") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" + { + "keywords": [ + { + "keyword": "짜장", + "shop_ids": [1], + "shop_id": null + }, + { + "keyword": "짜장면", + "shop_ids": [1], + "shop_id": null + } + ] + } + """))) + .andDo(print()); + } + + @Test + void 검색_문자와_관련된_키워드를_조회한다_상점인_경우에는_상점id도_조회된다() throws Exception { + mockMvc.perform( + get("/shops/search/related/마") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" + { + "keywords": [ + { + "keyword": "마슬랜 치킨", + "shop_ids": [], + "shop_id": 1 + } + ] + } + """))) + .andDo(print()); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/MenuFixture.java b/src/test/java/in/koreatech/koin/fixture/MenuFixture.java index eb38e756c..45216433e 100644 --- a/src/test/java/in/koreatech/koin/fixture/MenuFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/MenuFixture.java @@ -30,7 +30,7 @@ public MenuFixture( public Menu 짜장면_옵션메뉴(Shop shop, MenuCategory menuCategory) { Menu menu = Menu.builder() - .shopId(shop.getId()) + .shop(shop) .name("짜장면") .description("맛있는 짜장면") .build(); @@ -72,7 +72,7 @@ public MenuFixture( public Menu 짜장면_단일메뉴(Shop shop, MenuCategory menuCategory) { Menu menu = Menu.builder() - .shopId(shop.getId()) + .shop(shop) .name("짜장면") .description("맛있는 짜장면") .build(); @@ -104,4 +104,39 @@ public MenuFixture( menuCategory.getMenuCategoryMaps().add(menuCategoryMap); return menuRepository.save(menu); } + + public Menu 짜파게티_단일메뉴(Shop shop, MenuCategory menuCategory) { + Menu menu = Menu.builder() + .shop(shop) + .name("짜파게티") + .description("맛있는 짜장면") + .build(); + + menu.getMenuImages().addAll( + List.of( + MenuImage.builder() + .menu(menu) + .imageUrl("https://test.com/짜장면.jpg") + .build(), + MenuImage.builder() + .menu(menu) + .imageUrl("https://test.com/짜장면22.jpg") + .build() + ) + ); + menu.getMenuOptions().add( + MenuOption.builder() + .menu(menu) + .option("짜파게티") + .price(7000) + .build() + ); + MenuCategoryMap menuCategoryMap = MenuCategoryMap.builder() + .menu(menu) + .menuCategory(menuCategory) + .build(); + menu.getMenuCategoryMaps().add(menuCategoryMap); + menuCategory.getMenuCategoryMaps().add(menuCategoryMap); + return menuRepository.save(menu); + } } From 7f83b1bf62b64384df9484ba9af7d821786e0c0b Mon Sep 17 00:00:00 2001 From: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Date: Tue, 12 Nov 2024 15:08:14 +0900 Subject: [PATCH 03/14] =?UTF-8?q?chore:=20flyway=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0=20(#1012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: HyeonsuLee --- .../db/migration/V93__add_menu_search_keyword_table.sql | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 src/main/resources/db/migration/V93__add_menu_search_keyword_table.sql diff --git a/src/main/resources/db/migration/V93__add_menu_search_keyword_table.sql b/src/main/resources/db/migration/V93__add_menu_search_keyword_table.sql deleted file mode 100644 index 058eb5607..000000000 --- a/src/main/resources/db/migration/V93__add_menu_search_keyword_table.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE if not exists `shop_menu_search_keywords` -( - id INT UNSIGNED AUTO_INCREMENT NOT NULL, - keyword VARCHAR(255) NOT NULL, - created_at timestamp default CURRENT_TIMESTAMP NOT NULL, - updated_at timestamp default CURRENT_TIMESTAMP NOT NULL on update CURRENT_TIMESTAMP, - PRIMARY KEY (`id`) - ); From cf5f33e82ab7e4baa66cc1d46f39dab485387709 Mon Sep 17 00:00:00 2001 From: Hwang HyeonSik <142300831+Choon0414@users.noreply.github.com> Date: Wed, 13 Nov 2024 11:12:28 +0900 Subject: [PATCH 04/14] =?UTF-8?q?fix:=20=ED=9E=88=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=A1=B0=ED=9A=8C=20=EB=82=B4=EB=A6=BC=EC=B0=A8?= =?UTF-8?q?=EC=88=9C=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95=20(#1014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../koin/admin/version/repository/AdminVersionRepository.java | 2 +- .../koin/admin/version/service/AdminVersionService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/in/koreatech/koin/admin/version/repository/AdminVersionRepository.java b/src/main/java/in/koreatech/koin/admin/version/repository/AdminVersionRepository.java index b8c5486cb..24c66bd5e 100644 --- a/src/main/java/in/koreatech/koin/admin/version/repository/AdminVersionRepository.java +++ b/src/main/java/in/koreatech/koin/admin/version/repository/AdminVersionRepository.java @@ -21,7 +21,7 @@ public interface AdminVersionRepository extends Repository { Page findAllByIsPrevious(boolean isPrevious, Pageable pageable); - Page findAllByType(String type, PageRequest pageRequest); + Page findAllByTypeOrderByVersionDesc(String type, PageRequest pageRequest); Optional findById(Integer id); diff --git a/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java b/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java index c2f3083dc..75f233366 100644 --- a/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java +++ b/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java @@ -63,7 +63,7 @@ public AdminVersionHistoryResponse getHistory(String type, Integer page, Integer PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit(), Sort.by(Sort.Direction.ASC, "id")); - Page result = adminVersionRepository.findAllByType(versionType.getValue(), pageRequest); + Page result = adminVersionRepository.findAllByTypeOrderByVersionDesc(versionType.getValue(), pageRequest); return AdminVersionHistoryResponse.of(result, criteria); } From 36881a96557c002414e936605adafb47f2d467a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Wed, 13 Nov 2024 21:44:42 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=96=B4=EB=93=9C=EB=AF=BC=20=EA=B3=84=EC=A0=95=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80=20(#1?= =?UTF-8?q?017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../koin/admin/user/controller/AdminUserApi.java | 14 ++++++++++++++ .../admin/user/controller/AdminUserController.java | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserApi.java b/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserApi.java index b1bb87f0e..26a867e36 100644 --- a/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserApi.java +++ b/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserApi.java @@ -144,6 +144,20 @@ ResponseEntity refresh( @RequestBody @Valid AdminTokenRefreshRequest request ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "로그인 어드민 계정 정보 조회") + @GetMapping("/admin") + ResponseEntity getLoginAdminInfo( + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), diff --git a/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserController.java b/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserController.java index 8c1a50eae..db36de701 100644 --- a/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserController.java +++ b/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserController.java @@ -118,6 +118,14 @@ public ResponseEntity refresh( .body(tokenGroupResponse); } + @GetMapping("/admin") + public ResponseEntity getLoginAdminInfo( + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminResponse adminResponse = adminUserService.getAdmin(adminId); + return ResponseEntity.ok(adminResponse); + } + @GetMapping("/admin/{id}") public ResponseEntity getAdmin( @PathVariable("id") Integer id, From ace969db8bb25f4611703e1ed0479791eb6bc9a1 Mon Sep 17 00:00:00 2001 From: krSeonghyeon <149303551+krSeonghyeon@users.noreply.github.com> Date: Wed, 13 Nov 2024 21:55:37 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC,=20?= =?UTF-8?q?=EC=82=AC=EC=9E=A5=EB=8B=98=20=EC=83=81=EC=A0=90=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1,=20=EC=88=98=EC=A0=95=EC=8B=9C=20=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC,=20=EB=B0=B0=EB=84=88?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B6=94=EA=B0=80=20=20(#1015?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 어드민 상점 생성, 수정시 메인 카테고리 선택 기능 추가 * chore: 변수명 변경 * feat: shop_categories 테이블에 event_image_url 필드 추가 및 관련코드 수정 * feat: 로직 변경 * feat: 테스트코드 제외 완성 * refactor: JPA참조 변경 * refactor: dto 분리 * chore: 필드명 수정 --------- Co-authored-by: HyeonsuLee --- .../dto/AdminCreateShopCategoryRequest.java | 9 +- .../shop/dto/AdminCreateShopRequest.java | 10 ++- .../dto/AdminModifyShopCategoryRequest.java | 6 +- .../shop/dto/AdminModifyShopRequest.java | 6 +- .../shop/dto/AdminShopCategoryResponse.java | 8 +- .../admin/shop/dto/AdminShopResponse.java | 4 + .../admin/shop/service/AdminShopService.java | 10 ++- .../ownershop/dto/OwnerShopsRequest.java | 8 +- .../ownershop/service/OwnerShopService.java | 7 +- .../koin/domain/shop/controller/ShopApi.java | 7 +- .../shop/controller/ShopController.java | 7 +- .../shop/dto/shop/ModifyShopRequest.java | 4 + .../shop/ShopEventsWithBannerUrlResponse.java | 89 +++++++++++++++++++ ...> ShopEventsWithThumbnailUrlResponse.java} | 25 ++---- .../domain/shop/dto/shop/ShopResponse.java | 4 + .../koin/domain/shop/model/shop/Shop.java | 12 ++- .../domain/shop/model/shop/ShopCategory.java | 10 ++- .../koin/domain/shop/service/ShopService.java | 11 +-- ...lter_shops_add_main_category_id_column.sql | 5 ++ ...ries_add_event_banner_image_url_column.sql | 2 + .../koin/acceptance/BenefitApiTest.java | 29 +++++- .../koin/acceptance/OwnerApiTest.java | 4 +- .../koin/acceptance/OwnerShopApiTest.java | 14 +-- .../koin/acceptance/ShopApiTest.java | 25 +++--- .../koin/acceptance/ShopSearchApiTest.java | 25 +++++- .../admin/acceptance/AdminBenefitApiTest.java | 28 +++++- .../admin/acceptance/AdminShopApiTest.java | 10 ++- .../acceptance/AdminShopReviewApiTest.java | 23 ++++- .../admin/acceptance/AdminUserApiTest.java | 8 +- .../koin/fixture/ShopCategoryFixture.java | 2 + .../koreatech/koin/fixture/ShopFixture.java | 7 +- 31 files changed, 337 insertions(+), 82 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopEventsWithBannerUrlResponse.java rename src/main/java/in/koreatech/koin/domain/shop/dto/shop/{ShopEventsResponse.java => ShopEventsWithThumbnailUrlResponse.java} (75%) create mode 100644 src/main/resources/db/migration/V96__alter_shops_add_main_category_id_column.sql create mode 100644 src/main/resources/db/migration/V97__alter_shop_categories_add_event_banner_image_url_column.sql diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java index 1d892d5bc..09d036b60 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java @@ -17,7 +17,7 @@ public record AdminCreateShopCategoryRequest( @Schema(description = "이미지 URL", example = "https://static.koreatech.in/test.png", requiredMode = RequiredMode.REQUIRED) @NotBlank(message = "이미지 URL은 필수입니다.") - @Size(max = 100, message = "이미지 URL은 255자 이하로 입력해주세요.") + @Size(max = 255, message = "이미지 URL은 255자 이하로 입력해주세요.") String imageUrl, @Schema(description = "이름", example = "햄버거", requiredMode = RequiredMode.REQUIRED) @@ -27,7 +27,11 @@ public record AdminCreateShopCategoryRequest( @Schema(description = "상위 카테고리 id", example = "1", requiredMode = REQUIRED) @NotNull(message = "상위 카테고리는 필수입니다.") - Integer parentCategoryId + Integer parentCategoryId, + + @Schema(description = "이벤트 이미지 URL", example = "https://static.koreatech.in/test.png") + @Size(max = 255, message = "이벤트 이미지 URL은 255자 이하로 입력해주세요.") + String eventBannerImageUrl ) { public ShopCategory toShopCategory(ShopParentCategory shopParentCategory) { @@ -35,6 +39,7 @@ public ShopCategory toShopCategory(ShopParentCategory shopParentCategory) { .imageUrl(imageUrl) .name(name) .parentCategory(shopParentCategory) + .eventBannerImageUrl(eventBannerImageUrl) .build(); } } diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopRequest.java index c3ca69072..efec3ea7c 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopRequest.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopRequest.java @@ -11,6 +11,7 @@ import com.fasterxml.jackson.databind.annotation.JsonNaming; import in.koreatech.koin.domain.shop.model.shop.Shop; +import in.koreatech.koin.domain.shop.model.shop.ShopCategory; import in.koreatech.koin.domain.shop.model.shop.ShopOpen; import in.koreatech.koin.global.validation.NotBlankElement; import in.koreatech.koin.global.validation.UniqueId; @@ -31,7 +32,11 @@ public record AdminCreateShopRequest( @Size(min = 1, max = 100, message = "주소는 1자 이상, 100자 이하로 입력해주세요.") String address, - @Schema(description = "상점 카테고리 고유 id 리스트", example = "[1, 2]", requiredMode = REQUIRED) + @Schema(description = "메인 카테고리 고유 id", example = "2", requiredMode = REQUIRED) + @NotNull(message = "메인 카테고리는 필수입니다.") + Integer mainCategoryId, + + @Schema(description = "상점 카테고리 고유 id 리스트(메인 카테고리 포함)", example = "[1, 2]", requiredMode = REQUIRED) @NotNull(message = "카테고리는 필수입니다.") @UniqueId(message = "카테고리 ID는 중복될 수 없습니다.") @Size(min = 1, message = "최소 한 개의 카테고리가 필요합니다.") @@ -84,8 +89,9 @@ public record AdminCreateShopRequest( String phone ) { - public Shop toShop() { + public Shop toShop(ShopCategory shopCategory) { return Shop.builder() + .shopMainCategory(shopCategory) .address(address) .delivery(delivery) .deliveryPrice(deliveryPrice) diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoryRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoryRequest.java index 554dd7187..3f619c72c 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoryRequest.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoryRequest.java @@ -25,7 +25,11 @@ public record AdminModifyShopCategoryRequest( @Schema(description = "상위 카테고리 id", example = "1", requiredMode = REQUIRED) @NotNull(message = "상위 카테고리는 필수입니다.") - Integer parentCategoryId + Integer parentCategoryId, + + @Schema(description = "이벤트 이미지 URL", example = "https://static.koreatech.in/test.png") + @Size(max = 255, message = "이벤트 이미지 URL은 255자 이하로 입력해주세요.") + String eventBannerImageUrl ) { } diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java index d428a17b5..baa0aee07 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java @@ -31,7 +31,11 @@ public record AdminModifyShopRequest( @Size(min = 1, max = 100, message = "주소는 1자 이상, 100자 이하로 입력해주세요.") String address, - @Schema(description = "상점 카테고리 고유 id 리스트", example = "[1, 2]", requiredMode = REQUIRED) + @Schema(description = "메인 카테고리 고유 id", example = "2", requiredMode = REQUIRED) + @NotNull(message = "메인 카테고리는 필수입니다.") + Integer mainCategoryId, + + @Schema(description = "상점 카테고리 고유 id 리스트(메인 카테고리 포함)", example = "[1, 2]", requiredMode = REQUIRED) @NotNull(message = "카테고리는 필수입니다.") @UniqueId(message = "카테고리 ID는 중복될 수 없습니다.") @Size(min = 1, message = "최소 한 개의 카테고리가 필요합니다.") diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoryResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoryResponse.java index 521b5046b..c32c88b8a 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoryResponse.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoryResponse.java @@ -18,7 +18,10 @@ public record AdminShopCategoryResponse( String name, @Schema(description = "상위 카테고리 ID", example = "1") - Integer parentCategoryId + Integer parentCategoryId, + + @Schema(description = "카테고리 이벤트 이미지 URL", example = "string") + String eventBannerImageUrl ) { public static AdminShopCategoryResponse from(ShopCategory shopCategory) { @@ -26,7 +29,8 @@ public static AdminShopCategoryResponse from(ShopCategory shopCategory) { shopCategory.getId(), shopCategory.getImageUrl(), shopCategory.getName(), - shopCategory.getParentCategory().getId() + shopCategory.getParentCategory().getId(), + shopCategory.getEventBannerImageUrl() ); } } diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopResponse.java index 23b94f579..e99343528 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopResponse.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopResponse.java @@ -59,6 +59,9 @@ public record AdminShopResponse( @Schema(description = "소속된 상점 카테고리 리스트") List shopCategories, + @Schema(description = "메인 카테고리 id", example = "1") + Integer mainCategoryId, + @JsonFormat(pattern = "yyyy-MM-dd") @Schema(description = "업데이트 날짜", example = "2024-03-01", requiredMode = REQUIRED) LocalDateTime updatedAt, @@ -112,6 +115,7 @@ public static AdminShopResponse from(Shop shop, Boolean isEvent) { shopCategory.getName() ); }).toList(), + shop.getShopMainCategory() != null ? shop.getShopMainCategory().getId() : null, shop.getUpdatedAt(), shop.isDeleted(), isEvent, diff --git a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java index e7489afb0..4ef923c1d 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java +++ b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java @@ -126,7 +126,8 @@ public AdminMenuDetailResponse getMenu(Integer shopId, Integer menuId) { @Transactional public void createShop(AdminCreateShopRequest adminCreateShopRequest) { - Shop shop = adminCreateShopRequest.toShop(); + ShopCategory shopMainCategory = adminShopCategoryRepository.getById(adminCreateShopRequest.mainCategoryId()); + Shop shop = adminCreateShopRequest.toShop(shopMainCategory); Shop savedShop = adminShopRepository.save(shop); List categoryNames = List.of("추천 메뉴", "메인 메뉴", "세트 메뉴", "사이드 메뉴"); for (String categoryName : categoryNames) { @@ -234,6 +235,7 @@ public void cancelShopDelete(Integer shopId) { @Transactional public void modifyShop(Integer shopId, AdminModifyShopRequest adminModifyShopRequest) { Shop shop = adminShopRepository.getById(shopId); + ShopCategory shopMainCategory = adminShopCategoryRepository.getById(adminModifyShopRequest.mainCategoryId()); shop.modifyShop( adminModifyShopRequest.name(), adminModifyShopRequest.phone(), @@ -244,7 +246,8 @@ public void modifyShop(Integer shopId, AdminModifyShopRequest adminModifyShopReq adminModifyShopRequest.payCard(), adminModifyShopRequest.payBank(), adminModifyShopRequest.bank(), - adminModifyShopRequest.accountNumber() + adminModifyShopRequest.accountNumber(), + shopMainCategory ); shop.modifyShopCategories( adminShopCategoryRepository.findAllByIdIn(adminModifyShopRequest.categoryIds()), @@ -263,7 +266,8 @@ public void modifyShopCategory(Integer categoryId, AdminModifyShopCategoryReques shopCategory.modifyShopCategory( adminModifyShopCategoryRequest.name(), adminModifyShopCategoryRequest.imageUrl(), - shopParentCategory + shopParentCategory, + adminModifyShopCategoryRequest.eventBannerImageUrl() ); } diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java b/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java index 3eff137e3..0394d04f9 100644 --- a/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java +++ b/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java @@ -10,6 +10,7 @@ import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.shop.model.shop.Shop; +import in.koreatech.koin.domain.shop.model.shop.ShopCategory; import in.koreatech.koin.global.validation.NotBlankElement; import in.koreatech.koin.global.validation.UniqueId; import in.koreatech.koin.global.validation.UniqueUrl; @@ -28,6 +29,10 @@ public record OwnerShopsRequest( @NotBlank(message = "주소를 입력해주세요.") String address, + @Schema(description = "메인 카테고리 고유 id", example = "2", requiredMode = REQUIRED) + @NotNull(message = "메인 카테고리는 필수입니다.") + Integer mainCategoryId, + @Schema(description = "상점 카테고리 고유 id 리스트", example = "[1]", requiredMode = REQUIRED) @NotNull(message = "카테고리를 입력해주세요.") @Size(min = 1, message = "최소 한 개의 카테고리가 필요합니다.") @@ -79,9 +84,10 @@ public record OwnerShopsRequest( String phone ) { - public Shop toEntity(Owner owner) { + public Shop toEntity(Owner owner, ShopCategory shopMainCategory) { return Shop.builder() .owner(owner) + .shopMainCategory(shopMainCategory) .address(address) .deliveryPrice(deliveryPrice) .delivery(delivery) diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/service/OwnerShopService.java b/src/main/java/in/koreatech/koin/domain/ownershop/service/OwnerShopService.java index d96c59240..28911e227 100644 --- a/src/main/java/in/koreatech/koin/domain/ownershop/service/OwnerShopService.java +++ b/src/main/java/in/koreatech/koin/domain/ownershop/service/OwnerShopService.java @@ -89,7 +89,8 @@ public OwnerShopsResponse getOwnerShops(Integer ownerId) { @Transactional public void createOwnerShops(Integer ownerId, OwnerShopsRequest ownerShopsRequest) { Owner owner = ownerRepository.getById(ownerId); - Shop newShop = ownerShopsRequest.toEntity(owner); + ShopCategory shopMainCategory = shopCategoryRepository.getById(ownerShopsRequest.mainCategoryId()); + Shop newShop = ownerShopsRequest.toEntity(owner, shopMainCategory); Shop savedShop = shopRepository.save(newShop); List categoryNames = List.of("추천 메뉴", "메인 메뉴", "세트 메뉴", "사이드 메뉴"); for (String categoryName : categoryNames) { @@ -253,6 +254,7 @@ public void modifyCategory(Integer ownerId, Integer categoryId, ModifyCategoryRe @Transactional public void modifyShop(Integer ownerId, Integer shopId, ModifyShopRequest modifyShopRequest) { Shop shop = getOwnerShopById(shopId, ownerId); + ShopCategory shopCategory = shopCategoryRepository.getById(modifyShopRequest.mainCategoryId()); shop.modifyShop( modifyShopRequest.name(), modifyShopRequest.phone(), @@ -263,7 +265,8 @@ public void modifyShop(Integer ownerId, Integer shopId, ModifyShopRequest modify modifyShopRequest.payCard(), modifyShopRequest.payBank(), modifyShopRequest.bank(), - modifyShopRequest.accountNumber() + modifyShopRequest.accountNumber(), + shopCategory ); shop.modifyShopImages(modifyShopRequest.imageUrls(), entityManager); shop.modifyShopOpens(modifyShopRequest.open(), entityManager); diff --git a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java index a2a39b54a..8bb2d1c56 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java +++ b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java @@ -21,9 +21,10 @@ import in.koreatech.koin.domain.shop.dto.review.ModifyReviewRequest; import in.koreatech.koin.domain.shop.dto.review.ReviewsSortCriteria; import in.koreatech.koin.domain.shop.dto.shop.ShopCategoriesResponse; -import in.koreatech.koin.domain.shop.dto.shop.ShopEventsResponse; import in.koreatech.koin.domain.shop.dto.menu.ShopMenuResponse; import in.koreatech.koin.domain.shop.dto.review.ShopMyReviewsResponse; +import in.koreatech.koin.domain.shop.dto.shop.ShopEventsWithBannerUrlResponse; +import in.koreatech.koin.domain.shop.dto.shop.ShopEventsWithThumbnailUrlResponse; import in.koreatech.koin.domain.shop.dto.shop.ShopResponse; import in.koreatech.koin.domain.shop.dto.review.ShopReviewReportCategoryResponse; import in.koreatech.koin.domain.shop.dto.review.ShopReviewReportRequest; @@ -134,7 +135,7 @@ ResponseEntity getShopById( ) @Operation(summary = "특정 상점의 모든 이벤트 조회") @GetMapping("/shops/{shopId}/events") - ResponseEntity getShopEvents( + ResponseEntity getShopEvents( @PathVariable Integer shopId ); @@ -148,7 +149,7 @@ ResponseEntity getShopEvents( ) @Operation(summary = "모든 상점의 모든 이벤트 조회") @GetMapping("/shops/events") - ResponseEntity getShopAllEvent(); + ResponseEntity getShopAllEvent(); @ApiResponses( value = { diff --git a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java index 940df7def..8b6a590d5 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java +++ b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java @@ -16,7 +16,8 @@ import in.koreatech.koin.domain.shop.dto.review.ShopReviewsResponse; import in.koreatech.koin.domain.shop.dto.search.RelatedKeyword; import in.koreatech.koin.domain.shop.dto.shop.ShopCategoriesResponse; -import in.koreatech.koin.domain.shop.dto.shop.ShopEventsResponse; +import in.koreatech.koin.domain.shop.dto.shop.ShopEventsWithBannerUrlResponse; +import in.koreatech.koin.domain.shop.dto.shop.ShopEventsWithThumbnailUrlResponse; import in.koreatech.koin.domain.shop.dto.shop.ShopResponse; import in.koreatech.koin.domain.shop.dto.shop.ShopsFilterCriteria; import in.koreatech.koin.domain.shop.dto.shop.ShopsResponse; @@ -97,7 +98,7 @@ public ResponseEntity getShopsCategories() { } @GetMapping("/shops/{shopId}/events") - public ResponseEntity getShopEvents( + public ResponseEntity getShopEvents( @PathVariable Integer shopId ) { var response = shopService.getShopEvents(shopId); @@ -105,7 +106,7 @@ public ResponseEntity getShopEvents( } @GetMapping("/shops/events") - public ResponseEntity getShopAllEvent() { + public ResponseEntity getShopAllEvent() { var response = shopService.getAllEvents(); return ResponseEntity.ok(response); } diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ModifyShopRequest.java b/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ModifyShopRequest.java index d34f735f6..5a5fabeb3 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ModifyShopRequest.java +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ModifyShopRequest.java @@ -28,6 +28,10 @@ public record ModifyShopRequest( @Schema(example = "충청남도 천안시 동남구 병천면", description = "주소", requiredMode = NOT_REQUIRED) String address, + @Schema(description = "메인 카테고리 고유 id", example = "2", requiredMode = REQUIRED) + @NotNull(message = "메인 카테고리는 필수입니다.") + Integer mainCategoryId, + @Schema(example = "[1, 2]", description = "상점 카테고리 고유 id 리스트", requiredMode = REQUIRED) @NotNull(message = "카테고리는 필수입니다.") @UniqueId(message = "카테고리 ID는 중복될 수 없습니다.") diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopEventsWithBannerUrlResponse.java b/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopEventsWithBannerUrlResponse.java new file mode 100644 index 000000000..e57622a13 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopEventsWithBannerUrlResponse.java @@ -0,0 +1,89 @@ +package in.koreatech.koin.domain.shop.dto.shop; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.*; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.Clock; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.article.EventArticle; +import in.koreatech.koin.domain.shop.model.shop.Shop; +import in.koreatech.koin.domain.shop.model.shop.ShopCategory; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record ShopEventsWithBannerUrlResponse( + + @Schema(description = "이벤트 목록") + List events +) { + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerShopEventResponse( + @Schema(description = "상점 ID", example = "1", requiredMode = REQUIRED) + Integer shopId, + + @Schema(description = "상점 이름", example = "술꾼", requiredMode = REQUIRED) + String shopName, + + @Schema(description = "이벤트 ID", example = "1", requiredMode = REQUIRED) + Integer eventId, + + @Schema(description = "이벤트 제목", example = "콩순이 사장님이 미쳤어요!!", requiredMode = REQUIRED) + String title, + + @Schema(description = "이벤트 내용", example = "콩순이 가게 전메뉴 90% 할인! 가게 폐업 임박...", requiredMode = REQUIRED) + String content, + + @Schema(description = "이벤트 이미지", example = """ + [ "https://static.koreatech.in/example.png" ] + """, requiredMode = NOT_REQUIRED) + List thumbnailImages, + + @JsonFormat(pattern = "yyyy-MM-dd") + @Schema(description = "시작일", example = "2024-10-22", requiredMode = REQUIRED) + LocalDate startDate, + + @JsonFormat(pattern = "yyyy-MM-dd") + @Schema(description = "종료일", example = "2024-10-25", requiredMode = REQUIRED) + LocalDate endDate + ) { + + public static InnerShopEventResponse from(EventArticle eventArticle, Shop shop) { + return new InnerShopEventResponse( + shop.getId(), + shop.getName(), + eventArticle.getId(), + eventArticle.getTitle(), + eventArticle.getContent(), + Optional.ofNullable(shop) + .map(Shop::getShopMainCategory) + .map(ShopCategory::getEventBannerImageUrl) + .map(List::of) + .orElse(null), + eventArticle.getStartDate(), + eventArticle.getEndDate() + ); + } + } + + public static ShopEventsWithBannerUrlResponse of(List shops, Clock clock) { + List innerShopEventResponses = new ArrayList<>(); + for (Shop shop : shops) { + for (EventArticle eventArticle : shop.getEventArticles()) { + if (!eventArticle.getStartDate().isAfter(LocalDate.now(clock)) && + !eventArticle.getEndDate().isBefore(LocalDate.now(clock))) { + innerShopEventResponses.add(InnerShopEventResponse.from(eventArticle, shop)); + } + } + } + return new ShopEventsWithBannerUrlResponse(innerShopEventResponses); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopEventsResponse.java b/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopEventsWithThumbnailUrlResponse.java similarity index 75% rename from src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopEventsResponse.java rename to src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopEventsWithThumbnailUrlResponse.java index 706340157..330994a8f 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopEventsResponse.java +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopEventsWithThumbnailUrlResponse.java @@ -9,7 +9,7 @@ import java.util.List; import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; import in.koreatech.koin.domain.shop.model.article.EventArticle; @@ -17,14 +17,14 @@ import in.koreatech.koin.domain.shop.model.shop.Shop; import io.swagger.v3.oas.annotations.media.Schema; -@JsonNaming(value = SnakeCaseStrategy.class) -public record ShopEventsResponse( +@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) +public record ShopEventsWithThumbnailUrlResponse( @Schema(description = "이벤트 목록") List events ) { - @JsonNaming(value = SnakeCaseStrategy.class) + @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) public record InnerShopEventResponse( @Schema(description = "상점 ID", example = "1", requiredMode = REQUIRED) Integer shopId, @@ -71,20 +71,7 @@ public static InnerShopEventResponse from(EventArticle eventArticle) { } } - public static ShopEventsResponse of(List shops, Clock clock) { - List innerShopEventResponses = new ArrayList<>(); - for (Shop shop : shops) { - for (EventArticle eventArticle : shop.getEventArticles()) { - if (!eventArticle.getStartDate().isAfter(LocalDate.now(clock)) && - !eventArticle.getEndDate().isBefore(LocalDate.now(clock))) { - innerShopEventResponses.add(InnerShopEventResponse.from(eventArticle)); - } - } - } - return new ShopEventsResponse(innerShopEventResponses); - } - - public static ShopEventsResponse of(Shop shop, Clock clock) { + public static ShopEventsWithThumbnailUrlResponse of(Shop shop, Clock clock) { List innerShopEventResponses = new ArrayList<>(); for (EventArticle eventArticle : shop.getEventArticles()) { if (!eventArticle.getStartDate().isAfter(LocalDate.now(clock)) && @@ -92,6 +79,6 @@ public static ShopEventsResponse of(Shop shop, Clock clock) { innerShopEventResponses.add(InnerShopEventResponse.from(eventArticle)); } } - return new ShopEventsResponse(innerShopEventResponses); + return new ShopEventsWithThumbnailUrlResponse(innerShopEventResponses); } } diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopResponse.java b/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopResponse.java index 5df75fb66..bb130ce5a 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopResponse.java +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopResponse.java @@ -56,6 +56,9 @@ public record ShopResponse( @Schema(example = "041-000-0000", description = "전화번호", requiredMode = NOT_REQUIRED) String phone, + @Schema(description = "메인 카테고리 고유 id", example = "2") + Integer mainCategoryId, + @Schema(description = "소속된 상점 카테고리 리스트") List shopCategories, @@ -102,6 +105,7 @@ public static ShopResponse from(Shop shop, Boolean isEvent) { shop.isPayBank(), shop.isPayCard(), shop.getPhone(), + shop.getShopMainCategory() != null ? shop.getShopMainCategory().getId() : null, shop.getShopCategories().stream().map(shopCategoryMap -> { ShopCategory shopCategory = shopCategoryMap.getShopCategory(); return new InnerShopCategory( diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/Shop.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/Shop.java index bb6531545..8454d4bca 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/shop/Shop.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/Shop.java @@ -114,6 +114,10 @@ public class Shop extends BaseEntity { @Column(name = "hit", nullable = false) private Integer hit; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "main_category_id", referencedColumnName = "id") + private ShopCategory shopMainCategory; + @OneToMany(mappedBy = "shop", orphanRemoval = true, cascade = {PERSIST, REFRESH, MERGE, REMOVE}) private Set shopCategories = new HashSet<>(); @@ -161,7 +165,8 @@ private Shop( String remarks, Integer hit, String bank, - String accountNumber + String accountNumber, + ShopCategory shopMainCategory ) { this.owner = owner; this.name = name; @@ -180,6 +185,7 @@ private Shop( this.hit = hit; this.bank = bank; this.accountNumber = accountNumber; + this.shopMainCategory = shopMainCategory; } public void modifyShop( @@ -192,7 +198,8 @@ public void modifyShop( Boolean payCard, boolean payBank, String bank, - String accountNumber + String accountNumber, + ShopCategory shopMainCategory ) { this.address = address; this.delivery = delivery; @@ -204,6 +211,7 @@ public void modifyShop( this.phone = phone; this.bank = bank; this.accountNumber = accountNumber; + this.shopMainCategory = shopMainCategory; } public boolean isOpen(LocalDateTime now) { diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java index 39ed53da1..7049ff714 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java @@ -41,6 +41,10 @@ public class ShopCategory extends BaseEntity { @Column(name = "image_url") private String imageUrl; + @Size(max = 255) + @Column(name = "event_banner_image_url") + private String eventBannerImageUrl; + @OneToMany(mappedBy = "shopCategory", orphanRemoval = true, cascade = {PERSIST, REMOVE}) private List shopCategoryMaps = new ArrayList<>(); @@ -49,15 +53,17 @@ public class ShopCategory extends BaseEntity { private ShopParentCategory parentCategory; @Builder - private ShopCategory(String name, String imageUrl, ShopParentCategory parentCategory) { + private ShopCategory(String name, String imageUrl, ShopParentCategory parentCategory, String eventBannerImageUrl) { this.name = name; this.imageUrl = imageUrl; this.parentCategory = parentCategory; + this.eventBannerImageUrl = eventBannerImageUrl; } - public void modifyShopCategory(String name, String imageUrl, ShopParentCategory parentCategory) { + public void modifyShopCategory(String name, String imageUrl, ShopParentCategory parentCategory, String eventBannerImageUrl) { this.name = name; this.imageUrl = imageUrl; this.parentCategory = parentCategory; + this.eventBannerImageUrl = eventBannerImageUrl; } } diff --git a/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java b/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java index 458a4b6ac..ac9c5d808 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java +++ b/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java @@ -8,7 +8,8 @@ import in.koreatech.koin.domain.shop.dto.menu.MenuDetailResponse; import in.koreatech.koin.domain.shop.dto.menu.ShopMenuResponse; import in.koreatech.koin.domain.shop.dto.shop.ShopCategoriesResponse; -import in.koreatech.koin.domain.shop.dto.shop.ShopEventsResponse; +import in.koreatech.koin.domain.shop.dto.shop.ShopEventsWithBannerUrlResponse; +import in.koreatech.koin.domain.shop.dto.shop.ShopEventsWithThumbnailUrlResponse; import in.koreatech.koin.domain.shop.dto.shop.ShopResponse; import in.koreatech.koin.domain.shop.dto.shop.ShopsFilterCriteria; import in.koreatech.koin.domain.shop.dto.shop.ShopsResponse; @@ -103,14 +104,14 @@ public ShopCategoriesResponse getShopsCategories() { return ShopCategoriesResponse.from(shopCategories); } - public ShopEventsResponse getShopEvents(Integer shopId) { + public ShopEventsWithThumbnailUrlResponse getShopEvents(Integer shopId) { Shop shop = shopRepository.getById(shopId); - return ShopEventsResponse.of(shop, clock); + return ShopEventsWithThumbnailUrlResponse.of(shop, clock); } - public ShopEventsResponse getAllEvents() { + public ShopEventsWithBannerUrlResponse getAllEvents() { List shops = shopRepository.findAll(); - return ShopEventsResponse.of(shops, clock); + return ShopEventsWithBannerUrlResponse.of(shops, clock); } public ShopsResponseV2 getShopsV2( diff --git a/src/main/resources/db/migration/V96__alter_shops_add_main_category_id_column.sql b/src/main/resources/db/migration/V96__alter_shops_add_main_category_id_column.sql new file mode 100644 index 000000000..6718e60fb --- /dev/null +++ b/src/main/resources/db/migration/V96__alter_shops_add_main_category_id_column.sql @@ -0,0 +1,5 @@ +ALTER TABLE `shops` + ADD COLUMN `main_category_id` INT UNSIGNED COMMENT '메인 카테고리 id', + ADD CONSTRAINT `FK_SHOPS_ON_SHOP_CATEGORIES` + FOREIGN KEY (`main_category_id`) + REFERENCES `shop_categories` (`id`); diff --git a/src/main/resources/db/migration/V97__alter_shop_categories_add_event_banner_image_url_column.sql b/src/main/resources/db/migration/V97__alter_shop_categories_add_event_banner_image_url_column.sql new file mode 100644 index 000000000..913183486 --- /dev/null +++ b/src/main/resources/db/migration/V97__alter_shop_categories_add_event_banner_image_url_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE shop_categories + ADD COLUMN event_banner_image_url VARCHAR(255) NULL COMMENT '이벤트 배너 이미지' diff --git a/src/test/java/in/koreatech/koin/acceptance/BenefitApiTest.java b/src/test/java/in/koreatech/koin/acceptance/BenefitApiTest.java index 5ccbdb7e1..8de1c7301 100644 --- a/src/test/java/in/koreatech/koin/acceptance/BenefitApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/BenefitApiTest.java @@ -14,10 +14,16 @@ import in.koreatech.koin.domain.benefit.model.BenefitCategory; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.shop.model.shop.Shop; +import in.koreatech.koin.domain.shop.model.shop.ShopCategory; +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationMessage; +import in.koreatech.koin.domain.shop.model.shop.ShopParentCategory; import in.koreatech.koin.domain.student.model.Student; import in.koreatech.koin.fixture.BenefitCategoryFixture; import in.koreatech.koin.fixture.BenefitCategoryMapFixture; +import in.koreatech.koin.fixture.ShopCategoryFixture; import in.koreatech.koin.fixture.ShopFixture; +import in.koreatech.koin.fixture.ShopNotificationMessageFixture; +import in.koreatech.koin.fixture.ShopParentCategoryFixture; import in.koreatech.koin.fixture.ShopReviewFixture; import in.koreatech.koin.fixture.UserFixture; @@ -40,6 +46,15 @@ public class BenefitApiTest extends AcceptanceTest { @Autowired UserFixture userFixture; + @Autowired + ShopCategoryFixture shopCategoryFixture; + + @Autowired + ShopParentCategoryFixture shopParentCategoryFixture; + + @Autowired + ShopNotificationMessageFixture shopNotificationMessageFixture; + BenefitCategory 배달비_무료; BenefitCategory 최소주문금액_무료; BenefitCategory 서비스_증정; @@ -54,6 +69,11 @@ public class BenefitApiTest extends AcceptanceTest { Shop 영업중인_티바; Shop 영업중이_아닌_신전_떡볶이; + private ShopCategory shopCategory_치킨; + private ShopCategory shopCategory_일반; + private ShopParentCategory shopParentCategory_가게; + private ShopNotificationMessage notificationMessage_가게; + @BeforeAll void setup() { clear(); @@ -63,11 +83,16 @@ void setup() { 서비스_증정 = benefitCategoryFixture.서비스_증정(); 가게까지_픽업 = benefitCategoryFixture.가게까지_픽업(); - 마슬랜 = shopFixture.마슬랜(현수_사장님); - 김밥천국 = shopFixture.김밥천국(현수_사장님); + 마슬랜 = shopFixture.마슬랜(현수_사장님, shopCategory_치킨); + 김밥천국 = shopFixture.김밥천국(현수_사장님, shopCategory_일반); 영업중인_티바 = shopFixture.영업중인_티바(현수_사장님); 영업중이_아닌_신전_떡볶이 = shopFixture.영업중이_아닌_신전_떡볶이(현수_사장님); + notificationMessage_가게 = shopNotificationMessageFixture.알림메시지_가게(); + shopParentCategory_가게 = shopParentCategoryFixture.상위_카테고리_가게(notificationMessage_가게); + shopCategory_치킨 = shopCategoryFixture.카테고리_치킨(shopParentCategory_가게); + shopCategory_일반 = shopCategoryFixture.카테고리_일반음식(shopParentCategory_가게); + 성빈_학생 = userFixture.성빈_학생(); benefitCategoryMapFixture.혜택_추가(김밥천국, 배달비_무료); diff --git a/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java b/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java index c92b53d94..50f82f3eb 100644 --- a/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java @@ -103,7 +103,7 @@ class OwnerApiTest extends AcceptanceTest { void 로그인된_사장님_정보를_조회한다() throws Exception { // given Owner owner = userFixture.현수_사장님(); - Shop shop = shopFixture.마슬랜(owner); + Shop shop = shopFixture.마슬랜(owner, null); String token = userFixture.getToken(owner.getUser()); mockMvc.perform( @@ -370,7 +370,7 @@ class ownerRegister { @Test void 사장님이_회원가입_요청을_한다_기존에_존재하는_상점과_함께_회원가입() throws Exception { // given - Shop shop = shopFixture.마슬랜(null); + Shop shop = shopFixture.마슬랜(null, null); mockMvc.perform( post("/owners/register") .content(String.format(""" diff --git a/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java index eeecca650..0a7c7fcaa 100644 --- a/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java @@ -100,12 +100,12 @@ class OwnerShopApiTest extends AcceptanceTest { private Owner owner_준영; private String token_준영; private Shop shop_마슬랜; + private ShopNotificationMessage notificationMessage_가게; + private ShopParentCategory shopParentCategory_가게; private ShopCategory shopCategory_치킨; private ShopCategory shopCategory_일반; private MenuCategory menuCategory_메인; private MenuCategory menuCategory_사이드; - private ShopParentCategory shopParentCategory_가게; - private ShopNotificationMessage notificationMessage_가게; @BeforeAll void setUp() { @@ -114,13 +114,13 @@ void setUp() { token_현수 = userFixture.getToken(owner_현수.getUser()); owner_준영 = userFixture.준영_사장님(); token_준영 = userFixture.getToken(owner_준영.getUser()); - shop_마슬랜 = shopFixture.마슬랜(owner_현수); - menuCategory_메인 = menuCategoryFixture.메인메뉴(shop_마슬랜); - menuCategory_사이드 = menuCategoryFixture.사이드메뉴(shop_마슬랜); notificationMessage_가게 = shopNotificationMessageFixture.알림메시지_가게(); shopParentCategory_가게 = shopParentCategoryFixture.상위_카테고리_가게(notificationMessage_가게); shopCategory_치킨 = shopCategoryFixture.카테고리_치킨(shopParentCategory_가게); shopCategory_일반 = shopCategoryFixture.카테고리_일반음식(shopParentCategory_가게); + shop_마슬랜 = shopFixture.마슬랜(owner_현수, shopCategory_치킨); + menuCategory_메인 = menuCategoryFixture.메인메뉴(shop_마슬랜); + menuCategory_사이드 = menuCategoryFixture.사이드메뉴(shop_마슬랜); } @Test @@ -162,6 +162,7 @@ void setUp() { .content(String.format(""" { "address": "대전광역시 유성구 대학로 291", + "main_category_id": 1, "category_ids": [ %d ], @@ -287,6 +288,7 @@ void setUp() { "pay_bank": true, "pay_card": true, "phone": "010-7574-1212", + "main_category_id": 1, "shop_categories": [ ], "updated_at": "2024-01-15", @@ -640,6 +642,7 @@ void setUp() { .content(String.format(""" { "address": "충청남도 천안시 동남구 병천면 충절로 1600", + "main_category_id": 1, "category_ids": [ %d, %d ], @@ -682,6 +685,7 @@ void setUp() { assertSoftly( softly -> { softly.assertThat(result.getAddress()).isEqualTo("충청남도 천안시 동남구 병천면 충절로 1600"); + softly.assertThat(result.getShopMainCategory().getId()).isEqualTo(1); softly.assertThat(result.isDeleted()).isFalse(); softly.assertThat(result.getDeliveryPrice()).isEqualTo(1000); softly.assertThat(result.getDescription()).isEqualTo("이번주 전 메뉴 10% 할인 이벤트합니다."); diff --git a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java index d1c6cf2f5..d418d596c 100644 --- a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java @@ -20,6 +20,7 @@ import in.koreatech.koin.domain.shop.model.menu.Menu; import in.koreatech.koin.domain.shop.model.review.ShopReview; import in.koreatech.koin.domain.shop.model.shop.Shop; +import in.koreatech.koin.domain.shop.model.shop.ShopCategory; import in.koreatech.koin.domain.shop.model.shop.ShopNotificationMessage; import in.koreatech.koin.domain.shop.model.shop.ShopParentCategory; import in.koreatech.koin.domain.student.model.Student; @@ -78,16 +79,18 @@ class ShopApiTest extends AcceptanceTest { private ShopParentCategory shopParentCategory_가게; private ShopNotificationMessage notificationMessage_가게; + private ShopCategory shopCategory_치킨; + @BeforeAll void setUp() { clear(); owner = userFixture.준영_사장님(); - 마슬랜 = shopFixture.마슬랜(owner); 익명_학생 = userFixture.익명_학생(); token_익명 = userFixture.getToken(익명_학생.getUser()); - notificationMessage_가게 = shopNotificationMessageFixture.알림메시지_가게(); shopParentCategory_가게 = shopParentCategoryFixture.상위_카테고리_가게(notificationMessage_가게); + shopCategory_치킨 = shopCategoryFixture.카테고리_치킨(shopParentCategory_가게); + 마슬랜 = shopFixture.마슬랜(owner, shopCategory_치킨); } @Test @@ -198,7 +201,6 @@ void setUp() { ) .andExpect(status().isOk()) .andExpect(content().json(""" - { "address": "천안시 동남구 병천면 1600", "delivery": true, @@ -420,7 +422,6 @@ void setUp() { @Test void 상점들의_모든_카테고리를_조회한다() throws Exception { shopCategoryFixture.카테고리_일반음식(shopParentCategory_가게); - shopCategoryFixture.카테고리_치킨(shopParentCategory_가게); mockMvc.perform( get("/shops/categories") @@ -432,14 +433,15 @@ void setUp() { "shop_categories": [ { "id": 1, - "image_url": "https://test-image.com/normal.jpg", - "name": "일반음식점" + "image_url": "https://test-image.com/ckicken.jpg", + "name": "치킨" }, { "id": 2, - "image_url": "https://test-image.com/ckicken.jpg", - "name": "치킨" - } + "image_url": "https://test-image.com/normal.jpg", + "name": "일반음식점" + }, + ] } """)); @@ -524,7 +526,7 @@ void setUp() { void 이벤트_베너_조회() throws Exception { eventArticleFixture.참여_이벤트(마슬랜, LocalDate.now(clock), LocalDate.now(clock).plusDays(10)); eventArticleFixture.할인_이벤트(마슬랜, LocalDate.now(clock).minusDays(10), LocalDate.now(clock).minusDays(1)); - + mockMvc.perform( get("/shops/events") ) @@ -539,8 +541,7 @@ void setUp() { "title": "참여 이벤트", "content": "사장님과 참여해요!!!", "thumbnail_images": [ - "https://eventimage.com/참여_이벤트.jpg", - "https://eventimage.com/참여_이벤트.jpg" + "https://test-image.com/chicken-event.jpg" ], "start_date": "2024-01-15", "end_date": "2024-01-25" diff --git a/src/test/java/in/koreatech/koin/acceptance/ShopSearchApiTest.java b/src/test/java/in/koreatech/koin/acceptance/ShopSearchApiTest.java index 7a7e56df6..a3994edb7 100644 --- a/src/test/java/in/koreatech/koin/acceptance/ShopSearchApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/ShopSearchApiTest.java @@ -14,6 +14,9 @@ import in.koreatech.koin.domain.shop.model.menu.MenuSearchKeyWord; import in.koreatech.koin.domain.shop.model.review.ShopReview; import in.koreatech.koin.domain.shop.model.shop.Shop; +import in.koreatech.koin.domain.shop.model.shop.ShopCategory; +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationMessage; +import in.koreatech.koin.domain.shop.model.shop.ShopParentCategory; import in.koreatech.koin.domain.shop.repository.menu.MenuSearchKeywordRepository; import in.koreatech.koin.domain.student.model.Student; import in.koreatech.koin.fixture.EventArticleFixture; @@ -21,6 +24,8 @@ import in.koreatech.koin.fixture.MenuFixture; import in.koreatech.koin.fixture.ShopCategoryFixture; import in.koreatech.koin.fixture.ShopFixture; +import in.koreatech.koin.fixture.ShopNotificationMessageFixture; +import in.koreatech.koin.fixture.ShopParentCategoryFixture; import in.koreatech.koin.fixture.ShopReviewFixture; import in.koreatech.koin.fixture.ShopReviewReportFixture; import in.koreatech.koin.fixture.UserFixture; @@ -30,6 +35,7 @@ import org.junit.jupiter.api.TestInstance; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.shaded.org.checkerframework.checker.units.qual.A; @Transactional @SuppressWarnings("NonAsciiCharacters") @@ -51,14 +57,31 @@ class ShopSearchApiTest extends AcceptanceTest { @Autowired private MenuSearchKeywordRepository menuSearchKeywordRepository; + @Autowired + private ShopCategoryFixture shopCategoryFixture; + + @Autowired + private ShopNotificationMessageFixture shopNotificationMessageFixture; + + @Autowired + private ShopParentCategoryFixture shopParentCategoryFixture; + private Shop 마슬랜; private Owner owner; + private ShopCategory shopCategory_치킨; + private ShopCategory shopCategory_일반; + private ShopParentCategory shopParentCategory_가게; + private ShopNotificationMessage notificationMessage_가게; + @BeforeAll void setUp() { clear(); owner = userFixture.준영_사장님(); - 마슬랜 = shopFixture.마슬랜(owner); + 마슬랜 = shopFixture.마슬랜(owner, shopCategory_치킨); + notificationMessage_가게 = shopNotificationMessageFixture.알림메시지_가게(); + shopParentCategory_가게 = shopParentCategoryFixture.상위_카테고리_가게(notificationMessage_가게); + shopCategory_치킨 = shopCategoryFixture.카테고리_치킨(shopParentCategory_가게); menuSearchKeywordRepository.save(MenuSearchKeyWord.builder() .keyword("짜장면") .build()); diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminBenefitApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminBenefitApiTest.java index 8c64a448a..1dafe5c1e 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminBenefitApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminBenefitApiTest.java @@ -22,9 +22,15 @@ import in.koreatech.koin.domain.benefit.model.BenefitCategoryMap; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.shop.model.shop.Shop; +import in.koreatech.koin.domain.shop.model.shop.ShopCategory; +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationMessage; +import in.koreatech.koin.domain.shop.model.shop.ShopParentCategory; import in.koreatech.koin.fixture.BenefitCategoryFixture; import in.koreatech.koin.fixture.BenefitCategoryMapFixture; +import in.koreatech.koin.fixture.ShopCategoryFixture; import in.koreatech.koin.fixture.ShopFixture; +import in.koreatech.koin.fixture.ShopNotificationMessageFixture; +import in.koreatech.koin.fixture.ShopParentCategoryFixture; import in.koreatech.koin.fixture.UserFixture; @Transactional @@ -49,6 +55,15 @@ public class AdminBenefitApiTest extends AcceptanceTest { @Autowired UserFixture userFixture; + @Autowired + ShopParentCategoryFixture shopParentCategoryFixture; + + @Autowired + ShopCategoryFixture shopCategoryFixture; + + @Autowired + ShopNotificationMessageFixture shopNotificationMessageFixture; + Admin admin; String token_admin; Owner 현수_사장님; @@ -63,6 +78,11 @@ public class AdminBenefitApiTest extends AcceptanceTest { Shop 영업중인_티바; Shop 영업중이_아닌_신전_떡볶이; + private ShopParentCategory shopParentCategory_가게; + private ShopNotificationMessage notificationMessage_가게; + private ShopCategory shopCategory_치킨; + private ShopCategory shopCategory_일반; + @BeforeAll void setup() { clear(); @@ -74,8 +94,8 @@ void setup() { 서비스_증정 = benefitCategoryFixture.서비스_증정(); 가게까지_픽업 = benefitCategoryFixture.가게까지_픽업(); - 마슬랜 = shopFixture.마슬랜(현수_사장님); - 김밥천국 = shopFixture.김밥천국(현수_사장님); + 마슬랜 = shopFixture.마슬랜(현수_사장님, shopCategory_치킨); + 김밥천국 = shopFixture.김밥천국(현수_사장님, shopCategory_일반); 영업중인_티바 = shopFixture.영업중인_티바(현수_사장님); 영업중이_아닌_신전_떡볶이 = shopFixture.영업중이_아닌_신전_떡볶이(현수_사장님); @@ -83,6 +103,10 @@ void setup() { benefitCategoryMapFixture.혜택_추가(마슬랜, 배달비_무료); benefitCategoryMapFixture.혜택_추가(영업중인_티바, 배달비_무료); benefitCategoryMapFixture.혜택_추가(영업중이_아닌_신전_떡볶이, 배달비_무료); + + notificationMessage_가게 = shopNotificationMessageFixture.알림메시지_가게(); + shopParentCategory_가게 = shopParentCategoryFixture.상위_카테고리_가게(notificationMessage_가게); + shopCategory_치킨 = shopCategoryFixture.카테고리_치킨(shopParentCategory_가게); } @Test diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java index 49960ff5f..e633debbc 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java @@ -114,15 +114,15 @@ void setUp() { token_admin = userFixture.getToken(admin.getUser()); owner_현수 = userFixture.현수_사장님(); owner_준영 = userFixture.준영_사장님(); - shop_마슬랜 = shopFixture.마슬랜(owner_현수); - menuCategory_메인 = menuCategoryFixture.메인메뉴(shop_마슬랜); - menuCategory_사이드 = menuCategoryFixture.사이드메뉴(shop_마슬랜); notificationMessage_가게 = shopNotificationMessageFixture.알림메시지_가게(); notificationMessage_콜벤 = shopNotificationMessageFixture.알림메시지_콜벤(); shopParentCategory_가게 = shopParentCategoryFixture.상위_카테고리_가게(notificationMessage_가게); shopParentCategory_콜벤 = shopParentCategoryFixture.상위_카테고리_콜벤(notificationMessage_콜벤); shopCategory_치킨 = shopCategoryFixture.카테고리_치킨(shopParentCategory_가게); shopCategory_일반 = shopCategoryFixture.카테고리_일반음식(shopParentCategory_콜벤); + shop_마슬랜 = shopFixture.마슬랜(owner_현수, shopCategory_치킨); + menuCategory_메인 = menuCategoryFixture.메인메뉴(shop_마슬랜); + menuCategory_사이드 = menuCategoryFixture.사이드메뉴(shop_마슬랜); } @Test @@ -210,6 +210,7 @@ void setUp() { "shop_categories": [ ], + "main_category_id": 1, "updated_at": "2024-01-15", "is_deleted": false, "is_event": false, @@ -403,6 +404,7 @@ void setUp() { .content(String.format(""" { "address": "대전광역시 유성구 대학로 291", + "main_category_id": 1, "category_ids": [ %d ], @@ -647,6 +649,7 @@ void setUp() { .content(String.format(""" { "address": "충청남도 천안시 동남구 병천면 충절로 1600", + "main_category_id": 2, "category_ids": [ %d, %d ], @@ -717,6 +720,7 @@ void setUp() { assertSoftly(softly -> { softly.assertThat(result.getAddress()).isEqualTo("충청남도 천안시 동남구 병천면 충절로 1600"); + softly.assertThat(result.getShopMainCategory().getId()).isEqualTo(2); softly.assertThat(result.isDeleted()).isFalse(); softly.assertThat(result.getDeliveryPrice()).isEqualTo(1000); softly.assertThat(result.getDescription()).isEqualTo("이번주 전 메뉴 10% 할인 이벤트합니다."); diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopReviewApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopReviewApiTest.java index e8227ab1e..f213b01f0 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopReviewApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopReviewApiTest.java @@ -24,8 +24,14 @@ import in.koreatech.koin.domain.shop.model.review.ShopReview; import in.koreatech.koin.domain.shop.model.review.ShopReviewReport; import in.koreatech.koin.domain.shop.model.shop.Shop; +import in.koreatech.koin.domain.shop.model.shop.ShopCategory; +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationMessage; +import in.koreatech.koin.domain.shop.model.shop.ShopParentCategory; import in.koreatech.koin.domain.student.model.Student; +import in.koreatech.koin.fixture.ShopCategoryFixture; import in.koreatech.koin.fixture.ShopFixture; +import in.koreatech.koin.fixture.ShopNotificationMessageFixture; +import in.koreatech.koin.fixture.ShopParentCategoryFixture; import in.koreatech.koin.fixture.ShopReviewFixture; import in.koreatech.koin.fixture.ShopReviewReportFixture; import in.koreatech.koin.fixture.UserFixture; @@ -50,6 +56,15 @@ class AdminShopReviewApiTest extends AcceptanceTest { @Autowired private ShopFixture shopFixture; + @Autowired + private ShopNotificationMessageFixture shopNotificationMessageFixture; + + @Autowired + private ShopParentCategoryFixture shopParentCategoryFixture; + + @Autowired + private ShopCategoryFixture shopCategoryFixture; + @Autowired private AdminShopReviewRepository adminShopReviewRepository; @@ -59,6 +74,9 @@ class AdminShopReviewApiTest extends AcceptanceTest { private ShopReview 준호_리뷰; private Shop shop_마슬랜; private String token_admin; + private ShopCategory shopCategory_치킨; + private ShopParentCategory shopParentCategory_가게; + private ShopNotificationMessage notificationMessage_가게; @BeforeAll void setUp() { @@ -67,8 +85,11 @@ void setUp() { student_익명 = userFixture.익명_학생(); token_admin = userFixture.getToken(admin.getUser()); owner_현수 = userFixture.현수_사장님(); - shop_마슬랜 = shopFixture.마슬랜(owner_현수); + shop_마슬랜 = shopFixture.마슬랜(owner_현수, shopCategory_치킨); 준호_리뷰 = shopReviewFixture.리뷰_4점(student_익명, shop_마슬랜); + notificationMessage_가게 = shopNotificationMessageFixture.알림메시지_가게(); + shopParentCategory_가게 = shopParentCategoryFixture.상위_카테고리_가게(notificationMessage_가게); + shopCategory_치킨 = shopCategoryFixture.카테고리_치킨(shopParentCategory_가게); } @Test diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java index f28b46827..0ff65e5f1 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java @@ -316,7 +316,7 @@ void setup() { @Test void 관리자가_사장님_권한_요청을_허용한다() throws Exception { Owner owner = userFixture.철수_사장님(); - Shop shop = shopFixture.마슬랜(null); + Shop shop = shopFixture.마슬랜(null, null); Admin adminUser = userFixture.코인_운영자(); String token = userFixture.getToken(adminUser.getUser()); @@ -447,7 +447,7 @@ void setup() { @Test void 관리자가_특정_사장을_조회한다() throws Exception { Owner owner = userFixture.현수_사장님(); - Shop shop = shopFixture.마슬랜(owner); + Shop shop = shopFixture.마슬랜(owner, null); Admin adminUser = userFixture.코인_운영자(); String token = userFixture.getToken(adminUser.getUser()); @@ -485,7 +485,7 @@ void setup() { @Test void 관리자가_특정_사장을_수정한다() throws Exception { Owner owner = userFixture.현수_사장님(); - Shop shop = shopFixture.마슬랜(owner); + Shop shop = shopFixture.마슬랜(owner, null); Admin adminUser = userFixture.코인_운영자(); String token = userFixture.getToken(adminUser.getUser()); @@ -520,7 +520,7 @@ void setup() { @Test void 관리자가_가입_신청한_사장님_리스트_조회한다() throws Exception { Owner owner = userFixture.철수_사장님(); - Shop shop = shopFixture.마슬랜(null); + Shop shop = shopFixture.마슬랜(null, null); Admin adminUser = userFixture.코인_운영자(); String token = userFixture.getToken(adminUser.getUser()); diff --git a/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java b/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java index 5b4f02cea..313cef3d4 100644 --- a/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java @@ -22,6 +22,7 @@ public ShopCategoryFixture(ShopCategoryRepository categoryRepository) { .name("치킨") .imageUrl("https://test-image.com/ckicken.jpg") .parentCategory(parentCategory) + .eventBannerImageUrl("https://test-image.com/chicken-event.jpg") .build() ); } @@ -32,6 +33,7 @@ public ShopCategoryFixture(ShopCategoryRepository categoryRepository) { .name("일반음식점") .imageUrl("https://test-image.com/normal.jpg") .parentCategory(parentCategory) + .eventBannerImageUrl("https://test-image.com/normal-event.jpg") .build() ); } diff --git a/src/test/java/in/koreatech/koin/fixture/ShopFixture.java b/src/test/java/in/koreatech/koin/fixture/ShopFixture.java index 2cf968805..049f3d86a 100644 --- a/src/test/java/in/koreatech/koin/fixture/ShopFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/ShopFixture.java @@ -9,6 +9,7 @@ import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.shop.model.menu.MenuCategory; import in.koreatech.koin.domain.shop.model.shop.Shop; +import in.koreatech.koin.domain.shop.model.shop.ShopCategory; import in.koreatech.koin.domain.shop.model.shop.ShopCategoryMap; import in.koreatech.koin.domain.shop.model.shop.ShopImage; import in.koreatech.koin.domain.shop.model.shop.ShopOpen; @@ -24,10 +25,11 @@ public ShopFixture(ShopRepository shopRepository) { this.shopRepository = shopRepository; } - public Shop 김밥천국(Owner owner) { + public Shop 김밥천국(Owner owner, ShopCategory shopMainCategory) { var shop = shopRepository.save( Shop.builder() .owner(owner) + .shopMainCategory(shopMainCategory) .name("김밥천국") .internalName("김천") .chosung("김") @@ -79,10 +81,11 @@ public ShopFixture(ShopRepository shopRepository) { return shopRepository.save(shop); } - public Shop 마슬랜(Owner owner) { + public Shop 마슬랜(Owner owner, ShopCategory shopMainCategory) { var shop = shopRepository.save( Shop.builder() .owner(owner) + .shopMainCategory(shopMainCategory) .name("마슬랜 치킨") .internalName("마슬랜") .chosung("마") From d47373b77f91cd078184052dda941e4183c53b9a Mon Sep 17 00:00:00 2001 From: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Date: Thu, 14 Nov 2024 15:08:44 +0900 Subject: [PATCH 07/14] =?UTF-8?q?fix:=20shopCategoryMap=20ID=EC=97=90?= =?UTF-8?q?=EC=84=9C=20shopCategory=EC=9D=98=20ID=EB=A1=9C=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20(#1?= =?UTF-8?q?019)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: HyeonsuLee --- .../koreatech/koin/domain/shop/cache/dto/ShopCategoryCache.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopCategoryCache.java b/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopCategoryCache.java index bf06b9d5d..f26b2b215 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopCategoryCache.java +++ b/src/main/java/in/koreatech/koin/domain/shop/cache/dto/ShopCategoryCache.java @@ -10,7 +10,7 @@ public record ShopCategoryCache( public static ShopCategoryCache from(ShopCategoryMap shopCategoryMap) { return new ShopCategoryCache( - shopCategoryMap.getId(), + shopCategoryMap.getShopCategory().getId(), shopCategoryMap.getShopCategory().getName(), shopCategoryMap.getShopCategory().getImageUrl() ); From 2a54adb280194f46e04433b5883572bbe5d5ff12 Mon Sep 17 00:00:00 2001 From: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Date: Thu, 14 Nov 2024 17:17:04 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat:=20=EC=A3=BC=EB=B3=80=EC=83=81?= =?UTF-8?q?=EC=A0=90=20=EB=82=B4=EB=A6=BC/=EC=98=A4=EB=A6=84=EC=B0=A8?= =?UTF-8?q?=EC=88=9C=20=EC=A0=95=EB=A0=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#1023)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 오름차순, 내림차순 정렬 기준 추가 * feat: 오름차순 변수명 수정 * test: 주변 상점 정렬 테스트 추가 --------- Co-authored-by: HyeonsuLee --- .../shop/dto/shop/ShopsSortCriteria.java | 4 + .../koin/acceptance/ShopApiTest.java | 1593 ++++++++++------- 2 files changed, 903 insertions(+), 694 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopsSortCriteria.java b/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopsSortCriteria.java index 09c84ee98..0d0696185 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopsSortCriteria.java +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/shop/ShopsSortCriteria.java @@ -8,7 +8,11 @@ public enum ShopsSortCriteria { NONE("NONE", (shop1, shop2) -> 0), COUNT("COUNT", Comparator.comparingLong(InnerShopResponse::reviewCount).reversed()), + COUNT_ASC("COUNT_ASCD", Comparator.comparingLong(InnerShopResponse::reviewCount)), + COUNT_DESC("COUNT_DESC", Comparator.comparingLong(InnerShopResponse::reviewCount).reversed()), RATING("RATING", Comparator.comparingDouble(InnerShopResponse::averageRate).reversed()), + RATING_ASC("RATING_ASCD", Comparator.comparingDouble(InnerShopResponse::averageRate)), + RATING_DESC("RATING_DESC", Comparator.comparingDouble(InnerShopResponse::averageRate).reversed()), ; private final String value; diff --git a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java index d418d596c..6ec56e8b6 100644 --- a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java @@ -97,29 +97,29 @@ void setUp() { void 옵션이_하나_있는_상점의_메뉴를_조회한다() throws Exception { Menu menu = menuFixture.짜장면_단일메뉴(마슬랜, menuCategoryFixture.메인메뉴(마슬랜)); mockMvc.perform( - get("/shops/{shopId}/menus/{menuId}", menu.getShop().getId(), menu.getId()) - ) - .andExpect(status().isOk()) - .andExpect(content().json(""" - { - "id": 1, - "shop_id": 1, - "name": "짜장면", - "is_hidden": false, - "is_single": true, - "single_price": 7000, - "option_prices": null, - "description": "맛있는 짜장면", - "category_ids": [ - 1 - ], - "image_urls": [ - "https://test.com/짜장면.jpg", - "https://test.com/짜장면22.jpg" - ] - } - """) - ); + get("/shops/{shopId}/menus/{menuId}", menu.getShop().getId(), menu.getId()) + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" + { + "id": 1, + "shop_id": 1, + "name": "짜장면", + "is_hidden": false, + "is_single": true, + "single_price": 7000, + "option_prices": null, + "description": "맛있는 짜장면", + "category_ids": [ + 1 + ], + "image_urls": [ + "https://test.com/짜장면.jpg", + "https://test.com/짜장면22.jpg" + ] + } + """) + ); } @Test @@ -127,37 +127,37 @@ void setUp() { Menu menu = menuFixture.짜장면_옵션메뉴(마슬랜, menuCategoryFixture.메인메뉴(마슬랜)); mockMvc.perform( - get("/shops/{shopId}/menus/{menuId}", menu.getShop().getId(), menu.getId()) - ) - .andExpect(status().isOk()) - .andExpect(content().json(""" - { - "id": 1, - "shop_id": 1, - "name": "짜장면", - "is_hidden": false, - "is_single": false, - "single_price": null, - "option_prices": [ - { - "option": "곱빼기", - "price": 7500 - }, + get("/shops/{shopId}/menus/{menuId}", menu.getShop().getId(), menu.getId()) + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" { - "option": "일반", - "price": 7000 + "id": 1, + "shop_id": 1, + "name": "짜장면", + "is_hidden": false, + "is_single": false, + "single_price": null, + "option_prices": [ + { + "option": "곱빼기", + "price": 7500 + }, + { + "option": "일반", + "price": 7000 + } + ], + "description": "맛있는 짜장면", + "category_ids": [ + 1 + ], + "image_urls": [ + "https://test.com/짜장면.jpg", + "https://test.com/짜장면22.jpg" + ] } - ], - "description": "맛있는 짜장면", - "category_ids": [ - 1 - ], - "image_urls": [ - "https://test.com/짜장면.jpg", - "https://test.com/짜장면22.jpg" - ] - } - """)); + """)); } @Test @@ -167,28 +167,28 @@ void setUp() { Menu menu = menuFixture.짜장면_단일메뉴(마슬랜, menuCategoryFixture.추천메뉴(마슬랜)); mockMvc.perform( - get("/shops/{shopId}/menus/categories", menu.getShop().getId()) - ) - .andExpect(status().isOk()) - .andExpect(content().json(""" - { - "count": 3, - "menu_categories": [ - { - "id": 3, - "name": "추천 메뉴" - }, - { - "id": 2, - "name": "세트 메뉴" - }, - { - "id": 1, - "name": "사이드 메뉴" + get("/shops/{shopId}/menus/categories", menu.getShop().getId()) + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" + { + "count": 3, + "menu_categories": [ + { + "id": 3, + "name": "추천 메뉴" + }, + { + "id": 2, + "name": "세트 메뉴" + }, + { + "id": 1, + "name": "사이드 메뉴" + } + ] } - ] - } - """)); + """)); } @Test @@ -197,57 +197,57 @@ void setUp() { menuCategoryFixture.세트메뉴(마슬랜); mockMvc.perform( - get("/shops/{shopId}", 마슬랜.getId()) - ) - .andExpect(status().isOk()) - .andExpect(content().json(""" - { - "address": "천안시 동남구 병천면 1600", - "delivery": true, - "delivery_price": 3000, - "description": "마슬랜 치킨입니다.", - "id": 1, - "image_urls": [ - "https://test-image.com/마슬랜.png", - "https://test-image.com/마슬랜2.png" - ], - "menu_categories": [ - { - "id": 2, - "name": "세트 메뉴" - }, - { + get("/shops/{shopId}", 마슬랜.getId()) + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" + { + "address": "천안시 동남구 병천면 1600", + "delivery": true, + "delivery_price": 3000, + "description": "마슬랜 치킨입니다.", "id": 1, - "name": "사이드 메뉴" + "image_urls": [ + "https://test-image.com/마슬랜.png", + "https://test-image.com/마슬랜2.png" + ], + "menu_categories": [ + { + "id": 2, + "name": "세트 메뉴" + }, + { + "id": 1, + "name": "사이드 메뉴" + } + ], + "name": "마슬랜 치킨", + "open": [ + { + "day_of_week": "MONDAY", + "closed": false, + "open_time": "00:00", + "close_time": "21:00" + }, + { + "day_of_week": "FRIDAY", + "closed": false, + "open_time": "00:00", + "close_time": "00:00" + } + ], + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "shop_categories": [ + \s + ], + "updated_at": "2024-01-15", + "is_event": false, + "bank": "국민", + "account_number": "01022595923" } - ], - "name": "마슬랜 치킨", - "open": [ - { - "day_of_week": "MONDAY", - "closed": false, - "open_time": "00:00", - "close_time": "21:00" - }, - { - "day_of_week": "FRIDAY", - "closed": false, - "open_time": "00:00", - "close_time": "00:00" - } - ], - "pay_bank": true, - "pay_card": true, - "phone": "010-7574-1212", - "shop_categories": [ - \s - ], - "updated_at": "2024-01-15", - "is_event": false, - "bank": "국민", - "account_number": "01022595923" - } - """)); + """)); } @Test @@ -255,64 +255,64 @@ void setUp() { menuFixture.짜장면_단일메뉴(마슬랜, menuCategoryFixture.추천메뉴(마슬랜)); menuFixture.짜장면_옵션메뉴(마슬랜, menuCategoryFixture.세트메뉴(마슬랜)); mockMvc.perform( - get("/shops/{id}/menus", 마슬랜.getId()) - ) - .andExpect(status().isOk()) - .andExpect(content().json(""" - { - "count": 2, - "menu_categories": [ - { - "id": 1, - "name": "추천 메뉴", - "menus": [ + get("/shops/{id}/menus", 마슬랜.getId()) + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" + { + "count": 2, + "menu_categories": [ { "id": 1, - "name": "짜장면", - "is_hidden": false, - "is_single": true, - "single_price": 7000, - "option_prices": null, - "description": "맛있는 짜장면", - "image_urls": [ - "https://test.com/짜장면.jpg", - "https://test.com/짜장면22.jpg" + "name": "추천 메뉴", + "menus": [ + { + "id": 1, + "name": "짜장면", + "is_hidden": false, + "is_single": true, + "single_price": 7000, + "option_prices": null, + "description": "맛있는 짜장면", + "image_urls": [ + "https://test.com/짜장면.jpg", + "https://test.com/짜장면22.jpg" + ] + } ] - } - ] - }, - { - "id": 2, - "name": "세트 메뉴", - "menus": [ + }, { "id": 2, - "name": "짜장면", - "is_hidden": false, - "is_single": false, - "single_price": null, - "option_prices": [ + "name": "세트 메뉴", + "menus": [ { - "option": "곱빼기", - "price": 7500 - }, - { - "option": "일반", - "price": 7000 + "id": 2, + "name": "짜장면", + "is_hidden": false, + "is_single": false, + "single_price": null, + "option_prices": [ + { + "option": "곱빼기", + "price": 7500 + }, + { + "option": "일반", + "price": 7000 + } + ], + "description": "맛있는 짜장면", + "image_urls": [ + "https://test.com/짜장면.jpg", + "https://test.com/짜장면22.jpg" + ] } - ], - "description": "맛있는 짜장면", - "image_urls": [ - "https://test.com/짜장면.jpg", - "https://test.com/짜장면22.jpg" ] } - ] + ], + "updated_at": "2024-01-15" } - ], - "updated_at": "2024-01-15" - } - """)); + """)); } @Test @@ -324,99 +324,99 @@ void setUp() { boolean 신전_떡볶이_영업여부 = false; mockMvc.perform( - get("/shops") - ) - .andExpect(status().isOk()) - .andExpect(content().json(String.format(""" - { - "count": 2, - "shops": [ - { - "category_ids": [ - \s - ], - "delivery": true, - "id": 1, - "name": "마슬랜 치킨", - "open": [ - { - "day_of_week": "MONDAY", - "closed": false, - "open_time": "00:00", - "close_time": "21:00" - }, - { - "day_of_week": "FRIDAY", - "closed": false, - "open_time": "00:00", - "close_time": "00:00" - } - ], - "pay_bank": true, - "pay_card": true, - "phone": "010-7574-1212", - "is_event": false, - "is_open": %s - },{ - "category_ids": [ - \s - ], - "delivery": true, - "id": 2, - "name": "신전 떡볶이", - "open": [ - { - "day_of_week": "MONDAY", - "closed": false, - "open_time": "12:30", - "close_time": "21:30" - }, - { - "day_of_week": "TUESDAY", - "closed": false, - "open_time": "11:30", - "close_time": "21:30" - }, - { - "day_of_week": "WEDNESDAY", - "closed": false, - "open_time": "11:30", - "close_time": "21:30" - }, - { - "day_of_week": "THURSDAY", - "closed": false, - "open_time": "11:30", - "close_time": "21:30" - }, - { - "day_of_week": "FRIDAY", - "closed": false, - "open_time": "11:30", - "close_time": "21:30" - }, - { - "day_of_week": "SATURDAY", - "closed": false, - "open_time": "11:30", - "close_time": "21:30" - }, - { - "day_of_week": "SUNDAY", - "closed": false, - "open_time": "00:00", - "close_time": "00:00" + get("/shops") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" + { + "count": 2, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": true, + "id": 1, + "name": "마슬랜 치킨", + "open": [ + { + "day_of_week": "MONDAY", + "closed": false, + "open_time": "00:00", + "close_time": "21:00" + }, + { + "day_of_week": "FRIDAY", + "closed": false, + "open_time": "00:00", + "close_time": "00:00" + } + ], + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "is_event": false, + "is_open": %s + },{ + "category_ids": [ + \s + ], + "delivery": true, + "id": 2, + "name": "신전 떡볶이", + "open": [ + { + "day_of_week": "MONDAY", + "closed": false, + "open_time": "12:30", + "close_time": "21:30" + }, + { + "day_of_week": "TUESDAY", + "closed": false, + "open_time": "11:30", + "close_time": "21:30" + }, + { + "day_of_week": "WEDNESDAY", + "closed": false, + "open_time": "11:30", + "close_time": "21:30" + }, + { + "day_of_week": "THURSDAY", + "closed": false, + "open_time": "11:30", + "close_time": "21:30" + }, + { + "day_of_week": "FRIDAY", + "closed": false, + "open_time": "11:30", + "close_time": "21:30" + }, + { + "day_of_week": "SATURDAY", + "closed": false, + "open_time": "11:30", + "close_time": "21:30" + }, + { + "day_of_week": "SUNDAY", + "closed": false, + "open_time": "00:00", + "close_time": "00:00" + } + ], + "pay_bank": true, + "pay_card": true, + "phone": "010-7788-9900", + "is_event": false, + "is_open": %s } - ], - "pay_bank": true, - "pay_card": true, - "phone": "010-7788-9900", - "is_event": false, - "is_open": %s + ] } - ] - } - """, 마슬랜_영업여부, 신전_떡볶이_영업여부))); + """, 마슬랜_영업여부, 신전_떡볶이_영업여부))); } @Test @@ -424,27 +424,27 @@ void setUp() { shopCategoryFixture.카테고리_일반음식(shopParentCategory_가게); mockMvc.perform( - get("/shops/categories") - ) - .andExpect(status().isOk()) - .andExpect(content().json(""" - { - "total_count": 2, - "shop_categories": [ - { - "id": 1, - "image_url": "https://test-image.com/ckicken.jpg", - "name": "치킨" - }, - { - "id": 2, - "image_url": "https://test-image.com/normal.jpg", - "name": "일반음식점" - }, - - ] - } - """)); + get("/shops/categories") + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" + { + "total_count": 2, + "shop_categories": [ + { + "id": 1, + "image_url": "https://test-image.com/ckicken.jpg", + "name": "치킨" + }, + { + "id": 2, + "image_url": "https://test-image.com/normal.jpg", + "name": "일반음식점" + }, + + ] + } + """)); } @Test @@ -453,41 +453,41 @@ void setUp() { eventArticleFixture.참여_이벤트(마슬랜, LocalDate.now(clock).minusDays(3), LocalDate.now(clock).plusDays(3)); mockMvc.perform( - get("/shops/{shopId}/events", 마슬랜.getId()) - ) - .andExpect(status().isOk()) - .andExpect(content().json(""" - { - "events": [ - { - "shop_id": 1, - "shop_name": "마슬랜 치킨", - "event_id": 1, - "title": "할인 이벤트", - "content": "사장님이 미쳤어요!", - "thumbnail_images": [ - "https://eventimage.com/할인_이벤트.jpg", - "https://eventimage.com/할인_이벤트.jpg" - ], - "start_date": "2024-01-12", - "end_date": "2024-01-18" - }, + get("/shops/{shopId}/events", 마슬랜.getId()) + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" { - "shop_id": 1, - "shop_name": "마슬랜 치킨", - "event_id": 2, - "title": "참여 이벤트", - "content": "사장님과 참여해요!!!", - "thumbnail_images": [ - "https://eventimage.com/참여_이벤트.jpg", - "https://eventimage.com/참여_이벤트.jpg" - ], - "start_date": "2024-01-12", - "end_date": "2024-01-18" + "events": [ + { + "shop_id": 1, + "shop_name": "마슬랜 치킨", + "event_id": 1, + "title": "할인 이벤트", + "content": "사장님이 미쳤어요!", + "thumbnail_images": [ + "https://eventimage.com/할인_이벤트.jpg", + "https://eventimage.com/할인_이벤트.jpg" + ], + "start_date": "2024-01-12", + "end_date": "2024-01-18" + }, + { + "shop_id": 1, + "shop_name": "마슬랜 치킨", + "event_id": 2, + "title": "참여 이벤트", + "content": "사장님과 참여해요!!!", + "thumbnail_images": [ + "https://eventimage.com/참여_이벤트.jpg", + "https://eventimage.com/참여_이벤트.jpg" + ], + "start_date": "2024-01-12", + "end_date": "2024-01-18" + } + ] } - ] - } - """)); + """)); } @Test @@ -496,14 +496,14 @@ void setUp() { eventArticleFixture.참여_이벤트(마슬랜, LocalDate.now(clock).minusDays(3), LocalDate.now(clock).plusDays(3)); mockMvc.perform( - get("/shops/{shopId}", 마슬랜.getId()) - ) - .andExpect(status().isOk()) - .andExpect(content().json(""" - { - "is_event": true - } - """)); + get("/shops/{shopId}", 마슬랜.getId()) + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" + { + "is_event": true + } + """)); } @Test @@ -512,43 +512,43 @@ void setUp() { eventArticleFixture.참여_이벤트(마슬랜, LocalDate.now(clock).minusDays(5), LocalDate.now(clock).minusDays(3)); mockMvc.perform( - get("/shops/{shopId}", 마슬랜.getId()) - ) - .andExpect(status().isOk()) - .andExpect(content().json(""" - { - "is_event": false - } - """)); + get("/shops/{shopId}", 마슬랜.getId()) + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" + { + "is_event": false + } + """)); } @Test void 이벤트_베너_조회() throws Exception { eventArticleFixture.참여_이벤트(마슬랜, LocalDate.now(clock), LocalDate.now(clock).plusDays(10)); eventArticleFixture.할인_이벤트(마슬랜, LocalDate.now(clock).minusDays(10), LocalDate.now(clock).minusDays(1)); - + mockMvc.perform( - get("/shops/events") - ) - .andExpect(status().isOk()) - .andExpect(content().json(""" - { - "events": [ + get("/shops/events") + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" { - "shop_id": 1, - "shop_name": "마슬랜 치킨", - "event_id": 1, - "title": "참여 이벤트", - "content": "사장님과 참여해요!!!", - "thumbnail_images": [ - "https://test-image.com/chicken-event.jpg" - ], - "start_date": "2024-01-15", - "end_date": "2024-01-25" + "events": [ + { + "shop_id": 1, + "shop_name": "마슬랜 치킨", + "event_id": 1, + "title": "참여 이벤트", + "content": "사장님과 참여해요!!!", + "thumbnail_images": [ + "https://test-image.com/chicken-event.jpg" + ], + "start_date": "2024-01-15", + "end_date": "2024-01-25" + } + ] } - ] - } - """)); + """)); } @Test @@ -558,46 +558,46 @@ void setUp() { boolean 마슬랜_영업여부 = true; boolean 티바_영업여부 = true; mockMvc.perform( - get("/v2/shops") - .queryParam("sorter", "RATING") - ) - .andExpect(status().isOk()) - .andExpect(content().json(String.format(""" - { - "count": 2, - "shops": [ + get("/v2/shops") + .queryParam("sorter", "RATING") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" { - "category_ids": [ - \s - ], - "delivery": true, - "id": 2, - "name": "티바", - "pay_bank": true, - "pay_card": true, - "phone": "010-7788-9900", - "is_event": false, - "is_open": %s, - "average_rate": 4.0, - "review_count": 1 - },{ - "category_ids": [ - \s - ], - "delivery": true, - "id": 1, - "name": "마슬랜 치킨", - "pay_bank": true, - "pay_card": true, - "phone": "010-7574-1212", - "is_event": false, - "is_open": %s, - "average_rate": 0.0, - "review_count": 0 + "count": 2, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": true, + "id": 2, + "name": "티바", + "pay_bank": true, + "pay_card": true, + "phone": "010-7788-9900", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + },{ + "category_ids": [ + \s + ], + "delivery": true, + "id": 1, + "name": "마슬랜 치킨", + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "is_event": false, + "is_open": %s, + "average_rate": 0.0, + "review_count": 0 + } + ] } - ] - } - """, 티바_영업여부, 마슬랜_영업여부))); + """, 티바_영업여부, 마슬랜_영업여부))); } @Test @@ -611,46 +611,46 @@ void setUp() { boolean 마슬랜_영업여부 = true; boolean 티바_영업여부 = true; mockMvc.perform( - get("/v2/shops") - .queryParam("sorter", "COUNT") - ) - .andExpect(status().isOk()) - .andExpect(content().json(String.format(""" - { - "count": 2, - "shops": [ + get("/v2/shops") + .queryParam("sorter", "COUNT") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" { - "category_ids": [ - \s - ], - "delivery": true, - "id": 1, - "name": "마슬랜 치킨", - "pay_bank": true, - "pay_card": true, - "phone": "010-7574-1212", - "is_event": false, - "is_open": %s, - "average_rate": 4.0, - "review_count": 2 - },{ - "category_ids": [ - \s - ], - "delivery": true, - "id": 2, - "name": "티바", - "pay_bank": true, - "pay_card": true, - "phone": "010-7788-9900", - "is_event": false, - "is_open": %s, - "average_rate": 4.0, - "review_count": 1 + "count": 2, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": true, + "id": 1, + "name": "마슬랜 치킨", + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 2 + },{ + "category_ids": [ + \s + ], + "delivery": true, + "id": 2, + "name": "티바", + "pay_bank": true, + "pay_card": true, + "phone": "010-7788-9900", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + } + ] } - ] - } - """, 티바_영업여부, 마슬랜_영업여부))); + """, 티바_영업여부, 마슬랜_영업여부))); } @Test @@ -664,46 +664,46 @@ void setUp() { boolean 신전떡볶이_영업여부 = false; boolean 마슬랜_영업여부 = true; mockMvc.perform( - get("/v2/shops") - .queryParam("sorter", "COUNT") - ) - .andExpect(status().isOk()) - .andExpect(content().json(String.format(""" - { - "count": 2, - "shops": [ + get("/v2/shops") + .queryParam("sorter", "COUNT") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" { - "category_ids": [ - \s - ], - "delivery": true, - "id": 1, - "name": "마슬랜 치킨", - "pay_bank": true, - "pay_card": true, - "phone": "010-7574-1212", - "is_event": false, - "is_open": %s, - "average_rate": 4.0, - "review_count": 1 - },{ - "category_ids": [ - \s - ], - "delivery": true, - "id": 2, - "name": "신전 떡볶이", - "pay_bank": true, - "pay_card": true, - "phone": "010-7788-9900", - "is_event": false, - "is_open": %s, - "average_rate": 4.0, - "review_count": 2 + "count": 2, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": true, + "id": 1, + "name": "마슬랜 치킨", + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + },{ + "category_ids": [ + \s + ], + "delivery": true, + "id": 2, + "name": "신전 떡볶이", + "pay_bank": true, + "pay_card": true, + "phone": "010-7788-9900", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 2 + } + ] } - ] - } - """, 마슬랜_영업여부, 신전떡볶이_영업여부))); + """, 마슬랜_영업여부, 신전떡볶이_영업여부))); } @Test @@ -718,32 +718,32 @@ void setUp() { boolean 마슬랜_영업여부 = true; mockMvc.perform( - get("/v2/shops") - .queryParam("filter", "OPEN") - ) - .andExpect(status().isOk()) - .andExpect(content().json(String.format(""" - { - "count": 1, - "shops": [ + get("/v2/shops") + .queryParam("filter", "OPEN") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" { - "category_ids": [ - \s - ], - "delivery": true, - "id": 1, - "name": "마슬랜 치킨", - "pay_bank": true, - "pay_card": true, - "phone": "010-7574-1212", - "is_event": false, - "is_open": %s, - "average_rate": 4.0, - "review_count": 1 + "count": 1, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": true, + "id": 1, + "name": "마슬랜 치킨", + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + } + ] } - ] - } - """, 마슬랜_영업여부))); + """, 마슬랜_영업여부))); } @Test @@ -755,32 +755,32 @@ void setUp() { // 2024-01-15 12:00 월요일 기준 boolean 마슬랜_영업여부 = true; mockMvc.perform( - get("/v2/shops") - .queryParam("filter", "DELIVERY") - ) - .andExpect(status().isOk()) - .andExpect(content().json(String.format(""" - { - "count": 1, - "shops": [ + get("/v2/shops") + .queryParam("filter", "DELIVERY") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" { - "category_ids": [ - \s - ], - "delivery": true, - "id": 1, - "name": "마슬랜 치킨", - "pay_bank": true, - "pay_card": true, - "phone": "010-7574-1212", - "is_event": false, - "is_open": %s, - "average_rate": 4.0, - "review_count": 1 - } - ] - } - """, 마슬랜_영업여부))); + "count": 1, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": true, + "id": 1, + "name": "마슬랜 치킨", + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + } + ] + } + """, 마슬랜_영업여부))); } @Test @@ -795,33 +795,33 @@ void setUp() { // 2024-01-15 12:00 월요일 기준 boolean 마슬랜_영업여부 = true; mockMvc.perform( - get("/v2/shops") - .queryParam("filter", "DELIVERY") - .queryParam("filter", "OPEN") - ) - .andExpect(status().isOk()) - .andExpect(content().json(String.format(""" - { - "count": 1, - "shops": [ + get("/v2/shops") + .queryParam("filter", "DELIVERY") + .queryParam("filter", "OPEN") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" { - "category_ids": [ - \s - ], - "delivery": true, - "id": 1, - "name": "마슬랜 치킨", - "pay_bank": true, - "pay_card": true, - "phone": "010-7574-1212", - "is_event": false, - "is_open": %s, - "average_rate": 4.0, - "review_count": 1 + "count": 1, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": true, + "id": 1, + "name": "마슬랜 치킨", + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + } + ] } - ] - } - """, 마슬랜_영업여부))); + """, 마슬랜_영업여부))); } @Test @@ -837,47 +837,47 @@ void setUp() { boolean 신전_떡볶이_영업여부 = true; boolean 마슬랜_영업여부 = true; mockMvc.perform( - get("/v2/shops") - .queryParam("filter", "OPEN") - .queryParam("sorter", "COUNT") - ) - .andExpect(status().isOk()) - .andExpect(content().json(String.format(""" - { - "count": 2, - "shops": [ + get("/v2/shops") + .queryParam("filter", "OPEN") + .queryParam("sorter", "COUNT") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" { - "category_ids": [ - \s - ], - "delivery": false, - "id": 2, - "name": "신전 떡볶이", - "pay_bank": true, - "pay_card": true, - "phone": "010-7788-9900", - "is_event": false, - "is_open": %s, - "average_rate": 4.0, - "review_count": 2 - },{ - "category_ids": [ - \s - ], - "delivery": true, - "id": 1, - "name": "마슬랜 치킨", - "pay_bank": true, - "pay_card": true, - "phone": "010-7574-1212", - "is_event": false, - "is_open": %s, - "average_rate": 4.0, - "review_count": 1 + "count": 2, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": false, + "id": 2, + "name": "신전 떡볶이", + "pay_bank": true, + "pay_card": true, + "phone": "010-7788-9900", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 2 + },{ + "category_ids": [ + \s + ], + "delivery": true, + "id": 1, + "name": "마슬랜 치킨", + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + } + ] } - ] - } - """, 신전_떡볶이_영업여부, 마슬랜_영업여부))); + """, 신전_떡볶이_영업여부, 마슬랜_영업여부))); } @Test @@ -892,45 +892,45 @@ void setUp() { boolean 마슬랜_영업여부 = true; mockMvc.perform( - get("/v2/shops") - ) - .andExpect(status().isOk()) - .andExpect(content().json(String.format(""" - { - "count": 2, - "shops": [ + get("/v2/shops") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" { - "category_ids": [ - \s - ], - "delivery": true, - "id": 1, - "name": "마슬랜 치킨", - "pay_bank": true, - "pay_card": true, - "phone": "010-7574-1212", - "is_event": false, - "is_open": %s, - "average_rate": 4.0, - "review_count": 1 - },{ - "category_ids": [ - \s - ], - "delivery": false, - "id": 2, - "name": "신전 떡볶이", - "pay_bank": true, - "pay_card": true, - "phone": "010-7788-9900", - "is_event": false, - "is_open": %s, - "average_rate": 4.0, - "review_count": 1 + "count": 2, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": true, + "id": 1, + "name": "마슬랜 치킨", + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + },{ + "category_ids": [ + \s + ], + "delivery": false, + "id": 2, + "name": "신전 떡볶이", + "pay_bank": true, + "pay_card": true, + "phone": "010-7788-9900", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + } + ] } - ] - } - """, 마슬랜_영업여부, 신전_떡볶이_영업여부))); + """, 마슬랜_영업여부, 신전_떡볶이_영업여부))); } @Test @@ -944,45 +944,45 @@ void setUp() { boolean 신전_떡볶이_영업여부 = true; boolean 마슬랜_영업여부 = true; mockMvc.perform( - get("/v2/shops") - ) - .andExpect(status().isOk()) - .andExpect(content().json(String.format(""" - { - "count": 2, - "shops": [ + get("/v2/shops") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" { - "category_ids": [ - \s - ], - "delivery": true, - "id": 1, - "name": "마슬랜 치킨", - "pay_bank": true, - "pay_card": true, - "phone": "010-7574-1212", - "is_event": false, - "is_open": %s, - "average_rate": 4.0, - "review_count": 1 - },{ - "category_ids": [ - \s - ], - "delivery": false, - "id": 2, - "name": "신전 떡볶이", - "pay_bank": true, - "pay_card": true, - "phone": "010-7788-9900", - "is_event": false, - "is_open": %s, - "average_rate": 4.0, - "review_count": 1 + "count": 2, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": true, + "id": 1, + "name": "마슬랜 치킨", + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + },{ + "category_ids": [ + \s + ], + "delivery": false, + "id": 2, + "name": "신전 떡볶이", + "pay_bank": true, + "pay_card": true, + "phone": "010-7788-9900", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + } + ] } - ] - } - """, 마슬랜_영업여부, 신전_떡볶이_영업여부))); + """, 마슬랜_영업여부, 신전_떡볶이_영업여부))); } @Test @@ -996,46 +996,46 @@ void setUp() { boolean 마슬랜_영업여부 = true; boolean 티바_영업여부 = true; mockMvc.perform( - get("/v2/shops") - .queryParam("sorter", "COUNT") - ) - .andExpect(status().isOk()) - .andExpect(content().json(String.format(""" - { - "count": 2, - "shops": [ + get("/v2/shops") + .queryParam("sorter", "COUNT") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" { - "category_ids": [ - \s - ], - "delivery": true, - "id": 1, - "name": "마슬랜 치킨", - "pay_bank": true, - "pay_card": true, - "phone": "010-7574-1212", - "is_event": false, - "is_open": %s, - "average_rate": 4.0, - "review_count": 2 - },{ - "category_ids": [ - \s - ], - "delivery": true, - "id": 2, - "name": "티바", - "pay_bank": true, - "pay_card": true, - "phone": "010-7788-9900", - "is_event": false, - "is_open": %s, - "average_rate": 4.0, - "review_count": 1 + "count": 2, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": true, + "id": 1, + "name": "마슬랜 치킨", + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 2 + },{ + "category_ids": [ + \s + ], + "delivery": true, + "id": 2, + "name": "티바", + "pay_bank": true, + "pay_card": true, + "phone": "010-7788-9900", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + } + ] } - ] - } - """, 티바_영업여부, 마슬랜_영업여부))); + """, 티바_영업여부, 마슬랜_영업여부))); } @Test @@ -1077,12 +1077,217 @@ void setUp() { """, 마슬랜_영업여부, 신전_떡볶이_영업여부))); } + @Test + void 리뷰_평점기준_오름차순_정렬하여_모든_상점을_조회한다() throws Exception { + Shop 영업중인_티바 = shopFixture.영업중인_티바(owner); + shopReviewFixture.리뷰_4점(익명_학생, 영업중인_티바); + boolean 마슬랜_영업여부 = true; + boolean 티바_영업여부 = true; + mockMvc.perform( + get("/v2/shops") + .queryParam("sorter", "RATING_ASC") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" + { + "count": 2, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": true, + "id": 1, + "name": "마슬랜 치킨", + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "is_event": false, + "is_open": %s, + "average_rate": 0.0, + "review_count": 0 + }, + { + "category_ids": [ + \s + ], + "delivery": true, + "id": 2, + "name": "티바", + "pay_bank": true, + "pay_card": true, + "phone": "010-7788-9900", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + } + ] + } + """, 티바_영업여부, 마슬랜_영업여부))); + } + + @Test + void 리뷰_개수기준_오름차순_정렬하여_모든_상점을_조회한다() throws Exception { + Shop 영업중인_티바 = shopFixture.영업중인_티바(owner); + shopReviewFixture.리뷰_4점(익명_학생, 영업중인_티바); + + shopReviewFixture.리뷰_4점(익명_학생, 마슬랜); + shopReviewFixture.리뷰_4점(익명_학생, 마슬랜); + // 2024-01-15 12:00 월요일 기준 + boolean 마슬랜_영업여부 = true; + boolean 티바_영업여부 = true; + mockMvc.perform( + get("/v2/shops") + .queryParam("sorter", "COUNT_ASC") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" + { + "count": 2, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": true, + "id": 2, + "name": "티바", + "pay_bank": true, + "pay_card": true, + "phone": "010-7788-9900", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + },{ + "category_ids": [ + \s + ], + "delivery": true, + "id": 1, + "name": "마슬랜 치킨", + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 2 + } + ] + } + """, 티바_영업여부, 마슬랜_영업여부))); + } + + @Test + void 리뷰_평점기준_내림차순_정렬하여_모든_상점을_조회한다() throws Exception { + Shop 영업중인_티바 = shopFixture.영업중인_티바(owner); + shopReviewFixture.리뷰_4점(익명_학생, 영업중인_티바); + boolean 마슬랜_영업여부 = true; + boolean 티바_영업여부 = true; + mockMvc.perform( + get("/v2/shops") + .queryParam("sorter", "RATING_DESC") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" + { + "count": 2, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": true, + "id": 2, + "name": "티바", + "pay_bank": true, + "pay_card": true, + "phone": "010-7788-9900", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + },{ + "category_ids": [ + \s + ], + "delivery": true, + "id": 1, + "name": "마슬랜 치킨", + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "is_event": false, + "is_open": %s, + "average_rate": 0.0, + "review_count": 0 + } + ] + } + """, 티바_영업여부, 마슬랜_영업여부))); + } + + @Test + void 리뷰_개수기준_내림차순_정렬하여_모든_상점을_조회한다() throws Exception { + Shop 영업중인_티바 = shopFixture.영업중인_티바(owner); + shopReviewFixture.리뷰_4점(익명_학생, 영업중인_티바); + + shopReviewFixture.리뷰_4점(익명_학생, 마슬랜); + shopReviewFixture.리뷰_4점(익명_학생, 마슬랜); + // 2024-01-15 12:00 월요일 기준 + boolean 마슬랜_영업여부 = true; + boolean 티바_영업여부 = true; + mockMvc.perform( + get("/v2/shops") + .queryParam("sorter", "COUNT_DESC") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" + { + "count": 2, + "shops": [ + { + "category_ids": [ + \s + ], + "delivery": true, + "id": 1, + "name": "마슬랜 치킨", + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 2 + },{ + "category_ids": [ + \s + ], + "delivery": true, + "id": 2, + "name": "티바", + "pay_bank": true, + "pay_card": true, + "phone": "010-7788-9900", + "is_event": false, + "is_open": %s, + "average_rate": 4.0, + "review_count": 1 + } + ] + } + """, 티바_영업여부, 마슬랜_영업여부))); + } + @Test void 전화하기_발생시_정보가_알림큐에_저장된다() throws Exception { mockMvc.perform( - post("/shops/{shopId}/call-notification", 마슬랜.getId()) - .header("Authorization", "Bearer " + token_익명) - ) - .andExpect(status().isOk()); + post("/shops/{shopId}/call-notification", 마슬랜.getId()) + .header("Authorization", "Bearer " + token_익명) + ) + .andExpect(status().isOk()); } } From c40367cfe48ff9f0f3a692231992c6952e378555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=A7=84=ED=98=B8?= Date: Thu, 14 Nov 2024 17:27:47 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat:=20=EC=83=81=EC=A0=90=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=88=9C=EC=84=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20api=20=EC=B6=94=EA=B0=80=20(#1007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: shop categories 조회 api doc & controller 수정 * refactor: shop categories 조회 service 수정 * feat: category 순서 변경 api 추가 * feat: order_index 칼럼 추가 flyway 작성 * refactor: category 순서대로 조회하도록 수정 * test: 어드민 정렬된 카테고리 조회 테스트 추가 및 자잘한 코드 수정 * test: 정렬된 카테고리 조회 테스트 추가 * test: 상점 카테고리 정렬 테스트 추가 * chore: 불필요한 코드 제거 * chore: 네이밍 변경 * fix: 피드백 반영 * fix: flyway 버전 수정 * fix: 피드백 반영2 * fix: 깃 충돌 해결 * fix: 테스트 수정 --- .../admin/shop/controller/AdminShopApi.java | 39 +++++++++-- .../shop/controller/AdminShopController.java | 36 ++++++++-- .../dto/AdminCreateShopCategoryRequest.java | 3 +- ...AdminModifyShopCategoriesOrderRequest.java | 20 ++++++ .../shop/dto/AdminShopCategoriesResponse.java | 68 ------------------- .../ShopCategoryIllegalArgumentException.java | 20 ++++++ .../AdminShopCategoryRepository.java | 18 ++--- .../admin/shop/service/AdminShopService.java | 40 +++++++---- .../domain/shop/model/shop/ShopCategory.java | 20 +++++- .../shop/ShopCategoryRepository.java | 5 +- .../koin/domain/shop/service/ShopService.java | 39 ++++++----- ...__alter_order_index_to_shop_categories.sql | 11 +++ .../koin/acceptance/ShopApiTest.java | 43 ++++++------ .../admin/acceptance/AdminShopApiTest.java | 49 ++++++++----- .../koin/fixture/ShopCategoryFixture.java | 2 + 15 files changed, 251 insertions(+), 162 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoriesOrderRequest.java delete mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoriesResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/exception/ShopCategoryIllegalArgumentException.java create mode 100644 src/main/resources/db/migration/V98__alter_order_index_to_shop_categories.sql diff --git a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java index 0348b173d..9b25aee4f 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java +++ b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java @@ -5,7 +5,6 @@ import java.util.List; -import in.koreatech.koin.admin.shop.dto.*; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -15,6 +14,24 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import in.koreatech.koin.admin.shop.dto.AdminCreateMenuCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopRequest; +import in.koreatech.koin.admin.shop.dto.AdminMenuCategoriesResponse; +import in.koreatech.koin.admin.shop.dto.AdminMenuDetailResponse; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopCategoriesOrderRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopReviewReportStatusRequest; +import in.koreatech.koin.admin.shop.dto.AdminShopCategoryResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopMenuResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopParentCategoryResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopsResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopsReviewsResponse; import in.koreatech.koin.global.auth.Auth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -70,9 +87,7 @@ ResponseEntity getShop( ) @Operation(summary = "모든 상점 카테고리 조회") @GetMapping("/admin/shops/categories") - ResponseEntity getShopCategories( - @RequestParam(name = "page", defaultValue = "1") Integer page, - @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, + ResponseEntity> getShopCategories( @Auth(permit = {ADMIN}) Integer adminId ); @@ -259,6 +274,22 @@ ResponseEntity modifyShopCategory( @Auth(permit = {ADMIN}) Integer adminId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 카테고리 순서 수정") + @PutMapping("/admin/shops/categories/order") + ResponseEntity modifyShopCategoriesOrder( + @RequestBody @Valid AdminModifyShopCategoriesOrderRequest adminModifyShopCategoriesOrderRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), diff --git a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java index 8f2c7b70c..738a4e135 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java +++ b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java @@ -5,8 +5,6 @@ import java.util.List; -import in.koreatech.koin.admin.shop.dto.*; -import io.swagger.v3.oas.annotations.Operation; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -18,8 +16,27 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import in.koreatech.koin.admin.shop.dto.AdminCreateMenuCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopRequest; +import in.koreatech.koin.admin.shop.dto.AdminMenuCategoriesResponse; +import in.koreatech.koin.admin.shop.dto.AdminMenuDetailResponse; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopCategoriesOrderRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopReviewReportStatusRequest; +import in.koreatech.koin.admin.shop.dto.AdminShopCategoryResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopMenuResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopParentCategoryResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopsResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopsReviewsResponse; import in.koreatech.koin.admin.shop.service.AdminShopService; import in.koreatech.koin.global.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -51,12 +68,10 @@ public ResponseEntity getShop( } @GetMapping("/admin/shops/categories") - public ResponseEntity getShopCategories( - @RequestParam(name = "page", defaultValue = "1") Integer page, - @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, + public ResponseEntity> getShopCategories( @Auth(permit = {ADMIN}) Integer adminId ) { - AdminShopCategoriesResponse response = adminShopService.getShopCategories(page, limit); + List response = adminShopService.getShopCategories(); return ResponseEntity.ok(response); } @@ -143,6 +158,15 @@ public ResponseEntity modifyShopCategory( return ResponseEntity.ok().build(); } + @PutMapping("/admin/shops/categories/order") + public ResponseEntity modifyShopCategoriesOrder( + @RequestBody @Valid AdminModifyShopCategoriesOrderRequest adminModifyShopCategoriesOrderRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.modifyShopCategoriesOrder(adminModifyShopCategoriesOrderRequest); + return ResponseEntity.noContent().build(); + } + @PutMapping("/admin/shops/{shopId}/menus/categories") public ResponseEntity modifyMenuCategory( @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java index 09d036b60..2ad70dd6e 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java @@ -34,10 +34,11 @@ public record AdminCreateShopCategoryRequest( String eventBannerImageUrl ) { - public ShopCategory toShopCategory(ShopParentCategory shopParentCategory) { + public ShopCategory toShopCategory(Integer orderIndex, ShopParentCategory shopParentCategory) { return ShopCategory.builder() .imageUrl(imageUrl) .name(name) + .orderIndex(orderIndex) .parentCategory(shopParentCategory) .eventBannerImageUrl(eventBannerImageUrl) .build(); diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoriesOrderRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoriesOrderRequest.java new file mode 100644 index 000000000..a19ab7267 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoriesOrderRequest.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminModifyShopCategoriesOrderRequest( + @Schema(example = "[1, 2, 3]", description = "상점 카테고리 id 리스트 순서", requiredMode = REQUIRED) + @NotNull(message = "상점 카테고리 id 리스트는 필수입니다.") + List shopCategoryIds +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoriesResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoriesResponse.java deleted file mode 100644 index c1db7c43a..000000000 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoriesResponse.java +++ /dev/null @@ -1,68 +0,0 @@ -package in.koreatech.koin.admin.shop.dto; - -import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; -import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; - -import java.util.List; - -import org.springframework.data.domain.Page; - -import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; -import com.fasterxml.jackson.databind.annotation.JsonNaming; - -import in.koreatech.koin.domain.shop.model.shop.ShopCategory; -import in.koreatech.koin.global.model.Criteria; -import io.swagger.v3.oas.annotations.media.Schema; - -@JsonNaming(value = SnakeCaseStrategy.class) -public record AdminShopCategoriesResponse( - @Schema(description = "총 상점 카테고리 수", example = "57", requiredMode = REQUIRED) - Long totalCount, - - @Schema(description = "현재 페이지에서 조회된 상점 카테고리 수", example = "10", requiredMode = REQUIRED) - Integer currentCount, - - @Schema(description = "전체 페이지 수", example = "6", requiredMode = REQUIRED) - Integer totalPage, - - @Schema(description = "현재 페이지", example = "2", requiredMode = REQUIRED) - Integer currentPage, - - @Schema(description = "모든 상점 카테고리 리스트", requiredMode = NOT_REQUIRED) - List categories -) { - - public static AdminShopCategoriesResponse of(Page pagedResult, Criteria criteria) { - return new AdminShopCategoriesResponse( - pagedResult.getTotalElements(), - pagedResult.getContent().size(), - pagedResult.getTotalPages(), - criteria.getPage() + 1, - pagedResult.getContent() - .stream() - .map(InnerShopCategory::from) - .toList() - ); - } - - @JsonNaming(value = SnakeCaseStrategy.class) - public record InnerShopCategory( - @Schema(description = "고유 id", example = "0", requiredMode = REQUIRED) - Integer id, - - @Schema(description = "이미지 URL", example = "https://static.koreatech.in/test.png", requiredMode = NOT_REQUIRED) - String imageUrl, - - @Schema(description = "이름", example = "치킨", requiredMode = REQUIRED) - String name - ) { - - public static InnerShopCategory from(ShopCategory shopCategory) { - return new InnerShopCategory( - shopCategory.getId(), - shopCategory.getImageUrl(), - shopCategory.getName() - ); - } - } -} diff --git a/src/main/java/in/koreatech/koin/admin/shop/exception/ShopCategoryIllegalArgumentException.java b/src/main/java/in/koreatech/koin/admin/shop/exception/ShopCategoryIllegalArgumentException.java new file mode 100644 index 000000000..58beec24b --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/exception/ShopCategoryIllegalArgumentException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.shop.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class ShopCategoryIllegalArgumentException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "카테고리를 찾을 수 없습니다"; + + protected ShopCategoryIllegalArgumentException(String message) { + super(message); + } + + protected ShopCategoryIllegalArgumentException(String message, String detail) { + super(message, detail); + } + + public static ShopCategoryIllegalArgumentException withDetail(String detail) { + return new ShopCategoryIllegalArgumentException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java index a8804081c..4e86edd75 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java @@ -3,11 +3,9 @@ import java.util.List; import java.util.Optional; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; -import org.springframework.data.repository.query.Param; import in.koreatech.koin.domain.shop.exception.ShopCategoryNotFoundException; import in.koreatech.koin.domain.shop.model.shop.ShopCategory; @@ -16,18 +14,20 @@ public interface AdminShopCategoryRepository extends Repository findAll(Pageable pageable); + List findAll(); - @Query(value = "SELECT * FROM shop_categories WHERE id = :shopCategoryId", nativeQuery = true) - Optional findById(@Param("shopCategoryId") Integer shopCategoryId); + List findAll(Sort sort); - ShopCategory save(ShopCategory shopCategory); + Optional findById(Integer shopCategoryId); + + Optional findByName(String name); List findAllByIdIn(List ids); - Optional findByName(String name); + @Query("SELECT MAX(c.orderIndex) FROM ShopCategory c") + Integer findMaxOrderIndex(); - List findAll(); + ShopCategory save(ShopCategory shopCategory); default ShopCategory getById(Integer shopCategoryId) { return findById(shopCategoryId) diff --git a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java index 4ef923c1d..e5483567b 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java +++ b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java @@ -5,12 +5,16 @@ import java.time.Clock; import java.time.LocalDate; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; import in.koreatech.koin.admin.shop.dto.*; import in.koreatech.koin.admin.shop.exception.ShopCategoryNotEmptyException; +import in.koreatech.koin.admin.shop.exception.ShopCategoryIllegalArgumentException; import in.koreatech.koin.admin.shop.repository.*; import in.koreatech.koin.domain.shop.exception.ReviewNotFoundException; @@ -54,13 +58,8 @@ public class AdminShopService { private final AdminShopCategoryMapRepository adminShopCategoryMapRepository; private final AdminShopCategoryRepository adminShopCategoryRepository; private final AdminShopParentCategoryRepository adminShopParentCategoryRepository; - private final AdminShopImageRepository adminShopImageRepository; - private final AdminShopOpenRepository adminShopOpenRepository; private final AdminShopRepository adminShopRepository; private final AdminMenuRepository adminMenuRepository; - private final AdminMenuCategoryMapRepository adminMenuCategoryMapRepository; - private final AdminMenuImageRepository adminMenuImageRepository; - private final AdminMenuDetailRepository adminMenuDetailRepository; private final AdminShopReviewRepository adminShopReviewRepository; private final AdminShopReviewCustomRepository adminShopReviewCustomRepository; @@ -79,13 +78,11 @@ public AdminShopResponse getShop(Integer shopId) { return AdminShopResponse.from(shop, eventDuration); } - public AdminShopCategoriesResponse getShopCategories(Integer page, Integer limit) { - Integer total = adminShopCategoryRepository.count(); - Criteria criteria = Criteria.of(page, limit, total); - PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit(), - Sort.by(Sort.Direction.ASC, "id")); - Page result = adminShopCategoryRepository.findAll(pageRequest); - return AdminShopCategoriesResponse.of(result, criteria); + public List getShopCategories() { + List shopCategories = adminShopCategoryRepository.findAll(Sort.by("orderIndex")); + return shopCategories.stream() + .map(AdminShopCategoryResponse::from) + .toList(); } public AdminShopCategoryResponse getShopCategory(Integer categoryId) { @@ -169,9 +166,10 @@ public void createShopCategory(AdminCreateShopCategoryRequest adminCreateShopCat if (adminShopCategoryRepository.findByName(adminCreateShopCategoryRequest.name()).isPresent()) { throw ShopCategoryDuplicationException.withDetail("name: " + adminCreateShopCategoryRequest.name()); } + Integer maxOrderIndex = adminShopCategoryRepository.findMaxOrderIndex(); ShopParentCategory shopParentCategory = adminShopParentCategoryRepository.getById(adminCreateShopCategoryRequest.parentCategoryId()); - ShopCategory shopCategory = adminCreateShopCategoryRequest.toShopCategory(shopParentCategory); + ShopCategory shopCategory = adminCreateShopCategoryRequest.toShopCategory(maxOrderIndex, shopParentCategory); adminShopCategoryRepository.save(shopCategory); } @@ -277,6 +275,22 @@ private void validateExistCategoryName(String name, Integer categoryId) { } } + @Transactional + public void modifyShopCategoriesOrder(AdminModifyShopCategoriesOrderRequest adminModifyShopCategoriesOrderRequest) { + Map shopCategoryMap = adminShopCategoryRepository.findAll().stream() + .collect(Collectors.toMap(ShopCategory::getId, category -> category)); + + List shopCategoryIds = adminModifyShopCategoriesOrderRequest.shopCategoryIds(); + if (!Objects.equals(shopCategoryMap.keySet(), new HashSet<>(shopCategoryIds))) { + throw ShopCategoryIllegalArgumentException.withDetail("카테고리 목록이 잘못되었습니다."); + } + + for (int i = 0; i < shopCategoryIds.size(); i++) { + ShopCategory shopCategory = shopCategoryMap.get(shopCategoryIds.get(i)); + shopCategory.modifyOrderIndex(i); + } + } + @Transactional public void modifyMenuCategory(Integer shopId, AdminModifyMenuCategoryRequest adminModifyMenuCategoryRequest) { adminShopRepository.getById(shopId); diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java index 7049ff714..3f2b4bf31 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java @@ -17,6 +17,8 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; import lombok.AccessLevel; import lombok.Builder; @@ -45,6 +47,11 @@ public class ShopCategory extends BaseEntity { @Column(name = "event_banner_image_url") private String eventBannerImageUrl; + @NotNull + @PositiveOrZero + @Column(name = "order_index", nullable = false) + private Integer orderIndex = 0; + @OneToMany(mappedBy = "shopCategory", orphanRemoval = true, cascade = {PERSIST, REMOVE}) private List shopCategoryMaps = new ArrayList<>(); @@ -53,11 +60,18 @@ public class ShopCategory extends BaseEntity { private ShopParentCategory parentCategory; @Builder - private ShopCategory(String name, String imageUrl, ShopParentCategory parentCategory, String eventBannerImageUrl) { + private ShopCategory( + String name, + String imageUrl, + ShopParentCategory parentCategory, + String eventBannerImageUrl, + Integer orderIndex + ) { this.name = name; this.imageUrl = imageUrl; this.parentCategory = parentCategory; this.eventBannerImageUrl = eventBannerImageUrl; + this.orderIndex = orderIndex == null ? 0 : orderIndex; } public void modifyShopCategory(String name, String imageUrl, ShopParentCategory parentCategory, String eventBannerImageUrl) { @@ -66,4 +80,8 @@ public void modifyShopCategory(String name, String imageUrl, ShopParentCategory this.parentCategory = parentCategory; this.eventBannerImageUrl = eventBannerImageUrl; } + + public void modifyOrderIndex(Integer orderIndex) { + this.orderIndex = orderIndex; + } } diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopCategoryRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopCategoryRepository.java index cbc50fe41..47c3c670f 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopCategoryRepository.java +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopCategoryRepository.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Sort; import org.springframework.data.repository.Repository; import in.koreatech.koin.domain.shop.exception.ShopCategoryNotFoundException; @@ -10,6 +11,8 @@ public interface ShopCategoryRepository extends Repository { + List findAll(Sort sort); + Optional findById(Integer shopCategoryId); ShopCategory save(ShopCategory shopCategory); @@ -20,6 +23,4 @@ default ShopCategory getById(Integer shopCategoryId) { return findById(shopCategoryId) .orElseThrow(() -> ShopCategoryNotFoundException.withDetail("shopCategoryId: " + shopCategoryId)); } - - List findAll(); } diff --git a/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java b/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java index ac9c5d808..d3b615e02 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java +++ b/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java @@ -2,6 +2,17 @@ import static in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType.REVIEW_PROMPT; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import in.koreatech.koin.domain.shop.cache.ShopsCacheService; import in.koreatech.koin.domain.shop.cache.dto.ShopsCache; import in.koreatech.koin.domain.shop.dto.menu.MenuCategoriesResponse; @@ -34,15 +45,7 @@ import in.koreatech.koin.domain.user.repository.UserRepository; import in.koreatech.koin.global.domain.notification.repository.NotificationSubscribeRepository; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; -import java.time.Clock; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Collections; -import java.util.List; -import java.util.Map; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @Transactional(readOnly = true) @@ -100,7 +103,7 @@ public ShopsResponse getShops() { } public ShopCategoriesResponse getShopsCategories() { - List shopCategories = shopCategoryRepository.findAll(); + List shopCategories = shopCategoryRepository.findAll(Sort.by("orderIndex")); return ShopCategoriesResponse.from(shopCategories); } @@ -115,9 +118,9 @@ public ShopEventsWithBannerUrlResponse getAllEvents() { } public ShopsResponseV2 getShopsV2( - ShopsSortCriteria sortBy, - List shopsFilterCriterias, - String query + ShopsSortCriteria sortBy, + List shopsFilterCriterias, + String query ) { if (shopsFilterCriterias.contains(null)) { throw KoinIllegalArgumentException.withDetail("유효하지 않은 필터입니다."); @@ -126,12 +129,12 @@ public ShopsResponseV2 getShopsV2( LocalDateTime now = LocalDateTime.now(clock); Map shopInfoMap = shopCustomRepository.findAllShopInfo(now); return ShopsResponseV2.from( - shopCaches.shopCaches(), - shopInfoMap, - sortBy, - shopsFilterCriterias, - now, - query + shopCaches.shopCaches(), + shopInfoMap, + sortBy, + shopsFilterCriterias, + now, + query ); } diff --git a/src/main/resources/db/migration/V98__alter_order_index_to_shop_categories.sql b/src/main/resources/db/migration/V98__alter_order_index_to_shop_categories.sql new file mode 100644 index 000000000..af3e856e6 --- /dev/null +++ b/src/main/resources/db/migration/V98__alter_order_index_to_shop_categories.sql @@ -0,0 +1,11 @@ +ALTER TABLE shop_categories ADD COLUMN order_index INT; + +UPDATE shop_categories sc +JOIN ( + SELECT id, ROW_NUMBER() OVER (ORDER BY id) AS new_order_index + FROM shop_categories +) AS oc +ON sc.id = oc.id +SET sc.order_index = oc.new_order_index; + +ALTER TABLE shop_categories MODIFY order_index INT NOT NULL; diff --git a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java index 6ec56e8b6..1d613318c 100644 --- a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java @@ -420,31 +420,30 @@ void setUp() { } @Test - void 상점들의_모든_카테고리를_조회한다() throws Exception { - shopCategoryFixture.카테고리_일반음식(shopParentCategory_가게); + void 상점의_정렬된_모든_카테고리_조회() throws Exception { + shopCategoryFixture.카테고리_일반음식(shopParentCategory_가게); // 카테고리_치킨이 먼저 생성됨 mockMvc.perform( - get("/shops/categories") - ) - .andExpect(status().isOk()) - .andExpect(content().json(""" + get("/shops/categories") + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" + { + "total_count": 2, + "shop_categories": [ { - "total_count": 2, - "shop_categories": [ - { - "id": 1, - "image_url": "https://test-image.com/ckicken.jpg", - "name": "치킨" - }, - { - "id": 2, - "image_url": "https://test-image.com/normal.jpg", - "name": "일반음식점" - }, - - ] - } - """)); + "id": 2, + "name": "일반음식점", + "image_url": "https://test-image.com/normal.jpg" + }, + { + "id": 1, + "name": "치킨", + "image_url": "https://test-image.com/ckicken.jpg" + } + ] + } + """)); } @Test diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java index e633debbc..9580da6a2 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Sort; import org.springframework.http.MediaType; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; @@ -221,25 +222,16 @@ void setUp() { } @Test - void 어드민이_상점의_모든_카테고리를_조회한다() throws Exception { - for (int i = 0; i < 12; i++) { - ShopCategory request = ShopCategory.builder() - .name("카테고리" + i) - .build(); - adminShopCategoryRepository.save(request); - } - + void 어드민이_상점의_등록된_순서가_아닌_정렬된_모든_카테고리를_조회한다() throws Exception { mockMvc.perform( get("/admin/shops/categories") .header("Authorization", "Bearer " + token_admin) - .param("page", "1") ) .andExpect(status().isOk()) - .andExpect(jsonPath("$.total_count").value(14)) - .andExpect(jsonPath("$.current_count").value(10)) - .andExpect(jsonPath("$.total_page").value(2)) - .andExpect(jsonPath("$.current_page").value(1)) - .andExpect(jsonPath("$.categories.length()").value(10)); + .andExpect(jsonPath("$[0].id").value(2)) + .andExpect(jsonPath("$[0].name").value("일반음식점")) + .andExpect(jsonPath("$[1].id").value(1)) + .andExpect(jsonPath("$[1].name").value("치킨")); } @Test @@ -340,7 +332,7 @@ void setUp() { .header("Authorization", "Bearer " + token_admin) ) .andExpect(status().isOk()) - .andExpect(content().json(""" + .andExpect(content().json(""" { "count": 2, "menu_categories": [ @@ -744,7 +736,6 @@ void setUp() { }); } - @Test void 어드민이_상점_카테고리를_수정한다() throws Exception { ShopCategory shopCategory = shopCategoryFixture.카테고리_일반음식(shopParentCategory_가게); @@ -798,6 +789,29 @@ void setUp() { assertSoftly(softly -> softly.assertThat(menuCategory.getName()).isEqualTo("사이드 메뉴")); } + @Test + void 어드민이_상점_카테고리_순서를_변경한다() throws Exception { + mockMvc.perform( + put("/admin/shops/categories/order") + .header("Authorization", "Bearer " + token_admin) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "shop_category_ids": [%d, %d] + } + """.formatted(shopCategory_치킨.getId(), shopCategory_일반.getId())) + ) + .andExpect(status().isNoContent()); + + List shopCategories = adminShopCategoryRepository.findAll(Sort.by("orderIndex")); + assertSoftly(softly -> { + softly.assertThat(shopCategories.get(0).getId()).isEqualTo(shopCategory_치킨.getId()); + softly.assertThat(shopCategories.get(0).getOrderIndex()).isEqualTo(0); + softly.assertThat(shopCategories.get(1).getId()).isEqualTo(shopCategory_일반.getId()); + softly.assertThat(shopCategories.get(1).getOrderIndex()).isEqualTo(1); + }); + } + @Test void 어드민이_특정_상점의_메뉴를_단일_메뉴로_수정한다() throws Exception { // given @@ -923,7 +937,6 @@ void setUp() { .andExpect(status().isBadRequest()); } - @Test void 어드민이_상점을_삭제한다() throws Exception { Shop shop = shopFixture.영업중이_아닌_신전_떡볶이(owner_현수); @@ -962,7 +975,7 @@ void setUp() { mockMvc.perform( delete("/admin/shops/categories/{id}", shopCategory_치킨.getId()) .header("Authorization", "Bearer " + token_admin) - ) + ) .andExpect(status().isBadRequest()); } diff --git a/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java b/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java index 313cef3d4..930cb294c 100644 --- a/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/ShopCategoryFixture.java @@ -21,6 +21,7 @@ public ShopCategoryFixture(ShopCategoryRepository categoryRepository) { ShopCategory.builder() .name("치킨") .imageUrl("https://test-image.com/ckicken.jpg") + .orderIndex(2) .parentCategory(parentCategory) .eventBannerImageUrl("https://test-image.com/chicken-event.jpg") .build() @@ -32,6 +33,7 @@ public ShopCategoryFixture(ShopCategoryRepository categoryRepository) { ShopCategory.builder() .name("일반음식점") .imageUrl("https://test-image.com/normal.jpg") + .orderIndex(1) .parentCategory(parentCategory) .eventBannerImageUrl("https://test-image.com/normal-event.jpg") .build() From 6cb491cd95e318f3c4d637ed9f217a756c2de14f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Thu, 14 Nov 2024 20:15:36 +0900 Subject: [PATCH 10/14] =?UTF-8?q?fix:=20=EA=B0=95=EC=9D=98=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20null=20=EC=B2=B4=ED=81=AC=20=EC=B6=94=EA=B0=80=20(#?= =?UTF-8?q?1021)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 강의 시간 null 체크 * chore: 미사용 import 삭제 --- .../dto/TimetableLectureCreateRequest.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureCreateRequest.java b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureCreateRequest.java index ad8cd5925..6fd10926e 100644 --- a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureCreateRequest.java +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureCreateRequest.java @@ -3,7 +3,6 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; -import java.util.Arrays; import java.util.List; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; @@ -54,16 +53,17 @@ public record InnerTimeTableLectureRequest( @Schema(description = "강의 고유 번호", example = "1", requiredMode = NOT_REQUIRED) Integer lectureId - ){ + ) { public InnerTimeTableLectureRequest { if (grades == null) { grades = "0"; } } + public TimetableLecture toTimetableLecture(TimetableFrame timetableFrame) { return new TimetableLecture( classTitle, - Arrays.toString(classTime().stream().toArray()), + getClassTimeToString(), classPlace, professor, grades, @@ -77,7 +77,7 @@ public TimetableLecture toTimetableLecture(TimetableFrame timetableFrame) { public TimetableLecture toTimetableLecture(TimetableFrame timetableFrame, Lecture lecture) { return new TimetableLecture( classTitle, - Arrays.toString(classTime().stream().toArray()), + getClassTimeToString(), classPlace, professor, grades, @@ -87,5 +87,12 @@ public TimetableLecture toTimetableLecture(TimetableFrame timetableFrame, Lectur timetableFrame ); } + + private String getClassTimeToString() { + if (classTime != null) { + return classTime.toString(); + } + return null; + } } } From f9b0245896504b99ff32328abefda87c49fd1961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=84=A0=EA=B6=8C?= Date: Sun, 17 Nov 2024 17:10:56 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=88=98=20=EC=A6=9D=EA=B0=80=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20(#1025)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 게시글 조회 시 ip 당 1시간에 1번만 조회수가 오르도록 수정 * fix: 제한 시간 수정 --- .../article/controller/ArticleApi.java | 3 +- .../article/controller/ArticleController.java | 5 ++- .../article/model/redis/ArticleHitUser.java | 37 +++++++++++++++++++ .../redis/ArticleHitUserRepository.java | 18 +++++++++ .../article/service/ArticleService.java | 11 ++++-- 5 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/community/article/model/redis/ArticleHitUser.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/article/repository/redis/ArticleHitUserRepository.java diff --git a/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleApi.java b/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleApi.java index 3c449b328..bce069e90 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleApi.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleApi.java @@ -37,7 +37,8 @@ public interface ArticleApi { @GetMapping("/{id}") ResponseEntity getArticle( @RequestParam(required = false) Integer boardId, - @Parameter(in = PATH) @PathVariable("id") Integer articleId + @Parameter(in = PATH) @PathVariable("id") Integer articleId, + @IpAddress String ipAddress ); @ApiResponses( diff --git a/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleController.java b/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleController.java index 2b66f9efe..97180f428 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleController.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleController.java @@ -27,9 +27,10 @@ public class ArticleController implements ArticleApi { @GetMapping("/{id}") public ResponseEntity getArticle( @RequestParam(required = false) Integer boardId, - @PathVariable("id") Integer articleId + @PathVariable("id") Integer articleId, + @IpAddress String ipAddress ) { - ArticleResponse foundArticle = articleService.getArticle(boardId, articleId); + ArticleResponse foundArticle = articleService.getArticle(boardId, articleId, ipAddress); return ResponseEntity.ok().body(foundArticle); } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/model/redis/ArticleHitUser.java b/src/main/java/in/koreatech/koin/domain/community/article/model/redis/ArticleHitUser.java new file mode 100644 index 000000000..0240cab6a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/model/redis/ArticleHitUser.java @@ -0,0 +1,37 @@ +package in.koreatech.koin.domain.community.article.model.redis; + +import java.util.concurrent.TimeUnit; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@RedisHash(value = "articleHitUser") +public class ArticleHitUser { + + public static final String DELIMITER = ":"; + private static final long CACHE_EXPIRE_HOURS = 1L; + + @Id + private String id; + + @TimeToLive(unit = TimeUnit.HOURS) + private final Long expiration; + + @Builder + private ArticleHitUser(String id, Long expiration) { + this.id = id; + this.expiration = expiration; + } + + public static ArticleHitUser of(Integer articleId, String publicIp) { + return ArticleHitUser.builder() + .id(articleId + DELIMITER + publicIp) + .expiration(CACHE_EXPIRE_HOURS) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/repository/redis/ArticleHitUserRepository.java b/src/main/java/in/koreatech/koin/domain/community/article/repository/redis/ArticleHitUserRepository.java new file mode 100644 index 000000000..3b821f28d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/repository/redis/ArticleHitUserRepository.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.domain.community.article.repository.redis; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.community.article.model.redis.ArticleHitUser; + +public interface ArticleHitUserRepository extends Repository { + + ArticleHitUser save(ArticleHitUser articleHitUser); + + Optional findById(String id); + + default Optional findByArticleIdAndPublicIp(Integer articleId, String publicIp) { + return findById(articleId + ArticleHitUser.DELIMITER + publicIp); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java b/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java index 44079714d..a98721dfb 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java @@ -27,11 +27,13 @@ import in.koreatech.koin.domain.community.article.model.ArticleSearchKeywordIpMap; import in.koreatech.koin.domain.community.article.model.Board; import in.koreatech.koin.domain.community.article.model.redis.ArticleHit; +import in.koreatech.koin.domain.community.article.model.redis.ArticleHitUser; import in.koreatech.koin.domain.community.article.repository.ArticleRepository; import in.koreatech.koin.domain.community.article.repository.ArticleSearchKeywordIpMapRepository; import in.koreatech.koin.domain.community.article.repository.ArticleSearchKeywordRepository; import in.koreatech.koin.domain.community.article.repository.BoardRepository; import in.koreatech.koin.domain.community.article.repository.redis.ArticleHitRepository; +import in.koreatech.koin.domain.community.article.repository.redis.ArticleHitUserRepository; import in.koreatech.koin.domain.community.article.repository.redis.HotArticleRepository; import in.koreatech.koin.global.concurrent.ConcurrencyGuard; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; @@ -60,13 +62,16 @@ public class ArticleService { private final ArticleSearchKeywordRepository articleSearchKeywordRepository; private final ArticleHitRepository articleHitRepository; private final HotArticleRepository hotArticleRepository; + private final ArticleHitUserRepository articleHitUserRepository; private final Clock clock; @Transactional - public ArticleResponse getArticle(Integer boardId, Integer articleId) { + public ArticleResponse getArticle(Integer boardId, Integer articleId, String publicIp) { Article article = articleRepository.getById(articleId); - //TODO: 추후(Device 관련 로직 구현 후) 조회수 증가 제한 로직 부가 필요 - article.increaseKoinHit(); + if (articleHitUserRepository.findByArticleIdAndPublicIp(articleId, publicIp).isEmpty()) { + article.increaseKoinHit(); + articleHitUserRepository.save(ArticleHitUser.of(articleId, publicIp)); + } Board board = getBoard(boardId, article); Article prevArticle = articleRepository.getPreviousArticle(board, article); Article nextArticle = articleRepository.getNextArticle(board, article); From dd6e9f1d77b570d4858945a92c34a29a23f5ea0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=9E=AC?= <103095432+seongjae6751@users.noreply.github.com> Date: Tue, 19 Nov 2024 00:50:00 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=B6=94=EC=B2=9C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EB=A7=81=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#1033)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 키워드 추천 필터링 기능 구현 * feat: 키워드 추천 필터링 로직 완성 * chore: 컨벤션 * refactor: 어드민으로 패키지 분리 * chore: 리뷰 반영 --- .../keyword/controller/AdminKeywordApi.java | 52 +++++++++++++++++++ .../controller/AdminKeywordController.java | 42 +++++++++++++++ .../dto/AdminFilteredKeywordsResponse.java | 39 ++++++++++++++ .../dto/AdminKeywordFilterRequest.java | 17 ++++++ .../AdminArticleKeywordRepository.java | 21 ++++++++ .../keyword/service/AdminKeywordService.java | 38 ++++++++++++++ .../ArticleKeywordNotFoundException.java | 20 +++++++ .../keyword/model/ArticleKeyword.java | 10 +++- .../repository/ArticleKeywordRepository.java | 36 +++++++------ .../keyword/service/KeywordService.java | 16 +++--- ..._add_filter_column_to_article_keywords.sql | 5 ++ .../koin/fixture/KeywordFixture.java | 17 +++--- 12 files changed, 280 insertions(+), 33 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/admin/keyword/controller/AdminKeywordApi.java create mode 100644 src/main/java/in/koreatech/koin/admin/keyword/controller/AdminKeywordController.java create mode 100644 src/main/java/in/koreatech/koin/admin/keyword/dto/AdminFilteredKeywordsResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/keyword/dto/AdminKeywordFilterRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/keyword/repository/AdminArticleKeywordRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/keyword/service/AdminKeywordService.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/keyword/exception/ArticleKeywordNotFoundException.java create mode 100644 src/main/resources/db/migration/V99__add_filter_column_to_article_keywords.sql diff --git a/src/main/java/in/koreatech/koin/admin/keyword/controller/AdminKeywordApi.java b/src/main/java/in/koreatech/koin/admin/keyword/controller/AdminKeywordApi.java new file mode 100644 index 000000000..95433dc6b --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/keyword/controller/AdminKeywordApi.java @@ -0,0 +1,52 @@ +package in.koreatech.koin.admin.keyword.controller; + +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import in.koreatech.koin.admin.keyword.dto.AdminFilteredKeywordsResponse; +import in.koreatech.koin.admin.keyword.dto.AdminKeywordFilterRequest; +import in.koreatech.koin.global.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Admin) Keyword: 키워드", description = "키워드 알림 정보를 관리한다") +public interface AdminKeywordApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "키워드 필터링") + @PostMapping("/admin/articles/keyword/filter") + ResponseEntity toggleKeywordFilter( + @Valid @RequestBody AdminKeywordFilterRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "필터링 된 키워드 조회") + @GetMapping("/admin/articles/keyword/filtered") + ResponseEntity getFilteredKeywords( + @Auth(permit = {ADMIN}) Integer adminId + ); +} diff --git a/src/main/java/in/koreatech/koin/admin/keyword/controller/AdminKeywordController.java b/src/main/java/in/koreatech/koin/admin/keyword/controller/AdminKeywordController.java new file mode 100644 index 000000000..c8e95d961 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/keyword/controller/AdminKeywordController.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.admin.keyword.controller; + +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.admin.keyword.service.AdminKeywordService; +import in.koreatech.koin.admin.keyword.dto.AdminFilteredKeywordsResponse; +import in.koreatech.koin.admin.keyword.dto.AdminKeywordFilterRequest; +import in.koreatech.koin.global.auth.Auth; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/admin/articles/keyword") +public class AdminKeywordController implements AdminKeywordApi { + + private final AdminKeywordService adminKeywordService; + + @PostMapping("/filter") + public ResponseEntity toggleKeywordFilter( + @Valid @RequestBody AdminKeywordFilterRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminKeywordService.filterKeyword(request.keyword(), request.isFiltered()); + return ResponseEntity.ok().build(); + } + + @GetMapping("/filtered") + public ResponseEntity getFilteredKeywords( + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminFilteredKeywordsResponse response = adminKeywordService.getFilteredKeywords(); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/keyword/dto/AdminFilteredKeywordsResponse.java b/src/main/java/in/koreatech/koin/admin/keyword/dto/AdminFilteredKeywordsResponse.java new file mode 100644 index 000000000..d76b1543d --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/keyword/dto/AdminFilteredKeywordsResponse.java @@ -0,0 +1,39 @@ +package in.koreatech.koin.admin.keyword.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminFilteredKeywordsResponse( + @Schema(description = "필터링된 키워드 목록", requiredMode = REQUIRED) + List keywords +) { + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerFilteredKeywordResponse( + @Schema(description = "키워드 내용", example = "[ㅇ, ㄴ라]", requiredMode = REQUIRED) + String keyword + ) { + + public static InnerFilteredKeywordResponse from(ArticleKeyword articleKeyword) { + return new InnerFilteredKeywordResponse( + articleKeyword.getKeyword() + ); + } + } + + public static AdminFilteredKeywordsResponse from(List filteredKeywords) { + return new AdminFilteredKeywordsResponse( + filteredKeywords.stream() + .map(InnerFilteredKeywordResponse::from) + .toList() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/keyword/dto/AdminKeywordFilterRequest.java b/src/main/java/in/koreatech/koin/admin/keyword/dto/AdminKeywordFilterRequest.java new file mode 100644 index 000000000..621ac9c59 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/keyword/dto/AdminKeywordFilterRequest.java @@ -0,0 +1,17 @@ +package in.koreatech.koin.admin.keyword.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record AdminKeywordFilterRequest( + @NotBlank(message = "키워드는 공백일 수 없습니다.") + @Schema(example = "키워드", description = "필터링 할 키워드 단어", requiredMode = REQUIRED) + String keyword, + + @Schema(example = "true", description = "필터링 여부", requiredMode = REQUIRED) + Boolean isFiltered +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/keyword/repository/AdminArticleKeywordRepository.java b/src/main/java/in/koreatech/koin/admin/keyword/repository/AdminArticleKeywordRepository.java new file mode 100644 index 000000000..cf64af684 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/keyword/repository/AdminArticleKeywordRepository.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.admin.keyword.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.community.keyword.exception.ArticleKeywordNotFoundException; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; + +public interface AdminArticleKeywordRepository extends Repository { + + Optional findByKeyword(String keyword); + + default ArticleKeyword getByKeyword(String keyword) { + return findByKeyword(keyword) + .orElseThrow(() -> ArticleKeywordNotFoundException.withDetail("keyword : " + keyword)); + } + + List findByIsFiltered(boolean isFiltered); +} diff --git a/src/main/java/in/koreatech/koin/admin/keyword/service/AdminKeywordService.java b/src/main/java/in/koreatech/koin/admin/keyword/service/AdminKeywordService.java new file mode 100644 index 000000000..ed9e5dc69 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/keyword/service/AdminKeywordService.java @@ -0,0 +1,38 @@ +package in.koreatech.koin.admin.keyword.service; + +import java.util.List; +import java.util.Objects; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.admin.keyword.repository.AdminArticleKeywordRepository; +import in.koreatech.koin.admin.keyword.dto.AdminFilteredKeywordsResponse; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminKeywordService { + + private final AdminArticleKeywordRepository adminArticleKeywordRepository; + + @Transactional + public void filterKeyword(String keyword, Boolean isFiltered) { + ArticleKeyword articleKeyword = adminArticleKeywordRepository.getByKeyword(keyword); + + if (Objects.equals(articleKeyword.getIsFiltered(), isFiltered)) { + String action = isFiltered ? "필터링 된" : "필터링이 취소된"; + throw new KoinIllegalArgumentException("이미 " + action + " 키워드입니다: " + keyword); + } + + articleKeyword.applyFiltered(isFiltered); + } + + public AdminFilteredKeywordsResponse getFilteredKeywords() { + List filteredKeywords = adminArticleKeywordRepository.findByIsFiltered(true); + return AdminFilteredKeywordsResponse.from(filteredKeywords); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/exception/ArticleKeywordNotFoundException.java b/src/main/java/in/koreatech/koin/domain/community/keyword/exception/ArticleKeywordNotFoundException.java new file mode 100644 index 000000000..6e74c366d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/exception/ArticleKeywordNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.community.keyword.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class ArticleKeywordNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 키워드 단어 입니다."; + + public ArticleKeywordNotFoundException(String message) { + super(message); + } + + public ArticleKeywordNotFoundException(String message, String detail) { + super(message, detail); + } + + public static ArticleKeywordNotFoundException withDetail(String detail) { + return new ArticleKeywordNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/model/ArticleKeyword.java b/src/main/java/in/koreatech/koin/domain/community/keyword/model/ArticleKeyword.java index c29921ac3..a28b2996b 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/model/ArticleKeyword.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/model/ArticleKeyword.java @@ -36,13 +36,17 @@ public class ArticleKeyword extends BaseEntity { @Column(name = "last_used_at", columnDefinition = "TIMESTAMP") private LocalDateTime lastUsedAt; + @Column(name = "is_filtered", nullable = false) + private Boolean isFiltered = false; + @OneToMany(mappedBy = "articleKeyword", cascade = CascadeType.PERSIST) private List articleKeywordUserMaps = new ArrayList<>(); @Builder - private ArticleKeyword(String keyword, LocalDateTime lastUsedAt) { + private ArticleKeyword(String keyword, LocalDateTime lastUsedAt, Boolean isFiltered) { this.keyword = keyword; this.lastUsedAt = lastUsedAt; + this.isFiltered = isFiltered != null ? isFiltered : false; } public void addUserMap(ArticleKeywordUserMap keywordUserMap) { @@ -60,4 +64,8 @@ public void addUserMap(ArticleKeywordUserMap keywordUserMap) { private void updateLastUsedAt() { this.lastUsedAt = LocalDateTime.now(); } + + public void applyFiltered(Boolean isFiltered) { + this.isFiltered = isFiltered; + } } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordRepository.java b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordRepository.java index d07ff4e24..d50413eb7 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.repository.Repository; import in.koreatech.koin.domain.community.article.dto.ArticleKeywordResult; +import in.koreatech.koin.domain.community.keyword.exception.ArticleKeywordNotFoundException; import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; public interface ArticleKeywordRepository extends Repository { @@ -21,24 +22,25 @@ public interface ArticleKeywordRepository extends Repository findById(Integer id); - @Query(""" - SELECT new in.koreatech.koin.domain.community.article.dto.ArticleKeywordResult(k.id, k.keyword, COUNT(u)) - FROM ArticleKeywordUserMap u - JOIN u.articleKeyword k - WHERE k.lastUsedAt >= :oneWeekAgo - GROUP BY k.id, k.keyword - ORDER BY COUNT(u) DESC - """) - List findTopKeywordsInLastWeek(LocalDateTime oneWeekAgo, Pageable pageable); + List findAll(Pageable pageable); @Query(""" - SELECT new in.koreatech.koin.domain.community.article.dto.ArticleKeywordResult(k.id, k.keyword, COUNT(u)) - FROM ArticleKeyword k - LEFT JOIN k.articleKeywordUserMaps u - GROUP BY k.id, k.keyword - ORDER BY k.createdAt DESC - """) - List findTop15Keywords(Pageable pageable); + SELECT new in.koreatech.koin.domain.community.article.dto.ArticleKeywordResult(k.id, k.keyword, COUNT(u)) + FROM ArticleKeywordUserMap u + JOIN u.articleKeyword k + WHERE k.lastUsedAt >= :oneWeekAgo AND k.isFiltered = false + GROUP BY k.id, k.keyword + ORDER BY COUNT(u) DESC + """) + List findTopKeywordsInLastWeekExcludingFiltered(LocalDateTime oneWeekAgo, Pageable top15); - List findAll(Pageable pageable); + @Query(""" + SELECT new in.koreatech.koin.domain.community.article.dto.ArticleKeywordResult(k.id, k.keyword, COUNT(u)) + FROM ArticleKeyword k + LEFT JOIN k.articleKeywordUserMaps u + WHERE k.isFiltered = false + GROUP BY k.id, k.keyword + ORDER BY k.createdAt DESC + """) + List findTop15KeywordsExcludingFiltered(Pageable top15); } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java index 87455e3d3..795c47dd2 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java @@ -2,10 +2,8 @@ import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Objects; -import java.util.Optional; import java.util.stream.Collectors; import org.springframework.context.ApplicationEventPublisher; @@ -99,9 +97,8 @@ public ArticleKeywordsResponse getMyKeywords(Integer userId) { } public ArticleKeywordsSuggestionResponse suggestKeywords() { - List hotKeywords = articleKeywordSuggestRepository.findTop15ByOrderByCountDesc(); - - List suggestions = hotKeywords.stream() + List suggestions = articleKeywordSuggestRepository.findTop15ByOrderByCountDesc() + .stream() .map(ArticleKeywordSuggestCache::getKeyword) .collect(Collectors.toList()); @@ -165,11 +162,13 @@ private List matchKeyword(List
articles) { public void fetchTopKeywordsFromLastWeek() { Pageable top15 = PageRequest.of(0, 15); LocalDateTime oneWeekAgo = LocalDateTime.now().minusWeeks(1); - List topKeywords = articleKeywordRepository.findTopKeywordsInLastWeek(oneWeekAgo, top15); - if(topKeywords.size() < 15) { - topKeywords = articleKeywordRepository.findTop15Keywords(top15); + List topKeywords = articleKeywordRepository.findTopKeywordsInLastWeekExcludingFiltered(oneWeekAgo, top15); + + if (topKeywords.size() < 15) { + topKeywords = articleKeywordRepository.findTop15KeywordsExcludingFiltered(top15); } + List hotKeywords = topKeywords.stream() .map(result -> ArticleKeywordSuggestCache.builder() .hotKeywordId(result.hotKeywordId()) @@ -179,7 +178,6 @@ public void fetchTopKeywordsFromLastWeek() { .toList(); articleKeywordSuggestRepository.deleteAll(); - for(ArticleKeywordSuggestCache hotKeyword : hotKeywords) { articleKeywordSuggestRepository.save(hotKeyword); } diff --git a/src/main/resources/db/migration/V99__add_filter_column_to_article_keywords.sql b/src/main/resources/db/migration/V99__add_filter_column_to_article_keywords.sql new file mode 100644 index 000000000..612281844 --- /dev/null +++ b/src/main/resources/db/migration/V99__add_filter_column_to_article_keywords.sql @@ -0,0 +1,5 @@ +ALTER TABLE `article_keywords` + ADD COLUMN `is_filtered` TINYINT(1) NOT NULL DEFAULT 0; + +UPDATE `article_keywords` +SET `is_filtered` = 0; diff --git a/src/test/java/in/koreatech/koin/fixture/KeywordFixture.java b/src/test/java/in/koreatech/koin/fixture/KeywordFixture.java index f9f760e4f..5ae564092 100644 --- a/src/test/java/in/koreatech/koin/fixture/KeywordFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/KeywordFixture.java @@ -24,16 +24,21 @@ public KeywordFixture(ArticleKeywordRepository articleKeywordRepository, } public ArticleKeywordUserMap 키워드1(String keyword, User user) { + return 키워드1(keyword, user, false); + } + + public ArticleKeywordUserMap 키워드1(String keyword, User user, boolean isFiltered) { ArticleKeyword articleKeyword = articleKeywordRepository.save(ArticleKeyword.builder() .keyword(keyword) .lastUsedAt(LocalDateTime.now()) + .isFiltered(isFiltered) .build()); - ArticleKeywordUserMap articleKeywordUserMap = ArticleKeywordUserMap.builder() - .articleKeyword(articleKeyword) - .user(user) - .build(); - - return articleKeywordUserMapRepository.save(articleKeywordUserMap); + return articleKeywordUserMapRepository.save( + ArticleKeywordUserMap.builder() + .articleKeyword(articleKeyword) + .user(user) + .build() + ); } } From 1ffef38acb09531b0f5733210e0a60e5cab2706b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=9E=AC?= <103095432+seongjae6751@users.noreply.github.com> Date: Tue, 19 Nov 2024 00:56:17 +0900 Subject: [PATCH 13/14] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=B2=98=EB=A6=AC=20(#990)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 키워드 알림 중복 방지 기능 추가 * feat: 로직 일부 수정 * refactor: 메서드 분리 * chore: flyway 파일 버전 변경 * feat: 알림 메시지 내용 변경 * chore: flyway 파일 버전 변경 * chore: 컨벤션 * chore: 로그 제거 --- .../article/repository/ArticleRepository.java | 2 + .../model/ArticleKeywordEventListener.java | 67 ++++++++++++++++--- .../keyword/model/UserNotificationStatus.java | 43 ++++++++++++ .../UserNotificationStatusRepository.java | 18 +++++ .../keyword/service/KeywordService.java | 13 ++++ .../model/NotificationFactory.java | 7 +- .../service/NotificationService.java | 1 + ...99__add_user_notification_status_table.sql | 10 +++ 8 files changed, 149 insertions(+), 12 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/community/keyword/model/UserNotificationStatus.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java create mode 100644 src/main/resources/db/migration/V99__add_user_notification_status_table.sql diff --git a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java index b71c83f74..339d4b78e 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java @@ -118,4 +118,6 @@ default Article getNextArticle(Board board, Article article) { + "AND a.is_deleted = false ", nativeQuery = true) List
findAllByRegisteredAtIsAfter(LocalDate registeredAt); + @Query("SELECT a.title FROM Article a WHERE a.id = :id") + String getTitleById(@Param("id") Integer id); } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/model/ArticleKeywordEventListener.java b/src/main/java/in/koreatech/koin/domain/community/keyword/model/ArticleKeywordEventListener.java index d88fb78a6..2ec2c2d80 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/model/ArticleKeywordEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/model/ArticleKeywordEventListener.java @@ -6,13 +6,19 @@ import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; +import in.koreatech.koin.domain.community.article.repository.ArticleRepository; +import in.koreatech.koin.domain.community.keyword.repository.UserNotificationStatusRepository; +import in.koreatech.koin.domain.community.keyword.service.KeywordService; import in.koreatech.koin.global.domain.notification.model.Notification; import in.koreatech.koin.global.domain.notification.model.NotificationFactory; +import in.koreatech.koin.global.domain.notification.model.NotificationSubscribe; import in.koreatech.koin.global.domain.notification.repository.NotificationSubscribeRepository; import in.koreatech.koin.global.domain.notification.service.NotificationService; import lombok.RequiredArgsConstructor; @@ -25,22 +31,65 @@ public class ArticleKeywordEventListener { private final NotificationService notificationService; private final NotificationFactory notificationFactory; private final NotificationSubscribeRepository notificationSubscribeRepository; + private final UserNotificationStatusRepository userNotificationStatusRepository; + private final KeywordService keywordService; + private final ArticleRepository articleRepository; @TransactionalEventListener(phase = AFTER_COMMIT) public void onKeywordRequest(ArticleKeywordEvent event) { + String articleTitle = articleRepository.getTitleById(event.articleId()); + List notifications = notificationSubscribeRepository .findAllBySubscribeTypeAndDetailType(ARTICLE_KEYWORD, null) .stream() - .filter(subscribe -> subscribe.getUser().getDeviceToken() != null) - .filter(subscribe -> event.keyword().getArticleKeywordUserMaps().stream() - .anyMatch(map -> map.getUser().getId().equals(subscribe.getUser().getId()))) - .map(subscribe -> notificationFactory.generateKeywordNotification( - KEYWORD, - event.articleId(), - event.keyword().getKeyword(), - subscribe.getUser() - )).toList(); + .filter(this::hasDeviceToken) + .filter(subscribe -> isKeywordRegistered(event, subscribe)) + .filter(subscribe -> isNewArticle(event, subscribe)) + .map(subscribe -> createAndRecordNotification(event, articleTitle, subscribe)) + .toList(); notificationService.push(notifications); } + + private boolean hasDeviceToken(NotificationSubscribe subscribe) { + return subscribe.getUser().getDeviceToken() != null; + } + + private boolean isKeywordRegistered(ArticleKeywordEvent event, NotificationSubscribe subscribe) { + return event.keyword().getArticleKeywordUserMaps().stream() + .anyMatch(map -> map.getUser().getId().equals(subscribe.getUser().getId())); + } + + private boolean isNewArticle(ArticleKeywordEvent event, NotificationSubscribe subscribe) { + Integer userId = subscribe.getUser().getId(); + Integer lastNotifiedId = userNotificationStatusRepository + .findLastNotifiedArticleIdByUserId(userId) + .orElse(0); + return !lastNotifiedId.equals(event.articleId()); + } + + private Notification createAndRecordNotification( + ArticleKeywordEvent event, + String articleTitle, + NotificationSubscribe subscribe + ) { + Integer userId = subscribe.getUser().getId(); + String keyword = event.keyword().getKeyword(); + String description = generateDescription(keyword); + + Notification notification = notificationFactory.generateKeywordNotification( + KEYWORD, + event.articleId(), + articleTitle, + description, + subscribe.getUser() + ); + + keywordService.updateLastNotifiedArticle(userId, event.articleId()); + return notification; + } + + private String generateDescription(String keyword) { + return "방금 등록된 {%s} 공지를 확인해보세요!".formatted(keyword); + } } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/model/UserNotificationStatus.java b/src/main/java/in/koreatech/koin/domain/community/keyword/model/UserNotificationStatus.java new file mode 100644 index 000000000..50bdabfa2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/model/UserNotificationStatus.java @@ -0,0 +1,43 @@ +package in.koreatech.koin.domain.community.keyword.model; + +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "user_notification_status", uniqueConstraints = { + @UniqueConstraint(columnNames = "user_id") +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserNotificationStatus extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "user_id", nullable = false) + private Integer userId; + + @Column(name = "last_notified_article_id", nullable = false) + private Integer lastNotifiedArticleId; + + @Builder + public UserNotificationStatus(Integer userId, Integer lastNotifiedArticleId) { + this.userId = userId; + this.lastNotifiedArticleId = lastNotifiedArticleId; + } + + public void updateLastNotifiedArticleId(Integer articleId) { + this.lastNotifiedArticleId = articleId; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java new file mode 100644 index 000000000..29a88f5f4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.domain.community.keyword.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.community.keyword.model.UserNotificationStatus; + +public interface UserNotificationStatusRepository extends Repository { + + @Query("SELECT u.lastNotifiedArticleId FROM UserNotificationStatus u WHERE u.userId = :userId") + Optional findLastNotifiedArticleIdByUserId(Integer userId); + + Optional findByUserId(Integer userId); + + void save(UserNotificationStatus status); +} diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java index 795c47dd2..562823988 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java @@ -25,9 +25,11 @@ import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordEvent; import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordSuggestCache; +import in.koreatech.koin.domain.community.keyword.model.UserNotificationStatus; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordSuggestRepository; +import in.koreatech.koin.domain.community.keyword.repository.UserNotificationStatusRepository; import in.koreatech.koin.domain.user.repository.UserRepository; import in.koreatech.koin.global.auth.exception.AuthorizationException; import in.koreatech.koin.global.concurrent.ConcurrencyGuard; @@ -48,6 +50,7 @@ public class KeywordService { private final ArticleKeywordSuggestRepository articleKeywordSuggestRepository; private final ArticleRepository articleRepository; private final UserRepository userRepository; + private final UserNotificationStatusRepository userNotificationStatusRepository; @ConcurrencyGuard(lockName = "createKeyword", waitTime = 7, leaseTime = 5) public ArticleKeywordResponse createKeyword(Integer userId, ArticleKeywordCreateRequest request) { @@ -182,4 +185,14 @@ public void fetchTopKeywordsFromLastWeek() { articleKeywordSuggestRepository.save(hotKeyword); } } + + @Transactional + public void updateLastNotifiedArticle(Integer userId, Integer articleId) { + UserNotificationStatus status = userNotificationStatusRepository.findByUserId(userId) + .orElseGet(() -> new UserNotificationStatus(userId, articleId)); + + status.updateLastNotifiedArticleId(articleId); + + userNotificationStatusRepository.save(status); + } } diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java index 9b53b4fbe..990f17c93 100644 --- a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java +++ b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java @@ -83,14 +83,15 @@ public Notification generateDiningImageUploadNotification( public Notification generateKeywordNotification( MobileAppPath path, Integer eventKeywordId, - String keywordName, + String title, + String description, User target ) { return new Notification( path, generateSchemeUri(path, eventKeywordId), - "공지사항이 등록됐어요!", - "%s 공지가 등록되었습니다.".formatted(keywordName), + title, + description, null, NotificationType.MESSAGE, target diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/service/NotificationService.java b/src/main/java/in/koreatech/koin/global/domain/notification/service/NotificationService.java index 2b670a053..dd8230ddc 100644 --- a/src/main/java/in/koreatech/koin/global/domain/notification/service/NotificationService.java +++ b/src/main/java/in/koreatech/koin/global/domain/notification/service/NotificationService.java @@ -17,6 +17,7 @@ import in.koreatech.koin.global.domain.notification.repository.NotificationSubscribeRepository; import in.koreatech.koin.global.fcm.FcmClient; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Service @RequiredArgsConstructor diff --git a/src/main/resources/db/migration/V99__add_user_notification_status_table.sql b/src/main/resources/db/migration/V99__add_user_notification_status_table.sql new file mode 100644 index 000000000..fdefaf51b --- /dev/null +++ b/src/main/resources/db/migration/V99__add_user_notification_status_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE user_notification_status +( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + last_notified_article_id INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_id (user_id), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); From 4e8179dfaa44d2b710cdfdcdf5f473c569b869d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=9E=AC?= <103095432+seongjae6751@users.noreply.github.com> Date: Tue, 19 Nov 2024 01:38:44 +0900 Subject: [PATCH 14/14] =?UTF-8?q?fix:=20flyway=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EB=B3=80=EA=B2=BD=20(#1045)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: flyway 파일 버전 변경 * chore: flyway 파일 이름 변경 --- ...us_table.sql => V100__add_users_notification_status_table.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V99__add_user_notification_status_table.sql => V100__add_users_notification_status_table.sql} (100%) diff --git a/src/main/resources/db/migration/V99__add_user_notification_status_table.sql b/src/main/resources/db/migration/V100__add_users_notification_status_table.sql similarity index 100% rename from src/main/resources/db/migration/V99__add_user_notification_status_table.sql rename to src/main/resources/db/migration/V100__add_users_notification_status_table.sql