diff --git a/src/main/java/in/koreatech/koin/admin/benefit/controller/AdminBenefitApi.java b/src/main/java/in/koreatech/koin/admin/benefit/controller/AdminBenefitApi.java new file mode 100644 index 000000000..34a6775a1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/controller/AdminBenefitApi.java @@ -0,0 +1,133 @@ +package in.koreatech.koin.admin.benefit.controller; + +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.admin.benefit.dto.AdminBenefitCategoryResponse; +import in.koreatech.koin.admin.benefit.dto.AdminBenefitShopsResponse; +import in.koreatech.koin.admin.benefit.dto.AdminCreateBenefitCategoryRequest; +import in.koreatech.koin.admin.benefit.dto.AdminCreateBenefitCategoryResponse; +import in.koreatech.koin.admin.benefit.dto.AdminCreateBenefitShopsRequest; +import in.koreatech.koin.admin.benefit.dto.AdminCreateBenefitShopsResponse; +import in.koreatech.koin.admin.benefit.dto.AdminDeleteShopsRequest; +import in.koreatech.koin.admin.benefit.dto.AdminSearchBenefitShopsResponse; +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; + +@Tag(name = "(Admin) ShopBenefit: 상점 혜택", description = "어드민 상점 혜택 정보를 관리한다") +@RequestMapping("/admin/benefit") +public interface AdminBenefitApi { + + @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("/categories") + ResponseEntity getBenefitCategories(); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @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("/categories") + ResponseEntity createBenefitCategory( + @RequestBody AdminCreateBenefitCategoryRequest request + ); + + @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 = "혜택 카테고리를 삭제한다.") + @DeleteMapping("/categories/{id}") + ResponseEntity deleteBenefitCategory( + @PathVariable("id") Integer categoryId + ); + + @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("/{id}/shops") + ResponseEntity getBenefitShops( + @PathVariable("id") Integer benefitId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @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 = "특정 혜택을 제공하는 상점을 추가한다.") + @PostMapping("/{id}/shops") + ResponseEntity createBenefitShops( + @PathVariable("id") Integer benefitId, + @RequestBody AdminCreateBenefitShopsRequest request + ); + + @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 = "특정 혜택을 제공하는 상점을 삭제한다.") + @DeleteMapping("/{id}/shops") + ResponseEntity deleteBenefitShops( + @PathVariable("id") Integer benefitId, + @RequestBody AdminDeleteShopsRequest request + ); + + @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))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "혜택 상점을 추가하기 위해 상점을 검색한다.") + @GetMapping("/{id}/shops/search") + ResponseEntity searchShops( + @PathVariable("id") Integer benefitId, + @RequestParam("search_keyword") String searchKeyword + ); +} diff --git a/src/main/java/in/koreatech/koin/admin/benefit/controller/AdminBenefitController.java b/src/main/java/in/koreatech/koin/admin/benefit/controller/AdminBenefitController.java new file mode 100644 index 000000000..8cc654f26 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/controller/AdminBenefitController.java @@ -0,0 +1,71 @@ +package in.koreatech.koin.admin.benefit.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import in.koreatech.koin.admin.benefit.dto.*; +import in.koreatech.koin.admin.benefit.service.AdminBenefitService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/admin/benefit") +public class AdminBenefitController implements AdminBenefitApi { + + private final AdminBenefitService adminBenefitService; + + @GetMapping("/categories") + public ResponseEntity getBenefitCategories() { + AdminBenefitCategoryResponse response = adminBenefitService.getBenefitCategories(); + return ResponseEntity.ok(response); + } + + @PostMapping("/categories") + public ResponseEntity createBenefitCategory( + @RequestBody AdminCreateBenefitCategoryRequest request + ) { + AdminCreateBenefitCategoryResponse response = adminBenefitService.createBenefitCategory(request); + return ResponseEntity.status(201).body(response); + } + + @DeleteMapping("/categories/{id}") + public ResponseEntity deleteBenefitCategory( + @PathVariable("id") Integer categoryId + ) { + adminBenefitService.deleteBenefitCategory(categoryId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{id}/shops") + public ResponseEntity getBenefitShops( + @PathVariable("id") Integer benefitId) { + AdminBenefitShopsResponse response = adminBenefitService.getBenefitShops(benefitId); + return ResponseEntity.ok(response); + } + + @PostMapping("/{id}/shops") + public ResponseEntity createBenefitShops( + @PathVariable("id") Integer benefitId, + @RequestBody AdminCreateBenefitShopsRequest request + ) { + AdminCreateBenefitShopsResponse response = adminBenefitService.createBenefitShops(benefitId, request); + return ResponseEntity.status(201).body(response); + } + + @DeleteMapping("/{id}/shops") + public ResponseEntity deleteBenefitShops( + @PathVariable("id") Integer benefitId, + @RequestBody AdminDeleteShopsRequest request + ) { + adminBenefitService.deleteBenefitShops(benefitId, request); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{id}/shops/search") + public ResponseEntity searchShops( + @PathVariable("id") Integer benefitId, + @RequestParam("search_keyword") String searchKeyword + ) { + AdminSearchBenefitShopsResponse response = adminBenefitService.searchShops(benefitId, searchKeyword); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminBenefitCategoryResponse.java b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminBenefitCategoryResponse.java new file mode 100644 index 000000000..379d3019a --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminBenefitCategoryResponse.java @@ -0,0 +1,51 @@ +package in.koreatech.koin.admin.benefit.dto; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.benefit.model.BenefitCategory; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminBenefitCategoryResponse( + @Schema(description = "혜택 카테고리 리스트") + List benefits +) { + + public static AdminBenefitCategoryResponse from(List benefitCategories) { + return new AdminBenefitCategoryResponse( + benefitCategories.stream().map(InnerBenefitResponse::from).toList() + ); + } + + @JsonNaming(SnakeCaseStrategy.class) + public record InnerBenefitResponse( + @Schema(description = "혜택 카테고리 ID", example = "1") + Integer id, + + @Schema(description = "혜택 카테고리 제목", example = "배달비 아끼기") + String title, + + @Schema(description = "혜택 카테고리 설명", example = "계좌이체하면 배달비가 무료(할인)인 상점들을 모아뒀어요.") + String detail, + + @Schema(description = "혜택 카테고리 ON 이미지 URL", example = "https://example.com/button_on.jpg") + String onImageUrl, + + @Schema(description = "혜택 카테고리 OFF 이미지 URL", example = "https://example.com/button_off.jpg") + String offImageUrl + ) { + + public static InnerBenefitResponse from(BenefitCategory benefitCategory) { + return new InnerBenefitResponse( + benefitCategory.getId(), + benefitCategory.getTitle(), + benefitCategory.getDetail(), + benefitCategory.getOnImageUrl(), + benefitCategory.getOffImageUrl() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminBenefitShopsResponse.java b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminBenefitShopsResponse.java new file mode 100644 index 000000000..8ef41e87a --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminBenefitShopsResponse.java @@ -0,0 +1,43 @@ +package in.koreatech.koin.admin.benefit.dto; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.shop.Shop; +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdminBenefitShopsResponse( + @Schema(example = "3", description = "상점 개수") + Integer count, + + @Schema(description = "상점 정보") + List shops +) { + + public static AdminBenefitShopsResponse from(List shops) { + return new AdminBenefitShopsResponse( + shops.size(), + shops.stream() + .map(InnerShopResponse::from) + .toList() + ); + } + + private record InnerShopResponse( + @Schema(example = "1", description = "고유 id") + Integer id, + + @Schema(example = "수신반점", description = "이름") + String name + ) { + + public static InnerShopResponse from(Shop shop) { + return new InnerShopResponse( + shop.getId(), + shop.getName() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminCreateBenefitCategoryRequest.java b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminCreateBenefitCategoryRequest.java new file mode 100644 index 000000000..b1031ede1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminCreateBenefitCategoryRequest.java @@ -0,0 +1,39 @@ +package in.koreatech.koin.admin.benefit.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.benefit.model.BenefitCategory; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminCreateBenefitCategoryRequest( + @Schema(description = "혜택 카테고리 제목", example = "배달비 아끼기", requiredMode = REQUIRED) + @NotBlank(message = "혜택 카테고리 제목은 필수입니다.") + String title, + + @Schema(description = "혜택 카테고리 설명", example = "계좌이체하면 배달비가 무료(할인)인 상점들을 모아뒀어요.", requiredMode = REQUIRED) + @NotBlank(message = "혜택 카테고리 설명은 필수입니다.") + String detail, + + @Schema(description = "혜택 카테고리 ON 이미지 URL", example = "https://example.com/button_on.jpg", requiredMode = REQUIRED) + @NotBlank(message = "혜택 카테고리 ON 이미지 URL은 필수입니다.") + String onImageUrl, + + @Schema(description = "혜택 카테고리 OFF 이미지 URL", example = "https://example.com/button_off.jpg", requiredMode = REQUIRED) + @NotBlank(message = "혜택 카테고리 OFF 이미지 URL은 필수입니다.") + String offImageUrl +) { + + public BenefitCategory toBenefitCategory() { + return BenefitCategory.builder() + .title(title) + .detail(detail) + .onImageUrl(onImageUrl) + .offImageUrl(offImageUrl) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminCreateBenefitCategoryResponse.java b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminCreateBenefitCategoryResponse.java new file mode 100644 index 000000000..15103a1c5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminCreateBenefitCategoryResponse.java @@ -0,0 +1,36 @@ +package in.koreatech.koin.admin.benefit.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.benefit.model.BenefitCategory; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminCreateBenefitCategoryResponse( + @Schema(description = "혜택 카테고리 ID", example = "1") + Integer id, + + @Schema(description = "혜택 카테고리 제목", example = "배달비 아끼기") + String title, + + @Schema(description = "혜택 카테고리 설명", example = "계좌이체하면 배달비가 무료(할인)인 상점들을 모아뒀어요.") + String detail, + + @Schema(description = "혜택 카테고리 ON 이미지 URL", example = "https://example.com/button_on.jpg") + String onImageUrl, + + @Schema(description = "혜택 카테고리 OFF 이미지 URL", example = "https://example.com/button_off.jpg") + String offImageUrl +) { + + public static AdminCreateBenefitCategoryResponse from(BenefitCategory benefitCategory) { + return new AdminCreateBenefitCategoryResponse( + benefitCategory.getId(), + benefitCategory.getTitle(), + benefitCategory.getDetail(), + benefitCategory.getOnImageUrl(), + benefitCategory.getOffImageUrl() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminCreateBenefitShopsRequest.java b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminCreateBenefitShopsRequest.java new file mode 100644 index 000000000..35b36ae40 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminCreateBenefitShopsRequest.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.admin.benefit.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.global.validation.NotBlankElement; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminCreateBenefitShopsRequest( + @Schema(description = "상점 ID 리스트", example = "[1, 2, 5]", requiredMode = REQUIRED) + @NotNull(message = "상점 ID 리스트는 필수입니다.") + @NotBlankElement(message = "상점 ID 리스트는 빈 요소가 존재할 수 없습니다.") + List shopIds +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminCreateBenefitShopsResponse.java b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminCreateBenefitShopsResponse.java new file mode 100644 index 000000000..1f4adf7d9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminCreateBenefitShopsResponse.java @@ -0,0 +1,36 @@ +package in.koreatech.koin.admin.benefit.dto; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.shop.Shop; +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdminCreateBenefitShopsResponse( + @Schema(description = "상점 리스트") + List shops +) { + public static AdminCreateBenefitShopsResponse from(List shops) { + return new AdminCreateBenefitShopsResponse( + shops.stream().map(InnerShopResponse::from).toList() + ); + } + + private record InnerShopResponse( + @Schema(description = "상점 ID", example = "1") + Integer id, + + @Schema(description = "상점 이름", example = "수신반점") + String name + ) { + + public static InnerShopResponse from(Shop shop) { + return new InnerShopResponse( + shop.getId(), + shop.getName() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminDeleteShopsRequest.java b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminDeleteShopsRequest.java new file mode 100644 index 000000000..fbc5c2d53 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminDeleteShopsRequest.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.admin.benefit.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.global.validation.NotBlankElement; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record AdminDeleteShopsRequest( + @Schema(description = "상점 ID 리스트", example = "[1, 2, 5]", requiredMode = REQUIRED) + @NotNull(message = "상점 ID 리스트는 필수입니다.") + @NotBlankElement(message = "상점 ID 리스트는 빈 요소가 존재할 수 없습니다.") + List shopIds +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminSearchBenefitShopsResponse.java b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminSearchBenefitShopsResponse.java new file mode 100644 index 000000000..50fcdd61d --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminSearchBenefitShopsResponse.java @@ -0,0 +1,47 @@ +package in.koreatech.koin.admin.benefit.dto; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.shop.Shop; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminSearchBenefitShopsResponse( + @Schema(description = "혜택 상점 리스트") + List benefitShops, + + @Schema(description = "혜택을 제공하지 않는 상점 리스트") + List nonBenefitShops +) { + + public static AdminSearchBenefitShopsResponse from(List benefitShops, List nonBenefitShops) { + return new AdminSearchBenefitShopsResponse( + benefitShops.stream() + .map(InnerShopResponse::from) + .toList(), + nonBenefitShops.stream(). + map(InnerShopResponse::from) + .toList() + ); + } + + @JsonNaming(SnakeCaseStrategy.class) + public record InnerShopResponse( + @Schema(description = "상점 ID", example = "1") + Integer id, + + @Schema(description = "상점 이름", example = "수신반점") + String name + ) { + + public static InnerShopResponse from(Shop shop) { + return new InnerShopResponse( + shop.getId(), + shop.getName() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/benefit/exception/BenefitLimitException.java b/src/main/java/in/koreatech/koin/admin/benefit/exception/BenefitLimitException.java new file mode 100644 index 000000000..46bb06030 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/exception/BenefitLimitException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.benefit.exception; + +import in.koreatech.koin.global.exception.KoinIllegalStateException; + +public class BenefitLimitException extends KoinIllegalStateException { + + private static final String DEFAULT_MESSAGE = "혜택 카테고리의 개수가 제한을 초과하였습니다."; + + public BenefitLimitException(String message) { + super(message); + } + + public BenefitLimitException(String message, String detail) { + super(message, detail); + } + + public static BenefitLimitException withDetail(String detail) { + return new BenefitLimitException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/benefit/exception/BenefitNotFoundException.java b/src/main/java/in/koreatech/koin/admin/benefit/exception/BenefitNotFoundException.java new file mode 100644 index 000000000..91019d283 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/exception/BenefitNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.benefit.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class BenefitNotFoundException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "해당 ID의 혜택 카테고리가 존재하지 않습니다."; + + public BenefitNotFoundException(String message) { + super(message); + } + + public BenefitNotFoundException(String message, String detail) { + super(message, detail); + } + + public static BenefitNotFoundException withDetail(String detail) { + return new BenefitNotFoundException(DEFAULT_MESSAGE, detail); + } +} 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 new file mode 100644 index 000000000..9b522fa9a --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java @@ -0,0 +1,27 @@ +package in.koreatech.koin.admin.benefit.repository; + +import java.util.Collection; +import java.util.List; + +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.benefit.model.BenefitCategory; +import in.koreatech.koin.domain.benefit.model.BenefitCategoryMap; + +public interface AdminBenefitCategoryMapRepository extends Repository { + + void save(BenefitCategoryMap benefitCategoryMap); + + @Query("SELECT bcm FROM BenefitCategoryMap bcm WHERE bcm.benefitCategory.id = :benefitId") + List findAllByBenefitCategoryId(Integer benefitId); + + @Modifying + @Query("DELETE FROM BenefitCategoryMap bcm WHERE bcm.benefitCategory.id = :benefitId AND bcm.shop.id IN :shopIds") + void deleteByBenefitCategoryIdAndShopIds(Integer benefitId, List shopIds); + + @Modifying + @Query("DELETE FROM BenefitCategoryMap bcm WHERE bcm.benefitCategory.id = :categoryId") + void deleteByBenefitCategoryId(Integer categoryId); +} diff --git a/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryRepository.java b/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryRepository.java new file mode 100644 index 000000000..c4e5c5918 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryRepository.java @@ -0,0 +1,29 @@ +package in.koreatech.koin.admin.benefit.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.admin.benefit.exception.BenefitNotFoundException; +import in.koreatech.koin.domain.benefit.model.BenefitCategory; + +public interface AdminBenefitCategoryRepository extends Repository { + + BenefitCategory save(BenefitCategory benefitCategory); + + Optional findById(Integer benefitId); + + Optional findByTitle(String title); + + List findAllByOrderByTitleAsc(); + + void deleteById(Integer id); + + int count(); + + default BenefitCategory getById(Integer benefitId) { + return findById(benefitId) + .orElseThrow(() -> BenefitNotFoundException.withDetail("해당 ID의 혜택 카테고리가 존재하지 않습니다.")); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/benefit/service/AdminBenefitService.java b/src/main/java/in/koreatech/koin/admin/benefit/service/AdminBenefitService.java new file mode 100644 index 000000000..925d2cb84 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/service/AdminBenefitService.java @@ -0,0 +1,115 @@ +package in.koreatech.koin.admin.benefit.service; + +import static in.koreatech.koin.domain.benefit.model.BenefitCategory.MAX_BENEFIT_CATEGORIES; +import static in.koreatech.koin.domain.benefit.model.BenefitCategory.MIN_BENEFIT_CATEGORIES; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.admin.benefit.dto.AdminBenefitCategoryResponse; +import in.koreatech.koin.admin.benefit.dto.AdminBenefitShopsResponse; +import in.koreatech.koin.admin.benefit.dto.AdminCreateBenefitCategoryRequest; +import in.koreatech.koin.admin.benefit.dto.AdminCreateBenefitCategoryResponse; +import in.koreatech.koin.admin.benefit.dto.AdminCreateBenefitShopsRequest; +import in.koreatech.koin.admin.benefit.dto.AdminCreateBenefitShopsResponse; +import in.koreatech.koin.admin.benefit.dto.AdminDeleteShopsRequest; +import in.koreatech.koin.admin.benefit.dto.AdminSearchBenefitShopsResponse; +import in.koreatech.koin.admin.benefit.exception.BenefitLimitException; +import in.koreatech.koin.admin.benefit.repository.AdminBenefitCategoryMapRepository; +import in.koreatech.koin.admin.benefit.repository.AdminBenefitCategoryRepository; +import in.koreatech.koin.admin.shop.repository.AdminShopRepository; +import in.koreatech.koin.domain.benefit.model.BenefitCategory; +import in.koreatech.koin.domain.benefit.model.BenefitCategoryMap; +import in.koreatech.koin.domain.shop.model.shop.Shop; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AdminBenefitService { + + private final AdminBenefitCategoryRepository adminBenefitCategoryRepository; + private final AdminBenefitCategoryMapRepository adminBenefitCategoryMapRepository; + private final AdminShopRepository adminShopRepository; + + public AdminBenefitCategoryResponse getBenefitCategories() { + List categories = adminBenefitCategoryRepository.findAllByOrderByTitleAsc(); + return AdminBenefitCategoryResponse.from(categories); + } + + @Transactional + public AdminCreateBenefitCategoryResponse createBenefitCategory(AdminCreateBenefitCategoryRequest request) { + int currentCategoryCount = adminBenefitCategoryRepository.count(); + if (currentCategoryCount >= MAX_BENEFIT_CATEGORIES) { + throw BenefitLimitException.withDetail("혜택 카테고리는 반드시 " + MAX_BENEFIT_CATEGORIES + "개 이하이어야 합니다."); + } + boolean isExistCategory = adminBenefitCategoryRepository.findByTitle(request.title()).isPresent(); + if (isExistCategory) { + throw BenefitLimitException.withDetail("이미 존재하는 혜택 카테고리입니다."); + } + BenefitCategory benefitCategory = request.toBenefitCategory(); + BenefitCategory savedBenefitCategory = adminBenefitCategoryRepository.save(benefitCategory); + return AdminCreateBenefitCategoryResponse.from(savedBenefitCategory); + } + + @Transactional + public void deleteBenefitCategory(Integer categoryId) { + int currentCategoryCount = adminBenefitCategoryRepository.count(); + if (currentCategoryCount <= MIN_BENEFIT_CATEGORIES) { + throw BenefitLimitException.withDetail("혜택 카테고리는 반드시 " + MIN_BENEFIT_CATEGORIES + "개 이상이어야 합니다."); + } + adminBenefitCategoryMapRepository.deleteByBenefitCategoryId(categoryId); + adminBenefitCategoryRepository.deleteById(categoryId); + } + + public AdminBenefitShopsResponse getBenefitShops(Integer benefitId) { + List benefitCategoryMaps = adminBenefitCategoryMapRepository.findAllByBenefitCategoryId( + benefitId + ); + List shops = benefitCategoryMaps.stream() + .map(BenefitCategoryMap::getShop) + .toList(); + return AdminBenefitShopsResponse.from(shops); + } + + @Transactional + public AdminCreateBenefitShopsResponse createBenefitShops( + Integer benefitId, + AdminCreateBenefitShopsRequest request + ) { + List shops = adminShopRepository.findAllByIdIn(request.shopIds()); + BenefitCategory benefitCategory = adminBenefitCategoryRepository.getById(benefitId); + for (Shop shop : shops) { + BenefitCategoryMap benefitCategoryMap = BenefitCategoryMap.builder() + .shop(shop) + .benefitCategory(benefitCategory) + .build(); + adminBenefitCategoryMapRepository.save(benefitCategoryMap); + } + return AdminCreateBenefitShopsResponse.from(shops); + } + + @Transactional + public void deleteBenefitShops(Integer benefitId, AdminDeleteShopsRequest request) { + adminBenefitCategoryMapRepository.deleteByBenefitCategoryIdAndShopIds(benefitId, request.shopIds()); + } + + public AdminSearchBenefitShopsResponse searchShops(Integer benefitId, String searchKeyword) { + List shops = adminShopRepository.searchByName(searchKeyword); + Set benefitShopIds = adminBenefitCategoryMapRepository.findAllByBenefitCategoryId(benefitId).stream() + .map(benefitCategoryMap -> benefitCategoryMap.getShop().getId()) + .collect(Collectors.toSet()); + List benefitShops = shops.stream() + .filter(shop -> benefitShopIds.contains(shop.getId())) + .toList(); + List nonBenefitShops = shops.stream() + .filter(shop -> !benefitShopIds.contains(shop.getId())) + .toList(); + return AdminSearchBenefitShopsResponse.from(benefitShops, nonBenefitShops); + } +} + diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopRepository.java index 83fe2198b..c0e74bc3d 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopRepository.java +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopRepository.java @@ -41,7 +41,12 @@ default Shop getById(Integer shopId) { @Query(value = "SELECT * FROM shops WHERE id = :shopId AND is_deleted = true", nativeQuery = true) Optional findDeletedShopById(@Param("shopId") Integer shopId); + List findAllByIdIn(List shopIds); + @Modifying @Query(value = "UPDATE shops SET is_deleted = true WHERE id = :shopId", nativeQuery = true) int deleteById(@Param("shopId") Integer shopId); + + @Query("SELECT s FROM Shop s WHERE s.name LIKE :searchKeyword%") + List searchByName(String searchKeyword); } diff --git a/src/main/java/in/koreatech/koin/domain/benefit/dto/BenefitCategoryResponse.java b/src/main/java/in/koreatech/koin/domain/benefit/dto/BenefitCategoryResponse.java index bf0f6fe3e..ef6b99024 100644 --- a/src/main/java/in/koreatech/koin/domain/benefit/dto/BenefitCategoryResponse.java +++ b/src/main/java/in/koreatech/koin/domain/benefit/dto/BenefitCategoryResponse.java @@ -4,6 +4,10 @@ import java.util.List; +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.benefit.model.BenefitCategory; import io.swagger.v3.oas.annotations.media.Schema; @@ -18,6 +22,7 @@ public static BenefitCategoryResponse from(List benefitCategori ); } + @JsonNaming(SnakeCaseStrategy.class) public record InnerBenefitResponse( @Schema(description = "혜택 id", example = "1", requiredMode = NOT_REQUIRED) Integer id, @@ -26,14 +31,22 @@ public record InnerBenefitResponse( String title, @Schema(description = "혜택 카테고리 설명", example = "계좌이체하면 배달비가 무료(할인)인 상점들만 모아뒀어요.", requiredMode = NOT_REQUIRED) - String detail + String detail, + + @Schema(description = "혜택 카테고리 ON 이미지 URL", example = "https://example.com/button_on.jpg") + String onImageUrl, + + @Schema(description = "혜택 카테고리 OFF 이미지 URL", example = "https://example.com/button_off.jpg") + String offImageUrl ) { public static InnerBenefitResponse from(BenefitCategory benefitCategory) { return new InnerBenefitResponse( benefitCategory.getId(), benefitCategory.getTitle(), - benefitCategory.getDetail() + benefitCategory.getDetail(), + benefitCategory.getOnImageUrl(), + benefitCategory.getOffImageUrl() ); } } diff --git a/src/main/java/in/koreatech/koin/domain/benefit/dto/BenefitShopsResponse.java b/src/main/java/in/koreatech/koin/domain/benefit/dto/BenefitShopsResponse.java index 030ad029a..477fdc100 100644 --- a/src/main/java/in/koreatech/koin/domain/benefit/dto/BenefitShopsResponse.java +++ b/src/main/java/in/koreatech/koin/domain/benefit/dto/BenefitShopsResponse.java @@ -31,6 +31,7 @@ public static BenefitShopsResponse from( return new BenefitShopsResponse(shops.size(), shops); } + @JsonNaming(SnakeCaseStrategy.class) public record InnerShopResponse( @Schema(example = "[1, 2, 3]", description = "속해있는 상점 카테고리들의 고유 id 리스트", requiredMode = NOT_REQUIRED) List categoryIds, diff --git a/src/main/java/in/koreatech/koin/domain/benefit/model/BenefitCategory.java b/src/main/java/in/koreatech/koin/domain/benefit/model/BenefitCategory.java index d17217d43..607911ba3 100644 --- a/src/main/java/in/koreatech/koin/domain/benefit/model/BenefitCategory.java +++ b/src/main/java/in/koreatech/koin/domain/benefit/model/BenefitCategory.java @@ -32,9 +32,20 @@ public class BenefitCategory extends BaseEntity { @Column(name = "detail") String detail; + @Column(name = "on_image_url") + String onImageUrl; + + @Column(name = "off_image_url") + String offImageUrl; + + public static final int MAX_BENEFIT_CATEGORIES = 6; + public static final int MIN_BENEFIT_CATEGORIES = 2; + @Builder - public BenefitCategory(String title, String detail) { + public BenefitCategory(String title, String detail, String onImageUrl, String offImageUrl) { this.title = title; this.detail = detail; + this.onImageUrl = onImageUrl; + this.offImageUrl = offImageUrl; } } diff --git a/src/main/resources/db/migration/V64__alter_benefit_category_on_off_image_url.sql b/src/main/resources/db/migration/V64__alter_benefit_category_on_off_image_url.sql new file mode 100644 index 000000000..d43996f12 --- /dev/null +++ b/src/main/resources/db/migration/V64__alter_benefit_category_on_off_image_url.sql @@ -0,0 +1,27 @@ +ALTER TABLE shop_benefit_categories + ADD COLUMN on_image_url VARCHAR(255) NULL COMMENT '혜택 카테고리 on 이미지 url', + ADD COLUMN off_image_url VARCHAR(255) NULL COMMENT '혜택 카테고리 off 이미지 url'; + +UPDATE shop_benefit_categories +SET on_image_url = 'https://stage-static.koreatech.in/shop/benefit/deliveryOn.png', + off_image_url = 'https://stage-static.koreatech.in/shop/benefit/deliveryOff.png' +WHERE title = '배달비 아끼기'; + +UPDATE shop_benefit_categories +SET on_image_url = 'https://stage-static.koreatech.in/shop/benefit/paidOn.png', + off_image_url = 'https://stage-static.koreatech.in/shop/benefit/paidOff.png' +WHERE title = '최소주문금액 무료'; + +UPDATE shop_benefit_categories +SET on_image_url = 'https://stage-static.koreatech.in/shop/benefit/serviceOn.png', + off_image_url = 'https://stage-static.koreatech.in/shop/benefit/serviceOff.png' +WHERE title = '서비스 증정'; + +UPDATE shop_benefit_categories +SET on_image_url = 'https://stage-static.koreatech.in/shop/benefit/pickUpOn.png', + off_image_url = 'https://stage-static.koreatech.in/shop/benefit/pickUpOff.png' +WHERE title = '가게까지 픽업'; + +ALTER TABLE shop_benefit_categories + MODIFY COLUMN on_image_url VARCHAR (255) NOT NULL, + MODIFY COLUMN off_image_url VARCHAR (255) NOT NULL; diff --git a/src/test/java/in/koreatech/koin/acceptance/BenefitApiTest.java b/src/test/java/in/koreatech/koin/acceptance/BenefitApiTest.java index 8edda5d30..b0a678231 100644 --- a/src/test/java/in/koreatech/koin/acceptance/BenefitApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/BenefitApiTest.java @@ -88,31 +88,39 @@ void setup() { ) .andExpect(status().isOk()) .andExpect(content().json(""" + { + "benefits": [ { - "benefits": [ - { - "id": 1, - "title": "배달비 아끼기", - "detail": "계좌이체하면 배달비가 무료(할인)인 상점들을 모아뒀어요." - }, - { - "id": 2, - "title": "최소주문금액 무료", - "detail": "계좌이체하면 최소주문금액이 무료인 상점들을 모아뒀어요." - }, - { - "id": 3, - "title": "서비스 증정", - "detail": "계좌이체하면 서비스를 주는 상점들을 모아뒀어요." - }, - { - "id": 4, - "title": "가게까지 픽업", - "detail": "사장님께서 직접 가게까지 데려다주시는 상점들을 모아뒀어요." - } - ] + "id": 1, + "title": "배달비 아끼기", + "detail": "계좌이체하면 배달비가 무료(할인)인 상점들을 모아뒀어요.", + "on_image_url": "https://stage-static.koreatech.in/shop/benefit/deliveryOn.png", + "off_image_url": "https://stage-static.koreatech.in/shop/benefit/deliveryOff.png" + }, + { + "id": 2, + "title": "최소주문금액 무료", + "detail": "계좌이체하면 최소주문금액이 무료인 상점들을 모아뒀어요.", + "on_image_url": "https://stage-static.koreatech.in/shop/benefit/paidOn.png", + "off_image_url": "https://stage-static.koreatech.in/shop/benefit/paidOff.png" + }, + { + "id": 3, + "title": "서비스 증정", + "detail": "계좌이체하면 서비스를 주는 상점들을 모아뒀어요.", + "on_image_url": "https://stage-static.koreatech.in/shop/benefit/serviceOn.png", + "off_image_url": "https://stage-static.koreatech.in/shop/benefit/serviceOff.png" + }, + { + "id": 4, + "title": "가게까지 픽업", + "detail": "사장님께서 직접 가게까지 데려다주시는 상점들을 모아뒀어요.", + "on_image_url": "https://stage-static.koreatech.in/shop/benefit/pickUpOn.png", + "off_image_url": "https://stage-static.koreatech.in/shop/benefit/pickUpOff.png" } - """)); + ] + } + """)); } @Test @@ -122,180 +130,180 @@ void setup() { ) .andExpect(status().isOk()) .andExpect(content().json(String.format(""" + { + "count": 4, + "shops": [ + { + "category_ids": [], + "delivery": true, + "id": %d, + "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": true, + "average_rate": 5.0, + "review_count": 1 + }, + { + "category_ids": [], + "delivery": true, + "id": %d, + "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": true, + "average_rate": 4.0, + "review_count": 1 + }, { - "count": 4, - "shops": [ + "category_ids": [], + "delivery": true, + "id": %d, + "name": "티바", + "open": [ { - "categoryIds": [], - "delivery": true, - "id": %d, - "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" - } - ], - "payBank": true, - "payCard": true, - "phone": "010-7574-1212", - "isEvent": false, - "isOpen": true, - "averageRate": 5.0, - "reviewCount": 1 + "day_of_week": "SUNDAY", + "closed": false, + "open_time": "00:00", + "close_time": "00:00" }, { - "categoryIds": [], - "delivery": true, - "id": %d, - "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" - } - ], - "payBank": true, - "payCard": true, - "phone": "010-7574-1212", - "isEvent": false, - "isOpen": true, - "averageRate": 4.0, - "reviewCount": 1 + "day_of_week": "MONDAY", + "closed": false, + "open_time": "11:30", + "close_time": "21:30" }, { - "categoryIds": [], - "delivery": true, - "id": %d, - "name": "티바", - "open": [ - { - "day_of_week": "SUNDAY", - "closed": false, - "open_time": "00:00", - "close_time": "00:00" - }, - { - "day_of_week": "MONDAY", - "closed": false, - "open_time": "11: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" - } - ], - "payBank": true, - "payCard": true, - "phone": "010-7788-9900", - "isEvent": false, - "isOpen": true, - "averageRate": 4.0, - "reviewCount": 1 + "day_of_week": "TUESDAY", + "closed": false, + "open_time": "11:30", + "close_time": "21:30" }, { - "categoryIds": [], - "delivery": true, - "id": %d, - "name": "신전 떡볶이", - "open": [ - { - "day_of_week": "SUNDAY", - "closed": false, - "open_time": "00:00", - "close_time": "00:00" - }, - { - "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" - } - ], - "payBank": true, - "payCard": true, - "phone": "010-7788-9900", - "isEvent": false, - "isOpen": false, - "averageRate": 5.0, - "reviewCount": 1 + "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" + } + ], + "pay_bank": true, + "pay_card": true, + "phone": "010-7788-9900", + "is_event": false, + "is_open": true, + "average_rate": 4.0, + "review_count": 1 + }, + { + "category_ids": [], + "delivery": true, + "id": %d, + "name": "신전 떡볶이", + "open": [ + { + "day_of_week": "SUNDAY", + "closed": false, + "open_time": "00:00", + "close_time": "00:00" + }, + { + "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" } - ] + ], + "pay_bank": true, + "pay_card": true, + "phone": "010-7788-9900", + "is_event": false, + "is_open": false, + "average_rate": 5.0, + "review_count": 1 } - """, + ] + } + """, 김밥천국.getId(), 마슬랜.getId(), 영업중인_티바.getId(), diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminBenefitApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminBenefitApiTest.java new file mode 100644 index 000000000..ccf3f5251 --- /dev/null +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminBenefitApiTest.java @@ -0,0 +1,276 @@ +package in.koreatech.koin.admin.acceptance; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; + +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.http.MediaType; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.admin.benefit.repository.AdminBenefitCategoryMapRepository; +import in.koreatech.koin.admin.benefit.repository.AdminBenefitCategoryRepository; +import in.koreatech.koin.domain.benefit.model.BenefitCategory; +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.user.model.User; +import in.koreatech.koin.fixture.BenefitCategoryFixture; +import in.koreatech.koin.fixture.BenefitCategoryMapFixture; +import in.koreatech.koin.fixture.ShopFixture; +import in.koreatech.koin.fixture.UserFixture; + +@Transactional +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class AdminBenefitApiTest extends AcceptanceTest { + + @Autowired + AdminBenefitCategoryRepository adminBenefitCategoryRepository; + + @Autowired + AdminBenefitCategoryMapRepository adminBenefitCategoryMapRepository; + + @Autowired + BenefitCategoryFixture benefitCategoryFixture; + + @Autowired + BenefitCategoryMapFixture benefitCategoryMapFixture; + + @Autowired + ShopFixture shopFixture; + + @Autowired + UserFixture userFixture; + + User admin; + String token_admin; + Owner 현수_사장님; + + BenefitCategory 배달비_무료; + BenefitCategory 최소주문금액_무료; + BenefitCategory 서비스_증정; + BenefitCategory 가게까지_픽업; + + Shop 마슬랜; + Shop 김밥천국; + Shop 영업중인_티바; + Shop 영업중이_아닌_신전_떡볶이; + + @BeforeAll + void setup() { + clear(); + admin = userFixture.코인_운영자(); + token_admin = userFixture.getToken(admin); + + 배달비_무료 = benefitCategoryFixture.배달비_무료(); + 최소주문금액_무료 = benefitCategoryFixture.최소주문금액_무료(); + 서비스_증정 = benefitCategoryFixture.서비스_증정(); + 가게까지_픽업 = benefitCategoryFixture.가게까지_픽업(); + + 마슬랜 = shopFixture.마슬랜(현수_사장님); + 김밥천국 = shopFixture.김밥천국(현수_사장님); + 영업중인_티바 = shopFixture.영업중인_티바(현수_사장님); + 영업중이_아닌_신전_떡볶이 = shopFixture.영업중이_아닌_신전_떡볶이(현수_사장님); + + benefitCategoryMapFixture.혜택_추가(김밥천국, 배달비_무료); + benefitCategoryMapFixture.혜택_추가(마슬랜, 배달비_무료); + benefitCategoryMapFixture.혜택_추가(영업중인_티바, 배달비_무료); + benefitCategoryMapFixture.혜택_추가(영업중이_아닌_신전_떡볶이, 배달비_무료); + } + + @Test + void 혜택_카테고리를_조회한다() throws Exception { + mockMvc.perform( + get("/admin/benefit/categories") + .header("Authorization", "Bearer " + token_admin) + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" + { + "benefits": [ + { + "id": 1, + "title": "배달비 아끼기", + "detail": "계좌이체하면 배달비가 무료(할인)인 상점들을 모아뒀어요.", + "on_image_url": "https://stage-static.koreatech.in/shop/benefit/deliveryOn.png", + "off_image_url": "https://stage-static.koreatech.in/shop/benefit/deliveryOff.png" + }, + { + "id": 2, + "title": "최소주문금액 무료", + "detail": "계좌이체하면 최소주문금액이 무료인 상점들을 모아뒀어요.", + "on_image_url": "https://stage-static.koreatech.in/shop/benefit/paidOn.png", + "off_image_url": "https://stage-static.koreatech.in/shop/benefit/paidOff.png" + }, + { + "id": 3, + "title": "서비스 증정", + "detail": "계좌이체하면 서비스를 주는 상점들을 모아뒀어요.", + "on_image_url": "https://stage-static.koreatech.in/shop/benefit/serviceOn.png", + "off_image_url": "https://stage-static.koreatech.in/shop/benefit/serviceOff.png" + }, + { + "id": 4, + "title": "가게까지 픽업", + "detail": "사장님께서 직접 가게까지 데려다주시는 상점들을 모아뒀어요.", + "on_image_url": "https://stage-static.koreatech.in/shop/benefit/pickUpOn.png", + "off_image_url": "https://stage-static.koreatech.in/shop/benefit/pickUpOff.png" + } + ] + } + """)); + } + + @Test + void 혜택_카테고리를_추가한다() throws Exception { + mockMvc.perform( + post("/admin/benefit/categories") + .header("Authorization", "Bearer " + token_admin) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "엄청난 혜택", + "detail": "밥이 무료인 상점들을 모아뒀어요.", + "on_image_url": "https://example.com/exampleOn.jpg", + "off_image_url": "https://example.com/exampleOff.jpg" + } + """) + ) + .andExpect(status().isCreated()) + .andExpect(content().json(""" + { + "id": 5, + "title": "엄청난 혜택", + "detail": "밥이 무료인 상점들을 모아뒀어요.", + "on_image_url": "https://example.com/exampleOn.jpg", + "off_image_url": "https://example.com/exampleOff.jpg" + } + """)); + } + + @Test + void 혜택_카테고리를_삭제한다() throws Exception { + System.out.println(배달비_무료.getId()); + mockMvc.perform( + delete("/admin/benefit/categories/{id}", 배달비_무료.getId()) + .header("Authorization", "Bearer " + token_admin) + ) + .andExpect(status().isNoContent()); + + assertThat(adminBenefitCategoryRepository.findById(배달비_무료.getId())).isNotPresent(); + assertThat(adminBenefitCategoryMapRepository.findAllByBenefitCategoryId(배달비_무료.getId())).isEmpty(); + } + + @Test + void 특정_혜택을_제공하는_모든_상점을_조회한다() throws Exception { + mockMvc.perform( + get("/admin/benefit/{id}/shops", 배달비_무료.getId()) + .header("Authorization", "Bearer " + token_admin) + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" + { + "count": 4, + "shops": [ + { + "id": %d, + "name": "김밥천국" + }, + { + "id": %d, + "name": "마슬랜 치킨" + }, + { + "id": %d, + "name": "티바" + }, + { + "id": %d, + "name": "신전 떡볶이" + } + ] + } + """, 김밥천국.getId(), 마슬랜.getId(), 영업중인_티바.getId(), 영업중이_아닌_신전_떡볶이.getId()))); + } + + @Test + void 특정_혜택을_제공하는_상점들을_추가한다() throws Exception { + mockMvc.perform( + post("/admin/benefit/{id}/shops", 최소주문금액_무료.getId()) + .header("Authorization", "Bearer " + token_admin) + .contentType(MediaType.APPLICATION_JSON) + .content(String.format(""" + { + "shop_ids": [%d, %d] + } + """, 김밥천국.getId(), 마슬랜.getId())) + ) + .andExpect(status().isCreated()) + .andExpect(content().json(String.format(""" + { + "shops": [ + { + "id": %d, + "name": "김밥천국" + }, + { + "id": %d, + "name": "마슬랜 치킨" + } + ] + } + """, 김밥천국.getId(), 마슬랜.getId()))); + } + + @Test + void 특정_혜택을_제공하는_상점들을_삭제한다() throws Exception { + mockMvc.perform( + delete("/admin/benefit/{id}/shops", 배달비_무료.getId()) + .header("Authorization", "Bearer " + token_admin) + .contentType(MediaType.APPLICATION_JSON) + .content(String.format(""" + { + "shop_ids": [%d, %d] + } + """, 김밥천국.getId(), 마슬랜.getId())) + ) + .andExpect(status().isNoContent()); + + List shops = adminBenefitCategoryMapRepository.findAllByBenefitCategoryId(배달비_무료.getId()); + + assertThat(shops) + .extracting("shop.id") + .containsExactlyInAnyOrder( + 영업중인_티바.getId(), + 영업중이_아닌_신전_떡볶이.getId() + ); + } + + @Test + void 상점을_검색한다() throws Exception { + mockMvc.perform( + get("/admin/benefit/{id}/shops/search", 배달비_무료.getId()) + .header("Authorization", "Bearer " + token_admin) + .param("search_keyword", "김밥") + ) + .andExpect(status().isOk()) + .andExpect(content().json(String.format(""" + { + "benefit_shops": [ + { + "id": %d, + "name": "김밥천국" + } + ], + "non_benefit_shops": [] + } + """, 김밥천국.getId()))); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/BenefitCategoryFixture.java b/src/test/java/in/koreatech/koin/fixture/BenefitCategoryFixture.java index 190dcc129..5ebb432a3 100644 --- a/src/test/java/in/koreatech/koin/fixture/BenefitCategoryFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/BenefitCategoryFixture.java @@ -24,6 +24,8 @@ public BenefitCategoryFixture( return benefitCategoryRepository.save(BenefitCategory.builder() .title("배달비 아끼기") .detail("계좌이체하면 배달비가 무료(할인)인 상점들을 모아뒀어요.") + .onImageUrl("https://stage-static.koreatech.in/shop/benefit/deliveryOn.png") + .offImageUrl("https://stage-static.koreatech.in/shop/benefit/deliveryOff.png") .build() ); } @@ -32,6 +34,8 @@ public BenefitCategoryFixture( return benefitCategoryRepository.save(BenefitCategory.builder() .title("최소주문금액 무료") .detail("계좌이체하면 최소주문금액이 무료인 상점들을 모아뒀어요.") + .onImageUrl("https://stage-static.koreatech.in/shop/benefit/paidOn.png") + .offImageUrl("https://stage-static.koreatech.in/shop/benefit/paidOff.png") .build() ); } @@ -40,6 +44,8 @@ public BenefitCategoryFixture( return benefitCategoryRepository.save(BenefitCategory.builder() .title("서비스 증정") .detail("계좌이체하면 서비스를 주는 상점들을 모아뒀어요.") + .onImageUrl("https://stage-static.koreatech.in/shop/benefit/serviceOn.png") + .offImageUrl("https://stage-static.koreatech.in/shop/benefit/serviceOff.png") .build() ); } @@ -48,6 +54,8 @@ public BenefitCategoryFixture( return benefitCategoryRepository.save(BenefitCategory.builder() .title("가게까지 픽업") .detail("사장님께서 직접 가게까지 데려다주시는 상점들을 모아뒀어요.") + .onImageUrl("https://stage-static.koreatech.in/shop/benefit/pickUpOn.png") + .offImageUrl("https://stage-static.koreatech.in/shop/benefit/pickUpOff.png") .build() ); }