Skip to content

Commit 3c6275b

Browse files
authored
[Feat] 멘토 슬롯 도메인 조회, 삭제 구현 (#75)
* Docs: 멘토링 api 문서 상세 작성 * refactor: 슬롯 수정 실패 테스트 추가 및 중복 제거 * Feat: 멘토 슬롯 삭제, 슬롯-예약 양방향 관계 설정 * Feat: 멘토 슬롯 조회 * feat: MentorSlotValidatorTest 추가
1 parent 89a2fe5 commit 3c6275b

File tree

11 files changed

+494
-52
lines changed

11 files changed

+494
-52
lines changed

back/src/main/java/com/back/domain/mentoring/mentoring/controller/MentoringController.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public class MentoringController {
2525
private final Rq rq;
2626

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

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

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

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

9090
@DeleteMapping("/{mentoringId}")
91-
@Operation(summary = "멘토링 삭제")
91+
@Operation(summary = "멘토링 삭제", description = "멘토링을 삭제합니다. 멘토링 작성자만 접근할 수 있습니다.")
9292
public RsData<Void> deleteMentoring(
9393
@PathVariable Long mentoringId
9494
) {

back/src/main/java/com/back/domain/mentoring/reservation/entity/Reservation.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,17 @@ public Reservation(Mentoring mentoring, Mentee mentee, MentorSlot mentorSlot, St
4646
this.mentorSlot = mentorSlot;
4747
this.preQuestion = preQuestion;
4848
this.status = ReservationStatus.PENDING;
49+
50+
// 양방향 동기화
51+
mentorSlot.setReservation(this);
52+
}
53+
54+
public void updateStatus(ReservationStatus status) {
55+
this.status = status;
56+
57+
// 양방향 동기화
58+
if (status.equals(ReservationStatus.CANCELED) || status.equals(ReservationStatus.REJECTED)) {
59+
mentorSlot.removeReservation();
60+
}
4961
}
5062
}

back/src/main/java/com/back/domain/mentoring/reservation/repository/ReservationRepository.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,11 @@
55

66
public interface ReservationRepository extends JpaRepository<Reservation, Long> {
77
boolean existsByMentoringId(Long mentoringId);
8+
9+
/**
10+
* 예약 기록 존재 여부 확인 (모든 상태 포함)
11+
* - 슬롯 삭제 시 데이터 무결성 검증용
12+
* - 취소/거절된 예약도 히스토리로 보존
13+
*/
14+
boolean existsByMentorSlotId(Long slotId);
815
}

back/src/main/java/com/back/domain/mentoring/slot/controller/MentorSlotController.java

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,23 @@ public class MentorSlotController {
2222
private final MentorSlotService mentorSlotService;
2323
private final Rq rq;
2424

25+
@GetMapping("/{slotId}")
26+
@Operation(summary = "멘토 슬롯 조회", description = "특정 멘토 슬롯을 조회합니다.")
27+
public RsData<MentorSlotResponse> getMentorSlot(
28+
@PathVariable Long slotId
29+
) {
30+
MentorSlotResponse resDto = mentorSlotService.getMentorSlot(slotId);
31+
32+
return new RsData<>(
33+
"200",
34+
"멘토의 예약 가능 일정을 조회하였습니다.",
35+
resDto
36+
);
37+
}
38+
2539
@PostMapping
2640
@PreAuthorize("hasRole('MENTOR')")
27-
@Operation(summary = "멘토 슬롯 생성")
41+
@Operation(summary = "멘토 슬롯 생성", description = "멘토 슬롯을 생성합니다. 로그인한 멘토만 생성할 수 있습니다.")
2842
public RsData<MentorSlotResponse> createMentorSlot(
2943
@RequestBody @Valid MentorSlotRequest reqDto
3044
) {
@@ -33,13 +47,13 @@ public RsData<MentorSlotResponse> createMentorSlot(
3347

3448
return new RsData<>(
3549
"201",
36-
"멘토링 예약 일정을 등록했습니다.",
50+
"멘토의 예약 가능 일정을 등록했습니다.",
3751
resDto
3852
);
3953
}
4054

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

5064
return new RsData<>(
5165
"200",
52-
"멘토링 예약 일정이 수정되었습니다.",
66+
"멘토의 예약 가능 일정이 수정되었습니다.",
5367
resDto
5468
);
5569
}
5670

71+
@DeleteMapping("/{slotId}")
72+
@Operation(summary = "멘토 슬롯 삭제", description = "멘토 슬롯을 삭제합니다. 멘토 슬롯 작성자만 접근할 수 있습니다.")
73+
public RsData<Void> deleteMentorSlot(
74+
@PathVariable Long slotId
75+
) {
76+
Member member = rq.getActor();
77+
mentorSlotService.deleteMentorSlot(slotId, member);
78+
79+
return new RsData<>(
80+
"200",
81+
"멘토의 예약 가능 일정이 삭제되었습니다."
82+
);
83+
}
5784
}

back/src/main/java/com/back/domain/mentoring/slot/entity/MentorSlot.java

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ public MentorSlot(Mentor mentor, LocalDateTime startDateTime, LocalDateTime endD
4141
this.status = MentorSlotStatus.AVAILABLE;
4242
}
4343

44+
public void updateTime(LocalDateTime startDateTime, LocalDateTime endDateTime) {
45+
this.startDateTime = startDateTime;
46+
this.endDateTime = endDateTime;
47+
}
48+
4449
// =========================
4550
// TODO - 현재 상태
4651
// 1. reservation 필드에는 활성 예약(PENDING, APPROVED)만 세팅
@@ -68,14 +73,28 @@ public void updateStatus() {
6873
}
6974
}
7075

76+
public void setReservation(Reservation reservation) {
77+
this.reservation = reservation;
78+
updateStatus();
79+
}
80+
81+
public void removeReservation() {
82+
this.reservation = null;
83+
updateStatus();
84+
}
85+
86+
/**
87+
* 새로운 예약이 가능한지 확인
88+
* - 예약이 없거나
89+
* - 예약이 취소/거절된 경우 true
90+
*/
7191
public boolean isAvailable() {
7292
return reservation == null ||
7393
reservation.getStatus().equals(ReservationStatus.REJECTED) ||
7494
reservation.getStatus().equals(ReservationStatus.CANCELED);
7595
}
7696

77-
public void update(LocalDateTime startDateTime, LocalDateTime endDateTime) {
78-
this.startDateTime = startDateTime;
79-
this.endDateTime = endDateTime;
97+
public boolean isOwnerBy(Mentor mentor) {
98+
return this.mentor.equals(mentor);
8099
}
81100
}

back/src/main/java/com/back/domain/mentoring/slot/error/MentorSlotErrorCode.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@
99
public enum MentorSlotErrorCode implements ErrorCode {
1010

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

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

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

2425
// 404
2526
NOT_FOUND_MENTOR_SLOT("404-1", "일정 정보가 없습니다."),

back/src/main/java/com/back/domain/mentoring/slot/repository/MentorSlotRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,6 @@ boolean existsOverlappingExcept(
4343
@Param("start") LocalDateTime start,
4444
@Param("end") LocalDateTime end
4545
);
46+
47+
long countByMentorId(Long id);
4648
}

back/src/main/java/com/back/domain/mentoring/slot/service/MentorSlotService.java

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.back.domain.mentoring.mentoring.entity.Mentoring;
77
import com.back.domain.mentoring.mentoring.error.MentoringErrorCode;
88
import com.back.domain.mentoring.mentoring.repository.MentoringRepository;
9+
import com.back.domain.mentoring.reservation.repository.ReservationRepository;
910
import com.back.domain.mentoring.slot.dto.request.MentorSlotRequest;
1011
import com.back.domain.mentoring.slot.dto.response.MentorSlotResponse;
1112
import com.back.domain.mentoring.slot.entity.MentorSlot;
@@ -26,24 +27,29 @@ public class MentorSlotService {
2627
private final MentorSlotRepository mentorSlotRepository;
2728
private final MentorRepository mentorRepository;
2829
private final MentoringRepository mentoringRepository;
30+
private final ReservationRepository reservationRepository;
31+
32+
@Transactional(readOnly = true)
33+
public MentorSlotResponse getMentorSlot(Long slotId) {
34+
MentorSlot mentorSlot = findMentorSlot(slotId);
35+
Mentoring mentoring = findMentoring(mentorSlot.getMentor());
36+
37+
return MentorSlotResponse.from(mentorSlot, mentoring);
38+
}
2939

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

35-
// 시간대 유효성 검사
3645
MentorSlotValidator.validateTimeSlot(reqDto.startDateTime(), reqDto.endDateTime());
37-
38-
// 기존 슬롯과 시간 겹치는지 검사
3946
validateOverlappingSlots(mentor, reqDto.startDateTime(), reqDto.endDateTime());
4047

4148
MentorSlot mentorSlot = MentorSlot.builder()
4249
.mentor(mentor)
4350
.startDateTime(reqDto.startDateTime())
4451
.endDateTime(reqDto.endDateTime())
4552
.build();
46-
4753
mentorSlotRepository.save(mentorSlot);
4854

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

58-
if (!mentorSlot.getMentor().equals(mentor)) {
59-
throw new ServiceException(MentorSlotErrorCode.NOT_OWNER);
60-
}
61-
if (!mentorSlot.isAvailable()) {
62-
throw new ServiceException(MentorSlotErrorCode.CANNOT_UPDATE_RESERVED_SLOT);
63-
}
64+
validateOwner(mentorSlot, mentor);
65+
// 활성화된 예약이 있으면 수정 불가
66+
validateModification(mentorSlot);
6467

65-
// 시간대 유효성 검사
6668
MentorSlotValidator.validateTimeSlot(reqDto.startDateTime(), reqDto.endDateTime());
67-
68-
// 기존 슬롯과 시간 겹치는지 검사
6969
validateOverlappingExcept(mentor, mentorSlot, reqDto.startDateTime(), reqDto.endDateTime());
7070

71-
mentorSlot.update(reqDto.startDateTime(), reqDto.endDateTime());
71+
mentorSlot.updateTime(reqDto.startDateTime(), reqDto.endDateTime());
7272

7373
return MentorSlotResponse.from(mentorSlot, mentoring);
7474
}
7575

76+
@Transactional
77+
public void deleteMentorSlot(Long slotId, Member member) {
78+
Mentor mentor = findMentor(member);
79+
MentorSlot mentorSlot = findMentorSlot(slotId);
80+
81+
validateOwner(mentorSlot, mentor);
82+
// 예약 기록 존재 여부 검증 (모든 예약 기록 확인)
83+
validateNoReservationHistory(slotId);
84+
85+
mentorSlotRepository.delete(mentorSlot);
86+
}
87+
7688

7789
// ===== 헬퍼 메서드 =====
7890

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

98110
// ===== 검증 메서드 =====
99111

112+
private static void validateOwner(MentorSlot mentorSlot, Mentor mentor) {
113+
if (!mentorSlot.isOwnerBy(mentor)) {
114+
throw new ServiceException(MentorSlotErrorCode.NOT_OWNER);
115+
}
116+
}
117+
118+
/**
119+
* 주어진 시간대가 기존 슬롯과 겹치는지 검증
120+
* - mentor의 기존 모든 슬롯과 비교
121+
*/
100122
private void validateOverlappingSlots(Mentor mentor, LocalDateTime start, LocalDateTime end) {
101123
if (mentorSlotRepository.existsOverlappingSlot(mentor.getId(), start, end)) {
102124
throw new ServiceException(MentorSlotErrorCode.OVERLAPPING_SLOT);
103125
}
104126
}
105127

128+
/**
129+
* 특정 슬롯을 제외하고 시간대가 겹치는지 검증
130+
* - 대상 슬롯(self)은 제외
131+
*/
106132
private void validateOverlappingExcept(Mentor mentor, MentorSlot mentorSlot, LocalDateTime start, LocalDateTime end) {
107133
if (mentorSlotRepository.existsOverlappingExcept(mentor.getId(), mentorSlot.getId(), start, end)) {
108134
throw new ServiceException(MentorSlotErrorCode.OVERLAPPING_SLOT);
109135
}
110136
}
137+
138+
/**
139+
* 활성화된 예약이 있으면 수정 불가
140+
* - 예약 취소, 예약 거절 상태는 수정 가능
141+
*/
142+
private static void validateModification(MentorSlot mentorSlot) {
143+
if (!mentorSlot.isAvailable()) {
144+
throw new ServiceException(MentorSlotErrorCode.CANNOT_UPDATE_RESERVED_SLOT);
145+
}
146+
}
147+
148+
/**
149+
* 예약 기록이 하나라도 있으면 삭제 불가
150+
* - 데이터 무결성 보장
151+
* - 히스토리 보존
152+
*/
153+
private void validateNoReservationHistory(Long slotId) {
154+
if (reservationRepository.existsByMentorSlotId(slotId)) {
155+
throw new ServiceException(MentorSlotErrorCode.CANNOT_DELETE_RESERVED_SLOT);
156+
}
157+
}
111158
}

back/src/main/java/com/back/global/exception/ServiceException.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.back.global.exception;
22

33
import com.back.global.rsData.RsData;
4+
import lombok.Getter;
45

56
public class ServiceException extends RuntimeException {
7+
@Getter
68
private final String resultCode;
79
private final String msg;
810

0 commit comments

Comments
 (0)