diff --git a/nowait-app-admin-api/build.gradle b/nowait-app-admin-api/build.gradle index 028c8818..5568bb77 100644 --- a/nowait-app-admin-api/build.gradle +++ b/nowait-app-admin-api/build.gradle @@ -22,6 +22,7 @@ dependencies { implementation project(':nowait-domain:domain-admin-rdb') implementation project(':nowait-domain:domain-core-rdb') implementation project(':nowait-domain:domain-redis') + implementation project(':nowait-event') // Spring Boot Starter implementation 'org.springframework.boot:spring-boot-starter-web' diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/service/OrderService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/service/OrderService.java index 9f808caa..b2f8c701 100644 --- a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/service/OrderService.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/service/OrderService.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.stream.Collectors; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,6 +25,7 @@ import com.nowait.domaincorerdb.user.entity.User; import com.nowait.domaincorerdb.user.exception.UserNotFoundException; import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.nowaitevent.order.event.CookingCompleteEvent; import lombok.RequiredArgsConstructor; @@ -34,6 +36,7 @@ public class OrderService { private final StatisticCustomRepository statisticCustomRepository; private final UserRepository userRepository; private final StoreRepository storeRepository; + private final ApplicationEventPublisher publisher; @Transactional(readOnly = true) public List findAllOrders(Long storeId, MemberDetails memberDetails) { @@ -57,6 +60,23 @@ public OrderStatusUpdateResponseDto updateOrderStatus(Long orderId, OrderStatus throw new OrderUpdateUnauthorizedException(); } userOrder.updateStatus(newStatus); + + if (OrderStatus.COOKED.equals(newStatus)) { + List items = userOrder.getOrderItems().stream() + .map(item -> new CookingCompleteEvent.Item( + item.getMenu().getId(), + item.getQuantity() + )) + .toList(); + + publisher.publishEvent( + new CookingCompleteEvent( + userOrder.getStore().getStoreId(), + items + ) + ); + } + return OrderStatusUpdateResponseDto.fromEntity(userOrder); } diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/controller/StatisticsController.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/controller/StatisticsController.java index 0ad9f0e2..2fea19c0 100644 --- a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/controller/StatisticsController.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/controller/StatisticsController.java @@ -10,7 +10,9 @@ import org.springframework.web.bind.annotation.RestController; import com.nowait.applicationadmin.order.service.OrderService; +import com.nowait.applicationadmin.statistic.dto.PopularMenuDto; import com.nowait.applicationadmin.statistic.dto.StoreRankingDto; +import com.nowait.applicationadmin.statistic.service.PopularMenuRedisService; import com.nowait.applicationadmin.statistic.service.RankingService; import com.nowait.common.api.ApiUtils; import com.nowait.domainadminrdb.statistic.dto.OrderSalesSumDetail; @@ -29,6 +31,7 @@ public class StatisticsController { private final OrderService orderService; private final RankingService rankingService; + private final PopularMenuRedisService popularMenuRedisService; @GetMapping("/sales") @Operation(summary = "오늘의 매출 조회", description = "오늘의 매출을 조회합니다.") @@ -58,4 +61,20 @@ public ResponseEntity getTopSalesStores(@AuthenticationPrincipal MemberDetail ) ); } + + @GetMapping("/popular-menu") + @Operation(summary = "인기 메뉴 조회", description = "인기 메뉴를 조회합니다.") + @ApiResponse(responseCode = "200", description = "인기 메뉴 조회 성공") + public ResponseEntity getPopularMenu(@AuthenticationPrincipal MemberDetails memberDetails) { + + List popularMenu = popularMenuRedisService.getTodayTop5(memberDetails); + + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + popularMenu + ) + ); + } } diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/dto/PopularMenuDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/dto/PopularMenuDto.java new file mode 100644 index 00000000..828be84a --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/dto/PopularMenuDto.java @@ -0,0 +1,12 @@ +package com.nowait.applicationadmin.statistic.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class PopularMenuDto { + private Long menuId; + private String menuName; + private Long soldCount; +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/dto/StoreRankingDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/dto/StoreRankingDto.java index 0c40c84f..a32d6162 100644 --- a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/dto/StoreRankingDto.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/dto/StoreRankingDto.java @@ -4,12 +4,12 @@ @Getter public class StoreRankingDto { - private final Long storeId; + private final Long storeId; private final String storeName; - private final Long departmentId; + private final Long departmentId; private final String departmentName; private final Integer totalSales; - private final Long currentRank; + private final Long currentRank; private final Integer delta; private final String profileUrl; diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/PopularMenuRedisService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/PopularMenuRedisService.java new file mode 100644 index 00000000..95fd281c --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/PopularMenuRedisService.java @@ -0,0 +1,10 @@ +package com.nowait.applicationadmin.statistic.service; + +import java.util.List; + +import com.nowait.applicationadmin.statistic.dto.PopularMenuDto; +import com.nowait.domaincorerdb.user.entity.MemberDetails; + +public interface PopularMenuRedisService { + List getTodayTop5(MemberDetails memberDetails); +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/impl/PopularMenuRedisServiceImpl.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/impl/PopularMenuRedisServiceImpl.java new file mode 100644 index 00000000..01119cbb --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/impl/PopularMenuRedisServiceImpl.java @@ -0,0 +1,66 @@ +package com.nowait.applicationadmin.statistic.service.impl; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; + +import com.nowait.applicationadmin.statistic.dto.PopularMenuDto; +import com.nowait.applicationadmin.statistic.service.PopularMenuRedisService; +import com.nowait.common.enums.Role; +import com.nowait.domainadminrdb.statistic.exception.StatisticViewUnauthorizedException; +import com.nowait.domaincorerdb.menu.entity.Menu; +import com.nowait.domaincorerdb.menu.repository.MenuRepository; +import com.nowait.domaincorerdb.user.entity.MemberDetails; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincoreredis.rank.service.MenuCounterService; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PopularMenuRedisServiceImpl implements PopularMenuRedisService { + + private final MenuCounterService menuCounterService; + private final MenuRepository menuRepository; + private final UserRepository userRepository; + + @Override + public List getTodayTop5(MemberDetails memberDetails) { + + User user = userRepository.findById(memberDetails.getId()).orElseThrow(UserNotFoundException::new); + Long storeId = user.getStoreId(); + + if (!Role.SUPER_ADMIN.equals(user.getRole()) && !user.getStoreId().equals(storeId)) { + throw new StatisticViewUnauthorizedException(); + } + + Set> tuples = menuCounterService.getTopMenus(storeId, 5); + + List menuIds = tuples.stream() + .map(tuple -> Long.parseLong(tuple.getValue())) + .toList(); + + Map menuIdToNameMap = menuRepository.findAllById(menuIds) + .stream() + .collect(Collectors.toMap( + Menu::getId, + Menu::getName + )); + + + return tuples.stream() + .map(tuple -> { + Long menuId = Long.parseLong(tuple.getValue()); + String menuName = menuIdToNameMap.getOrDefault(menuId, "Unknown Menu"); + + return new PopularMenuDto(menuId, menuName, tuple.getScore().longValue()); + }) + .toList(); + } +} diff --git a/nowait-common/src/main/java/com/nowait/common/exception/ErrorMessage.java b/nowait-common/src/main/java/com/nowait/common/exception/ErrorMessage.java index fa83423b..db722455 100644 --- a/nowait-common/src/main/java/com/nowait/common/exception/ErrorMessage.java +++ b/nowait-common/src/main/java/com/nowait/common/exception/ErrorMessage.java @@ -60,6 +60,7 @@ public enum ErrorMessage { // Statistics STATISTIC_VIEW_UNAUTHORIZED("통계 보기 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "statistics001"), + MENU_COUNTER_UPDATE("메뉴 카운터 업데이트 실패", "statistics002"), // image IMAGE_FILE_EMPTY("이미지 파일을 업로드 해주세요", "image001"), diff --git a/nowait-domain/domain-redis/build.gradle b/nowait-domain/domain-redis/build.gradle index 8a6470e6..2beb40e2 100644 --- a/nowait-domain/domain-redis/build.gradle +++ b/nowait-domain/domain-redis/build.gradle @@ -22,6 +22,7 @@ repositories { dependencies { implementation project(':nowait-common') + implementation project(':nowait-event') implementation 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java index fd05a3cb..7c584af4 100644 --- a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java @@ -1,11 +1,19 @@ package com.nowait.domaincoreredis.common.util; +import java.time.format.DateTimeFormatter; + public class RedisKeyUtils { + // Store rank keys private static final String KEY_CURRENT = "nowait:store:rank:current"; private static final String KEY_PREVIOUS = "nowait:store:rank:previous"; private static final String KEY_NEXT = "nowait:store:rank:next"; + // Menu rank keys + private static final String KEY_FMT = "popular:%d:%s"; + private static final DateTimeFormatter DTF = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private RedisKeyUtils() { throw new UnsupportedOperationException("유틸리티 서비스는 인스턴스화 할 수 없습니다."); } @@ -21,4 +29,8 @@ public static String buildPreviousKey() { public static String buildNextKey() { return KEY_NEXT; } + + public static String buildMenuKey() { return KEY_FMT; } + + public static DateTimeFormatter buildMenuDateKey() { return DTF; } } diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/exception/MenuCounterUpdateException.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/exception/MenuCounterUpdateException.java new file mode 100644 index 00000000..fc8c13ab --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/exception/MenuCounterUpdateException.java @@ -0,0 +1,7 @@ +package com.nowait.domaincoreredis.rank.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class MenuCounterUpdateException extends RuntimeException { + public MenuCounterUpdateException() { super(ErrorMessage.MENU_COUNTER_UPDATE.getMessage()); } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/listener/CookingCompleteListener.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/listener/CookingCompleteListener.java new file mode 100644 index 00000000..1555fd0d --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/listener/CookingCompleteListener.java @@ -0,0 +1,31 @@ +package com.nowait.domaincoreredis.rank.listener; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.nowait.domaincoreredis.rank.service.MenuCounterService; +import com.nowait.nowaitevent.order.event.CookingCompleteEvent; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CookingCompleteListener { + private final MenuCounterService menuCounterService; + + @TransactionalEventListener( + classes = CookingCompleteEvent.class, + phase = TransactionPhase.AFTER_COMMIT + ) + public void onCookingComplete(CookingCompleteEvent event) { + Long storeId = event.getStoreId(); + for (CookingCompleteEvent.Item item : event.getItems()) { + menuCounterService.incrementMenuCounter( + item.getMenuId(), + storeId, + item.getQuantity() + ); + } + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/MenuCounterService.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/MenuCounterService.java new file mode 100644 index 00000000..d1024785 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/MenuCounterService.java @@ -0,0 +1,12 @@ +package com.nowait.domaincoreredis.rank.service; + +import java.util.Set; + +import org.springframework.data.redis.core.ZSetOperations; + +public interface MenuCounterService { + + void incrementMenuCounter(Long menuId, Long storeId, int qty); + + Set> getTopMenus(Long storeId, int topN); +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/MenuCounterServiceImpl.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/MenuCounterServiceImpl.java new file mode 100644 index 00000000..f2ffbce3 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/MenuCounterServiceImpl.java @@ -0,0 +1,61 @@ +package com.nowait.domaincoreredis.rank.service; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; + +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.domaincoreredis.rank.exception.MenuCounterUpdateException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MenuCounterServiceImpl implements MenuCounterService { + + private final String KEY_FMT = RedisKeyUtils.buildMenuKey(); + private final DateTimeFormatter DTF = RedisKeyUtils.buildMenuDateKey(); + private final RedisTemplate redis; + + @Override + public void incrementMenuCounter(Long menuId, Long storeId, int qty) { + + try { + String date = LocalDate.now().format(DTF); + String key = String.format(KEY_FMT, storeId, date); + + redis.opsForZSet().incrementScore(key, menuId.toString(), qty); + + Long expirationTime = redis.getExpire(key); + if (expirationTime == null || expirationTime < 0) { + long secondsUntilMidnight = Duration.between( + LocalDateTime.now(), + LocalDate.now().plusDays(1).atStartOfDay() + ).getSeconds(); + + redis.expire(key, Duration.ofSeconds(secondsUntilMidnight)); + } + + } catch (Exception e) { + log.error("Failed to increment menu counter for menuId: {}, storeId: {}", + menuId, storeId, e); + throw new MenuCounterUpdateException(); + } + } + + @Override + public Set> getTopMenus(Long storeId, int topN) { + String date = LocalDate.now().format(DTF); + String key = String.format(KEY_FMT, storeId, date); + + return redis.opsForZSet().reverseRangeWithScores(key, 0, topN - 1); + } +} diff --git a/nowait-event/build.gradle b/nowait-event/build.gradle new file mode 100644 index 00000000..067d51c3 --- /dev/null +++ b/nowait-event/build.gradle @@ -0,0 +1,40 @@ +import org.gradle.jvm.toolchain.JavaLanguageVersion + +plugins { + id 'java-library' +} + +jar { + enabled = true +} + + +group = 'com.nowait' +version = rootProject.version + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':nowait-common') + implementation project(':nowait-domain:domain-core-rdb') + + api 'org.springframework.boot:spring-boot-starter-data-jpa' + api 'jakarta.persistence:jakarta.persistence-api:3.1.0' + + implementation 'org.springframework.boot:spring-boot-starter-validation' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + compileOnly 'org.projectlombok:lombok:1.18.26' + annotationProcessor 'org.projectlombok:lombok:1.18.26' + + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' +} diff --git a/nowait-event/src/main/java/com/nowait/nowaitevent/order/event/CookingCompleteEvent.java b/nowait-event/src/main/java/com/nowait/nowaitevent/order/event/CookingCompleteEvent.java new file mode 100644 index 00000000..4db8b75d --- /dev/null +++ b/nowait-event/src/main/java/com/nowait/nowaitevent/order/event/CookingCompleteEvent.java @@ -0,0 +1,29 @@ +package com.nowait.nowaitevent.order.event; + +import java.util.List; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CookingCompleteEvent { + + @NotNull + private final Long storeId; + + @NotNull + private final List items; + + @Getter + @RequiredArgsConstructor + public static class Item { + + @NotNull + private final Long menuId; + + @NotNull + private final int quantity; + } +} diff --git a/settings.gradle b/settings.gradle index 89991c90..79aeb646 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,4 +7,5 @@ include 'nowait-domain:domain-core-rdb' include 'nowait-domain:domain-admin-rdb' include 'nowait-domain:domain-user-rdb' include 'nowait-domain:domain-redis' +include 'nowait-event' include 'nowait-infra'