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..e0c8e3c --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/MentoringController.java @@ -0,0 +1,99 @@ +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 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; +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, + @Valid @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..e08410a --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/dto/request/MentoringApplyRequest.java @@ -0,0 +1,21 @@ +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.NotNull; +import javax.validation.constraints.Size; + +@Getter +@NoArgsConstructor +public class MentoringApplyRequest { + + @NotNull + @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/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/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/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/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/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/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/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/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/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/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 +} 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/MentorRepository.java b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentorRepository.java new file mode 100644 index 0000000..55a6fb1 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/repository/MentorRepository.java @@ -0,0 +1,23 @@ +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.EntityGraph; +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 JOIN FETCH m.member WHERE m.active = true ORDER BY FUNCTION('RANDOM')") + List findRandomActiveMentors(Pageable pageable); + + @EntityGraph(attributePaths = "member") + Page findByActiveTrueOrderByCreatedAtDesc(Pageable pageable); + + @EntityGraph(attributePaths = "member") + Page findByActiveTrueAndCategoryOrderByCreatedAtDesc(MentoringCategory category, Pageable pageable); +} 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/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 { +} 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); +} 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..0244e91 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/mentoring/service/MentoringService.java @@ -0,0 +1,193 @@ +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.isActive()) { + throw new IllegalArgumentException("현재 활동 중이지 않은 멘토입니다."); + } + + if (mentor.getMember().getUserId().equals(applicantUserId)) { + throw new IllegalArgumentException("본인 멘토링에는 신청할 수 없습니다."); + } + + MentoringSlot slot = slotRepository.findByIdWithLock(request.getSlotId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 슬롯입니다.")); + + if (!slot.getMentor().getMentorId().equals(mentorId)) { + 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("동일 슬롯에 중복 신청할 수 없습니다."); + } + + 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(); + } +}