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..55746cdd --- /dev/null +++ b/src/main/java/com/mr/domain/learning/controller/LearningController.java @@ -0,0 +1,47 @@ +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 com.mr.global.security.principal.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long learningId, + @Valid @RequestBody LearningResultSaveRequestDTO.SaveResultDTO request + ){ + LearningResultResponseDTO.SaveResultResultDTO response = learningService.saveResult(userDetails.getUserId(), learningId, request); + + return ApiResponse.onSuccess(response); + } + + // 학습 진행률 조회 + @GetMapping("/{learningId}/progress") + public ApiResponse getLearningProgress( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long learningId + ) { + LearningProgressResponseDTO.ProgressResultDTO result = learningService.getLearningProgress(userDetails.getUserId(), 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..42bbd129 --- /dev/null +++ b/src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java @@ -0,0 +1,17 @@ +package com.mr.domain.learning.dto.req; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public class LearningResultSaveRequestDTO { + public record SaveResultDTO( + @NotNull(message = "점수는 필수 입력값입니다.") + @Min(value = 0, message = "점수는 0점 이상이어야 합니다.") + @Max(value = 100, message = "점수는 100점 이하여야 합니다.") + Integer score, + + @NotNull(message = "학습 스텝 ID는 필수 입력값입니다.") + Long learningStepId + ) {} +} 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..4c42e00c --- /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.getLastStudiedAt() + ); + } + } +} diff --git a/src/main/java/com/mr/domain/learning/entity/LearningStep.java b/src/main/java/com/mr/domain/learning/entity/LearningStep.java index 150b3ec6..8a2d6680 100644 --- a/src/main/java/com/mr/domain/learning/entity/LearningStep.java +++ b/src/main/java/com/mr/domain/learning/entity/LearningStep.java @@ -1,5 +1,7 @@ package com.mr.domain.learning.entity; +import com.mr.domain.learning.exception.LearningErrorStatus; +import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.entity.BaseCreatedEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -101,4 +103,10 @@ public void updateStepInfo(Integer stepNo, String title, String summary, this.estimatedMinutes = estimatedMinutes; } } + + public void validateBelongsTo(Learning targetLearning) { + if (this.learning == null || !this.learning.getId().equals(targetLearning.getId())) { + throw new GeneralException(LearningErrorStatus.INVALID_LEARNING_STEP); + } + } } 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..3dd23a11 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,9 @@ @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)을 찾을 수 없습니다."), + LEARNING_STEP_NOT_FOUND(HttpStatus.NOT_FOUND, "LEARNING_404_02", "해당 학습 단계(Step)를 찾을 수 없습니다."); 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..21d68f35 --- /dev/null +++ b/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java @@ -0,0 +1,17 @@ +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 findByUser_UserIdAndLearningStep_Id(Long userId, Long learningStepId); + // 유저가 완료한(점수 90점 이상) 학습 단계 수 조회 + @Query("SELECT COUNT(ulp) FROM UserLearningProgress ulp " + + "WHERE ulp.user.userId = :userId AND ulp.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..a203164a --- /dev/null +++ b/src/main/java/com/mr/domain/learning/service/LearningService.java @@ -0,0 +1,86 @@ +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.LearningStep; +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 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; + + // 학습 결과 저장 + @Transactional + public LearningResultResponseDTO.SaveResultResultDTO saveResult( + Long userId, + Long learningId, + LearningResultSaveRequestDTO.SaveResultDTO request + ){ + User user = userRepository.findById(userId) + .orElseThrow(()-> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); + + Learning learning = learningRepository.findById(learningId) + .orElseThrow(() -> new GeneralException(LearningErrorStatus.LEARNING_NOT_FOUND)); + + LearningStep learningStep = learningStepRepository.findById(request.learningStepId()) + .orElseThrow(() -> new GeneralException(LearningErrorStatus.LEARNING_STEP_NOT_FOUND)); + + learningStep.validateBelongsTo(learning); + + UserLearningProgress progress = userLearningProgressRepository + .findByUser_UserIdAndLearningStep_Id(userId, request.learningStepId()) + .map(p -> { + p.updateProgress(request.score(), LocalDateTime.now()); + return p; + }) + .orElseGet(() -> { + UserLearningProgress newProgress = UserLearningProgress.create(user, learning, learningStep); + newProgress.updateProgress(request.score(), LocalDateTime.now()); + return userLearningProgressRepository.save(newProgress); + }); + return LearningResultResponseDTO.SaveResultResultDTO.from(progress); + } + + // 학습 진행률 조회 로직 + public LearningProgressResponseDTO.ProgressResultDTO getLearningProgress( + Long userId, + Long learningId + ) { + // 학습 존재 여부 확인 + if (!learningRepository.existsById(learningId)) { + throw new GeneralException(LearningErrorStatus.LEARNING_NOT_FOUND); + } + // 전체 학습 단계 수 조회 + 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..3cca4c33 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,9 @@ 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은 필수입니다."); + PROFILE_IMAGE_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_03", "프로필 이미지 URL은 필수입니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_01", "존재하지 않는 사용자입니다."); + 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 { +}