diff --git a/nowait-app-admin-api/build.gradle b/nowait-app-admin-api/build.gradle index f6f6a509..028c8818 100644 --- a/nowait-app-admin-api/build.gradle +++ b/nowait-app-admin-api/build.gradle @@ -21,6 +21,7 @@ dependencies { implementation project(':nowait-infra') implementation project(':nowait-domain:domain-admin-rdb') implementation project(':nowait-domain:domain-core-rdb') + implementation project(':nowait-domain:domain-redis') // Spring Boot Starter implementation 'org.springframework.boot:spring-boot-starter-web' @@ -36,6 +37,9 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // SWAGGER implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' diff --git a/nowait-app-admin-api/src/main/java/com/nowait/ApiAdminApplication.java b/nowait-app-admin-api/src/main/java/com/nowait/ApiAdminApplication.java index 1098599d..b538931a 100644 --- a/nowait-app-admin-api/src/main/java/com/nowait/ApiAdminApplication.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/ApiAdminApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @EnableJpaAuditing +@EnableScheduling @SpringBootApplication public class ApiAdminApplication { public static void main(String[] args) { diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/controller/OrderController.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/controller/OrderController.java index 16246fbc..7022486b 100644 --- a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/controller/OrderController.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/controller/OrderController.java @@ -17,8 +17,6 @@ import com.nowait.applicationadmin.order.dto.OrderStatusUpdateResponseDto; import com.nowait.applicationadmin.order.service.OrderService; import com.nowait.common.api.ApiUtils; -import com.nowait.domaincorerdb.order.dto.OrderSalesSumDetail; -import com.nowait.domaincorerdb.order.dto.TopSalesStoresDetail; import com.nowait.domaincorerdb.user.entity.MemberDetails; import io.swagger.v3.oas.annotations.Operation; @@ -65,34 +63,4 @@ public ResponseEntity updateOrderStatus( .status(HttpStatus.OK) .body(ApiUtils.success(response)); } - - @GetMapping("/sales") - @Operation(summary = "오늘의 매출 조회", description = "오늘의 매출을 조회합니다.") - @ApiResponse(responseCode = "200", description = "오늘의 매출 조회 성공") - public ResponseEntity getTodaySales(@AuthenticationPrincipal MemberDetails memberDetails) { - OrderSalesSumDetail sales = orderService.getSaleSumByStoreId(memberDetails); - - return ResponseEntity - .status(HttpStatus.OK) - .body( - ApiUtils.success( - sales - ) - ); - } - - @GetMapping("/top-sales") - @Operation(summary = "오늘의 매출 상위 5개 주점 조회", description = "오늘의 매출이 가장 높은 상위 5개 주점을 조회합니다.") - @ApiResponse(responseCode = "200", description = "오늘의 매출 상위 5개 주점 조회 성공") - public ResponseEntity getTopSalesStores(@AuthenticationPrincipal MemberDetails memberDetails) { - List topSalesStoresDetail = orderService.getTop5StoresBySalesToday(memberDetails); - - return ResponseEntity - .status(HttpStatus.OK) - .body( - ApiUtils.success( - topSalesStoresDetail - ) - ); - } } 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 c0d3db14..9f808caa 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 @@ -9,8 +9,9 @@ import com.nowait.applicationadmin.order.dto.OrderResponseDto; import com.nowait.applicationadmin.order.dto.OrderStatusUpdateResponseDto; import com.nowait.common.enums.Role; -import com.nowait.domaincorerdb.order.dto.OrderSalesSumDetail; -import com.nowait.domaincorerdb.order.dto.TopSalesStoresDetail; +import com.nowait.domainadminrdb.statistic.dto.OrderSalesSumDetail; +import com.nowait.domainadminrdb.statistic.dto.TopSalesStoresDetail; +import com.nowait.domainadminrdb.statistic.repository.StatisticCustomRepository; import com.nowait.domaincorerdb.order.entity.OrderStatus; import com.nowait.domaincorerdb.order.entity.UserOrder; import com.nowait.domaincorerdb.order.exception.OrderNotFoundException; @@ -30,6 +31,7 @@ @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; + private final StatisticCustomRepository statisticCustomRepository; private final UserRepository userRepository; private final StoreRepository storeRepository; @@ -67,7 +69,7 @@ public OrderSalesSumDetail getSaleSumByStoreId(MemberDetails memberDetails) { throw new OrderViewUnauthorizedException(); } - return orderRepository.findSalesSumByStoreId(storeId); + return statisticCustomRepository.findSalesSumByStoreId(storeId); } @Transactional(readOnly = true) @@ -79,6 +81,6 @@ public List getTop5StoresBySalesToday(MemberDetails member throw new OrderViewUnauthorizedException(); } - return orderRepository.getTop4PlusMine(storeId); + return statisticCustomRepository.getTop4PlusMine(storeId); } } 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 new file mode 100644 index 00000000..0ad9f0e2 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/controller/StatisticsController.java @@ -0,0 +1,61 @@ +package com.nowait.applicationadmin.statistic.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.nowait.applicationadmin.order.service.OrderService; +import com.nowait.applicationadmin.statistic.dto.StoreRankingDto; +import com.nowait.applicationadmin.statistic.service.RankingService; +import com.nowait.common.api.ApiUtils; +import com.nowait.domainadminrdb.statistic.dto.OrderSalesSumDetail; +import com.nowait.domaincorerdb.user.entity.MemberDetails; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Statistics API", description = "통계 API") +@RestController +@RequestMapping("/admin/statistics") +@RequiredArgsConstructor +public class StatisticsController { + + private final OrderService orderService; + private final RankingService rankingService; + + @GetMapping("/sales") + @Operation(summary = "오늘의 매출 조회", description = "오늘의 매출을 조회합니다.") + @ApiResponse(responseCode = "200", description = "오늘의 매출 조회 성공") + public ResponseEntity getTodaySales(@AuthenticationPrincipal MemberDetails memberDetails) { + OrderSalesSumDetail sales = orderService.getSaleSumByStoreId(memberDetails); + + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + sales + ) + ); + } + + @GetMapping("/top-sales") + @Operation(summary = "주점별 매출 통계 조회", description = "주점별 매출 통계를 조회합니다.") + @ApiResponse(responseCode = "200", description = "주점별 매출 통계 조회 성공") + public ResponseEntity getTopSalesStores(@AuthenticationPrincipal MemberDetails memberDetails) { + List response = rankingService.getStatisticsRankings(memberDetails); + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + response + ) + ); + } +} 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 new file mode 100644 index 00000000..a5d0601a --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/dto/StoreRankingDto.java @@ -0,0 +1,25 @@ +package com.nowait.applicationadmin.statistic.dto; + +import lombok.Getter; + +@Getter +public class StoreRankingDto { + private final Long storeId; + private final String storeName; + private final Long departmentId; + private final String departmentName; + private final Integer totalSales; + private final Long currentRank; + private final Integer delta; + + public StoreRankingDto(Long storeId, String storeName, Long departmentId, String departmentName, Integer totalSales, + Long currentRank, Integer delta) { + this.storeId = storeId; + this.storeName = storeName; + this.departmentId = departmentId; + this.departmentName = departmentName; + this.totalSales = totalSales; + this.currentRank = currentRank; + this.delta = delta; + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/scheduler/RankingRefreshScheduler.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/scheduler/RankingRefreshScheduler.java new file mode 100644 index 00000000..ed81d051 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/scheduler/RankingRefreshScheduler.java @@ -0,0 +1,97 @@ +package com.nowait.applicationadmin.statistic.scheduler; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.nowait.domainadminrdb.statistic.dto.StoreSales; +import com.nowait.domainadminrdb.statistic.repository.StatisticCustomRepository; +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.domaincoreredis.rank.repository.RankingQueryRepository; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + + +@Component +@RequiredArgsConstructor +@Slf4j +public class RankingRefreshScheduler { + + private final StatisticCustomRepository statisticCustomRepository; + private final RankingQueryRepository rankingQueryRepository; + private final RedisTemplate redis; + + @PostConstruct + public void init() { + // 초기화 작업 + refresh(); + } + + @Scheduled(cron = "0 */5 * * * *") // 매 5분마다 실행 + public void refresh() { + log.info("RankingRefreshScheduler.refresh() called at {}", LocalDateTime.now()); + + try { + doRefresh(); + } catch (Exception e) { + log.error("랭킹 데이터 갱신 중 오류 발생", e); + // 예외 발생 시 알림 또는 로깅 처리 + } + } + + private void doRefresh() { + String nextKey = RedisKeyUtils.buildNextKey(); + String currentKey = RedisKeyUtils.buildCurrentKey(); + String previousKey = RedisKeyUtils.buildPreviousKey(); + + // 1) 다음 스냅샷 키 초기화 + redis.delete(nextKey); + + // 2) DB에서 매출 합계 가져와 ZADD + List salesList = statisticCustomRepository.findTotalSales(); + + if (salesList.isEmpty()) { + log.warn("매출 데이터가 없습니다. 다음 스냅샷 키를 초기화합니다."); + return; + } + + salesList.forEach(s -> + rankingQueryRepository.addToRanking(nextKey, s.getStoreId(), s.getTotalSales()) + ); + + // 3) 현재 스냅샷 키를 이전 스냅샷 키로 이동 + rotateKeys(currentKey, previousKey, nextKey); + } + + private void rotateKeys(String currentKey, String previousKey, String nextKey) { + try { + redis.execute((RedisCallback)connection -> { + // 현재 스냅샷 키를 이전 스냅샷 키로 이동하고, 다음 스냅샷 키를 현재 스냅샷 키로 이동 + if (redis.hasKey(previousKey)) { + redis.delete(previousKey); + } + // 현재 스냅샷 키를 다음 스냅샷 키로 이동 + if (redis.hasKey(currentKey)) { + redis.rename(currentKey, previousKey); + } + // 다음 스냅샷 키가 존재하면 현재 스냅샷 키로 이동 + if (redis.hasKey(nextKey)) { + redis.rename(nextKey, currentKey); + } + return null; + }); + log.info("Keys rotated: current -> {}, previous -> {}, next -> {}", currentKey, previousKey, nextKey); + } catch (Exception e) { + log.error("Redis 키 교체 중 오류 발생", e); + throw new RuntimeException("랭킹 데이터 갱신 실패", e); + } + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/RankingService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/RankingService.java new file mode 100644 index 00000000..b34f2f8b --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/RankingService.java @@ -0,0 +1,10 @@ +package com.nowait.applicationadmin.statistic.service; + +import java.util.List; + +import com.nowait.applicationadmin.statistic.dto.StoreRankingDto; +import com.nowait.domaincorerdb.user.entity.MemberDetails; + +public interface RankingService { + List getStatisticsRankings(MemberDetails memberDetails); +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/impl/RankingServiceImpl.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/impl/RankingServiceImpl.java new file mode 100644 index 00000000..9b5d764b --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/impl/RankingServiceImpl.java @@ -0,0 +1,75 @@ +package com.nowait.applicationadmin.statistic.service.impl; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationadmin.statistic.dto.StoreRankingDto; +import com.nowait.applicationadmin.statistic.service.RankingService; +import com.nowait.common.enums.Role; +import com.nowait.domainadminrdb.statistic.dto.StoreInfo; +import com.nowait.domainadminrdb.statistic.exception.StatisticViewUnauthorizedException; +import com.nowait.domainadminrdb.statistic.repository.StatisticCustomRepository; +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.dto.RankingEntry; +import com.nowait.domaincoreredis.rank.service.RankingQueryService; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RankingServiceImpl implements RankingService { + + private final StatisticCustomRepository statisticCustomRepository; + private final UserRepository userRepository; + private final RankingQueryService rankingQuery; + + + @Override + @Transactional(readOnly = true) + public List getStatisticsRankings(MemberDetails memberDetails) { + User user = userRepository.findById(memberDetails.getId()).orElseThrow(UserNotFoundException::new); + Long userStoreId = user.getStoreId(); + + if (!Role.SUPER_ADMIN.equals(user.getRole()) && !user.getStoreId().equals(userStoreId)) { + throw new StatisticViewUnauthorizedException(); + } + + // 1) Redis에서 Top4+내주점: storeId, totalSales, currentRank, delta + List entries = rankingQuery.getRankings(userStoreId, 5); + + // 2) DB에서 store 정보 가져오기 + List storeIds = entries.stream() + .map(RankingEntry::getStoreId) + .toList(); + + List infos = statisticCustomRepository.findStoreInfoByIds(storeIds); + + // StoreInfo를 storeId로 매핑 + Map infoMap = infos.stream() + .collect(Collectors.toMap(StoreInfo::getStoreId, Function.identity())); + + // 3) 매핑 → 최종 DTO + return entries.stream() + .map(e -> { + StoreInfo info = infoMap.get(e.getStoreId()); + return new StoreRankingDto( + e.getStoreId(), + info.getStoreName(), + info.getDepartmentId(), + info.getDepartmentName(), + e.getTotalSales(), + e.getCurrentRank(), + e.getDelta() + ); + }) + .collect(Collectors.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 b64c5e37..fa83423b 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 @@ -50,7 +50,6 @@ public enum ErrorMessage { STORE_WAITING_DISABLED("해당 주점은 대기 비활성화된 주점입니다.", "store006"), // storePayment - STORE_PAYMENT_PARAMETER_EMPTY("주점 결제 생성 시 파라미터 정보가 없습니다.", "storePayment001"), STORE_PAYMENT_NOT_FOUND("해당 주점 결제 정보를 찾을 수 없습니다.", "storePayment002"), STORE_PAYMENT_VIEW_UNAUTHORIZED("주점 결제 정보 보기 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "storePayment003"), @@ -59,6 +58,8 @@ public enum ErrorMessage { STORE_PAYMENT_DELETE_UNAUTHORIZED("주점 결제 정보 삭제 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "storePayment005"), STORE_PAYMENT_ALREADY_EXISTS("이미 존재하는 주점 결제 정보입니다.", "storePayment006"), + // Statistics + STATISTIC_VIEW_UNAUTHORIZED("통계 보기 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "statistics001"), // image IMAGE_FILE_EMPTY("이미지 파일을 업로드 해주세요", "image001"), diff --git a/nowait-domain/domain-admin-rdb/build.gradle b/nowait-domain/domain-admin-rdb/build.gradle index bf111954..21a07f1f 100644 --- a/nowait-domain/domain-admin-rdb/build.gradle +++ b/nowait-domain/domain-admin-rdb/build.gradle @@ -27,12 +27,27 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-data-jpa' api 'jakarta.persistence:jakarta.persistence-api:3.1.0' + // SPRING SECURITY + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + //QueryDsl + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // SWAGGER + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + compileOnly 'org.projectlombok:lombok:1.18.26' annotationProcessor 'org.projectlombok:lombok:1.18.26' + // Jackson + api 'com.fasterxml.jackson.core:jackson-databind:2.15.2' + api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2' + testImplementation 'org.junit.jupiter:junit-jupiter' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' - - // SWAGGER - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' } diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/config/QueryDslConfig.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/config/QueryDslConfig.java similarity index 91% rename from nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/config/QueryDslConfig.java rename to nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/config/QueryDslConfig.java index f75d497b..6c04df39 100644 --- a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/config/QueryDslConfig.java +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/config/QueryDslConfig.java @@ -1,4 +1,4 @@ -package com.nowait.domaincorerdb.config; +package com.nowait.domainadminrdb.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/dto/OrderSalesSumDetail.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/OrderSalesSumDetail.java similarity index 91% rename from nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/dto/OrderSalesSumDetail.java rename to nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/OrderSalesSumDetail.java index fe3b25ee..e3ac3ef8 100644 --- a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/dto/OrderSalesSumDetail.java +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/OrderSalesSumDetail.java @@ -1,4 +1,4 @@ -package com.nowait.domaincorerdb.order.dto; +package com.nowait.domainadminrdb.statistic.dto; import lombok.Builder; import lombok.Getter; diff --git a/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/StoreInfo.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/StoreInfo.java new file mode 100644 index 00000000..893503bf --- /dev/null +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/StoreInfo.java @@ -0,0 +1,18 @@ +package com.nowait.domainadminrdb.statistic.dto; + +import lombok.Getter; + +@Getter +public class StoreInfo { + private final Long storeId; + private final String storeName; + private final Long departmentId; + private final String departmentName; + + public StoreInfo(Long storeId, String storeName, Long departmentId, String departmentName) { + this.storeId = storeId; + this.storeName = storeName; + this.departmentId = departmentId; + this.departmentName = departmentName; + } +} diff --git a/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/StoreSales.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/StoreSales.java new file mode 100644 index 00000000..442eacc8 --- /dev/null +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/StoreSales.java @@ -0,0 +1,14 @@ +package com.nowait.domainadminrdb.statistic.dto; + +import lombok.Getter; + +@Getter +public class StoreSales { + private final Long storeId; + private final Integer totalSales; + + public StoreSales(Long storeId, Integer totalSales) { + this.storeId = storeId; + this.totalSales = totalSales; + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/dto/TopSalesStoresDetail.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/TopSalesStoresDetail.java similarity index 92% rename from nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/dto/TopSalesStoresDetail.java rename to nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/TopSalesStoresDetail.java index acc0d2d3..156e0249 100644 --- a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/dto/TopSalesStoresDetail.java +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/TopSalesStoresDetail.java @@ -1,4 +1,4 @@ -package com.nowait.domaincorerdb.order.dto; +package com.nowait.domainadminrdb.statistic.dto; import lombok.Builder; import lombok.Getter; diff --git a/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/exception/StatisticViewUnauthorizedException.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/exception/StatisticViewUnauthorizedException.java new file mode 100644 index 00000000..0757f643 --- /dev/null +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/exception/StatisticViewUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.nowait.domainadminrdb.statistic.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StatisticViewUnauthorizedException extends RuntimeException { + public StatisticViewUnauthorizedException() { + super(ErrorMessage.STATISTIC_VIEW_UNAUTHORIZED.getMessage()); + } +} diff --git a/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/repository/StatisticCustomRepository.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/repository/StatisticCustomRepository.java new file mode 100644 index 00000000..8c03bcdd --- /dev/null +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/repository/StatisticCustomRepository.java @@ -0,0 +1,23 @@ +package com.nowait.domainadminrdb.statistic.repository; + +import java.util.List; + +import com.nowait.domainadminrdb.statistic.dto.OrderSalesSumDetail; +import com.nowait.domainadminrdb.statistic.dto.StoreInfo; +import com.nowait.domainadminrdb.statistic.dto.StoreSales; +import com.nowait.domainadminrdb.statistic.dto.TopSalesStoresDetail; + + + +public interface StatisticCustomRepository { + + OrderSalesSumDetail findSalesSumByStoreId(Long storeId); + + List getTop4PlusMine(Long storeId); + + + // redis 사용하는 부분 + List findTotalSales(); + + List findStoreInfoByIds(List storeIds); +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderCustomRepositoryImpl.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/repository/StatisticCustomRepositoryImpl.java similarity index 81% rename from nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderCustomRepositoryImpl.java rename to nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/repository/StatisticCustomRepositoryImpl.java index 341940bb..440c13a6 100644 --- a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderCustomRepositoryImpl.java +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/repository/StatisticCustomRepositoryImpl.java @@ -1,6 +1,7 @@ -package com.nowait.domaincorerdb.order.repository; +package com.nowait.domainadminrdb.statistic.repository; import static com.nowait.domaincorerdb.order.entity.OrderStatus.*; +import static com.nowait.domaincorerdb.store.entity.QStore.*; import java.time.LocalDate; import java.time.LocalDateTime; @@ -12,24 +13,29 @@ import java.util.Set; import java.util.stream.Collectors; +import org.springframework.stereotype.Repository; + +import com.nowait.domainadminrdb.statistic.dto.OrderSalesSumDetail; +import com.nowait.domainadminrdb.statistic.dto.StoreInfo; +import com.nowait.domainadminrdb.statistic.dto.StoreSales; +import com.nowait.domainadminrdb.statistic.dto.TopSalesStoresDetail; import com.nowait.domaincorerdb.department.entity.QDepartment; -import com.nowait.domaincorerdb.order.dto.OrderSalesSumDetail; -import com.nowait.domaincorerdb.order.dto.TopSalesStoresDetail; import com.nowait.domaincorerdb.order.entity.QUserOrder; import com.nowait.domaincorerdb.store.entity.QStore; import com.querydsl.core.Tuple; import com.querydsl.jpa.impl.JPAQueryFactory; -public class OrderCustomRepositoryImpl implements OrderCustomRepository { +@Repository +public class StatisticCustomRepositoryImpl implements StatisticCustomRepository { private final JPAQueryFactory queryFactory; - public OrderCustomRepositoryImpl(JPAQueryFactory queryFactory) { + public StatisticCustomRepositoryImpl(JPAQueryFactory queryFactory) { this.queryFactory = queryFactory; } private static final QUserOrder u = QUserOrder.userOrder; - private static final QStore s = QStore.store; + private static final QStore s = store; private static final QDepartment d = QDepartment.department; @Override @@ -255,4 +261,52 @@ private Map getDepartmentNames(Set departmentIds) { } return departmentNameMap; } + + + + // redis 사용하는 부분 + @Override + public List findTotalSales() { + // 오늘 자정 + LocalDate today = LocalDate.now(); + LocalDateTime todayStart = today.atStartOfDay(); + LocalDateTime todayEnd = today.plusDays(1).atStartOfDay(); // 내일 00:00:00 + + List rows = queryFactory + .select(u.store.storeId, u.totalPrice.sum().coalesce(0)) + .from(u) + .where( + u.createdAt.goe(todayStart), + u.createdAt.lt(todayEnd), + u.status.eq(COOKED) + ) + .groupBy(u.store.storeId) + .fetch(); + + return rows.stream() + .map(t -> new StoreSales( + t.get(u.store.storeId), + t.get(u.totalPrice.sum().coalesce(0)) + )) + .collect(Collectors.toList()); + } + + @Override + public List findStoreInfoByIds(List storeIds) { + List tuples = queryFactory + .select(s.storeId, s.name, store.departmentId, d.name) + .from(store) + .join(d).on(store.departmentId.eq(d.id)) + .where(store.storeId.in(storeIds)) + .fetch(); + + return tuples.stream() + .map(t -> new StoreInfo( + t.get(store.storeId), + t.get(store.name), + t.get(store.departmentId), + t.get(d.name) + )) + .collect(Collectors.toList()); + } } diff --git a/nowait-domain/domain-core-rdb/build.gradle b/nowait-domain/domain-core-rdb/build.gradle index daa8ad3d..954fbd42 100644 --- a/nowait-domain/domain-core-rdb/build.gradle +++ b/nowait-domain/domain-core-rdb/build.gradle @@ -31,11 +31,6 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' implementation 'org.springframework.boot:spring-boot-starter-validation' - //QueryDsl - implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' - annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" - annotationProcessor "jakarta.annotation:jakarta.annotation-api" - annotationProcessor "jakarta.persistence:jakarta.persistence-api" // SWAGGER implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' @@ -43,6 +38,13 @@ dependencies { compileOnly 'org.projectlombok:lombok:1.18.26' annotationProcessor 'org.projectlombok:lombok:1.18.26' + //QueryDsl + // TODO Q클래스 생성시 오류 발생 해결 필요 + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + // Jackson api 'com.fasterxml.jackson.core:jackson-databind:2.15.2' api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2' diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderCustomRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderCustomRepository.java deleted file mode 100644 index 77d2747d..00000000 --- a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderCustomRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.nowait.domaincorerdb.order.repository; - -import java.util.List; - -import com.nowait.domaincorerdb.order.dto.OrderSalesSumDetail; -import com.nowait.domaincorerdb.order.dto.TopSalesStoresDetail; - -public interface OrderCustomRepository { - - OrderSalesSumDetail findSalesSumByStoreId(Long storeId); - - List getTop4PlusMine(Long storeId); -} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderRepository.java index 54c2b7b6..b6b0a12d 100644 --- a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderRepository.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderRepository.java @@ -9,7 +9,7 @@ import com.nowait.domaincorerdb.order.entity.UserOrder; @Repository -public interface OrderRepository extends JpaRepository, OrderCustomRepository { +public interface OrderRepository extends JpaRepository { boolean existsBySignatureAndCreatedAtAfter(String signature, LocalDateTime createdAt); List findByStore_StoreIdAndTableIdAndSessionId(Long storeId, Long tableId, String sessionId); 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 new file mode 100644 index 00000000..fd05a3cb --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java @@ -0,0 +1,24 @@ +package com.nowait.domaincoreredis.common.util; + +public class RedisKeyUtils { + + 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"; + + private RedisKeyUtils() { + throw new UnsupportedOperationException("유틸리티 서비스는 인스턴스화 할 수 없습니다."); + } + + public static String buildCurrentKey() { + return KEY_CURRENT; + } + + public static String buildPreviousKey() { + return KEY_PREVIOUS; + } + + public static String buildNextKey() { + return KEY_NEXT; + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/config/RedisConfig.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/config/RedisConfig.java index 57e8b919..b0967e0f 100644 --- a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/config/RedisConfig.java +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/config/RedisConfig.java @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -16,9 +17,16 @@ public class RedisConfig { @Value("${spring.data.redis.port}") private int port; + @Value("${spring.data.redis.password}") + private String password; + @Bean public RedisConnectionFactory redisConnectionFactory() { - return new LettuceConnectionFactory(host, port); + RedisStandaloneConfiguration redisConfiguration = new RedisStandaloneConfiguration(); + redisConfiguration.setHostName(host); + redisConfiguration.setPort(port); + redisConfiguration.setPassword(password); + return new LettuceConnectionFactory(redisConfiguration); } @Bean diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/dto/RankingEntry.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/dto/RankingEntry.java new file mode 100644 index 00000000..9dcc2e45 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/dto/RankingEntry.java @@ -0,0 +1,18 @@ +package com.nowait.domaincoreredis.rank.dto; + +import lombok.Getter; + +@Getter +public class RankingEntry { + private final Long storeId; + private final Integer totalSales; + private final Long currentRank; + private final Integer delta; + + public RankingEntry(Long storeId, Integer totalSales, Long currentRank, Integer delta) { + this.storeId = storeId; + this.totalSales = totalSales; + this.currentRank = currentRank; + this.delta = delta; + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/repository/RankingQueryRepository.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/repository/RankingQueryRepository.java new file mode 100644 index 00000000..f056b146 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/repository/RankingQueryRepository.java @@ -0,0 +1,16 @@ +package com.nowait.domaincoreredis.rank.repository; + +import java.util.List; + +import com.nowait.domaincoreredis.rank.dto.RankingEntry; + +public interface RankingQueryRepository { + + void addToRanking(String key, Long storeId, Integer totalSales); + + Long findPrevRank(String key, Long storeId); + + List findTopStores(String key, int topN); + + List findTopStoresWithUser(String key, Long userStoreId, int topN, List entries); +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/repository/RankingQueryRepositoryImpl.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/repository/RankingQueryRepositoryImpl.java new file mode 100644 index 00000000..fa792f2f --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/repository/RankingQueryRepositoryImpl.java @@ -0,0 +1,66 @@ +package com.nowait.domaincoreredis.rank.repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import com.nowait.domaincoreredis.rank.dto.RankingEntry; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class RankingQueryRepositoryImpl implements RankingQueryRepository { + + private final StringRedisTemplate redisTemplate; + + @Override + public void addToRanking(String key, Long storeId, Integer totalSales) { + double totalSalesDouble = totalSales != null ? totalSales.doubleValue() : 0.0; + redisTemplate.opsForZSet().add(key, String.valueOf(storeId), totalSalesDouble); + } + + @Override + public Long findPrevRank(String key, Long storeId) { + return redisTemplate.opsForZSet().reverseRank(key, String.valueOf(storeId)); + } + + @Override + public List findTopStores(String key, int topN) { + var topTuples = redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, topN - 1); + + List entries = topTuples.stream() + .map(tuple -> new RankingEntry( + Long.parseLong(tuple.getValue()), + tuple.getScore().intValue(), + redisTemplate.opsForZSet().reverseRank(key, tuple.getValue()) + 1, + 0 + )) + .collect(Collectors.toCollection(ArrayList::new)); + + return entries; + } + + @Override + public List findTopStoresWithUser(String key, Long userStoreId, int topN, List entries) { + Long userZero = redisTemplate.opsForZSet().reverseRank(key, userStoreId.toString()); + + if (userZero != null && userZero >= topN) { + List top4 = entries.subList(0, topN - 1); + Double userScore = redisTemplate.opsForZSet().score(key, userStoreId.toString()); + Integer userSales = userScore != null ? userScore.intValue() : 0; + + top4.add(new RankingEntry( + userStoreId, + userSales, + userZero + 1, + 0 // 초기 등락 값은 0으로 설정 + )); + entries = top4; + } + return entries; + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/RankingQueryService.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/RankingQueryService.java new file mode 100644 index 00000000..5b0eb5b5 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/RankingQueryService.java @@ -0,0 +1,13 @@ +package com.nowait.domaincoreredis.rank.service; + +import java.util.List; + +import com.nowait.domaincoreredis.rank.dto.RankingEntry; + +public interface RankingQueryService { + + /** + * 로그인한 사용자의 주점을 포함하여 상위 topN 순위와 등락 정보를 반환한다. + */ + List getRankings(Long userStoreId, int topN); +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/RankingQueryServiceImpl.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/RankingQueryServiceImpl.java new file mode 100644 index 00000000..7718f358 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/RankingQueryServiceImpl.java @@ -0,0 +1,52 @@ +package com.nowait.domaincoreredis.rank.service; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.domaincoreredis.rank.dto.RankingEntry; +import com.nowait.domaincoreredis.rank.repository.RankingQueryRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RankingQueryServiceImpl implements RankingQueryService { + + private final RankingQueryRepository rankingQueryRepository; + + @Override + public List getRankings(Long userStoreId, int topN) { + // 1) 현재 스냅샷에서 상위 topN 주점 조회 + List entries = getCurrentRankings(userStoreId, topN); + // 2) 이전 스냅샷에서 등락 정보 조회 + return calculateRankingDeltas(entries); + } + + private List getCurrentRankings(Long userStoreId, int topN) { + String currentKey = RedisKeyUtils.buildCurrentKey(); + List entries = rankingQueryRepository.findTopStores(currentKey, topN); + return rankingQueryRepository.findTopStoresWithUser(currentKey, userStoreId, topN, entries); + } + + private List calculateRankingDeltas(List entries) { + String previousKey = RedisKeyUtils.buildPreviousKey(); + return entries.stream() + .map(entry -> calculateDeltaForEntry(entry, previousKey)) + .collect(Collectors.toList()); + } + + private RankingEntry calculateDeltaForEntry(RankingEntry entry, String previousKey) { + Long prevZero = rankingQueryRepository.findPrevRank(previousKey, entry.getStoreId()); + long prevRank = (prevZero == null ? entry.getCurrentRank() : prevZero + 1); + int delta = (int)(prevRank - entry.getCurrentRank()); + return new RankingEntry( + entry.getStoreId(), + entry.getTotalSales(), + entry.getCurrentRank(), + delta + ); + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/service/StoreRankCacheService.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/service/StoreRankCacheService.java deleted file mode 100644 index 444aedcf..00000000 --- a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/service/StoreRankCacheService.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.nowait.domaincoreredis.service; - -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Service; - -@Service -public class StoreRankCacheService { - - private RedisTemplate redisTemplate; -}