1818import org .springframework .data .domain .Page ;
1919import org .springframework .data .domain .PageRequest ;
2020import org .springframework .data .domain .Pageable ;
21+ import org .springframework .data .redis .core .RedisTemplate ;
22+ import org .springframework .data .redis .core .ZSetOperations ;
2123import org .springframework .stereotype .Service ;
2224import org .springframework .transaction .annotation .Transactional ;
2325import org .springframework .web .multipart .MultipartFile ;
2426
2527import 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 ;
2930import java .util .*;
3031import 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