From f644998bcd49a1582280f0c844911b7ed2cc87ca Mon Sep 17 00:00:00 2001 From: Hoyeon Lee <89958157+howooyeon@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:49:31 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EB=A9=98=ED=86=A0=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=8F=20=EB=A9=94=EC=9D=B8=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/MentorCardResponse.java | 58 ++++++++++++++++ .../dto/response/MentorListResponse.java | 24 +++++++ .../dto/response/MentorMainResponse.java | 15 ++++ .../domain/mentoring/entity/Mentor.java | 68 +++++++++++++++++++ .../mentoring/entity/MentoringCategory.java | 9 +++ .../mentoring/entity/MentoringKeyword.java | 25 +++++++ .../repository/MentorRepository.java | 20 ++++++ .../MentoringKeywordRepository.java | 7 ++ 8 files changed, 226 insertions(+) create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentorCardResponse.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentorListResponse.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentorMainResponse.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/entity/Mentor.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringCategory.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringKeyword.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/repository/MentorRepository.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringKeywordRepository.java diff --git a/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentorCardResponse.java b/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentorCardResponse.java new file mode 100644 index 0000000..bcf8d47 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentorCardResponse.java @@ -0,0 +1,58 @@ +package com.kusitms.website.domain.mentoring.dto.response; + +import com.kusitms.website.domain.mentoring.entity.Mentor; +import com.kusitms.website.domain.mentoring.entity.MentoringCategory; +import com.kusitms.website.domain.mentoring.entity.MentoringMethod; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class MentorCardResponse { + + @Schema(description = "멘토 ID") + private Long mentorId; + + @Schema(description = "멘토링 타이틀") + private String title; + + @Schema(description = "프로필 이미지 URL") + private String profileImageUrl; + + @Schema(description = "이름") + private String name; + + @Schema(description = "기수") + private Integer cardinal; + + @Schema(description = "직무 카테고리") + private MentoringCategory category; + + @Schema(description = "경력") + private String experience; + + @Schema(description = "멘토링 방식") + private MentoringMethod method; + + @Schema(description = "시간당 가격") + private Integer pricePerHour; + + @Schema(description = "뱃지 키워드 (조건부)") + private String badgeKeyword; + + public static MentorCardResponse from(Mentor mentor, String badgeKeyword) { + return MentorCardResponse.builder() + .mentorId(mentor.getMentorId()) + .title(mentor.getTitle()) + .profileImageUrl(mentor.getProfileImageUrl()) + .name(mentor.getMember().getName()) + .cardinal(mentor.getMember().getCardinal()) + .category(mentor.getCategory()) + .experience(mentor.getExperience()) + .method(mentor.getMethod()) + .pricePerHour(mentor.getPricePerHour()) + .badgeKeyword(badgeKeyword) + .build(); + } +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentorListResponse.java b/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentorListResponse.java new file mode 100644 index 0000000..ba9b695 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentorListResponse.java @@ -0,0 +1,24 @@ +package com.kusitms.website.domain.mentoring.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class MentorListResponse { + + @Schema(description = "전체 멘토 수") + private long totalCount; + + @Schema(description = "전체 페이지 수") + private int totalPages; + + @Schema(description = "현재 페이지") + private int currentPage; + + @Schema(description = "멘토 카드 목록") + private List mentors; +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentorMainResponse.java b/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentorMainResponse.java new file mode 100644 index 0000000..77ef2bc --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentorMainResponse.java @@ -0,0 +1,15 @@ +package com.kusitms.website.domain.mentoring.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class MentorMainResponse { + + @Schema(description = "활동 중인 멘토 카드 (최대 4개, 랜덤)") + private List mentors; +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/entity/Mentor.java b/src/main/java/com/kusitms/website/domain/mentoring/entity/Mentor.java new file mode 100644 index 0000000..5d7973d --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/entity/Mentor.java @@ -0,0 +1,68 @@ +package com.kusitms.website.domain.mentoring.entity; + +import com.kusitms.website.domain.user.Member; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Mentor { + + @Id + @Column(name = "mentor_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long mentorId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Member member; + + @Column(nullable = false) + private String title; + + private String profileImageUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private MentoringCategory category; + + @Column(nullable = false) + private String experience; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private MentoringMethod method; + + @Column(nullable = false) + private Integer pricePerHour; + + @Column(columnDefinition = "TEXT") + private String introduction; + + private boolean active; + + private LocalDateTime createdAt; + + @Builder + public Mentor(Member member, String title, String profileImageUrl, + MentoringCategory category, String experience, + MentoringMethod method, Integer pricePerHour, + String introduction, boolean active) { + this.member = member; + this.title = title; + this.profileImageUrl = profileImageUrl; + this.category = category; + this.experience = experience; + this.method = method; + this.pricePerHour = pricePerHour; + this.introduction = introduction; + this.active = active; + this.createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringCategory.java b/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringCategory.java new file mode 100644 index 0000000..e1676c7 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringCategory.java @@ -0,0 +1,9 @@ +package com.kusitms.website.domain.mentoring.entity; + +public enum MentoringCategory { + PM, + DEV, + DESIGN, + MARKETING, + CONSULTING +} \ No newline at end of file diff --git a/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringKeyword.java b/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringKeyword.java new file mode 100644 index 0000000..a689bcd --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringKeyword.java @@ -0,0 +1,25 @@ +package com.kusitms.website.domain.mentoring.entity; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MentoringKeyword { + + @Id + @Column(name = "keyword_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long keywordId; + + @Column(nullable = false, unique = true) + private String name; + + public MentoringKeyword(String name) { + this.name = name; + } +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/repository/MentorRepository.java b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentorRepository.java new file mode 100644 index 0000000..6721b35 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentorRepository.java @@ -0,0 +1,20 @@ +package com.kusitms.website.domain.mentoring.repository; + +import com.kusitms.website.domain.mentoring.entity.Mentor; +import com.kusitms.website.domain.mentoring.entity.MentoringCategory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface MentorRepository extends JpaRepository { + + @Query("SELECT m FROM Mentor m WHERE m.active = true ORDER BY FUNCTION('RANDOM')") + List findRandomActiveMentors(Pageable pageable); + + Page findByActiveTrueOrderByCreatedAtDesc(Pageable pageable); + + Page findByActiveTrueAndCategoryOrderByCreatedAtDesc(MentoringCategory category, Pageable pageable); +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringKeywordRepository.java b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringKeywordRepository.java new file mode 100644 index 0000000..51b60f4 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringKeywordRepository.java @@ -0,0 +1,7 @@ +package com.kusitms.website.domain.mentoring.repository; + +import com.kusitms.website.domain.mentoring.entity.MentoringKeyword; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MentoringKeywordRepository extends JpaRepository { +} From 4bf4364f2aedf1b7b1216916fb252ad07d8663b5 Mon Sep 17 00:00:00 2001 From: Hoyeon Lee <89958157+howooyeon@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:49:51 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EB=A9=98=ED=86=A0=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=A0=95=EB=B3=B4=20=EB=B0=8F=20=ED=9B=84=EA=B8=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/KeywordChipResponse.java | 19 +++++ .../dto/response/MentorDetailResponse.java | 85 +++++++++++++++++++ .../MentoringReviewDetailResponse.java | 48 +++++++++++ .../response/MentoringReviewListResponse.java | 24 ++++++ .../dto/response/MentoringSlotResponse.java | 59 +++++++++++++ .../mentoring/entity/MentoringReview.java | 51 +++++++++++ .../entity/MentoringReviewKeyword.java | 30 +++++++ .../mentoring/entity/MentoringSlot.java | 51 +++++++++++ .../domain/mentoring/entity/SlotType.java | 6 ++ .../MentoringReviewKeywordRepository.java | 27 ++++++ .../repository/MentoringReviewRepository.java | 11 +++ .../repository/MentoringSlotRepository.java | 22 +++++ 12 files changed, 433 insertions(+) create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/dto/response/KeywordChipResponse.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentorDetailResponse.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentoringReviewDetailResponse.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentoringReviewListResponse.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentoringSlotResponse.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringReview.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringReviewKeyword.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringSlot.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/entity/SlotType.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringReviewKeywordRepository.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringReviewRepository.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringSlotRepository.java diff --git a/src/main/java/com/kusitms/website/domain/mentoring/dto/response/KeywordChipResponse.java b/src/main/java/com/kusitms/website/domain/mentoring/dto/response/KeywordChipResponse.java new file mode 100644 index 0000000..4231607 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/dto/response/KeywordChipResponse.java @@ -0,0 +1,19 @@ +package com.kusitms.website.domain.mentoring.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class KeywordChipResponse { + + @Schema(description = "키워드 ID") + private Long keywordId; + + @Schema(description = "키워드 이름") + private String name; + + @Schema(description = "선택 횟수") + private Long count; +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentorDetailResponse.java b/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentorDetailResponse.java new file mode 100644 index 0000000..a43187e --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentorDetailResponse.java @@ -0,0 +1,85 @@ +package com.kusitms.website.domain.mentoring.dto.response; + +import com.kusitms.website.domain.mentoring.entity.Mentor; +import com.kusitms.website.domain.mentoring.entity.MentoringCategory; +import com.kusitms.website.domain.mentoring.entity.MentoringMethod; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class MentorDetailResponse { + + @Schema(description = "멘토 ID") + private Long mentorId; + + @Schema(description = "멘토링 타이틀") + private String title; + + @Schema(description = "프로필 이미지 URL") + private String profileImageUrl; + + @Schema(description = "이름") + private String name; + + @Schema(description = "기수") + private Integer cardinal; + + @Schema(description = "직무 카테고리") + private MentoringCategory category; + + @Schema(description = "경력") + private String experience; + + @Schema(description = "멘토링 방식") + private MentoringMethod method; + + @Schema(description = "시간당 가격") + private Integer pricePerHour; + + @Schema(description = "멘토링 소개글") + private String introduction; + + @Schema(description = "가용 슬롯 목록") + private List slots; + + @Schema(description = "키워드 칩 목록 (3회 이상)") + private List keywordChips; + + @Schema(description = "후기 목록") + private MentoringReviewListResponse reviews; + + @Schema(description = "본인 멘토링 여부") + private boolean isOwnMentoring; + + @Schema(description = "기존 PENDING/ACTIVE 신청 존재 여부") + private boolean hasExistingApplication; + + public static MentorDetailResponse from(Mentor mentor, + List slots, + List keywordChips, + MentoringReviewListResponse reviews, + boolean isOwnMentoring, + boolean hasExistingApplication) { + return MentorDetailResponse.builder() + .mentorId(mentor.getMentorId()) + .title(mentor.getTitle()) + .profileImageUrl(mentor.getProfileImageUrl()) + .name(mentor.getMember().getName()) + .cardinal(mentor.getMember().getCardinal()) + .category(mentor.getCategory()) + .experience(mentor.getExperience()) + .method(mentor.getMethod()) + .pricePerHour(mentor.getPricePerHour()) + .introduction(mentor.getIntroduction()) + .slots(slots) + .keywordChips(keywordChips) + .reviews(reviews) + .isOwnMentoring(isOwnMentoring) + .hasExistingApplication(hasExistingApplication) + .build(); + } +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentoringReviewDetailResponse.java b/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentoringReviewDetailResponse.java new file mode 100644 index 0000000..57626ea --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentoringReviewDetailResponse.java @@ -0,0 +1,48 @@ +package com.kusitms.website.domain.mentoring.dto.response; + +import com.kusitms.website.domain.mentoring.entity.MentoringReview; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Builder +public class MentoringReviewDetailResponse { + + @Schema(description = "후기 ID") + private Long reviewId; + + @Schema(description = "작성자 이름") + private String reviewerName; + + @Schema(description = "작성자 기수") + private Integer reviewerCardinal; + + @Schema(description = "후기 내용") + private String content; + + @Schema(description = "선택된 키워드 목록") + private List keywords; + + @Schema(description = "작성일") + private LocalDateTime createdAt; + + public static MentoringReviewDetailResponse from(MentoringReview review) { + List keywordNames = review.getKeywords().stream() + .map(rk -> rk.getKeyword().getName()) + .collect(Collectors.toList()); + + return MentoringReviewDetailResponse.builder() + .reviewId(review.getReviewId()) + .reviewerName(review.getReviewer().getName()) + .reviewerCardinal(review.getReviewer().getCardinal()) + .content(review.getContent()) + .keywords(keywordNames) + .createdAt(review.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentoringReviewListResponse.java b/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentoringReviewListResponse.java new file mode 100644 index 0000000..0e32f11 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentoringReviewListResponse.java @@ -0,0 +1,24 @@ +package com.kusitms.website.domain.mentoring.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class MentoringReviewListResponse { + + @Schema(description = "전체 후기 수") + private long totalCount; + + @Schema(description = "전체 페이지 수") + private int totalPages; + + @Schema(description = "현재 페이지") + private int currentPage; + + @Schema(description = "후기 목록") + private List reviews; +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentoringSlotResponse.java b/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentoringSlotResponse.java new file mode 100644 index 0000000..a4c9e4d --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/dto/response/MentoringSlotResponse.java @@ -0,0 +1,59 @@ +package com.kusitms.website.domain.mentoring.dto.response; + +import com.kusitms.website.domain.mentoring.entity.MentoringSlot; +import com.kusitms.website.domain.mentoring.entity.SlotType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Getter +@Builder +public class MentoringSlotResponse { + + @Schema(description = "슬롯 ID") + private Long slotId; + + @Schema(description = "날짜") + private LocalDate date; + + @Schema(description = "시작 시간") + private LocalTime startTime; + + @Schema(description = "종료 시간") + private LocalTime endTime; + + @Schema(description = "슬롯 타입") + private SlotType slotType; + + @Schema(description = "최대 인원") + private int maxAttendees; + + @Schema(description = "현재 신청 인원 (PENDING + ACTIVE)") + private int currentAttendees; + + @Schema(description = "신청 가능 여부") + private boolean available; + + public static MentoringSlotResponse from(MentoringSlot slot, int currentAttendees) { + boolean available; + if (slot.getSlotType() == SlotType.ONE_TO_ONE) { + available = currentAttendees == 0; + } else { + available = currentAttendees < slot.getMaxAttendees(); + } + + return MentoringSlotResponse.builder() + .slotId(slot.getSlotId()) + .date(slot.getDate()) + .startTime(slot.getStartTime()) + .endTime(slot.getEndTime()) + .slotType(slot.getSlotType()) + .maxAttendees(slot.getMaxAttendees()) + .currentAttendees(currentAttendees) + .available(available) + .build(); + } +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringReview.java b/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringReview.java new file mode 100644 index 0000000..fe975f1 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringReview.java @@ -0,0 +1,51 @@ +package com.kusitms.website.domain.mentoring.entity; + +import com.kusitms.website.domain.user.Member; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MentoringReview { + + @Id + @Column(name = "review_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long reviewId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mentor_id", nullable = false) + private Mentor mentor; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Member reviewer; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + private LocalDateTime createdAt; + + @OneToMany(mappedBy = "review", cascade = CascadeType.ALL, orphanRemoval = true) + private List keywords = new ArrayList<>(); + + @Builder + public MentoringReview(Mentor mentor, Member reviewer, String content) { + this.mentor = mentor; + this.reviewer = reviewer; + this.content = content; + this.createdAt = LocalDateTime.now(); + } + + public void addKeyword(MentoringReviewKeyword keyword) { + this.keywords.add(keyword); + } +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringReviewKeyword.java b/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringReviewKeyword.java new file mode 100644 index 0000000..70e15e8 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringReviewKeyword.java @@ -0,0 +1,30 @@ +package com.kusitms.website.domain.mentoring.entity; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MentoringReviewKeyword { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id", nullable = false) + private MentoringReview review; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "keyword_id", nullable = false) + private MentoringKeyword keyword; + + public MentoringReviewKeyword(MentoringReview review, MentoringKeyword keyword) { + this.review = review; + this.keyword = keyword; + } +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringSlot.java b/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringSlot.java new file mode 100644 index 0000000..fce8d5f --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringSlot.java @@ -0,0 +1,51 @@ +package com.kusitms.website.domain.mentoring.entity; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.time.LocalDate; +import java.time.LocalTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MentoringSlot { + + @Id + @Column(name = "slot_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long slotId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mentor_id", nullable = false) + private Mentor mentor; + + @Column(nullable = false) + private LocalDate date; + + @Column(nullable = false) + private LocalTime startTime; + + @Column(nullable = false) + private LocalTime endTime; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SlotType slotType; + + private int maxAttendees; + + @Builder + public MentoringSlot(Mentor mentor, LocalDate date, LocalTime startTime, + LocalTime endTime, SlotType slotType, int maxAttendees) { + this.mentor = mentor; + this.date = date; + this.startTime = startTime; + this.endTime = endTime; + this.slotType = slotType; + this.maxAttendees = maxAttendees; + } +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/entity/SlotType.java b/src/main/java/com/kusitms/website/domain/mentoring/entity/SlotType.java new file mode 100644 index 0000000..feec094 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/entity/SlotType.java @@ -0,0 +1,6 @@ +package com.kusitms.website.domain.mentoring.entity; + +public enum SlotType { + ONE_TO_ONE, + ONE_TO_N +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringReviewKeywordRepository.java b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringReviewKeywordRepository.java new file mode 100644 index 0000000..8957957 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringReviewKeywordRepository.java @@ -0,0 +1,27 @@ +package com.kusitms.website.domain.mentoring.repository; + +import com.kusitms.website.domain.mentoring.entity.MentoringReviewKeyword; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface MentoringReviewKeywordRepository extends JpaRepository { + + @Query("SELECT rk.keyword.keywordId, rk.keyword.name, COUNT(rk) " + + "FROM MentoringReviewKeyword rk " + + "WHERE rk.review.mentor.mentorId = :mentorId " + + "GROUP BY rk.keyword.keywordId, rk.keyword.name " + + "HAVING COUNT(rk) >= 3 " + + "ORDER BY COUNT(rk) DESC, rk.keyword.keywordId ASC") + List findKeywordStatsForMentor(@Param("mentorId") Long mentorId); + + @Query("SELECT rk.review.mentor.mentorId, rk.keyword.name " + + "FROM MentoringReviewKeyword rk " + + "WHERE rk.review.mentor.mentorId IN :mentorIds " + + "GROUP BY rk.review.mentor.mentorId, rk.keyword.keywordId, rk.keyword.name " + + "HAVING COUNT(rk) >= 3 " + + "ORDER BY rk.review.mentor.mentorId, COUNT(rk) DESC, rk.keyword.keywordId ASC") + List findTopKeywordsForMentors(@Param("mentorIds") List mentorIds); +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringReviewRepository.java b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringReviewRepository.java new file mode 100644 index 0000000..f9d7305 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringReviewRepository.java @@ -0,0 +1,11 @@ +package com.kusitms.website.domain.mentoring.repository; + +import com.kusitms.website.domain.mentoring.entity.MentoringReview; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MentoringReviewRepository extends JpaRepository { + + Page findByMentorMentorIdOrderByCreatedAtDesc(Long mentorId, Pageable pageable); +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringSlotRepository.java b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringSlotRepository.java new file mode 100644 index 0000000..be8abc4 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringSlotRepository.java @@ -0,0 +1,22 @@ +package com.kusitms.website.domain.mentoring.repository; + +import com.kusitms.website.domain.mentoring.entity.MentoringSlot; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import javax.persistence.LockModeType; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface MentoringSlotRepository extends JpaRepository { + + List findByMentorMentorIdAndDateGreaterThanEqualOrderByDateAscStartTimeAsc( + Long mentorId, LocalDate fromDate); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM MentoringSlot s WHERE s.slotId = :slotId") + Optional findByIdWithLock(@Param("slotId") Long slotId); +} From 4a60c2dc99942b28b39ba066bc4bd50953251f31 Mon Sep 17 00:00:00 2001 From: Hoyeon Lee <89958157+howooyeon@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:49:57 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EB=A9=98=ED=86=A0=EB=A7=81=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/mentoring/MentoringController.java | 98 ++++++++++ .../dto/request/MentoringApplyRequest.java | 19 ++ .../mentoring/entity/ApplicationStatus.java | 8 + .../entity/MentoringApplication.java | 52 +++++ .../MentoringApplicationRepository.java | 29 +++ .../mentoring/service/MentoringService.java | 185 ++++++++++++++++++ 6 files changed, 391 insertions(+) create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/MentoringController.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/dto/request/MentoringApplyRequest.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/entity/ApplicationStatus.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringApplication.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringApplicationRepository.java create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/service/MentoringService.java diff --git a/src/main/java/com/kusitms/website/domain/mentoring/MentoringController.java b/src/main/java/com/kusitms/website/domain/mentoring/MentoringController.java new file mode 100644 index 0000000..54a2c84 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/MentoringController.java @@ -0,0 +1,98 @@ +package com.kusitms.website.domain.mentoring; + +import com.kusitms.website.domain.mentoring.dto.request.MentoringApplyRequest; +import com.kusitms.website.domain.mentoring.dto.response.MentorDetailResponse; +import com.kusitms.website.domain.mentoring.dto.response.MentorListResponse; +import com.kusitms.website.domain.mentoring.dto.response.MentorMainResponse; +import com.kusitms.website.domain.mentoring.dto.response.MentoringReviewListResponse; +import com.kusitms.website.domain.mentoring.entity.MentoringCategory; +import com.kusitms.website.domain.mentoring.service.MentoringService; +import com.kusitms.website.global.auth.UserPrincipal; +import com.kusitms.website.global.common.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/mentoring") +@RequiredArgsConstructor +@Tag(name = "Mentoring", description = "멘토링 API") +public class MentoringController { + + private final MentoringService mentoringService; + + @GetMapping + @Operation(summary = "멘토링 메인", description = "활동 중인 멘토 카드 최대 4개를 랜덤으로 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공"), + }) + public ResponseEntity> getMainMentors() { + return ResponseEntity.ok(new BaseResponse<>(mentoringService.getMainMentors())); + } + + @GetMapping("/list") + @Operation(summary = "멘토 리스트", description = "멘토 목록을 카테고리별로 페이지네이션하여 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공"), + }) + public ResponseEntity> getMentorList( + @RequestParam(required = false) MentoringCategory category, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "12") int size) { + return ResponseEntity.ok(new BaseResponse<>(mentoringService.getMentorList(category, page, size))); + } + + @GetMapping("/{mentorId}") + @Operation(summary = "멘토 세부정보", description = "멘토의 상세 정보를 조회합니다. 로그인 필수.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 필요"), + }) + public ResponseEntity> getMentorDetail( + @PathVariable Long mentorId) { + Long userId = getAuthenticatedUserId(); + return ResponseEntity.ok(new BaseResponse<>(mentoringService.getMentorDetail(mentorId, userId))); + } + + @GetMapping("/{mentorId}/reviews") + @Operation(summary = "멘토링 후기 페이지네이션", description = "멘토의 후기를 페이지네이션하여 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공"), + }) + public ResponseEntity> getMentorReviews( + @PathVariable Long mentorId, + @RequestParam(defaultValue = "0") int page) { + return ResponseEntity.ok(new BaseResponse<>(mentoringService.getMentorReviews(mentorId, page))); + } + + @PostMapping("/{mentorId}/apply") + @Operation(summary = "멘토링 신청", description = "멘토링 슬롯을 선택하여 신청합니다. 로그인 필수.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "신청 성공"), + @ApiResponse(responseCode = "400", description = "신청 실패"), + @ApiResponse(responseCode = "401", description = "인증 필요"), + }) + public ResponseEntity applyMentoring( + @PathVariable Long mentorId, + @RequestBody MentoringApplyRequest request) { + Long userId = getAuthenticatedUserId(); + mentoringService.applyMentoring(mentorId, userId, request); + return ResponseEntity.ok(new BaseResponse()); + } + + private Long getAuthenticatedUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated() + || authentication.getPrincipal().equals("anonymousUser")) { + throw new IllegalArgumentException("로그인이 필요합니다."); + } + UserPrincipal principal = (UserPrincipal) authentication.getPrincipal(); + return principal.getPk(); + } +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/dto/request/MentoringApplyRequest.java b/src/main/java/com/kusitms/website/domain/mentoring/dto/request/MentoringApplyRequest.java new file mode 100644 index 0000000..85fc1cb --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/dto/request/MentoringApplyRequest.java @@ -0,0 +1,19 @@ +package com.kusitms.website.domain.mentoring.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.Size; + +@Getter +@NoArgsConstructor +public class MentoringApplyRequest { + + @Schema(description = "슬롯 ID", required = true) + private Long slotId; + + @Size(max = 500) + @Schema(description = "멘토에게 공유할 메시지", maxLength = 500) + private String message; +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/entity/ApplicationStatus.java b/src/main/java/com/kusitms/website/domain/mentoring/entity/ApplicationStatus.java new file mode 100644 index 0000000..e299a20 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/entity/ApplicationStatus.java @@ -0,0 +1,8 @@ +package com.kusitms.website.domain.mentoring.entity; + +public enum ApplicationStatus { + PENDING, + ACTIVE, + REJECTED, + CANCELED +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringApplication.java b/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringApplication.java new file mode 100644 index 0000000..bd82617 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringApplication.java @@ -0,0 +1,52 @@ +package com.kusitms.website.domain.mentoring.entity; + +import com.kusitms.website.domain.user.Member; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MentoringApplication { + + @Id + @Column(name = "application_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long applicationId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "slot_id", nullable = false) + private MentoringSlot slot; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Member applicant; + + @Column(length = 500) + private String message; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ApplicationStatus status; + + private LocalDateTime createdAt; + + @Builder + public MentoringApplication(MentoringSlot slot, Member applicant, + String message, ApplicationStatus status) { + this.slot = slot; + this.applicant = applicant; + this.message = message; + this.status = status; + this.createdAt = LocalDateTime.now(); + } + + public void updateStatus(ApplicationStatus status) { + this.status = status; + } +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringApplicationRepository.java b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringApplicationRepository.java new file mode 100644 index 0000000..2973462 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentoringApplicationRepository.java @@ -0,0 +1,29 @@ +package com.kusitms.website.domain.mentoring.repository; + +import com.kusitms.website.domain.mentoring.entity.ApplicationStatus; +import com.kusitms.website.domain.mentoring.entity.MentoringApplication; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface MentoringApplicationRepository extends JpaRepository { + + @Query("SELECT COUNT(a) FROM MentoringApplication a WHERE a.slot.slotId = :slotId AND a.status IN :statuses") + int countBySlotIdAndStatusIn(@Param("slotId") Long slotId, + @Param("statuses") List statuses); + + boolean existsBySlotSlotIdAndApplicantUserIdAndStatusIn( + Long slotId, Long userId, List statuses); + + @Query("SELECT CASE WHEN COUNT(a) > 0 THEN true ELSE false END " + + "FROM MentoringApplication a " + + "WHERE a.slot.mentor.mentorId = :mentorId " + + "AND a.applicant.userId = :userId " + + "AND a.status IN :statuses") + boolean existsByMentorIdAndApplicantIdAndStatusIn( + @Param("mentorId") Long mentorId, + @Param("userId") Long userId, + @Param("statuses") List statuses); +} diff --git a/src/main/java/com/kusitms/website/domain/mentoring/service/MentoringService.java b/src/main/java/com/kusitms/website/domain/mentoring/service/MentoringService.java new file mode 100644 index 0000000..20198c0 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/service/MentoringService.java @@ -0,0 +1,185 @@ +package com.kusitms.website.domain.mentoring.service; + +import com.kusitms.website.domain.mentoring.dto.request.MentoringApplyRequest; +import com.kusitms.website.domain.mentoring.dto.response.*; +import com.kusitms.website.domain.mentoring.entity.*; +import com.kusitms.website.domain.mentoring.repository.*; +import com.kusitms.website.domain.user.Member; +import com.kusitms.website.domain.user.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MentoringService { + + private final MentorRepository mentorRepository; + private final MentoringSlotRepository slotRepository; + private final MentoringApplicationRepository applicationRepository; + private final MentoringReviewRepository reviewRepository; + private final MentoringReviewKeywordRepository reviewKeywordRepository; + private final MemberRepository memberRepository; + + private static final List OCCUPYING_STATUSES = + Arrays.asList(ApplicationStatus.PENDING, ApplicationStatus.ACTIVE); + + public MentorMainResponse getMainMentors() { + List mentors = mentorRepository.findRandomActiveMentors(PageRequest.of(0, 4)); + List cards = buildMentorCards(mentors); + return new MentorMainResponse(cards); + } + + public MentorListResponse getMentorList(MentoringCategory category, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + Page mentorPage; + + if (category == null) { + mentorPage = mentorRepository.findByActiveTrueOrderByCreatedAtDesc(pageable); + } else { + mentorPage = mentorRepository.findByActiveTrueAndCategoryOrderByCreatedAtDesc(category, pageable); + } + + List cards = buildMentorCards(mentorPage.getContent()); + + return MentorListResponse.builder() + .totalCount(mentorPage.getTotalElements()) + .totalPages(mentorPage.getTotalPages()) + .currentPage(mentorPage.getNumber()) + .mentors(cards) + .build(); + } + + public MentorDetailResponse getMentorDetail(Long mentorId, Long currentUserId) { + Mentor mentor = mentorRepository.findById(mentorId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 멘토입니다.")); + + List slots = slotRepository + .findByMentorMentorIdAndDateGreaterThanEqualOrderByDateAscStartTimeAsc( + mentorId, LocalDate.now()); + + List slotResponses = slots.stream() + .map(slot -> { + int count = applicationRepository.countBySlotIdAndStatusIn( + slot.getSlotId(), OCCUPYING_STATUSES); + return MentoringSlotResponse.from(slot, count); + }) + .collect(Collectors.toList()); + + List keywordStats = reviewKeywordRepository.findKeywordStatsForMentor(mentorId); + List keywordChips = keywordStats.stream() + .map(row -> new KeywordChipResponse( + (Long) row[0], (String) row[1], (Long) row[2])) + .collect(Collectors.toList()); + + Page reviewPage = reviewRepository + .findByMentorMentorIdOrderByCreatedAtDesc(mentorId, PageRequest.of(0, 10)); + MentoringReviewListResponse reviewListResponse = buildReviewListResponse(reviewPage); + + boolean isOwnMentoring = mentor.getMember().getUserId().equals(currentUserId); + boolean hasExistingApplication = applicationRepository + .existsByMentorIdAndApplicantIdAndStatusIn(mentorId, currentUserId, OCCUPYING_STATUSES); + + return MentorDetailResponse.from(mentor, slotResponses, keywordChips, + reviewListResponse, isOwnMentoring, hasExistingApplication); + } + + public MentoringReviewListResponse getMentorReviews(Long mentorId, int page) { + Page reviewPage = reviewRepository + .findByMentorMentorIdOrderByCreatedAtDesc(mentorId, PageRequest.of(page, 10)); + return buildReviewListResponse(reviewPage); + } + + @Transactional + public void applyMentoring(Long mentorId, Long applicantUserId, MentoringApplyRequest request) { + Mentor mentor = mentorRepository.findById(mentorId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 멘토입니다.")); + + if (mentor.getMember().getUserId().equals(applicantUserId)) { + throw new IllegalArgumentException("본인 멘토링에는 신청할 수 없습니다."); + } + + if (applicationRepository.existsByMentorIdAndApplicantIdAndStatusIn( + mentorId, applicantUserId, OCCUPYING_STATUSES)) { + throw new IllegalArgumentException("이미 진행 중인 신청이 존재합니다."); + } + + MentoringSlot slot = slotRepository.findByIdWithLock(request.getSlotId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 슬롯입니다.")); + + if (!slot.getMentor().getMentorId().equals(mentorId)) { + throw new IllegalArgumentException("해당 멘토의 슬롯이 아닙니다."); + } + + if (applicationRepository.existsBySlotSlotIdAndApplicantUserIdAndStatusIn( + slot.getSlotId(), applicantUserId, OCCUPYING_STATUSES)) { + throw new IllegalArgumentException("동일 슬롯에 중복 신청할 수 없습니다."); + } + + int currentCount = applicationRepository.countBySlotIdAndStatusIn( + slot.getSlotId(), OCCUPYING_STATUSES); + + if (slot.getSlotType() == SlotType.ONE_TO_ONE && currentCount >= 1) { + throw new IllegalArgumentException("해당 시간대는 이미 예약되었습니다."); + } + if (slot.getSlotType() == SlotType.ONE_TO_N && currentCount >= slot.getMaxAttendees()) { + throw new IllegalArgumentException("해당 시간대의 최대 인원에 도달했습니다."); + } + + Member applicant = memberRepository.findById(applicantUserId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + + MentoringApplication application = MentoringApplication.builder() + .slot(slot) + .applicant(applicant) + .message(request.getMessage()) + .status(ApplicationStatus.PENDING) + .build(); + + applicationRepository.save(application); + } + + private List buildMentorCards(List mentors) { + if (mentors.isEmpty()) { + return Collections.emptyList(); + } + + List mentorIds = mentors.stream() + .map(Mentor::getMentorId) + .collect(Collectors.toList()); + + List keywordRows = reviewKeywordRepository.findTopKeywordsForMentors(mentorIds); + + Map badgeMap = new HashMap<>(); + for (Object[] row : keywordRows) { + Long mId = (Long) row[0]; + String keyword = (String) row[1]; + badgeMap.putIfAbsent(mId, keyword); + } + + return mentors.stream() + .map(m -> MentorCardResponse.from(m, badgeMap.get(m.getMentorId()))) + .collect(Collectors.toList()); + } + + private MentoringReviewListResponse buildReviewListResponse(Page page) { + List reviews = page.getContent().stream() + .map(MentoringReviewDetailResponse::from) + .collect(Collectors.toList()); + + return MentoringReviewListResponse.builder() + .totalCount(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .currentPage(page.getNumber()) + .reviews(reviews) + .build(); + } +} From bc0fbe26f6c727edf8a52b1158f104a8929c3447 Mon Sep 17 00:00:00 2001 From: Hoyeon Lee <89958157+howooyeon@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:51:04 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EB=A9=98=ED=86=A0=EB=A7=81=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20enum=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../website/domain/mentoring/entity/MentoringMethod.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringMethod.java diff --git a/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringMethod.java b/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringMethod.java new file mode 100644 index 0000000..61070d8 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/entity/MentoringMethod.java @@ -0,0 +1,7 @@ +package com.kusitms.website.domain.mentoring.entity; + +public enum MentoringMethod { + ONLINE, + OFFLINE, + BOTH +} From d8474e77773b3a2684089f2a47e878b4bc3e57cd Mon Sep 17 00:00:00 2001 From: Hoyeon Lee <89958157+howooyeon@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:59:14 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=EB=A9=98=ED=86=A0=EB=A7=81=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EA=B2=80=EC=A6=9D=20=EA=B0=95=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20N+1=20=EC=BF=BC=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/mentoring/MentoringController.java | 3 ++- .../dto/request/MentoringApplyRequest.java | 2 ++ .../mentoring/repository/MentorRepository.java | 8 ++++++-- .../mentoring/service/MentoringService.java | 18 +++++++++++++----- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/kusitms/website/domain/mentoring/MentoringController.java b/src/main/java/com/kusitms/website/domain/mentoring/MentoringController.java index 54a2c84..e0c8e3c 100644 --- a/src/main/java/com/kusitms/website/domain/mentoring/MentoringController.java +++ b/src/main/java/com/kusitms/website/domain/mentoring/MentoringController.java @@ -9,6 +9,7 @@ import com.kusitms.website.domain.mentoring.service.MentoringService; import com.kusitms.website.global.auth.UserPrincipal; import com.kusitms.website.global.common.BaseResponse; +import javax.validation.Valid; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -80,7 +81,7 @@ public ResponseEntity> getMentorReview }) public ResponseEntity applyMentoring( @PathVariable Long mentorId, - @RequestBody MentoringApplyRequest request) { + @Valid @RequestBody MentoringApplyRequest request) { Long userId = getAuthenticatedUserId(); mentoringService.applyMentoring(mentorId, userId, request); return ResponseEntity.ok(new BaseResponse()); diff --git a/src/main/java/com/kusitms/website/domain/mentoring/dto/request/MentoringApplyRequest.java b/src/main/java/com/kusitms/website/domain/mentoring/dto/request/MentoringApplyRequest.java index 85fc1cb..e08410a 100644 --- a/src/main/java/com/kusitms/website/domain/mentoring/dto/request/MentoringApplyRequest.java +++ b/src/main/java/com/kusitms/website/domain/mentoring/dto/request/MentoringApplyRequest.java @@ -4,12 +4,14 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import javax.validation.constraints.NotNull; import javax.validation.constraints.Size; @Getter @NoArgsConstructor public class MentoringApplyRequest { + @NotNull @Schema(description = "슬롯 ID", required = true) private Long slotId; diff --git a/src/main/java/com/kusitms/website/domain/mentoring/repository/MentorRepository.java b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentorRepository.java index 6721b35..c9956e8 100644 --- a/src/main/java/com/kusitms/website/domain/mentoring/repository/MentorRepository.java +++ b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentorRepository.java @@ -7,14 +7,18 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + import java.util.List; public interface MentorRepository extends JpaRepository { - @Query("SELECT m FROM Mentor m WHERE m.active = true ORDER BY FUNCTION('RANDOM')") + @Query("SELECT m FROM Mentor m JOIN FETCH m.member WHERE m.active = true ORDER BY FUNCTION('RANDOM')") List findRandomActiveMentors(Pageable pageable); + @Query("SELECT m FROM Mentor m JOIN FETCH m.member WHERE m.active = true ORDER BY m.createdAt DESC") Page findByActiveTrueOrderByCreatedAtDesc(Pageable pageable); - Page findByActiveTrueAndCategoryOrderByCreatedAtDesc(MentoringCategory category, Pageable pageable); + @Query("SELECT m FROM Mentor m JOIN FETCH m.member WHERE m.active = true AND m.category = :category ORDER BY m.createdAt DESC") + Page findByActiveTrueAndCategoryOrderByCreatedAtDesc(@Param("category") MentoringCategory category, Pageable pageable); } diff --git a/src/main/java/com/kusitms/website/domain/mentoring/service/MentoringService.java b/src/main/java/com/kusitms/website/domain/mentoring/service/MentoringService.java index 20198c0..0244e91 100644 --- a/src/main/java/com/kusitms/website/domain/mentoring/service/MentoringService.java +++ b/src/main/java/com/kusitms/website/domain/mentoring/service/MentoringService.java @@ -103,13 +103,12 @@ public void applyMentoring(Long mentorId, Long applicantUserId, MentoringApplyRe Mentor mentor = mentorRepository.findById(mentorId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 멘토입니다.")); - if (mentor.getMember().getUserId().equals(applicantUserId)) { - throw new IllegalArgumentException("본인 멘토링에는 신청할 수 없습니다."); + if (!mentor.isActive()) { + throw new IllegalArgumentException("현재 활동 중이지 않은 멘토입니다."); } - if (applicationRepository.existsByMentorIdAndApplicantIdAndStatusIn( - mentorId, applicantUserId, OCCUPYING_STATUSES)) { - throw new IllegalArgumentException("이미 진행 중인 신청이 존재합니다."); + if (mentor.getMember().getUserId().equals(applicantUserId)) { + throw new IllegalArgumentException("본인 멘토링에는 신청할 수 없습니다."); } MentoringSlot slot = slotRepository.findByIdWithLock(request.getSlotId()) @@ -119,6 +118,15 @@ public void applyMentoring(Long mentorId, Long applicantUserId, MentoringApplyRe throw new IllegalArgumentException("해당 멘토의 슬롯이 아닙니다."); } + if (slot.getDate().isBefore(LocalDate.now())) { + throw new IllegalArgumentException("이미 지난 날짜의 슬롯에는 신청할 수 없습니다."); + } + + if (applicationRepository.existsByMentorIdAndApplicantIdAndStatusIn( + mentorId, applicantUserId, OCCUPYING_STATUSES)) { + throw new IllegalArgumentException("이미 진행 중인 신청이 존재합니다."); + } + if (applicationRepository.existsBySlotSlotIdAndApplicantUserIdAndStatusIn( slot.getSlotId(), applicantUserId, OCCUPYING_STATUSES)) { throw new IllegalArgumentException("동일 슬롯에 중복 신청할 수 없습니다."); From 8d56f2a90dbba6fb9b55bb3e9fad74f64adda4ee Mon Sep 17 00:00:00 2001 From: Hoyeon Lee <89958157+howooyeon@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:06:22 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=EB=A9=98=ED=86=A0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20N+1=20=EA=B0=9C=EC=84=A0=20=EC=8B=9C=20count=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/mentoring/repository/MentorRepository.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/kusitms/website/domain/mentoring/repository/MentorRepository.java b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentorRepository.java index c9956e8..55a6fb1 100644 --- a/src/main/java/com/kusitms/website/domain/mentoring/repository/MentorRepository.java +++ b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentorRepository.java @@ -4,11 +4,10 @@ import com.kusitms.website.domain.mentoring.entity.MentoringCategory; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - import java.util.List; public interface MentorRepository extends JpaRepository { @@ -16,9 +15,9 @@ public interface MentorRepository extends JpaRepository { @Query("SELECT m FROM Mentor m JOIN FETCH m.member WHERE m.active = true ORDER BY FUNCTION('RANDOM')") List findRandomActiveMentors(Pageable pageable); - @Query("SELECT m FROM Mentor m JOIN FETCH m.member WHERE m.active = true ORDER BY m.createdAt DESC") + @EntityGraph(attributePaths = "member") Page findByActiveTrueOrderByCreatedAtDesc(Pageable pageable); - @Query("SELECT m FROM Mentor m JOIN FETCH m.member WHERE m.active = true AND m.category = :category ORDER BY m.createdAt DESC") - Page findByActiveTrueAndCategoryOrderByCreatedAtDesc(@Param("category") MentoringCategory category, Pageable pageable); + @EntityGraph(attributePaths = "member") + Page findByActiveTrueAndCategoryOrderByCreatedAtDesc(MentoringCategory category, Pageable pageable); }