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개다.
+ *
+ *
+ * - mission-daily-01 '오늘 1강의 듣기': 오늘 완료(Chapter.status==COMPLETED, completedAt 기준)한 강의가
+ * 1개 이상이면 달성으로 본다. target=1 이므로 current는 0/1로 클램프한다(3강을 들어도 1/1).
+ *
- mission-daily-03 '오늘 학습 N분': current=오늘 학습시간(study_time_log SUM, 분),
+ * target=User.dailyStudyGoalMinutes(사용자 설정값, null이면 30). title도 target을 반영한다.
+ *
+ *
+ * "오늘" 경계는 §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