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 index 510a696eb..094ec84ef 100644 --- a/src/main/java/in/koreatech/koin/admin/benefit/controller/AdminBenefitApi.java +++ b/src/main/java/in/koreatech/koin/admin/benefit/controller/AdminBenefitApi.java @@ -12,7 +12,7 @@ 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.AdminBenefitCategoriesResponse; import in.koreatech.koin.admin.benefit.dto.AdminBenefitShopsResponse; import in.koreatech.koin.admin.benefit.dto.AdminCreateBenefitCategoryRequest; import in.koreatech.koin.admin.benefit.dto.AdminCreateBenefitCategoryResponse; @@ -21,6 +21,7 @@ import in.koreatech.koin.admin.benefit.dto.AdminDeleteShopsRequest; import in.koreatech.koin.admin.benefit.dto.AdminModifyBenefitCategoryRequest; import in.koreatech.koin.admin.benefit.dto.AdminModifyBenefitCategoryResponse; +import in.koreatech.koin.admin.benefit.dto.AdminModifyBenefitShopsRequest; import in.koreatech.koin.admin.benefit.dto.AdminSearchBenefitShopsResponse; import in.koreatech.koin.global.auth.Auth; import io.swagger.v3.oas.annotations.Operation; @@ -44,7 +45,7 @@ public interface AdminBenefitApi { ) @Operation(summary = "상점 혜택 카테고리를 모두 조회한다.") @GetMapping("/categories") - ResponseEntity getBenefitCategories( + ResponseEntity getBenefitCategories( @Auth(permit = {ADMIN}) Integer adminId ); @@ -127,6 +128,22 @@ ResponseEntity createBenefitShops( @Auth(permit = {ADMIN}) Integer adminId ); + @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 = "특정 혜택을 제공하는 상점을 수정한다.") + @PutMapping + ResponseEntity modifyBenefitShops( + @RequestBody AdminModifyBenefitShopsRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "204"), 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 index d5fb072d4..76577af7f 100644 --- a/src/main/java/in/koreatech/koin/admin/benefit/controller/AdminBenefitController.java +++ b/src/main/java/in/koreatech/koin/admin/benefit/controller/AdminBenefitController.java @@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import in.koreatech.koin.admin.benefit.dto.AdminBenefitCategoryResponse; +import in.koreatech.koin.admin.benefit.dto.AdminBenefitCategoriesResponse; import in.koreatech.koin.admin.benefit.dto.AdminBenefitShopsResponse; import in.koreatech.koin.admin.benefit.dto.AdminCreateBenefitCategoryRequest; import in.koreatech.koin.admin.benefit.dto.AdminCreateBenefitCategoryResponse; @@ -23,6 +23,7 @@ import in.koreatech.koin.admin.benefit.dto.AdminDeleteShopsRequest; import in.koreatech.koin.admin.benefit.dto.AdminModifyBenefitCategoryRequest; import in.koreatech.koin.admin.benefit.dto.AdminModifyBenefitCategoryResponse; +import in.koreatech.koin.admin.benefit.dto.AdminModifyBenefitShopsRequest; import in.koreatech.koin.admin.benefit.dto.AdminSearchBenefitShopsResponse; import in.koreatech.koin.admin.benefit.service.AdminBenefitService; import in.koreatech.koin.global.auth.Auth; @@ -36,10 +37,10 @@ public class AdminBenefitController implements AdminBenefitApi { private final AdminBenefitService adminBenefitService; @GetMapping("/categories") - public ResponseEntity getBenefitCategories( + public ResponseEntity getBenefitCategories( @Auth(permit = {ADMIN}) Integer adminId ) { - AdminBenefitCategoryResponse response = adminBenefitService.getBenefitCategories(); + AdminBenefitCategoriesResponse response = adminBenefitService.getBenefitCategories(); return ResponseEntity.ok(response); } @@ -90,6 +91,15 @@ public ResponseEntity createBenefitShops( return ResponseEntity.status(HttpStatus.CREATED).body(response); } + @PutMapping + public ResponseEntity modifyBenefitShops( + @RequestBody AdminModifyBenefitShopsRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminBenefitService.modifyBenefitShops(request); + return ResponseEntity.ok().build(); + } + @DeleteMapping("/{id}/shops") public ResponseEntity deleteBenefitShops( @PathVariable("id") Integer benefitId, diff --git a/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminBenefitCategoryResponse.java b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminBenefitCategoriesResponse.java similarity index 89% rename from src/main/java/in/koreatech/koin/admin/benefit/dto/AdminBenefitCategoryResponse.java rename to src/main/java/in/koreatech/koin/admin/benefit/dto/AdminBenefitCategoriesResponse.java index 379d3019a..ce74fc882 100644 --- a/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminBenefitCategoryResponse.java +++ b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminBenefitCategoriesResponse.java @@ -9,13 +9,13 @@ import io.swagger.v3.oas.annotations.media.Schema; @JsonNaming(SnakeCaseStrategy.class) -public record AdminBenefitCategoryResponse( +public record AdminBenefitCategoriesResponse( @Schema(description = "혜택 카테고리 리스트") List benefits ) { - public static AdminBenefitCategoryResponse from(List benefitCategories) { - return new AdminBenefitCategoryResponse( + public static AdminBenefitCategoriesResponse from(List benefitCategories) { + return new AdminBenefitCategoriesResponse( benefitCategories.stream().map(InnerBenefitResponse::from).toList() ); } 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 index 8ef41e87a..67779287f 100644 --- a/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminBenefitShopsResponse.java +++ b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminBenefitShopsResponse.java @@ -5,9 +5,11 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import in.koreatech.koin.domain.benefit.model.BenefitCategoryMap; import in.koreatech.koin.domain.shop.model.shop.Shop; import io.swagger.v3.oas.annotations.media.Schema; +@JsonNaming(SnakeCaseStrategy.class) public record AdminBenefitShopsResponse( @Schema(example = "3", description = "상점 개수") Integer count, @@ -16,27 +18,36 @@ public record AdminBenefitShopsResponse( List shops ) { - public static AdminBenefitShopsResponse from(List shops) { + public static AdminBenefitShopsResponse from(List benefitCategoryMaps) { return new AdminBenefitShopsResponse( - shops.size(), - shops.stream() + benefitCategoryMaps.size(), + benefitCategoryMaps.stream() .map(InnerShopResponse::from) .toList() ); } + @JsonNaming(SnakeCaseStrategy.class) private record InnerShopResponse( + @Schema(example = "1", description = "상점혜택 매핑id") + Integer shopBenefitMapId, + @Schema(example = "1", description = "고유 id") Integer id, @Schema(example = "수신반점", description = "이름") - String name + String name, + + @Schema(example = "4인 이상 픽업서비스", description = "혜택 미리보기 문구") + String detail ) { - public static InnerShopResponse from(Shop shop) { + public static InnerShopResponse from(BenefitCategoryMap benefitCategoryMap) { return new InnerShopResponse( - shop.getId(), - shop.getName() + benefitCategoryMap.getId(), + benefitCategoryMap.getShop().getId(), + benefitCategoryMap.getShop().getName(), + benefitCategoryMap.getDetail() ); } } 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 index 35b36ae40..b2a11f935 100644 --- a/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminCreateBenefitShopsRequest.java +++ b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminCreateBenefitShopsRequest.java @@ -9,14 +9,27 @@ import in.koreatech.koin.global.validation.NotBlankElement; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; @JsonNaming(SnakeCaseStrategy.class) public record AdminCreateBenefitShopsRequest( - @Schema(description = "상점 ID 리스트", example = "[1, 2, 5]", requiredMode = REQUIRED) - @NotNull(message = "상점 ID 리스트는 필수입니다.") - @NotBlankElement(message = "상점 ID 리스트는 빈 요소가 존재할 수 없습니다.") - List shopIds + @NotNull(message = "상점정보 리스트는 필수입니다.") + @NotBlankElement(message = "상점정보 리스트는 빈 요소가 존재할 수 없습니다.") + List shopDetails ) { + @JsonNaming(SnakeCaseStrategy.class) + public record InnerBenefitShopsRequest( + @Schema(description = "상점 고유 id", example = "2", requiredMode = REQUIRED) + @NotNull(message = "상점은 필수입니다.") + Integer shopId, + + @Schema(description = "혜택 미리보기 문구", example = "4인 이상 픽업서비스", requiredMode = REQUIRED) + @NotBlank(message = "혜택 미리보기 문구는 필수입니다.") + @Size(min = 2, max = 15, message = "혜택 미리보기 문구는 최소 2자 최대 15자입니다.") + String detail + ) { + } } 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 index 1f4adf7d9..246d431d1 100644 --- a/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminCreateBenefitShopsResponse.java +++ b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminCreateBenefitShopsResponse.java @@ -5,16 +5,19 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import in.koreatech.koin.domain.benefit.model.BenefitCategoryMap; import in.koreatech.koin.domain.shop.model.shop.Shop; import io.swagger.v3.oas.annotations.media.Schema; public record AdminCreateBenefitShopsResponse( - @Schema(description = "상점 리스트") + @Schema(description = "상점 정보") List shops ) { - public static AdminCreateBenefitShopsResponse from(List shops) { + public static AdminCreateBenefitShopsResponse from(List benefitCategoryMaps) { return new AdminCreateBenefitShopsResponse( - shops.stream().map(InnerShopResponse::from).toList() + benefitCategoryMaps.stream() + .map(InnerShopResponse::from) + .toList() ); } @@ -23,13 +26,17 @@ private record InnerShopResponse( Integer id, @Schema(description = "상점 이름", example = "수신반점") - String name + String name, + + @Schema(example = "4인 이상 픽업서비스", description = "혜택 미리보기 문구") + String detail ) { - public static InnerShopResponse from(Shop shop) { + public static InnerShopResponse from(BenefitCategoryMap benefitCategoryMap) { return new InnerShopResponse( - shop.getId(), - shop.getName() + benefitCategoryMap.getShop().getId(), + benefitCategoryMap.getShop().getName(), + benefitCategoryMap.getDetail() ); } } diff --git a/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminModifyBenefitShopsRequest.java b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminModifyBenefitShopsRequest.java new file mode 100644 index 000000000..c0c2614a6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/dto/AdminModifyBenefitShopsRequest.java @@ -0,0 +1,35 @@ +package in.koreatech.koin.admin.benefit.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.*; +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.global.validation.NotBlankElement; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminModifyBenefitShopsRequest( + @NotNull(message = "혜택문구 변경정보 리스트는 필수입니다.") + @NotBlankElement(message = "혜택문구 변경정보 리스트는 빈 요소가 존재할 수 없습니다.") + List modifyDetails +) { + + @JsonNaming(SnakeCaseStrategy.class) + public record InnerBenefitShopsRequest( + @Schema(description = "상점혜택 매핑id", example = "2", requiredMode = REQUIRED) + @NotNull(message = "상점혜택 매핑id는 필수입니다.") + Integer shopBenefitMapId, + + @Schema(description = "혜택 미리보기 문구", example = "4인 이상 픽업서비스", requiredMode = REQUIRED) + @NotBlank(message = "혜택 미리보기 문구는 필수입니다.") + @Size(min = 2, max = 15, message = "혜택 미리보기 문구는 최소 2자 최대 15자입니다.") + String detail + ) { + } +} diff --git a/src/main/java/in/koreatech/koin/admin/benefit/exception/BenefitMapNotFoundException.java b/src/main/java/in/koreatech/koin/admin/benefit/exception/BenefitMapNotFoundException.java new file mode 100644 index 000000000..fa6faa242 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/benefit/exception/BenefitMapNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.benefit.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class BenefitMapNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "해당 혜택 카테고리에 존재하지 않는 입니다."; + + public BenefitMapNotFoundException(String message) { + super(message); + } + + public BenefitMapNotFoundException(String message, String detail) { + super(message, detail); + } + + public static BenefitMapNotFoundException withDetail(String detail) { + return new BenefitMapNotFoundException(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 index 20f2ba22e..56125d12b 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 @@ -4,14 +4,14 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.Repository; +import org.springframework.data.repository.CrudRepository; import in.koreatech.koin.domain.benefit.model.BenefitCategoryMap; import org.springframework.data.repository.query.Param; -public interface AdminBenefitCategoryMapRepository extends Repository { +public interface AdminBenefitCategoryMapRepository extends CrudRepository { - void save(BenefitCategoryMap benefitCategoryMap); + List findAllByIdIn(List ids); @Query(""" SELECT bcm @@ -22,6 +22,16 @@ public interface AdminBenefitCategoryMapRepository extends Repository findAllByBenefitCategoryIdOrderByShopName(@Param("benefitId") Integer benefitId); + @Query(""" + SELECT bcm + FROM BenefitCategoryMap bcm + WHERE bcm.benefitCategory.id = :benefitId AND bcm.shop.id IN :shopIds + """) + List findAllByBenefitCategoryIdAndShopIds( + @Param("benefitId") Integer benefitId, + @Param("shopIds") List shopIds + ); + @Modifying @Query(""" DELETE FROM BenefitCategoryMap bcm 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 index b00c2e819..0a1c04c6c 100644 --- a/src/main/java/in/koreatech/koin/admin/benefit/service/AdminBenefitService.java +++ b/src/main/java/in/koreatech/koin/admin/benefit/service/AdminBenefitService.java @@ -1,13 +1,14 @@ package in.koreatech.koin.admin.benefit.service; import java.util.List; +import java.util.Map; 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.AdminBenefitCategoriesResponse; import in.koreatech.koin.admin.benefit.dto.AdminBenefitShopsResponse; import in.koreatech.koin.admin.benefit.dto.AdminCreateBenefitCategoryRequest; import in.koreatech.koin.admin.benefit.dto.AdminCreateBenefitCategoryResponse; @@ -16,9 +17,11 @@ import in.koreatech.koin.admin.benefit.dto.AdminDeleteShopsRequest; import in.koreatech.koin.admin.benefit.dto.AdminModifyBenefitCategoryRequest; import in.koreatech.koin.admin.benefit.dto.AdminModifyBenefitCategoryResponse; +import in.koreatech.koin.admin.benefit.dto.AdminModifyBenefitShopsRequest; import in.koreatech.koin.admin.benefit.dto.AdminSearchBenefitShopsResponse; import in.koreatech.koin.admin.benefit.exception.BenefitDuplicationException; import in.koreatech.koin.admin.benefit.exception.BenefitLimitException; +import in.koreatech.koin.admin.benefit.exception.BenefitMapNotFoundException; import in.koreatech.koin.admin.benefit.repository.AdminBenefitCategoryMapRepository; import in.koreatech.koin.admin.benefit.repository.AdminBenefitCategoryRepository; import in.koreatech.koin.admin.shop.repository.shop.AdminShopRepository; @@ -36,9 +39,9 @@ public class AdminBenefitService { private final AdminBenefitCategoryMapRepository adminBenefitCategoryMapRepository; private final AdminShopRepository adminShopRepository; - public AdminBenefitCategoryResponse getBenefitCategories() { + public AdminBenefitCategoriesResponse getBenefitCategories() { List categories = adminBenefitCategoryRepository.findAllByOrderByTitleAsc(); - return AdminBenefitCategoryResponse.from(categories); + return AdminBenefitCategoriesResponse.from(categories); } @Transactional @@ -90,10 +93,7 @@ public void deleteBenefitCategory(Integer categoryId) { public AdminBenefitShopsResponse getBenefitShops(Integer benefitId) { List benefitCategoryMaps = adminBenefitCategoryMapRepository.findAllByBenefitCategoryIdOrderByShopName(benefitId); - List shops = benefitCategoryMaps.stream() - .map(BenefitCategoryMap::getShop) - .toList(); - return AdminBenefitShopsResponse.from(shops); + return AdminBenefitShopsResponse.from(benefitCategoryMaps); } @Transactional @@ -101,18 +101,53 @@ 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() + Map shopIdToDetail = request.shopDetails().stream() + .collect(Collectors.toMap( + AdminCreateBenefitShopsRequest.InnerBenefitShopsRequest::shopId, + AdminCreateBenefitShopsRequest.InnerBenefitShopsRequest::detail + )); + List shops = adminShopRepository.findAllByIdIn(shopIdToDetail.keySet().stream().toList()); + + List benefitCategoryMaps = shops.stream() + .map(shop -> BenefitCategoryMap.builder() .shop(shop) .benefitCategory(benefitCategory) - .build(); - adminBenefitCategoryMapRepository.save(benefitCategoryMap); - } - return AdminCreateBenefitShopsResponse.from(shops); + .detail(shopIdToDetail.get(shop.getId())) + .build() + ) + .toList(); + adminBenefitCategoryMapRepository.saveAll(benefitCategoryMaps); + return AdminCreateBenefitShopsResponse.from(benefitCategoryMaps); + } + + @Transactional + public void modifyBenefitShops(AdminModifyBenefitShopsRequest request) { + Map shopBenefitIdToDetail = request.modifyDetails().stream() + .collect(Collectors.toMap( + AdminModifyBenefitShopsRequest.InnerBenefitShopsRequest::shopBenefitMapId, + AdminModifyBenefitShopsRequest.InnerBenefitShopsRequest::detail + )); + + List benefitCategoryMaps = + adminBenefitCategoryMapRepository.findAllByIdIn(shopBenefitIdToDetail.keySet().stream().toList()); + + validateBenefitMapIds(shopBenefitIdToDetail, benefitCategoryMaps); + benefitCategoryMaps.forEach(map -> map.modifyDetail(shopBenefitIdToDetail.get(map.getId()))); } + private static void validateBenefitMapIds( + Map shopBenefitIdToDetail, + List benefitCategoryMaps + ) { + List notFoundMapIds = shopBenefitIdToDetail.keySet().stream() + .filter(mapId -> benefitCategoryMaps.stream().noneMatch(map -> map.getId().equals(mapId))) + .toList(); + + if (!notFoundMapIds.isEmpty()) { + throw new BenefitMapNotFoundException("해당 혜택 카테고리에 존재하지 않는 상점이 포함되어 있습니다. shopBenefitMapId: " + notFoundMapIds); + } + } @Transactional public void deleteBenefitShops(Integer benefitId, AdminDeleteShopsRequest request) { diff --git a/src/main/java/in/koreatech/koin/admin/history/aop/AdminActivityHistoryAspect.java b/src/main/java/in/koreatech/koin/admin/history/aop/AdminActivityHistoryAspect.java index 0b784c58d..720ae012c 100644 --- a/src/main/java/in/koreatech/koin/admin/history/aop/AdminActivityHistoryAspect.java +++ b/src/main/java/in/koreatech/koin/admin/history/aop/AdminActivityHistoryAspect.java @@ -12,6 +12,7 @@ import org.springframework.web.util.ContentCachingRequestWrapper; import in.koreatech.koin.admin.history.enums.DomainType; +import in.koreatech.koin.admin.history.enums.HttpMethodType; import in.koreatech.koin.admin.history.model.AdminActivityHistory; import in.koreatech.koin.admin.history.repository.AdminActivityHistoryRepository; import in.koreatech.koin.admin.user.model.Admin; @@ -58,7 +59,7 @@ private void excludeSpecificMethods() { public Object logAdminActivity(ProceedingJoinPoint joinPoint) throws Throwable { HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest(); String requestURI = request.getRequestURI(); - String requestMethod = request.getMethod(); + HttpMethodType requestMethod = HttpMethodType.valueOf(request.getMethod()); ContentCachingRequestWrapper cachingRequest = (ContentCachingRequestWrapper)request; String requestMessage = new String(cachingRequest.getContentAsByteArray()); @@ -72,9 +73,10 @@ public Object logAdminActivity(ProceedingJoinPoint joinPoint) throws Throwable { .domainId(domainInfo.domainId()) .admin(admin) .requestMethod(requestMethod) - .domainName(domainInfo.domainName()) + .domainName(DomainType.valueOf(domainInfo.domainName())) .requestMessage(requestMessage) - .build()); + .build() + ); return result; } diff --git a/src/main/java/in/koreatech/koin/admin/history/controller/HistoryApi.java b/src/main/java/in/koreatech/koin/admin/history/controller/HistoryApi.java index 70bdba8fd..fe0edc9b1 100644 --- a/src/main/java/in/koreatech/koin/admin/history/controller/HistoryApi.java +++ b/src/main/java/in/koreatech/koin/admin/history/controller/HistoryApi.java @@ -1,6 +1,7 @@ package in.koreatech.koin.admin.history.controller; import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import static in.koreatech.koin.global.model.Criteria.Sort; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -8,7 +9,9 @@ import org.springframework.web.bind.annotation.RequestParam; import in.koreatech.koin.admin.history.dto.AdminHistoryResponse; -import in.koreatech.koin.admin.history.dto.AdminHistorysResponse; +import in.koreatech.koin.admin.history.dto.AdminHistoriesResponse; +import in.koreatech.koin.admin.history.enums.DomainType; +import in.koreatech.koin.admin.history.enums.HttpMethodType; import in.koreatech.koin.global.auth.Auth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -19,6 +22,7 @@ @Tag(name = "(Admin) History: 기록", description = "관리자 기록 관련 API") public interface HistoryApi { + @ApiResponses( value = { @ApiResponse(responseCode = "200"), @@ -29,13 +33,14 @@ public interface HistoryApi { } ) @Operation(summary = "히스토리 리스트 조회") - @GetMapping("/admin/historys") - ResponseEntity getHistorys( + @GetMapping("/admin/histories") + ResponseEntity getHistories( @RequestParam(required = false) Integer page, @RequestParam(required = false) Integer limit, - @RequestParam(required = false) String requestMethod, - @RequestParam(required = false) String domainName, + @RequestParam(required = false) HttpMethodType requestMethod, + @RequestParam(required = false) DomainType domainName, @RequestParam(required = false) Integer domainId, + @RequestParam(required = false) Sort sort, @Auth(permit = {ADMIN}) Integer adminId ); diff --git a/src/main/java/in/koreatech/koin/admin/history/controller/HistoryController.java b/src/main/java/in/koreatech/koin/admin/history/controller/HistoryController.java index efc11bbbf..01aaa834f 100644 --- a/src/main/java/in/koreatech/koin/admin/history/controller/HistoryController.java +++ b/src/main/java/in/koreatech/koin/admin/history/controller/HistoryController.java @@ -1,6 +1,7 @@ package in.koreatech.koin.admin.history.controller; import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import static in.koreatech.koin.global.model.Criteria.Sort; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -9,8 +10,10 @@ import org.springframework.web.bind.annotation.RestController; import in.koreatech.koin.admin.history.dto.AdminHistoryResponse; -import in.koreatech.koin.admin.history.dto.AdminHistorysCondition; -import in.koreatech.koin.admin.history.dto.AdminHistorysResponse; +import in.koreatech.koin.admin.history.dto.AdminHistoriesCondition; +import in.koreatech.koin.admin.history.dto.AdminHistoriesResponse; +import in.koreatech.koin.admin.history.enums.DomainType; +import in.koreatech.koin.admin.history.enums.HttpMethodType; import in.koreatech.koin.admin.history.service.HistoryService; import in.koreatech.koin.global.auth.Auth; import lombok.RequiredArgsConstructor; @@ -21,19 +24,20 @@ public class HistoryController implements HistoryApi { private final HistoryService historyService; - @GetMapping("/admin/historys") - public ResponseEntity getHistorys( + @GetMapping("/admin/histories") + public ResponseEntity getHistories( @RequestParam(required = false) Integer page, @RequestParam(required = false) Integer limit, - @RequestParam(required = false) String requestMethod, - @RequestParam(required = false) String domainName, + @RequestParam(required = false) HttpMethodType requestMethod, + @RequestParam(required = false) DomainType domainName, @RequestParam(required = false) Integer domainId, + @RequestParam(required = false) Sort sort, @Auth(permit = {ADMIN}) Integer adminId ) { - AdminHistorysCondition adminHistorysCondition = new AdminHistorysCondition(page, limit, requestMethod, - domainName, domainId); - AdminHistorysResponse historys = historyService.getHistorys(adminHistorysCondition); - return ResponseEntity.ok(historys); + AdminHistoriesCondition adminHistoriesCondition = new AdminHistoriesCondition(page, limit, requestMethod, + domainName, domainId, sort); + AdminHistoriesResponse histories = historyService.getHistories(adminHistoriesCondition); + return ResponseEntity.ok(histories); } @GetMapping("/admin/history/{id}") diff --git a/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistorysCondition.java b/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistoriesCondition.java similarity index 52% rename from src/main/java/in/koreatech/koin/admin/history/dto/AdminHistorysCondition.java rename to src/main/java/in/koreatech/koin/admin/history/dto/AdminHistoriesCondition.java index e738e5b10..e7e775554 100644 --- a/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistorysCondition.java +++ b/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistoriesCondition.java @@ -1,17 +1,22 @@ package in.koreatech.koin.admin.history.dto; import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static in.koreatech.koin.global.model.Criteria.*; +import static in.koreatech.koin.global.model.Criteria.Sort.CREATED_AT_ASC; +import static in.koreatech.koin.global.model.Criteria.Sort.CREATED_AT_DESC; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static org.springframework.data.domain.Sort.Direction; import java.util.Objects; import com.fasterxml.jackson.databind.annotation.JsonNaming; -import in.koreatech.koin.global.model.Criteria; +import in.koreatech.koin.admin.history.enums.DomainType; +import in.koreatech.koin.admin.history.enums.HttpMethodType; import io.swagger.v3.oas.annotations.media.Schema; @JsonNaming(value = SnakeCaseStrategy.class) -public record AdminHistorysCondition( +public record AdminHistoriesCondition( @Schema(description = "페이지", example = "1", defaultValue = "1", requiredMode = NOT_REQUIRED) Integer page, @@ -19,20 +24,33 @@ public record AdminHistorysCondition( Integer limit, @Schema(description = "HTTP 메소드", example = "POST", requiredMode = NOT_REQUIRED) - String requestMethod, + HttpMethodType requestMethod, @Schema(description = "도메인 이름", example = "NOTICE", requiredMode = NOT_REQUIRED) - String domainName, + DomainType domainName, @Schema(description = "특정 엔티티 id", requiredMode = NOT_REQUIRED) - Integer domainId + Integer domainId, + + @Schema(description = "정렬 기준", requiredMode = NOT_REQUIRED) + Sort sort ) { - public AdminHistorysCondition { + public AdminHistoriesCondition { if (Objects.isNull(page)) { - page = Criteria.DEFAULT_PAGE; + page = DEFAULT_PAGE; } if (Objects.isNull(limit)) { - limit = Criteria.DEFAULT_LIMIT; + limit = DEFAULT_LIMIT; + } + if (Objects.isNull(sort)) { + sort = CREATED_AT_DESC; + } + } + + public Direction getSortDir() { + if (sort == CREATED_AT_ASC) { + return Direction.ASC; } + return Direction.DESC; } } diff --git a/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistorysResponse.java b/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistoriesResponse.java similarity index 77% rename from src/main/java/in/koreatech/koin/admin/history/dto/AdminHistorysResponse.java rename to src/main/java/in/koreatech/koin/admin/history/dto/AdminHistoriesResponse.java index 26891b308..61e1a8722 100644 --- a/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistorysResponse.java +++ b/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistoriesResponse.java @@ -12,13 +12,11 @@ import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.annotation.JsonNaming; -import in.koreatech.koin.admin.history.enums.DomainType; -import in.koreatech.koin.admin.history.enums.HttpMethodType; import in.koreatech.koin.admin.history.model.AdminActivityHistory; import io.swagger.v3.oas.annotations.media.Schema; @JsonNaming(value = SnakeCaseStrategy.class) -public record AdminHistorysResponse( +public record AdminHistoriesResponse( @Schema(description = "조건에 해당하는 히스토리 수", example = "10", requiredMode = REQUIRED) Long totalCount, @@ -32,10 +30,10 @@ public record AdminHistorysResponse( Integer currentPage, @Schema(description = "어드민 계정 리스트", requiredMode = REQUIRED) - List historys + List histories ) { @JsonNaming(value = SnakeCaseStrategy.class) - public record InnerAdminHistorysResponse( + public record InnerAdminHistoriesResponse( @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) Integer id, @@ -61,31 +59,31 @@ public record InnerAdminHistorysResponse( ) String requestMessage, - @Schema(description = "요청 시간", example = "2019-08-16-23-01-52", requiredMode = REQUIRED) - @JsonFormat(pattern = "yyyy-MM-dd-HH-mm-ss") + @Schema(description = "요청 시간", example = "2019-08-16 23:01:52", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt ) { - public static InnerAdminHistorysResponse from(AdminActivityHistory adminActivityHistory) { - return new InnerAdminHistorysResponse( + public static InnerAdminHistoriesResponse from(AdminActivityHistory adminActivityHistory) { + return new InnerAdminHistoriesResponse( adminActivityHistory.getId(), adminActivityHistory.getDomainId(), adminActivityHistory.getAdmin().getUser().getName(), - DomainType.valueOf(adminActivityHistory.getDomainName()).getDescription(), - HttpMethodType.valueOf(adminActivityHistory.getRequestMethod()).getValue(), + adminActivityHistory.getDomainName().getDescription(), + adminActivityHistory.getRequestMethod().getValue(), adminActivityHistory.getRequestMessage(), adminActivityHistory.getCreatedAt() ); } } - public static AdminHistorysResponse of(Page adminActivityHistoryPage) { - return new AdminHistorysResponse( + public static AdminHistoriesResponse from(Page adminActivityHistoryPage) { + return new AdminHistoriesResponse( adminActivityHistoryPage.getTotalElements(), adminActivityHistoryPage.getContent().size(), adminActivityHistoryPage.getTotalPages(), adminActivityHistoryPage.getNumber() + 1, adminActivityHistoryPage.getContent().stream() - .map(InnerAdminHistorysResponse::from) + .map(InnerAdminHistoriesResponse::from) .collect(Collectors.toList()) ); } diff --git a/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistoryResponse.java b/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistoryResponse.java index 406ce536a..035fa6e59 100644 --- a/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistoryResponse.java +++ b/src/main/java/in/koreatech/koin/admin/history/dto/AdminHistoryResponse.java @@ -40,8 +40,8 @@ public record AdminHistoryResponse( ) String requestMessage, - @Schema(description = "요청 시간", example = "2019-08-16-23-01-52", requiredMode = REQUIRED) - @JsonFormat(pattern = "yyyy-MM-dd-HH-mm-ss") + @Schema(description = "요청 시간", example = "2019-08-16 23:01:52", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt ) { public static AdminHistoryResponse from(AdminActivityHistory adminActivityHistory) { @@ -49,8 +49,8 @@ public static AdminHistoryResponse from(AdminActivityHistory adminActivityHistor adminActivityHistory.getId(), adminActivityHistory.getDomainId(), adminActivityHistory.getAdmin().getUser().getName(), - DomainType.valueOf(adminActivityHistory.getDomainName()).getDescription(), - HttpMethodType.valueOf(adminActivityHistory.getRequestMethod()).getValue(), + adminActivityHistory.getDomainName().getDescription(), + adminActivityHistory.getRequestMethod().getValue(), adminActivityHistory.getRequestMessage(), adminActivityHistory.getCreatedAt() ); diff --git a/src/main/java/in/koreatech/koin/admin/history/model/AdminActivityHistory.java b/src/main/java/in/koreatech/koin/admin/history/model/AdminActivityHistory.java index 3ab600a04..afb88e2b4 100644 --- a/src/main/java/in/koreatech/koin/admin/history/model/AdminActivityHistory.java +++ b/src/main/java/in/koreatech/koin/admin/history/model/AdminActivityHistory.java @@ -3,17 +3,20 @@ import static jakarta.persistence.GenerationType.IDENTITY; import static lombok.AccessLevel.PROTECTED; +import in.koreatech.koin.admin.history.enums.DomainType; +import in.koreatech.koin.admin.history.enums.HttpMethodType; import in.koreatech.koin.admin.user.model.Admin; import in.koreatech.koin.global.domain.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -32,14 +35,14 @@ public class AdminActivityHistory extends BaseEntity { private Integer domainId; @NotNull - @Size(max = 10) + @Enumerated(value = EnumType.STRING) @Column(name = "request_method", nullable = false, length = 10) - private String requestMethod; + private HttpMethodType requestMethod; @NotNull - @Size(max = 20) + @Enumerated(value = EnumType.STRING) @Column(name = "domain_name", nullable = false, length = 20) - private String domainName; + private DomainType domainName; @Column(name = "request_message", columnDefinition = "TEXT") private String requestMessage; @@ -49,8 +52,13 @@ public class AdminActivityHistory extends BaseEntity { private Admin admin; @Builder - public AdminActivityHistory(Integer domainId, String requestMethod, String domainName, String requestMessage, - Admin admin) { + public AdminActivityHistory( + Integer domainId, + HttpMethodType requestMethod, + DomainType domainName, + String requestMessage, + Admin admin + ) { this.domainId = domainId; this.requestMethod = requestMethod; this.domainName = domainName; diff --git a/src/main/java/in/koreatech/koin/admin/history/repository/AdminActivityHistoryRepository.java b/src/main/java/in/koreatech/koin/admin/history/repository/AdminActivityHistoryRepository.java index eb0a9721f..01c6cf937 100644 --- a/src/main/java/in/koreatech/koin/admin/history/repository/AdminActivityHistoryRepository.java +++ b/src/main/java/in/koreatech/koin/admin/history/repository/AdminActivityHistoryRepository.java @@ -8,11 +8,12 @@ import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; -import in.koreatech.koin.admin.history.dto.AdminHistorysCondition; +import in.koreatech.koin.admin.history.dto.AdminHistoriesCondition; import in.koreatech.koin.admin.history.exception.AdminActivityHistoryNotFoundException; import in.koreatech.koin.admin.history.model.AdminActivityHistory; public interface AdminActivityHistoryRepository extends Repository { + AdminActivityHistory save(AdminActivityHistory adminActivityHistory); Optional findById(Integer id); @@ -27,10 +28,10 @@ default AdminActivityHistory getById(Integer id) { @Query(""" SELECT a FROM AdminActivityHistory a WHERE - (:#{#condition.requestMethod} IS NULL OR a.requestMethod = :#{#condition.requestMethod}) AND - (:#{#condition.domainName} IS NULL OR a.domainName = :#{#condition.domainName}) AND + (:#{#condition.requestMethod?.name()} IS NULL OR a.requestMethod = :#{#condition.requestMethod}) AND + (:#{#condition.domainName?.name()} IS NULL OR a.domainName = :#{#condition.domainName}) AND (:#{#condition.domainId} IS NULL OR a.domainId = :#{#condition.domainId}) """) - Page findByConditions(@Param("condition") AdminHistorysCondition adminsCondition, + Page findByConditions(@Param("condition") AdminHistoriesCondition adminsCondition, Pageable pageable); } diff --git a/src/main/java/in/koreatech/koin/admin/history/service/HistoryService.java b/src/main/java/in/koreatech/koin/admin/history/service/HistoryService.java index 84706bab5..124973a64 100644 --- a/src/main/java/in/koreatech/koin/admin/history/service/HistoryService.java +++ b/src/main/java/in/koreatech/koin/admin/history/service/HistoryService.java @@ -2,11 +2,12 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import in.koreatech.koin.admin.history.dto.AdminHistoryResponse; -import in.koreatech.koin.admin.history.dto.AdminHistorysCondition; -import in.koreatech.koin.admin.history.dto.AdminHistorysResponse; +import in.koreatech.koin.admin.history.dto.AdminHistoriesCondition; +import in.koreatech.koin.admin.history.dto.AdminHistoriesResponse; import in.koreatech.koin.admin.history.model.AdminActivityHistory; import in.koreatech.koin.admin.history.repository.AdminActivityHistoryRepository; import in.koreatech.koin.global.model.Criteria; @@ -18,16 +19,18 @@ public class HistoryService { private final AdminActivityHistoryRepository adminActivityHistoryRepository; - public AdminHistorysResponse getHistorys(AdminHistorysCondition condition) { + public AdminHistoriesResponse getHistories(AdminHistoriesCondition condition) { Integer total = adminActivityHistoryRepository.countAdminActivityHistory(); Criteria criteria = Criteria.of(condition.page(), condition.limit(), total); - PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit()); - Page adminActivityHistoryRepositoryPage = adminActivityHistoryRepository.findByConditions( - condition, - pageRequest); + PageRequest pageRequest = PageRequest.of( + criteria.getPage(), criteria.getLimit(), + Sort.by(condition.getSortDir(), "createdAt") + ); + Page adminActivityHistoryPage = adminActivityHistoryRepository.findByConditions( + condition, pageRequest); - return AdminHistorysResponse.of(adminActivityHistoryRepositoryPage); + return AdminHistoriesResponse.from(adminActivityHistoryPage); } public AdminHistoryResponse getHistory(Integer id) { 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 477fdc100..b33d49f50 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 @@ -7,6 +7,7 @@ import java.time.LocalTime; import java.util.Comparator; import java.util.List; +import java.util.Map; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -67,7 +68,10 @@ public record InnerShopResponse( double averageRate, @Schema(example = "10", description = "리뷰 개수", requiredMode = REQUIRED) - long reviewCount + long reviewCount, + + @Schema(example = "콜라 서비스", description = "혜택 설명", requiredMode = NOT_REQUIRED) + String benefitDetail ) { public static Comparator getComparator() { @@ -80,7 +84,8 @@ public static Comparator getComparator() { public static InnerShopResponse from( Shop shop, boolean isEvent, - boolean isOpen + boolean isOpen, + String benefitDetail ) { return new InnerShopResponse( shop.getShopCategories().stream().map(shopCategoryMap -> @@ -102,7 +107,8 @@ public static InnerShopResponse from( .orElse(0.0) * 10) / 10.0, shop.getReviews().stream() .filter(review -> !review.isDeleted()) - .count() + .count(), + benefitDetail ); } diff --git a/src/main/java/in/koreatech/koin/domain/benefit/model/BenefitCategoryMap.java b/src/main/java/in/koreatech/koin/domain/benefit/model/BenefitCategoryMap.java index 5da5479df..d29a0d14c 100644 --- a/src/main/java/in/koreatech/koin/domain/benefit/model/BenefitCategoryMap.java +++ b/src/main/java/in/koreatech/koin/domain/benefit/model/BenefitCategoryMap.java @@ -5,6 +5,7 @@ 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.FetchType; import jakarta.persistence.GeneratedValue; @@ -12,6 +13,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.validation.constraints.Size; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -34,9 +36,18 @@ public class BenefitCategoryMap extends BaseEntity { @JoinColumn(name = "benefit_id", referencedColumnName = "id", nullable = false) private BenefitCategory benefitCategory; + @Size(min = 2, max = 20) + @Column(name = "detail") + private String detail; + @Builder - public BenefitCategoryMap(Shop shop, BenefitCategory benefitCategory) { + public BenefitCategoryMap(Shop shop, BenefitCategory benefitCategory, String detail) { this.shop = shop; this.benefitCategory = benefitCategory; + this.detail = detail; + } + + public void modifyDetail(String detail) { + this.detail = detail; } } diff --git a/src/main/java/in/koreatech/koin/domain/benefit/repository/BenefitCategoryMapRepository.java b/src/main/java/in/koreatech/koin/domain/benefit/repository/BenefitCategoryMapRepository.java index a29f48c36..1555fc173 100644 --- a/src/main/java/in/koreatech/koin/domain/benefit/repository/BenefitCategoryMapRepository.java +++ b/src/main/java/in/koreatech/koin/domain/benefit/repository/BenefitCategoryMapRepository.java @@ -2,13 +2,21 @@ import java.util.List; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import in.koreatech.koin.domain.benefit.model.BenefitCategoryMap; public interface BenefitCategoryMapRepository extends Repository { - List findAllByBenefitCategoryId(Integer benefitCategoryId); + List findByBenefitCategoryId(Integer benefitCategoryId); + + @Query(""" + SELECT bcm FROM BenefitCategoryMap bcm + JOIN FETCH bcm.shop s + JOIN FETCH bcm.benefitCategory bc + """) + List findAllWithFetchJoin(); BenefitCategoryMap save(BenefitCategoryMap benefitCategoryMap); } diff --git a/src/main/java/in/koreatech/koin/domain/benefit/service/ShopBenefitService.java b/src/main/java/in/koreatech/koin/domain/benefit/service/ShopBenefitService.java index a309885fc..9d41fb04f 100644 --- a/src/main/java/in/koreatech/koin/domain/benefit/service/ShopBenefitService.java +++ b/src/main/java/in/koreatech/koin/domain/benefit/service/ShopBenefitService.java @@ -35,18 +35,24 @@ public BenefitCategoryResponse getBenefitCategories() { } public BenefitShopsResponse getBenefitShops(Integer benefitId) { - List benefitCategoryMaps = benefitCategoryMapRepository - .findAllByBenefitCategoryId(benefitId); + List benefitCategoryMaps = benefitCategoryMapRepository.findByBenefitCategoryId(benefitId); LocalDateTime now = LocalDateTime.now(clock); + List innerShopResponses = benefitCategoryMaps.stream() .map(benefitCategoryMap -> { Shop shop = benefitCategoryMap.getShop(); + String benefitDetail = benefitCategoryMap.getDetail(); boolean isDurationEvent = eventArticleRepository.isDurationEvent(shop.getId(), now.toLocalDate()); - return InnerShopResponse.from(shop, isDurationEvent, shop.isOpen(now)); + return InnerShopResponse.from( + shop, + isDurationEvent, + shop.isOpen(now), + benefitDetail + ); }) .sorted(InnerShopResponse.getComparator()) .toList(); - BenefitShopsResponse shopsResponse = BenefitShopsResponse.from(innerShopResponses); - return shopsResponse; + + return BenefitShopsResponse.from(innerShopResponses); } } diff --git a/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java b/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java index f14fd61a6..c13d74fa8 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java +++ b/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java @@ -3,17 +3,23 @@ import java.time.LocalDate; import java.util.List; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import in.koreatech.koin.domain.bus.dto.BusCourseResponse; import in.koreatech.koin.domain.bus.dto.BusRemainTimeResponse; +import in.koreatech.koin.domain.bus.dto.BusScheduleResponse; import in.koreatech.koin.domain.bus.dto.BusTimetableResponse; import in.koreatech.koin.domain.bus.dto.CityBusTimetableResponse; +import in.koreatech.koin.domain.bus.dto.ShuttleBusRoutesResponse; +import in.koreatech.koin.domain.bus.dto.ShuttleBusTimetableResponse; import in.koreatech.koin.domain.bus.dto.SingleBusTimeResponse; import in.koreatech.koin.domain.bus.model.BusTimetable; +import in.koreatech.koin.domain.bus.model.enums.BusRouteType; import in.koreatech.koin.domain.bus.model.enums.BusStation; import in.koreatech.koin.domain.bus.model.enums.BusType; import in.koreatech.koin.domain.bus.model.enums.CityBusDirection; @@ -90,4 +96,47 @@ ResponseEntity> getSearchTimetable( @Operation(summary = "버스 노선 조회") @GetMapping("/courses") ResponseEntity> getBusCourses(); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + } + ) + @Operation(summary = "학교버스 노선 조회") + @GetMapping("/courses/shuttle") + ResponseEntity getShuttleBusRoutes(); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "학교버스 특정 노선 시간표 조회", description = "id: 노선 id 값 (get /bus/courses/shuttle response 참조)") + @GetMapping("/timetable/shuttle/{id}") + ResponseEntity getShuttleBusTimetable(@PathVariable String id); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation( + summary = "버스 교통편 조회", + description = """ + ### 버스 교통편 조회 + - **시간** : 13:00 인 경우 13시 이후 출발하는 버스의 시간표를 조회합니다. 00:00 인 경우 해당 날짜의 모든 스케줄을 조회합니다. + - **날짜** : 요일을 기준으로 스케줄을 출력합니다. 공휴일 처리는 구현되어 있지 않습니다. + - **출발지 & 도착지** : 출발지와 도착지가 일치하는 경우 빈 리스트를 반환합니다. + """ + ) + @GetMapping("/route") + ResponseEntity getBusRouteSchedule( + @Parameter(description = "yyyy-MM-dd") @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, + @Parameter(description = "HH:mm") @RequestParam String time, + @RequestParam BusRouteType busRouteType, + @RequestParam BusStation depart, + @RequestParam BusStation arrival + ); } diff --git a/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java b/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java index f74ce989f..bfa8bd567 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java +++ b/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java @@ -7,20 +7,27 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import in.koreatech.koin.domain.bus.dto.BusCourseResponse; import in.koreatech.koin.domain.bus.dto.BusRemainTimeResponse; +import in.koreatech.koin.domain.bus.dto.BusRouteCommand; +import in.koreatech.koin.domain.bus.dto.BusScheduleResponse; import in.koreatech.koin.domain.bus.dto.BusTimetableResponse; import in.koreatech.koin.domain.bus.dto.CityBusTimetableResponse; +import in.koreatech.koin.domain.bus.dto.ShuttleBusRoutesResponse; +import in.koreatech.koin.domain.bus.dto.ShuttleBusTimetableResponse; import in.koreatech.koin.domain.bus.dto.SingleBusTimeResponse; import in.koreatech.koin.domain.bus.model.BusTimetable; +import in.koreatech.koin.domain.bus.model.enums.BusRouteType; import in.koreatech.koin.domain.bus.model.enums.BusStation; import in.koreatech.koin.domain.bus.model.enums.BusType; import in.koreatech.koin.domain.bus.model.enums.CityBusDirection; import in.koreatech.koin.domain.bus.service.BusService; +import in.koreatech.koin.domain.bus.service.ShuttleBusService; import lombok.RequiredArgsConstructor; @RestController @@ -29,6 +36,7 @@ public class BusController implements BusApi { private final BusService busService; + private final ShuttleBusService shuttleBusService; @GetMapping public ResponseEntity getBusRemainTime( @@ -82,4 +90,27 @@ public ResponseEntity> getSearchTimetable( depart, arrival); return ResponseEntity.ok().body(singleBusTimeResponses); } + + @GetMapping("/courses/shuttle") + public ResponseEntity getShuttleBusRoutes() { + return ResponseEntity.ok().body(shuttleBusService.getShuttleBusRoutes()); + } + + @GetMapping("/timetable/shuttle/{id}") + public ResponseEntity getShuttleBusTimetable(@PathVariable String id) { + return ResponseEntity.ok().body(shuttleBusService.getShuttleBusTimetable(id)); + } + + @GetMapping("/route") + public ResponseEntity getBusRouteSchedule( + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, + @RequestParam String time, + @RequestParam(value = "bus_type") BusRouteType busRouteType, + @RequestParam BusStation depart, + @RequestParam BusStation arrival + ) { + BusRouteCommand request = new BusRouteCommand(depart, arrival, busRouteType, date, LocalTime.parse(time)); + BusScheduleResponse busSchedule = busService.getBusSchedule(request); + return ResponseEntity.ok().body(busSchedule); + } } diff --git a/src/main/java/in/koreatech/koin/domain/bus/dto/BusRouteCommand.java b/src/main/java/in/koreatech/koin/domain/bus/dto/BusRouteCommand.java new file mode 100644 index 000000000..5e423b857 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/dto/BusRouteCommand.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.domain.bus.dto; + +import java.time.LocalDate; +import java.time.LocalTime; + +import in.koreatech.koin.domain.bus.model.enums.BusRouteType; +import in.koreatech.koin.domain.bus.model.enums.BusStation; + +public record BusRouteCommand( + + BusStation depart, + BusStation arrive, + BusRouteType busRouteType, + LocalDate date, + LocalTime time +) { + + public boolean checkAvailableCourse() { + return depart != arrive; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/dto/BusScheduleResponse.java b/src/main/java/in/koreatech/koin/domain/bus/dto/BusScheduleResponse.java new file mode 100644 index 000000000..73f368b41 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/dto/BusScheduleResponse.java @@ -0,0 +1,46 @@ +package in.koreatech.koin.domain.bus.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.time.LocalDate; +import java.time.LocalTime; +import java.util.Comparator; +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.bus.model.enums.BusStation; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record BusScheduleResponse( + @Schema(description = "출발 정류장", example = "KOREATECH", requiredMode = REQUIRED) + BusStation depart, + @Schema(description = "도착 정류장", example = "TERMINAL", requiredMode = REQUIRED) + BusStation arrival, + @Schema(description = "출발 날짜", example = "2024-11-05", requiredMode = REQUIRED) + LocalDate departDate, + @Schema(description = "출발 시간", example = "12:00", requiredMode = REQUIRED) + LocalTime departTime, + @Schema(description = "교통편 조회 결과", requiredMode = NOT_REQUIRED) + List schedule + +) { + @JsonNaming(SnakeCaseStrategy.class) + public record ScheduleInfo( + @Schema(description = "버스 타입 (shuttle, express, city)", example = "express", requiredMode = REQUIRED) + String busType, + @Schema(description = "버스 이름 또는 노선명", example = "대성티앤이", requiredMode = REQUIRED) + String busName, + @Schema(description = "버스 출발 시간", example = "16:50", requiredMode = REQUIRED) + LocalTime departTime + ) { + + public static Comparator compareBusType() { + List priority = List.of("shuttle", "express", "city"); + return Comparator.comparingInt(schedule -> priority.indexOf(schedule.busType)); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/dto/ShuttleBusRoutesResponse.java b/src/main/java/in/koreatech/koin/domain/bus/dto/ShuttleBusRoutesResponse.java new file mode 100644 index 000000000..99ea7418b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/dto/ShuttleBusRoutesResponse.java @@ -0,0 +1,78 @@ +package in.koreatech.koin.domain.bus.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.bus.model.enums.ShuttleBusRegion; +import in.koreatech.koin.domain.bus.model.enums.ShuttleRouteType; +import in.koreatech.koin.domain.bus.model.mongo.ShuttleBusRoute; +import in.koreatech.koin.domain.version.dto.VersionMessageResponse; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "셔틀버스 경로 응답") +public record ShuttleBusRoutesResponse( + @Schema(description = "노선 지역 분류 목록") List routeRegions, + @Schema(description = "학기 정보") RouteSemester semesterInfo +) { + + @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = "노선 지역 정보") + public record RouteRegion( + @Schema(description = "지역 이름", example = "천안") + String region, + + @Schema(description = "해당 지역의 경로 목록") + List routes + ) { + } + + @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = "노선 세부 정보") + public record RouteName( + @Schema(description = "노선 ID", example = "675013f9465776d6265ddfdb") String id, + @Schema(description = "노선 종류", example = "주말") String type, + @Schema(description = "노선 이름", example = "대학원") String routeName, + @Schema(description = "노선 부가 이름", example = "토요일") String subName + ) { + } + + @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = "학기 정보") + public record RouteSemester( + @Schema(description = "학기 이름", example = "정규학기") String name, + @Schema(description = "학기 시작 날짜", example = "2024-09-02") String from, + @Schema(description = "학기 종료 날짜", example = "2024-12-20") String to + ) { + } + + public static ShuttleBusRoutesResponse of(List shuttleBusRoutes, + VersionMessageResponse versionMessageResponse) { + List categories = mapCategories(shuttleBusRoutes); + String[] term = versionMessageResponse.content().split("~"); + RouteSemester routeSemester = new RouteSemester(versionMessageResponse.title(), term[0].trim(), term[1].trim()); + return new ShuttleBusRoutesResponse(categories, routeSemester); + } + + private static List mapCategories(List shuttleBusRoutes) { + return shuttleBusRoutes.stream() + .collect(Collectors.groupingBy(ShuttleBusRoute::getRegion)) + .entrySet().stream() + .map(entry -> new RouteRegion(entry.getKey().getLabel(), mapRouteNames(entry.getValue()))) + .sorted(Comparator.comparingInt(o -> ShuttleBusRegion.getOrdinalByLabel(o.region()))) + .toList(); + } + + private static List mapRouteNames(List routes) { + return routes.stream() + .map(route -> new RouteName(route.getId(), route.getRouteType().getLabel(), route.getRouteName(), + route.getSubName())) + .sorted(Comparator.comparingInt(o -> ShuttleRouteType.getOrdinalByLabel(o.type()))) + .toList(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/dto/ShuttleBusTimetableResponse.java b/src/main/java/in/koreatech/koin/domain/bus/dto/ShuttleBusTimetableResponse.java new file mode 100644 index 000000000..d655df2c9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/dto/ShuttleBusTimetableResponse.java @@ -0,0 +1,78 @@ +package in.koreatech.koin.domain.bus.dto; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.bus.model.mongo.ShuttleBusRoute; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "셔틀버스 노선 응답") +public record ShuttleBusTimetableResponse( + @Schema(description = "노선 ID", example = "675013f9465776d6265ddfdb") + String id, + + @Schema(description = "지역 이름", example = "천안") + String region, + + @Schema(description = "노선 타입", example = "순환") + String routeType, + + @Schema(description = "노선 이름", example = "천안 셔틀") + String routeName, + + @Schema(description = "노선 부가 이름", example = "null") + String subName, + + @Schema(description = "정류장 정보 목록") + List nodeInfo, + + @Schema(description = "회차 정보 목록") + List routeInfo +) { + + @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = "정류장 정보") + public record NodeInfoResponse( + @Schema(description = "정류장 이름", example = "캠퍼스 정문") + String name, + + @Schema(description = "정류장 세부 정보", example = "정문 앞 정류장") + String detail + ) { + } + + @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = "노선 정보") + public record RouteInfoResponse( + @Schema(description = "노선 이름", example = "1회") + String name, + + @Schema(description = "노선 세부 정보", example = "등교") + String detail, + + @Schema(description = "도착 시간 목록", example = "[\"08:00\", \"09:00\"]") + List arrivalTime + ) { + } + + public static ShuttleBusTimetableResponse from(ShuttleBusRoute shuttleBusRoute) { + List nodeInfoResponses = shuttleBusRoute.getNodeInfo().stream() + .map(node -> new NodeInfoResponse(node.getName(), node.getDetail())) + .toList(); + List routeInfoResponses = shuttleBusRoute.getRouteInfo().stream() + .map(route -> new RouteInfoResponse(route.getName(), route.getDetail(), route.getArrivalTime())) + .toList(); + return new ShuttleBusTimetableResponse( + shuttleBusRoute.getId(), + shuttleBusRoute.getRegion().getLabel(), + shuttleBusRoute.getRouteType().getLabel(), + shuttleBusRoute.getRouteName(), + shuttleBusRoute.getSubName(), + nodeInfoResponses, + routeInfoResponses + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalRegionException.java b/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalRegionException.java new file mode 100644 index 000000000..03b9f3ebd --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalRegionException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class BusIllegalRegionException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "버스 지역 구분이 잘못되었습니다."; + + public BusIllegalRegionException(String message) { + super(message); + } + + public BusIllegalRegionException(String message, String detail) { + super(message, detail); + } + + public static BusIllegalStationException withDetail(String detail) { + return new BusIllegalStationException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalRouteTypeException.java b/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalRouteTypeException.java new file mode 100644 index 000000000..ab007e07b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/exception/BusIllegalRouteTypeException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.exception; + +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public class BusIllegalRouteTypeException extends KoinIllegalArgumentException { + + private static final String DEFAULT_MESSAGE = "버스 노선 구분이 잘못되었습니다."; + + public BusIllegalRouteTypeException(String message) { + super(message); + } + + public BusIllegalRouteTypeException(String message, String detail) { + super(message, detail); + } + + public static BusIllegalStationException withDetail(String detail) { + return new BusIllegalStationException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusRouteType.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusRouteType.java new file mode 100644 index 000000000..372913f0d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusRouteType.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.bus.model.enums; + +import java.util.Arrays; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import in.koreatech.koin.domain.bus.exception.BusTypeNotFoundException; + +public enum BusRouteType { + CITY, + EXPRESS, + SHUTTLE, + ALL; + + @JsonCreator + public static BusRouteType from(String busRouteTypeName) { + return Arrays.stream(values()) + .filter(busType -> busType.name().equalsIgnoreCase(busRouteTypeName)) + .findAny() + .orElseThrow(() -> BusTypeNotFoundException.withDetail("busRouteTypeName: " + busRouteTypeName)); + } + + public String getName() { + return this.name().toLowerCase(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStation.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStation.java index 684b286f0..ab2ca6270 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStation.java +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStation.java @@ -10,17 +10,19 @@ @Getter public enum BusStation { - KOREATECH(List.of("학교", "한기대", "코리아텍"), BusStationNode.KOREATECH), - STATION(List.of("천안역", "천안역(학화호두과자)"), BusStationNode.STATION), - TERMINAL(List.of("터미널", "터미널(신세계 앞 횡단보도)", "야우리"), BusStationNode.TERMINAL), + KOREATECH(List.of("학교", "한기대", "코리아텍"), BusStationNode.KOREATECH, "한기대"), + STATION(List.of("천안역", "천안역(학화호두과자)"), BusStationNode.STATION, "천안역"), + TERMINAL(List.of("터미널", "터미널(신세계 앞 횡단보도)", "야우리"), BusStationNode.TERMINAL, "터미널"), ; private final List displayNames; private final BusStationNode node; + private final String queryName; - BusStation(List displayNames, BusStationNode node) { + BusStation(List displayNames, BusStationNode node, String queryName) { this.displayNames = displayNames; this.node = node; + this.queryName = queryName; } @JsonCreator diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/ShuttleBusRegion.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/ShuttleBusRegion.java new file mode 100644 index 000000000..cf9551b10 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/ShuttleBusRegion.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.bus.model.enums; + +import in.koreatech.koin.domain.bus.exception.BusIllegalRegionException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ShuttleBusRegion { + CHEONAN_ASAN("천안・아산"), + CHEONGJU("청주"), + SEOUL("서울"), + DAEJEON_SEJONG("대전・세종"), + ; + + private final String label; + + public static int getOrdinalByLabel(String label) { + for (ShuttleBusRegion region : ShuttleBusRegion.values()) { + if (region.getLabel().equals(label)) { + return region.ordinal(); + } + } + throw BusIllegalRegionException.withDetail("displayName: " + label); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/ShuttleRouteType.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/ShuttleRouteType.java new file mode 100644 index 000000000..8b41f016e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/ShuttleRouteType.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.bus.model.enums; + +import in.koreatech.koin.domain.bus.exception.BusIllegalRouteTypeException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ShuttleRouteType { + SHUTTLE("순환"), + WEEKDAYS("주중"), + WEEKEND("주말"), + ; + + private final String label; + + public static int getOrdinalByLabel(String label) { + for (ShuttleRouteType shuttleRouteType : ShuttleRouteType.values()) { + if (shuttleRouteType.getLabel().equals(label)) { + return shuttleRouteType.ordinal(); + } + } + throw BusIllegalRouteTypeException.withDetail("displayName: " + label); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusSchedule.java b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusSchedule.java new file mode 100644 index 000000000..dd796bce9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusSchedule.java @@ -0,0 +1,57 @@ +package in.koreatech.koin.domain.bus.model.express; + +import java.time.LocalTime; +import java.util.List; + +/** + * 한기대와 천안터미널 사이를 운행하는 대성 고속버스의 운행 스케줄을 정적인 데이터로 저장한 클래스입니다. + * 외부 API 가 동작하지 않는 이슈의 해결 전까지 임시적으로 사용하기 위해 작성되었습니다. + */ +public final class ExpressBusSchedule { + + /** + * 천안 터미널 -> 한기대 출발 시간 + */ + private static final List KOREA_TECH_SCHEDULE = List.of( + LocalTime.of(7, 0), + LocalTime.of(8, 30), + LocalTime.of(9, 0), + LocalTime.of(10, 0), + LocalTime.of(12, 0), + LocalTime.of(12, 30), + LocalTime.of(13, 0), + LocalTime.of(15, 0), + LocalTime.of(16, 0), + LocalTime.of(16, 40), + LocalTime.of(18, 0), + LocalTime.of(19, 30), + LocalTime.of(20, 30) + ); + + /** + * 한기대 -> 천안 터미널 출발 시간 + */ + private static final List TERMINAL_SCHEDULE = List.of( + LocalTime.of(8, 35), + LocalTime.of(10, 35), + LocalTime.of(11, 5), + LocalTime.of(11, 35), + LocalTime.of(13, 35), + LocalTime.of(14, 35), + LocalTime.of(15, 5), + LocalTime.of(16, 35), + LocalTime.of(17, 35), + LocalTime.of(19, 5), + LocalTime.of(19, 35), + LocalTime.of(21, 5), + LocalTime.of(22, 5) + ); + + public static List getExpressBusScheduleToKoreaTech() { + return KOREA_TECH_SCHEDULE; + } + + public static List getExpressBusScheduleToTerminal() { + return TERMINAL_SCHEDULE; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/mongo/CityBusTimetable.java b/src/main/java/in/koreatech/koin/domain/bus/model/mongo/CityBusTimetable.java index 7ca75c899..5096fe355 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/model/mongo/CityBusTimetable.java +++ b/src/main/java/in/koreatech/koin/domain/bus/model/mongo/CityBusTimetable.java @@ -1,12 +1,18 @@ package in.koreatech.koin.domain.bus.model.mongo; +import java.time.DayOfWeek; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; +import in.koreatech.koin.domain.bus.dto.BusScheduleResponse.ScheduleInfo; +import in.koreatech.koin.domain.bus.model.enums.BusStation; import jakarta.persistence.Id; import lombok.AccessLevel; import lombok.Builder; @@ -18,6 +24,20 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class CityBusTimetable { + /** + * 1. 한기대 -> 터미널행 시내버스의 기점은 한기대 정류장이 아님 + * 2. 400번은 병천3리, 402번은 황사동, 405번은 유관순열사사적지에서 출발해서 한기대 정류장에 도착 + * 3. 제공받는 시내버스의 운행 시간은 기점 기준이기 때문에 각 버스의 기점에서 한기대 정류장까지 이동 시간을 더해서 보정해야 함 + */ + private static final Integer ADDITIONAL_TIME_DEPART_TO_KOREATECH_400 = 6; + private static final Integer ADDITIONAL_TIME_DEPART_TO_KOREATECH_402 = 13; + private static final Integer ADDITIONAL_TIME_DEPART_TO_KOREATECH_405 = 7; + /** + * 1. 천안역 -> 한기대행 시내버스의 기점은 천안 터미널 정류장. + * 2. 제공받는 시내버스의 운행 시간은 기점 기준이기 때문에 터미널에서 천안역 정류장까지 이동 시간을 더해서 보정해야 함 + */ + private static final Integer ADDITIONAL_TIME_DEPART_TO_STATION = 7; + @Id @Field("_id") private String routeId; @@ -38,6 +58,15 @@ private CityBusTimetable(BusInfo busInfo, List busTimetables, Loca this.updatedAt = updatedAt; } + public List getScheduleInfo(LocalDate date, BusStation depart) { + Long busNumber = busInfo.getNumber(); + return busTimetables.stream() + .filter(busTimetable -> busTimetable.filterByDayOfWeek(date)) + .flatMap(busTimetable -> busTimetable.applyTimeOffset(busNumber, depart).stream() + .map(time -> new ScheduleInfo("city", busNumber.toString(), time))) + .collect(Collectors.toList()); + } + @Getter public static class BusInfo { @@ -71,5 +100,31 @@ private BusTimetable(String dayOfWeek, List departInfo) { this.dayOfWeek = dayOfWeek; this.departInfo = departInfo; } + + public boolean filterByDayOfWeek(LocalDate date) { + return switch (dayOfWeek) { + case "평일" -> date.getDayOfWeek() != DayOfWeek.SATURDAY && date.getDayOfWeek() != DayOfWeek.SUNDAY; + case "주말" -> date.getDayOfWeek() == DayOfWeek.SATURDAY || date.getDayOfWeek() == DayOfWeek.SUNDAY; + default -> false; + }; + } + + public List applyTimeOffset(Long busNumber, BusStation depart) { + return departInfo.stream() + .map(time -> { + LocalTime schedule = LocalTime.parse(time); + if (busNumber == 400 && depart == BusStation.KOREATECH) { + schedule = schedule.plusMinutes(ADDITIONAL_TIME_DEPART_TO_KOREATECH_400); + } else if (busNumber == 402 && depart == BusStation.KOREATECH) { + schedule = schedule.plusMinutes(ADDITIONAL_TIME_DEPART_TO_KOREATECH_402); + } else if (busNumber == 405 && depart == BusStation.KOREATECH) { + schedule = schedule.plusMinutes(ADDITIONAL_TIME_DEPART_TO_KOREATECH_405); + } else if (depart == BusStation.STATION) { + schedule = schedule.plusMinutes(ADDITIONAL_TIME_DEPART_TO_STATION); + } + return schedule; + }) + .collect(Collectors.toList()); + } } } diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/mongo/Route.java b/src/main/java/in/koreatech/koin/domain/bus/model/mongo/Route.java index 48aab2069..240c0a498 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/model/mongo/Route.java +++ b/src/main/java/in/koreatech/koin/domain/bus/model/mongo/Route.java @@ -1,7 +1,10 @@ package in.koreatech.koin.domain.bus.model.mongo; import java.time.Clock; +import java.time.DayOfWeek; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.format.TextStyle; import java.util.ArrayList; import java.util.List; @@ -9,6 +12,7 @@ import org.springframework.data.mongodb.core.mapping.Field; +import in.koreatech.koin.domain.bus.dto.BusScheduleResponse.ScheduleInfo; import in.koreatech.koin.domain.bus.exception.BusArrivalNodeNotFoundException; import in.koreatech.koin.domain.bus.model.BusRemainTime; import in.koreatech.koin.domain.bus.model.enums.BusStation; @@ -68,6 +72,52 @@ private ArrivalNode convertToArrivalNode(BusStation busStation) { "routeName: " + routeName + ", busStation: " + busStation.name())); } + public boolean filterRoutesByDayOfWeek(LocalDate date) { + DayOfWeek dayOfWeek = date.getDayOfWeek(); + return runningDays.contains(dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.US).toUpperCase()); + } + + public boolean filterDepartAndArriveNode(BusStation departNode, BusStation arriveNode) { + boolean foundDepart = false; + + for (ArrivalNode node : arrivalInfos) { + if (!foundDepart && node.getNodeName().contains(departNode.getQueryName()) + && isValidTimeFormat(node.getArrivalTime())) { + foundDepart = true; + } else if (foundDepart && node.getNodeName().contains(arriveNode.getQueryName())) { + return true; + } + } + + return false; + } + + public ScheduleInfo getShuttleBusScheduleInfo(BusStation depart) { + ArrivalNode findDepartNode = findArrivalNodeByStation(depart); + return new ScheduleInfo("shuttle", routeName, LocalTime.parse(findDepartNode.getArrivalTime())); + } + + public ScheduleInfo getCommutingShuttleBusScheduleInfo(BusStation depart) { + String busType = "한기대".equals(depart.getQueryName()) ? "하교셔틀" : "등교셔틀"; + ArrivalNode findDepartNode = findArrivalNodeByStation(depart); + + return new ScheduleInfo("shuttle", String.format("%s %s", routeName, busType), + LocalTime.parse(findDepartNode.getArrivalTime())); + } + + private ArrivalNode findArrivalNodeByStation(BusStation depart) { + return arrivalInfos.stream() + .filter(arrivalNode -> arrivalNode.getNodeName().contains(depart.getQueryName())) + .findFirst() + .orElseThrow(() -> new BusArrivalNodeNotFoundException("")); + } + + private boolean isValidTimeFormat(String time) { + // HH:mm 형식의 정규식 (00:00부터 23:59까지 유효) + String timeRegex = "([01]\\d|2[0-3]):[0-5]\\d"; + return time != null && time.matches(timeRegex); + } + @Builder private Route(String routeName, List runningDays, List arrivalInfos) { this.routeName = routeName; diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/mongo/ShuttleBusRoute.java b/src/main/java/in/koreatech/koin/domain/bus/model/mongo/ShuttleBusRoute.java new file mode 100644 index 000000000..d13e68730 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/mongo/ShuttleBusRoute.java @@ -0,0 +1,72 @@ +package in.koreatech.koin.domain.bus.model.mongo; + +import static lombok.AccessLevel.PROTECTED; + +import java.util.List; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import in.koreatech.koin.domain.bus.model.enums.ShuttleBusRegion; +import in.koreatech.koin.domain.bus.model.enums.ShuttleRouteType; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Document(collection = "shuttlebus_timetables") +public class ShuttleBusRoute { + + @Id + private String id; + + @Field("semester_type") + private String semesterType; + + @Field("region") + private ShuttleBusRegion region; + + @Field("route_type") + private ShuttleRouteType routeType; + + @Field("route_name") + private String routeName; + + @Field("sub_name") + private String subName; + + @Field("node_info") + private List nodeInfo; + + @Field("route_info") + private List routeInfo; + + @Getter + @NoArgsConstructor(access = PROTECTED) + public static class NodeInfo { + + @Field("name") + private String name; + + @Field("detail") + private String detail; + } + + @Getter + @NoArgsConstructor(access = PROTECTED) + public static class RouteInfo { + + @Field("name") + private String name; + + @Field("detail") + private String detail; + + @Field("running_days") + private List runningDays; + + @Field("arrival_time") + private List arrivalTime; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/repository/BusRepository.java b/src/main/java/in/koreatech/koin/domain/bus/repository/BusRepository.java index f6a6ca3ff..2d6505830 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/repository/BusRepository.java +++ b/src/main/java/in/koreatech/koin/domain/bus/repository/BusRepository.java @@ -16,6 +16,8 @@ public interface BusRepository extends Repository { List findByBusType(String busType); + List findByBusTypeAndRegion(String busType, String region); + Optional findByBusTypeAndDirectionAndRegion(String busType, String direction, String region); default BusCourse getByBusTypeAndDirectionAndRegion(String busType, String direction, String region) { diff --git a/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusTimetableRepository.java b/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusTimetableRepository.java index 252ca36a9..6d631a443 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusTimetableRepository.java +++ b/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusTimetableRepository.java @@ -15,6 +15,15 @@ public interface CityBusTimetableRepository extends Repository BusCacheNotFoundException.withDetail("number: " + number + ", direction: " + arrivalNode + " 기점 방향")); + .orElseThrow(() -> BusCacheNotFoundException.withDetail( + "number: " + number + ", direction: " + arrivalNode + " 기점 방향")); + } + + Optional findByBusInfoNumberAndBusInfoDepart(Long number, String departNode); + + default CityBusTimetable getByBusInfoNumberAndBusInfoDepart(Long number, String departNode) { + return findByBusInfoNumberAndBusInfoDepart(number, departNode) + .orElseThrow(() -> BusCacheNotFoundException.withDetail( + "number: " + number + ", direction: " + departNode + " 종점 방향")); } } diff --git a/src/main/java/in/koreatech/koin/domain/bus/repository/ShuttleBusRepository.java b/src/main/java/in/koreatech/koin/domain/bus/repository/ShuttleBusRepository.java new file mode 100644 index 000000000..2d60c4587 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/repository/ShuttleBusRepository.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.bus.repository; + +import java.util.List; +import java.util.Optional; + +import org.bson.types.ObjectId; +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.bus.exception.BusNotFoundException; +import in.koreatech.koin.domain.bus.model.mongo.ShuttleBusRoute; + +public interface ShuttleBusRepository extends Repository { + + List findBySemesterType(String semesterType); + + Optional findById(String id); + + default ShuttleBusRoute getById(String id) { + return findById(id).orElseThrow( + () -> BusNotFoundException.withDetail("id: " + id)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java b/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java index 6eb49f5c1..0bad5142f 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java +++ b/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java @@ -9,6 +9,7 @@ import java.time.ZonedDateTime; import java.time.format.TextStyle; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Locale; @@ -19,6 +20,9 @@ import in.koreatech.koin.domain.bus.dto.BusCourseResponse; import in.koreatech.koin.domain.bus.dto.BusRemainTimeResponse; +import in.koreatech.koin.domain.bus.dto.BusRouteCommand; +import in.koreatech.koin.domain.bus.dto.BusScheduleResponse; +import in.koreatech.koin.domain.bus.dto.BusScheduleResponse.ScheduleInfo; import in.koreatech.koin.domain.bus.dto.BusTimetableResponse; import in.koreatech.koin.domain.bus.dto.CityBusTimetableResponse; import in.koreatech.koin.domain.bus.dto.SingleBusTimeResponse; @@ -37,6 +41,7 @@ import in.koreatech.koin.domain.bus.model.mongo.Route; import in.koreatech.koin.domain.bus.repository.BusRepository; import in.koreatech.koin.domain.bus.repository.CityBusTimetableRepository; +import in.koreatech.koin.domain.bus.service.route.BusRouteStrategy; import in.koreatech.koin.domain.bus.util.city.CityBusClient; import in.koreatech.koin.domain.bus.util.city.CityBusRouteClient; import in.koreatech.koin.domain.bus.util.express.ExpressBusService; @@ -57,6 +62,7 @@ public class BusService { private final ExpressBusService expressBusService; private final CityBusRouteClient cityBusRouteClient; private final VersionService versionService; + private final List busRouteStrategies; @Transactional public BusRemainTimeResponse getBusRemainTime(BusType busType, BusStation depart, BusStation arrival) { @@ -229,4 +235,24 @@ public CityBusTimetableResponse getCityBusTimetable(Long busNumber, CityBusDirec return CityBusTimetableResponse.from(timetable); } + + public BusScheduleResponse getBusSchedule(BusRouteCommand request) { + List scheduleInfoList = Collections.emptyList(); + + if (request.checkAvailableCourse()) { + scheduleInfoList = busRouteStrategies.stream() + .filter(strategy -> strategy.support(request.busRouteType())) + .flatMap(strategy -> strategy.findSchedule(request).stream()) + .filter(schedule -> schedule.departTime().isAfter(request.time())) + .sorted(Comparator.comparing(ScheduleInfo::departTime) + .thenComparing(ScheduleInfo.compareBusType()) + ) + .toList(); + } + + return new BusScheduleResponse( + request.depart(), request.arrive(), request.date(), request.time(), + scheduleInfoList + ); + } } diff --git a/src/main/java/in/koreatech/koin/domain/bus/service/ShuttleBusService.java b/src/main/java/in/koreatech/koin/domain/bus/service/ShuttleBusService.java new file mode 100644 index 000000000..731bc729d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/service/ShuttleBusService.java @@ -0,0 +1,34 @@ +package in.koreatech.koin.domain.bus.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.bus.dto.ShuttleBusRoutesResponse; +import in.koreatech.koin.domain.bus.dto.ShuttleBusTimetableResponse; +import in.koreatech.koin.domain.bus.model.mongo.ShuttleBusRoute; +import in.koreatech.koin.domain.bus.repository.ShuttleBusRepository; +import in.koreatech.koin.domain.version.dto.VersionMessageResponse; +import in.koreatech.koin.domain.version.service.VersionService; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ShuttleBusService { + + private final ShuttleBusRepository shuttleBusRepository; + private final VersionService versionService; + + public ShuttleBusRoutesResponse getShuttleBusRoutes() { + VersionMessageResponse version = versionService.getVersionWithMessage("shuttle_bus_timetable"); + List shuttleBusRoutes = shuttleBusRepository.findBySemesterType(version.title()); + return ShuttleBusRoutesResponse.of(shuttleBusRoutes, version); + } + + public ShuttleBusTimetableResponse getShuttleBusTimetable(String id) { + ShuttleBusRoute shuttleBusRoute = shuttleBusRepository.getById(id); + return ShuttleBusTimetableResponse.from(shuttleBusRoute); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/service/route/BusRouteStrategy.java b/src/main/java/in/koreatech/koin/domain/bus/service/route/BusRouteStrategy.java new file mode 100644 index 000000000..332f89b67 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/service/route/BusRouteStrategy.java @@ -0,0 +1,13 @@ +package in.koreatech.koin.domain.bus.service.route; + +import java.util.List; + +import in.koreatech.koin.domain.bus.dto.BusRouteCommand; +import in.koreatech.koin.domain.bus.dto.BusScheduleResponse.ScheduleInfo; +import in.koreatech.koin.domain.bus.model.enums.BusRouteType; + +public interface BusRouteStrategy { + + List findSchedule(BusRouteCommand command); + boolean support(BusRouteType type); +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/service/route/CityBusRouteStrategy.java b/src/main/java/in/koreatech/koin/domain/bus/service/route/CityBusRouteStrategy.java new file mode 100644 index 000000000..d4eb4cd2e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/service/route/CityBusRouteStrategy.java @@ -0,0 +1,52 @@ +package in.koreatech.koin.domain.bus.service.route; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; + +import in.koreatech.koin.domain.bus.dto.BusRouteCommand; +import in.koreatech.koin.domain.bus.dto.BusScheduleResponse.ScheduleInfo; +import in.koreatech.koin.domain.bus.model.enums.BusRouteType; +import in.koreatech.koin.domain.bus.model.enums.BusStation; +import in.koreatech.koin.domain.bus.model.enums.CityBusDirection; +import in.koreatech.koin.domain.bus.model.mongo.CityBusTimetable; +import in.koreatech.koin.domain.bus.repository.CityBusTimetableRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CityBusRouteStrategy implements BusRouteStrategy { + + private final CityBusTimetableRepository cityBusTimetableRepository; + private static final Map CITY_BUS_INFO = Map.of( + 400L, CityBusDirection.병천3리, + 402L, CityBusDirection.황사동, + 405L, CityBusDirection.유관순열사사적지 + ); + + @Override + public List findSchedule(BusRouteCommand command) { + if (command.depart() == BusStation.STATION && command.arrive() == BusStation.TERMINAL) + return Collections.emptyList(); + + return CITY_BUS_INFO.entrySet().stream() + .map(entry -> getScheduleForRoute(entry.getKey(), command.depart(), entry.getValue())) + .flatMap(route -> route.getScheduleInfo(command.date(), command.depart()).stream()) + .toList(); + } + + private CityBusTimetable getScheduleForRoute(Long busNumber, BusStation depart, CityBusDirection cityBusInfo) { + if (depart == BusStation.TERMINAL) { + return cityBusTimetableRepository.getByBusInfoNumberAndBusInfoDepart(busNumber, "종합터미널"); + } else { + return cityBusTimetableRepository.getByBusInfoNumberAndBusInfoDepart(busNumber, cityBusInfo.getName()); + } + } + + @Override + public boolean support(BusRouteType type) { + return type == BusRouteType.CITY || type == BusRouteType.ALL; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/service/route/CommutingBusRouteStrategy.java b/src/main/java/in/koreatech/koin/domain/bus/service/route/CommutingBusRouteStrategy.java new file mode 100644 index 000000000..c4cd89298 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/service/route/CommutingBusRouteStrategy.java @@ -0,0 +1,40 @@ +package in.koreatech.koin.domain.bus.service.route; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import in.koreatech.koin.domain.bus.dto.BusRouteCommand; +import in.koreatech.koin.domain.bus.dto.BusScheduleResponse.ScheduleInfo; +import in.koreatech.koin.domain.bus.model.enums.BusRouteType; +import in.koreatech.koin.domain.bus.model.mongo.BusCourse; +import in.koreatech.koin.domain.bus.repository.BusRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CommutingBusRouteStrategy implements BusRouteStrategy { + + private final BusRepository busRepository; + private static final String BUS_TYPE = "commuting"; + private static final String REGION = "천안"; + + @Override + public List findSchedule(BusRouteCommand command) { + return busRepository.findByBusTypeAndRegion(BUS_TYPE, REGION).stream() + .map(BusCourse::getRoutes) + .flatMap(routes -> + routes.stream() + .filter(route -> route.filterRoutesByDayOfWeek(command.date())) + .filter(route -> route.filterDepartAndArriveNode(command.depart(), command.arrive())) + .map(route -> route.getCommutingShuttleBusScheduleInfo(command.depart())) + ) + .distinct() + .toList(); + } + + @Override + public boolean support(BusRouteType type) { + return type == BusRouteType.SHUTTLE || type == BusRouteType.ALL; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/service/route/ExpressBusRouteStrategy.java b/src/main/java/in/koreatech/koin/domain/bus/service/route/ExpressBusRouteStrategy.java new file mode 100644 index 000000000..8e66519fe --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/service/route/ExpressBusRouteStrategy.java @@ -0,0 +1,52 @@ +package in.koreatech.koin.domain.bus.service.route; + +import java.time.LocalTime; +import java.util.Collections; +import java.util.List; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.bus.dto.BusRouteCommand; +import in.koreatech.koin.domain.bus.dto.BusScheduleResponse.ScheduleInfo; +import in.koreatech.koin.domain.bus.model.enums.BusDirection; +import in.koreatech.koin.domain.bus.model.enums.BusRouteType; +import in.koreatech.koin.domain.bus.model.enums.BusStation; +import in.koreatech.koin.domain.bus.model.express.ExpressBusSchedule; + +@Component +public class ExpressBusRouteStrategy implements BusRouteStrategy { + + private static final String BUS_TYPE = "express"; + private static final String BUS_NAME = "대성티엔이"; + + @Override + public List findSchedule(BusRouteCommand command) { + if(validCourse(command.depart(), command.arrive())) return Collections.emptyList(); + BusDirection direction = getRouteDirection(command.depart(), command.arrive()); + + return getStaticExpressBusScheduleTimeList(direction).stream() + .map(time -> new ScheduleInfo(BUS_TYPE, BUS_NAME, time)) + .toList(); + } + + @Override + public boolean support(BusRouteType type) { + return type == BusRouteType.EXPRESS || type == BusRouteType.ALL; + } + + private List getStaticExpressBusScheduleTimeList(BusDirection direction) { + return switch (direction) { + case NORTH -> ExpressBusSchedule.getExpressBusScheduleToKoreaTech(); + case SOUTH -> ExpressBusSchedule.getExpressBusScheduleToTerminal(); + }; + } + + private BusDirection getRouteDirection(BusStation depart, BusStation arrive) { + return (depart == BusStation.KOREATECH && arrive == BusStation.TERMINAL) + ? BusDirection.NORTH : BusDirection.SOUTH; + } + + private boolean validCourse(BusStation depart, BusStation arrive) { + return (depart == BusStation.STATION || arrive == BusStation.STATION); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/service/route/ShuttleBusRouteStrategy.java b/src/main/java/in/koreatech/koin/domain/bus/service/route/ShuttleBusRouteStrategy.java new file mode 100644 index 000000000..7a86b3970 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/service/route/ShuttleBusRouteStrategy.java @@ -0,0 +1,40 @@ +package in.koreatech.koin.domain.bus.service.route; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import in.koreatech.koin.domain.bus.dto.BusRouteCommand; +import in.koreatech.koin.domain.bus.dto.BusScheduleResponse.ScheduleInfo; +import in.koreatech.koin.domain.bus.model.enums.BusRouteType; +import in.koreatech.koin.domain.bus.model.mongo.BusCourse; +import in.koreatech.koin.domain.bus.repository.BusRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ShuttleBusRouteStrategy implements BusRouteStrategy { + + private final BusRepository busRepository; + private static final String BUS_TYPE = "shuttle"; + private static final String REGION = "천안"; + + @Override + public List findSchedule(BusRouteCommand command) { + return busRepository.findByBusTypeAndRegion(BUS_TYPE, REGION).stream() + .map(BusCourse::getRoutes) + .flatMap(routes -> + routes.stream() + .filter(route -> route.filterRoutesByDayOfWeek(command.date())) + .filter(route -> route.filterDepartAndArriveNode(command.depart(), command.arrive())) + .map(route -> route.getShuttleBusScheduleInfo(command.depart())) + ) + .distinct() + .toList(); + } + + @Override + public boolean support(BusRouteType type) { + return type == BusRouteType.SHUTTLE || type == BusRouteType.ALL; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java b/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java index ee3daac70..9e72a3b09 100644 --- a/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java +++ b/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java @@ -22,8 +22,6 @@ import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.VerticalAlignment; import org.apache.poi.ss.usermodel.Workbook; -import org.apache.poi.xssf.streaming.SXSSFCell; -import org.apache.poi.xssf.streaming.SXSSFRow; import org.apache.poi.xssf.streaming.SXSSFSheet; import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.springframework.context.ApplicationEventPublisher; @@ -69,6 +67,7 @@ public class CoopService { private final PasswordEncoder passwordEncoder; private final JwtProvider jwtProvider; + public static final LocalDate LIMIT_DATE = LocalDate.of(2022, 11, 29); private final int EXCEL_COLUMN_COUNT = 8; @Transactional @@ -120,124 +119,118 @@ public CoopLoginResponse coopLogin(CoopLoginRequest request) { } public ByteArrayInputStream generateDiningExcel(LocalDate startDate, LocalDate endDate, Boolean isCafeteria) { - checkDate(startDate, endDate); - - List dinings; - - if (isCafeteria) { - List placeFilters = Arrays.asList("A코너", "B코너", "C코너"); - dinings = diningRepository.findByDateBetweenAndPlaceIn(startDate, endDate, placeFilters); - } else { - dinings = diningRepository.findByDateBetween(startDate, endDate); - } + validateDates(startDate, endDate); + List dinings = fetchDiningData(startDate, endDate, isCafeteria); try (SXSSFWorkbook workbook = new SXSSFWorkbook()) { - SXSSFSheet sheet = workbook.createSheet("식단 메뉴"); - sheet.setRandomAccessWindowSize(100); + SXSSFSheet sheet = createSheet(workbook, "식단 메뉴"); + CellStyle headerStyle = createHeaderStyle(workbook); + CellStyle commonStyle = createCommonStyle(workbook); - CellStyle headerStyle = makeHeaderStyle(workbook); - CellStyle commonStyle = makeCommonStyle(workbook); - createHeaderCell(sheet, headerStyle); - - ByteArrayInputStream result = putDiningData(dinings, sheet, commonStyle, workbook); - - return result; + addHeaderRow(sheet, headerStyle); + addDiningDataToSheet(dinings, sheet, commonStyle); + return writeWorkbookToStream(workbook); } catch (IOException e) { throw new RuntimeException("엑셀 파일 생성 중 오류가 발생했습니다.", e); } } - private static void checkDate(LocalDate startDate, LocalDate endDate) { - LocalDate limitDate = LocalDate.of(2022, 11, 29); - if (startDate.isBefore(limitDate) || endDate.isBefore(limitDate)) { + private void validateDates(LocalDate startDate, LocalDate endDate) { + LocalDate today = LocalDate.now(); + + if (startDate.isBefore(LIMIT_DATE) || endDate.isBefore(LIMIT_DATE)) { throw new DiningLimitDateException("2022/11/29 식단부터 다운받을 수 있어요."); } - - LocalDate now = LocalDate.now(); - if (startDate.isAfter(now) || endDate.isAfter(now)) { + if (startDate.isAfter(today) || endDate.isAfter(today)) { throw new DiningNowDateException("오늘 날짜 이후 기간은 설정할 수 없어요."); } - if (startDate.isAfter(endDate)) { throw new StartDateAfterEndDateException("시작일은 종료일 이전으로 설정해주세요."); } } - private ByteArrayInputStream putDiningData(List dinings, SXSSFSheet sheet, CellStyle commonStyle, - SXSSFWorkbook workbook) throws IOException { - AtomicInteger rowIdx = new AtomicInteger(1); + private List fetchDiningData(LocalDate startDate, LocalDate endDate, Boolean isCafeteria) { + if (isCafeteria) { + List cafeteriaPlaces = Arrays.asList("A코너", "B코너", "C코너"); + return diningRepository.findByDateBetweenAndPlaceIn(startDate, endDate, cafeteriaPlaces); + } + return diningRepository.findByDateBetween(startDate, endDate); + } + + private SXSSFSheet createSheet(SXSSFWorkbook workbook, String sheetName) { + SXSSFSheet sheet = workbook.createSheet(sheetName); + sheet.setRandomAccessWindowSize(100); + return sheet; + } + + private CellStyle createHeaderStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + Font font = workbook.createFont(); + font.setBold(true); + font.setColor(IndexedColors.WHITE.getIndex()); + style.setFont(font); + style.setFillForegroundColor(IndexedColors.LIGHT_BLUE.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + style.setAlignment(HorizontalAlignment.CENTER); + return style; + } + + private CellStyle createCommonStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setAlignment(HorizontalAlignment.CENTER); + style.setVerticalAlignment(VerticalAlignment.CENTER); + style.setWrapText(true); + return style; + } + + private void addHeaderRow(Sheet sheet, CellStyle headerStyle) { + String[] headers = {"날짜", "타입", "코너", "칼로리", "메뉴", "이미지", "품절 여부", "변경 여부"}; + Row headerRow = sheet.createRow(0); + + for (int i = 0; i < headers.length; i++) { + Cell cell = headerRow.createCell(i); + cell.setCellValue(headers[i]); + cell.setCellStyle(headerStyle); + } + } + private void addDiningDataToSheet(List dinings, SXSSFSheet sheet, CellStyle commonStyle) { + AtomicInteger rowIndex = new AtomicInteger(1); dinings.forEach(dining -> { - SXSSFRow row = sheet.createRow(rowIdx.getAndIncrement()); - row.createCell(0).setCellValue(dining.getDate().toString()); - row.createCell(1).setCellValue(dining.getType().getDiningName()); - row.createCell(2).setCellValue(dining.getPlace()); - row.createCell(3).setCellValue(dining.getKcal() != null ? dining.getKcal() : 0); - - String formattedMenu = dining.getMenu().toString() - .replaceAll("^\\[|\\]$", "") - .replaceAll(", ", "\n"); - - SXSSFCell menuCell = row.createCell(4); - menuCell.setCellValue(formattedMenu); - - row.createCell(5).setCellValue(dining.getImageUrl()); - row.createCell(6).setCellValue( - Optional.ofNullable(dining.getSoldOut()).map(Object::toString).orElse("") - ); - row.createCell(7).setCellValue(Optional.ofNullable(dining.getIsChanged()).map(Object::toString).orElse("")); - - for (int i = 0; i < EXCEL_COLUMN_COUNT; i++) { - row.getCell(i).setCellStyle(commonStyle); - } + Row row = sheet.createRow(rowIndex.getAndIncrement()); + fillDiningRow(dining, row, commonStyle); }); for (int i = 0; i < EXCEL_COLUMN_COUNT; i++) { sheet.setColumnWidth(i, 6000); } - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - workbook.write(out); - workbook.close(); - workbook.dispose(); - return new ByteArrayInputStream(out.toByteArray()); } - private void createHeaderCell(Sheet sheet, CellStyle headerStyle) { - Row headerRow = sheet.createRow(0); - headerRow.createCell(0).setCellValue("날짜"); - headerRow.createCell(1).setCellValue("타입"); - headerRow.createCell(2).setCellValue("코너"); - headerRow.createCell(3).setCellValue("칼로리"); - headerRow.createCell(4).setCellValue("메뉴"); - headerRow.createCell(5).setCellValue("이미지"); - headerRow.createCell(6).setCellValue("품절 여부"); - headerRow.createCell(7).setCellValue("변경 여부"); - - for (int i = 0; i < EXCEL_COLUMN_COUNT; i++) { - Cell cell = headerRow.getCell(i); - cell.setCellStyle(headerStyle); + private void fillDiningRow(Dining dining, Row row, CellStyle commonStyle) { + row.createCell(0).setCellValue(dining.getDate().toString()); + row.createCell(1).setCellValue(dining.getType().getDiningName()); + row.createCell(2).setCellValue(dining.getPlace()); + row.createCell(3).setCellValue(Optional.ofNullable(dining.getKcal()).orElse(0)); + row.createCell(4).setCellValue(formatMenu(dining.getMenu())); + row.createCell(5).setCellValue(dining.getImageUrl()); + row.createCell(6).setCellValue(Optional.ofNullable(dining.getSoldOut()).map(Object::toString).orElse("")); + row.createCell(7).setCellValue(Optional.ofNullable(dining.getIsChanged()).map(Object::toString).orElse("")); + + for (int i = 0; i < 8; i++) { + row.getCell(i).setCellStyle(commonStyle); } } - private static CellStyle makeCommonStyle(Workbook workbook) { - CellStyle commonStyle = workbook.createCellStyle(); - commonStyle.setAlignment(HorizontalAlignment.CENTER); - commonStyle.setVerticalAlignment(VerticalAlignment.CENTER); - commonStyle.setWrapText(true); - return commonStyle; + private String formatMenu(List menu) { + return String.join("\n", menu); } - private static CellStyle makeHeaderStyle(Workbook workbook) { - CellStyle headerStyle = workbook.createCellStyle(); - Font font = workbook.createFont(); - font.setBold(true); - font.setColor(IndexedColors.WHITE.getIndex()); - headerStyle.setFont(font); - headerStyle.setFillForegroundColor(IndexedColors.LIGHT_BLUE.getIndex()); - headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); - headerStyle.setAlignment(HorizontalAlignment.CENTER); - return headerStyle; + private ByteArrayInputStream writeWorkbookToStream(SXSSFWorkbook workbook) throws IOException { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + workbook.write(out); + workbook.dispose(); + return new ByteArrayInputStream(out.toByteArray()); + } } } diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/shop/response/ShopsResponseV2.java b/src/main/java/in/koreatech/koin/domain/shop/dto/shop/response/ShopsResponseV2.java index a391a3204..e0700f1ff 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/dto/shop/response/ShopsResponseV2.java +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/shop/response/ShopsResponseV2.java @@ -42,7 +42,8 @@ public static ShopsResponseV2 from( ShopsSortCriteria sortBy, List shopsFilterCriterias, LocalDateTime now, - String query + String query, + Map> benefitDetail ) { List innerShopResponses = shops.stream() .filter(queryPredicate(query)) @@ -53,7 +54,8 @@ public static ShopsResponseV2 from( shopInfo.durationEvent(), it.isOpen(now), shopInfo.averageRate(), - shopInfo.reviewCount() + shopInfo.reviewCount(), + benefitDetail.getOrDefault(it.id(), List.of()) ); }) .filter(ShopsFilterCriteria.createCombinedFilter(shopsFilterCriterias)) @@ -101,7 +103,10 @@ public record InnerShopResponse( double averageRate, @Schema(example = "10", description = "리뷰 개수", requiredMode = REQUIRED) - long reviewCount + long reviewCount, + + @Schema(example = "['배달비 무료', '콜라 서비스']", description = "혜택 설명", requiredMode = NOT_REQUIRED) + List benefitDetails ) { @JsonNaming(value = SnakeCaseStrategy.class) @@ -138,7 +143,8 @@ public static InnerShopResponse from( Boolean isEvent, Boolean isOpen, Double averageRate, - Long reviewCount + Long reviewCount, + List benefitDetails ) { return new InnerShopResponse( shop.shopCategories().stream().map(ShopCategoryCache::id).toList(), @@ -159,7 +165,8 @@ public static InnerShopResponse from( isEvent, isOpen, averageRate, - reviewCount + reviewCount, + benefitDetails ); } 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 edd973b1a..d73512fbb 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,8 @@ import static in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType.REVIEW_PROMPT; +import in.koreatech.koin.domain.benefit.model.BenefitCategoryMap; +import in.koreatech.koin.domain.benefit.repository.BenefitCategoryMapRepository; import in.koreatech.koin.domain.shop.cache.ShopsCacheService; import in.koreatech.koin.domain.shop.cache.dto.ShopsCache; import in.koreatech.koin.domain.shop.dto.shop.ShopsFilterCriteria; @@ -24,6 +26,8 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -43,6 +47,7 @@ public class ShopService { private final ShopCustomRepository shopCustomRepository; private final NotificationSubscribeRepository notificationSubscribeRepository; private final ShopReviewNotificationRedisRepository shopReviewNotificationRedisRepository; + private final BenefitCategoryMapRepository benefitCategoryMapRepository; public ShopResponse getShop(Integer shopId) { Shop shop = shopRepository.getById(shopId); @@ -63,9 +68,9 @@ public ShopCategoriesResponse getShopsCategories() { } public ShopsResponseV2 getShopsV2( - ShopsSortCriteria sortBy, - List filterCriteria, - String query + ShopsSortCriteria sortBy, + List filterCriteria, + String query ) { if (filterCriteria.contains(null)) { throw KoinIllegalArgumentException.withDetail("유효하지 않은 필터입니다."); @@ -73,13 +78,27 @@ public ShopsResponseV2 getShopsV2( ShopsCache shopCaches = shopsCache.findAllShopCache(); LocalDateTime now = LocalDateTime.now(clock); Map shopInfoMap = shopCustomRepository.findAllShopInfo(now); + List benefitCategorys = benefitCategoryMapRepository.findAllWithFetchJoin(); + Map> benefitDetailMap = new HashMap<>(benefitCategorys.size()); + benefitCategorys.forEach(benefitCategory -> { + int shopId = benefitCategory.getShop().getId(); + String benefitDetail = benefitCategory.getDetail(); + if (benefitDetailMap.containsKey(shopId)) { + benefitDetailMap.get(shopId).add(benefitDetail); + } else { + List details = new ArrayList<>(); + details.add(benefitDetail); + benefitDetailMap.put(shopId, details); + } + }); return ShopsResponseV2.from( - shopCaches.shopCaches(), - shopInfoMap, - sortBy, - filterCriteria, - now, - query + shopCaches.shopCaches(), + shopInfoMap, + sortBy, + filterCriteria, + now, + query, + benefitDetailMap ); } @@ -88,9 +107,9 @@ public void publishCallNotification(Integer shopId, Integer studentId) { if (isSubscribeReviewNotification(studentId)) { ShopReviewNotification shopReviewNotification = ShopReviewNotification.builder() - .shopId(shopId) - .studentId(studentId) - .build(); + .shopId(shopId) + .studentId(studentId) + .build(); double score = LocalDateTime.now(clock).plusHours(1).toEpochSecond(ZoneOffset.UTC); shopReviewNotificationRedisRepository.save(shopReviewNotification, score); diff --git a/src/main/resources/db/migration/V104__add_shop_benefit_detail.sql b/src/main/resources/db/migration/V104__add_shop_benefit_detail.sql new file mode 100644 index 000000000..c25f2cb76 --- /dev/null +++ b/src/main/resources/db/migration/V104__add_shop_benefit_detail.sql @@ -0,0 +1,2 @@ +ALTER TABLE `shop_benefit_category_map` + ADD COLUMN `detail` VARCHAR(20); diff --git a/src/test/java/in/koreatech/koin/acceptance/BenefitApiTest.java b/src/test/java/in/koreatech/koin/acceptance/BenefitApiTest.java index 8de1c7301..836d56fc1 100644 --- a/src/test/java/in/koreatech/koin/acceptance/BenefitApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/BenefitApiTest.java @@ -95,7 +95,7 @@ void setup() { 성빈_학생 = userFixture.성빈_학생(); - benefitCategoryMapFixture.혜택_추가(김밥천국, 배달비_무료); + benefitCategoryMapFixture.설명이_포함된_혜택_추가(김밥천국, 배달비_무료, "무료"); benefitCategoryMapFixture.혜택_추가(마슬랜, 배달비_무료); benefitCategoryMapFixture.혜택_추가(영업중인_티바, 배달비_무료); benefitCategoryMapFixture.혜택_추가(영업중이_아닌_신전_떡볶이, 배달비_무료); @@ -183,7 +183,8 @@ void setup() { "is_event": false, "is_open": true, "average_rate": 5.0, - "review_count": 1 + "review_count": 1, + "benefit_detail": "무료" }, { "category_ids": [], diff --git a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java index 60c6a7a0a..90c103288 100644 --- a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java @@ -7,6 +7,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import in.koreatech.koin.domain.benefit.model.BenefitCategory; +import in.koreatech.koin.fixture.BenefitCategoryFixture; +import in.koreatech.koin.fixture.BenefitCategoryMapFixture; import java.time.LocalDate; import org.junit.jupiter.api.BeforeAll; @@ -40,6 +43,12 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) class ShopApiTest extends AcceptanceTest { + @Autowired + private BenefitCategoryFixture benefitCategoryFixture; + + @Autowired + private BenefitCategoryMapFixture benefitCategoryMapFixture; + @Autowired private UserFixture userFixture; @@ -1897,4 +1906,64 @@ void setUp() { ) .andExpect(status().isOk()); } + + @Test + void 리뷰를_조회하면_혜택_정보가_조회된다() throws Exception { + Shop 영업중인_티바 = shopFixture.영업중인_티바(owner); + shopReviewFixture.리뷰_4점(익명_학생, 영업중인_티바); + + shopReviewFixture.리뷰_4점(익명_학생, 마슬랜); + shopReviewFixture.리뷰_4점(익명_학생, 마슬랜); + // 2024-01-15 12:00 월요일 기준 + boolean 마슬랜_영업여부 = true; + boolean 티바_영업여부 = true; + + BenefitCategory 최소주문금액_무료 = benefitCategoryFixture.최소주문금액_무료(); + BenefitCategory 서비스_증정 = benefitCategoryFixture.서비스_증정(); + benefitCategoryMapFixture.설명이_포함된_혜택_추가(영업중인_티바, 최소주문금액_무료, "무료"); + benefitCategoryMapFixture.설명이_포함된_혜택_추가(영업중인_티바, 서비스_증정, "콜라"); + 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, + "benefit_details": [] + },{ + "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, + "benefit_details": ["무료", "콜라"] + } + ] + } + """, 티바_영업여부, 마슬랜_영업여부))); + } } 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 1dafe5c1e..f2b5b6c29 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminBenefitApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminBenefitApiTest.java @@ -6,6 +6,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -13,6 +15,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import in.koreatech.koin.AcceptanceTest; import in.koreatech.koin.admin.benefit.repository.AdminBenefitCategoryMapRepository; @@ -64,6 +67,9 @@ public class AdminBenefitApiTest extends AcceptanceTest { @Autowired ShopNotificationMessageFixture shopNotificationMessageFixture; + @Autowired + private TransactionTemplate transactionTemplate; + Admin admin; String token_admin; Owner 현수_사장님; @@ -73,6 +79,11 @@ public class AdminBenefitApiTest extends AcceptanceTest { BenefitCategory 서비스_증정; BenefitCategory 가게까지_픽업; + BenefitCategoryMap 김밥천국_혜택; + BenefitCategoryMap 마슬랜_혜택; + BenefitCategoryMap 티바_혜택; + BenefitCategoryMap 신전_혜택; + Shop 마슬랜; Shop 김밥천국; Shop 영업중인_티바; @@ -99,10 +110,10 @@ void setup() { 영업중인_티바 = shopFixture.영업중인_티바(현수_사장님); 영업중이_아닌_신전_떡볶이 = shopFixture.영업중이_아닌_신전_떡볶이(현수_사장님); - benefitCategoryMapFixture.혜택_추가(김밥천국, 배달비_무료); - benefitCategoryMapFixture.혜택_추가(마슬랜, 배달비_무료); - benefitCategoryMapFixture.혜택_추가(영업중인_티바, 배달비_무료); - benefitCategoryMapFixture.혜택_추가(영업중이_아닌_신전_떡볶이, 배달비_무료); + 김밥천국_혜택 = benefitCategoryMapFixture.설명이_포함된_혜택_추가(김밥천국, 배달비_무료, "설명1"); + 마슬랜_혜택 = benefitCategoryMapFixture.설명이_포함된_혜택_추가(마슬랜, 배달비_무료, "설명2"); + 티바_혜택 = benefitCategoryMapFixture.설명이_포함된_혜택_추가(영업중인_티바, 배달비_무료, "설명3"); + 신전_혜택 = benefitCategoryMapFixture.설명이_포함된_혜택_추가(영업중이_아닌_신전_떡볶이, 배달비_무료, "설명4"); notificationMessage_가게 = shopNotificationMessageFixture.알림메시지_가게(); shopParentCategory_가게 = shopParentCategoryFixture.상위_카테고리_가게(notificationMessage_가게); @@ -233,24 +244,36 @@ void setup() { "count": 4, "shops": [ { + "shop_benefit_map_id": %d, "id": %d, - "name": "김밥천국" + "name": "김밥천국", + "detail": "설명1" }, { + "shop_benefit_map_id": %d, "id": %d, - "name": "마슬랜 치킨" + "name": "마슬랜 치킨", + "detail": "설명2" }, { + "shop_benefit_map_id": %d, "id": %d, - "name": "티바" + "name": "티바", + "detail": "설명3" }, { + "shop_benefit_map_id": %d, "id": %d, - "name": "신전 떡볶이" + "name": "신전 떡볶이", + "detail": "설명4" } ] } - """, 김밥천국.getId(), 마슬랜.getId(), 영업중인_티바.getId(), 영업중이_아닌_신전_떡볶이.getId()))); + """, + 김밥천국_혜택.getId(), 김밥천국.getId(), + 마슬랜_혜택.getId(), 마슬랜.getId(), + 티바_혜택.getId(), 영업중인_티바.getId(), + 신전_혜택.getId(), 영업중이_아닌_신전_떡볶이.getId()))); } @Test @@ -261,7 +284,16 @@ void setup() { .contentType(MediaType.APPLICATION_JSON) .content(String.format(""" { - "shop_ids": [%d, %d] + "shop_details": [ + { + "shop_id": %d, + "detail": "김밥혜택설명" + }, + { + "shop_id": %d, + "detail": "마슬랜혜택설명" + } + ] } """, 김밥천국.getId(), 마슬랜.getId())) ) @@ -271,17 +303,61 @@ void setup() { "shops": [ { "id": %d, - "name": "김밥천국" + "name": "김밥천국", + "detail": "김밥혜택설명" }, { "id": %d, - "name": "마슬랜 치킨" + "name": "마슬랜 치킨", + "detail": "마슬랜혜택설명" } ] } """, 김밥천국.getId(), 마슬랜.getId()))); } + @Test + void 특정_혜택을_제공하는_상점들을_수정한다() throws Exception { + mockMvc.perform( + put("/admin/benefit") + .header("Authorization", "Bearer " + token_admin) + .contentType(MediaType.APPLICATION_JSON) + .content(String.format(""" + { + "modify_details": [ + { + "shop_benefit_map_id": %d, + "detail": "김밥새혜택설명" + }, + { + "shop_benefit_map_id": %d, + "detail": "마슬랜새혜택설명" + } + ] + } + """, 김밥천국_혜택.getId(), 마슬랜_혜택.getId())) + ) + .andExpect(status().isOk()); + + transactionTemplate.executeWithoutResult(status -> { + List updatedBenefit = + adminBenefitCategoryMapRepository.findAllByIdIn( + List.of(김밥천국_혜택.getId(), 마슬랜_혜택.getId()) + ); + + Map details = updatedBenefit.stream() + .collect(Collectors.toMap( + BenefitCategoryMap::getId, + BenefitCategoryMap::getDetail + )); + + assertThat(details).isEqualTo(Map.of( + 김밥천국_혜택.getId(), "김밥새혜택설명", + 마슬랜_혜택.getId(), "마슬랜새혜택설명" + )); + }); + } + @Test void 특정_혜택을_제공하는_상점들을_삭제한다() throws Exception { mockMvc.perform( diff --git a/src/test/java/in/koreatech/koin/fixture/BenefitCategoryMapFixture.java b/src/test/java/in/koreatech/koin/fixture/BenefitCategoryMapFixture.java index 8eca67285..abf8583c4 100644 --- a/src/test/java/in/koreatech/koin/fixture/BenefitCategoryMapFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/BenefitCategoryMapFixture.java @@ -25,4 +25,12 @@ public BenefitCategoryMapFixture( .benefitCategory(benefitCategory) .build()); } + + public BenefitCategoryMap 설명이_포함된_혜택_추가(Shop shop, BenefitCategory benefitCategory, String detail) { + return benefitCategoryMapRepository.save(BenefitCategoryMap.builder() + .shop(shop) + .benefitCategory(benefitCategory) + .detail(detail) + .build()); + } }