Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
029e5aa
feat: Redis 사용을 위한 의존성 추가
Jjiggu Jul 16, 2025
83d543b
feat: QueryDSL 사용의 위한 의존성 추가
Jjiggu Jul 16, 2025
2e547cb
feat(Statistics): Statistics 에러 메시지 추가
Jjiggu Jul 16, 2025
502d1e3
feat(Statistics): 랭킹 repository Redis 구현 및 랭킹 조회/추가 기능 추가
Jjiggu Jul 16, 2025
97169dd
feat(Statistics): 랭킹 조회 서비스 구현 및 등락 계산 로직 추가
Jjiggu Jul 16, 2025
2518c63
feat(Statistics): 랭킹 통계 조회 서비스 구현 및 Redis-DB 연동
Jjiggu Jul 16, 2025
465396f
feat(Statistics): 통계 커스텀 repository 구현 및 매출/랭킹 쿼리 추가
Jjiggu Jul 16, 2025
d94b13b
feat(Statistics): 통계 API 컨트롤러 구현 및 매출, 랭킹 조회 엔드포인트 추가
Jjiggu Jul 16, 2025
079897e
feat(Statistics): 랭킹 스냅샷 주기적 갱신 스케줄러 구현
Jjiggu Jul 16, 2025
8b9c3c6
feat(Statistics): 랭킹 스냅샷용 Redis 키 유틸 클래스 추가
Jjiggu Jul 16, 2025
cb73635
feat(Statistics): StatisticViewUnauthorizedException 추가
Jjiggu Jul 16, 2025
ed56093
feat(Statistics): RankingEntry DTO 클래스 추가
Jjiggu Jul 16, 2025
3b96246
feat(Statistics): StoreInfo DTO 클래스 추가
Jjiggu Jul 16, 2025
d86d465
feat(Statistics): StoreSales DTO 클래스 추가
Jjiggu Jul 16, 2025
65c0e4b
feat(Statistics): StoreRankingDto DTO 클래스 추가
Jjiggu Jul 16, 2025
ac16554
refactor(Order): 통계 관련 엔드포인트 삭제
Jjiggu Jul 16, 2025
a4a2b9b
refactor(Order): OrderCustomRepository 상속 제거
Jjiggu Jul 16, 2025
2fbf75a
chore(Order): statistic 하위로 이동
Jjiggu Jul 16, 2025
ba33b26
chore(Order): statistic 하위로 이동
Jjiggu Jul 16, 2025
5b8260a
refactor: redis 연결 설정 추가
Jjiggu Jul 16, 2025
ae8328a
refactor: null 안정성 문제 해결
Jjiggu Jul 16, 2025
c141084
refactor: StoreInfo DTO final 불변성 보장
Jjiggu Jul 16, 2025
eacc152
refactor: 메서드 분리 및 예외처리 추가
Jjiggu Jul 16, 2025
e04cb53
chore: QueryDSL Q클래스 생성 관련 TODO 어노테이션 추가
Jjiggu Jul 16, 2025
5fca343
refactor: Redis 키 교체 과정에서 원자성 보장
Jjiggu Jul 16, 2025
c3c530a
refactor: 스케쥴러 설정 변경
Jjiggu Jul 16, 2025
987130e
refactor: 크론 표현식 수정
Jjiggu Jul 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions nowait-app-admin-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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> topSalesStoresDetail = orderService.getTop5StoresBySalesToday(memberDetails);

return ResponseEntity
.status(HttpStatus.OK)
.body(
ApiUtils.success(
topSalesStoresDetail
)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,6 +31,7 @@
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final StatisticCustomRepository statisticCustomRepository;
private final UserRepository userRepository;
private final StoreRepository storeRepository;

Expand Down Expand Up @@ -67,7 +69,7 @@ public OrderSalesSumDetail getSaleSumByStoreId(MemberDetails memberDetails) {
throw new OrderViewUnauthorizedException();
}

return orderRepository.findSalesSumByStoreId(storeId);
return statisticCustomRepository.findSalesSumByStoreId(storeId);
}

@Transactional(readOnly = true)
Expand All @@ -79,6 +81,6 @@ public List<TopSalesStoresDetail> getTop5StoresBySalesToday(MemberDetails member
throw new OrderViewUnauthorizedException();
}

return orderRepository.getTop4PlusMine(storeId);
return statisticCustomRepository.getTop4PlusMine(storeId);
}
}
Original file line number Diff line number Diff line change
@@ -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<StoreRankingDto> response = rankingService.getStatisticsRankings(memberDetails);
return ResponseEntity
.status(HttpStatus.OK)
.body(
ApiUtils.success(
response
)
);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +15 to +24

