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
@@ -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<LearningResultResponseDTO.SaveResultResultDTO> 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<LearningProgressResponseDTO.ProgressResultDTO> getLearningProgress(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable Long learningId
) {
LearningProgressResponseDTO.ProgressResultDTO result = learningService.getLearningProgress(userDetails.getUserId(), learningId);
return ApiResponse.onSuccess(result);
Comment thread
rkdehdrbs7885-oss marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -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
) {}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
}
8 changes: 8 additions & 0 deletions src/main/java/com/mr/domain/learning/entity/LearningStep.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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())
Expand All @@ -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";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Learning, Long> {
}
Original file line number Diff line number Diff line change
@@ -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<LearningStep, Long> {
// 특정 학습에 속한 전체 단계 수 조회
long countByLearningId(Long learningId);
}
Original file line number Diff line number Diff line change
@@ -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<UserLearningProgress, Long> {

Optional<UserLearningProgress> 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);
}
86 changes: 86 additions & 0 deletions src/main/java/com/mr/domain/learning/service/LearningService.java
Original file line number Diff line number Diff line change
@@ -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(
Comment thread
rkdehdrbs7885-oss marked this conversation as resolved.
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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<User, Long> {
}
Loading