Skip to content

Commit 0b5776f

Browse files
authored
Merge pull request #109 from GTable/feature/#108-Store-Caeching
Feat: 주점 매출 순위 등락 기능 추가
2 parents c0cdc58 + 987130e commit 0b5776f

30 files changed

Lines changed: 633 additions & 79 deletions

File tree

nowait-app-admin-api/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ dependencies {
2121
implementation project(':nowait-infra')
2222
implementation project(':nowait-domain:domain-admin-rdb')
2323
implementation project(':nowait-domain:domain-core-rdb')
24+
implementation project(':nowait-domain:domain-redis')
2425

2526
// Spring Boot Starter
2627
implementation 'org.springframework.boot:spring-boot-starter-web'
@@ -36,6 +37,9 @@ dependencies {
3637
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
3738
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
3839

40+
// redis
41+
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
42+
3943
// SWAGGER
4044
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
4145

nowait-app-admin-api/src/main/java/com/nowait/ApiAdminApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
import org.springframework.boot.autoconfigure.SpringBootApplication;
44
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
5+
import org.springframework.scheduling.annotation.EnableScheduling;
56

67
@EnableJpaAuditing
8+
@EnableScheduling
79
@SpringBootApplication
810
public class ApiAdminApplication {
911
public static void main(String[] args) {

nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/controller/OrderController.java

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@
1717
import com.nowait.applicationadmin.order.dto.OrderStatusUpdateResponseDto;
1818
import com.nowait.applicationadmin.order.service.OrderService;
1919
import com.nowait.common.api.ApiUtils;
20-
import com.nowait.domaincorerdb.order.dto.OrderSalesSumDetail;
21-
import com.nowait.domaincorerdb.order.dto.TopSalesStoresDetail;
2220
import com.nowait.domaincorerdb.user.entity.MemberDetails;
2321

2422
import io.swagger.v3.oas.annotations.Operation;
@@ -65,34 +63,4 @@ public ResponseEntity<?> updateOrderStatus(
6563
.status(HttpStatus.OK)
6664
.body(ApiUtils.success(response));
6765
}
68-
69-
@GetMapping("/sales")
70-
@Operation(summary = "오늘의 매출 조회", description = "오늘의 매출을 조회합니다.")
71-
@ApiResponse(responseCode = "200", description = "오늘의 매출 조회 성공")
72-
public ResponseEntity<?> getTodaySales(@AuthenticationPrincipal MemberDetails memberDetails) {
73-
OrderSalesSumDetail sales = orderService.getSaleSumByStoreId(memberDetails);
74-
75-
return ResponseEntity
76-
.status(HttpStatus.OK)
77-
.body(
78-
ApiUtils.success(
79-
sales
80-
)
81-
);
82-
}
83-
84-
@GetMapping("/top-sales")
85-
@Operation(summary = "오늘의 매출 상위 5개 주점 조회", description = "오늘의 매출이 가장 높은 상위 5개 주점을 조회합니다.")
86-
@ApiResponse(responseCode = "200", description = "오늘의 매출 상위 5개 주점 조회 성공")
87-
public ResponseEntity<?> getTopSalesStores(@AuthenticationPrincipal MemberDetails memberDetails) {
88-
List<TopSalesStoresDetail> topSalesStoresDetail = orderService.getTop5StoresBySalesToday(memberDetails);
89-
90-
return ResponseEntity
91-
.status(HttpStatus.OK)
92-
.body(
93-
ApiUtils.success(
94-
topSalesStoresDetail
95-
)
96-
);
97-
}
9866
}

nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/service/OrderService.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
import com.nowait.applicationadmin.order.dto.OrderResponseDto;
1010
import com.nowait.applicationadmin.order.dto.OrderStatusUpdateResponseDto;
1111
import com.nowait.common.enums.Role;
12-
import com.nowait.domaincorerdb.order.dto.OrderSalesSumDetail;
13-
import com.nowait.domaincorerdb.order.dto.TopSalesStoresDetail;
12+
import com.nowait.domainadminrdb.statistic.dto.OrderSalesSumDetail;
13+
import com.nowait.domainadminrdb.statistic.dto.TopSalesStoresDetail;
14+
import com.nowait.domainadminrdb.statistic.repository.StatisticCustomRepository;
1415
import com.nowait.domaincorerdb.order.entity.OrderStatus;
1516
import com.nowait.domaincorerdb.order.entity.UserOrder;
1617
import com.nowait.domaincorerdb.order.exception.OrderNotFoundException;
@@ -30,6 +31,7 @@
3031
@RequiredArgsConstructor
3132
public class OrderService {
3233
private final OrderRepository orderRepository;
34+
private final StatisticCustomRepository statisticCustomRepository;
3335
private final UserRepository userRepository;
3436
private final StoreRepository storeRepository;
3537

@@ -67,7 +69,7 @@ public OrderSalesSumDetail getSaleSumByStoreId(MemberDetails memberDetails) {
6769
throw new OrderViewUnauthorizedException();
6870
}
6971

70-
return orderRepository.findSalesSumByStoreId(storeId);
72+
return statisticCustomRepository.findSalesSumByStoreId(storeId);
7173
}
7274

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

82-
return orderRepository.getTop4PlusMine(storeId);
84+
return statisticCustomRepository.getTop4PlusMine(storeId);
8385
}
8486
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.nowait.applicationadmin.statistic.controller;
2+
3+
import java.util.List;
4+
5+
import org.springframework.http.HttpStatus;
6+
import org.springframework.http.ResponseEntity;
7+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
8+
import org.springframework.web.bind.annotation.GetMapping;
9+
import org.springframework.web.bind.annotation.RequestMapping;
10+
import org.springframework.web.bind.annotation.RestController;
11+
12+
import com.nowait.applicationadmin.order.service.OrderService;
13+
import com.nowait.applicationadmin.statistic.dto.StoreRankingDto;
14+
import com.nowait.applicationadmin.statistic.service.RankingService;
15+
import com.nowait.common.api.ApiUtils;
16+
import com.nowait.domainadminrdb.statistic.dto.OrderSalesSumDetail;
17+
import com.nowait.domaincorerdb.user.entity.MemberDetails;
18+
19+
import io.swagger.v3.oas.annotations.Operation;
20+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
21+
import io.swagger.v3.oas.annotations.tags.Tag;
22+
import lombok.RequiredArgsConstructor;
23+
24+
@Tag(name = "Statistics API", description = "통계 API")
25+
@RestController
26+
@RequestMapping("/admin/statistics")
27+
@RequiredArgsConstructor
28+
public class StatisticsController {
29+
30+
private final OrderService orderService;
31+
private final RankingService rankingService;
32+
33+
@GetMapping("/sales")
34+
@Operation(summary = "오늘의 매출 조회", description = "오늘의 매출을 조회합니다.")
35+
@ApiResponse(responseCode = "200", description = "오늘의 매출 조회 성공")
36+
public ResponseEntity<?> getTodaySales(@AuthenticationPrincipal MemberDetails memberDetails) {
37+
OrderSalesSumDetail sales = orderService.getSaleSumByStoreId(memberDetails);
38+
39+
return ResponseEntity
40+
.status(HttpStatus.OK)
41+
.body(
42+
ApiUtils.success(
43+
sales
44+
)
45+
);
46+
}
47+
48+
@GetMapping("/top-sales")
49+
@Operation(summary = "주점별 매출 통계 조회", description = "주점별 매출 통계를 조회합니다.")
50+
@ApiResponse(responseCode = "200", description = "주점별 매출 통계 조회 성공")
51+
public ResponseEntity<?> getTopSalesStores(@AuthenticationPrincipal MemberDetails memberDetails) {
52+
List<StoreRankingDto> response = rankingService.getStatisticsRankings(memberDetails);
53+
return ResponseEntity
54+
.status(HttpStatus.OK)
55+
.body(
56+
ApiUtils.success(
57+
response
58+
)
59+
);
60+
}
61+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.nowait.applicationadmin.statistic.dto;
2+
3+
import lombok.Getter;
4+
5+
@Getter
6+
public class StoreRankingDto {
7+
private final Long storeId;
8+
private final String storeName;
9+
private final Long departmentId;
10+
private final String departmentName;
11+
private final Integer totalSales;
12+
private final Long currentRank;
13+
private final Integer delta;
14+
15+
public StoreRankingDto(Long storeId, String storeName, Long departmentId, String departmentName, Integer totalSales,
16+
Long currentRank, Integer delta) {
17+
this.storeId = storeId;
18+
this.storeName = storeName;
19+
this.departmentId = departmentId;
20+
this.departmentName = departmentName;
21+
this.totalSales = totalSales;
22+
this.currentRank = currentRank;
23+
this.delta = delta;
24+
}
25+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package com.nowait.applicationadmin.statistic.scheduler;
2+
3+
import java.time.LocalDateTime;
4+
import java.util.List;
5+
6+
import org.springframework.context.annotation.Configuration;
7+
import org.springframework.data.redis.core.RedisCallback;
8+
import org.springframework.data.redis.core.RedisTemplate;
9+
import org.springframework.scheduling.annotation.EnableScheduling;
10+
import org.springframework.scheduling.annotation.Scheduled;
11+
import org.springframework.stereotype.Component;
12+
13+
import com.nowait.domainadminrdb.statistic.dto.StoreSales;
14+
import com.nowait.domainadminrdb.statistic.repository.StatisticCustomRepository;
15+
import com.nowait.domaincoreredis.common.util.RedisKeyUtils;
16+
import com.nowait.domaincoreredis.rank.repository.RankingQueryRepository;
17+
18+
import jakarta.annotation.PostConstruct;
19+
import lombok.RequiredArgsConstructor;
20+
import lombok.extern.slf4j.Slf4j;
21+
22+
23+
@Component
24+
@RequiredArgsConstructor
25+
@Slf4j
26+
public class RankingRefreshScheduler {
27+
28+
private final StatisticCustomRepository statisticCustomRepository;
29+
private final RankingQueryRepository rankingQueryRepository;
30+
private final RedisTemplate<String, String> redis;
31+
32+
@PostConstruct
33+
public void init() {
34+
// 초기화 작업
35+
refresh();
36+
}
37+
38+
@Scheduled(cron = "0 */5 * * * *") // 매 5분마다 실행
39+
public void refresh() {
40+
log.info("RankingRefreshScheduler.refresh() called at {}", LocalDateTime.now());
41+
42+
try {
43+
doRefresh();
44+
} catch (Exception e) {
45+
log.error("랭킹 데이터 갱신 중 오류 발생", e);
46+
// 예외 발생 시 알림 또는 로깅 처리
47+
}
48+
}
49+
50+
private void doRefresh() {
51+
String nextKey = RedisKeyUtils.buildNextKey();
52+
String currentKey = RedisKeyUtils.buildCurrentKey();
53+
String previousKey = RedisKeyUtils.buildPreviousKey();
54+
55+
// 1) 다음 스냅샷 키 초기화
56+
redis.delete(nextKey);
57+
58+
// 2) DB에서 매출 합계 가져와 ZADD
59+
List<StoreSales> salesList = statisticCustomRepository.findTotalSales();
60+
61+
if (salesList.isEmpty()) {
62+
log.warn("매출 데이터가 없습니다. 다음 스냅샷 키를 초기화합니다.");
63+
return;
64+
}
65+
66+
salesList.forEach(s ->
67+
rankingQueryRepository.addToRanking(nextKey, s.getStoreId(), s.getTotalSales())
68+
);
69+
70+
// 3) 현재 스냅샷 키를 이전 스냅샷 키로 이동
71+
rotateKeys(currentKey, previousKey, nextKey);
72+
}
73+
74+
private void rotateKeys(String currentKey, String previousKey, String nextKey) {
75+
try {
76+
redis.execute((RedisCallback<Object>)connection -> {
77+
// 현재 스냅샷 키를 이전 스냅샷 키로 이동하고, 다음 스냅샷 키를 현재 스냅샷 키로 이동
78+
if (redis.hasKey(previousKey)) {
79+
redis.delete(previousKey);
80+
}
81+
// 현재 스냅샷 키를 다음 스냅샷 키로 이동
82+
if (redis.hasKey(currentKey)) {
83+
redis.rename(currentKey, previousKey);
84+
}
85+
// 다음 스냅샷 키가 존재하면 현재 스냅샷 키로 이동
86+
if (redis.hasKey(nextKey)) {
87+
redis.rename(nextKey, currentKey);
88+
}
89+
return null;
90+
});
91+
log.info("Keys rotated: current -> {}, previous -> {}, next -> {}", currentKey, previousKey, nextKey);
92+
} catch (Exception e) {
93+
log.error("Redis 키 교체 중 오류 발생", e);
94+
throw new RuntimeException("랭킹 데이터 갱신 실패", e);
95+
}
96+
}
97+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.nowait.applicationadmin.statistic.service;
2+
3+
import java.util.List;
4+
5+
import com.nowait.applicationadmin.statistic.dto.StoreRankingDto;
6+
import com.nowait.domaincorerdb.user.entity.MemberDetails;
7+
8+
public interface RankingService {
9+
List<StoreRankingDto> getStatisticsRankings(MemberDetails memberDetails);
10+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.nowait.applicationadmin.statistic.service.impl;
2+
3+
import java.util.List;
4+
import java.util.Map;
5+
import java.util.function.Function;
6+
import java.util.stream.Collectors;
7+
8+
import org.springframework.stereotype.Service;
9+
import org.springframework.transaction.annotation.Transactional;
10+
11+
import com.nowait.applicationadmin.statistic.dto.StoreRankingDto;
12+
import com.nowait.applicationadmin.statistic.service.RankingService;
13+
import com.nowait.common.enums.Role;
14+
import com.nowait.domainadminrdb.statistic.dto.StoreInfo;
15+
import com.nowait.domainadminrdb.statistic.exception.StatisticViewUnauthorizedException;
16+
import com.nowait.domainadminrdb.statistic.repository.StatisticCustomRepository;
17+
import com.nowait.domaincorerdb.user.entity.MemberDetails;
18+
import com.nowait.domaincorerdb.user.entity.User;
19+
import com.nowait.domaincorerdb.user.exception.UserNotFoundException;
20+
import com.nowait.domaincorerdb.user.repository.UserRepository;
21+
import com.nowait.domaincoreredis.rank.dto.RankingEntry;
22+
import com.nowait.domaincoreredis.rank.service.RankingQueryService;
23+
24+
import lombok.RequiredArgsConstructor;
25+
26+
@Service
27+
@RequiredArgsConstructor
28+
public class RankingServiceImpl implements RankingService {
29+
30+
private final StatisticCustomRepository statisticCustomRepository;
31+
private final UserRepository userRepository;
32+
private final RankingQueryService rankingQuery;
33+
34+
35+
@Override
36+
@Transactional(readOnly = true)
37+
public List<StoreRankingDto> getStatisticsRankings(MemberDetails memberDetails) {
38+
User user = userRepository.findById(memberDetails.getId()).orElseThrow(UserNotFoundException::new);
39+
Long userStoreId = user.getStoreId();
40+
41+
if (!Role.SUPER_ADMIN.equals(user.getRole()) && !user.getStoreId().equals(userStoreId)) {
42+
throw new StatisticViewUnauthorizedException();
43+
}
44+
45+
// 1) Redis에서 Top4+내주점: storeId, totalSales, currentRank, delta
46+
List<RankingEntry> entries = rankingQuery.getRankings(userStoreId, 5);
47+
48+
// 2) DB에서 store 정보 가져오기
49+
List<Long> storeIds = entries.stream()
50+
.map(RankingEntry::getStoreId)
51+
.toList();
52+
53+
List<StoreInfo> infos = statisticCustomRepository.findStoreInfoByIds(storeIds);
54+
55+
// StoreInfo를 storeId로 매핑
56+
Map<Long, StoreInfo> infoMap = infos.stream()
57+
.collect(Collectors.toMap(StoreInfo::getStoreId, Function.identity()));
58+
59+
// 3) 매핑 → 최종 DTO
60+
return entries.stream()
61+
.map(e -> {
62+
StoreInfo info = infoMap.get(e.getStoreId());
63+
return new StoreRankingDto(
64+
e.getStoreId(),
65+
info.getStoreName(),
66+
info.getDepartmentId(),
67+
info.getDepartmentName(),
68+
e.getTotalSales(),
69+
e.getCurrentRank(),
70+
e.getDelta()
71+
);
72+
})
73+
.collect(Collectors.toList());
74+
}
75+
}

nowait-common/src/main/java/com/nowait/common/exception/ErrorMessage.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ public enum ErrorMessage {
5050
STORE_WAITING_DISABLED("해당 주점은 대기 비활성화된 주점입니다.", "store006"),
5151

5252
// storePayment
53-
5453
STORE_PAYMENT_PARAMETER_EMPTY("주점 결제 생성 시 파라미터 정보가 없습니다.", "storePayment001"),
5554
STORE_PAYMENT_NOT_FOUND("해당 주점 결제 정보를 찾을 수 없습니다.", "storePayment002"),
5655
STORE_PAYMENT_VIEW_UNAUTHORIZED("주점 결제 정보 보기 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "storePayment003"),
@@ -59,6 +58,8 @@ public enum ErrorMessage {
5958
STORE_PAYMENT_DELETE_UNAUTHORIZED("주점 결제 정보 삭제 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "storePayment005"),
6059
STORE_PAYMENT_ALREADY_EXISTS("이미 존재하는 주점 결제 정보입니다.", "storePayment006"),
6160

61+
// Statistics
62+
STATISTIC_VIEW_UNAUTHORIZED("통계 보기 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "statistics001"),
6263

6364
// image
6465
IMAGE_FILE_EMPTY("이미지 파일을 업로드 해주세요", "image001"),

0 commit comments

Comments
 (0)