Skip to content

Commit

Permalink
[REFACTOR] Service 계층 책임 분리 및 Validator 구현 (#162)
Browse files Browse the repository at this point in the history
- 각 서비스(ExamService, ExamStatusService, ExamResultService, ExamSubmissionService)의 책임을 명확히 분리하고, 관련된 로직을 전담하도록 수정
- GradingService에서 점수 계산 로직을 ScoreCalculator로 위임, 관련 메서드(calculateAverageScore, calculateScoreStatistics)를 ScoreCalculator에서 처리하도록 변경
- ExamStatistics 클래스에 생성자를 추가하여 발생한 오류 해결
- 중복된 엔드포인트 및 불필요한 로직 제거 (ExamSubmissionController, GradingController)
- 서비스 간 순환 참조 문제 해결을 위해 findExpiredExams 메서드를 ExamStatusService로 이동
- 각 서비스의 역할을 명확히 분리 (ExamService: CRUD, ExamQuestionService: 문제 관련, ExamResultService: 결과 조회 및 통계, ExamStatusService: 상태 변경, ExamSubmissionService: 제출 처리)
- GradingService를 ExamGradingService로 이름 변경하여 채점 기능을 명확히 표현하고, AutoGradingScheduler를 ExamScheduler로 이름 변경하여 시험 스케줄링 의도를 직관적으로 표현
- 유효성 검사 로직을 기능별 Validator로 분리 (ExamBaseValidator, ExamQuestionValidator, ExamGradingValidator, ExamStatusValidator, ExamSubmissionValidator, ExamTimeValidator)
- 권한 검증을 checkInstructorAccess에서 isInstructor로 변경
- 중복된 ErrorCode를 정리하고, 코드 일관성을 유지하며 재분류
- CourseAccessValidator 내 검증 로직 최적화 및 ExamSubmissionController의 검증 로직을 Validator 클래스를 통해 개선
  • Loading branch information
hyerin315 authored Jan 2, 2025
1 parent 40352a3 commit 13961df
Show file tree
Hide file tree
Showing 42 changed files with 1,262 additions and 877 deletions.
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
package com.example.epari.assignment.controller;

import java.util.List;

import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.epari.assignment.dto.submission.GradeRequestDto;
import com.example.epari.assignment.dto.submission.SubmissionRequestDto;
import com.example.epari.assignment.dto.submission.SubmissionResponseDto;
import com.example.epari.assignment.service.SubmissionService;
import com.example.epari.course.repository.CourseRepository;
import com.example.epari.global.annotation.CurrentUserEmail;
import com.example.epari.global.exception.BusinessBaseException;
import com.example.epari.global.exception.ErrorCode;
import com.example.epari.global.validator.CourseAccessValidator;
import com.example.epari.user.domain.Instructor;
import com.example.epari.user.domain.Student;
import com.example.epari.user.repository.InstructorRepository;
import com.example.epari.user.repository.StudentRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
Expand All @@ -25,112 +39,87 @@ public class SubmissionController {

private final StudentRepository studentRepository;

private final CourseRepository courseRepository;
private final InstructorRepository instructorRepository;

private final CourseAccessValidator courseAccessValidator;

@PreAuthorize("hasRole('STUDENT')")
@PostMapping
public ResponseEntity<SubmissionResponseDto> addSubmission(
@PathVariable Long courseId,
@PathVariable Long assignmentId,
@ModelAttribute SubmissionRequestDto requestDto,
public ResponseEntity<SubmissionResponseDto> addSubmission(@PathVariable Long courseId,
@PathVariable Long assignmentId, @ModelAttribute SubmissionRequestDto requestDto,
@CurrentUserEmail String email) {
Student student = studentRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("학생 정보를 찾을 수 없습니다."));

return ResponseEntity.ok(
submissionService.addSubmission(courseId, assignmentId, requestDto, student.getId())
);
return ResponseEntity.ok(submissionService.addSubmission(courseId, assignmentId, requestDto, student.getId()));
}

@PreAuthorize("hasRole('STUDENT')")
@GetMapping("/{submissionId}")
public ResponseEntity<SubmissionResponseDto> getSubmission(
@PathVariable Long courseId,
@PathVariable Long assignmentId,
@PathVariable Long submissionId) {
return ResponseEntity.ok(
submissionService.getSubmissionById(courseId, assignmentId, submissionId)
);
public ResponseEntity<SubmissionResponseDto> getSubmission(@PathVariable Long courseId,
@PathVariable Long assignmentId, @PathVariable Long submissionId) {
return ResponseEntity.ok(submissionService.getSubmissionById(courseId, assignmentId, submissionId));
}

@PreAuthorize("hasRole('INSTRUCTOR')")
@GetMapping("/list")
public ResponseEntity<List<SubmissionResponseDto>> getAllSubmissions(
@PathVariable Long courseId,
@PathVariable Long assignmentId,
@CurrentUserEmail String email) { // 강사 권한 검증을 위해 추가
public ResponseEntity<List<SubmissionResponseDto>> getAllSubmissions(@PathVariable Long courseId,
@PathVariable Long assignmentId, @CurrentUserEmail String email) {

// 강사가 해당 강의의 담당자인지 확인
if (!courseRepository.existsByCourseIdAndInstructorEmail(courseId, email)) {
throw new IllegalArgumentException("해당 강의의 조회 권한이 없습니다.");
}
// email -> id 변환 후 권한 검증
Instructor instructor = instructorRepository.findByEmail(email)
.orElseThrow(() -> new BusinessBaseException(ErrorCode.INSTRUCTOR_NOT_FOUND));

// CourseAccessValidator를 통한 권한 검증
courseAccessValidator.validateInstructorAccess(courseId, instructor.getId());

List<SubmissionResponseDto> submissions = submissionService.getSubmissionsWithStudents(courseId, assignmentId);
return ResponseEntity.ok(submissions);
}

@PreAuthorize("hasRole('STUDENT')")
@GetMapping
public ResponseEntity<SubmissionResponseDto> getStudentSubmission(
@PathVariable Long courseId,
@PathVariable Long assignmentId,
@CurrentUserEmail String email) {
public ResponseEntity<SubmissionResponseDto> getStudentSubmission(@PathVariable Long courseId,
@PathVariable Long assignmentId, @CurrentUserEmail String email) {
Student student = studentRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("학생 정보를 찾을 수 없습니다."));

SubmissionResponseDto submission = submissionService.getStudentSubmission(
courseId, assignmentId, student.getId());
SubmissionResponseDto submission = submissionService.getStudentSubmission(courseId, assignmentId,
student.getId());

// 제출물이 없는 경우 404 대신 200 OK와 null을 반환
return ResponseEntity.ok(submission);
}

@PreAuthorize("hasRole('STUDENT')")
@PutMapping("/{submissionId}")
public ResponseEntity<SubmissionResponseDto> updateSubmission(
@PathVariable Long courseId,
@PathVariable Long assignmentId,
@PathVariable Long submissionId,
@ModelAttribute SubmissionRequestDto requestDto,
@CurrentUserEmail String email) {
public ResponseEntity<SubmissionResponseDto> updateSubmission(@PathVariable Long courseId,
@PathVariable Long assignmentId, @PathVariable Long submissionId,
@ModelAttribute SubmissionRequestDto requestDto, @CurrentUserEmail String email) {
Student student = studentRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("학생 정보를 찾을 수 없습니다."));

return ResponseEntity.ok(
submissionService.updateSubmission(courseId, assignmentId, submissionId,
requestDto, student.getId())
);
submissionService.updateSubmission(courseId, assignmentId, submissionId, requestDto, student.getId()));
}

@PreAuthorize("hasRole('INSTRUCTOR')")
@PutMapping("/{submissionId}/grade")
public ResponseEntity<SubmissionResponseDto> gradeSubmission(
@PathVariable Long courseId,
@PathVariable Long assignmentId,
@PathVariable Long submissionId,
@CurrentUserEmail String email,
public ResponseEntity<SubmissionResponseDto> gradeSubmission(@PathVariable Long courseId,
@PathVariable Long assignmentId, @PathVariable Long submissionId, @CurrentUserEmail String email,
@RequestBody GradeRequestDto gradeRequestDto) {
return ResponseEntity.ok(
submissionService.gradeSubmission(
submissionId,
gradeRequestDto,
email)
);
return ResponseEntity.ok(submissionService.gradeSubmission(submissionId, gradeRequestDto, email));
}

@PreAuthorize("hasRole('STUDENT')")
@DeleteMapping("/{submissionId}")
public ResponseEntity<Void> deleteSubmission(
@PathVariable Long courseId,
@PathVariable Long assignmentId,
@PathVariable Long submissionId,
@CurrentUserEmail String email) {
public ResponseEntity<Void> deleteSubmission(@PathVariable Long courseId, @PathVariable Long assignmentId,
@PathVariable Long submissionId, @CurrentUserEmail String email) {
Student student = studentRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("학생 정보를 찾을 수 없습니다."));

submissionService.deleteSubmission(courseId, assignmentId, submissionId, student.getId());
return ResponseEntity.ok().build();
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
import com.example.epari.course.dto.attendance.AttendanceUpdateDto;
import com.example.epari.course.service.AttendanceService;
import com.example.epari.global.annotation.CurrentUserEmail;
import com.example.epari.global.exception.BusinessBaseException;
import com.example.epari.global.exception.ErrorCode;
import com.example.epari.user.domain.Instructor;
import com.example.epari.user.repository.InstructorRepository;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
Expand All @@ -32,20 +36,19 @@ public class AttendanceController {

private final AttendanceService attendanceService;

private final InstructorRepository instructorRepository;

/**
* 특정 강의의 특정 날짜 출석 현황을 조회
*/
@GetMapping
public ResponseEntity<List<AttendanceResponseDto>> getAttendances(
@PathVariable Long courseId,
public ResponseEntity<List<AttendanceResponseDto>> getAttendances(@PathVariable Long courseId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@CurrentUserEmail String email
) {
List<AttendanceResponseDto> responses = attendanceService.getAttendances(
courseId,
email,
date
);
@CurrentUserEmail String email) {
Instructor instructor = instructorRepository.findByEmail(email)
.orElseThrow(() -> new BusinessBaseException(ErrorCode.INSTRUCTOR_NOT_FOUND));

List<AttendanceResponseDto> responses = attendanceService.getAttendances(courseId, instructor.getId(), date);

return ResponseEntity.ok(responses);
}
Expand All @@ -54,13 +57,13 @@ public ResponseEntity<List<AttendanceResponseDto>> getAttendances(
* 특정 날짜의 학생들 출석 상태를 일괄 수정
*/
@PatchMapping
public ResponseEntity<Void> updateAttendances(
@PathVariable Long courseId,
public ResponseEntity<Void> updateAttendances(@PathVariable Long courseId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestBody @Valid List<AttendanceUpdateDto> request,
@CurrentUserEmail String email
) {
attendanceService.updateAttendances(courseId, email, date, request);
@RequestBody @Valid List<AttendanceUpdateDto> request, @CurrentUserEmail String email) {
Instructor instructor = instructorRepository.findByEmail(email)
.orElseThrow(() -> new BusinessBaseException(ErrorCode.INSTRUCTOR_NOT_FOUND));

attendanceService.updateAttendances(courseId, instructor.getId(), date, request);

return ResponseEntity.ok().build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@
import com.example.epari.course.dto.attendance.AttendanceStatResponseDto;
import com.example.epari.course.service.AttendanceStatisticsService;
import com.example.epari.global.annotation.CurrentUserEmail;
import com.example.epari.global.exception.BusinessBaseException;
import com.example.epari.global.exception.ErrorCode;
import com.example.epari.user.domain.Instructor;
import com.example.epari.user.repository.InstructorRepository;

import lombok.RequiredArgsConstructor;

/**
* 설명: 강의별 학생 출석 통계를 조회하는 REST API 컨트롤러 구현
*/
*/

@RestController
@RequestMapping("/api/courses/{courseId}/stats")
Expand All @@ -26,14 +30,18 @@ public class AttendanceStatisticsController {

private final AttendanceStatisticsService attendanceStatisticsService;

private final InstructorRepository instructorRepository;

@GetMapping
@PreAuthorize("hasRole('INSTRUCTOR')")
public ResponseEntity<List<AttendanceStatResponseDto>> getStudentAttendanceStats(
@PathVariable Long courseId,
public ResponseEntity<List<AttendanceStatResponseDto>> getStudentAttendanceStats(@PathVariable Long courseId,
@CurrentUserEmail String email) {

List<AttendanceStatResponseDto> stats =
attendanceStatisticsService.getStudentAttendanceStats(courseId, email);
Instructor instructor = instructorRepository.findByEmail(email)
.orElseThrow(() -> new BusinessBaseException(ErrorCode.INSTRUCTOR_NOT_FOUND));

List<AttendanceStatResponseDto> stats = attendanceStatisticsService.getStudentAttendanceStats(courseId,
instructor.getId());
return ResponseEntity.ok(stats);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,35 +35,16 @@ public interface CourseRepository extends JpaRepository<Course, Long> {
List<Course> findAllByInstructorId(@Param("instructorId") Long instructorId);

/**
* 주어진 강의 ID와 강사 이메일로 해당 강의가 해당 강사의 강의인지 확인합니다.
* 강사의 강의 접근 권한 확인
*/
@Query("""
SELECT EXISTS (
SELECT 1
FROM Course c
WHERE c.id = :courseId
AND c.instructor.email = :instructorEmail
)
""")
boolean existsByCourseIdAndInstructorEmail(
@Param("courseId") Long courseId,
@Param("instructorEmail") String instructorEmail);
@Query("SELECT EXISTS (SELECT 1 FROM Course c WHERE c.id = :courseId AND c.instructor.id = :instructorId)")
boolean existsByCourseIdAndInstructorId(@Param("courseId") Long courseId, @Param("instructorId") Long instructorId);

/**
* 주어진 강의 ID와 학생 이메일로 해당 강의를 수강중인 학생인지 확인합니다.
* 학생의 강의 접근 권한 확인
*/
@Query("""
SELECT EXISTS (
SELECT 1
FROM Course c
JOIN CourseStudent cs ON c.id = cs.course.id
WHERE c.id = :courseId
AND cs.student.email = :studentEmail
)
""")
boolean existsByCourseIdAndStudentEmail(
@Param("courseId") Long courseId,
@Param("studentEmail") String studentEmail);
@Query("SELECT EXISTS (SELECT 1 FROM Course c JOIN c.courseStudents cs WHERE c.id = :courseId AND cs.student.id = :studentId)")
boolean existsByCourseIdAndStudentId(@Param("courseId") Long courseId, @Param("studentId") Long studentId);

@Query("SELECT c FROM Course c " +
"INNER JOIN FETCH c.instructor i " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ public class AttendanceService {
* 해당 날짜의 출석 데이터가 없는 경우, 수강생 전체의 출석 데이터를 새로 생성
*/
@Transactional
public List<AttendanceResponseDto> getAttendances(Long courseId, String instructorEmail, LocalDate date) {
courseAccessValidator.validateInstructorAccess(courseId, instructorEmail);
public List<AttendanceResponseDto> getAttendances(Long courseId, Long instructorId, LocalDate date) {
courseAccessValidator.validateInstructorAccess(courseId, instructorId);

List<Attendance> attendances = attendanceRepository.findAllByCourseIdAndDate(courseId, date);

Expand All @@ -57,12 +57,12 @@ public List<AttendanceResponseDto> getAttendances(Long courseId, String instruct
@Transactional
public void updateAttendances(
Long courseId,
String instructorEmail,
Long instructorId,
LocalDate date,
List<AttendanceUpdateDto> updates
) {
// 강사 권한 검증
courseAccessValidator.validateInstructorAccess(courseId, instructorEmail);
courseAccessValidator.validateInstructorAccess(courseId, instructorId);

// 수정할 학생 ID 목록 추출
List<Long> studentIds = updates.stream()
Expand Down
Loading

0 comments on commit 13961df

Please sign in to comment.