diff --git a/src/main/java/com/mr/domain/learning/controller/LearningController.java b/src/main/java/com/mr/domain/learning/controller/LearningController.java new file mode 100644 index 00000000..3074861d --- /dev/null +++ b/src/main/java/com/mr/domain/learning/controller/LearningController.java @@ -0,0 +1,44 @@ +package com.mr.domain.learning.controller; + +import com.mr.domain.learning.dto.req.LearningResultSaveRequestDTO; +import com.mr.domain.learning.dto.res.LearningProgressResponseDTO; +import com.mr.domain.learning.dto.res.LearningResultResponseDTO; +import com.mr.domain.learning.service.LearningService; +import com.mr.global.apipayload.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/learnings") +public class LearningController { + + private final LearningService learningService; + + // 학습 결과 저장 + @PostMapping("/{learningId}/result") + public ApiResponse saveLearningResult( + @PathVariable Long learningId, // 임시 + @Valid @RequestBody LearningResultSaveRequestDTO.SaveResultDTO request + ){ + LearningResultResponseDTO.SaveResultResultDTO response = + learningService.saveResult(learningId, request); + + return ApiResponse.onSuccess(response); + } + + // 학습 진행률 조회 + @GetMapping("/{learningId}/progress") + public ApiResponse getLearningProgress( + @PathVariable Long learningId + ) { + LearningProgressResponseDTO.ProgressResultDTO result = learningService.getLearningProgress(learningId); + return ApiResponse.onSuccess(result); + } +} diff --git a/src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java b/src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java new file mode 100644 index 00000000..ecbf0108 --- /dev/null +++ b/src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java @@ -0,0 +1,10 @@ +package com.mr.domain.learning.dto.req; + +import jakarta.validation.constraints.NotNull; + +public class LearningResultSaveRequestDTO { + public record SaveResultDTO( + @NotNull(message = "점수는 필수 입력값입니다.") + Integer score + ) {} +} diff --git a/src/main/java/com/mr/domain/learning/dto/res/LearningProgressResponseDTO.java b/src/main/java/com/mr/domain/learning/dto/res/LearningProgressResponseDTO.java new file mode 100644 index 00000000..24682e0f --- /dev/null +++ b/src/main/java/com/mr/domain/learning/dto/res/LearningProgressResponseDTO.java @@ -0,0 +1,13 @@ +package com.mr.domain.learning.dto.res; + +public class LearningProgressResponseDTO { + + public record ProgressResultDTO( + Long learningId, + Integer progressRate + ){ + public static ProgressResultDTO of(Long learningId, Integer progressRate){ + return new ProgressResultDTO(learningId, progressRate); + } + } +} diff --git a/src/main/java/com/mr/domain/learning/dto/res/LearningResultResponseDTO.java b/src/main/java/com/mr/domain/learning/dto/res/LearningResultResponseDTO.java new file mode 100644 index 00000000..8cec4a2b --- /dev/null +++ b/src/main/java/com/mr/domain/learning/dto/res/LearningResultResponseDTO.java @@ -0,0 +1,27 @@ +package com.mr.domain.learning.dto.res; + +import com.mr.domain.learning.entity.UserLearningProgress; + +import java.time.LocalDateTime; + +public class LearningResultResponseDTO { + public record SaveResultResultDTO( + Long userLearningProgressId, + Long userId, + Long learningId, + String status, + Integer score, + LocalDateTime completedAt + ) { + public static SaveResultResultDTO from(UserLearningProgress progress) { + return new SaveResultResultDTO( + progress.getId(), + progress.getUser().getUserId(), + progress.getLearning().getId(), + progress.getLearningStatus(), + progress.getScore(), + progress.getUpdatedAt() + ); + } + } +} diff --git a/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java b/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java index 590a671a..b0cdac51 100644 --- a/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java +++ b/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java @@ -1,6 +1,7 @@ package com.mr.domain.learning.entity; import com.mr.domain.learning.exception.LearningErrorStatus; +import com.mr.domain.user.entity.User; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.entity.BaseTimeEntity; import jakarta.persistence.Column; @@ -41,8 +42,9 @@ public class UserLearningProgress extends BaseTimeEntity { private Long id; // 유저 아이디 - @Column(name = "user_id", nullable = false) - private Long userId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; // 학습 아이디 @ManyToOne(fetch = FetchType.LAZY) @@ -63,20 +65,20 @@ public class UserLearningProgress extends BaseTimeEntity { private LocalDateTime lastStudiedAt; @Builder(access = AccessLevel.PRIVATE) - private UserLearningProgress(Long userId, Learning learning, LearningStep learningStep, + private UserLearningProgress(User user, Learning learning, LearningStep learningStep, Integer score, LocalDateTime lastStudiedAt) { - this.userId = userId; + this.user = user; this.learning = learning; this.learningStep = learningStep; this.score = score; this.lastStudiedAt = lastStudiedAt != null ? lastStudiedAt : LocalDateTime.now(); } - public static UserLearningProgress create(Long userId, Learning learning, LearningStep learningStep) { + public static UserLearningProgress create(User user, Learning learning, LearningStep learningStep) { validateLearningAndStep(learning, learningStep); return UserLearningProgress.builder() - .userId(userId) + .user(user) .learning(learning) .learningStep(learningStep) .lastStudiedAt(LocalDateTime.now()) @@ -98,4 +100,12 @@ public void updateProgress(Integer score, LocalDateTime lastStudiedAt) { this.score = score; this.lastStudiedAt = lastStudiedAt != null ? lastStudiedAt : LocalDateTime.now(); } + + // 점수 기준 학습 진행 상태 파악 + public String getLearningStatus() { + if (this.score == null) { + return "NOT_STARTED"; + } + return this.score >= 90 ? "COMPLETED" : "RETRY"; + } } diff --git a/src/main/java/com/mr/domain/learning/exception/LearningErrorStatus.java b/src/main/java/com/mr/domain/learning/exception/LearningErrorStatus.java index 7c793b62..b728bdbf 100644 --- a/src/main/java/com/mr/domain/learning/exception/LearningErrorStatus.java +++ b/src/main/java/com/mr/domain/learning/exception/LearningErrorStatus.java @@ -9,7 +9,8 @@ @AllArgsConstructor public enum LearningErrorStatus implements BaseCode { - INVALID_LEARNING_STEP(HttpStatus.BAD_REQUEST, "LEARNING_400_01", "해당 학습 단계는 지칭한 학습(Learning)에 속하지 않습니다."); + INVALID_LEARNING_STEP(HttpStatus.BAD_REQUEST, "LEARNING_400_01", "해당 학습 단계는 지칭한 학습(Learning)에 속하지 않습니다."), + LEARNING_NOT_FOUND(HttpStatus.NOT_FOUND, "LEARNING_404_01", "해당 학습(Learning)을 찾을 수 없습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/domain/learning/repository/LearningRepository.java b/src/main/java/com/mr/domain/learning/repository/LearningRepository.java new file mode 100644 index 00000000..d26d6217 --- /dev/null +++ b/src/main/java/com/mr/domain/learning/repository/LearningRepository.java @@ -0,0 +1,7 @@ +package com.mr.domain.learning.repository; + +import com.mr.domain.learning.entity.Learning; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LearningRepository extends JpaRepository { +} diff --git a/src/main/java/com/mr/domain/learning/repository/LearningStepRepository.java b/src/main/java/com/mr/domain/learning/repository/LearningStepRepository.java new file mode 100644 index 00000000..91782968 --- /dev/null +++ b/src/main/java/com/mr/domain/learning/repository/LearningStepRepository.java @@ -0,0 +1,9 @@ +package com.mr.domain.learning.repository; + +import com.mr.domain.learning.entity.LearningStep; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LearningStepRepository extends JpaRepository { + // 특정 학습에 속한 전체 단계 수 조회 + long countByLearningId(Long learningId); +} diff --git a/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java b/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java new file mode 100644 index 00000000..8e8f5e63 --- /dev/null +++ b/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java @@ -0,0 +1,18 @@ +package com.mr.domain.learning.repository; + +import com.mr.domain.learning.entity.UserLearningProgress; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface UserLearningProgressRepository extends JpaRepository { + + Optional findByUserIdAndLearningId(Long userId, Long learningId); + // 유저가 완료한(점수 80점 이상) 학습 단계 수 조회 + @Query("SELECT COUNT(ulp) FROM UserLearningProgress ulp " + + "JOIN ulp.learningStep ls " + + "WHERE ulp.user.id = :userId AND ls.learning.id = :learningId AND ulp.score >= 90") + long countCompletedStepsByUserIdAndLearningId(@Param("userId") Long userId, @Param("learningId") Long learningId); +} diff --git a/src/main/java/com/mr/domain/learning/service/LearningService.java b/src/main/java/com/mr/domain/learning/service/LearningService.java new file mode 100644 index 00000000..dd2d374a --- /dev/null +++ b/src/main/java/com/mr/domain/learning/service/LearningService.java @@ -0,0 +1,82 @@ +package com.mr.domain.learning.service; + +import com.mr.domain.learning.dto.req.LearningResultSaveRequestDTO; +import com.mr.domain.learning.dto.res.LearningProgressResponseDTO; +import com.mr.domain.learning.dto.res.LearningResultResponseDTO; +import com.mr.domain.learning.entity.Learning; +import com.mr.domain.learning.entity.UserLearningProgress; +import com.mr.domain.learning.exception.LearningErrorStatus; +import com.mr.domain.learning.repository.LearningRepository; +import com.mr.domain.learning.repository.LearningStepRepository; +import com.mr.domain.learning.repository.UserLearningProgressRepository; +import com.mr.domain.user.entity.User; +import com.mr.domain.user.exception.UserErrorStatus; +import com.mr.domain.user.repository.UserRepository; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.security.SecurityUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LearningService { + + private final UserLearningProgressRepository userLearningProgressRepository; + private final LearningStepRepository learningStepRepository; + private final LearningRepository learningRepository; + // 임시 작명 + private final UserRepository userRepository; + + // 학습 결과 저장 + public LearningResultResponseDTO.SaveResultResultDTO saveResult( + Long learningId, + LearningResultSaveRequestDTO.SaveResultDTO request + ){ + Long userId = SecurityUtil.getCurrentUserId(); + + User user = userRepository.findById(userId) + .orElseThrow(()-> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); + + Learning learning = learningRepository.findById(learningId) + .orElseThrow(() -> new GeneralException(LearningErrorStatus.LEARNING_NOT_FOUND)); + + UserLearningProgress progress = userLearningProgressRepository + .findByUserIdAndLearningId(userId, learning.getId()) + .map(p -> { + p.updateProgress(request.score(), LocalDateTime.now()); + return p; + }) + .orElseGet(() -> { + UserLearningProgress newProgress = UserLearningProgress.create(user, learning, null); + newProgress.updateProgress(request.score(), LocalDateTime.now()); + return userLearningProgressRepository.save(newProgress); + }); + return LearningResultResponseDTO.SaveResultResultDTO.from(progress); + } + + // 학습 진행률 조회 로직 + public LearningProgressResponseDTO.ProgressResultDTO getLearningProgress(Long learningId) { + // 학습 존재 여부 확인 + if (!learningRepository.existsById(learningId)) { + throw new GeneralException(LearningErrorStatus.LEARNING_NOT_FOUND); + } + + // 현재 로그인한 유저 ID 획득 (SecurityUtil 활용) + Long userId = SecurityUtil.getCurrentUserId(); + + // 전체 학습 단계 수 조회 + long totalStepCount = learningStepRepository.countByLearningId(learningId); + + // 완료한 학습 단계 수 조회 + long completedStepCount = userLearningProgressRepository.countCompletedStepsByUserIdAndLearningId(userId, learningId); + + // 진행률 계산 (0으로 나누기 예외 방지) + int progressRate = totalStepCount == 0 ? 0 : (int) Math.round((double) completedStepCount / totalStepCount * 100); + + return LearningProgressResponseDTO.ProgressResultDTO.of(learningId, progressRate); + } +} diff --git a/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java b/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java index 4ac855ef..1c752e35 100644 --- a/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java @@ -11,7 +11,7 @@ public enum UserErrorStatus implements BaseCode { NICKNAME_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_01", "닉네임은 필수입니다."), NICKNAME_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "USER_400_02", "닉네임은 한글, 영어, 숫자 2~10자로 입력해야 합니다."), - PROFILE_IMAGE_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_03", "프로필 이미지 URL은 필수입니다."); + private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/domain/user/repository/UserRepository.java b/src/main/java/com/mr/domain/user/repository/UserRepository.java new file mode 100644 index 00000000..5826f092 --- /dev/null +++ b/src/main/java/com/mr/domain/user/repository/UserRepository.java @@ -0,0 +1,7 @@ +package com.mr.domain.user.repository; + +import com.mr.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { +}