diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bd33cb9..8371718 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -63,6 +63,9 @@ jobs: mkdir -p ./src/main/resources/webclient echo "${{ secrets.APPLICATION_WEBCLIENT_YML }}" | base64 --decode > ./src/main/resources/webclient/application-webclient.yml + + mkdir -p ./src/main/resources/redis + echo "${{ secrets.APPLICATION_REDIS_YML }}" | base64 --decode > ./src/main/resources/redis/application-redis.yml # Docker 이미지 빌드 - name: Build Docker image diff --git a/build.gradle b/build.gradle index df42d67..0f0a43a 100644 --- a/build.gradle +++ b/build.gradle @@ -77,6 +77,10 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + } tasks.named('test') { diff --git a/src/main/java/com/movelog/MoveLogApplication.java b/src/main/java/com/movelog/MoveLogApplication.java index a20b262..2c49081 100644 --- a/src/main/java/com/movelog/MoveLogApplication.java +++ b/src/main/java/com/movelog/MoveLogApplication.java @@ -16,6 +16,7 @@ @PropertySource(value = { "classpath:s3/application-s3.yml" }, factory = YamlPropertySourceFactory.class) @PropertySource(value = { "classpath:chatgpt/application-chatgpt.yml" }, factory = YamlPropertySourceFactory.class) @PropertySource(value = { "classpath:webclient/application-webclient.yml" }, factory = YamlPropertySourceFactory.class) +@PropertySource(value = { "classpath:redis/application-redis.yml" }, factory = YamlPropertySourceFactory.class) public class MoveLogApplication { public static void main(String[] args) { SpringApplication.run(MoveLogApplication.class, args); diff --git a/src/main/java/com/movelog/domain/news/application/NewsService.java b/src/main/java/com/movelog/domain/news/application/NewsService.java index 9f93d4d..74f5d32 100644 --- a/src/main/java/com/movelog/domain/news/application/NewsService.java +++ b/src/main/java/com/movelog/domain/news/application/NewsService.java @@ -8,7 +8,7 @@ import com.movelog.domain.record.domain.Keyword; import com.movelog.domain.record.domain.VerbType; import com.movelog.domain.record.exception.KeywordNotFoundException; -import com.movelog.domain.record.repository.KeywordRepository; +import com.movelog.domain.record.domain.repository.KeywordRepository; import com.movelog.domain.user.application.UserService; import com.movelog.domain.user.domain.User; import com.movelog.domain.user.domain.repository.UserRepository; diff --git a/src/main/java/com/movelog/domain/record/application/KeywordService.java b/src/main/java/com/movelog/domain/record/application/KeywordService.java new file mode 100644 index 0000000..ee8b155 --- /dev/null +++ b/src/main/java/com/movelog/domain/record/application/KeywordService.java @@ -0,0 +1,138 @@ +package com.movelog.domain.record.application; + +import com.movelog.domain.record.domain.Keyword; +import com.movelog.domain.record.domain.Record; +import com.movelog.domain.record.domain.repository.RecordRepository; +import com.movelog.domain.record.dto.response.MyKeywordStatsRes; +import com.movelog.domain.record.dto.response.SearchKeywordInStatsRes; +import com.movelog.domain.record.exception.KeywordNotFoundException; +import com.movelog.domain.record.domain.repository.KeywordRepository; +import com.movelog.domain.user.application.UserService; +import com.movelog.domain.user.domain.User; +import com.movelog.domain.user.domain.repository.UserRepository; +import com.movelog.domain.user.exception.UserNotFoundException; +import com.movelog.global.config.security.token.UserPrincipal; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class KeywordService { + + private final UserService userService; + private final UserRepository userRepository; + private final KeywordRepository keywordRepository; + private final RecordRepository recordRepository; + + public List searchKeywordInStats(UserPrincipal userPrincipal, String keyword) { + + User user = validUserById(userPrincipal); + + // 검색어를 포함한 키워드 리스트 조회 + List keywords = keywordRepository.findAllByUserAndKeywordContaining(user, keyword); + + // 기록이 많은 순서대로 정렬 + keywords = sortKeywordByRecordCount(keywords); + + return keywords.stream() + .map(keyword1 -> SearchKeywordInStatsRes.builder() + .keywordId(keyword1.getKeywordId()) + .noun(keyword1.getKeyword()) + .build()) + .toList(); + + } + + public MyKeywordStatsRes getMyKeywordStatsRes(UserPrincipal userPrincipal, Long keywordId) { + validUserById(userPrincipal); + Keyword keyword = validKeywordById(keywordId); + + return MyKeywordStatsRes.builder() + .noun(keyword.getKeyword()) + .count(keywordRecordCount(keywordId)) + .lastRecordedAt(getLastRecordedAt(keywordId)) + .avgDailyRecord(calculateAverageDailyRecords(keywordId)) + .avgWeeklyRecord(getAvgWeeklyRecord(keywordId)) + .build(); + } + + + // 키워드 내 기록 개수를 반환 + private int keywordRecordCount(Long keywordId){ + Keyword keyword = validKeywordById(keywordId); + return keyword.getRecords().size(); + } + + // 키워드 내 기록이 많은 순서대로 정렬 + private List sortKeywordByRecordCount(List keywords) { + return keywords.stream() + .sorted((k1, k2) -> keywordRecordCount(k2.getKeywordId()) - keywordRecordCount(k1.getKeywordId())) + .toList(); + } + + // 키워드의 마지막 기록 시간을 반환 + private LocalDateTime getLastRecordedAt(Long keywordId) { + Record record = recordRepository.findTopByKeywordKeywordIdOrderByActionTimeDesc(keywordId); + return record.getActionTime(); + } + + // 키워드의 일일 평균 기록 수를 반환 + public double calculateAverageDailyRecords(Long keywordId) { + List results = recordRepository.findKeywordRecordCountsByDate(keywordId); + + // 총 기록 수와 기록된 날짜 수 계산 + long totalRecords = results.stream() + .mapToLong(row -> (Long) row[0]) // recordCount + .sum(); + + long days = results.size(); // 날짜 수 + + // 일일 평균 계산 + double result = days == 0 ? 0 : (double) totalRecords / days; + // 소수점 둘째 자리에서 반올림하여 반환 + return roundToTwoDecimal(result); + } + + // 키워드의 최근 7일간 평균 기록 수를 반환 + public double getAvgWeeklyRecord(Long keywordId) { + Keyword keyword = validKeywordById(keywordId); + List records = recordRepository.findTop5ByKeywordOrderByActionTimeDesc(keyword); + + // 최근 7일간 기록 수 계산 + long totalRecords = records.size(); + long days = 7; + + // 일일 평균 계산 + double result = days == 0 ? 0 : (double) totalRecords / days; + // 소수점 둘째 자리에서 반올림하여 반환 + return roundToTwoDecimal(result); + + } + + // 소수점 둘째 자리에서 반올림하여 반환 + private double roundToTwoDecimal(double value) { + return Math.round(value * 100) / 100.0; + } + + private User validUserById(UserPrincipal userPrincipal) { + Optional userOptional = userService.findById(userPrincipal.getId()); + // Optional userOptional = userRepository.findById(5L); + if (userOptional.isEmpty()) { throw new UserNotFoundException(); } + return userOptional.get(); + } + + private Keyword validKeywordById(Long keywordId) { + Optional keywordOptional = keywordRepository.findById(keywordId); + if(keywordOptional.isEmpty()) { throw new KeywordNotFoundException(); } + return keywordOptional.get(); + } + +} diff --git a/src/main/java/com/movelog/domain/record/service/RecordService.java b/src/main/java/com/movelog/domain/record/application/RecordService.java similarity index 95% rename from src/main/java/com/movelog/domain/record/service/RecordService.java rename to src/main/java/com/movelog/domain/record/application/RecordService.java index 6a40c74..58def0b 100644 --- a/src/main/java/com/movelog/domain/record/service/RecordService.java +++ b/src/main/java/com/movelog/domain/record/application/RecordService.java @@ -1,22 +1,18 @@ -package com.movelog.domain.record.service; +package com.movelog.domain.record.application; -import com.movelog.domain.news.domain.News; -import com.movelog.domain.news.dto.response.NewsCalendarRes; import com.movelog.domain.record.domain.Keyword; import com.movelog.domain.record.domain.Record; import com.movelog.domain.record.domain.VerbType; import com.movelog.domain.record.dto.request.CreateRecordReq; -import com.movelog.domain.record.dto.request.SearchKeywordReq; import com.movelog.domain.record.dto.response.*; -import com.movelog.domain.record.repository.KeywordRepository; -import com.movelog.domain.record.repository.RecordRepository; +import com.movelog.domain.record.domain.repository.KeywordRepository; +import com.movelog.domain.record.domain.repository.RecordRepository; import com.movelog.domain.user.application.UserService; import com.movelog.domain.user.domain.User; import com.movelog.domain.user.domain.repository.UserRepository; import com.movelog.domain.user.exception.UserNotFoundException; import com.movelog.global.config.security.token.UserPrincipal; import com.movelog.global.util.S3Util; -import jakarta.validation.ConstraintViolation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; diff --git a/src/main/java/com/movelog/domain/record/domain/Keyword.java b/src/main/java/com/movelog/domain/record/domain/Keyword.java index 66774c3..8b7b2ed 100644 --- a/src/main/java/com/movelog/domain/record/domain/Keyword.java +++ b/src/main/java/com/movelog/domain/record/domain/Keyword.java @@ -44,4 +44,5 @@ public Keyword(User user, String keyword, VerbType verbType) { this.keyword = keyword; this.verbType = verbType; } + } diff --git a/src/main/java/com/movelog/domain/record/repository/KeywordRepository.java b/src/main/java/com/movelog/domain/record/domain/repository/KeywordRepository.java similarity index 93% rename from src/main/java/com/movelog/domain/record/repository/KeywordRepository.java rename to src/main/java/com/movelog/domain/record/domain/repository/KeywordRepository.java index 4cb26b3..0ebae04 100644 --- a/src/main/java/com/movelog/domain/record/repository/KeywordRepository.java +++ b/src/main/java/com/movelog/domain/record/domain/repository/KeywordRepository.java @@ -1,4 +1,4 @@ -package com.movelog.domain.record.repository; +package com.movelog.domain.record.domain.repository; import com.movelog.domain.record.domain.Keyword; import com.movelog.domain.record.domain.Record; @@ -20,4 +20,5 @@ public interface KeywordRepository extends JpaRepository { Keyword findByUserAndKeywordAndVerbType(User user, String noun, VerbType verbType); List findAllByUserAndKeywordContaining(User user, String keyword); + } diff --git a/src/main/java/com/movelog/domain/record/repository/RecordRepository.java b/src/main/java/com/movelog/domain/record/domain/repository/RecordRepository.java similarity index 74% rename from src/main/java/com/movelog/domain/record/repository/RecordRepository.java rename to src/main/java/com/movelog/domain/record/domain/repository/RecordRepository.java index 01f978b..93dacdd 100644 --- a/src/main/java/com/movelog/domain/record/repository/RecordRepository.java +++ b/src/main/java/com/movelog/domain/record/domain/repository/RecordRepository.java @@ -1,4 +1,4 @@ -package com.movelog.domain.record.repository; +package com.movelog.domain.record.domain.repository; import com.movelog.domain.record.domain.Keyword; import com.movelog.domain.record.domain.Record; @@ -7,6 +7,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; @@ -31,4 +32,11 @@ public interface RecordRepository extends JpaRepository { // 5개의 기록만 조회 List findTop5ByKeywordUserAndRecordImageNotNullOrderByActionTimeDesc(User user); + @Query("SELECT COUNT(r) AS recordCount, DATE(r.actionTime) AS recordDate " + + "FROM Record r " + + "WHERE r.keyword.keywordId = :keywordId " + + "GROUP BY DATE(r.actionTime)") + List findKeywordRecordCountsByDate(Long keywordId); + + Record findTopByKeywordKeywordIdOrderByActionTimeDesc(Long keywordId); } diff --git a/src/main/java/com/movelog/domain/record/dto/response/MyKeywordStatsRes.java b/src/main/java/com/movelog/domain/record/dto/response/MyKeywordStatsRes.java new file mode 100644 index 0000000..e56d581 --- /dev/null +++ b/src/main/java/com/movelog/domain/record/dto/response/MyKeywordStatsRes.java @@ -0,0 +1,32 @@ +package com.movelog.domain.record.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class MyKeywordStatsRes { + + @Schema( type = "String", example = "헬스", description = "통계 대상 명사(키워드)") + private String noun; + + @Schema( type = "int", example = "1", description = "사용자가 해당 명사에 대해 기록한 횟수") + private int count; + + @Schema(type = "LocalDateTime", example = "2025-08-01T00:00:00", description = "마지막 기록 일시(가장 최근에 기록한 시간)") + private LocalDateTime lastRecordedAt; + + @Schema(type = "Double", example = "0.5", description = "평균 일간 기록") + private double avgDailyRecord; + + @Schema(type = "Double", example = "0.5", description = "최근 7일단 평균 기록") + private double avgWeeklyRecord; + +} diff --git a/src/main/java/com/movelog/domain/record/dto/response/SearchKeywordInStatsRes.java b/src/main/java/com/movelog/domain/record/dto/response/SearchKeywordInStatsRes.java new file mode 100644 index 0000000..52b03ed --- /dev/null +++ b/src/main/java/com/movelog/domain/record/dto/response/SearchKeywordInStatsRes.java @@ -0,0 +1,20 @@ +package com.movelog.domain.record.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class SearchKeywordInStatsRes { + + @Schema( type = "int", example = "1", description="키워드 ID") + private Long keywordId; + + @Schema( type = "String", example ="헬스", description="검색어가 포함된 명사") + private String noun; +} diff --git a/src/main/java/com/movelog/domain/record/presentation/RecordController.java b/src/main/java/com/movelog/domain/record/presentation/RecordController.java index 279909a..8a886fe 100644 --- a/src/main/java/com/movelog/domain/record/presentation/RecordController.java +++ b/src/main/java/com/movelog/domain/record/presentation/RecordController.java @@ -1,10 +1,8 @@ package com.movelog.domain.record.presentation; -import com.movelog.domain.news.dto.response.NewsCalendarRes; import com.movelog.domain.record.dto.request.CreateRecordReq; -import com.movelog.domain.record.dto.request.SearchKeywordReq; import com.movelog.domain.record.dto.response.*; -import com.movelog.domain.record.service.RecordService; +import com.movelog.domain.record.application.RecordService; import com.movelog.global.config.security.token.UserPrincipal; import com.movelog.global.payload.Message; import com.movelog.global.util.ApiResponseUtil; @@ -23,7 +21,6 @@ import org.springframework.web.multipart.MultipartFile; import java.util.List; -import java.util.Set; @RestController @RequestMapping("api/v1/record") diff --git a/src/main/java/com/movelog/domain/record/presentation/StatsController.java b/src/main/java/com/movelog/domain/record/presentation/StatsController.java new file mode 100644 index 0000000..3c97fe0 --- /dev/null +++ b/src/main/java/com/movelog/domain/record/presentation/StatsController.java @@ -0,0 +1,69 @@ +package com.movelog.domain.record.presentation; + +import com.movelog.domain.record.application.KeywordService; +import com.movelog.domain.record.dto.response.MyKeywordStatsRes; +import com.movelog.domain.record.dto.response.SearchKeywordInStatsRes; +import com.movelog.global.config.security.token.UserPrincipal; +import com.movelog.global.payload.ErrorResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/stats") +@Tag(name = "Stats", description = "통계 관련 API입니다.") +public class StatsController { + + private final KeywordService keywordService; + + @Operation(summary = "통계 조회 시 단어 검색 API", description = "통계 조회 시 서비스 내에서 생성된 단어를 검색하는 API입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "단어 검색 결과 조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(type = "array", implementation = SearchKeywordInStatsRes.class))), + @ApiResponse(responseCode = "400", description = "단어 검색 결과 조회 실패", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/word/search") + public ResponseEntity searchKeywordInStats( + @Parameter(description = "Access Token을 입력해주세요.", required = true) @AuthenticationPrincipal UserPrincipal userPrincipal, + @Parameter(description = "검색할 명사를 입력해주세요.", required = true) @RequestParam String keyword + ) { + List response = keywordService.searchKeywordInStats(userPrincipal, keyword); + return ResponseEntity.ok(response); + } + + + @Operation(summary = "나의 특정 단어 통계 정보 조회 API", description = "나의 특정 단어 통계 정보를 조회하는 API입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "나의 특정 단어 통계 정보 조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = MyKeywordStatsRes.class))), + @ApiResponse(responseCode = "400", description = "나의 특정 단어 통계 정보 조회 실패", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/word/my/{keywordId}") + public ResponseEntity getMyKeywordStats( + @Parameter(description = "Access Token을 입력해주세요.", required = true) @AuthenticationPrincipal UserPrincipal userPrincipal, + @Parameter(description = "검색할 명사의 id를 입력해주세요.", required = true) @PathVariable Long keywordId + ) { + MyKeywordStatsRes response = keywordService.getMyKeywordStatsRes(userPrincipal, keywordId); + return ResponseEntity.ok(response); + } + + + +} diff --git a/src/main/java/com/movelog/global/config/RedisConfig.java b/src/main/java/com/movelog/global/config/RedisConfig.java new file mode 100644 index 0000000..b3c15de --- /dev/null +++ b/src/main/java/com/movelog/global/config/RedisConfig.java @@ -0,0 +1,37 @@ +package com.movelog.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.redis.host}") + private String host; + + @Value("${spring.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(new RedisStandaloneConfiguration( + host, port + )); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(redisConnectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + return template; + } +}