Skip to content

Commit 7a8c1bd

Browse files
authored
Merge pull request #77 from Move-Log/develop
[FEAT] 사용자 기록 기반 단어 통계 정보 조회 기능 구현
2 parents 4f8d918 + b250279 commit 7a8c1bd

File tree

14 files changed

+321
-14
lines changed

14 files changed

+321
-14
lines changed

.github/workflows/deploy.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ jobs:
6363
6464
mkdir -p ./src/main/resources/webclient
6565
echo "${{ secrets.APPLICATION_WEBCLIENT_YML }}" | base64 --decode > ./src/main/resources/webclient/application-webclient.yml
66+
67+
mkdir -p ./src/main/resources/redis
68+
echo "${{ secrets.APPLICATION_REDIS_YML }}" | base64 --decode > ./src/main/resources/redis/application-redis.yml
6669
6770
# Docker 이미지 빌드
6871
- name: Build Docker image

build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ dependencies {
7777
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
7878

7979
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
80+
81+
// Redis
82+
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
83+
8084
}
8185

8286
tasks.named('test') {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
@PropertySource(value = { "classpath:s3/application-s3.yml" }, factory = YamlPropertySourceFactory.class)
1717
@PropertySource(value = { "classpath:chatgpt/application-chatgpt.yml" }, factory = YamlPropertySourceFactory.class)
1818
@PropertySource(value = { "classpath:webclient/application-webclient.yml" }, factory = YamlPropertySourceFactory.class)
19+
@PropertySource(value = { "classpath:redis/application-redis.yml" }, factory = YamlPropertySourceFactory.class)
1920
public class MoveLogApplication {
2021
public static void main(String[] args) {
2122
SpringApplication.run(MoveLogApplication.class, args);

src/main/java/com/movelog/domain/news/application/NewsService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import com.movelog.domain.record.domain.Keyword;
99
import com.movelog.domain.record.domain.VerbType;
1010
import com.movelog.domain.record.exception.KeywordNotFoundException;
11-
import com.movelog.domain.record.repository.KeywordRepository;
11+
import com.movelog.domain.record.domain.repository.KeywordRepository;
1212
import com.movelog.domain.user.application.UserService;
1313
import com.movelog.domain.user.domain.User;
1414
import com.movelog.domain.user.domain.repository.UserRepository;
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package com.movelog.domain.record.application;
2+
3+
import com.movelog.domain.record.domain.Keyword;
4+
import com.movelog.domain.record.domain.Record;
5+
import com.movelog.domain.record.domain.repository.RecordRepository;
6+
import com.movelog.domain.record.dto.response.MyKeywordStatsRes;
7+
import com.movelog.domain.record.dto.response.SearchKeywordInStatsRes;
8+
import com.movelog.domain.record.exception.KeywordNotFoundException;
9+
import com.movelog.domain.record.domain.repository.KeywordRepository;
10+
import com.movelog.domain.user.application.UserService;
11+
import com.movelog.domain.user.domain.User;
12+
import com.movelog.domain.user.domain.repository.UserRepository;
13+
import com.movelog.domain.user.exception.UserNotFoundException;
14+
import com.movelog.global.config.security.token.UserPrincipal;
15+
import lombok.RequiredArgsConstructor;
16+
import lombok.extern.slf4j.Slf4j;
17+
import org.springframework.stereotype.Service;
18+
import org.springframework.transaction.annotation.Transactional;
19+
20+
import java.time.LocalDateTime;
21+
import java.util.List;
22+
import java.util.Optional;
23+
24+
@Service
25+
@RequiredArgsConstructor
26+
@Transactional(readOnly = true)
27+
@Slf4j
28+
public class KeywordService {
29+
30+
private final UserService userService;
31+
private final UserRepository userRepository;
32+
private final KeywordRepository keywordRepository;
33+
private final RecordRepository recordRepository;
34+
35+
public List<SearchKeywordInStatsRes> searchKeywordInStats(UserPrincipal userPrincipal, String keyword) {
36+
37+
User user = validUserById(userPrincipal);
38+
39+
// 검색어를 포함한 키워드 리스트 조회
40+
List<Keyword> keywords = keywordRepository.findAllByUserAndKeywordContaining(user, keyword);
41+
42+
// 기록이 많은 순서대로 정렬
43+
keywords = sortKeywordByRecordCount(keywords);
44+
45+
return keywords.stream()
46+
.map(keyword1 -> SearchKeywordInStatsRes.builder()
47+
.keywordId(keyword1.getKeywordId())
48+
.noun(keyword1.getKeyword())
49+
.build())
50+
.toList();
51+
52+
}
53+
54+
public MyKeywordStatsRes getMyKeywordStatsRes(UserPrincipal userPrincipal, Long keywordId) {
55+
validUserById(userPrincipal);
56+
Keyword keyword = validKeywordById(keywordId);
57+
58+
return MyKeywordStatsRes.builder()
59+
.noun(keyword.getKeyword())
60+
.count(keywordRecordCount(keywordId))
61+
.lastRecordedAt(getLastRecordedAt(keywordId))
62+
.avgDailyRecord(calculateAverageDailyRecords(keywordId))
63+
.avgWeeklyRecord(getAvgWeeklyRecord(keywordId))
64+
.build();
65+
}
66+
67+
68+
// 키워드 내 기록 개수를 반환
69+
private int keywordRecordCount(Long keywordId){
70+
Keyword keyword = validKeywordById(keywordId);
71+
return keyword.getRecords().size();
72+
}
73+
74+
// 키워드 내 기록이 많은 순서대로 정렬
75+
private List<Keyword> sortKeywordByRecordCount(List<Keyword> keywords) {
76+
return keywords.stream()
77+
.sorted((k1, k2) -> keywordRecordCount(k2.getKeywordId()) - keywordRecordCount(k1.getKeywordId()))
78+
.toList();
79+
}
80+
81+
// 키워드의 마지막 기록 시간을 반환
82+
private LocalDateTime getLastRecordedAt(Long keywordId) {
83+
Record record = recordRepository.findTopByKeywordKeywordIdOrderByActionTimeDesc(keywordId);
84+
return record.getActionTime();
85+
}
86+
87+
// 키워드의 일일 평균 기록 수를 반환
88+
public double calculateAverageDailyRecords(Long keywordId) {
89+
List<Object[]> results = recordRepository.findKeywordRecordCountsByDate(keywordId);
90+
91+
// 총 기록 수와 기록된 날짜 수 계산
92+
long totalRecords = results.stream()
93+
.mapToLong(row -> (Long) row[0]) // recordCount
94+
.sum();
95+
96+
long days = results.size(); // 날짜 수
97+
98+
// 일일 평균 계산
99+
double result = days == 0 ? 0 : (double) totalRecords / days;
100+
// 소수점 둘째 자리에서 반올림하여 반환
101+
return roundToTwoDecimal(result);
102+
}
103+
104+
// 키워드의 최근 7일간 평균 기록 수를 반환
105+
public double getAvgWeeklyRecord(Long keywordId) {
106+
Keyword keyword = validKeywordById(keywordId);
107+
List<Record> records = recordRepository.findTop5ByKeywordOrderByActionTimeDesc(keyword);
108+
109+
// 최근 7일간 기록 수 계산
110+
long totalRecords = records.size();
111+
long days = 7;
112+
113+
// 일일 평균 계산
114+
double result = days == 0 ? 0 : (double) totalRecords / days;
115+
// 소수점 둘째 자리에서 반올림하여 반환
116+
return roundToTwoDecimal(result);
117+
118+
}
119+
120+
// 소수점 둘째 자리에서 반올림하여 반환
121+
private double roundToTwoDecimal(double value) {
122+
return Math.round(value * 100) / 100.0;
123+
}
124+
125+
private User validUserById(UserPrincipal userPrincipal) {
126+
Optional<User> userOptional = userService.findById(userPrincipal.getId());
127+
// Optional<User> userOptional = userRepository.findById(5L);
128+
if (userOptional.isEmpty()) { throw new UserNotFoundException(); }
129+
return userOptional.get();
130+
}
131+
132+
private Keyword validKeywordById(Long keywordId) {
133+
Optional<Keyword> keywordOptional = keywordRepository.findById(keywordId);
134+
if(keywordOptional.isEmpty()) { throw new KeywordNotFoundException(); }
135+
return keywordOptional.get();
136+
}
137+
138+
}

src/main/java/com/movelog/domain/record/service/RecordService.java renamed to src/main/java/com/movelog/domain/record/application/RecordService.java

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
1-
package com.movelog.domain.record.service;
1+
package com.movelog.domain.record.application;
22

3-
import com.movelog.domain.news.domain.News;
4-
import com.movelog.domain.news.dto.response.NewsCalendarRes;
53
import com.movelog.domain.record.domain.Keyword;
64
import com.movelog.domain.record.domain.Record;
75
import com.movelog.domain.record.domain.VerbType;
86
import com.movelog.domain.record.dto.request.CreateRecordReq;
9-
import com.movelog.domain.record.dto.request.SearchKeywordReq;
107
import com.movelog.domain.record.dto.response.*;
11-
import com.movelog.domain.record.repository.KeywordRepository;
12-
import com.movelog.domain.record.repository.RecordRepository;
8+
import com.movelog.domain.record.domain.repository.KeywordRepository;
9+
import com.movelog.domain.record.domain.repository.RecordRepository;
1310
import com.movelog.domain.user.application.UserService;
1411
import com.movelog.domain.user.domain.User;
1512
import com.movelog.domain.user.domain.repository.UserRepository;
1613
import com.movelog.domain.user.exception.UserNotFoundException;
1714
import com.movelog.global.config.security.token.UserPrincipal;
1815
import com.movelog.global.util.S3Util;
19-
import jakarta.validation.ConstraintViolation;
2016
import lombok.RequiredArgsConstructor;
2117
import lombok.extern.slf4j.Slf4j;
2218
import org.springframework.data.domain.Page;

src/main/java/com/movelog/domain/record/domain/Keyword.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,5 @@ public Keyword(User user, String keyword, VerbType verbType) {
4444
this.keyword = keyword;
4545
this.verbType = verbType;
4646
}
47+
4748
}

src/main/java/com/movelog/domain/record/repository/KeywordRepository.java renamed to src/main/java/com/movelog/domain/record/domain/repository/KeywordRepository.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.movelog.domain.record.repository;
1+
package com.movelog.domain.record.domain.repository;
22

33
import com.movelog.domain.record.domain.Keyword;
44
import com.movelog.domain.record.domain.Record;
@@ -20,4 +20,5 @@ public interface KeywordRepository extends JpaRepository<Keyword,Long> {
2020
Keyword findByUserAndKeywordAndVerbType(User user, String noun, VerbType verbType);
2121

2222
List<Keyword> findAllByUserAndKeywordContaining(User user, String keyword);
23+
2324
}

src/main/java/com/movelog/domain/record/repository/RecordRepository.java renamed to src/main/java/com/movelog/domain/record/domain/repository/RecordRepository.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.movelog.domain.record.repository;
1+
package com.movelog.domain.record.domain.repository;
22

33
import com.movelog.domain.record.domain.Keyword;
44
import com.movelog.domain.record.domain.Record;
@@ -7,6 +7,7 @@
77
import org.springframework.data.domain.Pageable;
88
import org.springframework.data.jpa.repository.JpaRepository;
99
import org.springframework.data.jpa.repository.Query;
10+
import org.springframework.data.repository.query.Param;
1011
import org.springframework.stereotype.Repository;
1112

1213
import java.time.LocalDateTime;
@@ -31,4 +32,11 @@ public interface RecordRepository extends JpaRepository<Record,Long> {
3132
// 5개의 기록만 조회
3233
List<Record> findTop5ByKeywordUserAndRecordImageNotNullOrderByActionTimeDesc(User user);
3334

35+
@Query("SELECT COUNT(r) AS recordCount, DATE(r.actionTime) AS recordDate " +
36+
"FROM Record r " +
37+
"WHERE r.keyword.keywordId = :keywordId " +
38+
"GROUP BY DATE(r.actionTime)")
39+
List<Object[]> findKeywordRecordCountsByDate(Long keywordId);
40+
41+
Record findTopByKeywordKeywordIdOrderByActionTimeDesc(Long keywordId);
3442
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.movelog.domain.record.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
9+
import java.time.LocalDateTime;
10+
11+
@Builder
12+
@AllArgsConstructor
13+
@NoArgsConstructor
14+
@Getter
15+
public class MyKeywordStatsRes {
16+
17+
@Schema( type = "String", example = "헬스", description = "통계 대상 명사(키워드)")
18+
private String noun;
19+
20+
@Schema( type = "int", example = "1", description = "사용자가 해당 명사에 대해 기록한 횟수")
21+
private int count;
22+
23+
@Schema(type = "LocalDateTime", example = "2025-08-01T00:00:00", description = "마지막 기록 일시(가장 최근에 기록한 시간)")
24+
private LocalDateTime lastRecordedAt;
25+
26+
@Schema(type = "Double", example = "0.5", description = "평균 일간 기록")
27+
private double avgDailyRecord;
28+
29+
@Schema(type = "Double", example = "0.5", description = "최근 7일단 평균 기록")
30+
private double avgWeeklyRecord;
31+
32+
}

0 commit comments

Comments
 (0)