diff --git a/src/main/java/com/gdschongik/gdsc/domain/studyv2/api/StudentAssignmentHistoryControllerV2.java b/src/main/java/com/gdschongik/gdsc/domain/studyv2/api/StudentAssignmentHistoryControllerV2.java new file mode 100644 index 000000000..cfca52081 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/studyv2/api/StudentAssignmentHistoryControllerV2.java @@ -0,0 +1,27 @@ +package com.gdschongik.gdsc.domain.studyv2.api; + +import com.gdschongik.gdsc.domain.studyv2.application.StudentAssignmentHistoryServiceV2; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Student Assignment History V2", description = "학생 과제 제출이력 API입니다.") +@RestController +@RequestMapping("/v2/assignment-histories") +@RequiredArgsConstructor +public class StudentAssignmentHistoryControllerV2 { + + private final StudentAssignmentHistoryServiceV2 studentAssignmentHistoryServiceV2; + + @Operation(summary = "내 과제 제출하기", description = "나의 과제를 제출합니다. 제출된 과제는 채점되어 제출내역에 반영됩니다.") + @PostMapping("/submit") + public ResponseEntity submitMyAssignment(@RequestParam(name = "studySessionId") Long studySessionId) { + studentAssignmentHistoryServiceV2.submitMyAssignment(studySessionId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/studyv2/application/StudentAssignmentHistoryServiceV2.java b/src/main/java/com/gdschongik/gdsc/domain/studyv2/application/StudentAssignmentHistoryServiceV2.java new file mode 100644 index 000000000..9e840fd20 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/studyv2/application/StudentAssignmentHistoryServiceV2.java @@ -0,0 +1,77 @@ +package com.gdschongik.gdsc.domain.studyv2.application; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionFetcher; +import com.gdschongik.gdsc.domain.studyv2.dao.AssignmentHistoryV2Repository; +import com.gdschongik.gdsc.domain.studyv2.dao.StudyHistoryV2Repository; +import com.gdschongik.gdsc.domain.studyv2.dao.StudyV2Repository; +import com.gdschongik.gdsc.domain.studyv2.domain.AssignmentHistoryGraderV2; +import com.gdschongik.gdsc.domain.studyv2.domain.AssignmentHistoryV2; +import com.gdschongik.gdsc.domain.studyv2.domain.AssignmentHistoryValidatorV2; +import com.gdschongik.gdsc.domain.studyv2.domain.StudyHistoryV2; +import com.gdschongik.gdsc.domain.studyv2.domain.StudySessionV2; +import com.gdschongik.gdsc.domain.studyv2.domain.StudyV2; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.util.MemberUtil; +import com.gdschongik.gdsc.infra.github.client.GithubClient; +import jakarta.transaction.Transactional; +import java.time.LocalDateTime; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StudentAssignmentHistoryServiceV2 { + + private final MemberUtil memberUtil; + private final GithubClient githubClient; + private final StudyV2Repository studyV2Repository; + private final StudyHistoryV2Repository studyHistoryV2Repository; + private final AssignmentHistoryV2Repository assignmentHistoryV2Repository; + private final AssignmentHistoryValidatorV2 assignmentHistoryValidatorV2; + private final AssignmentHistoryGraderV2 assignmentHistoryGraderV2; + + @Transactional + public void submitMyAssignment(Long studySessionId) { + Member currentMember = memberUtil.getCurrentMember(); + StudyV2 study = studyV2Repository + .findFetchBySessionId(studySessionId) + .orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); + Optional optionalStudyHistory = + studyHistoryV2Repository.findByStudentAndStudy(currentMember, study); + + boolean isAppliedToStudy = optionalStudyHistory.isPresent(); + StudySessionV2 studySession = study.getStudySession(studySessionId); + LocalDateTime now = LocalDateTime.now(); + + assignmentHistoryValidatorV2.validateSubmitAvailable(isAppliedToStudy, studySession, now); + + String repositoryLink = + optionalStudyHistory.map(StudyHistoryV2::getRepositoryLink).orElse(null); + AssignmentSubmissionFetcher fetcher = + githubClient.getLatestAssignmentSubmissionFetcher(repositoryLink, studySession.getPosition()); + AssignmentHistoryV2 assignmentHistory = findOrCreate(currentMember, studySession); + + assignmentHistoryGraderV2.judge(fetcher, assignmentHistory); + + assignmentHistoryV2Repository.save(assignmentHistory); + + log.info( + "[StudentAssignmentHistoryServiceV2] 과제 제출: studySessionId={}, studentId={}, submissionStatus={}, submissionFailureType={}", + studySessionId, + currentMember.getId(), + assignmentHistory.getSubmissionStatus(), + assignmentHistory.getSubmissionFailureType()); + } + + private AssignmentHistoryV2 findOrCreate(Member student, StudySessionV2 studySession) { + return assignmentHistoryV2Repository + .findByMemberAndStudySession(student, studySession) + .orElseGet(() -> AssignmentHistoryV2.create(studySession, student)); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/studyv2/dao/AssignmentHistoryV2Repository.java b/src/main/java/com/gdschongik/gdsc/domain/studyv2/dao/AssignmentHistoryV2Repository.java new file mode 100644 index 000000000..b09f38cc4 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/studyv2/dao/AssignmentHistoryV2Repository.java @@ -0,0 +1,11 @@ +package com.gdschongik.gdsc.domain.studyv2.dao; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.studyv2.domain.AssignmentHistoryV2; +import com.gdschongik.gdsc.domain.studyv2.domain.StudySessionV2; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AssignmentHistoryV2Repository extends JpaRepository { + Optional findByMemberAndStudySession(Member member, StudySessionV2 studySession); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/studyv2/dao/StudyV2CustomRepository.java b/src/main/java/com/gdschongik/gdsc/domain/studyv2/dao/StudyV2CustomRepository.java index f8a4b865d..fc37e64a4 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/studyv2/dao/StudyV2CustomRepository.java +++ b/src/main/java/com/gdschongik/gdsc/domain/studyv2/dao/StudyV2CustomRepository.java @@ -7,5 +7,7 @@ public interface StudyV2CustomRepository { Optional findFetchById(Long id); + Optional findFetchBySessionId(Long sessionId); + List findFetchAll(); } diff --git a/src/main/java/com/gdschongik/gdsc/domain/studyv2/dao/StudyV2RepositoryImpl.java b/src/main/java/com/gdschongik/gdsc/domain/studyv2/dao/StudyV2RepositoryImpl.java index f769c78f0..980b9e892 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/studyv2/dao/StudyV2RepositoryImpl.java +++ b/src/main/java/com/gdschongik/gdsc/domain/studyv2/dao/StudyV2RepositoryImpl.java @@ -23,6 +23,16 @@ public Optional findFetchById(Long id) { .fetchOne()); } + @Override + public Optional findFetchBySessionId(Long sessionId) { + return Optional.ofNullable(queryFactory + .selectFrom(studyV2) + .join(studyV2.studySessions) + .fetchJoin() + .where(studyV2.studySessions.any().id.eq(sessionId)) + .fetchOne()); + } + @Override public List findFetchAll() { return queryFactory diff --git a/src/main/java/com/gdschongik/gdsc/domain/studyv2/domain/AssignmentHistoryGraderV2.java b/src/main/java/com/gdschongik/gdsc/domain/studyv2/domain/AssignmentHistoryGraderV2.java new file mode 100644 index 000000000..fde750619 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/studyv2/domain/AssignmentHistoryGraderV2.java @@ -0,0 +1,55 @@ +package com.gdschongik.gdsc.domain.studyv2.domain; + +import static com.gdschongik.gdsc.domain.study.domain.SubmissionFailureType.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmission; +import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionFetcher; +import com.gdschongik.gdsc.domain.study.domain.SubmissionFailureType; +import com.gdschongik.gdsc.global.annotation.DomainService; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@DomainService +public class AssignmentHistoryGraderV2 { + + private static final int MINIMUM_ASSIGNMENT_CONTENT_LENGTH = 300; + + public void judge(AssignmentSubmissionFetcher assignmentSubmissionFetcher, AssignmentHistoryV2 assignmentHistory) { + try { + AssignmentSubmission assignmentSubmission = assignmentSubmissionFetcher.fetch(); + judgeAssignmentSubmission(assignmentSubmission, assignmentHistory); + } catch (CustomException e) { + SubmissionFailureType failureType = translateException(e); + assignmentHistory.fail(failureType); + } + } + + private void judgeAssignmentSubmission( + AssignmentSubmission assignmentSubmission, AssignmentHistoryV2 assignmentHistory) { + if (assignmentSubmission.contentLength() < MINIMUM_ASSIGNMENT_CONTENT_LENGTH) { + assignmentHistory.fail(WORD_COUNT_INSUFFICIENT); + return; + } + + assignmentHistory.success( + assignmentSubmission.url(), + assignmentSubmission.commitHash(), + assignmentSubmission.contentLength(), + assignmentSubmission.committedAt()); + } + + private SubmissionFailureType translateException(CustomException e) { + ErrorCode errorCode = e.getErrorCode(); + + if (errorCode == GITHUB_CONTENT_NOT_FOUND) { + return LOCATION_UNIDENTIFIABLE; + } + + log.warn("[AssignmentHistoryGrader] 과제 제출정보 조회 중 알 수 없는 오류 발생: {}", e.getMessage()); + + return UNKNOWN; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/studyv2/domain/AssignmentHistoryValidatorV2.java b/src/main/java/com/gdschongik/gdsc/domain/studyv2/domain/AssignmentHistoryValidatorV2.java new file mode 100644 index 000000000..49e791cd7 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/studyv2/domain/AssignmentHistoryValidatorV2.java @@ -0,0 +1,22 @@ +package com.gdschongik.gdsc.domain.studyv2.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.global.annotation.DomainService; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.time.LocalDateTime; + +@DomainService +public class AssignmentHistoryValidatorV2 { + + /** + * 채점을 수행하기 전, 과제 제출이 가능한지 검증합니다. + */ + public void validateSubmitAvailable(boolean isAppliedToStudy, StudySessionV2 studySession, LocalDateTime now) { + if (!isAppliedToStudy) { + throw new CustomException(ASSIGNMENT_STUDY_NOT_APPLIED); + } + + studySession.validateAssignmentSubmittable(now); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/studyv2/domain/StudySessionV2.java b/src/main/java/com/gdschongik/gdsc/domain/studyv2/domain/StudySessionV2.java index c5bac9fb8..308d4b657 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/studyv2/domain/StudySessionV2.java +++ b/src/main/java/com/gdschongik/gdsc/domain/studyv2/domain/StudySessionV2.java @@ -16,6 +16,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -107,6 +108,22 @@ public static void createEmptyForAssignment(Integer position, StudyV2 study) { StudySessionV2.builder().position(position).study(study).build(); } + // 데이터 전달 로직 + + public void validateAssignmentSubmittable(LocalDateTime now) { + if (assignmentPeriod == null) { + throw new CustomException(ASSIGNMENT_SUBMIT_NOT_PUBLISHED); + } + + if (now.isBefore(assignmentPeriod.getStartDate())) { + throw new CustomException(ASSIGNMENT_SUBMIT_NOT_STARTED); + } + + if (now.isAfter(assignmentPeriod.getEndDate())) { + throw new CustomException(ASSIGNMENT_SUBMIT_DEADLINE_PASSED); + } + } + // 데이터 변경 로직 public void update(StudyUpdateCommand.Session command) { diff --git a/src/main/java/com/gdschongik/gdsc/domain/studyv2/domain/StudyV2.java b/src/main/java/com/gdschongik/gdsc/domain/studyv2/domain/StudyV2.java index 3d9a4e52f..3bce187e4 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/studyv2/domain/StudyV2.java +++ b/src/main/java/com/gdschongik/gdsc/domain/studyv2/domain/StudyV2.java @@ -29,6 +29,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Optional; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -197,6 +198,18 @@ public static StudyV2 createAssignment( .build(); } + // 데이터 조회 로직 + + public Optional getOptionalStudySession(Long studySessionId) { + return studySessions.stream() + .filter(session -> session.getId().equals(studySessionId)) + .findFirst(); + } + + public StudySessionV2 getStudySession(Long studySessionId) { + return getOptionalStudySession(studySessionId).orElseThrow(() -> new CustomException(STUDY_SESSION_NOT_FOUND)); + } + // 데이터 변경 로직 public void update(StudyUpdateCommand command) { @@ -208,16 +221,14 @@ public void update(StudyUpdateCommand command) { this.endTime = command.endTime(); command.studySessions().forEach(sessionCommand -> { - getStudySession(sessionCommand.studySessionId()).update(sessionCommand); + getStudySessionForUpdate(sessionCommand.studySessionId()).update(sessionCommand); }); validateLessonTimeOrderMatchesPosition(); } - private StudySessionV2 getStudySession(Long studySessionId) { - return studySessions.stream() - .filter(session -> session.getId().equals(studySessionId)) - .findFirst() + private StudySessionV2 getStudySessionForUpdate(Long studySessionId) { + return getOptionalStudySession(studySessionId) .orElseThrow(() -> new CustomException(STUDY_NOT_UPDATABLE_SESSION_NOT_FOUND)); } diff --git a/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java b/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java index 02f40bb4d..f7ff29601 100644 --- a/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java +++ b/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java @@ -105,6 +105,7 @@ public enum ErrorCode { STUDY_TIME_INVALID(HttpStatus.CONFLICT, "스터디종료 시각이 스터디시작 시각보다 빠릅니다."), ASSIGNMENT_STUDY_CAN_NOT_INPUT_STUDY_TIME(HttpStatus.CONFLICT, "과제 스터디는 스터디 시간을 입력할 수 없습니다."), STUDY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 스터디입니다."), + STUDY_SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 스터디 회차입니다."), STUDY_NOT_APPLICABLE(HttpStatus.CONFLICT, "스터디 신청기간이 아닙니다."), STUDY_NOT_CANCELABLE_APPLICATION_PERIOD(HttpStatus.CONFLICT, "스터디 신청기간이 아니라면 취소할 수 없습니다."), STUDY_NOT_CREATABLE_NOT_LIVE(HttpStatus.INTERNAL_SERVER_ERROR, "온라인 및 오프라인 타입만 라이브 스터디로 생성할 수 있습니다."),