From f12092ac654a86408e1463cd24bb5191ba099d83 Mon Sep 17 00:00:00 2001 From: Cho Sangwook <82208159+Sangwook02@users.noreply.github.com> Date: Fri, 11 Oct 2024 13:16:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9A=B0=EC=88=98=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94=EC=9B=90=20=EC=A7=80=EC=A0=95=20=EB=B0=8F=20=EC=B2=A0?= =?UTF-8?q?=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20(#800)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 우수 스터디원 지정 및 철회 API 구현 * feat: 우수 스터디원 지정 및 철회 로직 추가 * test: 우수 스터디원 지정 및 철회 테스트 추가 * feat: 수강신청 여부 검증 로직 추가 * test: 수강신청 여부 검증 로직 테스트 추가 * refactor: 레포지토리 메서드명 변경 * refactor: getById 메서드를 findById로 대체 * refactor: NPE 방지 코드 추가 * refactor: 타입 캐스팅으로 변경 --- .../api/MentorStudyAchievementController.java | 40 +++++++++ .../MentorStudyAchievementService.java | 74 +++++++++++++++++ .../dao/StudyAchievementCustomRepository.java | 4 + .../StudyAchievementCustomRepositoryImpl.java | 13 +++ .../dao/StudyHistoryCustomRepository.java | 8 ++ .../dao/StudyHistoryCustomRepositoryImpl.java | 27 ++++++ .../study/dao/StudyHistoryRepository.java | 2 +- .../study/domain/StudyHistoryValidator.java | 6 ++ .../request/OutstandingStudentRequest.java | 6 ++ .../gdsc/global/exception/ErrorCode.java | 1 + .../MentorStudyAchievementServiceTest.java | 82 +++++++++++++++++++ .../domain/StudyHistoryValidatorTest.java | 17 ++++ .../gdsc/helper/IntegrationTest.java | 21 +++++ 13 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyAchievementController.java create mode 100644 src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyAchievementService.java create mode 100644 src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryCustomRepository.java create mode 100644 src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryCustomRepositoryImpl.java create mode 100644 src/main/java/com/gdschongik/gdsc/domain/study/dto/request/OutstandingStudentRequest.java create mode 100644 src/test/java/com/gdschongik/gdsc/domain/study/application/MentorStudyAchievementServiceTest.java diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyAchievementController.java b/src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyAchievementController.java new file mode 100644 index 000000000..6b1f26455 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyAchievementController.java @@ -0,0 +1,40 @@ +package com.gdschongik.gdsc.domain.study.api; + +import com.gdschongik.gdsc.domain.study.application.MentorStudyAchievementService; +import com.gdschongik.gdsc.domain.study.dto.request.OutstandingStudentRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Mentor StudyAchievement", description = "멘토 스터디 우수 스터디원 관리 API입니다.") +@RestController +@RequestMapping("/mentor/study-achievements") +@RequiredArgsConstructor +public class MentorStudyAchievementController { + + private final MentorStudyAchievementService mentorStudyAchievementService; + + @Operation(summary = "우수 스터디원 지정", description = "우수 스터디원으로 지정합니다.") + @PostMapping + public ResponseEntity designateOutstandingStudent( + @RequestParam(name = "studyId") Long studyId, @Valid @RequestBody OutstandingStudentRequest request) { + mentorStudyAchievementService.designateOutstandingStudent(studyId, request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "우수 스터디원 철회", description = "우수 스터디원 지정을 철회합니다.") + @DeleteMapping + public ResponseEntity withdrawOutstandingStudent( + @RequestParam(name = "studyId") Long studyId, @Valid @RequestBody OutstandingStudentRequest request) { + mentorStudyAchievementService.withdrawOutstandingStudent(studyId, request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyAchievementService.java b/src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyAchievementService.java new file mode 100644 index 000000000..c639129a5 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyAchievementService.java @@ -0,0 +1,74 @@ +package com.gdschongik.gdsc.domain.study.application; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.member.dao.MemberRepository; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.dao.StudyAchievementRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyHistoryRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyRepository; +import com.gdschongik.gdsc.domain.study.domain.Study; +import com.gdschongik.gdsc.domain.study.domain.StudyAchievement; +import com.gdschongik.gdsc.domain.study.domain.StudyHistoryValidator; +import com.gdschongik.gdsc.domain.study.domain.StudyValidator; +import com.gdschongik.gdsc.domain.study.dto.request.OutstandingStudentRequest; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.util.MemberUtil; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MentorStudyAchievementService { + + private final MemberUtil memberUtil; + private final StudyValidator studyValidator; + private final StudyHistoryValidator studyHistoryValidator; + private final StudyRepository studyRepository; + private final StudyHistoryRepository studyHistoryRepository; + private final StudyAchievementRepository studyAchievementRepository; + private final MemberRepository memberRepository; + + @Transactional + public void designateOutstandingStudent(Long studyId, OutstandingStudentRequest request) { + Member currentMember = memberUtil.getCurrentMember(); + Study study = studyRepository.findById(studyId).orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); + Long countByStudyIdAndStudentIds = + studyHistoryRepository.countByStudyIdAndStudentIds(studyId, request.studentIds()); + + studyValidator.validateStudyMentor(currentMember, study); + studyHistoryValidator.validateAppliedToStudy( + countByStudyIdAndStudentIds, request.studentIds().size()); + + List outstandingStudents = memberRepository.findAllById(request.studentIds()); + List studyAchievements = outstandingStudents.stream() + .map(member -> StudyAchievement.create(member, study, request.achievementType())) + .toList(); + studyAchievementRepository.saveAll(studyAchievements); + + log.info( + "[MentorStudyAchievementService] 우수 스터디원 지정: studyId={}, studentIds={}", studyId, request.studentIds()); + } + + @Transactional + public void withdrawOutstandingStudent(Long studyId, OutstandingStudentRequest request) { + Member currentMember = memberUtil.getCurrentMember(); + Study study = studyRepository.findById(studyId).orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); + long countByStudyIdAndStudentIds = + studyHistoryRepository.countByStudyIdAndStudentIds(studyId, request.studentIds()); + + studyValidator.validateStudyMentor(currentMember, study); + studyHistoryValidator.validateAppliedToStudy( + countByStudyIdAndStudentIds, request.studentIds().size()); + + studyAchievementRepository.deleteByStudyAndAchievementTypeAndMemberIds( + studyId, request.achievementType(), request.studentIds()); + + log.info( + "[MentorStudyAchievementService] 우수 스터디원 철회: studyId={}, studentIds={}", studyId, request.studentIds()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyAchievementCustomRepository.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyAchievementCustomRepository.java index 2b14404f6..a6ad466ec 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyAchievementCustomRepository.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyAchievementCustomRepository.java @@ -1,8 +1,12 @@ package com.gdschongik.gdsc.domain.study.dao; +import com.gdschongik.gdsc.domain.study.domain.AchievementType; import com.gdschongik.gdsc.domain.study.domain.StudyAchievement; import java.util.List; public interface StudyAchievementCustomRepository { List findByStudyIdAndMemberIds(Long studyId, List memberIds); + + void deleteByStudyAndAchievementTypeAndMemberIds( + Long studyId, AchievementType achievementType, List memberIds); } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyAchievementCustomRepositoryImpl.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyAchievementCustomRepositoryImpl.java index 8eb3a9f46..0f8945573 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyAchievementCustomRepositoryImpl.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyAchievementCustomRepositoryImpl.java @@ -2,6 +2,7 @@ import static com.gdschongik.gdsc.domain.study.domain.QStudyAchievement.*; +import com.gdschongik.gdsc.domain.study.domain.AchievementType; import com.gdschongik.gdsc.domain.study.domain.StudyAchievement; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -21,6 +22,18 @@ public List findByStudyIdAndMemberIds(Long studyId, List .fetch(); } + @Override + public void deleteByStudyAndAchievementTypeAndMemberIds( + Long studyId, AchievementType achievementType, List memberIds) { + queryFactory + .delete(studyAchievement) + .where( + eqStudyId(studyId), + studyAchievement.achievementType.eq(achievementType), + studyAchievement.student.id.in(memberIds)) + .execute(); + } + private BooleanExpression eqStudyId(Long studyId) { return studyId != null ? studyAchievement.study.id.eq(studyId) : null; } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryCustomRepository.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryCustomRepository.java new file mode 100644 index 000000000..f64f7be6a --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryCustomRepository.java @@ -0,0 +1,8 @@ +package com.gdschongik.gdsc.domain.study.dao; + +import java.util.List; + +public interface StudyHistoryCustomRepository { + + long countByStudyIdAndStudentIds(Long studyId, List studentIds); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryCustomRepositoryImpl.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryCustomRepositoryImpl.java new file mode 100644 index 000000000..32232e65c --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryCustomRepositoryImpl.java @@ -0,0 +1,27 @@ +package com.gdschongik.gdsc.domain.study.dao; + +import static com.gdschongik.gdsc.domain.study.domain.QStudyHistory.*; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class StudyHistoryCustomRepositoryImpl implements StudyHistoryCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public long countByStudyIdAndStudentIds(Long studyId, List studentIds) { + return (long) queryFactory + .select(studyHistory.count()) + .from(studyHistory) + .where(eqStudyId(studyId), studyHistory.student.id.in(studentIds)) + .fetchOne(); + } + + private BooleanExpression eqStudyId(Long studyId) { + return studyHistory.study.id.eq(studyId); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryRepository.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryRepository.java index 02b61fceb..6e2dbb2cf 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryRepository.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryRepository.java @@ -9,7 +9,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -public interface StudyHistoryRepository extends JpaRepository { +public interface StudyHistoryRepository extends JpaRepository, StudyHistoryCustomRepository { List findAllByStudent(Member member); diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidator.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidator.java index 2376df7b4..6a21b0f44 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidator.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidator.java @@ -51,4 +51,10 @@ public void validateUpdateRepository( throw new CustomException(STUDY_HISTORY_REPOSITORY_NOT_UPDATABLE_OWNER_MISMATCH); } } + + public void validateAppliedToStudy(long countStudyHistory, int studentCount) { + if (countStudyHistory != studentCount) { + throw new CustomException(STUDY_HISTORY_NOT_APPLIED_STUDENT_EXISTS); + } + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/OutstandingStudentRequest.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/OutstandingStudentRequest.java new file mode 100644 index 000000000..771d09eef --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/OutstandingStudentRequest.java @@ -0,0 +1,6 @@ +package com.gdschongik.gdsc.domain.study.dto.request; + +import com.gdschongik.gdsc.domain.study.domain.AchievementType; +import java.util.List; + +public record OutstandingStudentRequest(List studentIds, AchievementType achievementType) {} 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 76ac38309..e0f5ac309 100644 --- a/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java +++ b/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java @@ -124,6 +124,7 @@ public enum ErrorCode { STUDY_HISTORY_REPOSITORY_NOT_UPDATABLE_ASSIGNMENT_ALREADY_SUBMITTED( HttpStatus.CONFLICT, "이미 제출한 과제가 있으므로 레포지토리를 수정할 수 없습니다."), STUDY_HISTORY_REPOSITORY_NOT_UPDATABLE_OWNER_MISMATCH(HttpStatus.CONFLICT, "레포지토리 소유자가 현재 멤버와 다릅니다."), + STUDY_HISTORY_NOT_APPLIED_STUDENT_EXISTS(HttpStatus.CONFLICT, "해당 스터디에 신청하지 않은 멤버가 있습니다."), // StudyAnnouncement STUDY_ANNOUNCEMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 스터디 공지입니다."), diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/application/MentorStudyAchievementServiceTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/application/MentorStudyAchievementServiceTest.java new file mode 100644 index 000000000..6fea5b242 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/study/application/MentorStudyAchievementServiceTest.java @@ -0,0 +1,82 @@ +package com.gdschongik.gdsc.domain.study.application; + +import static com.gdschongik.gdsc.domain.study.domain.AchievementType.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.domain.study.domain.Study; +import com.gdschongik.gdsc.domain.study.domain.StudyAchievement; +import com.gdschongik.gdsc.domain.study.dto.request.OutstandingStudentRequest; +import com.gdschongik.gdsc.helper.IntegrationTest; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +public class MentorStudyAchievementServiceTest extends IntegrationTest { + + @Autowired + private MentorStudyAchievementService mentorStudyAchievementService; + + @Nested + class 우수_스터디원_지정시 { + + @Test + void 성공한다() { + // given + LocalDateTime now = LocalDateTime.now(); + Member mentor = createMentor(); + Study study = createStudy( + mentor, + Period.createPeriod(now.plusDays(5), now.plusDays(10)), + Period.createPeriod(now.minusDays(5), now)); + + Member student = createRegularMember(); + createStudyHistory(student, study); + + logoutAndReloginAs(mentor.getId(), mentor.getRole()); + OutstandingStudentRequest request = + new OutstandingStudentRequest(List.of(student.getId()), FIRST_ROUND_OUTSTANDING_STUDENT); + + // when + mentorStudyAchievementService.designateOutstandingStudent(study.getId(), request); + + // then + List studyAchievements = + studyAchievementRepository.findByStudyIdAndMemberIds(study.getId(), request.studentIds()); + assertThat(studyAchievements).hasSize(request.studentIds().size()); + } + } + + @Nested + class 우수_스터디원_철회시 { + + @Test + void 성공한다() { + // given + Member student = createRegularMember(); + LocalDateTime now = LocalDateTime.now(); + Member mentor = createMentor(); + Study study = createStudy( + mentor, + Period.createPeriod(now.plusDays(5), now.plusDays(10)), + Period.createPeriod(now.minusDays(5), now)); + createStudyHistory(student, study); + createStudyAchievement(student, study, FIRST_ROUND_OUTSTANDING_STUDENT); + + logoutAndReloginAs(mentor.getId(), mentor.getRole()); + OutstandingStudentRequest request = + new OutstandingStudentRequest(List.of(student.getId()), FIRST_ROUND_OUTSTANDING_STUDENT); + + // when + mentorStudyAchievementService.withdrawOutstandingStudent(study.getId(), request); + + // then + List studyAchievements = + studyAchievementRepository.findByStudyIdAndMemberIds(study.getId(), request.studentIds()); + assertThat(studyAchievements).isEmpty(); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidatorTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidatorTest.java index 5e6dcb2f1..7ddc02778 100644 --- a/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidatorTest.java +++ b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidatorTest.java @@ -124,4 +124,21 @@ class 레포지토리_입력시 { .hasMessage(STUDY_HISTORY_REPOSITORY_NOT_UPDATABLE_OWNER_MISMATCH.getMessage()); } } + + @Nested + class 스터디_수강신청_여부_확인시 { + + @Test + void 해당_스터디를_신청하지_않은_멤버가_있다면_실패한다() { + // given + Long countStudyHistory = 1L; + int requestStudentCount = 2; + + // when & then + assertThatThrownBy( + () -> studyHistoryValidator.validateAppliedToStudy(countStudyHistory, requestStudentCount)) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_HISTORY_NOT_APPLIED_STUDENT_EXISTS.getMessage()); + } + } } diff --git a/src/test/java/com/gdschongik/gdsc/helper/IntegrationTest.java b/src/test/java/com/gdschongik/gdsc/helper/IntegrationTest.java index dbd8c601b..c2c7deed9 100644 --- a/src/test/java/com/gdschongik/gdsc/helper/IntegrationTest.java +++ b/src/test/java/com/gdschongik/gdsc/helper/IntegrationTest.java @@ -30,10 +30,15 @@ import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound; import com.gdschongik.gdsc.domain.recruitment.domain.RoundType; import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.domain.study.dao.StudyAchievementRepository; import com.gdschongik.gdsc.domain.study.dao.StudyDetailRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyHistoryRepository; import com.gdschongik.gdsc.domain.study.dao.StudyRepository; +import com.gdschongik.gdsc.domain.study.domain.AchievementType; import com.gdschongik.gdsc.domain.study.domain.Study; +import com.gdschongik.gdsc.domain.study.domain.StudyAchievement; import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import com.gdschongik.gdsc.domain.study.domain.StudyHistory; import com.gdschongik.gdsc.global.security.PrincipalDetails; import com.gdschongik.gdsc.infra.feign.payment.client.PaymentClient; import com.gdschongik.gdsc.infra.github.client.GithubClient; @@ -81,6 +86,12 @@ public abstract class IntegrationTest { @Autowired protected StudyDetailRepository studyDetailRepository; + @Autowired + protected StudyHistoryRepository studyHistoryRepository; + + @Autowired + protected StudyAchievementRepository studyAchievementRepository; + @MockBean protected OnboardingRecruitmentService onboardingRecruitmentService; @@ -262,6 +273,16 @@ protected StudyDetail createNewStudyDetail(Long week, Study study, LocalDateTime return studyDetailRepository.save(studyDetail); } + protected StudyHistory createStudyHistory(Member member, Study study) { + StudyHistory studyHistory = StudyHistory.create(member, study); + return studyHistoryRepository.save(studyHistory); + } + + protected StudyAchievement createStudyAchievement(Member member, Study study, AchievementType achievementType) { + StudyAchievement studyAchievement = StudyAchievement.create(member, study, achievementType); + return studyAchievementRepository.save(studyAchievement); + } + protected StudyDetail publishAssignment(StudyDetail studyDetail) { studyDetail.publishAssignment(ASSIGNMENT_TITLE, studyDetail.getPeriod().getEndDate(), DESCRIPTION_LINK); return studyDetailRepository.save(studyDetail);