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 f038fb6..adab175 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 @@ -147,8 +147,8 @@ public OffsetPageResponse getMyCourses( — Chapter.status는 생성/잠금 상태에 가깝고, 학생별 학습 완료 여부는 lesson_progress.is_completed 에 저장되므로 사용자별 진행 기록을 기준으로 집계한다. */ - Map studiedByCourseId = - countStudiedChaptersByCourseIds( + Map completedByCourseId = + countCompletedChaptersByCourseIds( userId, coursePage.getContent().stream().map(Course::getId).toList()); @@ -160,7 +160,7 @@ public OffsetPageResponse getMyCourses( .map( course -> courseMapper.toSummaryResponse( - course, studiedByCourseId.getOrDefault(course.getId(), 0))) + course, completedByCourseId.getOrDefault(course.getId(), 0))) .toList(); OffsetPageResponse result = OffsetPageResponse.of(content, coursePage.getTotalElements(), page, size); @@ -189,7 +189,7 @@ public CourseDetailResponse getCourse(String userId, String courseId) { 2. 학습 강의 수 집계 후 DTO 변환 (단일 코스) */ CourseDetailResponse result = - courseMapper.toDetailResponse(course, countStudiedChapters(userId, courseId)); + courseMapper.toDetailResponse(course, countCompletedChapters(userId, courseId)); log.debug("[CourseService] getCourse() - END | courseId: {}", courseId); return result; @@ -683,7 +683,7 @@ public CourseDetailResponse updateTutoring( */ CourseDetailResponse result = courseMapper.toDetailResponse( - course, countStudiedChapters(principal.getUserId(), courseId)); + course, countCompletedChapters(principal.getUserId(), courseId)); log.debug("[CourseService] updateTutoring() - END | courseId: {}", courseId); return result; @@ -719,22 +719,22 @@ public void deleteTutoring( // ========================= Private Helpers ========================= /** - * 여러 과외의 사용자별 학습 강의(챕터) 수를 한 번에 집계한다 (N+1 방지). + * 여러 과외의 사용자별 완료(isCompleted=true) 강의(챕터) 수를 한 번에 집계한다 (N+1 방지). * - *

학생이 실제로 들어간 강의 수는 Chapter.status가 아니라 LessonProgress로 기록된다. 홈/내 과외의 - * 강의 수 표시는 완강 수가 아니라 학습을 시작했거나 완료한 챕터 수를 사용한다. + *

completedLessons 는 진행률(progress, 완료 기준)과 일관돼야 하므로 진행중(currentSlideIdx>0)은 제외하고 + * 완료한 챕터만 센다. 강의를 열기만 하고 끝내지 않은 경우 진행률 0%인데 1강 완료로 표시되던 모순을 막는다. * * @param userId 사용자 ID * @param courseIds 집계 대상 과외 ID 목록 - * @return courseId → 학습한 챕터 수 (0개인 코스는 키 없음 → 호출부에서 getOrDefault(0)) + * @return courseId → 완료한 챕터 수 (0개인 코스는 키 없음 → 호출부에서 getOrDefault(0)) */ - private Map countStudiedChaptersByCourseIds( + private Map countCompletedChaptersByCourseIds( String userId, List courseIds) { if (courseIds.isEmpty()) { return Map.of(); } - return lessonProgressRepository.countStudiedByUserIdAndCourseIdIn(userId, courseIds).stream() + return lessonProgressRepository.countCompletedByUserIdAndCourseIdIn(userId, courseIds).stream() .collect( Collectors.toMap( row -> (String) row[0], @@ -742,15 +742,15 @@ private Map countStudiedChaptersByCourseIds( } /** - * 단일 과외의 사용자별 학습 강의(챕터) 수를 집계한다. + * 단일 과외의 사용자별 완료(isCompleted=true) 강의(챕터) 수를 집계한다. * * @param userId 사용자 ID * @param courseId 과외 ID - * @return 학습한 챕터 수 + * @return 완료한 챕터 수 */ - private int countStudiedChapters(String userId, String courseId) { + private int countCompletedChapters(String userId, String courseId) { return Math.toIntExact( - lessonProgressRepository.countStudiedByCourseIdAndUserId(courseId, userId)); + lessonProgressRepository.countCompletedByCourseIdAndUserId(courseId, userId)); } /** 과외를 조회하고 소유권을 검증한다. 존재하지 않거나 소유자가 다를 경우 CRS_001을 발생시킨다 (존재 노출 방지). */ diff --git a/src/main/java/com/swez/backend/domain/lesson/repository/LessonProgressRepository.java b/src/main/java/com/swez/backend/domain/lesson/repository/LessonProgressRepository.java index 9ecf43a..8951cae 100644 --- a/src/main/java/com/swez/backend/domain/lesson/repository/LessonProgressRepository.java +++ b/src/main/java/com/swez/backend/domain/lesson/repository/LessonProgressRepository.java @@ -88,4 +88,23 @@ long countStudiedByCourseIdAndUserId( + "AND lp.isCompleted = true") long countCompletedByCourseIdAndUserId( @Param("courseId") String courseId, @Param("userId") String userId); + + /** + * 여러 과외의 사용자별 완료(isCompleted=true) 챕터 수를 한 번에 집계한다 (N+1 방지). + * + *

completedLessons(완료 강의 수)는 진행률(progress, 완료 기준)과 일관돼야 하므로 진행중(currentSlideIdx>0)은 + * 제외하고 완료만 센다. 진행 포함 집계는 countStudiedByUserIdAndCourseIdIn 을 별도로 쓴다. + * + * @param userId 사용자 ID + * @param courseIds 집계 대상 과외 ID 목록 + * @return Object[] = {courseId, completedCount} + */ + @Query( + "SELECT lp.chapter.course.id, COUNT(lp) FROM LessonProgress lp " + + "WHERE lp.user.id = :userId " + + "AND lp.chapter.course.id IN :courseIds " + + "AND lp.isCompleted = true " + + "GROUP BY lp.chapter.course.id") + List countCompletedByUserIdAndCourseIdIn( + @Param("userId") String userId, @Param("courseIds") List courseIds); } diff --git a/src/test/java/com/swez/backend/domain/course/service/CourseServiceTest.java b/src/test/java/com/swez/backend/domain/course/service/CourseServiceTest.java index 1dc23ef..924ed3a 100644 --- a/src/test/java/com/swez/backend/domain/course/service/CourseServiceTest.java +++ b/src/test/java/com/swez/backend/domain/course/service/CourseServiceTest.java @@ -308,7 +308,9 @@ void getMyCourses_returnsList() { Course course = makeCourse(user); given(courseRepository.findByUserId(eq(USER_ID), isNull(), isNull(), any(Pageable.class))) .willReturn(new PageImpl<>(List.of(course))); - given(lessonProgressRepository.countStudiedByUserIdAndCourseIdIn(USER_ID, List.of(COURSE_ID))) + given( + lessonProgressRepository.countCompletedByUserIdAndCourseIdIn( + USER_ID, List.of(COURSE_ID))) .willReturn(List.of(new Object[] {COURSE_ID, 2L})); // when @@ -320,6 +322,27 @@ void getMyCourses_returnsList() { assertThat(result.getContent().get(0).id()).isEqualTo(COURSE_ID); assertThat(result.getContent().get(0).completedLessons()).isEqualTo(2); } + + @Test + @DisplayName("완료한 강의가 없으면(진행중만 존재) completedLessons는 0이다") + void getMyCourses_onlyInProgress_completedIsZero() { + // given — 완료 집계 쿼리가 빈 결과(키 없음) → 진행중만 있는 코스 (버그 회귀: 0%인데 1강 표시 방지) + User user = makeUser(USER_ID); + Course course = makeCourse(user); + given(courseRepository.findByUserId(eq(USER_ID), isNull(), isNull(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(course))); + given( + lessonProgressRepository.countCompletedByUserIdAndCourseIdIn( + USER_ID, List.of(COURSE_ID))) + .willReturn(List.of()); + + // when + OffsetPageResponse result = + courseService.getMyCourses(USER_ID, null, null, 0, 20); + + // then + assertThat(result.getContent().get(0).completedLessons()).isZero(); + } } @Nested @@ -332,7 +355,7 @@ void getCourse_owner_returnsDetail() { // given User user = makeUser(USER_ID); given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(makeCourse(user))); - given(lessonProgressRepository.countStudiedByCourseIdAndUserId(COURSE_ID, USER_ID)) + given(lessonProgressRepository.countCompletedByCourseIdAndUserId(COURSE_ID, USER_ID)) .willReturn(2L); // when