-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #77 from Move-Log/develop
[FEAT] 사용자 기록 기반 단어 통계 정보 조회 기능 구현
- Loading branch information
Showing
14 changed files
with
321 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
138 changes: 138 additions & 0 deletions
138
src/main/java/com/movelog/domain/record/application/KeywordService.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<SearchKeywordInStatsRes> searchKeywordInStats(UserPrincipal userPrincipal, String keyword) { | ||
|
||
User user = validUserById(userPrincipal); | ||
|
||
// 검색어를 포함한 키워드 리스트 조회 | ||
List<Keyword> 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<Keyword> sortKeywordByRecordCount(List<Keyword> 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<Object[]> 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<Record> 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<User> userOptional = userService.findById(userPrincipal.getId()); | ||
// Optional<User> userOptional = userRepository.findById(5L); | ||
if (userOptional.isEmpty()) { throw new UserNotFoundException(); } | ||
return userOptional.get(); | ||
} | ||
|
||
private Keyword validKeywordById(Long keywordId) { | ||
Optional<Keyword> keywordOptional = keywordRepository.findById(keywordId); | ||
if(keywordOptional.isEmpty()) { throw new KeywordNotFoundException(); } | ||
return keywordOptional.get(); | ||
} | ||
|
||
} |
10 changes: 3 additions & 7 deletions
10
.../domain/record/service/RecordService.java → ...ain/record/application/RecordService.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
32 changes: 32 additions & 0 deletions
32
src/main/java/com/movelog/domain/record/dto/response/MyKeywordStatsRes.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
|
||
} |
20 changes: 20 additions & 0 deletions
20
src/main/java/com/movelog/domain/record/dto/response/SearchKeywordInStatsRes.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
69 changes: 69 additions & 0 deletions
69
src/main/java/com/movelog/domain/record/presentation/StatsController.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<SearchKeywordInStatsRes> 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); | ||
} | ||
|
||
|
||
|
||
} |
Oops, something went wrong.