diff --git a/src/main/java/com/swez/backend/domain/calendar/service/CalendarService.java b/src/main/java/com/swez/backend/domain/calendar/service/CalendarService.java index 9719847..64b10b7 100644 --- a/src/main/java/com/swez/backend/domain/calendar/service/CalendarService.java +++ b/src/main/java/com/swez/backend/domain/calendar/service/CalendarService.java @@ -4,6 +4,7 @@ package com.swez.backend.domain.calendar.service; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.List; @@ -14,6 +15,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.swez.backend.domain.auth.entity.User; +import com.swez.backend.domain.auth.exception.AuthErrorCode; import com.swez.backend.domain.auth.repository.UserRepository; import com.swez.backend.domain.calendar.dto.request.CreateCalendarEventRequest; import com.swez.backend.domain.calendar.dto.request.UpdateCalendarEventRequest; @@ -27,7 +30,10 @@ import com.swez.backend.domain.calendar.mapper.CalendarMapper; import com.swez.backend.domain.calendar.repository.CalendarEventRepository; import com.swez.backend.domain.course.entity.Course; +import com.swez.backend.domain.course.enums.ChapterStatus; +import com.swez.backend.domain.course.repository.ChapterRepository; import com.swez.backend.domain.course.repository.CourseRepository; +import com.swez.backend.domain.report.repository.StudyTimeLogRepository; import com.swez.backend.global.exception.CustomException; import com.swez.backend.global.util.IdGenerator; @@ -41,9 +47,14 @@ @RequiredArgsConstructor public class CalendarService { + /** 목표 학습시간이 미설정(null)인 사용자에 대한 기본값(분). 기존 ReportService 관례와 동일. */ + private static final int DEFAULT_DAILY_STUDY_GOAL_MINUTES = 30; + private final CalendarEventRepository calendarEventRepository; private final CourseRepository courseRepository; private final UserRepository userRepository; + private final ChapterRepository chapterRepository; + private final StudyTimeLogRepository studyTimeLogRepository; private final CalendarMapper calendarMapper; /** @@ -249,18 +260,58 @@ public StreakResponse getStreak(String userId, int months) { } /** - * 오늘의 미션을 반환한다. TODO: S3에서 gamification 테이블 연동 시 교체 + * 오늘의 데일리 미션을 실데이터 기반으로 반환한다. 미션은 2개다. + * + * + * + *

"오늘" 경계는 §3 컨벤션대로 반열린 구간 {@code [today 00:00, 익일 00:00)} 으로 처리한다. * * @param userId 인증된 사용자 ID - * @return 오늘의 미션 응답 DTO + * @return 오늘의 미션 응답 DTO (미션 2개) */ @Transactional(readOnly = true) public MissionResponse getMissionsToday(String userId) { log.debug("[CalendarService] getMissionsToday() - START | userId: {}", userId); /* - 1. S2: 하드코딩 미션 3개 반환 - - TODO: S3에서 user_game_state 또는 daily_mission 테이블 연동으로 교체 + 1. "오늘" 범위 계산 — 반열린 구간 [start, end) (start 포함, end 미포함) + 기존 LocalDate.now() 컨벤션을 따른다(서버 시스템 타임존 기준). + */ + LocalDate today = LocalDate.now(); + LocalDateTime start = today.atStartOfDay(); + LocalDateTime end = today.plusDays(1).atStartOfDay(); + + /* + 2. '오늘 1강의 듣기' 집계 — 오늘 완료한 강의 수를 0/1로 클램프 + target=1 이므로 진행바 의미상 0 또는 1로만 표시한다. + */ + long completedLessonCount = + chapterRepository.countCompletedByUserInRange( + userId, ChapterStatus.COMPLETED, start, end); + boolean lessonMissionCompleted = completedLessonCount >= 1; + int lessonCurrent = lessonMissionCompleted ? 1 : 0; + + /* + 3. '오늘 학습 N분' 집계 — current=오늘 학습시간 합산(분), target=사용자 목표 학습시간 + 목표시간 미설정(null)이면 기본값 30. 방어적으로 target<=0 인 경우엔 isCompleted=true 처리 + (이미 달성으로 간주, 진행바 0-division 방지는 프론트가 target>0 가드). + */ + int studyMinutes = studyTimeLogRepository.sumStudyMinutesInRange(userId, start, end); + User user = findUserById(userId); + int studyGoalMinutes = + user.getDailyStudyGoalMinutes() != null + ? user.getDailyStudyGoalMinutes() + : DEFAULT_DAILY_STUDY_GOAL_MINUTES; + boolean studyMissionCompleted = + studyGoalMinutes <= 0 || studyMinutes >= studyGoalMinutes; + + /* + 4. 미션 2개 구성 — '오늘 1강의 듣기' / '오늘 학습 N분'. xpReward는 기존 값 유지. */ List missions = List.of( @@ -269,41 +320,42 @@ public MissionResponse getMissionsToday(String userId) { .title("오늘 1강의 듣기") .description("강의를 1개 이상 완료하세요") .type("daily") - .current(0) + .current(lessonCurrent) .target(1) .xpReward(50) - .isCompleted(false) - .build(), - MissionResponse.MissionItem.builder() - .id("mission-daily-02") - .title("노트 1개 작성") - .description("강의 중 메모를 1개 이상 작성하세요") - .type("daily") - .current(0) - .target(1) - .xpReward(20) - .isCompleted(false) + .isCompleted(lessonMissionCompleted) .build(), MissionResponse.MissionItem.builder() .id("mission-daily-03") - .title("오늘 학습 30분") - .description("총 30분 이상 학습하세요") + .title("오늘 학습 " + studyGoalMinutes + "분") + .description("총 " + studyGoalMinutes + "분 이상 학습하세요") .type("daily") - .current(0) - .target(30) + .current(studyMinutes) + .target(studyGoalMinutes) .xpReward(30) - .isCompleted(false) + .isCompleted(studyMissionCompleted) .build()); MissionResponse result = - MissionResponse.builder().date(LocalDate.now().toString()).missions(missions).build(); + MissionResponse.builder().date(today.toString()).missions(missions).build(); - log.debug("[CalendarService] getMissionsToday() - END"); + log.debug( + "[CalendarService] getMissionsToday() - END | lessonCurrent: {}, studyMinutes: {}, goal: {}", + lessonCurrent, + studyMinutes, + studyGoalMinutes); return result; } // ========================= Private Helpers ========================= + /** userId 로 사용자를 조회한다. 없으면 USER_001 예외를 발생시킨다. */ + private User findUserById(String userId) { + return userRepository + .findById(userId) + .orElseThrow(() -> new CustomException(AuthErrorCode.USER_001)); + } + private int calculateStreak(String userId) { List completed = calendarEventRepository.findCompletedByUserIdSince(userId, LocalDate.now().minusDays(365)); diff --git a/src/main/java/com/swez/backend/domain/course/mapper/CourseMapper.java b/src/main/java/com/swez/backend/domain/course/mapper/CourseMapper.java index 8ca4a52..9429806 100644 --- a/src/main/java/com/swez/backend/domain/course/mapper/CourseMapper.java +++ b/src/main/java/com/swez/backend/domain/course/mapper/CourseMapper.java @@ -30,10 +30,14 @@ public class CourseMapper { /** * Course 엔티티를 CourseSummaryResponse 로 변환한다. * + *

완료 강의 수(completedLessons)는 집계 책임이 서비스에 있으므로(N+1 방지 벌크 집계) 매퍼는 + * 계산된 값을 파라미터로만 받아 매핑한다. 기존 completedSlidesByChapterId 주입 패턴과 동일. + * * @param course 변환할 Course 엔티티 + * @param completedLessons 완료한 강의(챕터) 수 — 서비스에서 Chapter.status==COMPLETED 로 집계한 값 * @return CourseSummaryResponse */ - public CourseSummaryResponse toSummaryResponse(Course course) { + public CourseSummaryResponse toSummaryResponse(Course course, int completedLessons) { String tutorName = course.getTutor() != null ? course.getTutor().getName() : null; return CourseSummaryResponse.builder() .id(course.getId()) @@ -44,7 +48,7 @@ public CourseSummaryResponse toSummaryResponse(Course course) { .status(course.getStatus()) .progress(course.getProgressPercent().doubleValue() / 100.0) .totalLessons(course.getTotalChapters()) - .completedLessons(0) // TODO: chapter completed 집계 연동 시 교체 + .completedLessons(completedLessons) .lastStudiedAt(null) // TODO: 학습 이력 연동 시 교체 .createdAt(course.getCreatedAt()) .build(); @@ -53,10 +57,14 @@ public CourseSummaryResponse toSummaryResponse(Course course) { /** * Course 엔티티를 CourseDetailResponse 로 변환한다. * + *

완료 강의 수(completedLessons)는 집계 책임이 서비스에 있으므로 매퍼는 계산된 값을 파라미터로만 받아 + * 매핑한다. + * * @param course 변환할 Course 엔티티 + * @param completedLessons 완료한 강의(챕터) 수 — 서비스에서 Chapter.status==COMPLETED 로 집계한 값 * @return CourseDetailResponse */ - public CourseDetailResponse toDetailResponse(Course course) { + public CourseDetailResponse toDetailResponse(Course course, int completedLessons) { String tutorId = course.getTutor() != null ? course.getTutor().getTutorId() : null; String tutorName = course.getTutor() != null ? course.getTutor().getName() : null; return CourseDetailResponse.builder() @@ -71,7 +79,7 @@ public CourseDetailResponse toDetailResponse(Course course) { .mode(course.getMode()) .progress(course.getProgressPercent().doubleValue() / 100.0) .totalLessons(course.getTotalChapters()) - .completedLessons(0) // TODO: chapter completed 집계 연동 시 교체 + .completedLessons(completedLessons) .lastStudiedAt(null) // TODO: 학습 이력 연동 시 교체 .createdAt(course.getCreatedAt()) .build(); diff --git a/src/main/java/com/swez/backend/domain/course/repository/ChapterRepository.java b/src/main/java/com/swez/backend/domain/course/repository/ChapterRepository.java index 6ba5b2b..d82a6b6 100644 --- a/src/main/java/com/swez/backend/domain/course/repository/ChapterRepository.java +++ b/src/main/java/com/swez/backend/domain/course/repository/ChapterRepository.java @@ -3,6 +3,7 @@ */ package com.swez.backend.domain.course.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -11,6 +12,7 @@ import org.springframework.data.repository.query.Param; import com.swez.backend.domain.course.entity.Chapter; +import com.swez.backend.domain.course.enums.ChapterStatus; public interface ChapterRepository extends JpaRepository { @@ -50,4 +52,31 @@ public interface ChapterRepository extends JpaRepository { * @return 해당 과외의 챕터 수 */ long countByCourseId(String courseId); + + /** + * 특정 사용자가 [start, end) 구간에 완료한 챕터(강의) 수를 집계한다. + * + *

'오늘 1강의 듣기' 미션 집계용. 완료 기준은 {@code Chapter.status == COMPLETED} 이며, + * 완료 시각은 {@code completedAt} 컬럼을 사용한다(리포트/마이코스 통계와 동일 기준). 소유자 + * 필터는 챕터가 속한 과외의 사용자({@code course.user.id})로 건다. + * + *

"오늘" 경계는 나노초 누락 리스크가 있는 {@code LocalTime.MAX} 대신 반열린 구간 + * {@code [start, end)} 으로 처리한다(start 포함, end 미포함). + * + * @param userId 사용자 ID (과외 소유자) + * @param status 완료로 판정할 상태 값 (보통 {@link ChapterStatus#COMPLETED}) + * @param start 집계 시작 시각 (포함) + * @param end 집계 종료 시각 (미포함) + * @return 해당 구간에 완료한 챕터 수 + */ + @Query( + "SELECT COUNT(ch) FROM Chapter ch " + + "WHERE ch.course.user.id = :userId " + + "AND ch.status = :status " + + "AND ch.completedAt >= :start AND ch.completedAt < :end") + long countCompletedByUserInRange( + @Param("userId") String userId, + @Param("status") ChapterStatus status, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); } diff --git a/src/main/java/com/swez/backend/domain/course/service/CourseService.java b/src/main/java/com/swez/backend/domain/course/service/CourseService.java index 02ae272..ded9399 100644 --- a/src/main/java/com/swez/backend/domain/course/service/CourseService.java +++ b/src/main/java/com/swez/backend/domain/course/service/CourseService.java @@ -136,10 +136,24 @@ public OffsetPageResponse getMyCourses( Page coursePage = courseRepository.findByUserId(userId, status, subject, pageable); /* - 2. DTO 변환 및 페이지 응답 래핑 + 2. 완료 강의 수 벌크 집계 (N+1 방지) + — 페이지 코스들의 courseId를 모아 findAllByCourseIdIn 으로 챕터를 한 번에 조회한 뒤 + courseId → COMPLETED 챕터 수 Map을 만들어 매퍼에 주입한다. + */ + Map completedByCourseId = + countCompletedChaptersByCourseIds( + coursePage.getContent().stream().map(Course::getId).toList()); + + /* + 3. DTO 변환 및 페이지 응답 래핑 */ List content = - coursePage.getContent().stream().map(courseMapper::toSummaryResponse).toList(); + coursePage.getContent().stream() + .map( + course -> + courseMapper.toSummaryResponse( + course, completedByCourseId.getOrDefault(course.getId(), 0))) + .toList(); OffsetPageResponse result = OffsetPageResponse.of(content, coursePage.getTotalElements(), page, size); @@ -164,9 +178,10 @@ public CourseDetailResponse getCourse(String userId, String courseId) { Course course = findCourseByIdAndUser(courseId, userId); /* - 2. DTO 변환 + 2. 완료 강의 수 집계 후 DTO 변환 (단일 코스) */ - CourseDetailResponse result = courseMapper.toDetailResponse(course); + CourseDetailResponse result = + courseMapper.toDetailResponse(course, countCompletedChapters(courseId)); log.debug("[CourseService] getCourse() - END | courseId: {}", courseId); return result; @@ -424,8 +439,10 @@ public CourseDetailResponse createTutoring( /* 4. DTO 변환 + — 갓 생성된 과외는 CURRICULUM_GENERATING 상태로 챕터가 아직 없으므로 + 완료 강의 수는 항상 0이다. 불필요한 집계 쿼리를 피해 0을 직접 전달한다. */ - CourseDetailResponse result = courseMapper.toDetailResponse(course); + CourseDetailResponse result = courseMapper.toDetailResponse(course, 0); log.debug("[CourseService] createTutoring() - END | courseId: {}", course.getId()); return result; @@ -601,9 +618,10 @@ public CourseDetailResponse updateTutoring( course.updateInfo(request.courseName(), request.subject(), request.examDate()); /* - 3. DTO 변환 + 3. 완료 강의 수 집계 후 DTO 변환 (단일 코스) */ - CourseDetailResponse result = courseMapper.toDetailResponse(course); + CourseDetailResponse result = + courseMapper.toDetailResponse(course, countCompletedChapters(courseId)); log.debug("[CourseService] updateTutoring() - END | courseId: {}", courseId); return result; @@ -638,6 +656,42 @@ public void deleteTutoring( // ========================= Private Helpers ========================= + /** + * 여러 과외의 완료 강의(챕터) 수를 한 번에 집계한다 (N+1 방지). + * + *

코스 목록을 루프 돌며 코스별 카운트 쿼리를 날리는 대신, courseId를 모아 + * {@code findAllByCourseIdIn} 으로 챕터를 한 번에 조회한 뒤 메모리에서 status==COMPLETED 만 + * 필터링해 courseId별로 groupingBy 한다. ReportService.getProgressReport 의 챕터 그룹핑 패턴과 동일. + * + * @param courseIds 집계 대상 과외 ID 목록 + * @return courseId → 완료한 챕터 수 (완료 0개인 코스는 키 없음 → 호출부에서 getOrDefault(0)) + */ + private Map countCompletedChaptersByCourseIds(List courseIds) { + if (courseIds.isEmpty()) { + return Map.of(); + } + + return chapterRepository.findAllByCourseIdIn(courseIds).stream() + .filter(chapter -> chapter.getStatus() == ChapterStatus.COMPLETED) + .collect( + Collectors.groupingBy( + chapter -> chapter.getCourse().getId(), + Collectors.summingInt(chapter -> 1))); + } + + /** + * 단일 과외의 완료 강의(챕터) 수를 집계한다. 기준은 Chapter.status==COMPLETED. + * + * @param courseId 과외 ID + * @return 완료한 챕터 수 + */ + private int countCompletedChapters(String courseId) { + return (int) + chapterRepository.findByCourseIdOrderByOrderIndexAsc(courseId).stream() + .filter(chapter -> chapter.getStatus() == ChapterStatus.COMPLETED) + .count(); + } + /** 과외를 조회하고 소유권을 검증한다. 존재하지 않거나 소유자가 다를 경우 CRS_001을 발생시킨다 (존재 노출 방지). */ private Course findCourseByIdAndUser(String courseId, String userId) { Course course = diff --git a/src/main/java/com/swez/backend/domain/report/repository/StudyTimeLogRepository.java b/src/main/java/com/swez/backend/domain/report/repository/StudyTimeLogRepository.java index 5bbe725..69148a0 100644 --- a/src/main/java/com/swez/backend/domain/report/repository/StudyTimeLogRepository.java +++ b/src/main/java/com/swez/backend/domain/report/repository/StudyTimeLogRepository.java @@ -47,4 +47,29 @@ List findDailyStudyMinutes( + "WHERE s.user.id = :userId " + "ORDER BY CAST(s.startedAt AS LocalDate) DESC") List findDistinctStudyDates(@Param("userId") String userId); + + /** + * 특정 사용자의 [start, end) 구간 학습 시간 합계(분)를 단일 스칼라로 반환한다. 기록이 없으면 0. + * + *

'오늘 학습 N분' 미션 집계용. 기존 {@link #findDailyStudyMinutes}는 일자별로 그룹핑한 + * Object[] 리스트를 반환하므로 단일 합계가 필요한 미션 집계에는 부적합하여 전용 메서드를 둔다. + * 집계 기준은 {@code startedAt}(학습 시작 시각)이며 {@code createdAt}은 레코드 생성 시각이라 + * 부적합하다. + * + *

"오늘" 경계는 나노초 누락 리스크가 있는 {@code LocalTime.MAX} 대신 반열린 구간 + * {@code [start, end)} 으로 처리한다(start 포함, end 미포함). + * + * @param userId 사용자 ID + * @param start 집계 시작 시각 (포함) + * @param end 집계 종료 시각 (미포함) + * @return 학습 시간 합계(분). 기록이 없으면 0 + */ + @Query( + "SELECT COALESCE(SUM(s.durationMinutes), 0) FROM StudyTimeLog s " + + "WHERE s.user.id = :userId " + + "AND s.startedAt >= :start AND s.startedAt < :end") + int sumStudyMinutesInRange( + @Param("userId") String userId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); } diff --git a/src/test/java/com/swez/backend/domain/calendar/service/CalendarServiceTest.java b/src/test/java/com/swez/backend/domain/calendar/service/CalendarServiceTest.java index c3471f2..e805515 100644 --- a/src/test/java/com/swez/backend/domain/calendar/service/CalendarServiceTest.java +++ b/src/test/java/com/swez/backend/domain/calendar/service/CalendarServiceTest.java @@ -36,6 +36,12 @@ import com.swez.backend.domain.calendar.enums.CalendarEventType; import com.swez.backend.domain.calendar.mapper.CalendarMapper; import com.swez.backend.domain.calendar.repository.CalendarEventRepository; +import com.swez.backend.domain.course.enums.ChapterStatus; +import com.swez.backend.domain.course.repository.ChapterRepository; +import com.swez.backend.domain.report.repository.StudyTimeLogRepository; + +import java.time.LocalDateTime; +import java.util.Optional; @DisplayName("CalendarService 단위 테스트") @ExtendWith(MockitoExtension.class) @@ -44,6 +50,8 @@ class CalendarServiceTest { @Mock private CalendarEventRepository calendarEventRepository; @Mock private UserRepository userRepository; + @Mock private ChapterRepository chapterRepository; + @Mock private StudyTimeLogRepository studyTimeLogRepository; @Mock private CalendarMapper calendarMapper; @InjectMocks private CalendarService calendarService; @@ -173,14 +181,77 @@ void getStreak_intensityLevel2() { class GetMissionsToday { @Test - @DisplayName("S2 하드코딩 미션 3개를 반환한다") - void getMissionsToday_returns3Missions() { + @DisplayName("미션 2개(1강의 듣기/학습 N분)를 실데이터로 반환한다") + void getMissionsToday_returnsTwoMissionsWithRealData() { + // given — 오늘 2강 완료(클램프되어 1/1), 오늘 학습 45분, 목표 60분 + User user = User.builder().id(USER_ID).dailyStudyGoalMinutes(60).build(); + given( + chapterRepository.countCompletedByUserInRange( + eq(USER_ID), + eq(ChapterStatus.COMPLETED), + any(LocalDateTime.class), + any(LocalDateTime.class))) + .willReturn(2L); + given( + studyTimeLogRepository.sumStudyMinutesInRange( + eq(USER_ID), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(45); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + + // when + MissionResponse result = calendarService.getMissionsToday(USER_ID); + + // then — 데일리 미션 2개만 반환(3개에서 1개 삭제됨) + assertThat(result.missions()).hasSize(2); + + // 1강의 듣기: 2강을 들어도 0/1로 클램프 → current=1, isCompleted=true + MissionResponse.MissionItem lesson = result.missions().get(0); + assertThat(lesson.id()).isEqualTo("mission-daily-01"); + assertThat(lesson.current()).isEqualTo(1); + assertThat(lesson.target()).isEqualTo(1); + assertThat(lesson.isCompleted()).isTrue(); + + // 학습 N분: current=45, target=사용자 설정값 60, title도 target 반영, 미달이므로 미완료 + MissionResponse.MissionItem study = result.missions().get(1); + assertThat(study.id()).isEqualTo("mission-daily-03"); + assertThat(study.current()).isEqualTo(45); + assertThat(study.target()).isEqualTo(60); + assertThat(study.title()).isEqualTo("오늘 학습 60분"); + assertThat(study.isCompleted()).isFalse(); + } + + @Test + @DisplayName("오늘 완료 강의 0개면 1강의 미션은 current=0·미완료") + void getMissionsToday_noCompletedLesson_returnsZero() { + // given — 오늘 완료 강의 0개, 목표시간 미설정(null) → 기본 30분, 학습 30분으로 달성 + User user = User.builder().id(USER_ID).build(); + ReflectionTestUtils.setField(user, "dailyStudyGoalMinutes", null); + given( + chapterRepository.countCompletedByUserInRange( + eq(USER_ID), + eq(ChapterStatus.COMPLETED), + any(LocalDateTime.class), + any(LocalDateTime.class))) + .willReturn(0L); + given( + studyTimeLogRepository.sumStudyMinutesInRange( + eq(USER_ID), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(30); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + // when MissionResponse result = calendarService.getMissionsToday(USER_ID); // then - assertThat(result.missions()).hasSize(3); - assertThat(result.missions().get(0).type()).isEqualTo("daily"); + MissionResponse.MissionItem lesson = result.missions().get(0); + assertThat(lesson.current()).isZero(); + assertThat(lesson.isCompleted()).isFalse(); + + // 목표시간 null → 기본 30, 학습 30분이므로 달성 + MissionResponse.MissionItem study = result.missions().get(1); + assertThat(study.target()).isEqualTo(30); + assertThat(study.title()).isEqualTo("오늘 학습 30분"); + assertThat(study.isCompleted()).isTrue(); } } }