@coderabbitai coderabbitai Bot Jul 16, 2025

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

생성자에 null 체크 및 검증 로직 추가를 권장합니다.

현재 생성자에서 매개변수에 대한 null 체크나 유효성 검증이 없어 런타임 오류가 발생할 수 있습니다.

public StoreRankingDto(Long storeId, String storeName, Long departmentId, String departmentName, Integer totalSales,
	Long currentRank, Integer delta) {
+	Objects.requireNonNull(storeId, "storeId는 null일 수 없습니다");
+	Objects.requireNonNull(storeName, "storeName은 null일 수 없습니다");
+	Objects.requireNonNull(departmentId, "departmentId는 null일 수 없습니다");
+	Objects.requireNonNull(departmentName, "departmentName은 null일 수 없습니다");
+	Objects.requireNonNull(totalSales, "totalSales는 null일 수 없습니다");
+	Objects.requireNonNull(currentRank, "currentRank는 null일 수 없습니다");
+	Objects.requireNonNull(delta, "delta는 null일 수 없습니다");
	this.storeId = storeId;
	this.storeName = storeName;
	this.departmentId = departmentId;
	this.departmentName = departmentName;
	this.totalSales = totalSales;
	this.currentRank = currentRank;
	this.delta = delta;
}

추가로 java.util.Objects import가 필요합니다:

+import java.util.Objects;
🤖 Prompt for AI Agents
In
nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/dto/StoreRankingDto.java
around lines 15 to 24, the constructor lacks null checks and validation for its
parameters, which can lead to runtime errors. Add null checks using
java.util.Objects.requireNonNull for parameters that must not be null, and
include any necessary validation logic to ensure data integrity. Also, import
java.util.Objects to support these checks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후 해결 예정

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후 해결 예정

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Jjiggu 네, 이해했습니다. 현재 PR 범위를 벗어난 개선사항으로 추후 해결하실 계획이시군요.

혹시 이 null 체크 및 검증 로직 추가 작업을 추적하기 위해 새로운 이슈를 생성하시겠습니까? 추후 작업 시 참조하실 수 있도록 도와드릴 수 있습니다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Jjiggu 알겠습니다! 현재 PR의 핵심 기능에 집중하시는 것이 좋겠네요.

추후 이 개선사항을 추적하기 위해 별도 이슈를 생성해드릴까요? 아니면 나중에 직접 처리하실 예정인가요?

🐰
/| /|
( ✌️ ✌️ )
) (
( () )
( ( ) ) )
()
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이슈 만들어줘

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Create GitHub issue for StoreRankingDto null check improvement
gh issue create \
  --title "StoreRankingDto 생성자에 null 체크 및 검증 로직 추가" \
  --body "## 개요
StoreRankingDto 생성자에서 매개변수에 대한 null 체크나 유효성 검증이 없어 런타임 오류가 발생할 수 있습니다.

## 상세 설명
현재 StoreRankingDto 클래스의 생성자는 다음 매개변수들에 대한 null 체크가 없습니다:
- storeId
- storeName  
- departmentId
- departmentName
- totalSales
- currentRank
- delta

## 해결 방안
java.util.Objects.requireNonNull을 사용하여 각 매개변수에 대한 null 체크를 추가하고, 필요한 유효성 검증 로직을 구현해야 합니다.

## 관련 링크
- PR: https://github.com/GTable/NoWait_BE_MultiModule/pull/109
- Comment: https://github.com/GTable/NoWait_BE_MultiModule/pull/109#discussion_r2210094285

## 요청자
@Jjiggu" \
  --assignee "Jjiggu"

Length of output: 668


@Jjiggu 이슈가 성공적으로 생성되었습니다!

이슈 #110: StoreRankingDto 생성자에 null 체크 및 검증 로직 추가
🔗 #110

이슈에는 현재 PR과 코멘트 링크가 포함되어 있어 나중에 작업하실 때 참조하실 수 있습니다. 담당자로 설정해드렸습니다.

}
Original file line number Diff line number Diff line change
@@ -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<String, String> 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<StoreSales> 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<Object>)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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<StoreRankingDto> getStatisticsRankings(MemberDetails memberDetails);
}
Original file line number Diff line number Diff line change
@@ -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<StoreRankingDto> 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<RankingEntry> entries = rankingQuery.getRankings(userStoreId, 5);

// 2) DB에서 store 정보 가져오기
List<Long> storeIds = entries.stream()
.map(RankingEntry::getStoreId)
.toList();

List<StoreInfo> infos = statisticCustomRepository.findStoreInfoByIds(storeIds);

// StoreInfo를 storeId로 매핑
Map<Long, StoreInfo> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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"),
Expand Down
Loading