Skip to content

Commit 76306f9

Browse files
authored
Merge pull request #92 from Move-Log/develop
[FEAT] 전체 사용자 기준 기록 통계 정보 조회 기능 구현
2 parents 1ef8c11 + 3d958b6 commit 76306f9

File tree

8 files changed

+416
-13
lines changed

8 files changed

+416
-13
lines changed

src/main/java/com/movelog/MoveLogApplication.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
package com.movelog;
22

3+
import com.movelog.domain.record.application.DataMigrationService;
34
import com.movelog.global.config.YamlPropertySourceFactory;
5+
import jakarta.annotation.PostConstruct;
6+
import lombok.extern.slf4j.Slf4j;
47
import org.springframework.boot.SpringApplication;
58
import org.springframework.boot.autoconfigure.SpringBootApplication;
69
import org.springframework.cloud.openfeign.EnableFeignClients;
710
import org.springframework.context.annotation.PropertySource;
811
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
912

13+
@Slf4j
1014
@SpringBootApplication
1115
@EnableJpaAuditing
1216
@EnableFeignClients
@@ -18,7 +22,22 @@
1822
@PropertySource(value = { "classpath:webclient/application-webclient.yml" }, factory = YamlPropertySourceFactory.class)
1923
@PropertySource(value = { "classpath:redis/application-redis.yml" }, factory = YamlPropertySourceFactory.class)
2024
public class MoveLogApplication {
25+
26+
private final DataMigrationService dataMigrationService;
27+
28+
// 생성자를 통한 의존성 주입
29+
public MoveLogApplication(DataMigrationService dataMigrationService) {
30+
this.dataMigrationService = dataMigrationService;
31+
}
32+
2133
public static void main(String[] args) {
2234
SpringApplication.run(MoveLogApplication.class, args);
2335
}
36+
37+
@PostConstruct
38+
public void init() {
39+
log.info("Redis data migration start!");
40+
dataMigrationService.migrateDataToRedis();
41+
log.info("Redis data migration complete!");
42+
}
2443
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.movelog.domain.record.application;
2+
3+
4+
import com.movelog.domain.record.domain.Record;
5+
import com.movelog.domain.record.domain.VerbType;
6+
import com.movelog.domain.record.domain.repository.RecordRepository;
7+
import jakarta.transaction.Transactional;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.data.redis.core.RedisTemplate;
10+
import org.springframework.stereotype.Service;
11+
12+
import java.time.LocalDate;
13+
import java.time.format.DateTimeFormatter;
14+
import java.util.List;
15+
16+
@Service
17+
@RequiredArgsConstructor
18+
public class DataMigrationService {
19+
20+
private final RecordRepository recordRepository; // 기존 DB 조회용
21+
private final RedisTemplate<String, String> redisTemplate;
22+
23+
/**
24+
* 기존 DB 데이터를 Redis로 마이그레이션
25+
*/
26+
@Transactional
27+
public void migrateDataToRedis() {
28+
List<Record> records = recordRepository.findAllWithKeyword(); // 모든 데이터 한 번에 조회
29+
30+
for (Record record : records) {
31+
if (record.getKeyword() == null) continue;
32+
33+
String action = record.getKeyword().getKeyword(); // 키워드 (명사)
34+
LocalDate date = record.getActionTime().toLocalDate(); // 날짜
35+
VerbType verbType = record.getKeyword().getVerbType(); // 동사 유형
36+
37+
if (verbType == null) continue; // Null 체크
38+
39+
// Redis 키 설정 (날짜별, 전체 통계)
40+
String dailyKey = "stats:daily:" + verbType.getVerbType() + ":" + date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
41+
String totalKey = "stats:total:" + verbType.getVerbType();
42+
43+
// Redis에 데이터 저장
44+
redisTemplate.opsForZSet().incrementScore(dailyKey, action, 1);
45+
redisTemplate.opsForZSet().incrementScore(totalKey, action, 1);
46+
}
47+
48+
System.out.println("✅ Redis 데이터 마이그레이션 완료!");
49+
}
50+
}

src/main/java/com/movelog/domain/record/application/RecordService.java

Lines changed: 255 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,15 @@
1818
import org.springframework.data.domain.Page;
1919
import org.springframework.data.domain.PageRequest;
2020
import org.springframework.data.domain.Pageable;
21+
import org.springframework.data.redis.core.RedisTemplate;
22+
import org.springframework.data.redis.core.ZSetOperations;
2123
import org.springframework.stereotype.Service;
2224
import org.springframework.transaction.annotation.Transactional;
2325
import org.springframework.web.multipart.MultipartFile;
2426

2527
import java.text.Collator;
26-
import java.time.LocalDate;
27-
import java.time.LocalDateTime;
28-
import java.time.LocalTime;
28+
import java.time.*;
29+
import java.time.format.DateTimeFormatter;
2930
import java.util.*;
3031
import java.util.stream.Collectors;
3132

@@ -40,11 +41,19 @@ public class RecordService {
4041
private final KeywordRepository keywordRepository;
4142
private final S3Util s3Util;
4243
private final UserRepository userRepository;
44+
private final RedisTemplate<String, String> redisTemplate;
45+
46+
47+
private static final String DAILY_PREFIX = "stats:daily:";
48+
private static final String WEEKLY_PREFIX = "stats:weekly:";
49+
private static final String MONTHLY_PREFIX = "stats:monthly:";
50+
private static final String TOTAL_PREFIX = "stats:total";
51+
4352

4453
@Transactional
4554
public void createRecord(UserPrincipal userPrincipal, CreateRecordReq createRecordReq, MultipartFile img) {
46-
User user = validUserById(userPrincipal);
47-
// User user = userRepository.findById(5L).orElseThrow(UserNotFoundException::new);
55+
// User user = validUserById(userPrincipal);
56+
User user = userRepository.findById(5L).orElseThrow(UserNotFoundException::new);
4857
validateCreateRecordReq(createRecordReq);
4958

5059
String recordImgUrl;
@@ -201,6 +210,247 @@ public List<Recent5RecordImagesRes> retrieveCurrentRecordImages(UserPrincipal us
201210
.toList();
202211
}
203212

213+
/**
214+
* 전체 사용자 기록 통계 조회 (월별 조회 및 일간 조회 개선)
215+
*/
216+
public AllUserRecordStatsRes getAllUserRecordStats(UserPrincipal userPrincipal, String category, String period, String month) {
217+
validUserById(userPrincipal);
218+
String redisKey = getRedisKey(period);
219+
220+
// 총 기록 횟수 조회
221+
int totalRecords = getTotalRecords(redisKey, category);
222+
223+
// 최고 연속 기록 조회
224+
int maxConsecutiveDays = getMaxConsecutiveDays(category);
225+
226+
// 평균 일간 기록 계산
227+
double avgDailyRecord = calculateAvgDailyRecord(category);
228+
229+
// 하루 동안 가장 많이 기록한 횟수 조회
230+
int maxDailyRecord = getMaxDailyRecord(redisKey, category);
231+
232+
// TOP 5 키워드 조회
233+
List<Map<String, Object>> topRecords = getTopRecords(category);
234+
235+
// 날짜별 기록 개수 조회 (달력 표시용, 월별인지 확인 후 호출)
236+
Map<LocalDate, Integer> dailyRecords = "monthly".equals(period) ?
237+
getMonthlyRecords(category, month) :
238+
getDailyRecords(category);
239+
240+
return AllUserRecordStatsRes.builder()
241+
.category(category)
242+
.totalRecords(totalRecords)
243+
.maxConsecutiveDays(maxConsecutiveDays)
244+
.avgDailyRecord(avgDailyRecord)
245+
.maxDailyRecord(maxDailyRecord)
246+
.topRecords(topRecords)
247+
.dailyRecords(dailyRecords)
248+
.build();
249+
}
250+
251+
252+
/**
253+
* Redis Key 생성
254+
*/
255+
private String getRedisKey(String period) {
256+
String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
257+
String week = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-ww"));
258+
String month = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
259+
260+
return switch (period) {
261+
case "daily" -> DAILY_PREFIX + today;
262+
case "weekly" -> WEEKLY_PREFIX + week;
263+
case "monthly" -> MONTHLY_PREFIX + month;
264+
default -> TOTAL_PREFIX;
265+
};
266+
}
267+
268+
/**
269+
* 총 기록 횟수 조회 (Redis 조회 개선)
270+
*/
271+
private int getTotalRecords(String redisKey, String category) {
272+
Set<String> records = redisTemplate.opsForZSet().reverseRange(redisKey, 0, -1);
273+
274+
// Redis에서 데이터가 없을 경우 DB 데이터 기반으로 계산
275+
if (records == null || records.isEmpty()) {
276+
System.out.println("⚠️ Redis is empty for key: " + redisKey + " → Fetching from DB");
277+
278+
// DB에서 전체 기록 횟수 계산
279+
List<Record> recordsFromDb = recordRepository.findAllWithKeyword();
280+
return (int) recordsFromDb.stream()
281+
.filter(record -> record.getKeyword() != null && record.getKeyword().getVerbType().getVerbType().equals(category))
282+
.count();
283+
}
284+
285+
return records.size();
286+
}
287+
288+
289+
/**
290+
* 최대 연속 기록일 계산
291+
*/
292+
private int getMaxConsecutiveDays(String category) {
293+
VerbType verbType = VerbType.fromValue(category);
294+
List<Record> records = recordRepository.findAllWithKeyword();
295+
296+
// `VerbType`에 맞는 데이터만 필터링하여 날짜만 추출
297+
List<LocalDate> recordDates = records.stream()
298+
.filter(record -> record.getKeyword() != null && record.getKeyword().getVerbType() == verbType)
299+
.map(record -> record.getActionTime().toLocalDate())
300+
.distinct() // 중복 제거
301+
.sorted() // 날짜순 정렬
302+
.collect(Collectors.toList());
303+
304+
if (recordDates.isEmpty()) return 0; // 기록이 없으면 0 반환
305+
306+
int maxStreak = 1;
307+
int currentStreak = 1;
308+
309+
for (int i = 1; i < recordDates.size(); i++) {
310+
if (recordDates.get(i).equals(recordDates.get(i - 1).plusDays(1))) {
311+
currentStreak++;
312+
maxStreak = Math.max(maxStreak, currentStreak);
313+
} else {
314+
currentStreak = 1;
315+
}
316+
}
317+
318+
return maxStreak;
319+
}
320+
321+
322+
/**
323+
* 평균 일간 기록 계산 (DB 데이터 기반)
324+
*/
325+
private double calculateAvgDailyRecord(String category) {
326+
Map<LocalDate, Integer> dailyRecords = getDailyRecords(category); // 날짜별 기록 개수 조회
327+
328+
if (dailyRecords.isEmpty()) return 0.0; // 기록이 없으면 0 반환
329+
330+
int totalRecords = dailyRecords.values().stream().mapToInt(Integer::intValue).sum();
331+
int totalDays = dailyRecords.size();
332+
333+
// 반올림
334+
return Math.round((double) totalRecords / totalDays * 10) / 10.0;
335+
336+
}
337+
338+
339+
/**
340+
* 하루 동안 가장 많이 기록한 횟수 조회 (Redis + DB 조회)
341+
*/
342+
private int getMaxDailyRecord(String redisKey, String category) {
343+
Set<String> records = redisTemplate.opsForZSet().reverseRange(redisKey, 0, -1);
344+
345+
if (records == null || records.isEmpty()) {
346+
System.out.println("⚠️ Redis is empty for key: " + redisKey + " → Fetching from DB");
347+
348+
// DB에서 날짜별 기록 개수 가져와서 최대값 찾기
349+
Map<LocalDate, Integer> dailyRecords = getDailyRecords(category);
350+
return dailyRecords.values().stream().max(Integer::compareTo).orElse(0);
351+
}
352+
353+
return records.size();
354+
}
355+
356+
357+
/**
358+
* TOP 5 키워드 조회 (모든 데이터 가져온 후 서비스 단에서 필터링)
359+
*/
360+
private List<Map<String, Object>> getTopRecords(String category) {
361+
// 한글 문자열을 VerbType Enum으로 변환
362+
VerbType verbType = VerbType.fromValue(category);
363+
364+
// 모든 데이터 한 번에 조회
365+
List<Record> records = recordRepository.findAllWithKeyword();
366+
Map<String, Integer> keywordCounts = new HashMap<>();
367+
368+
for (Record record : records) {
369+
if (record.getKeyword() != null && record.getKeyword().getVerbType() == verbType) {
370+
String keyword = record.getKeyword().getKeyword();
371+
keywordCounts.put(keyword, keywordCounts.getOrDefault(keyword, 0) + 1);
372+
}
373+
}
374+
375+
return keywordCounts.entrySet().stream()
376+
.sorted((a, b) -> b.getValue().compareTo(a.getValue())) // 내림차순 정렬
377+
.limit(5)
378+
.map(entry -> {
379+
Map<String, Object> map = new HashMap<>();
380+
map.put("record", entry.getKey());
381+
map.put("count", entry.getValue());
382+
return map;
383+
})
384+
.collect(Collectors.toList());
385+
}
386+
387+
388+
/**
389+
* 날짜별 기록 개수 조회 (평균 일간 기록 정보 조회를 위함)
390+
*/
391+
private Map<LocalDate, Integer> getDailyRecords(String category) {
392+
// 한글 문자열을 VerbType Enum으로 변환
393+
VerbType verbType = VerbType.fromValue(category);
394+
395+
// 모든 데이터를 조회 후, 서비스 단에서 필터링
396+
List<Record> records = recordRepository.findAllWithKeyword();
397+
Map<LocalDate, Integer> dailyRecordCount = new HashMap<>();
398+
399+
// `VerbType`에 맞는 데이터만 필터링하여 날짜별 개수 카운트
400+
for (Record record : records) {
401+
if (record.getKeyword() != null && record.getKeyword().getVerbType() == verbType) {
402+
LocalDate date = record.getActionTime().toLocalDate();
403+
dailyRecordCount.put(date, dailyRecordCount.getOrDefault(date, 0) + 1);
404+
}
405+
}
406+
return dailyRecordCount;
407+
}
408+
409+
private Map<LocalDate, Integer> getMonthlyRecords(String category, String month) {
410+
VerbType verbType = VerbType.fromValue(category);
411+
Map<LocalDate, Integer> dailyRecordCount = new LinkedHashMap<>();
412+
413+
if (month == null || month.isBlank()) {
414+
log.info("⚠️ Invalid month input: [{}]", month);
415+
return dailyRecordCount;
416+
}
417+
418+
// month 값 정리: 공백 제거 + 숫자와 '-'만 유지
419+
month = month.trim().replaceAll("[^0-9-]", "");
420+
421+
// 🕒 월 시작일 & 종료일 설정
422+
YearMonth yearMonth = YearMonth.parse(month, DateTimeFormatter.ofPattern("yyyy-MM"));
423+
LocalDateTime startDate = yearMonth.atDay(1).atStartOfDay(); // ex) 2025-02-01 00:00:00
424+
LocalDateTime endDate = yearMonth.atEndOfMonth().atTime(23, 59, 59); // ex) 2025-02-28 23:59:59
425+
426+
log.info("🔍 Fetching records for category: [{}] between [{}] and [{}]", category, startDate, endDate);
427+
428+
// 📌 DB에서 해당 월의 데이터만 필터링하여 조회
429+
List<Record> records = recordRepository.findRecordsByMonth(verbType, startDate, endDate);
430+
log.info("📊 Retrieved {} records from DB", records.size());
431+
432+
if (records.isEmpty()) {
433+
log.info("⚠️ No records found for category: {}", category);
434+
return dailyRecordCount;
435+
}
436+
437+
// 📅 날짜별 개수 카운트 (타임존 변환 추가)
438+
for (Record record : records) {
439+
log.info("⏳ Raw ActionTime: {}", record.getActionTime());
440+
ZonedDateTime zonedDateTime = record.getActionTime().atZone(ZoneId.of("UTC"))
441+
.withZoneSameInstant(ZoneId.of("Asia/Seoul"));
442+
LocalDate date = zonedDateTime.toLocalDate();
443+
log.info("📅 Converted LocalDate: {}", date);
444+
445+
dailyRecordCount.put(date, dailyRecordCount.getOrDefault(date, 0) + 1);
446+
}
447+
448+
log.info("✅ Final Monthly Records: {}", dailyRecordCount);
449+
450+
return dailyRecordCount;
451+
}
452+
453+
204454

205455
private User validUserById(UserPrincipal userPrincipal) {
206456
Optional<User> userOptional = userService.findById(userPrincipal.getId());

0 commit comments

Comments
 (0)