diff --git a/src/main/java/org/runimo/runimo/auth/filters/JwtAuthenticationFilter.java b/src/main/java/org/runimo/runimo/auth/filters/JwtAuthenticationFilter.java index 8f0094b6..1d2c4c53 100644 --- a/src/main/java/org/runimo/runimo/auth/filters/JwtAuthenticationFilter.java +++ b/src/main/java/org/runimo/runimo/auth/filters/JwtAuthenticationFilter.java @@ -81,6 +81,7 @@ private boolean processToken(String jwtToken, HttpServletResponse response) thro } private void setErrorResponse(UserErrorCode errorCode, HttpServletResponse response) throws IOException { + log.warn(errorCode.name(), errorCode.getClientMessage()); response.setStatus(errorCode.getHttpStatusCode().value()); response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); diff --git a/src/main/java/org/runimo/runimo/records/controller/RecordController.java b/src/main/java/org/runimo/runimo/records/controller/RecordController.java index 023e713b..84c598aa 100644 --- a/src/main/java/org/runimo/runimo/records/controller/RecordController.java +++ b/src/main/java/org/runimo/runimo/records/controller/RecordController.java @@ -14,9 +14,7 @@ import org.runimo.runimo.records.service.usecases.RecordCreateUsecase; import org.runimo.runimo.records.service.usecases.RecordQueryUsecase; import org.runimo.runimo.records.service.usecases.RecordUpdateUsecase; -import org.runimo.runimo.records.service.usecases.dtos.RecordCreateCommand; -import org.runimo.runimo.records.service.usecases.dtos.RecordDetailViewResponse; -import org.runimo.runimo.records.service.usecases.dtos.RecordSaveResponse; +import org.runimo.runimo.records.service.usecases.dtos.*; import org.runimo.runimo.user.controller.UserId; import org.runimo.runimo.records.service.dtos.WeeklyRecordStatResponse; import org.runimo.runimo.records.service.dtos.WeeklyStatQuery; @@ -103,4 +101,25 @@ public ResponseEntity> queryWeeklyReco WeeklyRecordStatResponse response = recordQueryUsecase.getUserWeeklyRecordStat(new WeeklyStatQuery(userId, startDate, endDate)); return ResponseEntity.ok(SuccessResponse.of(UserHttpResponseCode.MY_PAGE_DATA_FETCHED, response)); } + + @Operation(summary = "월간 기록 통계 조회", description = "요청한 기간의 월간 기록 통계를 조회") + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", description = "월간 기록 통계 조회 성공", + content = @Content(schema = @Schema(implementation = WeeklyRecordStatResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @ApiResponse(responseCode = "401", description = "인증 실패") + } + ) + @GetMapping("/stats/monthly") + public ResponseEntity> queryMonthlyRecordStat( + @RequestParam int year, + @RequestParam int month, + @UserId Long userId) { + if(month < 1 || month > 12) { + throw new IllegalArgumentException("잘못된 월입니다."); + } + MonthlyRecordStatResponse response = recordQueryUsecase.getUserMonthlyRecordStat(new MonthlyStatQuery(userId, year, month)); + return ResponseEntity.ok(SuccessResponse.of(UserHttpResponseCode.MY_PAGE_DATA_FETCHED, response)); + } } diff --git a/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java b/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java index 495d01d4..109dacea 100644 --- a/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java +++ b/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java @@ -1,7 +1,7 @@ package org.runimo.runimo.records.repository; import org.runimo.runimo.records.domain.RunningRecord; -import org.runimo.runimo.records.service.dtos.RunningRecordDistance; +import org.runimo.runimo.records.service.dtos.DailyStat; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; @@ -33,13 +33,13 @@ Slice findFirstRunOfWeek( @Query("select r from RunningRecord r where r.userId = :userId") Slice findLatestByUserId(Long userId, Pageable pageRequest); - @Query("select new org.runimo.runimo.records.service.dtos.RunningRecordDistance(" + - "sum(r.totalDistance.amount), " + - "cast(r.startedAt as localdate)) " + + @Query("select new org.runimo.runimo.records.service.dtos.DailyStat(" + + "cast(r.startedAt as localdate), " + + "sum(r.totalDistance.amount)) " + "from RunningRecord r " + "where r.userId = :userId " + "and r.startedAt between :startOfWeek and :now " + "group by function('date', r.startedAt)") - List findDailyDistanceByUserIdAndThisWeek(Long userId, LocalDateTime startOfWeek, LocalDateTime now); + List findDailyDistanceByUserIdAndThisWeek(Long userId, LocalDateTime startOfWeek, LocalDateTime now); } diff --git a/src/main/java/org/runimo/runimo/records/service/RecordFinder.java b/src/main/java/org/runimo/runimo/records/service/RecordFinder.java index 051cbaef..75a38e7d 100644 --- a/src/main/java/org/runimo/runimo/records/service/RecordFinder.java +++ b/src/main/java/org/runimo/runimo/records/service/RecordFinder.java @@ -3,7 +3,7 @@ import lombok.RequiredArgsConstructor; import org.runimo.runimo.records.domain.RunningRecord; import org.runimo.runimo.records.repository.RecordRepository; -import org.runimo.runimo.records.service.dtos.RunningRecordDistance; +import org.runimo.runimo.records.service.dtos.DailyStat; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; @@ -49,7 +49,7 @@ public Optional findLatestRunningRecordByUserId(Long userId) { } @Transactional(readOnly = true) - public List findDailyStatByUserIdBetween(Long id, LocalDateTime from, LocalDateTime to) { + public List findDailyStatByUserIdBetween(Long id, LocalDateTime from, LocalDateTime to) { return recordRepository.findDailyDistanceByUserIdAndThisWeek(id, from, to); } } diff --git a/src/main/java/org/runimo/runimo/records/service/dtos/RunningRecordDistance.java b/src/main/java/org/runimo/runimo/records/service/dtos/RunningRecordDistance.java deleted file mode 100644 index 4cb695f4..00000000 --- a/src/main/java/org/runimo/runimo/records/service/dtos/RunningRecordDistance.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.runimo.runimo.records.service.dtos; - -import java.time.DayOfWeek; -import java.time.LocalDate; - -public record RunningRecordDistance( - Long totalDistanceInMeters, - LocalDate date -) { - public DayOfWeek getDayOfWeek() { - return date.getDayOfWeek(); - } - public DailyStat toDailyStat() { - return new DailyStat(date, totalDistanceInMeters); - } -} diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecase.java b/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecase.java index 3745f3ec..b89e881c 100644 --- a/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecase.java +++ b/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecase.java @@ -1,5 +1,7 @@ package org.runimo.runimo.records.service.usecases; +import org.runimo.runimo.records.service.usecases.dtos.MonthlyRecordStatResponse; +import org.runimo.runimo.records.service.usecases.dtos.MonthlyStatQuery; import org.runimo.runimo.records.service.usecases.dtos.RecordDetailViewResponse; import org.runimo.runimo.records.service.dtos.WeeklyRecordStatResponse; import org.runimo.runimo.records.service.dtos.WeeklyStatQuery; @@ -7,4 +9,5 @@ public interface RecordQueryUsecase { RecordDetailViewResponse getRecordDetailView(Long publicId); WeeklyRecordStatResponse getUserWeeklyRecordStat(WeeklyStatQuery query); + MonthlyRecordStatResponse getUserMonthlyRecordStat(MonthlyStatQuery query); } diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImpl.java b/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImpl.java index 051f933f..c5e7409d 100644 --- a/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImpl.java +++ b/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImpl.java @@ -3,16 +3,17 @@ import lombok.RequiredArgsConstructor; import org.runimo.runimo.records.domain.RunningRecord; import org.runimo.runimo.records.service.RecordFinder; -import org.runimo.runimo.records.service.dtos.RunningRecordDistance; -import org.runimo.runimo.records.service.usecases.dtos.RecordDetailViewResponse; import org.runimo.runimo.records.service.dtos.DailyStat; +import org.runimo.runimo.records.service.usecases.dtos.MonthlyRecordStatResponse; +import org.runimo.runimo.records.service.usecases.dtos.MonthlyStatQuery; +import org.runimo.runimo.records.service.usecases.dtos.RecordDetailViewResponse; import org.runimo.runimo.records.service.dtos.WeeklyRecordStatResponse; import org.runimo.runimo.records.service.dtos.WeeklyStatQuery; import org.springframework.stereotype.Service; -import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; import java.util.*; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -30,27 +31,23 @@ public RecordDetailViewResponse getRecordDetailView(Long recordId) { @Override public WeeklyRecordStatResponse getUserWeeklyRecordStat(WeeklyStatQuery query) { // DB에서 일별로 이미 합산된 데이터 조회 - List dailyDistances = recordFinder.findDailyStatByUserIdBetween( + List dailyDistances = recordFinder.findDailyStatByUserIdBetween( query.userId(), query.startDate().atStartOfDay(), query.endDate().atTime(23, 59, 59) ); + return new WeeklyRecordStatResponse(dailyDistances); + } - // 결과를 DayOfWeek 맵으로 변환 - Map dailyStatsMap = dailyDistances.stream() - .collect(Collectors.toMap( - RunningRecordDistance::getDayOfWeek, - RunningRecordDistance::toDailyStat - )); - - // 월요일부터 일요일까지 순서대로 일간 데이터 정리 - List weeklyStats = Arrays.stream(DayOfWeek.values()) - .map(day -> dailyStatsMap.getOrDefault(day, new DailyStat( - query.startDate().with(day), - 0L - ))) - .toList(); - - return new WeeklyRecordStatResponse(weeklyStats); + @Override + public MonthlyRecordStatResponse getUserMonthlyRecordStat(MonthlyStatQuery query) { + LocalDate from = LocalDate.of(query.year(), query.month(), 1); + LocalDate to = from.with(TemporalAdjusters.lastDayOfMonth()); + List dailyDistances = recordFinder.findDailyStatByUserIdBetween( + query.userId(), + from.atStartOfDay(), + to.atTime(23, 59, 59) + ); + return new MonthlyRecordStatResponse(dailyDistances); } } diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/dtos/MonthlyRecordStatResponse.java b/src/main/java/org/runimo/runimo/records/service/usecases/dtos/MonthlyRecordStatResponse.java new file mode 100644 index 00000000..3ee23bda --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/usecases/dtos/MonthlyRecordStatResponse.java @@ -0,0 +1,10 @@ +package org.runimo.runimo.records.service.usecases.dtos; + +import org.runimo.runimo.records.service.dtos.DailyStat; + +import java.util.List; + +public record MonthlyRecordStatResponse( + List dailyStats +) { +} diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/dtos/MonthlyStatQuery.java b/src/main/java/org/runimo/runimo/records/service/usecases/dtos/MonthlyStatQuery.java new file mode 100644 index 00000000..afe8e35a --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/usecases/dtos/MonthlyStatQuery.java @@ -0,0 +1,8 @@ +package org.runimo.runimo.records.service.usecases.dtos; + +public record MonthlyStatQuery( + Long userId, + Integer year, + Integer month +) { +} diff --git a/src/test/java/org/runimo/runimo/records/api/RecordAcceptanceTest.java b/src/test/java/org/runimo/runimo/records/api/RecordAcceptanceTest.java index f47ddb57..71950332 100644 --- a/src/test/java/org/runimo/runimo/records/api/RecordAcceptanceTest.java +++ b/src/test/java/org/runimo/runimo/records/api/RecordAcceptanceTest.java @@ -190,21 +190,77 @@ void tearDown() { .log().ifValidationFails() .statusCode(200) .body("code", equalTo("MY_PAGE_DATA_FETCHED")) - .body("payload.daily_stats.size()", equalTo(7)) + .body("payload.daily_stats.size()", equalTo(5)) .body("payload.daily_stats[0].date", equalTo("2025-03-31")) .body("payload.daily_stats[0].distance", equalTo(1000)) .body("payload.daily_stats[1].date", equalTo("2025-04-01")) .body("payload.daily_stats[1].distance", equalTo(2000)) - .body("payload.daily_stats[2].date", equalTo("2025-04-02")) - .body("payload.daily_stats[2].distance", equalTo(0)) - .body("payload.daily_stats[3].date", equalTo("2025-04-03")) - .body("payload.daily_stats[3].distance", equalTo(0)) - .body("payload.daily_stats[4].date", equalTo("2025-04-04")) - .body("payload.daily_stats[4].distance", equalTo(5000)) - .body("payload.daily_stats[5].date", equalTo("2025-04-05")) - .body("payload.daily_stats[5].distance", equalTo(6000)) - .body("payload.daily_stats[6].date", equalTo("2025-04-06")) - .body("payload.daily_stats[6].distance", equalTo(7000)); + .body("payload.daily_stats[2].date", equalTo("2025-04-04")) + .body("payload.daily_stats[2].distance", equalTo(5000)) + .body("payload.daily_stats[3].date", equalTo("2025-04-05")) + .body("payload.daily_stats[3].distance", equalTo(6000)) + .body("payload.daily_stats[4].date", equalTo("2025-04-06")) + .body("payload.daily_stats[4].distance", equalTo(7000)); + } + + @Test + @WithMockUser(username = USER_UUID) + @Sql(scripts = "/sql/weekly_record_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 월간_기록_통계_조회_성공시_정확한_정보를_반환한다() { + // given + String token = AUTH_HEADER_PREFIX + jwtTokenFactory.generateAccessToken(USER_UUID); + + // when & then + given() + .header("Authorization", token) + .contentType(ContentType.JSON) + .param("year", 2025) + .param("month", 4) + .when() + .get("/api/v1/records/stats/monthly") + .then() + .log().all() + .statusCode(200) + .body("code", equalTo("MY_PAGE_DATA_FETCHED")) + .body("payload.daily_stats.size()", equalTo(6)) + .body("payload.daily_stats[0].date", equalTo("2025-04-01")) + .body("payload.daily_stats[0].distance", equalTo(2000)) + .body("payload.daily_stats[1].date", equalTo("2025-04-02")) + .body("payload.daily_stats[1].distance", equalTo(3000)); + } + + @Test + @WithMockUser(username = USER_UUID) + @Sql(scripts = "/sql/weekly_record_partial_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 월간_기록_통계_조회_잘못된_요청_데이터() { + // given + String token = AUTH_HEADER_PREFIX + jwtTokenFactory.generateAccessToken(USER_UUID); + + // when & then + given() + .header("Authorization", token) + .contentType(ContentType.JSON) + .param("year", 2025) + .param("month", 13) // 잘못된 월 + .when() + .get("/api/v1/records/stats/monthly") + .then() + .log().all() + .statusCode(400); + } + + @Test + void 월간_기록_통계_조회_인증_실패() { + // when & then + given() + .contentType(ContentType.JSON) + .param("year", 2025) + .param("month", 4) + .when() + .get("/api/v1/records/stats/monthly") + .then() + .log().all() + .statusCode(401); } } diff --git a/src/test/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImplTest.java b/src/test/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImplTest.java index a8370b25..03340c4b 100644 --- a/src/test/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImplTest.java +++ b/src/test/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImplTest.java @@ -4,10 +4,11 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.runimo.runimo.records.service.RecordFinder; -import org.runimo.runimo.records.service.dtos.RunningRecordDistance; import org.runimo.runimo.records.service.dtos.DailyStat; import org.runimo.runimo.records.service.dtos.WeeklyRecordStatResponse; import org.runimo.runimo.records.service.dtos.WeeklyStatQuery; +import org.runimo.runimo.records.service.usecases.dtos.MonthlyRecordStatResponse; +import org.runimo.runimo.records.service.usecases.dtos.MonthlyStatQuery; import java.time.DayOfWeek; import java.time.LocalDate; @@ -39,14 +40,14 @@ void setUp() { LocalDateTime now = LocalDateTime.of(2025, 4, 6, 10, 0); LocalDateTime startOfWeek = now.with(DayOfWeek.MONDAY).withHour(0).withMinute(0).withSecond(0); - List savedData = List.of( - new RunningRecordDistance(1000L, LocalDate.of(2025, 3, 31)), // 월 - new RunningRecordDistance(2000L, LocalDate.of(2025, 4, 1)), // 화 - new RunningRecordDistance(3000L, LocalDate.of(2025, 4, 2)), // 수 - new RunningRecordDistance(4000L, LocalDate.of(2025, 4, 3)), // 목 - new RunningRecordDistance(5000L, LocalDate.of(2025, 4, 4)), // 금 - new RunningRecordDistance(6000L, LocalDate.of(2025, 4, 5)), // 토 - new RunningRecordDistance(7000L, LocalDate.of(2025, 4, 6)) // 일 + List savedData = List.of( + new DailyStat(LocalDate.of(2025, 3, 31), 1000L), // 월 + new DailyStat(LocalDate.of(2025, 4, 1), 2000L), // 화 + new DailyStat(LocalDate.of(2025, 4, 2), 3000L), // 수 + new DailyStat(LocalDate.of(2025, 4, 3), 4000L), // 목 + new DailyStat(LocalDate.of(2025, 4, 4), 5000L), // 금 + new DailyStat(LocalDate.of(2025, 4, 5), 6000L), // 토 + new DailyStat(LocalDate.of(2025, 4, 6), 7000L) // 일 ); when(recordFinder.findDailyStatByUserIdBetween(eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) @@ -79,13 +80,12 @@ void setUp() { LocalDateTime now = LocalDateTime.of(2025, 4, 6, 10, 0); LocalDateTime startOfWeek = now.with(DayOfWeek.MONDAY).withHour(0).withMinute(0).withSecond(0); - List savedData = List.of( - new RunningRecordDistance(1000L, LocalDate.of(2025, 3, 31)), // 월 - new RunningRecordDistance(9000L, LocalDate.of(2025, 4, 1)), // 화 - // 수, 목 데이터 없음 - new RunningRecordDistance(5000L, LocalDate.of(2025, 4, 4)), // 금 - new RunningRecordDistance(6000L, LocalDate.of(2025, 4, 5)), // 토 - new RunningRecordDistance(7000L, LocalDate.of(2025, 4, 6)) // 일 + List savedData = List.of( + new DailyStat(LocalDate.of(2025, 3, 31), 1000L), // 월 + new DailyStat(LocalDate.of(2025, 4, 1), 9000L), // 화 + new DailyStat(LocalDate.of(2025, 4, 4), 5000L), // 금 + new DailyStat(LocalDate.of(2025, 4, 5), 6000L), // 토 + new DailyStat(LocalDate.of(2025, 4, 6), 7000L) // 일 ); when(recordFinder.findDailyStatByUserIdBetween(eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) @@ -102,8 +102,6 @@ void setUp() { List expectedDistances = List.of( new DailyStat(LocalDate.of(2025, 3, 31), 1000L), new DailyStat(LocalDate.of(2025, 4, 1), 9000L), - new DailyStat(LocalDate.of(2025, 4, 2), 0L), // 데이터 없음 - new DailyStat(LocalDate.of(2025, 4, 3), 0L), // 데이터 없음 new DailyStat(LocalDate.of(2025, 4, 4), 5000L), new DailyStat(LocalDate.of(2025, 4, 5), 6000L), new DailyStat(LocalDate.of(2025, 4, 6), 7000L) @@ -111,4 +109,29 @@ void setUp() { assertEquals(expectedDistances, response.dailyStats()); } + + @Test + void 월간_일별_달리기_거리_합() { + // given + MonthlyStatQuery query = new MonthlyStatQuery(1L, 2025, 4); + + List savedData = List.of( + new DailyStat(LocalDate.of(2025, 4, 1), 2000L), // 화 + new DailyStat(LocalDate.of(2025, 4, 2), 3000L), // 수 + new DailyStat(LocalDate.of(2025, 4, 3), 4000L), // 목 + new DailyStat(LocalDate.of(2025, 4, 4), 5000L), // 금 + new DailyStat(LocalDate.of(2025, 4, 5), 6000L), // 토 + new DailyStat(LocalDate.of(2025, 4, 6), 7000L) // 일 + ); + + when(recordFinder.findDailyStatByUserIdBetween(eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(savedData); + + // when + MonthlyRecordStatResponse res = queryUsecase.getUserMonthlyRecordStat(query); + + // then + assertEquals(savedData.size(), res.dailyStats().size()); + assertEquals(2000L, res.dailyStats().getFirst().distance()); + } } \ No newline at end of file