Skip to content

Commit efbef4f

Browse files
sso0omluckhee
authored andcommitted
[Feat] 예약 확정/취소/변경 (#125)
* Feat: 예약 수락 * Fix: 하드코딩된 일정 -> now 기준으로 변경 * Feat: 예약 엔티티 검증 * Refactor: 예약 수락 시 동시성 제어 * Feat: 예약 거절, 취소
1 parent 507a6ef commit efbef4f

File tree

17 files changed

+828
-88
lines changed

17 files changed

+828
-88
lines changed

back/src/main/java/com/back/domain/member/member/service/MemberStorage.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import lombok.RequiredArgsConstructor;
1111
import org.springframework.stereotype.Component;
1212

13+
import java.util.Optional;
14+
1315
@Component
1416
@RequiredArgsConstructor
1517
public class MemberStorage {
@@ -27,6 +29,9 @@ public Mentor findMentorByMemberId(Long memberId) {
2729
return mentorRepository.findByMemberIdWithMember(memberId)
2830
.orElseThrow(() -> new ServiceException(MemberErrorCode.NOT_FOUND_MENTOR));
2931
}
32+
public Optional<Mentor> findMentorByMemberOptional(Member member) {
33+
return mentorRepository.findByMemberIdWithMember(member.getId());
34+
}
3035

3136
public Mentee findMenteeByMember(Member member) {
3237
return menteeRepository.findByMemberIdWithMember(member.getId())

back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringStorage.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import com.back.domain.mentoring.mentoring.entity.Mentoring;
55
import com.back.domain.mentoring.mentoring.error.MentoringErrorCode;
66
import com.back.domain.mentoring.mentoring.repository.MentoringRepository;
7+
import com.back.domain.mentoring.reservation.entity.Reservation;
8+
import com.back.domain.mentoring.reservation.error.ReservationErrorCode;
79
import com.back.domain.mentoring.reservation.repository.ReservationRepository;
810
import com.back.domain.mentoring.slot.entity.MentorSlot;
911
import com.back.domain.mentoring.slot.error.MentorSlotErrorCode;
@@ -52,6 +54,11 @@ public MentorSlot findMentorSlot(Long slotId) {
5254
.orElseThrow(() -> new ServiceException(MentorSlotErrorCode.NOT_FOUND_MENTOR_SLOT));
5355
}
5456

57+
public Reservation findReservation(Long reservationId) {
58+
return reservationRepository.findById(reservationId)
59+
.orElseThrow(() -> new ServiceException(ReservationErrorCode.NOT_FOUND_RESERVATION));
60+
}
61+
5562

5663
// ==== exists 메서드 =====
5764

back/src/main/java/com/back/domain/mentoring/reservation/constant/ReservationStatus.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,21 @@ public enum ReservationStatus {
55
APPROVED, // 승인됨
66
REJECTED, // 거절됨
77
CANCELED, // 취소됨
8-
COMPLETED // 완료됨
8+
COMPLETED; // 완료됨
9+
10+
public boolean canApprove() {
11+
return this == PENDING;
12+
}
13+
14+
public boolean canReject() {
15+
return this == PENDING;
16+
}
17+
18+
public boolean canCancel() {
19+
return this == PENDING || this == APPROVED;
20+
}
21+
22+
public boolean canComplete() {
23+
return this == APPROVED;
24+
}
925
}

back/src/main/java/com/back/domain/mentoring/reservation/controller/ReservationController.java

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.back.domain.mentoring.reservation.controller;
22

3+
import com.back.domain.member.member.entity.Member;
34
import com.back.domain.member.member.service.MemberStorage;
45
import com.back.domain.member.mentee.entity.Mentee;
6+
import com.back.domain.member.mentor.entity.Mentor;
57
import com.back.domain.mentoring.reservation.dto.request.ReservationRequest;
68
import com.back.domain.mentoring.reservation.dto.response.ReservationResponse;
79
import com.back.domain.mentoring.reservation.service.ReservationService;
@@ -12,10 +14,9 @@
1214
import jakarta.validation.Valid;
1315
import lombok.RequiredArgsConstructor;
1416
import org.springframework.security.access.prepost.PreAuthorize;
15-
import org.springframework.web.bind.annotation.PostMapping;
16-
import org.springframework.web.bind.annotation.RequestBody;
17-
import org.springframework.web.bind.annotation.RequestMapping;
18-
import org.springframework.web.bind.annotation.RestController;
17+
import org.springframework.web.bind.annotation.*;
18+
19+
import java.util.Optional;
1920

2021
@RestController
2122
@RequestMapping("/reservations")
@@ -43,4 +44,61 @@ public RsData<ReservationResponse> createReservation(
4344
resDto
4445
);
4546
}
47+
48+
@PatchMapping("/{reservationId}/approve")
49+
@PreAuthorize("hasRole('MENTOR')")
50+
@Operation(summary = "예약 수락", description = "멘토가 멘티의 예약 신청을 수락합니다. 로그인한 멘토만 예약 수락할 수 있습니다.")
51+
public RsData<ReservationResponse> approveReservation(
52+
@PathVariable Long reservationId
53+
) {
54+
Mentor mentor = memberStorage.findMentorByMember(rq.getActor());
55+
56+
ReservationResponse resDto = reservationService.approveReservation(mentor, reservationId);
57+
58+
return new RsData<>(
59+
"200",
60+
"예약이 수락되었습니다.",
61+
resDto
62+
);
63+
}
64+
65+
@PatchMapping("/{reservationId}/reject")
66+
@PreAuthorize("hasRole('MENTOR')")
67+
@Operation(summary = "예약 거절", description = "멘토가 멘티의 예약 신청을 거절합니다. 로그인한 멘토만 예약 거절할 수 있습니다.")
68+
public RsData<ReservationResponse> rejectReservation(
69+
@PathVariable Long reservationId
70+
) {
71+
Mentor mentor = memberStorage.findMentorByMember(rq.getActor());
72+
73+
ReservationResponse resDto = reservationService.rejectReservation(mentor, reservationId);
74+
75+
return new RsData<>(
76+
"200",
77+
"예약이 거절되었습니다.",
78+
resDto
79+
);
80+
}
81+
82+
@PatchMapping("/{reservationId}/cancel")
83+
@Operation(summary = "예약 취소", description = "멘토 또는 멘티가 예약을 취소합니다. 로그인 후 예약 취소할 수 있습니다.")
84+
public RsData<ReservationResponse> cancelReservation(
85+
@PathVariable Long reservationId
86+
) {
87+
Member member = rq.getActor();
88+
ReservationResponse resDto;
89+
90+
Optional<Mentor> mentor = memberStorage.findMentorByMemberOptional(member);
91+
if (mentor.isPresent()) {
92+
resDto = reservationService.cancelReservation(mentor.get(), reservationId);
93+
} else {
94+
Mentee mentee = memberStorage.findMenteeByMember(member);
95+
resDto = reservationService.cancelReservation(mentee, reservationId);
96+
}
97+
98+
return new RsData<>(
99+
"200",
100+
"예약이 취소되었습니다.",
101+
resDto
102+
);
103+
}
46104
}

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

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import com.back.domain.member.mentor.entity.Mentor;
55
import com.back.domain.mentoring.mentoring.entity.Mentoring;
66
import com.back.domain.mentoring.reservation.constant.ReservationStatus;
7+
import com.back.domain.mentoring.reservation.error.ReservationErrorCode;
78
import com.back.domain.mentoring.slot.entity.MentorSlot;
9+
import com.back.global.exception.ServiceException;
810
import com.back.global.jpa.BaseEntity;
911
import jakarta.persistence.*;
1012
import lombok.Builder;
@@ -38,6 +40,9 @@ public class Reservation extends BaseEntity {
3840
@Column(nullable = false)
3941
private ReservationStatus status;
4042

43+
@Version
44+
private Long version;
45+
4146
@Builder
4247
public Reservation(Mentoring mentoring, Mentee mentee, MentorSlot mentorSlot, String preQuestion) {
4348
this.mentoring = mentoring;
@@ -48,18 +53,104 @@ public Reservation(Mentoring mentoring, Mentee mentee, MentorSlot mentorSlot, St
4853
this.status = ReservationStatus.PENDING;
4954
}
5055

51-
public void updateStatus(ReservationStatus status) {
56+
private void updateStatus(ReservationStatus status) {
5257
this.status = status;
5358

5459
// 양방향 동기화
55-
if (status.equals(ReservationStatus.CANCELED) || status.equals(ReservationStatus.REJECTED)) {
60+
if (status == ReservationStatus.CANCELED || status == ReservationStatus.REJECTED) {
5661
mentorSlot.removeReservation();
5762
} else {
5863
mentorSlot.updateStatus();
5964
}
6065
}
6166

67+
public boolean isMentor(Mentor mentor) {
68+
return this.mentor.equals(mentor);
69+
}
70+
6271
public boolean isMentee(Mentee mentee) {
6372
return this.mentee.equals(mentee);
6473
}
74+
75+
public void approve(Mentor mentor) {
76+
ensureMentor(mentor);
77+
ensureCanApprove();
78+
ensureNotPast();
79+
updateStatus(ReservationStatus.APPROVED);
80+
}
81+
82+
public void reject(Mentor mentor) {
83+
ensureMentor(mentor);
84+
ensureCanReject();
85+
ensureNotPast();
86+
updateStatus(ReservationStatus.REJECTED);
87+
}
88+
89+
public void cancel(Mentor mentor) {
90+
ensureMentor(mentor);
91+
ensureCanCancel();
92+
ensureNotPast();
93+
updateStatus(ReservationStatus.CANCELED);
94+
}
95+
96+
public void cancel(Mentee mentee) {
97+
ensureMentee(mentee);
98+
ensureCanCancel();
99+
ensureNotPast();
100+
updateStatus(ReservationStatus.CANCELED);
101+
}
102+
103+
public void complete() {
104+
ensureCanComplete();
105+
updateStatus(ReservationStatus.COMPLETED);
106+
}
107+
108+
109+
// ===== 헬퍼 메서드 =====
110+
111+
private void ensureMentor(Mentor mentor) {
112+
if (!isMentor(mentor)) {
113+
throw new ServiceException(ReservationErrorCode.FORBIDDEN_NOT_MENTOR);
114+
}
115+
}
116+
117+
private void ensureMentee(Mentee mentee) {
118+
if (!isMentee(mentee)) {
119+
throw new ServiceException(ReservationErrorCode.FORBIDDEN_NOT_MENTEE);
120+
}
121+
}
122+
123+
private void ensureCanApprove() {
124+
if(!this.status.canApprove()) {
125+
throw new ServiceException(ReservationErrorCode.CANNOT_APPROVE);
126+
}
127+
}
128+
129+
private void ensureCanReject() {
130+
if(!this.status.canReject()) {
131+
throw new ServiceException(ReservationErrorCode.CANNOT_REJECT);
132+
}
133+
}
134+
135+
private void ensureCanCancel() {
136+
if(!this.status.canCancel()) {
137+
throw new ServiceException(ReservationErrorCode.CANNOT_CANCEL);
138+
}
139+
}
140+
141+
private void ensureCanComplete() {
142+
if(!this.status.canComplete()) {
143+
throw new ServiceException(ReservationErrorCode.CANNOT_COMPLETE);
144+
}
145+
// 시작 이후 완료 가능 (조기 종료 허용)
146+
if (!mentorSlot.isPast()) {
147+
throw new ServiceException(ReservationErrorCode.MENTORING_NOT_STARTED);
148+
}
149+
}
150+
151+
private void ensureNotPast() {
152+
if (mentorSlot.isPast()) {
153+
throw new ServiceException(ReservationErrorCode.INVALID_MENTOR_SLOT);
154+
}
155+
}
65156
}

back/src/main/java/com/back/domain/mentoring/reservation/error/ReservationErrorCode.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,28 @@
77
@Getter
88
@RequiredArgsConstructor
99
public enum ReservationErrorCode implements ErrorCode {
10+
11+
// 400
12+
CANNOT_APPROVE("400-1", "예약 요청 상태가 아닙니다. 수락이 불가능합니다."),
13+
CANNOT_REJECT("400-2", "예약 요청 상태가 아닙니다. 거절이 불가능합니다."),
14+
CANNOT_CANCEL("400-3", "예약 요청 상태 또는 예약 승인 상태가 아닙니다. 취소가 불가능합니다."),
15+
CANNOT_COMPLETE("400-4", "예약 승인 상태가 아닙니다. 완료가 불가능합니다."),
16+
INVALID_MENTOR_SLOT("400-5", "이미 시간이 지난 슬롯입니다. 예약 상태 변경이 불가능합니다."),
17+
MENTORING_NOT_STARTED("400-6", "멘토링이 시작되지 않았습니다. 완료가 불가능합니다."),
18+
19+
20+
// 403
21+
FORBIDDEN_NOT_MENTOR("403-1", "해당 예약에 대한 멘토 권한이 없습니다."),
22+
FORBIDDEN_NOT_MENTEE("403-2", "해당 예약에 대한 멘티 권한이 없습니다."),
23+
1024
// 404
1125
NOT_FOUND_RESERVATION("404-1", "예약이 존재하지 않습니다."),
1226

1327
// 409
1428
NOT_AVAILABLE_SLOT("409-1", "이미 예약이 완료된 시간대입니다."),
1529
ALREADY_RESERVED_SLOT("409-2", "이미 예약한 시간대입니다. 예약 목록을 확인해 주세요."),
16-
CONCURRENT_RESERVATION_CONFLICT("409-3", "다른 사용자가 먼저 예약했습니다. 새로고침 후 다시 시도해 주세요.");
30+
CONCURRENT_RESERVATION_CONFLICT("409-3", "다른 사용자가 먼저 예약했습니다. 새로고침 후 다시 시도해 주세요."),
31+
CONCURRENT_APPROVAL_CONFLICT("409-4", "이미 수락한 예약입니다.");
1732

1833
private final String code;
1934
private final String message;

back/src/main/java/com/back/domain/mentoring/reservation/service/ReservationService.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.back.domain.mentoring.reservation.service;
22

33
import com.back.domain.member.mentee.entity.Mentee;
4+
import com.back.domain.member.mentor.entity.Mentor;
45
import com.back.domain.mentoring.mentoring.entity.Mentoring;
56
import com.back.domain.mentoring.mentoring.service.MentoringStorage;
67
import com.back.domain.mentoring.reservation.constant.ReservationStatus;
@@ -44,6 +45,7 @@ public ReservationResponse createReservation(Mentee mentee, ReservationRequest r
4445
.build();
4546

4647
mentorSlot.setReservation(reservation);
48+
// flush 필요...?
4749

4850
reservationRepository.save(reservation);
4951

@@ -53,6 +55,42 @@ public ReservationResponse createReservation(Mentee mentee, ReservationRequest r
5355
}
5456
}
5557

58+
@Transactional
59+
public ReservationResponse approveReservation(Mentor mentor, Long reservationId) {
60+
try {
61+
Reservation reservation = mentoringStorage.findReservation(reservationId);
62+
63+
reservation.approve(mentor);
64+
65+
// 세션
66+
67+
return ReservationResponse.from(reservation);
68+
} catch (OptimisticLockException e) {
69+
throw new ServiceException(ReservationErrorCode.CONCURRENT_APPROVAL_CONFLICT);
70+
}
71+
}
72+
73+
@Transactional
74+
public ReservationResponse rejectReservation(Mentor mentor, Long reservationId) {
75+
Reservation reservation = mentoringStorage.findReservation(reservationId);
76+
reservation.reject(mentor);
77+
return ReservationResponse.from(reservation);
78+
}
79+
80+
@Transactional
81+
public ReservationResponse cancelReservation(Mentor mentor, Long reservationId) {
82+
Reservation reservation = mentoringStorage.findReservation(reservationId);
83+
reservation.cancel(mentor);
84+
return ReservationResponse.from(reservation);
85+
}
86+
87+
@Transactional
88+
public ReservationResponse cancelReservation(Mentee mentee, Long reservationId) {
89+
Reservation reservation = mentoringStorage.findReservation(reservationId);
90+
reservation.cancel(mentee);
91+
return ReservationResponse.from(reservation);
92+
}
93+
5694

5795
// ===== 검증 메서드 =====
5896

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,8 @@ public boolean isAvailable() {
9797
public boolean isOwnerBy(Mentor mentor) {
9898
return this.mentor.equals(mentor);
9999
}
100+
101+
public boolean isPast() {
102+
return startDateTime.isBefore(LocalDateTime.now());
103+
}
100104
}

back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,9 @@ void deleteMentoringSuccess() throws Exception {
309309
@DisplayName("멘토링 삭제 성공 - 멘토 슬롯이 있는 경우")
310310
void deleteMentoringSuccessExistsMentorSlot() throws Exception {
311311
Mentoring mentoring = mentoringFixture.createMentoring(mentor);
312-
LocalDateTime baseDateTime = LocalDateTime.of(2025, 10, 1, 10, 0);
313-
mentoringFixture.createMentorSlots(mentor, baseDateTime, 3, 2);
312+
313+
LocalDateTime baseDateTime = LocalDateTime.now().plusMonths(3);
314+
mentoringFixture.createMentorSlots(mentor, baseDateTime, 3, 2, 30L);
314315

315316
long preMentoringCnt = mentoringRepository.count();
316317
long preSlotCnt = mentorSlotRepository.count();

0 commit comments

Comments
 (0)