Skip to content

Commit

Permalink
Merge pull request #77 from Move-Log/develop
Browse files Browse the repository at this point in the history
[FEAT] 사용자 기록 기반 단어 통계 정보 조회 기능 구현
  • Loading branch information
EunbeenDev authored Jan 24, 2025
2 parents 4f8d918 + b250279 commit 7a8c1bd
Show file tree
Hide file tree
Showing 14 changed files with 321 additions and 14 deletions.
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

0 comments on commit 7a8c1bd

Please sign in to comment.