Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;

Expand All @@ -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;

/**
Expand Down Expand Up @@ -249,18 +260,58 @@ public StreakResponse getStreak(String userId, int months) {
}

/**
* 오늘의 미션을 반환한다. TODO: S3에서 gamification 테이블 연동 시 교체
* 오늘의 데일리 미션을 실데이터 기반으로 반환한다. 미션은 2개다.
*
* <ul>
* <li>mission-daily-01 '오늘 1강의 듣기': 오늘 완료(Chapter.status==COMPLETED, completedAt 기준)한 강의가
* 1개 이상이면 달성으로 본다. target=1 이므로 current는 0/1로 클램프한다(3강을 들어도 1/1).
* <li>mission-daily-03 '오늘 학습 N분': current=오늘 학습시간(study_time_log SUM, 분),
* target=User.dailyStudyGoalMinutes(사용자 설정값, null이면 30). title도 target을 반영한다.
* </ul>
*
* <p>"오늘" 경계는 §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<MissionResponse.MissionItem> missions =
List.of(
Expand All @@ -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<CalendarEvent> completed =
calendarEventRepository.findCompletedByUserIdSince(userId, LocalDate.now().minusDays(365));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@ public class CourseMapper {
/**
* Course 엔티티를 CourseSummaryResponse 로 변환한다.
*
* <p>완료 강의 수(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())
Expand All @@ -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();
Expand All @@ -53,10 +57,14 @@ public CourseSummaryResponse toSummaryResponse(Course course) {
/**
* Course 엔티티를 CourseDetailResponse 로 변환한다.
*
* <p>완료 강의 수(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()
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
package com.swez.backend.domain.course.repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

Expand All @@ -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<Chapter, String> {

Expand Down Expand Up @@ -50,4 +52,31 @@ public interface ChapterRepository extends JpaRepository<Chapter, String> {
* @return 해당 과외의 챕터 수
*/
long countByCourseId(String courseId);

/**
* 특정 사용자가 [start, end) 구간에 완료한 챕터(강의) 수를 집계한다.
*
* <p>'오늘 1강의 듣기' 미션 집계용. 완료 기준은 {@code Chapter.status == COMPLETED} 이며,
* 완료 시각은 {@code completedAt} 컬럼을 사용한다(리포트/마이코스 통계와 동일 기준). 소유자
* 필터는 챕터가 속한 과외의 사용자({@code course.user.id})로 건다.
*
* <p>"오늘" 경계는 나노초 누락 리스크가 있는 {@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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,24 @@ public OffsetPageResponse<CourseSummaryResponse> getMyCourses(
Page<Course> coursePage = courseRepository.findByUserId(userId, status, subject, pageable);

/*
2. DTO 변환 및 페이지 응답 래핑
2. 완료 강의 수 벌크 집계 (N+1 방지)
— 페이지 코스들의 courseId를 모아 findAllByCourseIdIn 으로 챕터를 한 번에 조회한 뒤
courseId → COMPLETED 챕터 수 Map을 만들어 매퍼에 주입한다.
*/
Map<String, Integer> completedByCourseId =
countCompletedChaptersByCourseIds(
coursePage.getContent().stream().map(Course::getId).toList());

/*
3. DTO 변환 및 페이지 응답 래핑
*/
List<CourseSummaryResponse> content =
coursePage.getContent().stream().map(courseMapper::toSummaryResponse).toList();
coursePage.getContent().stream()
.map(
course ->
courseMapper.toSummaryResponse(
course, completedByCourseId.getOrDefault(course.getId(), 0)))
.toList();
OffsetPageResponse<CourseSummaryResponse> result =
OffsetPageResponse.of(content, coursePage.getTotalElements(), page, size);

Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -638,6 +656,42 @@ public void deleteTutoring(

// ========================= Private Helpers =========================

/**
* 여러 과외의 완료 강의(챕터) 수를 한 번에 집계한다 (N+1 방지).
*
* <p>코스 목록을 루프 돌며 코스별 카운트 쿼리를 날리는 대신, courseId를 모아
* {@code findAllByCourseIdIn} 으로 챕터를 한 번에 조회한 뒤 메모리에서 status==COMPLETED 만
* 필터링해 courseId별로 groupingBy 한다. ReportService.getProgressReport 의 챕터 그룹핑 패턴과 동일.
*
* @param courseIds 집계 대상 과외 ID 목록
* @return courseId → 완료한 챕터 수 (완료 0개인 코스는 키 없음 → 호출부에서 getOrDefault(0))
*/
private Map<String, Integer> countCompletedChaptersByCourseIds(List<String> 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 =
Expand Down
Loading
Loading