Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 스터디 과제 제출하기 V2 API 구현 #938

Merged
merged 10 commits into from
Feb 27, 2025
Original file line number Diff line number Diff line change
@@ -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<Void> submitMyAssignment(@RequestParam(name = "studySessionId") Long studySessionId) {
studentAssignmentHistoryServiceV2.submitMyAssignment(studySessionId);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -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<StudyHistoryV2> 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));
}
}
Original file line number Diff line number Diff line change
@@ -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<AssignmentHistoryV2, Long> {
Optional<AssignmentHistoryV2> findByMemberAndStudySession(Member member, StudySessionV2 studySession);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@
public interface StudyV2CustomRepository {
Optional<StudyV2> findFetchById(Long id);

Optional<StudyV2> findFetchBySessionId(Long sessionId);

List<StudyV2> findFetchAll();
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ public Optional<StudyV2> findFetchById(Long id) {
.fetchOne());
}

@Override
public Optional<StudyV2> findFetchBySessionId(Long sessionId) {
return Optional.ofNullable(queryFactory
.selectFrom(studyV2)
.join(studyV2.studySessions)
.fetchJoin()
.where(studyV2.studySessions.any().id.eq(sessionId))
.fetchOne());
}

@Override
public List<StudyV2> findFetchAll() {
return queryFactory
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -196,6 +197,18 @@ public static StudyV2 createAssignment(
.build();
}

// 데이터 조회 로직

public Optional<StudySessionV2> 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) {
Expand All @@ -207,16 +220,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));
}
Comment on lines +229 to 232

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The exception message STUDY_NOT_UPDATABLE_SESSION_NOT_FOUND seems more appropriate for a scenario where the session cannot be updated. Consider using STUDY_SESSION_NOT_FOUND for consistency with other parts of the code.

.orElseThrow(() -> new CustomException(STUDY_SESSION_NOT_FOUND));


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "온라인 및 오프라인 타입만 라이브 스터디로 생성할 수 있습니다."),
Expand Down