Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public class MentoringController {
private final Rq rq;

@GetMapping
@Operation(summary = "멘토링 목록 조회")
@Operation(summary = "멘토링 목록 조회", description = "멘토링 목록을 조회합니다")
public RsData<MentoringPagingResponse> getMentorings(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
Expand All @@ -42,7 +42,7 @@ public RsData<MentoringPagingResponse> getMentorings(
}

@GetMapping("/{mentoringId}")
@Operation(summary = "멘토링 상세 조회")
@Operation(summary = "멘토링 상세 조회", description = "특정 멘토링을 상세 조회합니다.")
public RsData<MentoringResponse> getMentoring(
@PathVariable Long mentoringId
) {
Expand All @@ -57,7 +57,7 @@ public RsData<MentoringResponse> getMentoring(

@PostMapping
@PreAuthorize("hasRole('MENTOR')")
@Operation(summary = "멘토링 생성")
@Operation(summary = "멘토링 생성", description = "멘토링을 생성합니다. 로그인한 멘토만 생성할 수 있습니다.")
public RsData<MentoringResponse> createMentoring(
@RequestBody @Valid MentoringRequest reqDto
) {
Expand All @@ -72,7 +72,7 @@ public RsData<MentoringResponse> createMentoring(
}

@PutMapping("/{mentoringId}")
@Operation(summary = "멘토링 수정")
@Operation(summary = "멘토링 수정", description = "멘토링을 수정합니다. 멘토링 작성자만 접근할 수 있습니다.")
public RsData<MentoringResponse> updateMentoring(
@PathVariable Long mentoringId,
@RequestBody @Valid MentoringRequest reqDto
Expand All @@ -88,7 +88,7 @@ public RsData<MentoringResponse> updateMentoring(
}

@DeleteMapping("/{mentoringId}")
@Operation(summary = "멘토링 삭제")
@Operation(summary = "멘토링 삭제", description = "멘토링을 삭제합니다. 멘토링 작성자만 접근할 수 있습니다.")
public RsData<Void> deleteMentoring(
@PathVariable Long mentoringId
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,17 @@ public Reservation(Mentoring mentoring, Mentee mentee, MentorSlot mentorSlot, St
this.mentorSlot = mentorSlot;
this.preQuestion = preQuestion;
this.status = ReservationStatus.PENDING;

// 양방향 동기화
mentorSlot.setReservation(this);
}

public void updateStatus(ReservationStatus status) {
this.status = status;

// 양방향 동기화
if (status.equals(ReservationStatus.CANCELED) || status.equals(ReservationStatus.REJECTED)) {
mentorSlot.removeReservation();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,11 @@

public interface ReservationRepository extends JpaRepository<Reservation, Long> {
boolean existsByMentoringId(Long mentoringId);

/**
* 예약 기록 존재 여부 확인 (모든 상태 포함)
* - 슬롯 삭제 시 데이터 무결성 검증용
* - 취소/거절된 예약도 히스토리로 보존
*/
boolean existsByMentorSlotId(Long slotId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,23 @@ public class MentorSlotController {
private final MentorSlotService mentorSlotService;
private final Rq rq;

@GetMapping("/{slotId}")
@Operation(summary = "멘토 슬롯 조회", description = "특정 멘토 슬롯을 조회합니다.")
public RsData<MentorSlotResponse> getMentorSlot(
@PathVariable Long slotId
) {
MentorSlotResponse resDto = mentorSlotService.getMentorSlot(slotId);

return new RsData<>(
"200",
"멘토의 예약 가능 일정을 조회하였습니다.",
resDto
);
}

@PostMapping
@PreAuthorize("hasRole('MENTOR')")
@Operation(summary = "멘토 슬롯 생성")
@Operation(summary = "멘토 슬롯 생성", description = "멘토 슬롯을 생성합니다. 로그인한 멘토만 생성할 수 있습니다.")
public RsData<MentorSlotResponse> createMentorSlot(
@RequestBody @Valid MentorSlotRequest reqDto
) {
Expand All @@ -33,13 +47,13 @@ public RsData<MentorSlotResponse> createMentorSlot(

return new RsData<>(
"201",
"멘토링 예약 일정을 등록했습니다.",
"멘토의 예약 가능 일정을 등록했습니다.",
resDto
);
}

@PutMapping("/{slotId}")
@Operation(summary = "멘토 슬롯 수정")
@Operation(summary = "멘토 슬롯 수정", description = "멘토 슬롯을 수정합니다. 멘토 슬롯 작성자만 접근할 수 있습니다.")
public RsData<MentorSlotResponse> updateMentorSlot(
@PathVariable Long slotId,
@RequestBody @Valid MentorSlotRequest reqDto
Expand All @@ -49,9 +63,22 @@ public RsData<MentorSlotResponse> updateMentorSlot(

return new RsData<>(
"200",
"멘토링 예약 일정이 수정되었습니다.",
"멘토의 예약 가능 일정이 수정되었습니다.",
resDto
);
}

@DeleteMapping("/{slotId}")
@Operation(summary = "멘토 슬롯 삭제", description = "멘토 슬롯을 삭제합니다. 멘토 슬롯 작성자만 접근할 수 있습니다.")
public RsData<Void> deleteMentorSlot(
@PathVariable Long slotId
) {
Member member = rq.getActor();
mentorSlotService.deleteMentorSlot(slotId, member);

return new RsData<>(
"200",
"멘토의 예약 가능 일정이 삭제되었습니다."
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ public MentorSlot(Mentor mentor, LocalDateTime startDateTime, LocalDateTime endD
this.status = MentorSlotStatus.AVAILABLE;
}

public void updateTime(LocalDateTime startDateTime, LocalDateTime endDateTime) {
this.startDateTime = startDateTime;
this.endDateTime = endDateTime;
}

// =========================
// TODO - 현재 상태
// 1. reservation 필드에는 활성 예약(PENDING, APPROVED)만 세팅
Expand Down Expand Up @@ -68,14 +73,28 @@ public void updateStatus() {
}
}

public void setReservation(Reservation reservation) {
this.reservation = reservation;
updateStatus();
}

public void removeReservation() {
this.reservation = null;
updateStatus();
}

/**
* 새로운 예약이 가능한지 확인
* - 예약이 없거나
* - 예약이 취소/거절된 경우 true
*/
public boolean isAvailable() {
return reservation == null ||
reservation.getStatus().equals(ReservationStatus.REJECTED) ||
reservation.getStatus().equals(ReservationStatus.CANCELED);
}

public void update(LocalDateTime startDateTime, LocalDateTime endDateTime) {
this.startDateTime = startDateTime;
this.endDateTime = endDateTime;
public boolean isOwnerBy(Mentor mentor) {
return this.mentor.equals(mentor);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@
public enum MentorSlotErrorCode implements ErrorCode {

// 400 DateTime 체크
START_TIME_REQUIRED("400-1", "시작 일시와 종료 일시는 필수입니다."),
END_TIME_REQUIRED("400-2", "시작 일시와 종료 일시는 필수입니다."),
START_TIME_REQUIRED("400-1", "시작 일시는 필수입니다."),
END_TIME_REQUIRED("400-2", "종료 일시는 필수입니다."),
START_TIME_IN_PAST("400-3", "시작 일시는 현재 이후여야 합니다."),
END_TIME_BEFORE_START("400-4", "종료 일시는 시작 일시보다 이후여야 합니다."),
INSUFFICIENT_SLOT_DURATION("400-5", "슬롯은 최소 30분 이상이어야 합니다."),
INSUFFICIENT_SLOT_DURATION("400-5", "슬롯은 최소 20분 이상이어야 합니다."),

// 400 Slot 체크
CANNOT_UPDATE_RESERVED_SLOT("400-6", "예약된 슬롯은 수정할 수 없습니다."),
CANNOT_DELETE_RESERVED_SLOT("400-7", "예약된 슬롯은 삭제할 수 없습니다."),

// 403
NOT_OWNER("403-1", "일정의 소유주가 아닙니다."),
NOT_OWNER("403-1", "접근 권한이 없습니다."),

// 404
NOT_FOUND_MENTOR_SLOT("404-1", "일정 정보가 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,6 @@ boolean existsOverlappingExcept(
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end
);

long countByMentorId(Long id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.back.domain.mentoring.mentoring.entity.Mentoring;
import com.back.domain.mentoring.mentoring.error.MentoringErrorCode;
import com.back.domain.mentoring.mentoring.repository.MentoringRepository;
import com.back.domain.mentoring.reservation.repository.ReservationRepository;
import com.back.domain.mentoring.slot.dto.request.MentorSlotRequest;
import com.back.domain.mentoring.slot.dto.response.MentorSlotResponse;
import com.back.domain.mentoring.slot.entity.MentorSlot;
Expand All @@ -26,24 +27,29 @@ public class MentorSlotService {
private final MentorSlotRepository mentorSlotRepository;
private final MentorRepository mentorRepository;
private final MentoringRepository mentoringRepository;
private final ReservationRepository reservationRepository;

@Transactional(readOnly = true)
public MentorSlotResponse getMentorSlot(Long slotId) {
MentorSlot mentorSlot = findMentorSlot(slotId);
Mentoring mentoring = findMentoring(mentorSlot.getMentor());

return MentorSlotResponse.from(mentorSlot, mentoring);
}

@Transactional
public MentorSlotResponse createMentorSlot(MentorSlotRequest reqDto, Member member) {
Mentor mentor = findMentor(member);
Mentoring mentoring = findMentoring(mentor);

// 시간대 유효성 검사
MentorSlotValidator.validateTimeSlot(reqDto.startDateTime(), reqDto.endDateTime());

// 기존 슬롯과 시간 겹치는지 검사
validateOverlappingSlots(mentor, reqDto.startDateTime(), reqDto.endDateTime());

MentorSlot mentorSlot = MentorSlot.builder()
.mentor(mentor)
.startDateTime(reqDto.startDateTime())
.endDateTime(reqDto.endDateTime())
.build();

mentorSlotRepository.save(mentorSlot);

return MentorSlotResponse.from(mentorSlot, mentoring);
Expand All @@ -55,24 +61,30 @@ public MentorSlotResponse updateMentorSlot(Long slotId, MentorSlotRequest reqDto
Mentoring mentoring = findMentoring(mentor);
MentorSlot mentorSlot = findMentorSlot(slotId);

if (!mentorSlot.getMentor().equals(mentor)) {
throw new ServiceException(MentorSlotErrorCode.NOT_OWNER);
}
if (!mentorSlot.isAvailable()) {
throw new ServiceException(MentorSlotErrorCode.CANNOT_UPDATE_RESERVED_SLOT);
}
validateOwner(mentorSlot, mentor);
// 활성화된 예약이 있으면 수정 불가
validateModification(mentorSlot);

// 시간대 유효성 검사
MentorSlotValidator.validateTimeSlot(reqDto.startDateTime(), reqDto.endDateTime());

// 기존 슬롯과 시간 겹치는지 검사
validateOverlappingExcept(mentor, mentorSlot, reqDto.startDateTime(), reqDto.endDateTime());

mentorSlot.update(reqDto.startDateTime(), reqDto.endDateTime());
mentorSlot.updateTime(reqDto.startDateTime(), reqDto.endDateTime());

return MentorSlotResponse.from(mentorSlot, mentoring);
}

@Transactional
public void deleteMentorSlot(Long slotId, Member member) {
Mentor mentor = findMentor(member);
MentorSlot mentorSlot = findMentorSlot(slotId);

validateOwner(mentorSlot, mentor);
// 예약 기록 존재 여부 검증 (모든 예약 기록 확인)
validateNoReservationHistory(slotId);

mentorSlotRepository.delete(mentorSlot);
}


// ===== 헬퍼 메서드 =====

Expand All @@ -97,15 +109,50 @@ private MentorSlot findMentorSlot(Long slotId) {

// ===== 검증 메서드 =====

private static void validateOwner(MentorSlot mentorSlot, Mentor mentor) {
if (!mentorSlot.isOwnerBy(mentor)) {
throw new ServiceException(MentorSlotErrorCode.NOT_OWNER);
}
}

/**
* 주어진 시간대가 기존 슬롯과 겹치는지 검증
* - mentor의 기존 모든 슬롯과 비교
*/
private void validateOverlappingSlots(Mentor mentor, LocalDateTime start, LocalDateTime end) {
if (mentorSlotRepository.existsOverlappingSlot(mentor.getId(), start, end)) {
throw new ServiceException(MentorSlotErrorCode.OVERLAPPING_SLOT);
}
}

/**
* 특정 슬롯을 제외하고 시간대가 겹치는지 검증
* - 대상 슬롯(self)은 제외
*/
private void validateOverlappingExcept(Mentor mentor, MentorSlot mentorSlot, LocalDateTime start, LocalDateTime end) {
if (mentorSlotRepository.existsOverlappingExcept(mentor.getId(), mentorSlot.getId(), start, end)) {
throw new ServiceException(MentorSlotErrorCode.OVERLAPPING_SLOT);
}
}

/**
* 활성화된 예약이 있으면 수정 불가
* - 예약 취소, 예약 거절 상태는 수정 가능
*/
private static void validateModification(MentorSlot mentorSlot) {
if (!mentorSlot.isAvailable()) {
throw new ServiceException(MentorSlotErrorCode.CANNOT_UPDATE_RESERVED_SLOT);
}
}

/**
* 예약 기록이 하나라도 있으면 삭제 불가
* - 데이터 무결성 보장
* - 히스토리 보존
*/
private void validateNoReservationHistory(Long slotId) {
if (reservationRepository.existsByMentorSlotId(slotId)) {
throw new ServiceException(MentorSlotErrorCode.CANNOT_DELETE_RESERVED_SLOT);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.back.global.exception;

import com.back.global.rsData.RsData;
import lombok.Getter;

public class ServiceException extends RuntimeException {
@Getter
private final String resultCode;
private final String msg;

Expand Down
Loading