Skip to content
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.example.solidconnection.admin.controller;

import com.example.solidconnection.admin.dto.MentorApplicationCountResponse;
import com.example.solidconnection.admin.dto.MentorApplicationRejectRequest;
import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition;
import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse;
import com.example.solidconnection.admin.service.AdminMentorApplicationService;
Expand All @@ -12,6 +14,9 @@
import org.springframework.http.ResponseEntity;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -34,4 +39,27 @@ public ResponseEntity<PageResponse<MentorApplicationSearchResponse>> searchMento

return ResponseEntity.ok(PageResponse.of(page));
}

@PostMapping("/{mentorApplicationId}/approve")
public ResponseEntity<Void> approveMentorApplication(
@PathVariable("mentorApplicationId") Long mentorApplicationId
) {
adminMentorApplicationService.approveMentorApplication(mentorApplicationId);
return ResponseEntity.ok().build();
}

@PostMapping("/{mentorApplicationId}/reject")
public ResponseEntity<Void> rejectMentorApplication(
@PathVariable("mentorApplicationId") Long mentorApplicationId,
@Valid @RequestBody MentorApplicationRejectRequest request
) {
adminMentorApplicationService.rejectMentorApplication(mentorApplicationId, request);
return ResponseEntity.ok().build();
}

@GetMapping("/count")
public ResponseEntity<MentorApplicationCountResponse> getMentorApplicationCount() {
MentorApplicationCountResponse response = adminMentorApplicationService.getMentorApplicationCount();
return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.solidconnection.admin.dto;

public record MentorApplicationCountResponse(
long approvedCount,
long pendingCount,
long rejectedCount
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.solidconnection.admin.dto;

import jakarta.validation.constraints.NotBlank;

public record MentorApplicationRejectRequest(
@NotBlank(message = "거절 사유는 필수입니다")
String rejectedReason
) {

}
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
package com.example.solidconnection.admin.service;

import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_NOT_FOUND;
import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED;

import com.example.solidconnection.admin.dto.MentorApplicationCountResponse;
import com.example.solidconnection.admin.dto.MentorApplicationRejectRequest;
import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition;
import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse;
import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.mentor.domain.MentorApplication;
import com.example.solidconnection.mentor.domain.MentorApplicationStatus;
import com.example.solidconnection.mentor.repository.MentorApplicationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
Expand All @@ -22,4 +30,37 @@ public Page<MentorApplicationSearchResponse> searchMentorApplications(
) {
return mentorApplicationRepository.searchMentorApplications(mentorApplicationSearchCondition, pageable);
}

@Transactional
public void approveMentorApplication(Long mentorApplicationId) {
MentorApplication mentorApplication = mentorApplicationRepository.findById(mentorApplicationId)
.orElseThrow(() -> new CustomException(MENTOR_APPLICATION_NOT_FOUND));

if(mentorApplication.getUniversityId() == null){
throw new CustomException(MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED);
}

mentorApplication.approve();
}

@Transactional
public void rejectMentorApplication(long mentorApplicationId, MentorApplicationRejectRequest request) {
MentorApplication mentorApplication = mentorApplicationRepository.findById(mentorApplicationId)
.orElseThrow(() -> new CustomException(MENTOR_APPLICATION_NOT_FOUND));

mentorApplication.reject(request.rejectedReason());
}

@Transactional(readOnly = true)
public MentorApplicationCountResponse getMentorApplicationCount() {
long approvedCount = mentorApplicationRepository.countByMentorApplicationStatus(MentorApplicationStatus.APPROVED);
long pendingCount = mentorApplicationRepository.countByMentorApplicationStatus(MentorApplicationStatus.PENDING);
long rejectedCount = mentorApplicationRepository.countByMentorApplicationStatus(MentorApplicationStatus.REJECTED);
Copy link
Member

Choose a reason for hiding this comment

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

하나의 메서드 안에서 세 개의 쿼리를 실행하는데, 중간에 데이터가 변경되면 의도와 다른 개수가 나올 것 같습니다. 그룹바이로 하나의 쿼리로 될 거 같긴 합니다

Copy link
Contributor Author

Choose a reason for hiding this comment

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

그렇겠네요, 도중에 상태가 변경된다면 값의 일관성이 깨질수도 있으니 jpql로 한번에 조회하는 식으로 수정하겠습니다!

Copy link
Contributor Author

@sukangpunch sukangpunch Nov 26, 2025

Choose a reason for hiding this comment

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

jpql 방식으로 조회를 시도 하면 Object[] 혹은, dto 리스트, queryDsl 을 활용하여 map 으로 받는 방식들이 존재하는데, 셋 다 비용이 있어 보여서 고민이 됩니다...
그리고 @transactional(readOnly = true) + mysql innoDB 의 REPEATABLE_READ 격리 수준 에선, 해당 트랜잭션 시작 시 스냅샷을 생성하고 트랜잭션 내의 쿼리들은 동일한 스냅샷을 읽는다고 합니다!
그래서 우려되는 문제는 발생하지 않지 않을까? 싶어서 답변 다시 드립니다!
@whqtker

Copy link
Member

Choose a reason for hiding this comment

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

현 격리 수준에서 동일한 스냅샷을 읽기 때문에 정합성 깨짐 문제는 발생하지 않음 + 여러모로 JPQL을 통한 하나의 쿼리보다 현제 쿼리가 효율적인

말씀해주신 내용 전부 동의합니다 ~! 그대로 유지해도 될 거 같습니다.


return new MentorApplicationCountResponse(
approvedCount,
pendingCount,
rejectedCount
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ public enum ErrorCode {
UNIVERSITY_ID_MUST_BE_NULL_FOR_OTHER(HttpStatus.BAD_REQUEST.value(), "기타 학교를 선택한 경우 학교 정보를 입력할 수 없습니다."),
INVALID_UNIVERSITY_SELECT_TYPE(HttpStatus.BAD_REQUEST.value(), "지원하지 않는 학교 선택 방식입니다."),
MENTOR_ALREADY_EXISTS(HttpStatus.BAD_REQUEST.value(), "이미 존재하는 멘토입니다."),
MENTOR_APPLICATION_ALREADY_CONFIRMED(HttpStatus.BAD_REQUEST.value(), "이미 승인 또는 거절된 멘토 승격 요청 입니다."),
MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED(HttpStatus.BAD_REQUEST.value(), "승인하려는 멘토 신청에 대학교가 선택되지 않았습니다."),

// socket
UNAUTHORIZED_SUBSCRIBE(HttpStatus.FORBIDDEN.value(), "구독 권한이 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.example.solidconnection.mentor.domain;

import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_ALREADY_CONFIRMED;
import static java.time.ZoneOffset.UTC;
import static java.time.temporal.ChronoUnit.MICROS;

import com.example.solidconnection.common.BaseEntity;
import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.common.exception.ErrorCode;
Expand Down Expand Up @@ -116,4 +120,20 @@ private void validateExchangeStatus(ExchangeStatus exchangeStatus) {
throw new CustomException(ErrorCode.INVALID_EXCHANGE_STATUS_FOR_MENTOR);
}
}

public void approve(){
if(this.mentorApplicationStatus != MentorApplicationStatus.PENDING) {
throw new CustomException(MENTOR_APPLICATION_ALREADY_CONFIRMED);
}
this.mentorApplicationStatus = MentorApplicationStatus.APPROVED;
this.approvedAt = ZonedDateTime.now(UTC).truncatedTo(MICROS);
}

public void reject(String rejectedReason){
if(this.mentorApplicationStatus != MentorApplicationStatus.PENDING) {
throw new CustomException(MENTOR_APPLICATION_ALREADY_CONFIRMED);
}
this.mentorApplicationStatus = MentorApplicationStatus.REJECTED;
this.rejectedReason = rejectedReason;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ public interface MentorApplicationRepository extends JpaRepository<MentorApplica
boolean existsBySiteUserIdAndMentorApplicationStatusIn(long siteUserId, List<MentorApplicationStatus> mentorApplicationStatuses);

Optional<MentorApplication> findBySiteUserIdAndMentorApplicationStatus(long siteUserId, MentorApplicationStatus mentorApplicationStatus);

long countByMentorApplicationStatus(MentorApplicationStatus mentorApplicationStatus);
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
package com.example.solidconnection.admin.service;

import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_ALREADY_CONFIRMED;
import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_NOT_FOUND;
import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode;
import static org.junit.jupiter.api.Assertions.assertAll;

import com.example.solidconnection.admin.dto.MentorApplicationCountResponse;
import com.example.solidconnection.admin.dto.MentorApplicationRejectRequest;
import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition;
import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse;
import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.mentor.domain.MentorApplication;
import com.example.solidconnection.mentor.domain.MentorApplicationStatus;
import com.example.solidconnection.mentor.domain.UniversitySelectType;
import com.example.solidconnection.mentor.fixture.MentorApplicationFixture;
import com.example.solidconnection.mentor.repository.MentorApplicationRepository;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.fixture.SiteUserFixture;
import com.example.solidconnection.support.TestContainerSpringBootTest;
Expand Down Expand Up @@ -40,6 +49,9 @@ class AdminMentorApplicationServiceTest {
@Autowired
private UniversityFixture universityFixture;

@Autowired
private MentorApplicationRepository mentorApplicationRepository;

private MentorApplication mentorApplication1;
private MentorApplication mentorApplication2;
private MentorApplication mentorApplication3;
Expand Down Expand Up @@ -209,4 +221,160 @@ class 멘토_승격_지원서_목록_조회 {
.containsOnly(regionKoreanName);
}
}

@Nested
class 멘토_승격_지원서_승인{

@Test
void 대기중인_멘토_지원서를_승인한다() {
// given
long pendingMentorApplicationId = mentorApplication2.getId();

// when
adminMentorApplicationService.approveMentorApplication(pendingMentorApplicationId);

// then
MentorApplication result = mentorApplicationRepository.findById(mentorApplication2.getId()).get();
assertThat(result.getMentorApplicationStatus()).isEqualTo(MentorApplicationStatus.APPROVED);
assertThat(result.getApprovedAt()).isNotNull();
}

@Test
void 대학이_선택되지_않은_멘토_지원서를_승인하면_예외가_발생한다(){
// given
SiteUser user = siteUserFixture.사용자();
MentorApplication noUniversityIdMentorApplication = mentorApplicationFixture.대기중_멘토신청(user.getId(), UniversitySelectType.OTHER, null);

// when & then
assertThatCode(() -> adminMentorApplicationService.approveMentorApplication(noUniversityIdMentorApplication.getId()))
.isInstanceOf(CustomException.class)
.hasMessage(MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED.getMessage());
}

@Test
void 이미_승인된_멘토_지원서를_승인하면_예외가_발생한다() {
// given
long approvedMentorApplicationId = mentorApplication1.getId();

// when & then
assertThatCode(() -> adminMentorApplicationService.approveMentorApplication(approvedMentorApplicationId))
.isInstanceOf(CustomException.class)
.hasMessage(MENTOR_APPLICATION_ALREADY_CONFIRMED.getMessage());
}

@Test
void 이미_거절된_멘토_지원서를_승인하면_예외가_발생한다() {
// given
long rejectedMentorApplicationId = mentorApplication3.getId();

// when & then
assertThatCode(() -> adminMentorApplicationService.approveMentorApplication(rejectedMentorApplicationId))
.isInstanceOf(CustomException.class)
.hasMessage(MENTOR_APPLICATION_ALREADY_CONFIRMED.getMessage());
}

@Test
void 존재하지_않는_멘토_지원서를_승인하면_예외가_발생한다() {
// given
long nonExistentId = 99999L;

// when & then
assertThatCode(() -> adminMentorApplicationService.approveMentorApplication(nonExistentId))
.isInstanceOf(CustomException.class)
.hasMessage(MENTOR_APPLICATION_NOT_FOUND.getMessage());
}
}

@Nested
class 멘토_승격_지원서_거절{

@Test
void 대기중인_멘토_지원서를_거절한다() {
// given
long pendingMentorApplicationId = mentorApplication2.getId();
MentorApplicationRejectRequest request = new MentorApplicationRejectRequest("파견학교 인증 자료 누락");

// when
adminMentorApplicationService.rejectMentorApplication(pendingMentorApplicationId, request);

// then
MentorApplication result = mentorApplicationRepository.findById(mentorApplication2.getId()).get();
assertThat(result.getMentorApplicationStatus()).isEqualTo(MentorApplicationStatus.REJECTED);
assertThat(result.getRejectedReason()).isEqualTo(request.rejectedReason());
}

@Test
void 이미_승인된_멘토_지원서를_거절하면_예외가_발생한다() {
// given
long approvedMentorApplicationId = mentorApplication1.getId();
MentorApplicationRejectRequest request = new MentorApplicationRejectRequest("파견학교 인증 자료 누락");

// when & then
assertThatCode(() -> adminMentorApplicationService.rejectMentorApplication(approvedMentorApplicationId, request))
.isInstanceOf(CustomException.class)
.hasMessage(MENTOR_APPLICATION_ALREADY_CONFIRMED.getMessage());
}

@Test
void 이미_거절된_멘토_지원서를_거절하면_예외가_발생한다() {
// given
long rejectedMentorApplicationId = mentorApplication3.getId();
MentorApplicationRejectRequest request = new MentorApplicationRejectRequest("파견학교 인증 자료 누락");

// when & then
assertThatCode(() -> adminMentorApplicationService.rejectMentorApplication(rejectedMentorApplicationId, request))
.isInstanceOf(CustomException.class)
.hasMessage(MENTOR_APPLICATION_ALREADY_CONFIRMED.getMessage());
}

@Test
void 존재하지_않는_멘토_지원서를_거절하면_예외가_발생한다() {
// given
long nonExistentId = 99999L;
MentorApplicationRejectRequest request = new MentorApplicationRejectRequest("파견학교 인증 자료 누락");

// when & then
assertThatCode(() -> adminMentorApplicationService.rejectMentorApplication(nonExistentId, request))
.isInstanceOf(CustomException.class)
.hasMessage(MENTOR_APPLICATION_NOT_FOUND.getMessage());
}
}

@Nested
class 멘토_지원서_상태별_개수_조회 {

@Test
void 상태별_멘토_지원서_개수를_조회한다() {
// given
List<MentorApplication> expectedApprovedCount = List.of(mentorApplication1, mentorApplication4);
List<MentorApplication> expectedPendingCount = List.of(mentorApplication2, mentorApplication5);
List<MentorApplication> expectedRejectedCount = List.of(mentorApplication3, mentorApplication6);

// when
MentorApplicationCountResponse response = adminMentorApplicationService.getMentorApplicationCount();

// then
assertAll(
() -> assertThat(response.approvedCount()).isEqualTo(expectedApprovedCount.size()),
() -> assertThat(response.pendingCount()).isEqualTo(expectedPendingCount.size()),
() -> assertThat(response.rejectedCount()).isEqualTo(expectedRejectedCount.size())
);
}

@Test
void 멘토_지원서가_없으면_모든_개수가_0이다() {
// given
mentorApplicationRepository.deleteAll();

// when
MentorApplicationCountResponse response = adminMentorApplicationService.getMentorApplicationCount();

// then
assertAll(
() -> assertThat(response.approvedCount()).isEqualTo(0L),
() -> assertThat(response.pendingCount()).isEqualTo(0L),
() -> assertThat(response.rejectedCount()).isEqualTo(0L)
);
}
}
}
Loading