diff --git a/src/main/java/com/campustable/be/domain/menu/controller/MenuController.java b/src/main/java/com/campustable/be/domain/menu/controller/MenuController.java index ddc4f8a..61c5263 100644 --- a/src/main/java/com/campustable/be/domain/menu/controller/MenuController.java +++ b/src/main/java/com/campustable/be/domain/menu/controller/MenuController.java @@ -3,6 +3,7 @@ import com.campustable.be.domain.menu.dto.MenuRequest; import com.campustable.be.domain.menu.dto.MenuResponse; import com.campustable.be.domain.menu.dto.MenuUpdateRequest; +import com.campustable.be.domain.menu.dto.TopMenuResponse; import com.campustable.be.domain.menu.service.MenuService; import com.campustable.be.global.aop.LogMonitoringInvocation; import jakarta.validation.Valid; @@ -21,7 +22,7 @@ public class MenuController implements MenuControllerDocs { - private final MenuService menuService; + private final MenuService menuService; @Override @@ -29,39 +30,41 @@ public class MenuController implements MenuControllerDocs { @LogMonitoringInvocation public ResponseEntity> getAllMenus(){ - List menus = menuService.getAllMenus(); + List menus = menuService.getAllMenus(); - return ResponseEntity.ok(menus); + return ResponseEntity.ok(menus); - } + } - @Override + @Override + @LogMonitoringInvocation + @GetMapping("/menus/{menuId}") + public ResponseEntity getMenuById(@PathVariable Long menuId){ + return ResponseEntity.ok(menuService.getMenuById(menuId)); + } + + + @Override @LogMonitoringInvocation @GetMapping("/category/{category_id}/menus") public ResponseEntity> getAllMenusByCategoryId( @PathVariable(name = "category_id") Long categoryId){ - List menus = menuService.getAllMenusByCategory(categoryId); + List menus = menuService.getAllMenusByCategory(categoryId); - return ResponseEntity.ok(menus); + return ResponseEntity.ok(menus); - } + } - @Override - @LogMonitoringInvocation - @GetMapping("/menus/{menuId}") - public ResponseEntity getMenuById(@PathVariable Long menuId){ - return ResponseEntity.ok(menuService.getMenuById(menuId)); - } - @Override - @LogMonitoringInvocation - @GetMapping("/menus/cafeteria/{cafeteria-id}") - public ResponseEntity> getAllMenusByCafeteriaId( + @Override + @LogMonitoringInvocation + @GetMapping("/cafeteria/{cafeteria-id}") + public ResponseEntity> getAllMenusByCafeteriaId( @PathVariable(name = "cafeteria-id") Long cafeteriaId - ) { - return ResponseEntity.ok(menuService.getAllMenusByCafeteriaId(cafeteriaId)); - } + ) { + return ResponseEntity.ok(menuService.getAllMenusByCafeteriaId(cafeteriaId)); + } @Override @PostMapping(value = "/admin/menus", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -71,8 +74,8 @@ public ResponseEntity createMenu( ){ MenuResponse createMenu = menuService.createMenu(request, request.getImage()); - return ResponseEntity.status(HttpStatus.CREATED).body(createMenu); - } + return ResponseEntity.status(HttpStatus.CREATED).body(createMenu); + } @Override @PostMapping(value = "/admin/menus/{menu_id}/image" ,consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -94,10 +97,10 @@ public ResponseEntity updateMenu( @PathVariable(name = "menu_id") Long menuId, @RequestBody MenuUpdateRequest updateRequest){ - MenuResponse updateMenu = menuService.updateMenu(menuId, updateRequest); + MenuResponse updateMenu = menuService.updateMenu(menuId, updateRequest); - return ResponseEntity.ok(updateMenu); - } + return ResponseEntity.ok(updateMenu); + } @Override @LogMonitoringInvocation @@ -105,9 +108,19 @@ public ResponseEntity updateMenu( public ResponseEntity deleteMenu( @PathVariable(name = "menu_id") Long menuId) { - menuService.deleteMenu(menuId); + menuService.deleteMenu(menuId); - return ResponseEntity.noContent().build(); - } + return ResponseEntity.noContent().build(); + } + + @Override + @GetMapping("/cafeteria/{cafeteria-id}/top-menus") + @LogMonitoringInvocation + public ResponseEntity> getTop3MenusByCafeteriaId(@PathVariable(name = "cafeteria-id") Long cafeteriaId) { + + List topMenus = menuService.getTop3MenusByCafeteriaId(cafeteriaId); + + return ResponseEntity.ok(topMenus); + } } diff --git a/src/main/java/com/campustable/be/domain/menu/controller/MenuControllerDocs.java b/src/main/java/com/campustable/be/domain/menu/controller/MenuControllerDocs.java index 35b88ff..c65ecb7 100644 --- a/src/main/java/com/campustable/be/domain/menu/controller/MenuControllerDocs.java +++ b/src/main/java/com/campustable/be/domain/menu/controller/MenuControllerDocs.java @@ -3,6 +3,7 @@ import com.campustable.be.domain.menu.dto.MenuRequest; import com.campustable.be.domain.menu.dto.MenuResponse; import com.campustable.be.domain.menu.dto.MenuUpdateRequest; +import com.campustable.be.domain.menu.dto.TopMenuResponse; import com.campustable.be.global.exception.ErrorResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -136,4 +137,22 @@ ResponseEntity updateMenu( ResponseEntity deleteMenu( @Parameter(description = "삭제할 메뉴 ID", example = "1") Long menuId ); + + /** + * 특정 식당의 인기 메뉴(Top 3)를 조회합니다. + * Redis ZSet을 기반으로 식당별 주문량이 가장 많은 Top3 메뉴 정보와 랭킹을 반환 + * @param cafeteriaId 식당 고유 식별자 + * @return 랭킹과 메뉴 상세 정보 리스트를 담은 ResponseEntity + */ + @Operation(summary = "식당별 인기 메뉴 조회(Top 3)", description = "특정 식당 ID의 Top 3 인기 메뉴를 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "404", description = "해당 식당을 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + ResponseEntity> getTop3MenusByCafeteriaId( + @Parameter(description = "조회할 식당 ID",example = "1") Long cafeteriaId + ); + + } \ No newline at end of file diff --git a/src/main/java/com/campustable/be/domain/menu/dto/MenuRequest.java b/src/main/java/com/campustable/be/domain/menu/dto/MenuRequest.java index 9858225..befed3e 100644 --- a/src/main/java/com/campustable/be/domain/menu/dto/MenuRequest.java +++ b/src/main/java/com/campustable/be/domain/menu/dto/MenuRequest.java @@ -44,6 +44,7 @@ public Menu toEntity(Category category) { .menuUrl(null) .available(this.getAvailable()) .stockQuantity(this.getStockQuantity()) + .build(); } diff --git a/src/main/java/com/campustable/be/domain/menu/dto/MenuResponse.java b/src/main/java/com/campustable/be/domain/menu/dto/MenuResponse.java index af03eaa..17a52c4 100644 --- a/src/main/java/com/campustable/be/domain/menu/dto/MenuResponse.java +++ b/src/main/java/com/campustable/be/domain/menu/dto/MenuResponse.java @@ -24,6 +24,7 @@ public class MenuResponse { private Boolean available; private Integer stockQuantity; private LocalDateTime createdDate; + private Long cafeteriaId; public static MenuResponse from(Menu menu) { return new MenuResponse( @@ -34,7 +35,8 @@ public static MenuResponse from(Menu menu) { menu.getMenuUrl(), menu.getAvailable(), menu.getStockQuantity(), - menu.getCreatedAt() + menu.getCreatedAt(), + menu.getCategory().getCafeteria().getCafeteriaId() ); } diff --git a/src/main/java/com/campustable/be/domain/menu/dto/TopMenuResponse.java b/src/main/java/com/campustable/be/domain/menu/dto/TopMenuResponse.java new file mode 100644 index 0000000..a8799cb --- /dev/null +++ b/src/main/java/com/campustable/be/domain/menu/dto/TopMenuResponse.java @@ -0,0 +1,21 @@ +package com.campustable.be.domain.menu.dto; + +import com.campustable.be.domain.menu.entity.Menu; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class TopMenuResponse { + + private Long rank; + private MenuResponse menu; + + public static TopMenuResponse of(Long rank, Menu menu) { + return TopMenuResponse.builder() + .rank(rank) + .menu(MenuResponse.from(menu)) + .build(); + } + +} diff --git a/src/main/java/com/campustable/be/domain/menu/service/MenuService.java b/src/main/java/com/campustable/be/domain/menu/service/MenuService.java index 1fd2a0d..0515b18 100644 --- a/src/main/java/com/campustable/be/domain/menu/service/MenuService.java +++ b/src/main/java/com/campustable/be/domain/menu/service/MenuService.java @@ -8,13 +8,21 @@ import com.campustable.be.domain.menu.dto.MenuRequest; import com.campustable.be.domain.menu.dto.MenuResponse; import com.campustable.be.domain.menu.dto.MenuUpdateRequest; +import com.campustable.be.domain.menu.dto.TopMenuResponse; import com.campustable.be.domain.menu.entity.Menu; import com.campustable.be.domain.menu.repository.MenuRepository; import com.campustable.be.domain.s3.service.S3Service; import com.campustable.be.global.exception.CustomException; import com.campustable.be.global.exception.ErrorCode; import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -31,6 +39,7 @@ public class MenuService { private final MenuRepository menuRepository; private final CategoryRepository categoryRepository; private final CafeteriaService cafeteriaService; + private final StringRedisTemplate stringRedisTemplate; private final S3Service s3Service; @@ -52,12 +61,22 @@ public MenuResponse createMenu(MenuRequest request, MultipartFile image) { Menu menu = request.toEntity(category); Menu savedMenu = menuRepository.save(menu); + try { + Long cafeteriaId = savedMenu.getCategory().getCafeteria().getCafeteriaId(); + String key = "cafeteria:" + cafeteriaId + ":menu:rank"; + + stringRedisTemplate.opsForZSet().add(key, String.valueOf(savedMenu.getId()), 0.0); + } catch (Exception e) { + + log.error("메뉴 생성 후 Redis 랭킹 등록 실패. (Menu Id: {}), 원인 :{}", savedMenu.getId(), e.getMessage()); + + } + if (image != null && !image.isEmpty()) { return uploadMenuImage(savedMenu.getId(), image); } return MenuResponse.from(savedMenu); - } @Transactional @@ -96,6 +115,7 @@ public MenuResponse uploadMenuImage(Long menuId, MultipartFile image) { } } + return MenuResponse.from(savedMenu); } @@ -192,5 +212,51 @@ public void deleteMenu(Long menuId) { menuRepository.deleteById(menuId); } + + @Transactional + public List getTop3MenusByCafeteriaId(Long cafeteriaId) { + + cafeteriaService.findCafeteriaById(cafeteriaId); + + String key = "cafeteria:" + cafeteriaId + ":menu:rank"; + + Set topMenus = stringRedisTemplate.opsForZSet().reverseRange(key, 0, 2); + + if (topMenus == null || topMenus.isEmpty()) { + return List.of(); + } + + List topMenuIds = topMenus.stream() + .map(id -> { + try { + return Long.parseLong(id); + } catch (NumberFormatException e) { + log.warn("Redis에 잘못된 메뉴 ID 형식: {}", id); + return null; + } + }) + .filter(Objects::nonNull) + .toList(); + + List menus = menuRepository.findAllById(topMenuIds); + + Map topMenusMap = menus.stream() + .collect(Collectors.toMap(Menu::getId, Function.identity())); + + List topMenusResponse = new ArrayList<>(); + + for (int i = 0; i < topMenuIds.size(); i++) { + Long topMenuId = topMenuIds.get(i); + Menu menu = topMenusMap.get(topMenuId); + + if (menu != null) { + topMenusResponse.add(TopMenuResponse.of((long) (i + 1), menu)); + } + } + + return topMenusResponse; + + } + } diff --git a/src/main/java/com/campustable/be/domain/order/service/OrderService.java b/src/main/java/com/campustable/be/domain/order/service/OrderService.java index fa4135a..ab852ae 100644 --- a/src/main/java/com/campustable/be/domain/order/service/OrderService.java +++ b/src/main/java/com/campustable/be/domain/order/service/OrderService.java @@ -1,8 +1,11 @@ package com.campustable.be.domain.order.service; +import com.campustable.be.domain.cafeteria.entity.Cafeteria; import com.campustable.be.domain.cart.entity.Cart; import com.campustable.be.domain.cart.repository.CartRepository; +import com.campustable.be.domain.category.entity.Category; import com.campustable.be.domain.menu.entity.Menu; +import com.campustable.be.domain.menu.repository.MenuRepository; import com.campustable.be.domain.order.dto.OrderResponse; import com.campustable.be.domain.order.entity.Order; import com.campustable.be.domain.order.entity.OrderItem; @@ -14,10 +17,15 @@ import com.campustable.be.global.exception.CustomException; import com.campustable.be.global.exception.ErrorCode; import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; @Service @RequiredArgsConstructor @@ -29,8 +37,10 @@ public class OrderService { private final CartRepository cartRepository; private final UserRepository userRepository; private final OrderItemRepository orderItemRepository; + private final StringRedisTemplate stringRedisTemplate; public OrderResponse createOrder() { + Long userId = SecurityUtil.getCurrentUserId(); User user = userRepository.findById(userId) @@ -45,7 +55,6 @@ public OrderResponse createOrder() { List orderItems = cart.getCartItems().stream() .map(cartItem -> { - Menu menu = cartItem.getMenu(); menu.decreaseStockQuantity(cartItem.getQuantity()); @@ -60,13 +69,42 @@ public OrderResponse createOrder() { Order order = Order.createOrder(user, orderItems); orderRepository.save(order); + user.setCart(null); + cartRepository.delete(cart); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + updateMenuRanking(orderItems); + } + }); return OrderResponse.from(order); } - public void updateCategoryToReady(Long orderId,Long categoryId) { + + private void updateMenuRanking(List orderItems) { + + for (OrderItem orderItem : orderItems) { + try { + Menu menu = orderItem.getMenu(); + Category category = menu.getCategory(); + Cafeteria cafeteria = category.getCafeteria(); + Long cafeteriaId = cafeteria.getCafeteriaId(); + + String key = "cafeteria:" + cafeteriaId + ":menu:rank"; + + stringRedisTemplate.opsForZSet() + .incrementScore(key, String.valueOf(menu.getId()), orderItem.getQuantity()); + + } catch (Exception e) { + log.error("랭킹 점수 반영 실패: {}", e.getMessage()); + } + } + } + + public void updateCategoryToReady(Long orderId, Long categoryId) { List items = orderItemRepository.findByOrderOrderIdAndCategoryId(orderId, categoryId); if (items.isEmpty()) { @@ -76,7 +114,7 @@ public void updateCategoryToReady(Long orderId,Long categoryId) { items.forEach(OrderItem::markAsReady); //PREPARING -> READY } - public void updateCategoryToComplete(Long orderId,Long categoryId) { + public void updateCategoryToComplete(Long orderId, Long categoryId) { List items = orderItemRepository.findByOrderOrderIdAndCategoryId(orderId, categoryId); if (items.isEmpty()) { @@ -102,7 +140,7 @@ public List getMyOrders() { public List getOrdersByUserId(Long userId) { - User user = userRepository.findById(userId) + User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); return orderRepository.findByUserUserIdOrderByCreatedAtDesc(userId).stream()