diff --git a/back/.env.default b/back/.env.default index 56fb874d..08978a25 100644 --- a/back/.env.default +++ b/back/.env.default @@ -1,3 +1,5 @@ CUSTOM__JWT__SECRET_KEY=NEED_TO_SET DB_USERNAME=NEED_TO_SET DB_PASSWORD=NEED_TO_SET +MAIL_USERNAME=NEED_TO_SET +MAIL_PASSWORD=NEED_TO_SET diff --git a/back/build.gradle.kts b/back/build.gradle.kts index 8f5cd7bd..728deb73 100644 --- a/back/build.gradle.kts +++ b/back/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-web") implementation ("org.springframework.boot:spring-boot-starter-data-redis") + implementation("org.springframework.boot:spring-boot-starter-mail") // QueryDSL implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta") @@ -65,8 +66,12 @@ dependencies { implementation ("software.amazon.awssdk:s3:2.25.0") implementation ("org.springframework.kafka:spring-kafka") + implementation("org.springframework.boot:spring-boot-starter-websocket") runtimeOnly("com.mysql:mysql-connector-j") + + // Sentry + implementation("io.sentry:sentry-spring-boot-starter-jakarta:8.19.1") } tasks.withType { diff --git a/back/src/main/java/com/back/BackApplication.java b/back/src/main/java/com/back/BackApplication.java index 5c84eef1..89fd1b4e 100644 --- a/back/src/main/java/com/back/BackApplication.java +++ b/back/src/main/java/com/back/BackApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableScheduling public class BackApplication { public static void main(String[] args) { diff --git a/back/src/main/java/com/back/domain/file/video/controller/VideoController.java b/back/src/main/java/com/back/domain/file/video/controller/VideoController.java index 73189046..4539e52d 100644 --- a/back/src/main/java/com/back/domain/file/video/controller/VideoController.java +++ b/back/src/main/java/com/back/domain/file/video/controller/VideoController.java @@ -6,6 +6,7 @@ import com.back.global.rsData.RsData; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -16,7 +17,8 @@ public class VideoController { private final FileManager fileManager; @GetMapping("/videos/upload") - @Operation(summary="업로드용 URL 요청", description="파일 업로드를 위한 Presigned URL을 발급받습니다.") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "업로드용 URL 요청", description = "파일 업로드를 위한 Presigned URL을 발급받습니다.") public RsData getUploadUrl() { PresignedUrlResponse uploadUrl = fileManager.getUploadUrl(); UploadUrlGetResponse response = new UploadUrlGetResponse(uploadUrl.url().toString(), uploadUrl.expiresAt()); @@ -24,7 +26,7 @@ public RsData getUploadUrl() { } @GetMapping("/videos/download") - @Operation(summary="다운로드용 URL 요청", description="파일 다운로드를 위한 Presigned URL을 발급받습니다.") + @Operation(summary = "다운로드용 URL 요청", description = "파일 다운로드를 위한 Presigned URL을 발급받습니다.") public RsData getDownloadUrls(@RequestParam String objectKey) { PresignedUrlResponse downloadUrl = fileManager.getDownloadUrl(objectKey); UploadUrlGetResponse response = new UploadUrlGetResponse(downloadUrl.url().toString(), downloadUrl.expiresAt()); diff --git a/back/src/main/java/com/back/domain/member/member/email/EmailService.java b/back/src/main/java/com/back/domain/member/member/email/EmailService.java new file mode 100644 index 00000000..c66cd2e0 --- /dev/null +++ b/back/src/main/java/com/back/domain/member/member/email/EmailService.java @@ -0,0 +1,118 @@ +package com.back.domain.member.member.email; + +import com.back.global.exception.ServiceException; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +import java.io.UnsupportedEncodingException; + +@Slf4j +@Service +@RequiredArgsConstructor +@ConditionalOnBean(JavaMailSender.class) +public class EmailService { + private final JavaMailSender mailSender; + + /** + * 간단한 텍스트 이메일 발송 + */ + public void sendSimpleEmail(String to, String subject, String text) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom("snake20011600@gmail.com", "JobMate"); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(text, false); // false = plain text + + mailSender.send(message); + log.info("텍스트 이메일 발송 성공: {}", to); + } catch (MessagingException | UnsupportedEncodingException e) { + log.error("텍스트 이메일 발송 실패: {}", to, e); + throw new ServiceException("500-1", "텍스트 이메일 발송에 실패했습니다."); + } + } + + /** + * HTML 이메일 발송 + */ + public void sendHtmlEmail(String to, String subject, String htmlContent) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom("snake20011600@gmail.com", "JobMate"); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(htmlContent, true); // true = HTML + + mailSender.send(message); + log.info("HTML 이메일 발송 성공: {}", to); + } catch (MessagingException | UnsupportedEncodingException e) { + log.error("HTML 이메일 발송 실패: {}", to, e); + throw new ServiceException("500-2", "HTML 이메일 발송에 실패했습니다."); + } + } + + /** + * 인증번호 이메일 발송 (멘토 회원가입용) + */ + public void sendVerificationCode(String to, String verificationCode) { + String subject = "[FiveLogic] 멘토 회원가입 인증번호"; + String htmlContent = buildVerificationEmailHtml(verificationCode); + sendHtmlEmail(to, subject, htmlContent); + } + + /** + * 인증번호 이메일 HTML 템플릿 + */ + private String buildVerificationEmailHtml(String verificationCode) { + return """ + + + + + + + +
+
+

JobMate 멘토 회원가입

+
+
+

인증번호 확인

+

안녕하세요. FiveLogic입니다.

+

멘토 회원가입을 위한 인증번호를 안내드립니다.

+

아래 인증번호를 입력하여 회원가입을 완료해주세요.

+ +
+
%s
+
+ +

※ 인증번호는 5분간 유효합니다.

+

본인이 요청하지 않은 경우, 이 메일을 무시하셔도 됩니다.

+
+ +
+ + + """.formatted(verificationCode); + } +} diff --git a/back/src/main/java/com/back/domain/member/member/service/MemberStorage.java b/back/src/main/java/com/back/domain/member/member/service/MemberStorage.java index c7d44a1c..8131a22a 100644 --- a/back/src/main/java/com/back/domain/member/member/service/MemberStorage.java +++ b/back/src/main/java/com/back/domain/member/member/service/MemberStorage.java @@ -2,6 +2,7 @@ import com.back.domain.member.member.entity.Member; import com.back.domain.member.member.error.MemberErrorCode; +import com.back.domain.member.member.repository.MemberRepository; import com.back.domain.member.mentee.entity.Mentee; import com.back.domain.member.mentee.repository.MenteeRepository; import com.back.domain.member.mentor.entity.Mentor; @@ -13,7 +14,7 @@ @Component @RequiredArgsConstructor public class MemberStorage { - + private final MemberRepository memberRepository; private final MentorRepository mentorRepository; private final MenteeRepository menteeRepository; @@ -39,4 +40,9 @@ public Mentee findMenteeByMember(Member member) { public boolean existsMentorById(Long mentorId) { return mentorRepository.existsById(mentorId); } + + public Member findMemberByEmail(String email) { + return memberRepository.findByEmail(email) + .orElseThrow(() -> new ServiceException(MemberErrorCode.NOT_FOUND_MEMBER)); + } } diff --git a/back/src/main/java/com/back/domain/member/member/verification/EmailVerificationService.java b/back/src/main/java/com/back/domain/member/member/verification/EmailVerificationService.java index ce98c1d4..bb148cc9 100644 --- a/back/src/main/java/com/back/domain/member/member/verification/EmailVerificationService.java +++ b/back/src/main/java/com/back/domain/member/member/verification/EmailVerificationService.java @@ -1,8 +1,9 @@ package com.back.domain.member.member.verification; +import com.back.domain.member.member.email.EmailService; import com.back.global.exception.ServiceException; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.security.SecureRandom; @@ -10,13 +11,19 @@ import java.util.Optional; @Service -@RequiredArgsConstructor @Slf4j public class EmailVerificationService { private final VerificationCodeStore codeStore; + private final Optional emailService; private static final Duration CODE_TTL = Duration.ofMinutes(5); private static final SecureRandom random = new SecureRandom(); + public EmailVerificationService(VerificationCodeStore codeStore, + @Autowired(required = false) EmailService emailService) { + this.codeStore = codeStore; + this.emailService = Optional.ofNullable(emailService); + } + public String generateAndSendCode(String email) { // 6자리 랜덤 코드 생성 String code = String.format("%06d", random.nextInt(1000000)); @@ -24,8 +31,12 @@ public String generateAndSendCode(String email) { // 저장 codeStore.saveCode(email, code, CODE_TTL); - // TODO: 실제 이메일 발송 로직 추가 (JavaMailSender) - log.info("Generated verification code for {}: {}", email, code); + // 이메일 발송 (EmailService가 있을 때만) + emailService.ifPresentOrElse( + service -> service.sendVerificationCode(email, code), + () -> log.info("EmailService not available - verification code: {}", code) + ); + log.info("Generated and sent verification code for {}: {}", email, code); return code; // 테스트용으로 반환 (실제로는 이메일로만 전송) } @@ -45,4 +56,4 @@ public void verifyCode(String email, String inputCode) { codeStore.deleteCode(email); log.info("Email verification successful for: {}", email); } -} \ No newline at end of file +} diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/controller/ReviewController.java b/back/src/main/java/com/back/domain/mentoring/mentoring/controller/ReviewController.java index d224ee3f..8dcd4b9f 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/controller/ReviewController.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/controller/ReviewController.java @@ -25,7 +25,7 @@ public class ReviewController { private final ReviewService reviewService; @GetMapping("/mentorings/{mentoringId}/reviews") - @Operation(summary = "멘토링 리뷰 조회", description = "멘토링 리뷰 목록을 조회합니다.") + @Operation(summary = "멘토링 리뷰 목록 조회", description = "멘토링 리뷰 목록을 조회합니다.") public RsData getReviews( @PathVariable Long mentoringId, @RequestParam(defaultValue = "0") int page, diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/dto/MentoringDetailDto.java b/back/src/main/java/com/back/domain/mentoring/mentoring/dto/MentoringDetailDto.java index 21ebb055..5f341d86 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/dto/MentoringDetailDto.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/dto/MentoringDetailDto.java @@ -17,6 +17,8 @@ public record MentoringDetailDto( String bio, @Schema(description = "멘토링 썸네일") String thumb, + @Schema(description = "멘토링 평점") + Double rating, @Schema(description = "생성일") LocalDateTime createDate, @Schema(description = "수정일") @@ -29,6 +31,7 @@ public static MentoringDetailDto from(Mentoring mentoring) { mentoring.getTagNames(), mentoring.getBio(), mentoring.getThumb(), + mentoring.getRating(), mentoring.getCreateDate(), mentoring.getModifyDate() ); diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/dto/request/MentoringRequest.java b/back/src/main/java/com/back/domain/mentoring/mentoring/dto/request/MentoringRequest.java index e9853dc7..23386e80 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/dto/request/MentoringRequest.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/dto/request/MentoringRequest.java @@ -7,18 +7,18 @@ import java.util.List; public record MentoringRequest( - @Schema(description = "멘토링 제목") + @Schema(description = "멘토링 제목", example = "title") @NotNull @Size(max = 100) String title, @Schema(description = "멘토링 태그", example = "[\"Java\", \"Spring\"]") List tags, - @Schema(description = "멘토링 소개") + @Schema(description = "멘토링 소개", example = "bio") @NotNull String bio, - @Schema(description = "멘토링 썸네일") + @Schema(description = "멘토링 썸네일", example = "test.png") String thumb ) { } diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/dto/request/ReviewRequest.java b/back/src/main/java/com/back/domain/mentoring/mentoring/dto/request/ReviewRequest.java index 009b1735..f70cdbc1 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/dto/request/ReviewRequest.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/dto/request/ReviewRequest.java @@ -11,7 +11,7 @@ public record ReviewRequest( Double rating, @Size(max = 1000) - @Schema(description = "리뷰 내용") + @Schema(description = "리뷰 내용", example = "review content") String content ) { } diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/entity/Mentoring.java b/back/src/main/java/com/back/domain/mentoring/mentoring/entity/Mentoring.java index f533fced..7e90b6a4 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/entity/Mentoring.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/entity/Mentoring.java @@ -30,6 +30,9 @@ public class Mentoring extends BaseEntity { @Column(length = 255) private String thumb; + @Column + private double rating = 0.0; + @Builder public Mentoring(Mentor mentor, String title, String bio, String thumb) { this.mentor = mentor; @@ -56,6 +59,10 @@ public void updateTags(List tags) { } } + public void updateRating(double averageRating) { + this.rating = averageRating; + } + public boolean isOwner(Mentor mentor) { return this.mentor.equals(mentor); } diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/error/MentoringErrorCode.java b/back/src/main/java/com/back/domain/mentoring/mentoring/error/MentoringErrorCode.java index 074a4506..8715f8b8 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/error/MentoringErrorCode.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/error/MentoringErrorCode.java @@ -20,7 +20,7 @@ public enum MentoringErrorCode implements ErrorCode { NOT_FOUND_MENTEE("404-3", "멘티를 찾을 수 없습니다."), // 409 - ALREADY_EXISTS_MENTORING("409-1", "이미 멘토링 정보가 존재합니다."); + ALREADY_EXISTS_MENTORING("409-1", "이미 동일한 이름의 멘토링이 존재합니다."); private final String code; private final String message; diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/repository/MentoringRepository.java b/back/src/main/java/com/back/domain/mentoring/mentoring/repository/MentoringRepository.java index 007ef4a6..bc4d2a7a 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/repository/MentoringRepository.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/repository/MentoringRepository.java @@ -9,5 +9,6 @@ public interface MentoringRepository extends JpaRepository, MentoringRepositoryCustom { List findByMentorId(Long mentorId); Optional findTopByOrderByIdDesc(); - boolean existsByMentorId(Long mentorId); + boolean existsByMentorIdAndTitle(Long mentorId, String title); + boolean existsByMentorIdAndTitleAndIdNot(Long mentorId, String title, Long MentoringId); } diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/repository/ReviewRepository.java b/back/src/main/java/com/back/domain/mentoring/mentoring/repository/ReviewRepository.java index 90d053ed..938462de 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/repository/ReviewRepository.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/repository/ReviewRepository.java @@ -1,6 +1,5 @@ package com.back.domain.mentoring.mentoring.repository; -import com.back.domain.member.mentor.entity.Mentor; import com.back.domain.mentoring.mentoring.entity.Review; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -9,16 +8,26 @@ import org.springframework.data.repository.query.Param; public interface ReviewRepository extends JpaRepository { - boolean existsByReservationId(Long id); + boolean existsByReservationId(Long reservationId); @Query(""" - SELECT AVG(r.rating) + SELECT ROUND(AVG(r.rating), 1) FROM Review r INNER JOIN r.reservation res - WHERE res.mentor = :mentor + WHERE res.mentor.id = :mentorId """) - Double findAverageRating( - @Param("mentor") Mentor mentor + Double calculateMentorAverageRating( + @Param("mentorId") Long mentorId + ); + + @Query(""" + SELECT ROUND(AVG(r.rating), 1) + FROM Review r + INNER JOIN r.reservation res + WHERE res.mentoring.id = :mentoringId + """) + Double calculateMentoringAverageRating( + @Param("mentoringId") Long mentoringId ); @Query(""" diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringService.java b/back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringService.java index 10769769..7a5a4984 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringService.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringService.java @@ -51,10 +51,7 @@ public MentoringResponse getMentoring(Long mentoringId) { @Transactional public MentoringResponse createMentoring(MentoringRequest reqDto, Mentor mentor) { - // 멘토당 멘토링 1개 제한 체크 (추후 1:N 변경 시 제거 필요) - if (mentoringRepository.existsByMentorId(mentor.getId())) { - throw new ServiceException(MentoringErrorCode.ALREADY_EXISTS_MENTORING); - } + validateMentoringTitle(mentor.getId(), reqDto.title()); Mentoring mentoring = Mentoring.builder() .mentor(mentor) @@ -79,6 +76,7 @@ public MentoringResponse updateMentoring(Long mentoringId, MentoringRequest reqD Mentoring mentoring = mentoringStorage.findMentoring(mentoringId); validateOwner(mentoring, mentor); + validateMentoringTitleForUpdate(mentor.getId(), reqDto.title(), mentoringId); List tags = getOrCreateTags(reqDto.tags()); @@ -100,11 +98,6 @@ public void deleteMentoring(Long mentoringId, Mentor mentor) { if (mentoringStorage.hasReservationsForMentoring(mentoring.getId())) { throw new ServiceException(MentoringErrorCode.CANNOT_DELETE_MENTORING); } - - // 멘토 슬롯 있을 시 일괄 삭제 (추후 1:N 변경 시 제거 필요) - if (mentoringStorage.hasMentorSlotsForMentor(mentor.getId())) { - mentoringStorage.deleteMentorSlotsData(mentor.getId()); - } mentoringRepository.delete(mentoring); } @@ -153,4 +146,16 @@ private void validateOwner(Mentoring mentoring, Mentor mentor) { throw new ServiceException(MentoringErrorCode.FORBIDDEN_NOT_OWNER); } } + + private void validateMentoringTitle(Long mentorId, String title) { + if (mentoringRepository.existsByMentorIdAndTitle(mentorId, title)) { + throw new ServiceException(MentoringErrorCode.ALREADY_EXISTS_MENTORING); + } + } + + private void validateMentoringTitleForUpdate(Long mentorId, String title, Long mentoringId) { + if (mentoringRepository.existsByMentorIdAndTitleAndIdNot(mentorId, title, mentoringId)) { + throw new ServiceException(MentoringErrorCode.ALREADY_EXISTS_MENTORING); + } + } } diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringStorage.java b/back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringStorage.java index 1aa13358..29d08298 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringStorage.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringStorage.java @@ -1,12 +1,13 @@ package com.back.domain.mentoring.mentoring.service; -import com.back.domain.member.mentor.entity.Mentor; 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.entity.Reservation; import com.back.domain.mentoring.reservation.error.ReservationErrorCode; import com.back.domain.mentoring.reservation.repository.ReservationRepository; +import com.back.domain.mentoring.session.entity.MentoringSession; +import com.back.domain.mentoring.session.repository.MentoringSessionRepository; import com.back.domain.mentoring.slot.entity.MentorSlot; import com.back.domain.mentoring.slot.error.MentorSlotErrorCode; import com.back.domain.mentoring.slot.repository.MentorSlotRepository; @@ -28,6 +29,7 @@ public class MentoringStorage { private final MentoringRepository mentoringRepository; private final MentorSlotRepository mentorSlotRepository; private final ReservationRepository reservationRepository; + private final MentoringSessionRepository mentoringSessionRepository; // ===== find 메서드 ===== @@ -36,11 +38,6 @@ public Mentoring findMentoring(Long mentoringId) { .orElseThrow(() -> new ServiceException(MentoringErrorCode.NOT_FOUND_MENTORING)); } - // TODO : 멘토:멘토링 1:N으로 변경 시 삭제 예정 - public Mentoring findMentoringByMentor(Mentor mentor) { - return findMentoringsByMentorId(mentor.getId()).getFirst(); - } - public List findMentoringsByMentorId(Long mentorId) { List mentorings = mentoringRepository.findByMentorId(mentorId); if (mentorings.isEmpty()) { @@ -80,4 +77,9 @@ public boolean hasReservationForMentorSlot(Long slotId) { public void deleteMentorSlotsData(Long mentorId) { mentorSlotRepository.deleteAllByMentorId(mentorId); } + + public MentoringSession getMentoringSessionBySessionUuid(String mentoringSessionId) { + return mentoringSessionRepository.findBySessionUrl(mentoringSessionId) + .orElseThrow(() -> new ServiceException("404", "세션을 찾을 수 없습니다.")); + } } diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/service/ReviewService.java b/back/src/main/java/com/back/domain/mentoring/mentoring/service/ReviewService.java index 23b41d28..2b079a73 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/service/ReviewService.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/service/ReviewService.java @@ -4,6 +4,7 @@ import com.back.domain.member.mentor.entity.Mentor; import com.back.domain.mentoring.mentoring.dto.request.ReviewRequest; import com.back.domain.mentoring.mentoring.dto.response.ReviewResponse; +import com.back.domain.mentoring.mentoring.entity.Mentoring; import com.back.domain.mentoring.mentoring.entity.Review; import com.back.domain.mentoring.mentoring.error.ReviewErrorCode; import com.back.domain.mentoring.mentoring.repository.ReviewRepository; @@ -54,6 +55,7 @@ public ReviewResponse createReview(Long reservationId, ReviewRequest reqDto, Men .build(); reviewRepository.save(review); + updateMentoringRating(review.getReservation().getMentoring()); updateMentorRating(reservation.getMentor()); return ReviewResponse.from(review); @@ -67,31 +69,36 @@ public ReviewResponse updateReview(Long reviewId, ReviewRequest reqDto, Mentee m validateRating(reqDto.rating()); review.update(reqDto.rating(), reqDto.content()); + updateMentoringRating(review.getReservation().getMentoring()); updateMentorRating(review.getReservation().getMentor()); return ReviewResponse.from(review); } @Transactional - public ReviewResponse deleteReview(Long reviewId, Mentee mentee) { + public void deleteReview(Long reviewId, Mentee mentee) { Review review = findReview(reviewId); validateMentee(mentee, review); reviewRepository.delete(review); + updateMentoringRating(review.getReservation().getMentoring()); updateMentorRating(review.getReservation().getMentor()); - - return ReviewResponse.from(review); } // ===== 평점 업데이트 ===== private void updateMentorRating(Mentor mentor) { - Double averageRating = reviewRepository.findAverageRating(mentor); + Double averageRating = reviewRepository.calculateMentorAverageRating(mentor.getId()); mentor.updateRating(averageRating != null ? averageRating : 0.0); } + private void updateMentoringRating(Mentoring mentoring) { + Double averageRating = reviewRepository.calculateMentoringAverageRating (mentoring.getId()); + mentoring.updateRating(averageRating != null ? averageRating : 0.0); + } + // ===== 헬퍼 메서드 ===== diff --git a/back/src/main/java/com/back/domain/mentoring/reservation/dto/ReservationDetailDto.java b/back/src/main/java/com/back/domain/mentoring/reservation/dto/ReservationDetailDto.java index b22a195a..8a5c5042 100644 --- a/back/src/main/java/com/back/domain/mentoring/reservation/dto/ReservationDetailDto.java +++ b/back/src/main/java/com/back/domain/mentoring/reservation/dto/ReservationDetailDto.java @@ -2,40 +2,58 @@ import com.back.domain.mentoring.reservation.constant.ReservationStatus; import com.back.domain.mentoring.reservation.entity.Reservation; +import com.back.domain.mentoring.session.entity.MentoringSession; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; public record ReservationDetailDto( - @Schema(description = "예약 ID") - Long reservationId, - @Schema(description = "예약 상태") - ReservationStatus status, - @Schema(description = "사전 질문") - String preQuestion, + @Schema(description = "예약 ID") + Long reservationId, + @Schema(description = "예약 상태") + ReservationStatus status, + @Schema(description = "사전 질문") + String preQuestion, - @Schema(description = "멘토 슬롯 ID") - Long mentorSlotId, - @Schema(description = "시작 일시") - LocalDateTime startDateTime, - @Schema(description = "종료 일시") - LocalDateTime endDateTime, + @Schema(description = "멘토 슬롯 ID") + Long mentorSlotId, + @Schema(description = "시작 일시") + LocalDateTime startDateTime, + @Schema(description = "종료 일시") + LocalDateTime endDateTime, - @Schema(description = "생성일") - LocalDateTime createDate, - @Schema(description = "수정일") - LocalDateTime modifyDate + @Schema(description = "생성일") + LocalDateTime createDate, + @Schema(description = "수정일") + LocalDateTime modifyDate, + @Schema(description = "멘토링 세션 ID") + Long mentoringSessionId ) { public static ReservationDetailDto from(Reservation reservation) { return new ReservationDetailDto( - reservation.getId(), - reservation.getStatus(), - reservation.getPreQuestion(), - reservation.getMentorSlot().getId(), - reservation.getMentorSlot().getStartDateTime(), - reservation.getMentorSlot().getEndDateTime(), - reservation.getCreateDate(), - reservation.getModifyDate() + reservation.getId(), + reservation.getStatus(), + reservation.getPreQuestion(), + reservation.getMentorSlot().getId(), + reservation.getMentorSlot().getStartDateTime(), + reservation.getMentorSlot().getEndDateTime(), + reservation.getCreateDate(), + reservation.getModifyDate(), + null + ); + } + + public static ReservationDetailDto from(Reservation reservation, MentoringSession mentoringSession) { + return new ReservationDetailDto( + reservation.getId(), + reservation.getStatus(), + reservation.getPreQuestion(), + reservation.getMentorSlot().getId(), + reservation.getMentorSlot().getStartDateTime(), + reservation.getMentorSlot().getEndDateTime(), + reservation.getCreateDate(), + reservation.getModifyDate(), + mentoringSession.getId() ); } } diff --git a/back/src/main/java/com/back/domain/mentoring/reservation/dto/ReservationDto.java b/back/src/main/java/com/back/domain/mentoring/reservation/dto/ReservationDto.java index 8da541c5..c6a89412 100644 --- a/back/src/main/java/com/back/domain/mentoring/reservation/dto/ReservationDto.java +++ b/back/src/main/java/com/back/domain/mentoring/reservation/dto/ReservationDto.java @@ -2,6 +2,7 @@ import com.back.domain.mentoring.reservation.constant.ReservationStatus; import com.back.domain.mentoring.reservation.entity.Reservation; +import com.back.domain.mentoring.session.entity.MentoringSession; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; @@ -22,9 +23,11 @@ public record ReservationDto( @Schema(description = "시작 일시") LocalDateTime startDateTime, @Schema(description = "종료 일시") - LocalDateTime endDateTime + LocalDateTime endDateTime, + @Schema(description = "멘토링 세션 ID") + Long mentoringSessionId ) { - public static ReservationDto from(Reservation reservation) { + public static ReservationDto from(Reservation reservation, MentoringSession mentoringSession) { return new ReservationDto( reservation.getId(), reservation.getStatus(), @@ -32,7 +35,8 @@ public static ReservationDto from(Reservation reservation) { reservation.getMentoring().getTitle(), reservation.getMentorSlot().getId(), reservation.getMentorSlot().getStartDateTime(), - reservation.getMentorSlot().getEndDateTime() + reservation.getMentorSlot().getEndDateTime(), + mentoringSession != null ? mentoringSession.getId() : null ); } } diff --git a/back/src/main/java/com/back/domain/mentoring/reservation/dto/request/ReservationRequest.java b/back/src/main/java/com/back/domain/mentoring/reservation/dto/request/ReservationRequest.java index 01f59559..890927df 100644 --- a/back/src/main/java/com/back/domain/mentoring/reservation/dto/request/ReservationRequest.java +++ b/back/src/main/java/com/back/domain/mentoring/reservation/dto/request/ReservationRequest.java @@ -16,7 +16,7 @@ public record ReservationRequest( @NotNull Long mentoringId, - @Schema(description = "사전 질문") + @Schema(description = "사전 질문", example = "question") String preQuestion ) { } diff --git a/back/src/main/java/com/back/domain/mentoring/reservation/dto/response/ReservationResponse.java b/back/src/main/java/com/back/domain/mentoring/reservation/dto/response/ReservationResponse.java index 2ef3769b..6755c092 100644 --- a/back/src/main/java/com/back/domain/mentoring/reservation/dto/response/ReservationResponse.java +++ b/back/src/main/java/com/back/domain/mentoring/reservation/dto/response/ReservationResponse.java @@ -5,6 +5,7 @@ import com.back.domain.mentoring.mentoring.dto.MentoringDto; import com.back.domain.mentoring.reservation.dto.ReservationDetailDto; import com.back.domain.mentoring.reservation.entity.Reservation; +import com.back.domain.mentoring.session.entity.MentoringSession; public record ReservationResponse( ReservationDetailDto reservation, @@ -20,4 +21,12 @@ public static ReservationResponse from(Reservation reservation) { MenteeDto.from(reservation.getMentee()) ); } + public static ReservationResponse from(Reservation reservation, MentoringSession mentoringSession) { + return new ReservationResponse( + ReservationDetailDto.from(reservation, mentoringSession), + MentoringDto.from(reservation.getMentoring()), + MentorDto.from(reservation.getMentor()), + MenteeDto.from(reservation.getMentee()) + ); + } } diff --git a/back/src/main/java/com/back/domain/mentoring/reservation/entity/Reservation.java b/back/src/main/java/com/back/domain/mentoring/reservation/entity/Reservation.java index 773ec2fd..67fbdfd9 100644 --- a/back/src/main/java/com/back/domain/mentoring/reservation/entity/Reservation.java +++ b/back/src/main/java/com/back/domain/mentoring/reservation/entity/Reservation.java @@ -37,7 +37,7 @@ public class Reservation extends BaseEntity { @JoinColumn(name = "mentee_id", nullable = false) private Mentee mentee; - @OneToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "mentor_slot_id", nullable = false) private MentorSlot mentorSlot; @@ -63,13 +63,6 @@ public Reservation(Mentoring mentoring, Mentee mentee, MentorSlot mentorSlot, St private void updateStatus(ReservationStatus status) { this.status = status; - - // 양방향 동기화 - if (status == ReservationStatus.CANCELED || status == ReservationStatus.REJECTED) { - mentorSlot.removeReservation(); - } else { - mentorSlot.updateStatus(); - } } public boolean isMentor(Mentor mentor) { diff --git a/back/src/main/java/com/back/domain/mentoring/reservation/error/ReservationErrorCode.java b/back/src/main/java/com/back/domain/mentoring/reservation/error/ReservationErrorCode.java index f97e753e..64ad5429 100644 --- a/back/src/main/java/com/back/domain/mentoring/reservation/error/ReservationErrorCode.java +++ b/back/src/main/java/com/back/domain/mentoring/reservation/error/ReservationErrorCode.java @@ -30,7 +30,8 @@ public enum ReservationErrorCode implements ErrorCode { NOT_AVAILABLE_SLOT("409-1", "이미 예약이 완료된 시간대입니다."), ALREADY_RESERVED_SLOT("409-2", "이미 예약한 시간대입니다. 예약 목록을 확인해 주세요."), CONCURRENT_RESERVATION_CONFLICT("409-3", "다른 사용자가 먼저 예약했습니다. 새로고침 후 다시 시도해 주세요."), - CONCURRENT_APPROVAL_CONFLICT("409-4", "이미 수락한 예약입니다."); + CONCURRENT_APPROVAL_CONFLICT("409-4", "이미 수락한 예약입니다."), + OVERLAPPING_TIME("409-5", "이미 해당 시간에 다른 예약이 있습니다. 예약 목록을 확인해 주세요."); private final String code; private final String message; diff --git a/back/src/main/java/com/back/domain/mentoring/reservation/repository/ReservationRepository.java b/back/src/main/java/com/back/domain/mentoring/reservation/repository/ReservationRepository.java index 39fb5bfe..d2507b80 100644 --- a/back/src/main/java/com/back/domain/mentoring/reservation/repository/ReservationRepository.java +++ b/back/src/main/java/com/back/domain/mentoring/reservation/repository/ReservationRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -20,22 +21,22 @@ public interface ReservationRepository extends JpaRepository SELECT r FROM Reservation r WHERE r.id = :reservationId - AND (r.mentee.member = :member - OR r.mentor.member = :member) + AND (r.mentee.member.id = :memberId + OR r.mentor.member.id = :memberId) """) Optional findByIdAndMember( @Param("reservationId") Long reservationId, - @Param("member") Member member + @Param("memberId") Long memberId ); @Query(""" SELECT r FROM Reservation r - WHERE r.mentor.member = :member + WHERE r.mentor.member.id = :memberId ORDER BY r.mentorSlot.startDateTime DESC """) Page findAllByMentorMember( - @Param("member") Member member, + @Param("memberId") Long memberId, Pageable pageable ); @@ -58,4 +59,21 @@ Page findAllByMenteeMember( * - 취소/거절된 예약도 히스토리로 보존 */ boolean existsByMentorSlotId(Long slotId); + + @Query(""" + SELECT CASE WHEN COUNT(r) > 0 + THEN TRUE + ELSE FALSE + END + FROM Reservation r + WHERE r.mentee.id = :menteeId + AND r.status NOT IN ('REJECTED', 'CANCELED') + AND r.mentorSlot.startDateTime < :end + AND r.mentorSlot.endDateTime > :start + """) + boolean existsOverlappingTimeForMentee( + @Param("menteeId") Long menteeId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end + ); } diff --git a/back/src/main/java/com/back/domain/mentoring/reservation/service/ReservationService.java b/back/src/main/java/com/back/domain/mentoring/reservation/service/ReservationService.java index 86d68c2b..2d88fd54 100644 --- a/back/src/main/java/com/back/domain/mentoring/reservation/service/ReservationService.java +++ b/back/src/main/java/com/back/domain/mentoring/reservation/service/ReservationService.java @@ -12,6 +12,9 @@ import com.back.domain.mentoring.reservation.entity.Reservation; import com.back.domain.mentoring.reservation.error.ReservationErrorCode; import com.back.domain.mentoring.reservation.repository.ReservationRepository; +import com.back.domain.mentoring.session.entity.MentoringSession; +import com.back.domain.mentoring.session.service.MentoringSessionService; +import com.back.domain.mentoring.slot.constant.MentorSlotStatus; import com.back.domain.mentoring.slot.entity.MentorSlot; import com.back.domain.mentoring.slot.service.DateTimeValidator; import com.back.global.exception.ServiceException; @@ -32,6 +35,7 @@ public class ReservationService { private final ReservationRepository reservationRepository; private final MentoringStorage mentoringStorage; + private final MentoringSessionService mentoringSessionService; @Transactional(readOnly = true) public Page getReservations(Member member, int page, int size) { @@ -40,19 +44,24 @@ public Page getReservations(Member member, int page, int size) { Page reservations; if (member.getRole() == Member.Role.MENTOR) { - reservations = reservationRepository.findAllByMentorMember(member, pageable); + reservations = reservationRepository.findAllByMentorMember(member.getId(), pageable); } else { reservations = reservationRepository.findAllByMenteeMember(member, pageable); } - return reservations.map(ReservationDto::from); + return reservations.map(r -> { + MentoringSession mentoringSession = mentoringSessionService.getMentoringSessionByReservation(r); + return ReservationDto.from(r, mentoringSession); + }); } @Transactional(readOnly = true) public ReservationResponse getReservation(Member member, Long reservationId) { - Reservation reservation = reservationRepository.findByIdAndMember(reservationId, member) + Reservation reservation = reservationRepository.findByIdAndMember(reservationId, member.getId()) .orElseThrow(() -> new ServiceException(ReservationErrorCode.RESERVATION_NOT_ACCESSIBLE)); - return ReservationResponse.from(reservation); + MentoringSession mentoringSession = mentoringSessionService.getMentoringSessionByReservation(reservation); + + return ReservationResponse.from(reservation, mentoringSession); } @Transactional @@ -61,8 +70,9 @@ public ReservationResponse createReservation(Mentee mentee, ReservationRequest r Mentoring mentoring = mentoringStorage.findMentoring(reqDto.mentoringId()); MentorSlot mentorSlot = mentoringStorage.findMentorSlot(reqDto.mentorSlotId()); - validateMentorSlotStatus(mentorSlot, mentee); DateTimeValidator.validateStartTimeNotInPast(mentorSlot.getStartDateTime()); + validateMentorSlotStatus(mentorSlot, mentee); + validateOverlappingTimeForMentee(mentee, mentorSlot); Reservation reservation = Reservation.builder() .mentoring(mentoring) @@ -70,12 +80,10 @@ public ReservationResponse createReservation(Mentee mentee, ReservationRequest r .mentorSlot(mentorSlot) .preQuestion(reqDto.preQuestion()) .build(); - - mentorSlot.setReservation(reservation); - // flush 필요...? - reservationRepository.save(reservation); + mentorSlot.updateStatus(MentorSlotStatus.PENDING); + return ReservationResponse.from(reservation); } catch (OptimisticLockException e) { throw new ServiceException(ReservationErrorCode.CONCURRENT_RESERVATION_CONFLICT); @@ -88,8 +96,10 @@ public ReservationResponse approveReservation(Mentor mentor, Long reservationId) Reservation reservation = mentoringStorage.findReservation(reservationId); reservation.approve(mentor); + reservation.getMentorSlot().updateStatus(MentorSlotStatus.APPROVED); - // 세션 + // 예약이 승인되면 세션을 생성한다. + MentoringSession mentoringSession = mentoringSessionService.create(reservation); return ReservationResponse.from(reservation); } catch (OptimisticLockException e) { @@ -101,6 +111,8 @@ public ReservationResponse approveReservation(Mentor mentor, Long reservationId) public ReservationResponse rejectReservation(Mentor mentor, Long reservationId) { Reservation reservation = mentoringStorage.findReservation(reservationId); reservation.reject(mentor); + reservation.getMentorSlot().updateStatus(MentorSlotStatus.AVAILABLE); + return ReservationResponse.from(reservation); } @@ -108,6 +120,8 @@ public ReservationResponse rejectReservation(Mentor mentor, Long reservationId) public ReservationResponse cancelReservation(Member member, Long reservationId) { Reservation reservation = mentoringStorage.findReservation(reservationId); reservation.cancel(member); + reservation.getMentorSlot().updateStatus(MentorSlotStatus.AVAILABLE); + return ReservationResponse.from(reservation); } @@ -127,4 +141,13 @@ private void validateMentorSlotStatus(MentorSlot mentorSlot, Mentee mentee) { throw new ServiceException(ReservationErrorCode.NOT_AVAILABLE_SLOT); } } + + private void validateOverlappingTimeForMentee(Mentee mentee, MentorSlot mentorSlot) { + boolean isOverlapping = reservationRepository + .existsOverlappingTimeForMentee(mentee.getId(), mentorSlot.getStartDateTime(), mentorSlot.getEndDateTime()); + + if (isOverlapping) { + throw new ServiceException(ReservationErrorCode.OVERLAPPING_TIME); + } + } } diff --git a/back/src/main/java/com/back/domain/mentoring/session/controller/ChatController.java b/back/src/main/java/com/back/domain/mentoring/session/controller/ChatController.java new file mode 100644 index 00000000..706fc85a --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/controller/ChatController.java @@ -0,0 +1,23 @@ +package com.back.domain.mentoring.session.controller; + +import com.back.domain.mentoring.session.dto.ChatMessageRequest; +import com.back.domain.mentoring.session.service.ChatManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.stereotype.Controller; + +import java.security.Principal; + +@Slf4j +@Controller +@RequiredArgsConstructor +public class ChatController { + private final ChatManager chatManager; + + @MessageMapping({"/chat/message/{mentoringSessionUuid}"}) + public void message(@DestinationVariable String mentoringSessionUuid, Principal principal, ChatMessageRequest chatMessageRequest) { + chatManager.chat(mentoringSessionUuid, principal, chatMessageRequest); + } +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/controller/MentoringSessionController.java b/back/src/main/java/com/back/domain/mentoring/session/controller/MentoringSessionController.java new file mode 100644 index 00000000..5fe4a42f --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/controller/MentoringSessionController.java @@ -0,0 +1,53 @@ +package com.back.domain.mentoring.session.controller; + +import com.back.domain.member.member.entity.Member; +import com.back.domain.mentoring.session.dto.*; +import com.back.domain.mentoring.session.service.MentoringSessionManager; +import com.back.global.rq.Rq; +import com.back.global.rsData.RsData; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/sessions") +public class MentoringSessionController { + private final MentoringSessionManager mentoringSessionManager; + private final Rq rq; + + //세션참여 URL발급 + @GetMapping("/{sessionId}/url") + public RsData getSessionUrl(@PathVariable Long sessionId) { + GetSessionUrlResponse response = mentoringSessionManager.getSessionUrl(sessionId); + return new RsData<>("200", "요청완료", response); + } + + //세션 상세 정보(참여 현황?, 제목 등등?) + @GetMapping("/{sessionId}") + public RsData getSessionDetail(@PathVariable Long sessionId) { + GetSessionInfoResponse response = mentoringSessionManager.getSessionDetail(sessionId); + return new RsData<>("200", "요청완료", response); + } + + //세션 열기 + @PutMapping("/{sessionId}") + @PreAuthorize("hasRole('MENTOR')") + public RsData openSession(@PathVariable Long sessionId) { + Member member = rq.getActor(); + OpenSessionRequest openSessionRequest = new OpenSessionRequest(sessionId); + OpenSessionResponse response = mentoringSessionManager.openSession(member, openSessionRequest); + return new RsData<>("200", "세션 오픈 완료", response); + } + + //세션종료 + @DeleteMapping("/{sessionId}") + @PreAuthorize("hasRole('MENTOR')") + public RsData closeSession(@PathVariable Long sessionId) { + Member member = rq.getActor(); + DeleteSessionRequest deleteSessionRequest = new DeleteSessionRequest(sessionId); + CloseSessionResponse response = mentoringSessionManager.closeSession(member, deleteSessionRequest); + return new RsData<>("200", "세션 종료 완료", response); + } +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/dto/ChatMessageRequest.java b/back/src/main/java/com/back/domain/mentoring/session/dto/ChatMessageRequest.java new file mode 100644 index 00000000..437e7ec4 --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/dto/ChatMessageRequest.java @@ -0,0 +1,10 @@ +package com.back.domain.mentoring.session.dto; + +import com.back.domain.mentoring.session.entity.MessageType; +import com.back.domain.mentoring.session.entity.SenderRole; + +public record ChatMessageRequest( + MessageType type, + String content +) { +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/dto/ChatMessageResponse.java b/back/src/main/java/com/back/domain/mentoring/session/dto/ChatMessageResponse.java new file mode 100644 index 00000000..99bb3f35 --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/dto/ChatMessageResponse.java @@ -0,0 +1,10 @@ +package com.back.domain.mentoring.session.dto; + +import java.time.LocalDateTime; + +public record ChatMessageResponse( + String senderName, + String content, + LocalDateTime createdAt +) { +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/dto/CloseSessionResponse.java b/back/src/main/java/com/back/domain/mentoring/session/dto/CloseSessionResponse.java new file mode 100644 index 00000000..313ddfef --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/dto/CloseSessionResponse.java @@ -0,0 +1,8 @@ +package com.back.domain.mentoring.session.dto; + +public record CloseSessionResponse( + String sessionUrl, + String mentoringTitle, + String status +) { +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/dto/DeleteSessionRequest.java b/back/src/main/java/com/back/domain/mentoring/session/dto/DeleteSessionRequest.java new file mode 100644 index 00000000..83287b5f --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/dto/DeleteSessionRequest.java @@ -0,0 +1,6 @@ +package com.back.domain.mentoring.session.dto; + +public record DeleteSessionRequest( + Long sessionId +) { +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/dto/GetSessionInfoResponse.java b/back/src/main/java/com/back/domain/mentoring/session/dto/GetSessionInfoResponse.java new file mode 100644 index 00000000..97c6df90 --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/dto/GetSessionInfoResponse.java @@ -0,0 +1,9 @@ +package com.back.domain.mentoring.session.dto; + +public record GetSessionInfoResponse( + String mentoringTitle, + String mentorName, + String menteeName, + String sessionStatus +) { +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/dto/GetSessionUrlResponse.java b/back/src/main/java/com/back/domain/mentoring/session/dto/GetSessionUrlResponse.java new file mode 100644 index 00000000..21bf3838 --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/dto/GetSessionUrlResponse.java @@ -0,0 +1,6 @@ +package com.back.domain.mentoring.session.dto; + +public record GetSessionUrlResponse( + String sessionUrl +) { +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/dto/OpenSessionRequest.java b/back/src/main/java/com/back/domain/mentoring/session/dto/OpenSessionRequest.java new file mode 100644 index 00000000..bf27ab63 --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/dto/OpenSessionRequest.java @@ -0,0 +1,6 @@ +package com.back.domain.mentoring.session.dto; + +public record OpenSessionRequest( + Long sessionId +) { +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/dto/OpenSessionResponse.java b/back/src/main/java/com/back/domain/mentoring/session/dto/OpenSessionResponse.java new file mode 100644 index 00000000..344c2b3b --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/dto/OpenSessionResponse.java @@ -0,0 +1,8 @@ +package com.back.domain.mentoring.session.dto; + +public record OpenSessionResponse( + String sessionUrl, + String mentoringTitle, + String status +) { +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/entity/ChatMessage.java b/back/src/main/java/com/back/domain/mentoring/session/entity/ChatMessage.java new file mode 100644 index 00000000..5268b369 --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/entity/ChatMessage.java @@ -0,0 +1,58 @@ +package com.back.domain.mentoring.session.entity; + +import com.back.domain.member.member.entity.Member; +import com.back.global.jpa.BaseEntity; +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +public class ChatMessage extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mentoring_session_id", nullable = false) + private MentoringSession mentoringSession; + + @ManyToOne + private Member sender; + + @Column + @Enumerated(EnumType.STRING) + private SenderRole senderRole; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private MessageType type; + + @Column(nullable = false) + private LocalDateTime timestamp; + + @Builder(access = lombok.AccessLevel.PRIVATE) + private ChatMessage(MentoringSession mentoringSession, Member sender, SenderRole senderRole, String content, MessageType type, LocalDateTime timestamp) { + this.mentoringSession = mentoringSession; + this.sender = sender; + this.senderRole = senderRole; + this.content = content; + this.type = type; + this.timestamp = timestamp; + } + + public static ChatMessage create(MentoringSession mentoringSession, Member sender, SenderRole senderRole, String content, MessageType type) { + return ChatMessage.builder() + .mentoringSession(mentoringSession) + .sender(sender) + .senderRole(senderRole) + .content(content) + .type(type) + .timestamp(LocalDateTime.now()) + .build(); + } +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/entity/MentoringSession.java b/back/src/main/java/com/back/domain/mentoring/session/entity/MentoringSession.java new file mode 100644 index 00000000..1dafe206 --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/entity/MentoringSession.java @@ -0,0 +1,75 @@ +package com.back.domain.mentoring.session.entity; + +import com.back.domain.member.mentor.entity.Mentor; +import com.back.domain.mentoring.mentoring.entity.Mentoring; +import com.back.domain.mentoring.reservation.constant.ReservationStatus; +import com.back.domain.mentoring.reservation.entity.Reservation; +import com.back.global.jpa.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +public class MentoringSession extends BaseEntity { + private String sessionUrl; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reservation_id", nullable = false) + private Reservation reservation; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mentoring_id", nullable = false) + private Mentoring mentoring; + + @Enumerated(EnumType.STRING) + private MentoringSessionStatus status; + + @OneToMany(mappedBy = "mentoringSession", cascade = CascadeType.ALL) + private List chatMessages = new ArrayList<>(); + + // 화면 공유, WebRTC 관련 필드 등 추가 가능 + + @Builder(access = AccessLevel.PRIVATE) + private MentoringSession(Reservation reservation) { + this.sessionUrl = java.util.UUID.randomUUID().toString(); + this.reservation = reservation; + this.mentoring = reservation.getMentoring(); + this.status = MentoringSessionStatus.CLOSED; + this.chatMessages = new ArrayList<>(); + } + + public static MentoringSession create(Reservation reservation) { + if (reservation.getStatus() != ReservationStatus.APPROVED) { + throw new IllegalArgumentException("Reservation must be APPROVED to create a MentoringSession."); + } + return MentoringSession.builder() + .reservation(reservation) + .build(); + } + + private MentoringSession updateStatus(MentoringSessionStatus status) { + this.status = status; + return this; + } + + public MentoringSession openSession(Mentor mentor) { + if (!mentoring.isOwner(mentor)) { + throw new IllegalArgumentException("Only the mentor who owns the mentoring can open the session."); + } + return updateStatus(MentoringSessionStatus.OPEN); + } + + public MentoringSession closeSession(Mentor mentor) { + if (!mentoring.isOwner(mentor)) { + throw new IllegalArgumentException("Only the mentor who owns the mentoring can open the session."); + } + return updateStatus(MentoringSessionStatus.CLOSED); + } +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/entity/MentoringSessionStatus.java b/back/src/main/java/com/back/domain/mentoring/session/entity/MentoringSessionStatus.java new file mode 100644 index 00000000..ca936d74 --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/entity/MentoringSessionStatus.java @@ -0,0 +1,5 @@ +package com.back.domain.mentoring.session.entity; + +public enum MentoringSessionStatus { + OPEN, CLOSED +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/entity/MessageType.java b/back/src/main/java/com/back/domain/mentoring/session/entity/MessageType.java new file mode 100644 index 00000000..e4e31128 --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/entity/MessageType.java @@ -0,0 +1,5 @@ +package com.back.domain.mentoring.session.entity; + +public enum MessageType { + TEXT, IMAGE, FILE, SYSTEM +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/entity/SenderRole.java b/back/src/main/java/com/back/domain/mentoring/session/entity/SenderRole.java new file mode 100644 index 00000000..4c36ecdc --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/entity/SenderRole.java @@ -0,0 +1,5 @@ +package com.back.domain.mentoring.session.entity; + +public enum SenderRole { + MENTOR, MENTEE, SYSTEM +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/repository/ChatMessageRepository.java b/back/src/main/java/com/back/domain/mentoring/session/repository/ChatMessageRepository.java new file mode 100644 index 00000000..7d9be405 --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/repository/ChatMessageRepository.java @@ -0,0 +1,9 @@ +package com.back.domain.mentoring.session.repository; + +import com.back.domain.mentoring.session.entity.ChatMessage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ChatMessageRepository extends JpaRepository { +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/repository/MentoringSessionRepository.java b/back/src/main/java/com/back/domain/mentoring/session/repository/MentoringSessionRepository.java new file mode 100644 index 00000000..95c290ff --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/repository/MentoringSessionRepository.java @@ -0,0 +1,17 @@ +package com.back.domain.mentoring.session.repository; + +import com.back.domain.mentoring.mentoring.entity.Mentoring; +import com.back.domain.mentoring.reservation.entity.Reservation; +import com.back.domain.mentoring.session.entity.MentoringSession; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MentoringSessionRepository extends JpaRepository { + Optional findByMentoring(Mentoring mentoring); + void deleteByReservation(Reservation reservation); + Optional findBySessionUrl(String mentoringSessionUUid); + Optional findByReservation(Reservation reservation); +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/service/ChatManager.java b/back/src/main/java/com/back/domain/mentoring/session/service/ChatManager.java new file mode 100644 index 00000000..5ebbd5fc --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/service/ChatManager.java @@ -0,0 +1,31 @@ +package com.back.domain.mentoring.session.service; + +import com.back.domain.member.member.entity.Member; +import com.back.domain.member.member.service.MemberStorage; +import com.back.domain.mentoring.mentoring.service.MentoringStorage; +import com.back.domain.mentoring.session.dto.ChatMessageRequest; +import com.back.domain.mentoring.session.dto.ChatMessageResponse; +import com.back.domain.mentoring.session.entity.MentoringSession; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.Principal; + +@Service +@RequiredArgsConstructor +public class ChatManager { + private final SimpMessageSendingOperations messagingTemplate; + private final ChatMessageService chatMessageService; + private final MentoringStorage mentoringStorage; + private final MemberStorage memberStorage; + + @Transactional + public void chat(String mentoringSessionId, Principal principal, ChatMessageRequest chatMessageRequest) { + Member member = memberStorage.findMemberByEmail(principal.getName()); + MentoringSession mentoringSession = mentoringStorage.getMentoringSessionBySessionUuid(mentoringSessionId); + ChatMessageResponse responseDto = chatMessageService.saveAndProcessMessage(member, mentoringSession, chatMessageRequest); + messagingTemplate.convertAndSend("/topic/chat/room/" + mentoringSessionId, responseDto); + } +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/service/ChatMessageService.java b/back/src/main/java/com/back/domain/mentoring/session/service/ChatMessageService.java new file mode 100644 index 00000000..0634e392 --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/service/ChatMessageService.java @@ -0,0 +1,36 @@ +package com.back.domain.mentoring.session.service; + +import com.back.domain.member.member.entity.Member; +import com.back.domain.mentoring.session.dto.ChatMessageRequest; +import com.back.domain.mentoring.session.dto.ChatMessageResponse; +import com.back.domain.mentoring.session.entity.ChatMessage; +import com.back.domain.mentoring.session.entity.MentoringSession; +import com.back.domain.mentoring.session.entity.MessageType; +import com.back.domain.mentoring.session.entity.SenderRole; +import com.back.domain.mentoring.session.repository.ChatMessageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class ChatMessageService { + private final ChatMessageRepository chatMessageRepository; + + public ChatMessage create(MentoringSession mentoringSession, Member sender, String content, MessageType messageType) { + SenderRole senderRole = sender.getRole() == Member.Role.MENTOR ? SenderRole.MENTOR : SenderRole.MENTEE; + ChatMessage chatMessage = ChatMessage.create(mentoringSession, sender, senderRole , content, messageType); + return chatMessageRepository.save(chatMessage); + } + + public ChatMessageResponse saveAndProcessMessage(Member sender, MentoringSession mentoringSession, ChatMessageRequest messageRequest) { + ChatMessage message = create(mentoringSession, sender, messageRequest.content(), messageRequest.type()); + + return new ChatMessageResponse( + message.getSender().getNickname(), + message.getContent(), + message.getTimestamp() + ); + } +} \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/mentoring/session/service/MentoringSessionManager.java b/back/src/main/java/com/back/domain/mentoring/session/service/MentoringSessionManager.java new file mode 100644 index 00000000..e91d115b --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/service/MentoringSessionManager.java @@ -0,0 +1,54 @@ +package com.back.domain.mentoring.session.service; + +import com.back.domain.member.member.entity.Member; +import com.back.domain.member.member.service.MemberStorage; +import com.back.domain.member.mentor.entity.Mentor; +import com.back.domain.mentoring.mentoring.entity.Mentoring; +import com.back.domain.mentoring.reservation.entity.Reservation; +import com.back.domain.mentoring.session.dto.*; +import com.back.domain.mentoring.session.entity.MentoringSession; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MentoringSessionManager { + private final MentoringSessionService mentoringSessionService; + private final MemberStorage memberStorage; + + public GetSessionUrlResponse getSessionUrl(Long sessionId) { + MentoringSession session = mentoringSessionService.getMentoringSession(sessionId); + return new GetSessionUrlResponse(session.getSessionUrl()); + } + + @Transactional + public GetSessionInfoResponse getSessionDetail(Long sessionId) { + MentoringSession session = mentoringSessionService.getMentoringSession(sessionId); + Mentoring mentoring = session.getMentoring(); + Reservation reservation = session.getReservation(); + String sessionStatus = session.getStatus().toString(); + return new GetSessionInfoResponse( + mentoring.getTitle(), + reservation.getMentor().getMember().getNickname(), + reservation.getMentee().getMember().getNickname(), + sessionStatus + ); + } + + @Transactional + public OpenSessionResponse openSession(Member requestUser, OpenSessionRequest openSessionRequest) { + Mentor mentor = memberStorage.findMentorByMember(requestUser); + MentoringSession session = mentoringSessionService.getMentoringSession(openSessionRequest.sessionId()); + MentoringSession openedSession = mentoringSessionService.save(session.openSession(mentor)); + return new OpenSessionResponse(openedSession.getSessionUrl(), openedSession.getMentoring().getTitle(), openedSession.getStatus().toString()); + } + + @Transactional + public CloseSessionResponse closeSession(Member requestUser, DeleteSessionRequest deleteRequest) { + Mentor mentor = memberStorage.findMentorByMember(requestUser); + MentoringSession session = mentoringSessionService.getMentoringSession(deleteRequest.sessionId()); + MentoringSession closedSession = mentoringSessionService.save(session.closeSession(mentor)); + return new CloseSessionResponse(closedSession.getSessionUrl(), closedSession.getMentoring().getTitle(), closedSession.getStatus().toString()); + } +} diff --git a/back/src/main/java/com/back/domain/mentoring/session/service/MentoringSessionService.java b/back/src/main/java/com/back/domain/mentoring/session/service/MentoringSessionService.java new file mode 100644 index 00000000..3d457e2e --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/session/service/MentoringSessionService.java @@ -0,0 +1,43 @@ +package com.back.domain.mentoring.session.service; + +import com.back.domain.mentoring.mentoring.entity.Mentoring; +import com.back.domain.mentoring.reservation.entity.Reservation; +import com.back.domain.mentoring.session.entity.MentoringSession; +import com.back.domain.mentoring.session.repository.MentoringSessionRepository; +import com.back.global.exception.ServiceException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MentoringSessionService { + private final MentoringSessionRepository mentoringSessionRepository; + + public MentoringSession create(Reservation reservation) { + MentoringSession mentoringSession = MentoringSession.create(reservation); + return mentoringSessionRepository.save(mentoringSession); + } + + public MentoringSession getMentoringSession(Long id) { + return mentoringSessionRepository.findById(id) + .orElseThrow(() -> new ServiceException("404", "잘못된 id")); + } + + public MentoringSession getMentoringSessionByReservation(Reservation reservation) { + return mentoringSessionRepository.findByReservation((reservation)) + .orElseThrow(() -> new ServiceException("404", "해당 예약의 세션이 없습니다.")); + } + + public MentoringSession getMentoringSessionByMentoring(Mentoring mentoring) { + return mentoringSessionRepository.findByMentoring(mentoring) + .orElseThrow(() -> new ServiceException("404", "해당 멘토링의 세션이 없습니다.")); + } + + public void deleteByReservation(Reservation reservation) { + mentoringSessionRepository.deleteByReservation(reservation); + } + + public MentoringSession save(MentoringSession mentoringSession) { + return mentoringSessionRepository.save(mentoringSession); + } +} diff --git a/back/src/main/java/com/back/domain/mentoring/slot/dto/request/MentorSlotRepetitionRequest.java b/back/src/main/java/com/back/domain/mentoring/slot/dto/request/MentorSlotRepetitionRequest.java index 03ac7b8e..bb7ee76a 100644 --- a/back/src/main/java/com/back/domain/mentoring/slot/dto/request/MentorSlotRepetitionRequest.java +++ b/back/src/main/java/com/back/domain/mentoring/slot/dto/request/MentorSlotRepetitionRequest.java @@ -11,24 +11,24 @@ import java.util.List; public record MentorSlotRepetitionRequest( - @Schema(description = "반복 시작일") + @Schema(description = "반복 시작일", example = "yyyy-MM-dd") @NotNull LocalDate repeatStartDate, - @Schema(description = "반복 종료일") + @Schema(description = "반복 종료일", example = "yyyy-MM-dd") @NotNull LocalDate repeatEndDate, - @Schema(description = "반복 요일") + @Schema(description = "반복 요일", example = "[\"MONDAY\", \"FRIDAY\"]") @NotEmpty List daysOfWeek, - @Schema(description = "시작 시간") + @Schema(description = "시작 시간", example = "HH:mm:ss") @NotNull @JsonFormat(pattern = "HH:mm:ss") LocalTime startTime, - @Schema(description = "종료 시간") + @Schema(description = "종료 시간", example = "HH:mm:ss") @NotNull @JsonFormat(pattern = "HH:mm:ss") LocalTime endTime diff --git a/back/src/main/java/com/back/domain/mentoring/slot/dto/request/MentorSlotRequest.java b/back/src/main/java/com/back/domain/mentoring/slot/dto/request/MentorSlotRequest.java index ba243213..109ffa28 100644 --- a/back/src/main/java/com/back/domain/mentoring/slot/dto/request/MentorSlotRequest.java +++ b/back/src/main/java/com/back/domain/mentoring/slot/dto/request/MentorSlotRequest.java @@ -10,11 +10,11 @@ public record MentorSlotRequest( @NotNull Long mentorId, - @Schema(description = "시작 일시") + @Schema(description = "시작 일시", example = "yyyy-MM-ddTHH:mm:ss") @NotNull LocalDateTime startDateTime, - @Schema(description = "종료 일시") + @Schema(description = "종료 일시", example = "yyyy-MM-ddTHH:mm:ss") @NotNull LocalDateTime endDateTime ) { diff --git a/back/src/main/java/com/back/domain/mentoring/slot/dto/response/MentorSlotDto.java b/back/src/main/java/com/back/domain/mentoring/slot/dto/response/MentorSlotDto.java index f6bdc831..2185b0ec 100644 --- a/back/src/main/java/com/back/domain/mentoring/slot/dto/response/MentorSlotDto.java +++ b/back/src/main/java/com/back/domain/mentoring/slot/dto/response/MentorSlotDto.java @@ -1,7 +1,6 @@ package com.back.domain.mentoring.slot.dto.response; import com.back.domain.mentoring.slot.constant.MentorSlotStatus; -import com.back.domain.mentoring.slot.entity.MentorSlot; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; @@ -16,15 +15,8 @@ public record MentorSlotDto( @Schema(description = "종료 일시") LocalDateTime endDateTime, @Schema(description = "멘토 슬롯 상태") - MentorSlotStatus mentorSlotStatus + MentorSlotStatus mentorSlotStatus, + @Schema(description = "활성화된 예약 ID") + Long reservationId ) { - public static MentorSlotDto from(MentorSlot mentorSlot) { - return new MentorSlotDto( - mentorSlot.getId(), - mentorSlot.getMentor().getId(), - mentorSlot.getStartDateTime(), - mentorSlot.getEndDateTime(), - mentorSlot.getStatus() - ); - } } diff --git a/back/src/main/java/com/back/domain/mentoring/slot/dto/response/MentorSlotResponse.java b/back/src/main/java/com/back/domain/mentoring/slot/dto/response/MentorSlotResponse.java index 0d8bde2f..e796a3fd 100644 --- a/back/src/main/java/com/back/domain/mentoring/slot/dto/response/MentorSlotResponse.java +++ b/back/src/main/java/com/back/domain/mentoring/slot/dto/response/MentorSlotResponse.java @@ -6,16 +6,20 @@ import com.back.domain.mentoring.slot.dto.MentorSlotDetailDto; import com.back.domain.mentoring.slot.entity.MentorSlot; +import java.util.List; + public record MentorSlotResponse( MentorSlotDetailDto mentorSlot, MentorDto mentor, - MentoringDto mentoring + List mentorings ) { - public static MentorSlotResponse from(MentorSlot mentorSlot, Mentoring mentoring) { + public static MentorSlotResponse from(MentorSlot mentorSlot, List mentorings) { return new MentorSlotResponse( MentorSlotDetailDto.from(mentorSlot), MentorDto.from(mentorSlot.getMentor()), - MentoringDto.from(mentoring) + mentorings.stream() + .map(MentoringDto::from) + .toList() ); } } diff --git a/back/src/main/java/com/back/domain/mentoring/slot/entity/MentorSlot.java b/back/src/main/java/com/back/domain/mentoring/slot/entity/MentorSlot.java index 2410ef5e..dc023622 100644 --- a/back/src/main/java/com/back/domain/mentoring/slot/entity/MentorSlot.java +++ b/back/src/main/java/com/back/domain/mentoring/slot/entity/MentorSlot.java @@ -1,7 +1,6 @@ package com.back.domain.mentoring.slot.entity; import com.back.domain.member.mentor.entity.Mentor; -import com.back.domain.mentoring.reservation.entity.Reservation; import com.back.domain.mentoring.slot.constant.MentorSlotStatus; import com.back.global.jpa.BaseEntity; import jakarta.persistence.*; @@ -25,9 +24,6 @@ public class MentorSlot extends BaseEntity { @Column(nullable = false) private LocalDateTime endDateTime; - @OneToOne(mappedBy = "mentorSlot") - private Reservation reservation; - @Enumerated(EnumType.STRING) @Column(nullable = false) private MentorSlotStatus status; @@ -48,41 +44,8 @@ public void updateTime(LocalDateTime startDateTime, LocalDateTime endDateTime) { this.endDateTime = endDateTime; } - // ========================= - // TODO - 현재 상태 - // 1. reservation 필드에는 활성 예약(PENDING, APPROVED)만 세팅 - // 2. 취소/거절 예약은 DB에 남기고 reservation 필드에는 연결하지 않음 - // 3. 슬롯 재생성 불필요, 상태 기반 isAvailable() 로 새 예약 가능 판단 - // - // TODO - 추후 변경 - // 1. 1:N 구조로 리팩토링 - // - MentorSlot에 여러 Reservation 연결 가능 - // - 모든 예약 기록(히스토리) 보존 - // 2. 상태 기반 필터링 유지: 활성 예약만 계산 시 사용 - // 3. 이벤트 소싱/분석 등 확장 가능하도록 구조 개선 - // ========================= - - public void updateStatus() { - if (reservation == null) { - this.status = MentorSlotStatus.AVAILABLE; - } else { - this.status = switch (reservation.getStatus()) { - case PENDING -> MentorSlotStatus.PENDING; - case APPROVED -> MentorSlotStatus.APPROVED; - case COMPLETED -> MentorSlotStatus.COMPLETED; - case REJECTED, CANCELED -> MentorSlotStatus.AVAILABLE; - }; - } - } - - public void setReservation(Reservation reservation) { - this.reservation = reservation; - updateStatus(); - } - - public void removeReservation() { - this.reservation = null; - this.status = MentorSlotStatus.AVAILABLE; + public void updateStatus(MentorSlotStatus status) { + this.status = status; } /** @@ -91,7 +54,7 @@ public void removeReservation() { * - 예약이 취소/거절된 경우 true */ public boolean isAvailable() { - return reservation == null; + return status == MentorSlotStatus.AVAILABLE; } public boolean isOwnerBy(Mentor mentor) { diff --git a/back/src/main/java/com/back/domain/mentoring/slot/repository/MentorSlotRepository.java b/back/src/main/java/com/back/domain/mentoring/slot/repository/MentorSlotRepository.java index 83b5cc17..f97856e6 100644 --- a/back/src/main/java/com/back/domain/mentoring/slot/repository/MentorSlotRepository.java +++ b/back/src/main/java/com/back/domain/mentoring/slot/repository/MentorSlotRepository.java @@ -1,5 +1,6 @@ package com.back.domain.mentoring.slot.repository; +import com.back.domain.mentoring.slot.dto.response.MentorSlotDto; import com.back.domain.mentoring.slot.entity.MentorSlot; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -17,21 +18,38 @@ public interface MentorSlotRepository extends JpaRepository { void deleteAllByMentorId(Long mentorId); @Query(""" - SELECT ms + SELECT new com.back.domain.mentoring.slot.dto.response.MentorSlotDto( + ms.id, + ms.mentor.id, + ms.startDateTime, + ms.endDateTime, + ms.status, + r.id + ) FROM MentorSlot ms + LEFT JOIN Reservation r + ON ms.id = r.mentorSlot.id + AND r.status IN ('PENDING', 'APPROVED', 'COMPLETED') WHERE ms.mentor.id = :mentorId AND ms.startDateTime < :end AND ms.endDateTime >= :start ORDER BY ms.startDateTime ASC """) - List findMySlots( + List findMySlots( @Param("mentorId") Long mentorId, @Param("start") LocalDateTime start, @Param("end") LocalDateTime end ); @Query(""" - SELECT ms + SELECT new com.back.domain.mentoring.slot.dto.response.MentorSlotDto( + ms.id, + ms.mentor.id, + ms.startDateTime, + ms.endDateTime, + ms.status, + NULL + ) FROM MentorSlot ms WHERE ms.mentor.id = :mentorId AND ms.status = 'AVAILABLE' @@ -39,13 +57,12 @@ List findMySlots( AND ms.endDateTime >= :start ORDER BY ms.startDateTime ASC """) - List findAvailableSlots( + List findAvailableSlots( @Param("mentorId") Long mentorId, @Param("start") LocalDateTime start, @Param("end") LocalDateTime end ); - // TODO: 현재는 시간 겹침만 체크, 추후 1:N 구조 시 활성 예약 기준으로 변경 @Query(""" SELECT CASE WHEN COUNT(ms) > 0 THEN TRUE diff --git a/back/src/main/java/com/back/domain/mentoring/slot/service/MentorSlotService.java b/back/src/main/java/com/back/domain/mentoring/slot/service/MentorSlotService.java index 068a7f04..0103cecf 100644 --- a/back/src/main/java/com/back/domain/mentoring/slot/service/MentorSlotService.java +++ b/back/src/main/java/com/back/domain/mentoring/slot/service/MentorSlotService.java @@ -33,35 +33,27 @@ public class MentorSlotService { public List getMyMentorSlots(Mentor mentor, LocalDateTime startDate, LocalDateTime endDate) { DateTimeValidator.validateTime(startDate, endDate); - List availableSlots = mentorSlotRepository.findMySlots(mentor.getId(), startDate, endDate); - - return availableSlots.stream() - .map(MentorSlotDto::from) - .toList(); + return mentorSlotRepository.findMySlots(mentor.getId(), startDate, endDate); } @Transactional(readOnly = true) public List getAvailableMentorSlots(Long mentorId, LocalDateTime startDate, LocalDateTime endDate) { DateTimeValidator.validateTime(startDate, endDate); - List availableSlots = mentorSlotRepository.findAvailableSlots(mentorId, startDate, endDate); - - return availableSlots.stream() - .map(MentorSlotDto::from) - .toList(); + return mentorSlotRepository.findAvailableSlots(mentorId, startDate, endDate); } @Transactional(readOnly = true) public MentorSlotResponse getMentorSlot(Long slotId) { MentorSlot mentorSlot = mentorStorage.findMentorSlot(slotId); - Mentoring mentoring = mentorStorage.findMentoringByMentor(mentorSlot.getMentor()); + List mentorings = mentorStorage.findMentoringsByMentorId(mentorSlot.getMentor().getId()); - return MentorSlotResponse.from(mentorSlot, mentoring); + return MentorSlotResponse.from(mentorSlot, mentorings); } @Transactional public MentorSlotResponse createMentorSlot(MentorSlotRequest reqDto, Mentor mentor) { - Mentoring mentoring = mentorStorage.findMentoringByMentor(mentor); + List mentorings = mentorStorage.findMentoringsByMentorId(mentor.getId()); DateTimeValidator.validateTimeSlot(reqDto.startDateTime(), reqDto.endDateTime()); validateOverlappingSlots(mentor, reqDto.startDateTime(), reqDto.endDateTime()); @@ -73,7 +65,7 @@ public MentorSlotResponse createMentorSlot(MentorSlotRequest reqDto, Mentor ment .build(); mentorSlotRepository.save(mentorSlot); - return MentorSlotResponse.from(mentorSlot, mentoring); + return MentorSlotResponse.from(mentorSlot, mentorings); } @Transactional @@ -92,7 +84,6 @@ public void createMentorSlotRepetition(MentorSlotRepetitionRequest reqDto, Mento @Transactional public MentorSlotResponse updateMentorSlot(Long slotId, MentorSlotRequest reqDto, Mentor mentor) { - Mentoring mentoring = mentorStorage.findMentoringByMentor(mentor); MentorSlot mentorSlot = mentorStorage.findMentorSlot(slotId); validateOwner(mentorSlot, mentor); @@ -104,7 +95,8 @@ public MentorSlotResponse updateMentorSlot(Long slotId, MentorSlotRequest reqDto mentorSlot.updateTime(reqDto.startDateTime(), reqDto.endDateTime()); - return MentorSlotResponse.from(mentorSlot, mentoring); + List mentorings = mentorStorage.findMentoringsByMentorId(mentor.getId()); + return MentorSlotResponse.from(mentorSlot, mentorings); } @Transactional diff --git a/back/src/main/java/com/back/domain/post/post/controller/PostController.java b/back/src/main/java/com/back/domain/post/post/controller/PostController.java index b1d27ddf..51939133 100644 --- a/back/src/main/java/com/back/domain/post/post/controller/PostController.java +++ b/back/src/main/java/com/back/domain/post/post/controller/PostController.java @@ -26,7 +26,7 @@ public class PostController { private final PostDetailFacade postDetailFacade; - @Operation(summary = "게시글 조회 - 페이징 처리") + @Operation(summary = "게시글 조회 - 페이징 처리", description = "keyword는 null 일수 있습니다. keyword는 title, member name(작성자 이름) 검색에 활용됩니다. \n 페이지는 10개씩 보여줍니다.") @GetMapping("/page/{postType}") public RsData getPostWithPage( @PathVariable Post.PostType postType, @@ -40,7 +40,11 @@ public RsData getPostWithPage( return new RsData<>("200", "게시글이 조회 되었습니다.", resDto); } - @Operation(summary = "게시글 생성 ") + @Operation(summary = "게시글 생성 ", description = "postType : 게시글 타입(url 분기시 활용, \"QUESTIONPOST, PRACTICEPOST, INFORMATIONPOST\"만 입력 가능) \n" + + "title : 제목 \n" + + "content : 내용 \n" + + "job : 직업(PRACTICEPOST 작성 시 필수) \n" + + "example) { \"postType\": \"QUESTIONPOST\", \"title\": \"게시글 제목\", \"content\": \"게시글 내용\", \"job\": \"백엔드 개발자\" }") @PostMapping public RsData createPost( @Valid @RequestBody PostCreateRequest postCreateRequest @@ -52,7 +56,7 @@ public RsData createPost( return new RsData<>("200", "게시글이 성공적으로 생성되었습니다.", postCreateResponse); } - @Operation(summary = "게시글 다건 조회") + @Operation(summary = "게시글 다건 조회", description = "백오피스에서 필요시 사용, 페이징 처리 없음") @GetMapping("/all") public RsData> getAllPost() { List postAllResponse = postService.getAllPostResponse(); @@ -61,7 +65,7 @@ public RsData> getAllPost() { return new RsData<>("200", "게시글 다건 조회 성공", postAllResponse); } - @Operation(summary = "게시글 단건 조회") + @Operation(summary = "게시글 단건 조회", description = "백오피스에서 필요시 사용, content 내용 없음. 상세페이지 조회는 /post/detail/{post_id} 사용") @GetMapping("/{post_id}") public RsData getSinglePost(@PathVariable Long post_id) { PostSingleResponse postSingleResponse = postService.makePostSingleResponse(post_id); @@ -81,12 +85,12 @@ public RsData removePost(@PathVariable Long post_id) { return new RsData<>("200", "게시글 삭제 성공", null); } - @Operation(summary = "게시글 수정") + @Operation(summary = "게시글 수정", description = "title, content은 공백이거나 null 일 수 없습니다. 글자수 제한은 없습니다. ") @PutMapping("/{post_id}") public RsData updatePost(@PathVariable Long post_id - ,@Valid @RequestBody PostCreateRequest postCreateRequest) { + ,@Valid @RequestBody PostModifyRequest postModifyRequest) { Member member = rq.getActor(); - postService.updatePost(post_id, member, postCreateRequest); + postService.updatePost(post_id, member, postModifyRequest); return new RsData<>("200", "게시글 수정 성공", null); } @@ -114,7 +118,7 @@ public RsData disLikePost(@PathVariable Long post_id) { } - @Operation(summary = "게시글 상세페이지") + @Operation(summary = "게시글 상세페이지", description = "사용자 단건 조회시 사용") @GetMapping("/detail/{post_id}") public RsData getPostDetail(@PathVariable Long post_id) { diff --git a/back/src/main/java/com/back/domain/post/post/dto/PostModifyRequest.java b/back/src/main/java/com/back/domain/post/post/dto/PostModifyRequest.java index 50c79fa1..747d7c01 100644 --- a/back/src/main/java/com/back/domain/post/post/dto/PostModifyRequest.java +++ b/back/src/main/java/com/back/domain/post/post/dto/PostModifyRequest.java @@ -2,7 +2,6 @@ - public record PostModifyRequest( String title, String content) { diff --git a/back/src/main/java/com/back/domain/post/post/service/PostService.java b/back/src/main/java/com/back/domain/post/post/service/PostService.java index b385cfd2..e10c0c02 100644 --- a/back/src/main/java/com/back/domain/post/post/service/PostService.java +++ b/back/src/main/java/com/back/domain/post/post/service/PostService.java @@ -72,20 +72,20 @@ public void removePost(Long postId, Member member) { } @Transactional - public void updatePost(long postId, Member member, @Valid PostCreateRequest postCreateRequest) { + public void updatePost(long postId, Member member, @Valid PostModifyRequest postModifyRequest) { Post post = findById(postId); if (!post.isAuthor(member)) throw new ServiceException("400", "수정 권한이 없습니다."); - if ( postCreateRequest.title() == null || postCreateRequest.title().isBlank()) { + if ( postModifyRequest.title() == null || postModifyRequest.title().isBlank()) { throw new ServiceException("400", "제목을 입력해주세요."); } - if ( postCreateRequest.content() == null || postCreateRequest.content().isBlank()) { + if ( postModifyRequest.content() == null || postModifyRequest.content().isBlank()) { throw new ServiceException("400", "내용을 입력해주세요."); } - post.updateTitle(postCreateRequest.title()); - post.updateContent(postCreateRequest.content()); + post.updateTitle(postModifyRequest.title()); + post.updateContent(postModifyRequest.content()); postRepository.save(post); } diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapIntegrationQueue.java b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapIntegrationQueue.java new file mode 100644 index 00000000..3ffb88b2 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapIntegrationQueue.java @@ -0,0 +1,55 @@ +package com.back.domain.roadmap.roadmap.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name= "job_roadmap_integration_queue") +@NoArgsConstructor +public class JobRoadmapIntegrationQueue { + @Id + @Column(name = "job_id") + private Long jobId; + + @Column(name = "requested_at", nullable = false) + private LocalDateTime requestedAt; + + @Column(name = "retry_count", nullable = false) + private Integer retryCount = 0; + + @Version + @Column(name = "version") + private Long version = 0L; + + public JobRoadmapIntegrationQueue(Long jobId) { + this.jobId = jobId; + this.requestedAt = LocalDateTime.now(); + this.retryCount = 0; + } + + public void updateRequestedAt() { + this.requestedAt = LocalDateTime.now(); + } + + public void incrementRetryCount() { + this.retryCount += 1; + } + + public boolean isMaxRetryExceeded(int maxRetry) { + return this.retryCount >= maxRetry; + } + + public Long getJobId() { + return jobId; + } + + public Integer getRetryCount() { + return retryCount; + } +} diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java index 99b2198d..5d8cb126 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java @@ -47,53 +47,29 @@ public class JobRoadmapNodeStat extends BaseEntity { private String alternativeParents; // 대안 부모 후보들: JSON 형태 { "T:1": 8, "N:kotlin": 7 } @Builder - public JobRoadmapNodeStat(Integer stepOrder, Double weight, RoadmapNode node) { - this.stepOrder = stepOrder; - this.weight = weight != null ? weight : 0.0; + public JobRoadmapNodeStat( + RoadmapNode node, + Integer stepOrder, + Double weight, + Double averagePosition, + Integer mentorCount, + Integer totalMentorCount, + Double mentorCoverageRatio, + Integer outgoingTransitions, + Integer incomingTransitions, + String transitionCounts, + String alternativeParents + ) { this.node = node; - } - - public void setStepOrder(Integer stepOrder) { this.stepOrder = stepOrder; - } - - public void setWeight(Double weight) { - this.weight = weight; - } - - public void setNode(RoadmapNode node) { - this.node = node; - } - - public void setAveragePosition(Double averagePosition) { + this.weight = weight != null ? weight : 0.0; this.averagePosition = averagePosition; - } - - public void setMentorCount(Integer mentorCount) { this.mentorCount = mentorCount; - } - - public void setTotalMentorCount(Integer totalMentorCount) { this.totalMentorCount = totalMentorCount; - } - - public void setMentorCoverageRatio(Double mentorCoverageRatio) { this.mentorCoverageRatio = mentorCoverageRatio; - } - - public void setOutgoingTransitions(Integer outgoingTransitions) { this.outgoingTransitions = outgoingTransitions; - } - - public void setIncomingTransitions(Integer incomingTransitions) { this.incomingTransitions = incomingTransitions; - } - - public void setTransitionCounts(String transitionCounts) { this.transitionCounts = transitionCounts; - } - - public void setAlternativeParents(String alternativeParents) { this.alternativeParents = alternativeParents; } } diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java index d2beac97..80956cd5 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java @@ -97,6 +97,12 @@ public RoadmapNode(String taskName, String learningAdvice, String recommendedRes } + // ========== 도메인 메서드 (Public) ========== + + /** + * 자식 노드를 추가하고 부모-자식 관계를 설정합니다. + * 자식의 level은 부모 level + 1로 자동 설정됩니다. + */ public void addChild(RoadmapNode child) { if (child == null) { throw new IllegalArgumentException("자식 노드는 null일 수 없습니다."); @@ -106,34 +112,54 @@ public void addChild(RoadmapNode child) { } this.children.add(child); child.setParent(this); - child.setLevel(this.level + 1); // 부모 level + 1로 자동 설정 + child.setLevel(this.level + 1); } - public void setParent(RoadmapNode parent) { - this.parent = parent; + /** + * 이 노드를 특정 로드맵에 할당합니다. + * JobRoadmap 또는 MentorRoadmap 저장 후 ID를 받은 시점에 호출됩니다. + * + * @param roadmapId 로드맵 ID + * @param roadmapType 로드맵 타입 (JOB 또는 MENTOR) + */ + public void assignToRoadmap(Long roadmapId, RoadmapType roadmapType) { + if (roadmapId == null) { + throw new IllegalArgumentException("roadmapId는 null일 수 없습니다."); + } + if (roadmapType == null) { + throw new IllegalArgumentException("roadmapType은 null일 수 없습니다."); + } + this.roadmapId = roadmapId; + this.roadmapType = roadmapType; } - public void setLevel(int level) { - this.level = level; + /** + * 이 노드를 루트 노드로 초기화합니다. + * level=0, stepOrder=1로 설정됩니다. + * 직업 로드맵 통합 알고리즘에서 메인 루트 설정 시 사용됩니다. + */ + public void initializeAsRoot() { + this.level = 0; + this.stepOrder = 1; } - public void setStepOrder(int stepOrder) { - this.stepOrder = stepOrder; + /** + * 형제 노드들 사이에서의 순서를 할당합니다. + * BFS 트리 구성 시 부모의 자식들 중 몇 번째인지 설정하는 데 사용됩니다. + * + * @param order 형제 노드 중 순서 (1부터 시작) + */ + public void assignOrderInSiblings(int order) { + this.stepOrder = order; } - public void setRoadmapId(Long roadmapId) { - this.roadmapId = roadmapId; - } - - public void setRoadmapType(RoadmapType roadmapType) { - this.roadmapType = roadmapType; - } + // ========== Package-private 메서드 (같은 패키지에서만 접근) ========== - public void setTask(Task task) { - this.task = task; + void setParent(RoadmapNode parent) { + this.parent = parent; } - public void setTaskName(String taskName) { - this.taskName = taskName; + void setLevel(int level) { + this.level = level; } } diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/event/MentorRoadmapChangeEvent.java b/back/src/main/java/com/back/domain/roadmap/roadmap/event/MentorRoadmapChangeEvent.java new file mode 100644 index 00000000..c25b8bf3 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/event/MentorRoadmapChangeEvent.java @@ -0,0 +1,13 @@ +package com.back.domain.roadmap.roadmap.event; + +public class MentorRoadmapChangeEvent { + private final Long jobId; + + public MentorRoadmapChangeEvent(Long jobId) { + this.jobId = jobId; + } + + public Long getJobId() { + return jobId; + } +} diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapIntegrationQueueRepository.java b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapIntegrationQueueRepository.java new file mode 100644 index 00000000..27dd0ddd --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapIntegrationQueueRepository.java @@ -0,0 +1,12 @@ +package com.back.domain.roadmap.roadmap.repository; + +import com.back.domain.roadmap.roadmap.entity.JobRoadmapIntegrationQueue; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface JobRoadmapIntegrationQueueRepository extends JpaRepository { + @Query("SELECT q FROM JobRoadmapIntegrationQueue q ORDER BY q.requestedAt ASC") + List findAllOrderByRequestedAt(); +} diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapNodeStatRepository.java b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapNodeStatRepository.java index 62b67616..8beb2301 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapNodeStatRepository.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapNodeStatRepository.java @@ -2,6 +2,7 @@ import com.back.domain.roadmap.roadmap.entity.JobRoadmapNodeStat; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -15,4 +16,8 @@ public interface JobRoadmapNodeStatRepository extends JpaRepository findByNode_RoadmapIdWithNode(@Param("roadmapId") Long roadmapId); + + @Modifying + @Query("DELETE FROM JobRoadmapNodeStat s WHERE s.node.id IN :nodeIds") + void deleteByNodeIdIn(@Param("nodeIds") List nodeIds); } diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/RoadmapNodeRepository.java b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/RoadmapNodeRepository.java index fc67d543..610757f0 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/RoadmapNodeRepository.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/RoadmapNodeRepository.java @@ -20,6 +20,36 @@ void deleteByRoadmapIdAndRoadmapType( @Param("roadmapType") RoadmapNode.RoadmapType roadmapType ); + @Query("SELECT MAX(n.level) FROM RoadmapNode n " + + "WHERE n.roadmapId = :roadmapId AND n.roadmapType = :roadmapType") + Integer findMaxLevelByRoadmapIdAndRoadmapType( + @Param("roadmapId") Long roadmapId, + @Param("roadmapType") RoadmapNode.RoadmapType roadmapType); + + @Modifying + @Query("DELETE FROM RoadmapNode n " + + "WHERE n.roadmapId = :roadmapId " + + "AND n.roadmapType = :roadmapType " + + "AND n.level = :level") + void deleteByRoadmapIdAndRoadmapTypeAndLevel( + @Param("roadmapId") Long roadmapId, + @Param("roadmapType") RoadmapNode.RoadmapType roadmapType, + @Param("level") int level); + + // 부모-자식 구조를 가진 엔티티를 삭제하기 위해 자식부터 순서대로 삭제(PQL 2단계 방식) + @Modifying + @Query("DELETE FROM RoadmapNode r WHERE r.parent IS NOT NULL AND r.roadmapId = :roadmapId AND r.roadmapType = :roadmapType") + void deleteChildren(@Param("roadmapId") Long roadmapId, @Param("roadmapType") RoadmapNode.RoadmapType roadmapType); + + @Modifying + @Query("DELETE FROM RoadmapNode r WHERE r.parent IS NULL AND r.roadmapId = :roadmapId AND r.roadmapType = :roadmapType") + void deleteParents(@Param("roadmapId") Long roadmapId, @Param("roadmapType") RoadmapNode.RoadmapType roadmapType); + + + @Query("SELECT n.id FROM RoadmapNode n WHERE n.roadmapId = :roadmapId AND n.roadmapType = :roadmapType") + List findIdsByRoadmapIdAndRoadmapType(@Param("roadmapId") Long roadmapId, + @Param("roadmapType") RoadmapNode.RoadmapType roadmapType); + // 조회용 메서드 (성능 최적화용) List findByRoadmapIdAndRoadmapTypeOrderByStepOrder( Long roadmapId, diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapBatchIntegrator.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapBatchIntegrator.java new file mode 100644 index 00000000..985aa7c2 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapBatchIntegrator.java @@ -0,0 +1,64 @@ +package com.back.domain.roadmap.roadmap.service; + +import com.back.domain.roadmap.roadmap.entity.JobRoadmapIntegrationQueue; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapIntegrationQueueRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JobRoadmapBatchIntegrator { + private final JobRoadmapIntegrationQueueRepository queueRepository; + private final JobRoadmapIntegrationProcessor processor; + private static final int MAX_RETRY = 3; + + @Scheduled(fixedDelay = 120000) // 2분 + public void integrate() { + List pendingQueues = queueRepository.findAllOrderByRequestedAt(); + + if(pendingQueues.isEmpty()) { + log.debug("처리할 큐가 없습니다."); + return; + } + + log.info("직업 로드맵 배치 통합 시작: {}개 직업", pendingQueues.size()); + + int successCount = 0; + int conflictCount = 0; + + for(JobRoadmapIntegrationQueue queue : pendingQueues) { + try { + processor.processQueue(queue); + successCount++; + + } catch (ObjectOptimisticLockingFailureException e) { + // 낙관적 락 충돌: 다른 트랜잭션이 큐를 수정함 (정상 동작) + conflictCount++; + log.info("버전 충돌 발생 (정상): jobId={}, 다음 주기에 재처리", + queue.getJobId()); + + } catch (Exception e) { + // 실제 에러: 통합 로직 실패 등 + log.error("직업 로드맵 통합 실패: jobId={}, error={}", + queue.getJobId(), e.getMessage()); + + try { + processor.handleRetry(queue, MAX_RETRY); + } catch (Exception retryError) { + log.error("재시도 처리 실패: jobId={}, error={}", + queue.getJobId(), retryError.getMessage()); + } + } + } + + int failureCount = pendingQueues.size() - successCount - conflictCount; + log.info("직업 로드맵 배치 통합 완료: 성공 {}, 충돌 {}, 실패 {}, 총 {}개", + successCount, conflictCount, failureCount, pendingQueues.size()); + } +} diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationProcessor.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationProcessor.java new file mode 100644 index 00000000..0ebe2497 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationProcessor.java @@ -0,0 +1,56 @@ +package com.back.domain.roadmap.roadmap.service; + +import com.back.domain.roadmap.roadmap.entity.JobRoadmapIntegrationQueue; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapIntegrationQueueRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class JobRoadmapIntegrationProcessor { + private final JobRoadmapIntegrationQueueRepository queueRepository; + private final JobRoadmapIntegrationServiceV2 integrationService; + + /** + * 단일 큐 항목 처리 (통합 + 큐 삭제를 하나의 트랜잭션으로) + * REQUIRES_NEW: 각 큐 항목이 독립적인 트랜잭션 + * + * ObjectOptimisticLockingFailureException 발생 시: + * - 다른 트랜잭션(이벤트 리스너)이 이 큐를 동시에 수정함 + * - 최신 요청(requestedAt 갱신)이 반영되었으므로 이번 처리는 무시 + * - 전체 트랜잭션 롤백 (통합 결과도 저장 안 됨) + * - 다음 스케줄링 때 갱신된 큐로 재처리됨 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void processQueue(JobRoadmapIntegrationQueue queue) { + Long jobId = queue.getJobId(); + + // 1. 통합 실행 + integrationService.integrateJobRoadmap(jobId); + + // 2. 성공 시 큐 삭제 (같은 트랜잭션) + queueRepository.delete(queue); + + log.info("직업 로드맵 통합 성공: jobId={}", jobId); + } + + /** + * 재시도 로직 처리 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleRetry(JobRoadmapIntegrationQueue queue, int maxRetry) { + if (queue.isMaxRetryExceeded(maxRetry)) { + queueRepository.delete(queue); + log.warn("최대 재시도 횟수 초과로 큐에서 제거: jobId={}", queue.getJobId()); + } else { + queue.incrementRetryCount(); + queue.updateRequestedAt(); + queueRepository.save(queue); + log.info("재시도 예약: jobId={}, retryCount={}", queue.getJobId(), queue.getRetryCount()); + } + } +} diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationService.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationService.java index b0c06704..cb98a8d8 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationService.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationService.java @@ -351,7 +351,7 @@ public JobRoadmap integrateJobRoadmap(Long jobId) { // 연결 수행 visited.add(ck); parentNode.addChild(childNode); - childNode.setStepOrder(order++); + childNode.assignOrderInSiblings(order++); q.add(ck); } } @@ -372,7 +372,7 @@ public JobRoadmap integrateJobRoadmap(Long jobId) { visited.add(dc.childKey); bestParentNode.addChild(childNode); int childCount = bestParentNode.getChildren().size(); - childNode.setStepOrder(childCount); + childNode.assignOrderInSiblings(childCount); deferredProcessed++; log.debug("Deferred 연결 성공: {} -> {}", bestParent, dc.childKey); } else { @@ -393,7 +393,7 @@ public JobRoadmap integrateJobRoadmap(Long jobId) { visited.add(dc.childKey); fallbackParentNode.addChild(childNode); int childCount = fallbackParentNode.getChildren().size(); - childNode.setStepOrder(childCount); + childNode.assignOrderInSiblings(childCount); log.debug("Fallback 연결: {} -> {}", dc.parentKey, dc.childKey); } else { // 최종 실패 -> 대안 기록 @@ -408,8 +408,7 @@ public JobRoadmap integrateJobRoadmap(Long jobId) { // 메인 루트 설정 (단일 루트만 허용) RoadmapNode mainRoot = keyToNode.get(rootKey); if (mainRoot != null) { - mainRoot.setStepOrder(1); - mainRoot.setLevel(0); + mainRoot.initializeAsRoot(); } // 고아 노드(visited되지 않은 노드) 로그 기록 @@ -447,8 +446,7 @@ public JobRoadmap integrateJobRoadmap(Long jobId) { // 모든 노드에 roadmapId, roadmapType 설정 후 JobRoadmap의 노드로 추가 for (RoadmapNode n : allNodes) { - n.setRoadmapId(roadmapId); - n.setRoadmapType(RoadmapType.JOB); + n.assignToRoadmap(roadmapId, RoadmapType.JOB); jobRoadmap.getNodes().add(n); } @@ -459,18 +457,13 @@ public JobRoadmap integrateJobRoadmap(Long jobId) { for (RoadmapNode persisted : saved.getNodes()) { String k = generateKey(persisted); AggregatedNode a = agg.get(k); - JobRoadmapNodeStat stat = new JobRoadmapNodeStat(); - stat.setNode(persisted); - stat.setStepOrder(persisted.getStepOrder()); // 해당 키를 선택한 멘토 수 int mentorCount = mentorAppearSet.getOrDefault(k, Collections.emptySet()).size(); - stat.setMentorCount(mentorCount); // 평균 단계 위치 List posList = positions.getOrDefault(k, Collections.emptyList()); Double avgPos = posList.isEmpty() ? null : posList.stream().mapToInt(Integer::intValue).average().orElse(0.0); - stat.setAveragePosition(avgPos); // Weight 계산: priorityScore와 동일한 복합 가중치 사용 // (등장빈도, 멘토커버리지, 평균위치, 연결성) @@ -494,30 +487,34 @@ public JobRoadmap integrateJobRoadmap(Long jobId) { // 방어적 코딩: 0~1 범위로 클램프 weight = Math.max(0.0, Math.min(1.0, weight)); - stat.setWeight(weight); - stat.setTotalMentorCount(totalMentorCount); - stat.setMentorCoverageRatio(mentorCoverageScore); // 0.0 ~ 1.0 - stat.setOutgoingTransitions(outgoing); - stat.setIncomingTransitions(incoming); - // 다음으로 이어지는 노드들의 분포 (Ut.json 사용) Map outMap = transitions.getOrDefault(k, Collections.emptyMap()); + String transitionCountsJson = null; if (!outMap.isEmpty()) { - String json = Ut.json.toString(outMap); - if (json != null) { - stat.setTransitionCounts(json); - } + transitionCountsJson = Ut.json.toString(outMap); } // 대안 부모 정보 저장 (메타정보 포함, Ut.json 사용) List altParents = skippedParents.get(k); + String alternativeParentsJson = null; if (altParents != null && !altParents.isEmpty()) { - String json = Ut.json.toString(altParents); - if (json != null) { - stat.setAlternativeParents(json); - } + alternativeParentsJson = Ut.json.toString(altParents); } + JobRoadmapNodeStat stat = JobRoadmapNodeStat.builder() + .node(persisted) + .stepOrder(persisted.getStepOrder()) + .weight(weight) + .averagePosition(avgPos) + .mentorCount(mentorCount) + .totalMentorCount(totalMentorCount) + .mentorCoverageRatio(mentorCoverageScore) + .outgoingTransitions(outgoing) + .incomingTransitions(incoming) + .transitionCounts(transitionCountsJson) + .alternativeParents(alternativeParentsJson) + .build(); + stats.add(stat); } diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationServiceV2.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationServiceV2.java new file mode 100644 index 00000000..184f2710 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationServiceV2.java @@ -0,0 +1,803 @@ +package com.back.domain.roadmap.roadmap.service; + +import com.back.domain.job.job.entity.Job; +import com.back.domain.job.job.repository.JobRepository; +import com.back.domain.roadmap.roadmap.entity.JobRoadmap; +import com.back.domain.roadmap.roadmap.entity.JobRoadmapNodeStat; +import com.back.domain.roadmap.roadmap.entity.MentorRoadmap; +import com.back.domain.roadmap.roadmap.entity.RoadmapNode; +import com.back.domain.roadmap.roadmap.entity.RoadmapNode.RoadmapType; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapNodeStatRepository; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapRepository; +import com.back.domain.roadmap.roadmap.repository.MentorRoadmapRepository; +import com.back.domain.roadmap.task.entity.Task; +import com.back.domain.roadmap.task.repository.TaskRepository; +import com.back.global.exception.ServiceException; +import com.back.standard.util.Ut; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class JobRoadmapIntegrationServiceV2 { + private final MentorRoadmapRepository mentorRoadmapRepository; + private final JobRepository jobRepository; + private final JobRoadmapRepository jobRoadmapRepository; + private final TaskRepository taskRepository; + private final JobRoadmapNodeStatRepository jobRoadmapNodeStatRepository; + private final com.back.domain.roadmap.roadmap.repository.RoadmapNodeRepository roadmapNodeRepository; + + // --- 통합 알고리즘 상수 --- + private final double BRANCH_THRESHOLD = 0.25; + private final int MAX_DEPTH = 10; + private final int MAX_CHILDREN = 4; + private final int MAX_DEFERRED_RETRY = 3; + + // --- 품질 필터링 상수 --- + private static final double MIN_STANDARDIZATION_RATE = 0.5; // 최소 표준화율 50% + private static final double MIN_QUALITY_THRESHOLD = 0.4; // 최소 품질 점수 40% + private static final double QUALITY_NODE_COUNT_WEIGHT = 0.3; // 품질 점수: 노드 개수 가중치 + private static final double QUALITY_STANDARDIZATION_WEIGHT = 0.7; // 품질 점수: 표준화율 가중치 + + // --- 부모 우선순위 점수(priorityScore) 가중치 --- + private static final double W_TRANSITION_POPULARITY = 0.4; // 전체 멘토 대비 전이 빈도 (전체적인 인기) + private static final double W_TRANSITION_STRENGTH = 0.3; // 부모 노드 내에서의 전이 강도 (연결의 확실성) + private static final double W_POSITION_SIMILARITY = 0.2; // 부모-자식 노드 간 평균 위치 유사성 + private static final double W_MENTOR_COVERAGE = 0.1; // 부모 노드의 신뢰도 (얼마나 많은 멘토가 언급했나) + + + // ======================================== + // Public API + // ======================================== + + @Transactional + public JobRoadmap integrateJobRoadmap(Long jobId) { + // 1. 데이터 준비 + Job job = validateAndGetJob(jobId); + deleteExistingJobRoadmap(job); + List mentorRoadmaps = loadAndFilterMentorRoadmaps(jobId); + + // 2. 통계 집계 + AggregationResult aggregation = aggregateStatistics(mentorRoadmaps); + + // 3. 트리 구성 + TreeBuildResult treeResult = buildMainTree(aggregation); + + // 4. 로그 및 영속화 + logOrphanNodes(treeResult, aggregation); + return persistJobRoadmap(job, treeResult, aggregation); + } + + + // ======================================== + // Step 1: 데이터 준비 및 필터링 + // ======================================== + + private Job validateAndGetJob(Long jobId) { + return jobRepository.findById(jobId) + .orElseThrow(() -> new ServiceException("404", "직업을 찾을 수 없습니다. id=" + jobId)); + } + + private void deleteExistingJobRoadmap(Job job) { + jobRoadmapRepository.findByJob(job).ifPresent(existing -> { + Long roadmapId = existing.getId(); + + // 1. 연관된 노드 ID들 조회 + List nodeIds = roadmapNodeRepository.findIdsByRoadmapIdAndRoadmapType( + roadmapId, + RoadmapType.JOB + ); + + // 2. 해당 노드들의 JobRoadmapNodeStat 먼저 삭제 + if (!nodeIds.isEmpty()) { + jobRoadmapNodeStatRepository.deleteByNodeIdIn(nodeIds); + } + + // 3. 레벨이 깊은 노드부터 삭제 (leaf → root 순서) + // 최대 레벨 조회 + Integer maxLevel = roadmapNodeRepository.findMaxLevelByRoadmapIdAndRoadmapType( + roadmapId, RoadmapType.JOB); + + if (maxLevel != null) { + // 가장 깊은 레벨부터 0까지 역순으로 삭제 + for (int level = maxLevel; level >= 0; level--) { + roadmapNodeRepository.deleteByRoadmapIdAndRoadmapTypeAndLevel( + roadmapId, RoadmapType.JOB, level); + } + } + + // 5. JobRoadmap 삭제 + jobRoadmapRepository.delete(existing); + + log.info("기존 JobRoadmap 삭제: id={}, 노드들 먼저 삭제됨", roadmapId); + }); + } + + private List loadAndFilterMentorRoadmaps(Long jobId) { + List all = mentorRoadmapRepository.findAllByMentorJobIdWithNodes(jobId); + + List filtered = all.stream() + .filter(mr -> mr.getNodes() != null && mr.getNodes().size() >= 3) + .filter(mr -> calculateStandardizationRate(mr) >= MIN_STANDARDIZATION_RATE) + .filter(mr -> calculateRoadmapQuality(mr) >= MIN_QUALITY_THRESHOLD) + .toList(); + + if (filtered.isEmpty()) { + throw new ServiceException("404", "해당 직업에 대한 유효한 멘토 로드맵이 존재하지 않습니다. " + + "(최소 조건: 노드 3개 이상, 표준화율 " + (int)(MIN_STANDARDIZATION_RATE * 100) + "% 이상, 품질 점수 " + MIN_QUALITY_THRESHOLD + " 이상)"); + } + + log.info("멘토 로드맵 품질 필터링: 전체 {}개 → 유효 {}개 (노드 3개 이상, 표준화율 {}% 이상, 품질 점수 {} 이상)", + all.size(), filtered.size(), (int)(MIN_STANDARDIZATION_RATE * 100), MIN_QUALITY_THRESHOLD); + + return filtered; + } + + + // ======================================== + // Step 2: 통계 집계 + // ======================================== + + private AggregationResult aggregateStatistics(List mentorRoadmaps) { + AggregationResult result = new AggregationResult(mentorRoadmaps.size()); + + for (MentorRoadmap mr : mentorRoadmaps) { + List nodes = getSortedNodes(mr); + if (nodes.isEmpty()) continue; + + aggregateRootCandidate(nodes.get(0), result); + aggregateNodesFromRoadmap(mr, nodes, result); + } + + return result; + } + + private List getSortedNodes(MentorRoadmap mr) { + return mr.getNodes().stream() + .sorted(Comparator.comparingInt(RoadmapNode::getStepOrder)) + .toList(); + } + + private void aggregateRootCandidate(RoadmapNode first, AggregationResult result) { + result.rootCount.merge(generateKey(first), 1, Integer::sum); + } + + private void aggregateNodesFromRoadmap(MentorRoadmap mr, List nodes, AggregationResult result) { + Long mentorId = mr.getMentor().getId(); + + for (int i = 0; i < nodes.size(); i++) { + RoadmapNode rn = nodes.get(i); + String key = generateKey(rn); + + aggregateNodeStatistics(rn, key, i + 1, mentorId, result); + aggregateDescriptions(rn, key, result.descriptions); + + if (i < nodes.size() - 1) { + aggregateTransition(key, generateKey(nodes.get(i + 1)), result); + } + } + } + + private void aggregateNodeStatistics(RoadmapNode rn, String key, int position, Long mentorId, AggregationResult result) { + result.agg.computeIfAbsent(key, kk -> new AggregatedNode( + rn.getTask(), + rn.getTask() != null ? rn.getTask().getName() : rn.getTaskName() + )).count++; + + result.positions.computeIfAbsent(key, kk -> new ArrayList<>()).add(position); + result.mentorAppearSet.computeIfAbsent(key, kk -> new HashSet<>()).add(mentorId); + } + + private void aggregateDescriptions(RoadmapNode rn, String key, DescriptionCollections descriptions) { + if (rn.getLearningAdvice() != null && !rn.getLearningAdvice().isBlank()) { + descriptions.learningAdvices.computeIfAbsent(key, kk -> new ArrayList<>()).add(rn.getLearningAdvice()); + } + + if (rn.getRecommendedResources() != null && !rn.getRecommendedResources().isBlank()) { + descriptions.recommendedResources.computeIfAbsent(key, kk -> new ArrayList<>()).add(rn.getRecommendedResources()); + } + + if (rn.getLearningGoals() != null && !rn.getLearningGoals().isBlank()) { + descriptions.learningGoals.computeIfAbsent(key, kk -> new ArrayList<>()).add(rn.getLearningGoals()); + } + + if (rn.getDifficulty() != null) { + descriptions.difficulties.computeIfAbsent(key, kk -> new ArrayList<>()).add(rn.getDifficulty()); + } + + if (rn.getImportance() != null) { + descriptions.importances.computeIfAbsent(key, kk -> new ArrayList<>()).add(rn.getImportance()); + } + + if (rn.getEstimatedHours() != null) { + descriptions.estimatedHours.computeIfAbsent(key, kk -> new ArrayList<>()).add(rn.getEstimatedHours()); + } + } + + private void aggregateTransition(String fromKey, String toKey, AggregationResult result) { + result.transitions.computeIfAbsent(fromKey, kk -> new HashMap<>()).merge(toKey, 1, Integer::sum); + } + + + // ======================================== + // Step 3: 트리 구성 + // ======================================== + + private TreeBuildResult buildMainTree(AggregationResult aggregation) { + String rootKey = selectRootKey(aggregation); + Map taskMap = prefetchTasks(aggregation); + Map keyToNode = createNodes(aggregation, taskMap); + ParentEvaluation parentEval = evaluateParentCandidates(aggregation, keyToNode); + + return constructTreeViaBFS(rootKey, keyToNode, parentEval, aggregation); + } + + private String selectRootKey(AggregationResult aggregation) { + String rootKey = aggregation.rootCount.entrySet().stream() + .max(Comparator.comparingInt((Map.Entry e) -> e.getValue()) + .thenComparing(Map.Entry::getKey)) + .map(Map.Entry::getKey) + .orElseGet(() -> aggregation.agg.entrySet().stream() + .max(Comparator.comparingInt(e -> e.getValue().count)) + .map(Map.Entry::getKey) + .orElseThrow()); + + log.info("선택된 rootKey={} (빈도={})", rootKey, aggregation.rootCount.getOrDefault(rootKey, 0)); + return rootKey; + } + + /** + * Task 엔티티를 일괄 로드하여 N+1 문제 방지 + * @param aggregation 집계 결과 + * @return taskId → Task 매핑 + */ + private Map prefetchTasks(AggregationResult aggregation) { + Set taskIds = aggregation.agg.values().stream() + .map(a -> a.task != null ? a.task.getId() : null) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + Map taskMap = new HashMap<>(); + if (!taskIds.isEmpty()) { + taskRepository.findAllById(taskIds).forEach(t -> taskMap.put(t.getId(), t)); + } + + return taskMap; + } + + private Map createNodes(AggregationResult aggregation, Map taskMap) { + Map keyToNode = new HashMap<>(); + + aggregation.agg.forEach((key, aggNode) -> { + Double avgDifficulty = calculateAverage(aggregation.descriptions.difficulties.get(key)); + Double avgImportance = calculateAverage(aggregation.descriptions.importances.get(key)); + Integer avgEstimatedHours = calculateIntegerAverage(aggregation.descriptions.estimatedHours.get(key)); + + RoadmapNode node = RoadmapNode.builder() + .taskName(aggNode.displayName) + .learningAdvice(mergeTopDescriptions(aggregation.descriptions.learningAdvices.get(key))) + .recommendedResources(mergeTopDescriptions(aggregation.descriptions.recommendedResources.get(key))) + .learningGoals(mergeTopDescriptions(aggregation.descriptions.learningGoals.get(key))) + .difficulty(avgDifficulty != null ? avgDifficulty.intValue() : null) + .importance(avgImportance != null ? avgImportance.intValue() : null) + .estimatedHours(avgEstimatedHours) + .task(aggNode.task != null ? taskMap.get(aggNode.task.getId()) : null) + .roadmapId(0L) + .roadmapType(RoadmapType.JOB) + .build(); + + keyToNode.put(key, node); + }); + + return keyToNode; + } + + private ParentEvaluation evaluateParentCandidates(AggregationResult aggregation, Map keyToNode) { + Map> chosenChildren = new HashMap<>(); + Map> childToParentCandidates = new HashMap<>(); + + for (Map.Entry> e : aggregation.transitions.entrySet()) { + String parentKey = e.getKey(); + Map childTransitions = e.getValue(); + int parentTotalTransitions = childTransitions.values().stream().mapToInt(Integer::intValue).sum(); + + List> sortedChildren = childTransitions.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(MAX_CHILDREN) + .toList(); + + List chosen = new ArrayList<>(); + for (int i = 0; i < sortedChildren.size(); i++) { + Map.Entry ce = sortedChildren.get(i); + String childKey = ce.getKey(); + int transitionCount = ce.getValue(); + + if (i == 0) { + chosen.add(childKey); + } else { + double ratio = (double) transitionCount / aggregation.agg.get(parentKey).count; + if (ratio >= BRANCH_THRESHOLD) { + chosen.add(childKey); + } + } + + double priorityScore = calculatePriorityScore(parentKey, childKey, transitionCount, + parentTotalTransitions, aggregation); + + childToParentCandidates.computeIfAbsent(childKey, k -> new ArrayList<>()) + .add(new ParentCandidate(parentKey, transitionCount, priorityScore)); + } + + if (!chosen.isEmpty()) { + chosenChildren.put(parentKey, chosen); + } + } + + Map childToBestParent = new HashMap<>(); + childToParentCandidates.forEach((child, candidates) -> { + candidates.sort(Comparator.comparingDouble(ParentCandidate::getPriorityScore).reversed() + .thenComparing(ParentCandidate::getParentKey)); + childToBestParent.put(child, candidates.get(0).parentKey); + }); + + return new ParentEvaluation(chosenChildren, childToBestParent); + } + + private double calculatePriorityScore(String parentKey, String childKey, int transitionCount, + int parentTotalTransitions, AggregationResult aggregation) { + double transitionPopularity = (double) transitionCount / aggregation.totalMentorCount; + double transitionStrength = (double) transitionCount / parentTotalTransitions; + + double avgParentPos = aggregation.positions.getOrDefault(parentKey, Collections.emptyList()) + .stream().mapToInt(Integer::intValue).average().orElse(99.0); + double avgChildPos = aggregation.positions.getOrDefault(childKey, Collections.emptyList()) + .stream().mapToInt(Integer::intValue).average().orElse(99.0); + double positionSimilarity = 1.0 / (1.0 + Math.abs(avgParentPos - avgChildPos)); + + double mentorCoverage = (double) aggregation.mentorAppearSet.getOrDefault(parentKey, Collections.emptySet()).size() + / aggregation.totalMentorCount; + + return W_TRANSITION_POPULARITY * transitionPopularity + + W_TRANSITION_STRENGTH * transitionStrength + + W_POSITION_SIMILARITY * positionSimilarity + + W_MENTOR_COVERAGE * mentorCoverage; + } + + private TreeBuildResult constructTreeViaBFS(String rootKey, Map keyToNode, + ParentEvaluation parentEval, AggregationResult aggregation) { + TreeBuildResult result = new TreeBuildResult(rootKey, keyToNode); + Queue deferredQueue = new ArrayDeque<>(); + + performFirstPassBFS(result, parentEval, aggregation, deferredQueue); + performDeferredRetry(result, parentEval, deferredQueue); + initializeMainRoot(result); + + return result; + } + + private void performFirstPassBFS(TreeBuildResult result, ParentEvaluation parentEval, + AggregationResult aggregation, Queue deferredQueue) { + Queue q = new ArrayDeque<>(); + q.add(result.rootKey); + + while (!q.isEmpty()) { + String pk = q.poll(); + RoadmapNode parentNode = result.keyToNode.get(pk); + List childs = parentEval.chosenChildren.getOrDefault(pk, Collections.emptyList()); + int order = 1; + + for (String ck : childs) { + if (result.visited.contains(ck)) { + recordAsAlternative(pk, ck, aggregation, result); + continue; + } + + RoadmapNode childNode = result.keyToNode.get(ck); + if (childNode == null) continue; + + String bestParent = parentEval.childToBestParent.get(ck); + if (bestParent != null && !bestParent.equals(pk)) { + if (!result.visited.contains(bestParent)) { + deferredQueue.add(new DeferredChild(pk, ck, MAX_DEFERRED_RETRY)); + } else { + recordAsAlternative(pk, ck, aggregation, result); + } + continue; + } + + if (parentNode.getLevel() + 1 >= MAX_DEPTH) { + log.warn("MAX_DEPTH({}) 초과로 노드 추가 중단: parent={}, child={}", MAX_DEPTH, pk, ck); + recordAsAlternative(pk, ck, aggregation, result); + continue; + } + + result.visited.add(ck); + parentNode.addChild(childNode); + childNode.assignOrderInSiblings(order++); + q.add(ck); + } + } + } + + private void performDeferredRetry(TreeBuildResult result, ParentEvaluation parentEval, + Queue deferredQueue) { + int deferredProcessed = 0; + + while (!deferredQueue.isEmpty()) { + DeferredChild dc = deferredQueue.poll(); + if (result.visited.contains(dc.childKey)) continue; + + String bestParent = parentEval.childToBestParent.get(dc.childKey); + if (bestParent != null && result.visited.contains(bestParent)) { + RoadmapNode bestParentNode = result.keyToNode.get(bestParent); + RoadmapNode childNode = result.keyToNode.get(dc.childKey); + + if (bestParentNode != null && childNode != null && bestParentNode.getLevel() + 1 < MAX_DEPTH) { + result.visited.add(dc.childKey); + bestParentNode.addChild(childNode); + childNode.assignOrderInSiblings(bestParentNode.getChildren().size()); + deferredProcessed++; + } + } else if (dc.retryCount > 0) { + deferredQueue.add(new DeferredChild(dc.parentKey, dc.childKey, dc.retryCount - 1)); + } + } + + log.info("Deferred 재시도 완료: {}개 노드 연결", deferredProcessed); + } + + private void initializeMainRoot(TreeBuildResult result) { + RoadmapNode mainRoot = result.keyToNode.get(result.rootKey); + if (mainRoot != null) { + mainRoot.initializeAsRoot(); + } + } + + private void recordAsAlternative(String parentKey, String childKey, AggregationResult aggregation, TreeBuildResult result) { + int transitionCount = aggregation.transitions.getOrDefault(parentKey, Collections.emptyMap()) + .getOrDefault(childKey, 0); + int parentMentorCount = aggregation.mentorAppearSet.getOrDefault(parentKey, Collections.emptySet()).size(); + + double transitionPopularity = (double) transitionCount / aggregation.totalMentorCount; + + List parentPosList = aggregation.positions.getOrDefault(parentKey, Collections.emptyList()); + double avgParentPos = parentPosList.isEmpty() ? 99.0 + : parentPosList.stream().mapToInt(Integer::intValue).average().orElse(99.0); + double positionScore = 1.0 / (avgParentPos + 1); + + double mentorCoverageScore = (double) parentMentorCount / aggregation.totalMentorCount; + + double score = + W_TRANSITION_POPULARITY * transitionPopularity + + W_POSITION_SIMILARITY * positionScore + + W_MENTOR_COVERAGE * mentorCoverageScore; + + AlternativeParentInfo info = new AlternativeParentInfo(parentKey, transitionCount, parentMentorCount, score); + result.skippedParents.computeIfAbsent(childKey, k -> new ArrayList<>()).add(info); + } + + + // ======================================== + // Step 4: 로그 및 영속화 + // ======================================== + + private void logOrphanNodes(TreeBuildResult result, AggregationResult aggregation) { + long orphanCount = result.keyToNode.values().stream() + .filter(n -> !result.visited.contains(generateKey(n))) + .count(); + + if (orphanCount > 0) { + log.info("=== 제외된 고아 노드 ==="); + result.keyToNode.entrySet().stream() + .filter(e -> !result.visited.contains(e.getKey())) + .forEach(e -> { + String key = e.getKey(); + AggregatedNode aggNode = aggregation.agg.get(key); + int count = aggNode != null ? aggNode.count : 0; + int mentorCount = aggregation.mentorAppearSet.getOrDefault(key, Collections.emptySet()).size(); + log.info(" - 키: {}, 이름: {}, 출현빈도: {}회, 멘토수: {}명", + key, e.getValue().getTaskName(), count, mentorCount); + }); + log.info("총 {}개의 저빈도 노드가 메인 트리에서 제외되었습니다.", orphanCount); + } + } + + private JobRoadmap persistJobRoadmap(Job job, TreeBuildResult treeResult, AggregationResult aggregation) { + JobRoadmap jobRoadmap = jobRoadmapRepository.save(JobRoadmap.builder().job(job).build()); + Long roadmapId = jobRoadmap.getId(); + + attachNodesToRoadmap(jobRoadmap, treeResult, roadmapId); + JobRoadmap saved = jobRoadmapRepository.save(jobRoadmap); + + saveNodeStatistics(saved, treeResult, aggregation); + + log.info("JobRoadmap 생성 완료: id={}, 노드={}개", saved.getId(), saved.getNodes().size()); + return saved; + } + + private void attachNodesToRoadmap(JobRoadmap roadmap, TreeBuildResult treeResult, Long roadmapId) { + List allNodes = treeResult.keyToNode.values().stream() + .filter(n -> treeResult.visited.contains(generateKey(n))) + .peek(n -> n.assignToRoadmap(roadmapId, RoadmapType.JOB)) + .toList(); + + roadmap.getNodes().addAll(allNodes); + } + + private void saveNodeStatistics(JobRoadmap roadmap, TreeBuildResult treeResult, AggregationResult aggregation) { + List stats = roadmap.getNodes().stream() + .map(node -> createNodeStat(node, treeResult, aggregation)) + .toList(); + + jobRoadmapNodeStatRepository.saveAll(stats); + } + + private JobRoadmapNodeStat createNodeStat(RoadmapNode node, TreeBuildResult treeResult, AggregationResult aggregation) { + String key = generateKey(node); + AggregatedNode aggNode = aggregation.agg.get(key); + + int mentorCount = aggregation.mentorAppearSet.getOrDefault(key, Collections.emptySet()).size(); + + List posList = aggregation.positions.getOrDefault(key, Collections.emptyList()); + Double avgPos = posList.isEmpty() ? null : posList.stream().mapToInt(Integer::intValue).average().orElse(0.0); + + double frequencyScore = aggNode == null ? 0.0 : (double) aggNode.count / (double) aggregation.totalMentorCount; + double mentorCoverageScore = (double) mentorCount / (double) aggregation.totalMentorCount; + double positionScore = avgPos != null ? 1.0 / (avgPos + 1) : 0.0; + + int outgoing = aggregation.transitions.getOrDefault(key, Collections.emptyMap()).values().stream() + .mapToInt(Integer::intValue).sum(); + int incoming = aggregation.transitions.entrySet().stream() + .mapToInt(e -> e.getValue().getOrDefault(key, 0)).sum(); + int totalTransitions = outgoing + incoming; + double connectivityScore = totalTransitions > 0 ? Math.min(1.0, (double) totalTransitions / (aggregation.totalMentorCount * 2)) : 0.0; + + double weight = 0.4 * frequencyScore + + 0.3 * mentorCoverageScore + + 0.2 * positionScore + + 0.1 * connectivityScore; + + weight = Math.max(0.0, Math.min(1.0, weight)); + + Map outMap = aggregation.transitions.getOrDefault(key, Collections.emptyMap()); + String transitionCountsJson = null; + if (!outMap.isEmpty()) { + transitionCountsJson = Ut.json.toString(outMap); + } + + List altParents = treeResult.skippedParents.get(key); + String alternativeParentsJson = null; + if (altParents != null && !altParents.isEmpty()) { + alternativeParentsJson = Ut.json.toString(altParents); + } + + return JobRoadmapNodeStat.builder() + .node(node) + .stepOrder(node.getStepOrder()) + .weight(weight) + .averagePosition(avgPos) + .mentorCount(mentorCount) + .totalMentorCount(aggregation.totalMentorCount) + .mentorCoverageRatio(mentorCoverageScore) + .outgoingTransitions(outgoing) + .incomingTransitions(incoming) + .transitionCounts(transitionCountsJson) + .alternativeParents(alternativeParentsJson) + .build(); + } + + + // ======================================== + // 헬퍼 메서드 + // ======================================== + + private String generateKey(RoadmapNode rn) { + if (rn.getTask() != null) { + return "T:" + rn.getTask().getId(); + } + String name = rn.getTaskName(); + if (name == null || name.trim().isEmpty()) return "N:__unknown__"; + return "N:" + name.trim().toLowerCase().replaceAll("\\s+", " "); + } + + private String mergeTopDescriptions(List list) { + if (list == null || list.isEmpty()) return null; + LinkedHashSet set = new LinkedHashSet<>(); + for (String s : list) { + if (s == null) continue; + String c = s.trim(); + if (!c.isEmpty()) set.add(c); + if (set.size() >= 3) break; + } + return String.join("\n\n", set); + } + + private Double calculateAverage(List list) { + if (list == null || list.isEmpty()) return null; + return list.stream().mapToInt(Integer::intValue).average().orElse(0.0); + } + + private Integer calculateIntegerAverage(List list) { + Double avg = calculateAverage(list); + return avg != null ? (int) Math.round(avg) : null; + } + + /** + * 멘토 로드맵의 Task 표준화율 계산 + * @param roadmap 멘토 로드맵 + * @return 표준화율 (0.0 ~ 1.0) + */ + private double calculateStandardizationRate(MentorRoadmap roadmap) { + List nodes = roadmap.getNodes(); + if (nodes == null || nodes.isEmpty()) { + return 0.0; + } + + long standardizedCount = nodes.stream() + .filter(n -> n.getTask() != null) + .count(); + + return (double) standardizedCount / nodes.size(); + } + + /** + * 멘토 로드맵의 품질 점수 계산 + * 품질 점수 = (노드 개수 점수 × 0.3) + (표준화율 × 0.7) + * + * @param roadmap 멘토 로드맵 + * @return 품질 점수 (0.0 ~ 1.0) + */ + private double calculateRoadmapQuality(MentorRoadmap roadmap) { + List nodes = roadmap.getNodes(); + if (nodes == null || nodes.isEmpty()) { + return 0.0; + } + + // 노드 개수 점수 (3개=0.0, 15개=1.0) + int nodeCount = nodes.size(); + double nodeScore = Math.min(1.0, (nodeCount - 3.0) / 12.0); + + // 표준화율 + double standardizationScore = calculateStandardizationRate(roadmap); + + // 복합 점수 (노드 개수 30%, 표준화율 70%) + return QUALITY_NODE_COUNT_WEIGHT * nodeScore + QUALITY_STANDARDIZATION_WEIGHT * standardizationScore; + } + + + // ======================================== + // 중간 객체 (Internal DTOs) + // ======================================== + + /** + * 통계 집계 결과를 담는 객체 + */ + @Getter + private static class AggregationResult { + final Map agg = new HashMap<>(); + final Map> transitions = new HashMap<>(); + final Map rootCount = new HashMap<>(); + final Map> mentorAppearSet = new HashMap<>(); + final Map> positions = new HashMap<>(); + final DescriptionCollections descriptions = new DescriptionCollections(); + final int totalMentorCount; + + AggregationResult(int totalMentorCount) { + this.totalMentorCount = totalMentorCount; + } + } + + /** + * Description 관련 필드들을 묶은 객체 + */ + @Getter + private static class DescriptionCollections { + final Map> learningAdvices = new HashMap<>(); + final Map> recommendedResources = new HashMap<>(); + final Map> learningGoals = new HashMap<>(); + final Map> difficulties = new HashMap<>(); + final Map> importances = new HashMap<>(); + final Map> estimatedHours = new HashMap<>(); + } + + /** + * 트리 구성 결과를 담는 객체 + */ + @Getter + private static class TreeBuildResult { + final String rootKey; + final Map keyToNode; + final Set visited = new HashSet<>(); + final Map> skippedParents = new HashMap<>(); + + TreeBuildResult(String rootKey, Map keyToNode) { + this.rootKey = rootKey; + this.keyToNode = keyToNode; + visited.add(rootKey); + } + } + + /** + * 부모 후보 평가 결과를 담는 객체 + */ + @Getter + private static class ParentEvaluation { + final Map> chosenChildren; + final Map childToBestParent; + + ParentEvaluation(Map> chosenChildren, + Map childToBestParent) { + this.chosenChildren = chosenChildren; + this.childToBestParent = childToBestParent; + } + } + + + // ======================================== + // 헬퍼 클래스 (Helper Classes) + // ======================================== + + private static class AggregatedNode { + Task task; + String displayName; + int count = 0; + + AggregatedNode(Task task, String displayName) { + this.task = task; + this.displayName = displayName; + } + } + + private static class AlternativeParentInfo { + public String parentKey; // 부모 노드 키 (T:1, N:kotlin 등) + public int transitionCount; // 전이 빈도 + public int mentorCount; // 해당 부모를 사용한 멘토 수 + public double score; // 가중치 점수 + + public AlternativeParentInfo(String parentKey, int transitionCount, int mentorCount, double score) { + this.parentKey = parentKey; + this.transitionCount = transitionCount; + this.mentorCount = mentorCount; + this.score = score; + } + } + + private static class ParentCandidate { + private final String parentKey; + private final int transitionCount; + private final double priorityScore; + + public ParentCandidate(String parentKey, int transitionCount, double priorityScore) { + this.parentKey = parentKey; + this.transitionCount = transitionCount; + this.priorityScore = priorityScore; + } + + public String getParentKey() { + return parentKey; + } + + public double getPriorityScore() { + return priorityScore; + } + } + + private static class DeferredChild { + String parentKey; + String childKey; + int retryCount; + + public DeferredChild(String parentKey, String childKey, int retryCount) { + this.parentKey = parentKey; + this.childKey = childKey; + this.retryCount = retryCount; + } + } +} diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapUpdateEventListener.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapUpdateEventListener.java new file mode 100644 index 00000000..8e40874f --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapUpdateEventListener.java @@ -0,0 +1,39 @@ +package com.back.domain.roadmap.roadmap.service; + +import com.back.domain.roadmap.roadmap.entity.JobRoadmapIntegrationQueue; +import com.back.domain.roadmap.roadmap.event.MentorRoadmapChangeEvent; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapIntegrationQueueRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JobRoadmapUpdateEventListener { + private final JobRoadmapIntegrationQueueRepository jobRoadmapIntegrationQueueRepository; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void requestJobRoadmapUpdate(MentorRoadmapChangeEvent event) { + Long jobId = event.getJobId(); + + try { + JobRoadmapIntegrationQueue queue = + jobRoadmapIntegrationQueueRepository.findById(jobId) + .orElse(new JobRoadmapIntegrationQueue(jobId)); + + queue.updateRequestedAt(); + jobRoadmapIntegrationQueueRepository.save(queue); + log.info("직업 로드맵 재생성 예약: jobId: {}", jobId); + + } catch (Exception e) { + log.error("큐 저장 실패: jobId={}, error={}", event.getJobId(), e.getMessage()); + // 재발행 또는 별도 에러 큐 저장 + } + } +} diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/MentorRoadmapService.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/MentorRoadmapService.java index 11bd1e00..3a0c5e22 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/service/MentorRoadmapService.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/MentorRoadmapService.java @@ -8,6 +8,7 @@ import com.back.domain.roadmap.roadmap.dto.response.MentorRoadmapSaveResponse; import com.back.domain.roadmap.roadmap.entity.MentorRoadmap; import com.back.domain.roadmap.roadmap.entity.RoadmapNode; +import com.back.domain.roadmap.roadmap.event.MentorRoadmapChangeEvent; import com.back.domain.roadmap.roadmap.repository.MentorRoadmapRepository; import com.back.domain.roadmap.roadmap.repository.RoadmapNodeRepository; import com.back.domain.roadmap.task.entity.Task; @@ -15,6 +16,7 @@ import com.back.global.exception.ServiceException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,6 +32,7 @@ public class MentorRoadmapService { private final RoadmapNodeRepository roadmapNodeRepository; private final MentorRepository mentorRepository; private final TaskService taskService; + private final ApplicationEventPublisher eventPublisher; // 멘토 로드맵 생성 @Transactional @@ -55,9 +58,10 @@ public MentorRoadmapSaveResponse create(Long mentorId, MentorRoadmapSaveRequest // roadmapId를 포함한 노드 생성 및 추가 List allNodes = createValidatedNodesWithRoadmapId(request.nodes(), mentorRoadmap.getId()); + // CASCADE로 노드들이 자동 저장됨 (추가 save() 호출 불필요) mentorRoadmap.addNodes(allNodes); - // CASCADE로 노드들이 자동 저장됨 (추가 save() 호출 불필요) + eventPublisher.publishEvent(new MentorRoadmapChangeEvent(mentor.getJobId())); log.info("멘토 로드맵 생성 완료 - 멘토 ID: {}, 로드맵 ID: {}, 노드 수: {} (cascade 활용)", mentorId, mentorRoadmap.getId(), mentorRoadmap.getNodes().size()); @@ -130,6 +134,8 @@ public MentorRoadmapSaveResponse update(Long id, Long mentorId, MentorRoadmapSav log.info("멘토 로드맵 수정 완료 - 로드맵 ID: {}, 노드 수: {} (cascade 활용)", mentorRoadmap.getId(), mentorRoadmap.getNodes().size()); + eventPublisher.publishEvent(new MentorRoadmapChangeEvent(mentorRoadmap.getMentor().getJobId())); + return new MentorRoadmapSaveResponse( mentorRoadmap.getId(), mentorRoadmap.getMentor().getId(), @@ -151,6 +157,8 @@ public void delete(Long roadmapId, Long mentorId) { throw new ServiceException("403", "본인의 로드맵만 삭제할 수 있습니다."); } + Long jobId = mentorRoadmap.getMentor().getJobId(); + // 1. 관련 노드들을 먼저 직접 삭제 roadmapNodeRepository.deleteByRoadmapIdAndRoadmapType( roadmapId, @@ -161,6 +169,8 @@ public void delete(Long roadmapId, Long mentorId) { mentorRoadmapRepository.delete(mentorRoadmap); log.info("멘토 로드맵 삭제 완료 - 멘토 ID: {}, 로드맵 ID: {}", mentorId, roadmapId); + + eventPublisher.publishEvent(new MentorRoadmapChangeEvent(jobId)); } // taskId가 null인 자유입력 Task를 자동으로 pending alias로 등록 diff --git a/back/src/main/java/com/back/global/initData/RoadmapInitData.java b/back/src/main/java/com/back/global/initData/RoadmapInitData.java index dae6df93..db43ec38 100644 --- a/back/src/main/java/com/back/global/initData/RoadmapInitData.java +++ b/back/src/main/java/com/back/global/initData/RoadmapInitData.java @@ -14,6 +14,7 @@ import com.back.domain.roadmap.roadmap.repository.JobRoadmapRepository; import com.back.domain.roadmap.roadmap.repository.MentorRoadmapRepository; import com.back.domain.roadmap.roadmap.service.JobRoadmapIntegrationService; +import com.back.domain.roadmap.roadmap.service.JobRoadmapIntegrationServiceV2; import com.back.domain.roadmap.roadmap.service.MentorRoadmapService; import com.back.domain.roadmap.task.entity.Task; import com.back.domain.roadmap.task.repository.TaskRepository; @@ -43,6 +44,7 @@ public class RoadmapInitData { private final MentorRoadmapRepository mentorRoadmapRepository; private final JobRoadmapRepository jobRoadmapRepository; private final JobRoadmapIntegrationService jobRoadmapIntegrationService; + private final JobRoadmapIntegrationServiceV2 jobRoadmapIntegrationServiceV2; @Bean ApplicationRunner baseInitDataApplicationRunner2() { @@ -58,7 +60,8 @@ public void runInitData() { // 통합 로직 테스트 //initTestMentorRoadmaps(); // 테스트용 멘토 로드맵 10개 생성 - //testJobRoadmapIntegration(); // 통합 로직 실행 및 트리 구조 출력 + //testJobRoadmapIntegration(); // V1 통합 로직 실행 및 트리 구조 출력 + //testJobRoadmapIntegrationV2(); // V2 통합 로직 실행 및 트리 구조 출력 } // --- Job 초기화 --- @@ -1052,4 +1055,18 @@ private void printNodeRecursive(RoadmapNode node, String prefix, boolean isLast) printNodeRecursive(children.get(i), childPrefix, isLastChild); } } + + // --- V2 통합 로직 테스트 및 트리 구조 출력 --- + public void testJobRoadmapIntegrationV2() { + Job backendJob = jobRepository.findByName("백엔드 개발자") + .orElseThrow(() -> new RuntimeException("백엔드 개발자 직업을 찾을 수 없습니다.")); + + log.info("\n\n=== V2 직업 로드맵 통합 시작 ==="); + JobRoadmap integratedRoadmap = jobRoadmapIntegrationServiceV2.integrateJobRoadmap(backendJob.getId()); + log.info("=== V2 직업 로드맵 통합 완료 ===\n"); + + log.info("\n\n=== V2 통합된 직업 로드맵 트리 구조 출력 ==="); + printJobRoadmapTree(integratedRoadmap); + log.info("=== V2 트리 구조 출력 완료 ===\n\n"); + } } diff --git a/back/src/main/java/com/back/global/initData/SessionInitData.java b/back/src/main/java/com/back/global/initData/SessionInitData.java new file mode 100644 index 00000000..f51df3ed --- /dev/null +++ b/back/src/main/java/com/back/global/initData/SessionInitData.java @@ -0,0 +1,62 @@ +package com.back.global.initData; + +import com.back.domain.member.member.entity.Member; +import com.back.domain.member.member.service.MemberService; +import com.back.domain.member.mentee.entity.Mentee; +import com.back.domain.member.mentee.repository.MenteeRepository; +import com.back.domain.member.mentor.entity.Mentor; +import com.back.domain.member.mentor.repository.MentorRepository; +import com.back.domain.mentoring.mentoring.dto.request.MentoringRequest; +import com.back.domain.mentoring.mentoring.dto.response.MentoringResponse; +import com.back.domain.mentoring.mentoring.service.MentoringService; +import com.back.domain.mentoring.reservation.dto.request.ReservationRequest; +import com.back.domain.mentoring.reservation.service.ReservationService; +import com.back.domain.mentoring.slot.dto.request.MentorSlotRequest; +import com.back.domain.mentoring.slot.dto.response.MentorSlotResponse; +import com.back.domain.mentoring.slot.service.MentorSlotService; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.LocalDateTime; +import java.util.Arrays; + +//TODO : 삭제 예정 +@Configuration +@RequiredArgsConstructor +public class SessionInitData { + private final MemberService memberService; + private final MentoringService mentoringService; + private final MentorSlotService mentorSlotService; + private final ReservationService reservationService; + private final MentorRepository mentorRepository; + private final MenteeRepository menteeRepository; + +// @Bean + public CommandLineRunner initData() { + return args -> { + // 멘토, 멘티 생성 + Member mentorMember = memberService.joinMentor("mentor@example.com", "Mentor Name", "mentor123", "123", "IT", 10); + Member menteeMember = memberService.joinMentee("mentee@example.com", "Mentee Name", "mentee123", "123", "IT"); + + Mentor mentor = mentorRepository.findByMemberIdWithMember(mentorMember.getId()).orElseThrow(); + Mentee mentee = menteeRepository.findByMemberIdWithMember(menteeMember.getId()).orElseThrow(); + + // 멘토링 생성 + MentoringRequest mentoringRequest = new MentoringRequest("Test Mentoring", Arrays.asList("Java", "Spring"), "This is a test mentoring.", null); + MentoringResponse mentoringResponse = mentoringService.createMentoring(mentoringRequest, mentor); + + // 멘토 슬롯 생성 + MentorSlotRequest mentorSlotRequest = new MentorSlotRequest(mentor.getId(), LocalDateTime.now().plusDays(1), LocalDateTime.now().plusDays(1).plusHours(1)); + MentorSlotResponse mentorSlotResponse = mentorSlotService.createMentorSlot(mentorSlotRequest, mentor); + + // 예약 생성 + ReservationRequest reservationRequest = new ReservationRequest(mentor.getId(), mentorSlotResponse.mentorSlot().mentorSlotId(), mentoringResponse.mentoring().mentoringId(), "Any pre-questions?"); + var reservationResponse = reservationService.createReservation(mentee, reservationRequest); + + // 예약 승인 + reservationService.approveReservation(mentor, reservationResponse.reservation().reservationId()); + }; + } +} \ No newline at end of file diff --git a/back/src/main/java/com/back/global/security/CustomAuthenticationFilter.java b/back/src/main/java/com/back/global/security/CustomAuthenticationFilter.java index 73b83ad8..08798b8b 100644 --- a/back/src/main/java/com/back/global/security/CustomAuthenticationFilter.java +++ b/back/src/main/java/com/back/global/security/CustomAuthenticationFilter.java @@ -6,6 +6,7 @@ import com.back.global.rq.Rq; import com.back.global.rsData.RsData; import com.back.standard.util.Ut; +import io.sentry.Sentry; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -14,6 +15,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -36,8 +38,9 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse try { work(request, response, filterChain); - } catch (Exception e) { + } catch (AuthenticationException e) { log.error("CustomAuthenticationFilter에서 예외 발생: ",e); //401 에러로 빠지는거 추적 가능 + Sentry.captureException(e); RsData rsData = new RsData<>("401-1", "인증 오류가 발생했습니다."); response.setContentType("application/json;charset=UTF-8"); response.setStatus(rsData.statusCode()); diff --git a/back/src/main/java/com/back/global/security/SecurityConfig.java b/back/src/main/java/com/back/global/security/SecurityConfig.java index eb17a89a..109a956b 100644 --- a/back/src/main/java/com/back/global/security/SecurityConfig.java +++ b/back/src/main/java/com/back/global/security/SecurityConfig.java @@ -30,7 +30,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/auth/**").permitAll() .requestMatchers("/actuator/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll() - .requestMatchers("/videos/upload-url").permitAll() + .requestMatchers("/videos/upload").permitAll() + .requestMatchers("/ws-chat/**").permitAll() .anyRequest().authenticated() ) .headers(headers -> headers @@ -51,7 +52,7 @@ public UrlBasedCorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(List.of("http://localhost:3000")); - configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowCredentials(true); diff --git a/back/src/main/java/com/back/global/websocket/HttpCookieHandshakeInterceptor.java b/back/src/main/java/com/back/global/websocket/HttpCookieHandshakeInterceptor.java new file mode 100644 index 00000000..40db8274 --- /dev/null +++ b/back/src/main/java/com/back/global/websocket/HttpCookieHandshakeInterceptor.java @@ -0,0 +1,38 @@ +package com.back.global.websocket; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.util.Map; + +@Component +public class HttpCookieHandshakeInterceptor implements HandshakeInterceptor { + @Override + public boolean beforeHandshake(ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Map attributes){ + + if (request instanceof ServletServerHttpRequest servletRequest) { + HttpServletRequest req = servletRequest.getServletRequest(); + if (req.getCookies() != null) { + for (jakarta.servlet.http.Cookie c : req.getCookies()) { + if ("accessToken".equals(c.getName())) { + attributes.put("accessToken", c.getValue()); + break; + } + } + } + } + return true; + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Exception exception) {} +} \ No newline at end of file diff --git a/back/src/main/java/com/back/global/websocket/WebSocketAuthChannelInterceptor.java b/back/src/main/java/com/back/global/websocket/WebSocketAuthChannelInterceptor.java new file mode 100644 index 00000000..7f060ccc --- /dev/null +++ b/back/src/main/java/com/back/global/websocket/WebSocketAuthChannelInterceptor.java @@ -0,0 +1,81 @@ +package com.back.global.websocket; + +import com.back.domain.member.member.entity.Member; +import com.back.domain.member.member.service.MemberService; +import com.back.global.security.SecurityUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WebSocketAuthChannelInterceptor implements ChannelInterceptor { + private final MemberService memberService; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + log.debug("STOMP CONNECT attempt."); + + String token = Optional.ofNullable(accessor.getSessionAttributes()) + .map(attrs -> (String) attrs.get("accessToken")) + .orElse(null); + + if (token == null || token.isBlank()) { + log.error("WebSocket connection rejected: No accessToken found in session attributes."); + throw new IllegalArgumentException("Missing access token for WebSocket connection."); + } + + try { + Map payload = memberService.payload(token); + String email = (String) payload.get("email"); + + Optional memberOpt = memberService.findByEmail(email); + + if (memberOpt.isEmpty()) { + log.error("WebSocket connection rejected: User not found for email {}", email); + throw new IllegalArgumentException("Invalid user."); + } + + Member member = memberOpt.get(); + + SecurityUser user = new SecurityUser( + member.getId(), + member.getEmail(), + "", // Password is not needed here + member.getName(), + member.getNickname(), + List.of(new SimpleGrantedAuthority("ROLE_" + member.getRole().name())) + ); + + Authentication authentication = new UsernamePasswordAuthenticationToken( + user, null, user.getAuthorities() + ); + + accessor.setUser(authentication); + log.info("STOMP user authenticated successfully: {}", user.getUsername()); + + } catch (Exception e) { + log.error("WebSocket authentication failed: {}", e.getMessage(), e); + throw new IllegalArgumentException("Authentication failed.", e); + } + } + + return message; + } +} diff --git a/back/src/main/java/com/back/global/websocket/WebSocketConfig.java b/back/src/main/java/com/back/global/websocket/WebSocketConfig.java new file mode 100644 index 00000000..5054a4fc --- /dev/null +++ b/back/src/main/java/com/back/global/websocket/WebSocketConfig.java @@ -0,0 +1,34 @@ +package com.back.global.websocket; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@RequiredArgsConstructor +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + private final HttpCookieHandshakeInterceptor httpCookieHandshakeInterceptor; + private final WebSocketAuthChannelInterceptor webSocketAuthChannelInterceptor; + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/topic"); + registry.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws-chat") + .addInterceptors(httpCookieHandshakeInterceptor) + .setAllowedOriginPatterns("*").withSockJS(); + } + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(webSocketAuthChannelInterceptor); + } +} diff --git a/back/src/main/java/com/back/global/websocket/WebSocketEventListener.java b/back/src/main/java/com/back/global/websocket/WebSocketEventListener.java new file mode 100644 index 00000000..55750188 --- /dev/null +++ b/back/src/main/java/com/back/global/websocket/WebSocketEventListener.java @@ -0,0 +1,35 @@ +package com.back.global.websocket; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionConnectedEvent; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +import java.security.Principal; + +@Slf4j +@Component +public class WebSocketEventListener { + + @EventListener + public void handleWebSocketConnectListener(SessionConnectedEvent event) { + StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); + String sessionId = headerAccessor.getSessionId(); + Principal user = headerAccessor.getUser(); + String username = user != null ? user.getName() : "anonymous"; + + log.info("[Connected] Session ID: {}, User: {}", sessionId, username); + } + + @EventListener + public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { + StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); + String sessionId = headerAccessor.getSessionId(); + Principal user = headerAccessor.getUser(); + String username = user != null ? user.getName() : "anonymous"; + + log.info("[Disconnected] Session ID: {}, User: {}", sessionId, username); + } +} diff --git a/back/src/main/resources/application-dev.yml b/back/src/main/resources/application-dev.yml index b05bb84c..ba771fba 100644 --- a/back/src/main/resources/application-dev.yml +++ b/back/src/main/resources/application-dev.yml @@ -4,5 +4,6 @@ spring: username: sa password: driver-class-name: org.h2.Driver - - + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration diff --git a/back/src/main/resources/application-test.yml b/back/src/main/resources/application-test.yml index 4603eafa..ea28e203 100644 --- a/back/src/main/resources/application-test.yml +++ b/back/src/main/resources/application-test.yml @@ -3,4 +3,7 @@ spring: url: jdbc:h2:mem:db_test;MODE=MySQL username: sa password: - driver-class-name: org.h2.Driver \ No newline at end of file + driver-class-name: org.h2.Driver + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration diff --git a/back/src/main/resources/application.yml b/back/src/main/resources/application.yml index 9109e957..d684b274 100644 --- a/back/src/main/resources/application.yml +++ b/back/src/main/resources/application.yml @@ -2,6 +2,12 @@ server: port: 8080 forward-headers-strategy: native +sentry: + dsn: https://2c0d2da223ce1abb9fa4ed3faaf01955@o4510117752471552.ingest.us.sentry.io/4510118514589696 + traces-sample-rate: 1.0 + send-default-pii: true + + spring: application: name: back @@ -36,6 +42,21 @@ spring: use_sql_comments: true default_batch_fetch_size: 100 open-in-view: false + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 springdoc: #Swagger ?? default-produces-media-type: application/json;charset=UTF-8 diff --git a/back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java b/back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java index 73374ef3..47118687 100644 --- a/back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java +++ b/back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java @@ -8,7 +8,6 @@ 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.repository.MentorSlotRepository; import com.back.fixture.MemberTestFixture; import com.back.fixture.mentoring.MentoringTestFixture; import com.back.global.exception.ServiceException; @@ -26,7 +25,6 @@ import org.springframework.test.web.servlet.ResultActions; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -45,7 +43,6 @@ class MentoringControllerTest { @Autowired private MentoringTestFixture mentoringFixture; @Autowired private MentoringRepository mentoringRepository; - @Autowired private MentorSlotRepository mentorSlotRepository; @Autowired private ReservationRepository reservationRepository; @Autowired private AuthTokenService authTokenService; @@ -304,41 +301,6 @@ void deleteMentoringSuccess() throws Exception { assertThat(preCnt - afterCnt).isEqualTo(1); assertThat(mentoringRepository.findById(mentoring.getId())).isEmpty(); assertThat(reservationRepository.existsByMentoringId(mentoring.getId())).isFalse(); - assertThat(mentorSlotRepository.existsByMentorId(mentor.getId())).isFalse(); - } - - @Test - @DisplayName("멘토링 삭제 성공 - 멘토 슬롯이 있는 경우") - void deleteMentoringSuccessExistsMentorSlot() throws Exception { - Mentoring mentoring = mentoringFixture.createMentoring(mentor); - - LocalDateTime baseDateTime = LocalDateTime.now().plusMonths(3); - mentoringFixture.createMentorSlots(mentor, baseDateTime, 3, 2, 30L); - - long preMentoringCnt = mentoringRepository.count(); - long preSlotCnt = mentorSlotRepository.count(); - - ResultActions resultActions = mvc.perform( - delete(MENTORING_URL + "/" + mentoring.getId()) - .cookie(new Cookie(TOKEN, mentorToken)) - ) - .andDo(print()); - - long afterMentoringCnt = mentoringRepository.count(); - long afterSlotCnt = mentorSlotRepository.count(); - - resultActions - .andExpect(handler().handlerType(MentoringController.class)) - .andExpect(handler().methodName("deleteMentoring")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.resultCode").value("200")) - .andExpect(jsonPath("$.msg").value("멘토링이 삭제되었습니다.")); - - assertThat(preMentoringCnt - afterMentoringCnt).isEqualTo(1); - assertThat(preSlotCnt - afterSlotCnt).isEqualTo(6); - assertThat(mentoringRepository.findById(mentoring.getId())).isEmpty(); - assertThat(reservationRepository.existsByMentoringId(mentoring.getId())).isFalse(); - assertThat(mentorSlotRepository.existsByMentorId(mentor.getId())).isFalse(); } @Test diff --git a/back/src/test/java/com/back/domain/mentoring/mentoring/service/MentoringServiceTest.java b/back/src/test/java/com/back/domain/mentoring/mentoring/service/MentoringServiceTest.java index 822f936f..fbde5117 100644 --- a/back/src/test/java/com/back/domain/mentoring/mentoring/service/MentoringServiceTest.java +++ b/back/src/test/java/com/back/domain/mentoring/mentoring/service/MentoringServiceTest.java @@ -176,7 +176,7 @@ void createMentoring() { when(tagRepository.findByNameIn(request.tags())) .thenReturn(tags); - when(mentoringRepository.existsByMentorId(mentor1.getId())) + when(mentoringRepository.existsByMentorIdAndTitle(mentor1.getId(), request.title())) .thenReturn(false); // when @@ -188,23 +188,23 @@ void createMentoring() { assertThat(result.mentoring().bio()).isEqualTo(request.bio()); assertThat(result.mentoring().tags()).isEqualTo(request.tags()); assertThat(result.mentoring().thumb()).isEqualTo(request.thumb()); - verify(mentoringRepository).existsByMentorId(mentor1.getId()); + verify(mentoringRepository).existsByMentorIdAndTitle(mentor1.getId(), request.title()); verify(tagRepository).findByNameIn(request.tags()); verify(mentoringRepository).save(any(Mentoring.class)); } @Test - @DisplayName("이미 존재하면 예외 (멘토당 멘토링 1개 제한)") + @DisplayName("해당 멘토에게 동일한 이름의 멘토링이 존재하면 예외") void throwExceptionWhenAlreadyExists() { // given - when(mentoringRepository.existsByMentorId(mentor1.getId())) + when(mentoringRepository.existsByMentorIdAndTitle(mentor1.getId(), request.title())) .thenReturn(true); // when & then assertThatThrownBy(() -> mentoringService.createMentoring(request, mentor1)) .isInstanceOf(ServiceException.class) .hasFieldOrPropertyWithValue("resultCode", MentoringErrorCode.ALREADY_EXISTS_MENTORING.getCode()); - verify(mentoringRepository).existsByMentorId(mentor1.getId()); + verify(mentoringRepository).existsByMentorIdAndTitle(mentor1.getId(), request.title()); verify(mentoringRepository, never()).save(any(Mentoring.class)); } } @@ -269,8 +269,6 @@ void deleteMentoring() { .thenReturn(mentoring1); when(mentoringStorage.hasReservationsForMentoring(mentoringId)) .thenReturn(false); - when(mentoringStorage.hasMentorSlotsForMentor(mentor1.getId())) - .thenReturn(false); // when mentoringService.deleteMentoring(mentoringId, mentor1); @@ -278,28 +276,6 @@ void deleteMentoring() { // then verify(mentoringStorage).findMentoring(mentoringId); verify(mentoringStorage).hasReservationsForMentoring(mentoringId); - verify(mentoringStorage).hasMentorSlotsForMentor(mentor1.getId()); - verify(mentoringRepository).delete(mentoring1); - } - - @Test - @DisplayName("멘토 슬롯 있으면 함께 삭제") - void deleteWithMentorSlots() { - // given - Long mentoringId = 1L; - - when(mentoringStorage.findMentoring(mentoringId)) - .thenReturn(mentoring1); - when(mentoringStorage.hasReservationsForMentoring(mentoringId)) - .thenReturn(false); - when(mentoringStorage.hasMentorSlotsForMentor(mentor1.getId())) - .thenReturn(true); - - // when - mentoringService.deleteMentoring(mentoringId, mentor1); - - // then - verify(mentoringStorage).deleteMentorSlotsData(mentor1.getId()); verify(mentoringRepository).delete(mentoring1); } diff --git a/back/src/test/java/com/back/domain/mentoring/mentoring/service/ReviewServiceTest.java b/back/src/test/java/com/back/domain/mentoring/mentoring/service/ReviewServiceTest.java index e6764c5f..32516a68 100644 --- a/back/src/test/java/com/back/domain/mentoring/mentoring/service/ReviewServiceTest.java +++ b/back/src/test/java/com/back/domain/mentoring/mentoring/service/ReviewServiceTest.java @@ -132,7 +132,7 @@ void createReview() { .thenReturn(false); when(reviewRepository.save(any(Review.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - when(reviewRepository.findAverageRating(mentor)) + when(reviewRepository.calculateMentorAverageRating(mentor.getId())) .thenReturn(3.5); // when @@ -144,7 +144,7 @@ void createReview() { assertThat(response.menteeId()).isEqualTo(mentee.getId()); verify(reviewRepository).save(any(Review.class)); - verify(reviewRepository).findAverageRating(mentor); + verify(reviewRepository).calculateMentorAverageRating(mentor.getId()); assertThat(mentor.getRate()).isEqualTo(3.5); } @@ -213,7 +213,7 @@ void updateReview() { when(reviewRepository.findById(review.getId())) .thenReturn(Optional.of(review)); - when(reviewRepository.findAverageRating(mentor)) + when(reviewRepository.calculateMentorAverageRating(mentor.getId())) .thenReturn(4.0); // when @@ -226,7 +226,7 @@ void updateReview() { assertThat(mentor.getRate()).isEqualTo(4.0); verify(reviewRepository).findById(review.getId()); - verify(reviewRepository).findAverageRating(mentor); + verify(reviewRepository).calculateMentorAverageRating(mentor.getId()); } @Test @@ -278,20 +278,18 @@ void deleteReview() { when(reviewRepository.findById(review.getId())) .thenReturn(Optional.of(review)); // 리뷰 삭제 후 평균 평점이 없을 경우 - when(reviewRepository.findAverageRating(mentor)) + when(reviewRepository.calculateMentorAverageRating(mentor.getId())) .thenReturn(null); // when - ReviewResponse response = reviewService.deleteReview(review.getId(), mentee); + reviewService.deleteReview(review.getId(), mentee); // then - assertThat(response.reviewId()).isEqualTo(review.getId()); // 평균 평점이 0.0으로 업데이트 되는지 확인 assertThat(mentor.getRate()).isEqualTo(0.0); - verify(reviewRepository).findById(review.getId()); verify(reviewRepository).delete(review); - verify(reviewRepository).findAverageRating(mentor); + verify(reviewRepository).calculateMentorAverageRating(mentor.getId()); } @Test diff --git a/back/src/test/java/com/back/domain/mentoring/reservation/controller/ReservationControllerTest.java b/back/src/test/java/com/back/domain/mentoring/reservation/controller/ReservationControllerTest.java index a946c1b1..69a49d67 100644 --- a/back/src/test/java/com/back/domain/mentoring/reservation/controller/ReservationControllerTest.java +++ b/back/src/test/java/com/back/domain/mentoring/reservation/controller/ReservationControllerTest.java @@ -2,7 +2,6 @@ import com.back.domain.member.member.entity.Member; import com.back.domain.member.member.service.AuthTokenService; -import com.back.domain.member.mentee.entity.Mentee; import com.back.domain.member.mentor.entity.Mentor; import com.back.domain.mentoring.mentoring.entity.Mentoring; import com.back.domain.mentoring.reservation.entity.Reservation; @@ -49,7 +48,6 @@ class ReservationControllerTest { private static final String RESERVATION_URL = "/reservations"; private Mentor mentor; - private Mentee mentee; private Mentoring mentoring; private MentorSlot mentorSlot; private String menteeToken; @@ -62,7 +60,7 @@ void setUp() { // Mentee Member menteeMember = memberFixture.createMenteeMember(); - mentee = memberFixture.createMentee(menteeMember); + memberFixture.createMentee(menteeMember); menteeToken = authTokenService.genAccessToken(menteeMember); // Mentoring, MentorSlot diff --git a/back/src/test/java/com/back/domain/mentoring/reservation/entity/ReservationTest.java b/back/src/test/java/com/back/domain/mentoring/reservation/entity/ReservationTest.java index 02e2dd79..b381f05b 100644 --- a/back/src/test/java/com/back/domain/mentoring/reservation/entity/ReservationTest.java +++ b/back/src/test/java/com/back/domain/mentoring/reservation/entity/ReservationTest.java @@ -6,7 +6,6 @@ import com.back.domain.mentoring.mentoring.entity.Mentoring; import com.back.domain.mentoring.reservation.constant.ReservationStatus; import com.back.domain.mentoring.reservation.error.ReservationErrorCode; -import com.back.domain.mentoring.slot.constant.MentorSlotStatus; import com.back.domain.mentoring.slot.entity.MentorSlot; import com.back.fixture.MemberFixture; import com.back.fixture.MenteeFixture; @@ -63,7 +62,6 @@ void approve() { // then assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.APPROVED); - assertThat(reservation.getMentorSlot().getStatus()).isEqualTo(MentorSlotStatus.APPROVED); } @Test @@ -120,8 +118,6 @@ void reject() { // then assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.REJECTED); - assertThat(reservation.getMentorSlot().getStatus()).isEqualTo(MentorSlotStatus.AVAILABLE); - assertThat(reservation.getMentorSlot().getReservation()).isNull(); } @Test @@ -149,8 +145,6 @@ void cancelPending() { // then assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.CANCELED); - assertThat(reservation.getMentorSlot().getStatus()).isEqualTo(MentorSlotStatus.AVAILABLE); - assertThat(reservation.getMentorSlot().getReservation()).isNull(); } @Test @@ -164,8 +158,6 @@ void cancelApproved() { // then assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.CANCELED); - assertThat(reservation.getMentorSlot().getStatus()).isEqualTo(MentorSlotStatus.AVAILABLE); - assertThat(reservation.getMentorSlot().getReservation()).isNull(); } @Test @@ -194,8 +186,6 @@ void cancelPending() { // then assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.CANCELED); - assertThat(reservation.getMentorSlot().getStatus()).isEqualTo(MentorSlotStatus.AVAILABLE); - assertThat(reservation.getMentorSlot().getReservation()).isNull(); } @Test @@ -209,8 +199,6 @@ void cancelApproved() { // then assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.CANCELED); - assertThat(reservation.getMentorSlot().getStatus()).isEqualTo(MentorSlotStatus.AVAILABLE); - assertThat(reservation.getMentorSlot().getReservation()).isNull(); } @Test diff --git a/back/src/test/java/com/back/domain/mentoring/reservation/service/ReservationServiceTest.java b/back/src/test/java/com/back/domain/mentoring/reservation/service/ReservationServiceTest.java index fff1a4f3..353154c2 100644 --- a/back/src/test/java/com/back/domain/mentoring/reservation/service/ReservationServiceTest.java +++ b/back/src/test/java/com/back/domain/mentoring/reservation/service/ReservationServiceTest.java @@ -12,6 +12,9 @@ import com.back.domain.mentoring.reservation.entity.Reservation; import com.back.domain.mentoring.reservation.error.ReservationErrorCode; import com.back.domain.mentoring.reservation.repository.ReservationRepository; +import com.back.domain.mentoring.session.entity.MentoringSession; +import com.back.domain.mentoring.session.service.MentoringSessionService; +import com.back.fixture.mentoring.MentoringSessionFixture; import com.back.domain.mentoring.slot.entity.MentorSlot; import com.back.domain.mentoring.slot.error.MentorSlotErrorCode; import com.back.fixture.MemberFixture; @@ -58,6 +61,9 @@ class ReservationServiceTest { @Mock private MentoringStorage mentoringStorage; + @Mock + private MentoringSessionService mentoringSessionService; + private Mentor mentor; private Mentee mentee, mentee2; private Mentoring mentoring; @@ -98,7 +104,7 @@ void getReservations() { 10 ); - when(reservationRepository.findAllByMentorMember(mentor.getMember(), pageable)) + when(reservationRepository.findAllByMentorMember(mentor.getMember().getId(), pageable)) .thenReturn(reservationPage); // when @@ -113,7 +119,7 @@ void getReservations() { assertThat(result.getSize()).isEqualTo(5); assertThat(result.getTotalElements()).isEqualTo(10); assertThat(result.getTotalPages()).isEqualTo(2); - verify(reservationRepository).findAllByMentorMember(mentor.getMember(), pageable); + verify(reservationRepository).findAllByMentorMember(mentor.getMember().getId(), pageable); } } @@ -126,8 +132,10 @@ void getReservation() { // given Long reservationId = reservation.getId(); - when(reservationRepository.findByIdAndMember(reservationId, mentor.getMember())) + when(reservationRepository.findByIdAndMember(reservationId, mentor.getMember().getId())) .thenReturn(Optional.of(reservation)); + MentoringSession session = MentoringSessionFixture.create(reservation); + when(mentoringSessionService.getMentoringSessionByReservation(reservation)).thenReturn(session); // when ReservationResponse response = reservationService.getReservation( @@ -141,14 +149,14 @@ void getReservation() { assertThat(response.mentee().menteeId()).isEqualTo(mentee.getId()); assertThat(response.mentor().mentorId()).isEqualTo(mentor.getId()); assertThat(response.reservation().mentorSlotId()).isEqualTo(mentorSlot2.getId()); - verify(reservationRepository).findByIdAndMember(reservationId, mentor.getMember()); + verify(reservationRepository).findByIdAndMember(reservationId, mentor.getMember().getId()); } @Test @DisplayName("권한이 없을 경우 예외") void getReservation_notAccessible() { // given - when(reservationRepository.findByIdAndMember(reservation.getId(), mentee2.getMember())) + when(reservationRepository.findByIdAndMember(reservation.getId(), mentee2.getMember().getId())) .thenReturn(Optional.empty()); // when & then @@ -267,9 +275,6 @@ void throwExceptionWhenStartTimeInPast() { .thenReturn(mentoring); when(mentoringStorage.findMentorSlot(pastRequest.mentorSlotId())) .thenReturn(pastSlot); - when(reservationRepository.findByMentorSlotIdAndStatusIn(pastSlot.getId(), - List.of(ReservationStatus.PENDING, ReservationStatus.APPROVED, ReservationStatus.COMPLETED))) - .thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> reservationService.createReservation(mentee, pastRequest)) diff --git a/back/src/test/java/com/back/domain/mentoring/session/entity/MentoringSessionTest.java b/back/src/test/java/com/back/domain/mentoring/session/entity/MentoringSessionTest.java new file mode 100644 index 00000000..2dac4543 --- /dev/null +++ b/back/src/test/java/com/back/domain/mentoring/session/entity/MentoringSessionTest.java @@ -0,0 +1,69 @@ +package com.back.domain.mentoring.session.entity; + + +import com.back.domain.member.mentor.entity.Mentor; +import com.back.domain.mentoring.reservation.entity.Reservation; +import com.back.fixture.MentorFixture; +import com.back.fixture.mentoring.ReservationFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class MentoringSessionTest { + @Test + @DisplayName("APPROVED된 Reservation에 대해서 MentoringSession 생성") + void mentoringSessionCreationTest() { + Mentor mentor = MentorFixture.create(); + Reservation reservation = ReservationFixture.createWithMentor(mentor); + reservation.approve(mentor); + + MentoringSession mentoringSession = MentoringSession.create(reservation); + + assertThat(mentoringSession).isNotNull(); + assertThat(mentoringSession.getSessionUrl()).isNotNull(); + assertThat(mentoringSession.getReservation()).isEqualTo(reservation); + assertThat(mentoringSession.getMentoring()).isEqualTo(reservation.getMentoring()); + assertThat(mentoringSession.getStatus()).isEqualTo(MentoringSessionStatus.CLOSED); + } + + @Test + @DisplayName("APPROVED되지않은 Reservation에 대해 MentoringSession을 생성하려하면 에러를 반환한,") + void mentoringSessionCreationWithInvalidReservationTest() { + Mentor mentor = MentorFixture.create(); + Reservation reservation = ReservationFixture.createWithMentor(mentor); + + try { + MentoringSession.create(reservation); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage()).isEqualTo("Reservation must be APPROVED to create a MentoringSession."); + } + } + + @Test + @DisplayName("MentoringSession의 상태를 OPEN으로 변경할 수 있다.") + void mentoringSessionOpenTest() { + Mentor mentor = MentorFixture.create(); + Reservation reservation = ReservationFixture.createWithMentor(mentor); + + reservation.approve(mentor); + MentoringSession mentoringSession = MentoringSession.create(reservation); + + mentoringSession.openSession(mentor); + assertThat(mentoringSession.getStatus()).isEqualTo(MentoringSessionStatus.OPEN); + } + + @Test + @DisplayName("MentoringSession의 상태를 CLOSED로 변경할 수 있다.") + void mentoringSessionClosedTest() { + Mentor mentor = MentorFixture.create(); + Reservation reservation = ReservationFixture.createWithMentor(mentor); + + reservation.approve(mentor); + MentoringSession mentoringSession = MentoringSession.create(reservation); + + mentoringSession.closeSession(mentor); + assertThat(mentoringSession.getStatus()).isEqualTo(MentoringSessionStatus.CLOSED); + } + +} diff --git a/back/src/test/java/com/back/domain/mentoring/session/service/MentoringSessionServiceTest.java b/back/src/test/java/com/back/domain/mentoring/session/service/MentoringSessionServiceTest.java new file mode 100644 index 00000000..2eecf947 --- /dev/null +++ b/back/src/test/java/com/back/domain/mentoring/session/service/MentoringSessionServiceTest.java @@ -0,0 +1,87 @@ +package com.back.domain.mentoring.session.service; + +import com.back.domain.mentoring.reservation.entity.Reservation; +import com.back.domain.mentoring.session.entity.MentoringSession; +import com.back.domain.mentoring.session.entity.MentoringSessionStatus; +import com.back.domain.mentoring.session.repository.MentoringSessionRepository; +import com.back.fixture.mentoring.MentoringSessionFixture; +import com.back.fixture.mentoring.ReservationFixture; +import com.back.global.exception.ServiceException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class MentoringSessionServiceTest { + @Mock + private MentoringSessionRepository mentoringSessionRepository; + @InjectMocks + private MentoringSessionService mentoringSessionService; + + @Test + @DisplayName("승인된 예약으로 멘토링 세션을 생성할 수 있다.") + void createMentoringSession() { + Reservation reservation = ReservationFixture.createDefault(); + reservation.approve(reservation.getMentor()); + + MentoringSession session = MentoringSession.create(reservation); + when(mentoringSessionRepository.save(any(MentoringSession.class))).thenReturn(session); + + MentoringSession actualSession = mentoringSessionService.create(reservation); + + // then + assertThat(actualSession).isNotNull(); + assertThat(actualSession.getReservation()).isEqualTo(reservation); + assertThat(actualSession.getStatus()).isEqualTo(MentoringSessionStatus.CLOSED); + } + + @Test + @DisplayName("예약이 승인되지 않은 멘토링은 세션을 생성할 수 없다.") + void cannotCreateMentoringSessionIfNotApproved() { + Reservation reservation = ReservationFixture.createDefault(); + + try { + mentoringSessionService.create(reservation); + } catch (Exception e) { + assertThat(e).isInstanceOf(IllegalArgumentException.class); + assertThat(e.getMessage()).isEqualTo("Reservation must be APPROVED to create a MentoringSession."); + } + } + + @Test + @DisplayName("멘토링 세션이 조회된다.") + void getMentoringSession() { + MentoringSession session = MentoringSessionFixture.createDefault(); + + when(mentoringSessionRepository.findById(any())) + .thenReturn(java.util.Optional.of(session)); + + MentoringSession actualSession = mentoringSessionService.getMentoringSession(session.getId()); + + assertThat(actualSession).isNotNull(); + assertThat(actualSession).isEqualTo(session); + } + + @Test + @DisplayName("존재하지 않는 멘토링 세션은 조회하면 예외가 발생한다.") + void cannotGetNonExistentMentoringSession() { + Long nonExistentId = 999L; + + when(mentoringSessionRepository.findById(any())) + .thenReturn(java.util.Optional.empty()); + + try { + mentoringSessionService.getMentoringSession(nonExistentId); + } catch (Exception e) { + assertThat(e).isInstanceOf(ServiceException.class); + assertThat(e.getMessage()).isEqualTo("404 : 잘못된 id"); + } + } +} diff --git a/back/src/test/java/com/back/domain/mentoring/slot/controller/MentorSlotControllerTest.java b/back/src/test/java/com/back/domain/mentoring/slot/controller/MentorSlotControllerTest.java index 6d038da8..d3baa83a 100644 --- a/back/src/test/java/com/back/domain/mentoring/slot/controller/MentorSlotControllerTest.java +++ b/back/src/test/java/com/back/domain/mentoring/slot/controller/MentorSlotControllerTest.java @@ -4,6 +4,7 @@ import com.back.domain.member.member.service.AuthTokenService; import com.back.domain.member.mentor.entity.Mentor; import com.back.domain.mentoring.mentoring.entity.Mentoring; +import com.back.domain.mentoring.slot.dto.response.MentorSlotDto; import com.back.domain.mentoring.slot.entity.MentorSlot; import com.back.domain.mentoring.slot.error.MentorSlotErrorCode; import com.back.domain.mentoring.slot.repository.MentorSlotRepository; @@ -157,8 +158,8 @@ void getMentorSlotSuccess() throws Exception { .andExpect(jsonPath("$.msg").value("멘토의 예약 가능 일정을 조회하였습니다.")) .andExpect(jsonPath("$.data.mentorSlot.mentorSlotId").value(mentorSlot.getId())) .andExpect(jsonPath("$.data.mentor.mentorId").value(mentorSlot.getMentor().getId())) - .andExpect(jsonPath("$.data.mentoring.mentoringId").value(mentoring.getId())) - .andExpect(jsonPath("$.data.mentoring.title").value(mentoring.getTitle())) + .andExpect(jsonPath("$.data.mentorings[0].mentoringId").value(mentoring.getId())) + .andExpect(jsonPath("$.data.mentorings[0].title").value(mentoring.getTitle())) .andExpect(jsonPath("$.data.mentorSlot.startDateTime").value(mentorSlot.getStartDateTime().format(formatter))) .andExpect(jsonPath("$.data.mentorSlot.endDateTime").value(mentorSlot.getEndDateTime().format(formatter))) .andExpect(jsonPath("$.data.mentorSlot.mentorSlotStatus").value(mentorSlot.getStatus().name())); @@ -188,8 +189,8 @@ void createMentorSlotSuccess() throws Exception { resultActions .andExpect(jsonPath("$.data.mentorSlot.mentorSlotId").value(mentorSlot.getId())) .andExpect(jsonPath("$.data.mentor.mentorId").value(mentorSlot.getMentor().getId())) - .andExpect(jsonPath("$.data.mentoring.mentoringId").value(mentoring.getId())) - .andExpect(jsonPath("$.data.mentoring.title").value(mentoring.getTitle())) + .andExpect(jsonPath("$.data.mentorings[0].mentoringId").value(mentoring.getId())) + .andExpect(jsonPath("$.data.mentorings[0].title").value(mentoring.getTitle())) .andExpect(jsonPath("$.data.mentorSlot.startDateTime").value(startDateTime)) .andExpect(jsonPath("$.data.mentorSlot.endDateTime").value(endDateTime)) .andExpect(jsonPath("$.data.mentorSlot.mentorSlotStatus").value("AVAILABLE")); @@ -274,7 +275,7 @@ void createMentorSlotRepetitionSuccess() throws Exception { Set.of(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY)); assertThat(afterCount - beforeCount).isEqualTo(expectedCount); - List createdSlots = mentorSlotRepository.findMySlots( + List createdSlots = mentorSlotRepository.findMySlots( mentor.getId(), startDate.atStartOfDay(), endDate.plusDays(1).atStartOfDay() @@ -283,7 +284,7 @@ void createMentorSlotRepetitionSuccess() throws Exception { // 모든 슬롯이 월/수/금인지 검증 Set actualDaysOfWeek = createdSlots.stream() - .map(slot -> slot.getStartDateTime().getDayOfWeek()) + .map(slot -> slot.startDateTime().getDayOfWeek()) .collect(Collectors.toSet()); assertThat(actualDaysOfWeek).containsExactlyInAnyOrder( @@ -291,9 +292,9 @@ void createMentorSlotRepetitionSuccess() throws Exception { ); // 시간 검증 - MentorSlot firstSlot = createdSlots.getFirst(); - assertThat(firstSlot.getStartDateTime().getHour()).isEqualTo(10); - assertThat(firstSlot.getEndDateTime().getHour()).isEqualTo(11); + MentorSlotDto firstSlot = createdSlots.getFirst(); + assertThat(firstSlot.startDateTime().getHour()).isEqualTo(10); + assertThat(firstSlot.endDateTime().getHour()).isEqualTo(11); } @@ -315,8 +316,8 @@ void updateMentorSlotSuccess() throws Exception { .andExpect(jsonPath("$.msg").value("멘토의 예약 가능 일정이 수정되었습니다.")) .andExpect(jsonPath("$.data.mentorSlot.mentorSlotId").value(mentorSlot.getId())) .andExpect(jsonPath("$.data.mentor.mentorId").value(mentorSlot.getMentor().getId())) - .andExpect(jsonPath("$.data.mentoring.mentoringId").value(mentoring.getId())) - .andExpect(jsonPath("$.data.mentoring.title").value(mentoring.getTitle())) + .andExpect(jsonPath("$.data.mentorings[0].mentoringId").value(mentoring.getId())) + .andExpect(jsonPath("$.data.mentorings[0].title").value(mentoring.getTitle())) .andExpect(jsonPath("$.data.mentorSlot.endDateTime").value(expectedEndDate)) .andExpect(jsonPath("$.data.mentorSlot.mentorSlotStatus").value("AVAILABLE")); } diff --git a/back/src/test/java/com/back/domain/mentoring/slot/service/MentorSlotServiceTest.java b/back/src/test/java/com/back/domain/mentoring/slot/service/MentorSlotServiceTest.java index b55b462b..7995d3cc 100644 --- a/back/src/test/java/com/back/domain/mentoring/slot/service/MentorSlotServiceTest.java +++ b/back/src/test/java/com/back/domain/mentoring/slot/service/MentorSlotServiceTest.java @@ -89,14 +89,29 @@ void getMyMentorSlots() { LocalDateTime startDate = base.atStartOfDay(); LocalDateTime endDate = base.withDayOfMonth(base.lengthOfMonth()).atTime(23, 59); - MentorSlot slot2 = MentorSlotFixture.create(2L, mentor1, + MentorSlotDto slotDto1 = new MentorSlotDto( + 1L, mentor1.getId(), + mentorSlot1.getStartDateTime(), + mentorSlot1.getEndDateTime(), + MentorSlotStatus.AVAILABLE, + null + ); + MentorSlotDto slotDto2 = new MentorSlotDto( + 2L, mentor1.getId(), base.withDayOfMonth(2).atTime(10, 0), - base.withDayOfMonth(2).atTime(11, 0)); - MentorSlot slot3 = MentorSlotFixture.create(3L, mentor1, - base.withDayOfMonth(15).atTime(14, 0), - base.withDayOfMonth(15).atTime(15, 0)); + base.withDayOfMonth(2).atTime(11, 0), + MentorSlotStatus.AVAILABLE, + null + ); + MentorSlotDto slotDto3 = new MentorSlotDto( + 3L, mentor1.getId(), + base.withDayOfMonth(15).atTime(14, 0), + base.withDayOfMonth(15).atTime(15, 0), + MentorSlotStatus.AVAILABLE, + null + ); - List slots = List.of(mentorSlot1, slot2, slot3); + List slots = List.of(slotDto1, slotDto2, slotDto3); when(mentorSlotRepository.findMySlots(mentor1.getId(), startDate, endDate)) .thenReturn(slots); @@ -141,11 +156,22 @@ void getAvailableMentorSlots() { LocalDateTime startDate = base.atStartOfDay(); LocalDateTime endDate = base.withDayOfMonth(base.lengthOfMonth()).atTime(23, 59); - MentorSlot slot2 = MentorSlotFixture.create(2L, mentor1, + MentorSlotDto slotDto1 = new MentorSlotDto( + 1L, mentor1.getId(), + mentorSlot1.getStartDateTime(), + mentorSlot1.getEndDateTime(), + MentorSlotStatus.AVAILABLE, + null + ); + MentorSlotDto slotDto2 = new MentorSlotDto( + 2L, mentor1.getId(), base.withDayOfMonth(2).atTime(10, 0), - base.withDayOfMonth(2).atTime(11, 0)); + base.withDayOfMonth(2).atTime(11, 0), + MentorSlotStatus.AVAILABLE, + null + ); - List availableSlots = List.of(mentorSlot1, slot2); + List availableSlots = List.of(slotDto1, slotDto2); when(mentorSlotRepository.findAvailableSlots(mentor1.getId(), startDate, endDate)) .thenReturn(availableSlots); @@ -171,8 +197,8 @@ void getMentorSlot() { when(mentoringStorage.findMentorSlot(slotId)) .thenReturn(mentorSlot1); - when(mentoringStorage.findMentoringByMentor(mentor1)) - .thenReturn(mentoring1); + when(mentoringStorage.findMentoringsByMentorId(mentor1.getId())) + .thenReturn(List.of(mentoring1)); // when MentorSlotResponse result = mentorSlotService.getMentorSlot(slotId); @@ -182,7 +208,7 @@ void getMentorSlot() { assertThat(result.mentorSlot().mentorSlotId()).isEqualTo(slotId); assertThat(result.mentor().mentorId()).isEqualTo(mentor1.getId()); verify(mentoringStorage).findMentorSlot(slotId); - verify(mentoringStorage).findMentoringByMentor(mentor1); + verify(mentoringStorage).findMentoringsByMentorId(mentor1.getId()); } } @@ -210,8 +236,8 @@ void setUp() { @DisplayName("생성 성공") void createMentorSlot() { // given - when(mentoringStorage.findMentoringByMentor(mentor1)) - .thenReturn(mentoring1); + when(mentoringStorage.findMentoringsByMentorId(mentor1.getId())) + .thenReturn(List.of(mentoring1)); when(mentorSlotRepository.existsOverlappingSlot( mentor1.getId(), request.startDateTime(), request.endDateTime())) .thenReturn(false); @@ -222,13 +248,13 @@ void createMentorSlot() { // then assertThat(result).isNotNull(); assertThat(result.mentor().mentorId()).isEqualTo(mentor1.getId()); - assertThat(result.mentoring().mentoringId()).isEqualTo(mentoring1.getId()); - assertThat(result.mentoring().title()).isEqualTo(mentoring1.getTitle()); + assertThat(result.mentorings().getFirst().mentoringId()).isEqualTo(mentoring1.getId()); + assertThat(result.mentorings().getFirst().title()).isEqualTo(mentoring1.getTitle()); assertThat(result.mentorSlot().startDateTime()).isEqualTo(request.startDateTime()); assertThat(result.mentorSlot().endDateTime()).isEqualTo(request.endDateTime()); assertThat(result.mentorSlot().mentorSlotStatus()).isEqualTo(MentorSlotStatus.AVAILABLE); - verify(mentoringStorage).findMentoringByMentor(mentor1); + verify(mentoringStorage).findMentoringsByMentorId(mentor1.getId()); verify(mentorSlotRepository).existsOverlappingSlot(mentor1.getId(), request.startDateTime(), request.endDateTime()); verify(mentorSlotRepository).save(any(MentorSlot.class)); } @@ -237,8 +263,8 @@ void createMentorSlot() { @DisplayName("기존 슬롯과 시간 겹치면 예외") void throwExceptionWhenOverlapping() { // given - when(mentoringStorage.findMentoringByMentor(mentor1)) - .thenReturn(mentoring1); + when(mentoringStorage.findMentoringsByMentorId(mentor1.getId())) + .thenReturn(List.of(mentoring1)); when(mentorSlotRepository.existsOverlappingSlot( mentor1.getId(), request.startDateTime(), request.endDateTime())) .thenReturn(true); @@ -354,12 +380,12 @@ void updateMentorSlot() { // given Long slotId = 1L; - when(mentoringStorage.findMentoringByMentor(mentor1)) - .thenReturn(mentoring1); when(mentoringStorage.findMentorSlot(slotId)) .thenReturn(mentorSlot1); when(mentorSlotRepository.existsOverlappingExcept(mentor1.getId(), slotId, request.startDateTime(), request.endDateTime())) .thenReturn(false); + when(mentoringStorage.findMentoringsByMentorId(mentor1.getId())) + .thenReturn(List.of(mentoring1)); // when MentorSlotResponse result = mentorSlotService.updateMentorSlot(slotId, request, mentor1); @@ -368,8 +394,8 @@ void updateMentorSlot() { assertThat(result).isNotNull(); assertThat(result.mentorSlot().mentorSlotId()).isEqualTo(slotId); assertThat(result.mentor().mentorId()).isEqualTo(mentor1.getId()); - assertThat(result.mentoring().mentoringId()).isEqualTo(mentoring1.getId()); - assertThat(result.mentoring().title()).isEqualTo(mentoring1.getTitle()); + assertThat(result.mentorings().getFirst().mentoringId()).isEqualTo(mentoring1.getId()); + assertThat(result.mentorings().getFirst().title()).isEqualTo(mentoring1.getTitle()); assertThat(result.mentorSlot().startDateTime()).isEqualTo(request.startDateTime()); assertThat(result.mentorSlot().endDateTime()).isEqualTo(request.endDateTime()); assertThat(result.mentorSlot().mentorSlotStatus()).isEqualTo(MentorSlotStatus.AVAILABLE); @@ -385,8 +411,6 @@ void throwExceptionWhenNotOwner() { // given Long slotId = 1L; - when(mentoringStorage.findMentoringByMentor(mentor2)) - .thenReturn(mentoring1); when(mentoringStorage.findMentorSlot(slotId)) .thenReturn(mentorSlot1); @@ -402,10 +426,7 @@ void throwExceptionWhenReserved() { // given Long slotId = 1L; Reservation reservation = ReservationFixture.create(1L, mentoring1, mentee1, mentorSlot1); - mentorSlot1.setReservation(reservation); - when(mentoringStorage.findMentoringByMentor(mentor1)) - .thenReturn(mentoring1); when(mentoringStorage.findMentorSlot(slotId)) .thenReturn(mentorSlot1); @@ -421,8 +442,6 @@ void throwExceptionWhenOverlapping() { // given Long slotId = 1L; - when(mentoringStorage.findMentoringByMentor(mentor1)) - .thenReturn(mentoring1); when(mentoringStorage.findMentorSlot(slotId)) .thenReturn(mentorSlot1); when(mentorSlotRepository.existsOverlappingExcept( diff --git a/back/src/test/java/com/back/domain/post/post/controller/PostControllerTest.java b/back/src/test/java/com/back/domain/post/post/controller/PostControllerTest.java index a4a87c26..cf3278ed 100644 --- a/back/src/test/java/com/back/domain/post/post/controller/PostControllerTest.java +++ b/back/src/test/java/com/back/domain/post/post/controller/PostControllerTest.java @@ -402,9 +402,22 @@ void t10() throws Exception { @Test @DisplayName("게시글 수정 실패 - title blank") void t11() throws Exception { + mvc.perform( + post("/post") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "memberId": 1, + "postType": "INFORMATIONPOST", + "title": "삭제용 제목", + "content": "삭제용 내용" + } + """) + ); + ResultActions resultActions = mvc .perform( - put("/post/{post_id}", 6L) + put("/post/{post_id}", 9L) .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -418,8 +431,8 @@ void t11() throws Exception { resultActions .andExpect(handler().handlerType(PostController.class)) .andExpect(handler().methodName("updatePost")) - .andExpect(jsonPath("$.resultCode").value("400-1")) - .andExpect(jsonPath("$.msg").value("title-NotBlank-제목은 null 혹은 공백일 수 없습니다.")); + .andExpect(jsonPath("$.resultCode").value("400")) + .andExpect(jsonPath("$.msg").value("제목을 입력해주세요.")); } @Test diff --git a/back/src/test/java/com/back/domain/post/post/service/PostServiceTest.java b/back/src/test/java/com/back/domain/post/post/service/PostServiceTest.java index 1d3e19a2..6535c331 100644 --- a/back/src/test/java/com/back/domain/post/post/service/PostServiceTest.java +++ b/back/src/test/java/com/back/domain/post/post/service/PostServiceTest.java @@ -3,6 +3,7 @@ import com.back.domain.member.member.entity.Member; import com.back.domain.post.post.dto.PostCreateRequest; import com.back.domain.post.post.dto.PostDto; +import com.back.domain.post.post.dto.PostModifyRequest; import com.back.domain.post.post.entity.Post; import com.back.domain.post.post.repository.PostRepository; import com.back.fixture.MemberFixture; @@ -202,7 +203,7 @@ void updatePost_author_success() { Member author = MemberFixture.create(1L, "author@test.com", "Author", "password", Member.Role.MENTEE); Post post = createPost("기존 제목", "기존 내용", author, Post.PostType.INFORMATIONPOST); Long postId = 1L; - PostCreateRequest updateRequest = new PostCreateRequest("INFORMATIONPOST","새 제목","새 내용",""); + PostModifyRequest updateRequest = new PostModifyRequest("새 제목","새 내용"); when(postRepository.findById(postId)).thenReturn(Optional.of(post)); when(postRepository.save(any(Post.class))).thenReturn(post); @@ -224,7 +225,7 @@ void updatePost_notAuthor_failure() { Member otherUser = MemberFixture.create(2L, "other@test.com", "Other", "password", Member.Role.MENTEE); Post post = createPost("제목", "내용", author, Post.PostType.INFORMATIONPOST); Long postId = 1L; - PostCreateRequest updateRequest = new PostCreateRequest("INFORMATIONPOST","새 내용","새 제목",""); + PostModifyRequest updateRequest = new PostModifyRequest("새 내용","새 제목"); when(postRepository.findById(postId)).thenReturn(Optional.of(post)); @@ -244,7 +245,7 @@ void updatePost_nullOrBlankTitle_failure() { Member author = MemberFixture.create(1L, "author@test.com", "Author", "password", Member.Role.MENTEE); Post post = createPost("제목", "내용", author, Post.PostType.INFORMATIONPOST); Long postId = 1L; - PostCreateRequest updateRequest = new PostCreateRequest("INFORMATIONPOST","","새 내용",""); + PostModifyRequest updateRequest = new PostModifyRequest("","새 내용"); when(postRepository.findById(postId)).thenReturn(Optional.of(post)); @@ -263,7 +264,7 @@ void updatePost_nullOrBlankContent_failure() { Member author = MemberFixture.create(1L, "author@test.com", "Author", "password", Member.Role.MENTEE); Post post = createPost("제목", "내용", author, Post.PostType.INFORMATIONPOST); Long postId = 1L; - PostCreateRequest updateRequest = new PostCreateRequest("INFORMATIONPOST","새 제목","",""); + PostModifyRequest updateRequest = new PostModifyRequest("새 제목",""); when(postRepository.findById(postId)).thenReturn(Optional.of(post)); diff --git a/back/src/test/java/com/back/fixture/MentorFixture.java b/back/src/test/java/com/back/fixture/MentorFixture.java index 7dcdc0f5..ed54f94f 100644 --- a/back/src/test/java/com/back/fixture/MentorFixture.java +++ b/back/src/test/java/com/back/fixture/MentorFixture.java @@ -42,4 +42,9 @@ public static Mentor create(Long id, Member member, Long jobId, Double rate, Int ReflectionTestUtils.setField(mentor, "id", id); return mentor; } + + public static Mentor create() { + Member member = MemberFixture.createDefault(); + return create(member); + } } diff --git a/back/src/test/java/com/back/fixture/mentoring/MentoringSessionFixture.java b/back/src/test/java/com/back/fixture/mentoring/MentoringSessionFixture.java new file mode 100644 index 00000000..b23ddfc0 --- /dev/null +++ b/back/src/test/java/com/back/fixture/mentoring/MentoringSessionFixture.java @@ -0,0 +1,19 @@ +package com.back.fixture.mentoring; + +import com.back.domain.mentoring.reservation.constant.ReservationStatus; +import com.back.domain.mentoring.reservation.entity.Reservation; +import com.back.domain.mentoring.session.entity.MentoringSession; + +public class MentoringSessionFixture { + public static MentoringSession createDefault() { + Reservation reservation = ReservationFixture.createDefault(); + return create(reservation); + } + + public static MentoringSession create(Reservation reservation) { + if (reservation.getStatus() != ReservationStatus.APPROVED) { + reservation.approve(reservation.getMentor()); + } + return MentoringSession.create(reservation); + } +} \ No newline at end of file diff --git a/back/src/test/java/com/back/fixture/mentoring/MentoringTestFixture.java b/back/src/test/java/com/back/fixture/mentoring/MentoringTestFixture.java index fc6a6199..7b9375f7 100644 --- a/back/src/test/java/com/back/fixture/mentoring/MentoringTestFixture.java +++ b/back/src/test/java/com/back/fixture/mentoring/MentoringTestFixture.java @@ -149,7 +149,6 @@ public Reservation createReservation(Mentoring mentoring, Mentee mentee, MentorS .mentorSlot(slot) .preQuestion(preQuestion) .build(); - slot.setReservation(reservation); return reservationRepository.save(reservation); } diff --git a/back/src/test/java/com/back/fixture/mentoring/ReservationFixture.java b/back/src/test/java/com/back/fixture/mentoring/ReservationFixture.java index 89b3a7d0..aacd5eca 100644 --- a/back/src/test/java/com/back/fixture/mentoring/ReservationFixture.java +++ b/back/src/test/java/com/back/fixture/mentoring/ReservationFixture.java @@ -1,9 +1,15 @@ package com.back.fixture.mentoring; +import com.back.domain.member.member.entity.Member; import com.back.domain.member.mentee.entity.Mentee; +import com.back.domain.member.mentor.entity.Mentor; import com.back.domain.mentoring.mentoring.entity.Mentoring; import com.back.domain.mentoring.reservation.entity.Reservation; +import com.back.domain.mentoring.slot.constant.MentorSlotStatus; import com.back.domain.mentoring.slot.entity.MentorSlot; +import com.back.fixture.MemberFixture; +import com.back.fixture.MenteeFixture; +import com.back.fixture.MentorFixture; import org.springframework.test.util.ReflectionTestUtils; public class ReservationFixture { @@ -11,12 +17,15 @@ public class ReservationFixture { private static final String DEFAULT_PRE_QUESTION = "테스트 사전 질문입니다."; public static Reservation create(Mentoring mentoring, Mentee mentee, MentorSlot mentorSlot) { - return Reservation.builder() + Reservation reservation = Reservation.builder() .mentoring(mentoring) .mentee(mentee) .mentorSlot(mentorSlot) .preQuestion(DEFAULT_PRE_QUESTION) .build(); + + mentorSlot.updateStatus(MentorSlotStatus.PENDING); + return reservation; } public static Reservation create(Long id, Mentoring mentoring, Mentee mentee, MentorSlot mentorSlot) { @@ -28,10 +37,25 @@ public static Reservation create(Long id, Mentoring mentoring, Mentee mentee, Me .build(); ReflectionTestUtils.setField(reservation, "id", id); + mentorSlot.updateStatus(MentorSlotStatus.PENDING); + return reservation; + } - // 양방향 연결 설정 - mentorSlot.setReservation(reservation); + public static Reservation createDefault() { + Member mentorMember = MemberFixture.createDefault(); + Member menteeMember = MemberFixture.createDefault(); + Mentor mentor = MentorFixture.create(mentorMember); + Mentee mentee = MenteeFixture.create(menteeMember); + Mentoring mentoring = MentoringFixture.create(mentor); + MentorSlot mentorSlot = MentorSlotFixture.create(mentor); + return create(mentoring, mentee, mentorSlot); + } - return reservation; + public static Reservation createWithMentor(Mentor mentor) { + Member menteeMember = MemberFixture.createDefault(); + Mentee mentee = MenteeFixture.create(menteeMember); + Mentoring mentoring = MentoringFixture.create(mentor); + MentorSlot mentorSlot = MentorSlotFixture.create(mentor); + return create(mentoring, mentee, mentorSlot); } }