Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT] 사용자 기록 기반 단어 통계 정보 조회 기능 구현 #77

Merged
merged 8 commits into from
Jan 24, 2025
3 changes: 3 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
1 change: 1 addition & 0 deletions src/main/java/com/movelog/MoveLogApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
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();
}

}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,5 @@ public Keyword(User user, String keyword, VerbType verbType) {
this.keyword = keyword;
this.verbType = verbType;
}

}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,4 +20,5 @@ public interface KeywordRepository extends JpaRepository<Keyword,Long> {
Keyword findByUserAndKeywordAndVerbType(User user, String noun, VerbType verbType);

List<Keyword> findAllByUserAndKeywordContaining(User user, String keyword);

}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -31,4 +32,11 @@ public interface RecordRepository extends JpaRepository<Record,Long> {
// 5개의 기록만 조회
List<Record> 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<Object[]> findKeywordRecordCountsByDate(Long keywordId);

Record findTopByKeywordKeywordIdOrderByActionTimeDesc(Long keywordId);
}
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;

}
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;
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -23,7 +21,6 @@
import org.springframework.web.multipart.MultipartFile;

import java.util.List;
import java.util.Set;

@RestController
@RequestMapping("api/v1/record")
Expand Down
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);
}



}
Loading