From 8972f0ec5e5bd7b47d0b1287dd9d5bdc17ff292f Mon Sep 17 00:00:00 2001 From: p1001q Date: Sat, 12 Sep 2026 03:25:15 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=95=20=EA=B7=B8=EB=A3=B9=20ID=20=EB=B0=98=ED=99=98=20(#95?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CalendarController.java | 5 +-- .../dto/response/CalendarResponseDTO.java | 9 +++++- .../calendar/service/CalendarService.java | 25 ++++++++++++++- .../CompletedMeasurementGroupProjection.java | 9 ++++++ .../repository/ExerciseSessionRepository.java | 23 ++++++++++++++ .../calendar/service/CalendarServiceTest.java | 31 ++++++++++++++++++- 6 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/atmd/backend/domain/fitness/repository/CompletedMeasurementGroupProjection.java diff --git a/src/main/java/com/atmd/backend/domain/calendar/controller/CalendarController.java b/src/main/java/com/atmd/backend/domain/calendar/controller/CalendarController.java index ae7cd0e..fabfdb2 100644 --- a/src/main/java/com/atmd/backend/domain/calendar/controller/CalendarController.java +++ b/src/main/java/com/atmd/backend/domain/calendar/controller/CalendarController.java @@ -28,7 +28,8 @@ public class CalendarController { @Operation( summary = "월별 캘린더 조회", - description = "로그인한 유저의 특정 연월(year, month)에 해당하는 운동 기록과 달성률을 반환합니다." + description = "로그인한 유저의 특정 연월(year, month)에 해당하는 운동 기록, 달성률, " + + "날짜별 완료 측정 그룹 ID를 반환합니다." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "캘린더 조회 성공"), @@ -62,4 +63,4 @@ public ResponseEntity>> getRecentSeven List response = calendarService.getRecentSevenDaysStatus(currentUserId); return ResponseEntity.ok(ApiResponse.success(response)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/atmd/backend/domain/calendar/dto/response/CalendarResponseDTO.java b/src/main/java/com/atmd/backend/domain/calendar/dto/response/CalendarResponseDTO.java index 73cd608..ed62b02 100644 --- a/src/main/java/com/atmd/backend/domain/calendar/dto/response/CalendarResponseDTO.java +++ b/src/main/java/com/atmd/backend/domain/calendar/dto/response/CalendarResponseDTO.java @@ -44,5 +44,12 @@ public static class DailyRecord { @Schema(description = "운동 완료 종류 수", example = "3") private int exerciseCount; + + @Schema( + description = "해당 날짜에 완료한 체력 측정 그룹 ID. 완료된 측정이 없으면 null", + example = "550e8400-e29b-41d4-a716-446655440000", + nullable = true + ) + private String measurementGroupId; } -} \ No newline at end of file +} diff --git a/src/main/java/com/atmd/backend/domain/calendar/service/CalendarService.java b/src/main/java/com/atmd/backend/domain/calendar/service/CalendarService.java index 8467039..2b60804 100644 --- a/src/main/java/com/atmd/backend/domain/calendar/service/CalendarService.java +++ b/src/main/java/com/atmd/backend/domain/calendar/service/CalendarService.java @@ -7,6 +7,7 @@ import com.atmd.backend.domain.fitness.enums.ExerciseSessionMode; import com.atmd.backend.domain.fitness.enums.ExerciseSessionStatus; import com.atmd.backend.domain.fitness.enums.ExerciseType; +import com.atmd.backend.domain.fitness.repository.CompletedMeasurementGroupProjection; import com.atmd.backend.domain.fitness.repository.ExerciseSessionRepository; import com.atmd.backend.global.common.exception.GeneralException; import lombok.RequiredArgsConstructor; @@ -89,6 +90,24 @@ public CalendarResponseDTO getMonthlyCalendar(Long userId, int year, int month) exerciseCountByDate.put(date, exerciseTypes.size()) ); + Map measurementByDate = + exerciseSessionRepository.findCompletedMeasurementGroupsInPeriod( + userId, + ExerciseSessionMode.MEASUREMENT, + ExerciseSessionStatus.COMPLETED, + startDateTime, + endDateTime + ).stream() + .filter(measurement -> !isCurrentMonth + || !measurement.getCompletedAt().toLocalDate().isAfter(today)) + .collect(Collectors.toMap( + measurement -> measurement.getCompletedAt().toLocalDate(), + measurement -> measurement, + (first, second) -> first.getCompletedAt().isAfter(second.getCompletedAt()) + ? first + : second + )); + // 운동을 한 번이라도 한 날짜 수 int completedDays = (int) exerciseCountByDate.values().stream() .filter(count -> count > 0) @@ -107,11 +126,15 @@ public CalendarResponseDTO getMonthlyCalendar(Long userId, int year, int month) for (int day = 1; day <= yearMonth.lengthOfMonth(); day++) { LocalDate date = yearMonth.atDay(day); int exerciseCount = exerciseCountByDate.getOrDefault(date, 0); + CompletedMeasurementGroupProjection measurement = measurementByDate.get(date); dailyRecords.add( CalendarResponseDTO.DailyRecord.builder() .date(date) .exerciseCount(exerciseCount) + .measurementGroupId( + measurement == null ? null : measurement.getMeasurementGroupId() + ) .build() ); } @@ -171,4 +194,4 @@ public List getRecentSevenDaysStatus(Long userId) { return result; } -} \ No newline at end of file +} diff --git a/src/main/java/com/atmd/backend/domain/fitness/repository/CompletedMeasurementGroupProjection.java b/src/main/java/com/atmd/backend/domain/fitness/repository/CompletedMeasurementGroupProjection.java new file mode 100644 index 0000000..db334f1 --- /dev/null +++ b/src/main/java/com/atmd/backend/domain/fitness/repository/CompletedMeasurementGroupProjection.java @@ -0,0 +1,9 @@ +package com.atmd.backend.domain.fitness.repository; + +import java.time.LocalDateTime; + +public interface CompletedMeasurementGroupProjection { + String getMeasurementGroupId(); + + LocalDateTime getCompletedAt(); +} diff --git a/src/main/java/com/atmd/backend/domain/fitness/repository/ExerciseSessionRepository.java b/src/main/java/com/atmd/backend/domain/fitness/repository/ExerciseSessionRepository.java index 858128a..fb0a356 100644 --- a/src/main/java/com/atmd/backend/domain/fitness/repository/ExerciseSessionRepository.java +++ b/src/main/java/com/atmd/backend/domain/fitness/repository/ExerciseSessionRepository.java @@ -111,4 +111,27 @@ List findAllByUserIdAndModeAndStatusAndCompletedAtGreaterThanEq LocalDateTime start, LocalDateTime end ); + + @Query(""" + select e.measurementGroupId as measurementGroupId, + max(e.completedAt) as completedAt + from ExerciseSession e + where e.user.id = :userId + and e.mode = :mode + and e.status = :status + and e.measurementGroupId is not null + and e.isDeleted = false + group by e.measurementGroupId + having count(distinct e.exerciseType) = 4 + and max(e.completedAt) >= :start + and max(e.completedAt) < :end + order by max(e.completedAt) asc + """) + List findCompletedMeasurementGroupsInPeriod( + @Param("userId") Long userId, + @Param("mode") ExerciseSessionMode mode, + @Param("status") ExerciseSessionStatus status, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end + ); } diff --git a/src/test/java/com/atmd/backend/domain/calendar/service/CalendarServiceTest.java b/src/test/java/com/atmd/backend/domain/calendar/service/CalendarServiceTest.java index 7daf3f9..1149452 100644 --- a/src/test/java/com/atmd/backend/domain/calendar/service/CalendarServiceTest.java +++ b/src/test/java/com/atmd/backend/domain/calendar/service/CalendarServiceTest.java @@ -7,6 +7,7 @@ import com.atmd.backend.domain.fitness.enums.ExerciseSessionMode; import com.atmd.backend.domain.fitness.enums.ExerciseSessionStatus; import com.atmd.backend.domain.fitness.enums.ExerciseType; +import com.atmd.backend.domain.fitness.repository.CompletedMeasurementGroupProjection; import com.atmd.backend.domain.fitness.repository.ExerciseSessionRepository; import com.atmd.backend.domain.user.entity.User; import com.atmd.backend.global.common.exception.GeneralException; @@ -29,6 +30,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; @ExtendWith(MockitoExtension.class) class CalendarServiceTest { @@ -141,6 +143,33 @@ void getMonthlyCalendar_Success() { assertThat(result.getTotalTargetDays()).isEqualTo(10); } + @Test + @DisplayName("완료된 체력 측정이 있는 날짜에 측정 그룹 ID를 반환한다") + void getMonthlyCalendar_WithCompletedMeasurement_ReturnsMeasurementGroupId() { + Long userId = 1L; + String measurementGroupId = "550e8400-e29b-41d4-a716-446655440000"; + LocalDateTime completedAt = LocalDateTime.of(2026, 9, 3, 12, 0); + + CompletedMeasurementGroupProjection measurement = + mock(CompletedMeasurementGroupProjection.class); + given(measurement.getMeasurementGroupId()).willReturn(measurementGroupId); + given(measurement.getCompletedAt()).willReturn(completedAt); + + given(exerciseSessionRepository.findCompletedMeasurementGroupsInPeriod( + eq(userId), + eq(ExerciseSessionMode.MEASUREMENT), + eq(ExerciseSessionStatus.COMPLETED), + eq(LocalDate.of(2026, 9, 1).atStartOfDay()), + eq(LocalDate.of(2026, 10, 1).atStartOfDay()) + )).willReturn(List.of(measurement)); + + CalendarResponseDTO result = calendarService.getMonthlyCalendar(userId, 2026, 9); + + assertThat(result.getDailyRecords().get(2).getMeasurementGroupId()) + .isEqualTo(measurementGroupId); + assertThat(result.getDailyRecords().get(1).getMeasurementGroupId()).isNull(); + } + @Test @DisplayName("유효하지 않은 월(13월) 입력 시 INVALID_YEAR_MONTH 예외가 발생한다") void getMonthlyCalendar_InvalidMonth_ThrowsException() { @@ -251,4 +280,4 @@ void getRecentSevenDaysStatus_Success() { assertThat(result.get(6).isCompleted()).isTrue(); } } -} \ No newline at end of file +}