diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/controller/MentorRoadmapController.java b/back/src/main/java/com/back/domain/roadmap/roadmap/controller/MentorRoadmapController.java index 7606eb2b..3109e872 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/controller/MentorRoadmapController.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/controller/MentorRoadmapController.java @@ -1,25 +1,27 @@ package com.back.domain.roadmap.roadmap.controller; -import com.back.domain.member.member.entity.Member; +import com.back.domain.member.member.service.MemberStorage; +import com.back.domain.member.mentor.entity.Mentor; import com.back.domain.roadmap.roadmap.dto.request.MentorRoadmapSaveRequest; import com.back.domain.roadmap.roadmap.dto.response.MentorRoadmapSaveResponse; import com.back.domain.roadmap.roadmap.dto.response.MentorRoadmapResponse; import com.back.domain.roadmap.roadmap.service.MentorRoadmapService; -import com.back.global.exception.ServiceException; import com.back.global.rq.Rq; import com.back.global.rsData.RsData; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/mentor-roadmaps") @RequiredArgsConstructor -@Tag(name = "MentorRoadmap", description = "멘토 로드맵 관리 API") +@Tag(name = "MentorRoadmapController", description = "멘토 로드맵 API") public class MentorRoadmapController { private final MentorRoadmapService mentorRoadmapService; + private final MemberStorage memberStorage; private final Rq rq; @Operation( @@ -35,15 +37,16 @@ public class MentorRoadmapController { 사용 시나리오: 1. TaskController로 Task 검색 2. Task 선택 시 TaskId와 TaskName 획득 - 3. Task 없는 경우 TaskId null, TaskName 직접 입력 - 4. 노드 설명과 입력 + 3. Task 없는 경우 TaskId는 null, TaskName 직접 입력 + 4. description(Task에 대한 멘토의 경험, 조언, 학습 방법 등) 입력 """ ) @PostMapping + @PreAuthorize("hasRole('MENTOR')") public RsData create(@Valid @RequestBody MentorRoadmapSaveRequest request) { - Member member = validateMentorAuth(); + Mentor mentor = memberStorage.findMentorByMember(rq.getActor()); - MentorRoadmapSaveResponse response = mentorRoadmapService.create(member.getId(), request); + MentorRoadmapSaveResponse response = mentorRoadmapService.create(mentor.getId(), request); return new RsData<>( "201", @@ -53,9 +56,10 @@ public RsData create(@Valid @RequestBody MentorRoadma } @Operation( - summary = "멘토 로드맵 상세 조회", + summary = "멘토 로드맵 상세 조회 (로드맵 ID)", description = """ 로드맵 ID로 멘토 로드맵 상세 정보를 조회합니다. + 로그인한 사용자만 조회할 수 있습니다. 반환 정보: - 로드맵 기본 정보 (로드맵 ID, 멘토 ID, 제목, 설명, 생성일, 수정일 등) @@ -63,7 +67,8 @@ public RsData create(@Valid @RequestBody MentorRoadma """ ) @GetMapping("/{id}") - public RsData getByMentorId(@PathVariable Long id) { + @PreAuthorize("isAuthenticated()") + public RsData getById(@PathVariable Long id) { MentorRoadmapResponse response = mentorRoadmapService.getById(id); return new RsData<>( @@ -73,12 +78,38 @@ public RsData getByMentorId(@PathVariable Long id) { ); } + @Operation( + summary = "멘토 로드맵 상세 조회 (멘토 ID)", + description = """ + 멘토 ID로 해당 멘토의 로드맵 상세 정보를 조회합니다. + 로그인한 사용자만 조회할 수 있습니다. + + 반환 정보: + - 로드맵 기본 정보 (로드맵 ID, 멘토 ID, 제목, 설명, 생성일, 수정일 등) + - 모든 노드 정보 (stepOrder 순으로 정렬) + + 주의: 멘토가 로드맵을 생성하지 않았다면 404 에러가 발생합니다. + """ + ) + @GetMapping("/mentor/{mentorId}") + @PreAuthorize("isAuthenticated()") + public RsData getByMentorId(@PathVariable Long mentorId) { + MentorRoadmapResponse response = mentorRoadmapService.getByMentorId(mentorId); + + return new RsData<>( + "200", + "멘토 로드맵 조회 성공", + response + ); + } + @Operation(summary = "멘토 로드맵 수정", description = "로드맵 ID로 로드맵을 찾아 수정합니다. 본인이 생성한 로드맵만 수정할 수 있습니다.") @PutMapping("/{id}") + @PreAuthorize("hasRole('MENTOR')") public RsData update(@PathVariable Long id, @Valid @RequestBody MentorRoadmapSaveRequest request) { - Member member = validateMentorAuth(); + Mentor mentor = memberStorage.findMentorByMember(rq.getActor()); - MentorRoadmapSaveResponse response = mentorRoadmapService.update(id, member.getId(), request); + MentorRoadmapSaveResponse response = mentorRoadmapService.update(id, mentor.getId(), request); return new RsData<>( "200", @@ -89,11 +120,11 @@ public RsData update(@PathVariable Long id, @Valid @R @Operation(summary = "멘토 로드맵 삭제", description = "로드맵 ID로 로드맵을 삭제합니다. 본인이 생성한 로드맵만 삭제할 수 있습니다.") @DeleteMapping("/{id}") - public RsData delete( @PathVariable Long id) { + @PreAuthorize("hasRole('MENTOR')") + public RsData delete(@PathVariable Long id) { + Mentor mentor = memberStorage.findMentorByMember(rq.getActor()); - Member member = validateMentorAuth(); - - mentorRoadmapService.delete(id, member.getId()); + mentorRoadmapService.delete(id, mentor.getId()); return new RsData<>( "200", @@ -102,15 +133,4 @@ public RsData delete( @PathVariable Long id) { ); } - // 멘토 권한 검증 - private Member validateMentorAuth() { - Member member = rq.getActor(); - if (member == null) { - throw new ServiceException("401", "로그인이 필요합니다."); - } - if (member.getRole() != Member.Role.MENTOR) { - throw new ServiceException("403", "멘토만 접근 가능합니다."); - } - return member; - } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/MentorRoadmapResponse.java b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/MentorRoadmapResponse.java index ea99b5a4..588138eb 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/MentorRoadmapResponse.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/MentorRoadmapResponse.java @@ -17,12 +17,13 @@ public record MentorRoadmapResponse( // 정적 팩터리 메서드 - MentorRoadmap로부터 Response DTO 생성 public static MentorRoadmapResponse from(MentorRoadmap mentorRoadmap) { List nodeResponses = mentorRoadmap.getNodes().stream() + .sorted((n1, n2) -> Integer.compare(n1.getStepOrder(), n2.getStepOrder())) // stepOrder로 정렬 .map(RoadmapNodeResponse::from) .toList(); return new MentorRoadmapResponse( mentorRoadmap.getId(), - mentorRoadmap.getMentorId(), + mentorRoadmap.getMentor().getId(), mentorRoadmap.getTitle(), mentorRoadmap.getDescription(), nodeResponses, diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/MentorRoadmapSaveResponse.java b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/MentorRoadmapSaveResponse.java index 63080e37..83f4192b 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/MentorRoadmapSaveResponse.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/MentorRoadmapSaveResponse.java @@ -2,7 +2,6 @@ import java.time.LocalDateTime; -// 순수 데이터 전송 객체 - 엔티티에 의존하지 않음 public record MentorRoadmapSaveResponse( Long id, Long mentorId, diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/RoadmapNodeResponse.java b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/RoadmapNodeResponse.java index 219b00bd..345174f4 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/RoadmapNodeResponse.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/RoadmapNodeResponse.java @@ -16,7 +16,7 @@ public static RoadmapNodeResponse from(RoadmapNode node) { return new RoadmapNodeResponse( node.getId(), node.getTask() != null ? node.getTask().getId() : null, - node.getTask() != null ? node.getTask().getName() : node.getRawTaskName(), + node.getTaskName(), // taskName 필드 직접 사용 (Task 엔티티 접근 불필요) node.getDescription(), node.getStepOrder(), node.getTask() != null diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/MentorRoadmap.java b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/MentorRoadmap.java index 5d3aa9fd..4fd29adc 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/MentorRoadmap.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/MentorRoadmap.java @@ -1,5 +1,6 @@ package com.back.domain.roadmap.roadmap.entity; +import com.back.domain.member.mentor.entity.Mentor; import com.back.global.jpa.BaseEntity; import jakarta.persistence.*; import lombok.Getter; @@ -20,8 +21,9 @@ public class MentorRoadmap extends BaseEntity { @Column(name = "description", columnDefinition = "TEXT") private String description; - @Column(name = "mentor_id", nullable = false) - private Long mentorId; // Mentor 엔티티 FK + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mentor_id", nullable = false) + private Mentor mentor; @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "roadmap_id") @@ -30,15 +32,18 @@ public class MentorRoadmap extends BaseEntity { private List nodes; - public MentorRoadmap(Long mentorId, String title, String description) { - this.mentorId = mentorId; + public MentorRoadmap(Mentor mentor, String title, String description) { + this.mentor = mentor; this.title = title; this.description = description; this.nodes = new ArrayList<>(); } public RoadmapNode getRootNode() { - return nodes.isEmpty() ? null : nodes.get(0); + return nodes.stream() + .filter(node -> node.getStepOrder() == 1) + .findFirst() + .orElse(null); } // 노드 추가 헬퍼 메서드 (이미 완전히 초기화된 노드 추가) diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java index 080b5dae..d7225976 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java @@ -3,6 +3,7 @@ import com.back.domain.roadmap.task.entity.Task; import com.back.global.jpa.BaseEntity; import jakarta.persistence.*; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -36,7 +37,7 @@ public class RoadmapNode extends BaseEntity { private int stepOrder = 0; @Column(name = "raw_task_name") - private String rawTaskName; // 원본 Task 입력값 + private String taskName; // Task 이름 표시값(DB에 없는 Task 입력시 입력값 그대로 출력) @Column(name = "description", columnDefinition = "TEXT") private String description; @@ -49,8 +50,10 @@ public enum RoadmapType { MENTOR, JOB } - public RoadmapNode(String rawTaskName, String description, Task task, int stepOrder, long roadmapId, RoadmapType roadmapType) { - this.rawTaskName = rawTaskName; + // Builder 패턴 적용된 생성자 + @Builder + public RoadmapNode(String taskName, String description, Task task, int stepOrder, long roadmapId, RoadmapType roadmapType) { + this.taskName = taskName; this.description = description; this.task = task; this.stepOrder = stepOrder; @@ -58,6 +61,7 @@ public RoadmapNode(String rawTaskName, String description, Task task, int stepOr this.roadmapType = roadmapType; } + public void addChild(RoadmapNode child) { if (child == null) { throw new IllegalArgumentException("자식 노드는 null일 수 없습니다."); diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/MentorRoadmapRepository.java b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/MentorRoadmapRepository.java index 36aa8282..0492ffd0 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/MentorRoadmapRepository.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/MentorRoadmapRepository.java @@ -1,5 +1,6 @@ package com.back.domain.roadmap.roadmap.repository; +import com.back.domain.member.mentor.entity.Mentor; import com.back.domain.roadmap.roadmap.entity.MentorRoadmap; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -9,24 +10,32 @@ public interface MentorRoadmapRepository extends JpaRepository { - @Query(""" + /** + * 로드맵 ID로 상세 조회 (노드 포함) + * 로드맵 ID 기반 상세 조회 API용 + */ + @Query(""" SELECT mr FROM MentorRoadmap mr LEFT JOIN FETCH mr.nodes n - LEFT JOIN FETCH n.task WHERE mr.id = :id """) - Optional findByIdWithNodes(@Param("id") Long id); + Optional findByIdWithNodes(@Param("id") Long id); + /** + * 멘토 ID로 상세 조회 (노드 포함) + * - 멘토 ID 기반 상세 조회 API용 + * - 멘토는 이미 WHERE 조건에서 활용되므로 별도 fetch 하지 않음 + */ @Query(""" SELECT mr FROM MentorRoadmap mr LEFT JOIN FETCH mr.nodes n - LEFT JOIN FETCH n.task - WHERE mr.mentorId = :mentorId + WHERE mr.mentor.id = :mentorId """) Optional findByMentorIdWithNodes(@Param("mentorId") Long mentorId); - // 기본 정보만 조회하는 메서드도 유지 - Optional findByMentorId(Long mentorId); - - boolean existsByMentorId(Long mentorId); + /** + * 멘토의 로드맵 존재 여부 확인 + * - 로드맵 생성 시 중복 체크용 + */ + boolean existsByMentor(Mentor mentor); } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/RoadmapNodeRepository.java b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/RoadmapNodeRepository.java deleted file mode 100644 index c69fdc9a..00000000 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/RoadmapNodeRepository.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.back.domain.roadmap.roadmap.repository; - -import com.back.domain.roadmap.roadmap.entity.RoadmapNode; -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 RoadmapNodeRepository extends JpaRepository { - // 멘토 로드맵의 모든 노드를 stepOrder 순으로 조회 (Task 정보 포함) - // 현재는 MentorRoadmap.nodes 양방향 연관관계를 사용하지만, 필요시 사용 가능 - @Query(""" - SELECT rn FROM RoadmapNode rn - LEFT JOIN FETCH rn.task t - WHERE rn.roadmapId = :roadmapId - AND rn.roadmapType = 'MENTOR' - ORDER BY rn.stepOrder ASC - """) - List findMentorRoadmapNodesWithTask(@Param("roadmapId") Long roadmapId); - - // 특정 로드맵의 노드 개수 조회 - @Query(""" - SELECT COUNT(rn) FROM RoadmapNode rn - WHERE rn.roadmapId = :roadmapId - AND rn.roadmapType = :roadmapType - """) - long countByRoadmapIdAndType(@Param("roadmapId") Long roadmapId, - @Param("roadmapType") RoadmapNode.RoadmapType roadmapType); - - // 특정 로드맵의 마지막 stepOrder 조회 (새 노드 추가 시 사용) - @Query(""" - SELECT MAX(rn.stepOrder) FROM RoadmapNode rn - WHERE rn.roadmapId = :roadmapId - AND rn.roadmapType = 'MENTOR' - """) - Integer findMaxStepOrderByMentorRoadmap(@Param("roadmapId") Long roadmapId); - - // 로드맵 삭제 시 관련 노드들 일괄 삭제 - void deleteByRoadmapIdAndRoadmapType(Long roadmapId, RoadmapNode.RoadmapType roadmapType); -} \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/MentorRoadmapService.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/MentorRoadmapService.java index ad6d2eda..8740282a 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/service/MentorRoadmapService.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/MentorRoadmapService.java @@ -1,5 +1,7 @@ package com.back.domain.roadmap.roadmap.service; +import com.back.domain.member.mentor.entity.Mentor; +import com.back.domain.member.mentor.repository.MentorRepository; import com.back.domain.roadmap.roadmap.dto.request.MentorRoadmapSaveRequest; import com.back.domain.roadmap.roadmap.dto.request.RoadmapNodeRequest; import com.back.domain.roadmap.roadmap.dto.response.MentorRoadmapSaveResponse; @@ -27,20 +29,25 @@ public class MentorRoadmapService { private final MentorRoadmapRepository mentorRoadmapRepository; private final TaskRepository taskRepository; + private final MentorRepository mentorRepository; // 멘토 로드맵 생성 @Transactional public MentorRoadmapSaveResponse create(Long mentorId, MentorRoadmapSaveRequest request) { + // 멘토 존재 확인 + Mentor mentor = mentorRepository.findById(mentorId) + .orElseThrow(() -> new ServiceException("404", "멘토를 찾을 수 없습니다.")); + // 멘토가 이미 로드맵을 가지고 있는지 확인 - if (mentorRoadmapRepository.existsByMentorId(mentorId)) { + if (mentorRoadmapRepository.existsByMentor(mentor)) { throw new ServiceException("409", "이미 로드맵이 존재합니다. 멘토는 하나의 로드맵만 생성할 수 있습니다."); } - // 공통 검증 + // 공통 검증(노드 개수, stepOrder 연속성) validateRequest(request); // MentorRoadmap 생성 및 저장 (로드맵 ID 확보) - MentorRoadmap mentorRoadmap = new MentorRoadmap(mentorId, request.title(), request.description()); + MentorRoadmap mentorRoadmap = new MentorRoadmap(mentor, request.title(), request.description()); mentorRoadmap = mentorRoadmapRepository.save(mentorRoadmap); // roadmapId를 포함한 노드 생성 및 추가 @@ -48,14 +55,14 @@ public MentorRoadmapSaveResponse create(Long mentorId, MentorRoadmapSaveRequest mentorRoadmap.addNodes(allNodes); // 최종 저장 (노드들 CASCADE INSERT) - mentorRoadmap = saveRoadmap(mentorRoadmap); + mentorRoadmap = mentorRoadmapRepository.save(mentorRoadmap); log.info("멘토 로드맵 생성 완료 - 멘토 ID: {}, 로드맵 ID: {}, 노드 수: {} (cascade 활용)", mentorId, mentorRoadmap.getId(), mentorRoadmap.getNodes().size()); return new MentorRoadmapSaveResponse( mentorRoadmap.getId(), - mentorRoadmap.getMentorId(), + mentorRoadmap.getMentor().getId(), mentorRoadmap.getTitle(), mentorRoadmap.getDescription(), mentorRoadmap.getNodes().size(), @@ -63,7 +70,7 @@ public MentorRoadmapSaveResponse create(Long mentorId, MentorRoadmapSaveRequest ); } - // 멘토 ID로 멘토 로드맵 상세 조회 + // 로드맵 ID로 멘토 로드맵 상세 조회 @Transactional(readOnly = true) public MentorRoadmapResponse getById(Long id) { // 로드맵과 노드들을 한 번에 조회 (성능 최적화) @@ -73,6 +80,16 @@ public MentorRoadmapResponse getById(Long id) { return MentorRoadmapResponse.from(mentorRoadmap); } + // 멘토 ID로 멘토 로드맵 상세 조회 (미래 API 확장성 대비) + @Transactional(readOnly = true) + public MentorRoadmapResponse getByMentorId(Long mentorId) { + // 멘토 ID로 로드맵과 노드들을 한 번에 조회 (성능 최적화) + MentorRoadmap mentorRoadmap = mentorRoadmapRepository.findByMentorIdWithNodes(mentorId) + .orElseThrow(() -> new ServiceException("404", "해당 멘토의 로드맵을 찾을 수 없습니다.")); + + return MentorRoadmapResponse.from(mentorRoadmap); + } + // 멘토 로드맵 수정 @Transactional public MentorRoadmapSaveResponse update(Long id, Long mentorId, MentorRoadmapSaveRequest request) { @@ -81,7 +98,7 @@ public MentorRoadmapSaveResponse update(Long id, Long mentorId, MentorRoadmapSav .orElseThrow(() -> new ServiceException("404", "로드맵을 찾을 수 없습니다.")); // 권한 확인 - 본인의 로드맵만 수정 가능 - if (!mentorRoadmap.getMentorId().equals(mentorId)) { + if (!mentorRoadmap.getMentor().getId().equals(mentorId)) { throw new ServiceException("403", "본인의 로드맵만 수정할 수 있습니다."); } @@ -98,14 +115,14 @@ public MentorRoadmapSaveResponse update(Long id, Long mentorId, MentorRoadmapSav mentorRoadmap.addNodes(allNodes); // 최종 저장 (노드들 CASCADE INSERT) - mentorRoadmap = saveRoadmap(mentorRoadmap); + mentorRoadmap = mentorRoadmapRepository.save(mentorRoadmap); log.info("멘토 로드맵 수정 완료 - 로드맵 ID: {}, 노드 수: {} (cascade 활용)", mentorRoadmap.getId(), mentorRoadmap.getNodes().size()); return new MentorRoadmapSaveResponse( mentorRoadmap.getId(), - mentorRoadmap.getMentorId(), + mentorRoadmap.getMentor().getId(), mentorRoadmap.getTitle(), mentorRoadmap.getDescription(), mentorRoadmap.getNodes().size(), @@ -120,7 +137,7 @@ public void delete(Long roadmapId, Long mentorId) { .orElseThrow(() -> new ServiceException("404", "로드맵을 찾을 수 없습니다.")); // 권한 확인 - if (!mentorRoadmap.getMentorId().equals(mentorId)) { + if (!mentorRoadmap.getMentor().getId().equals(mentorId)) { throw new ServiceException("403", "본인의 로드맵만 삭제할 수 있습니다."); } @@ -135,15 +152,37 @@ private void validateRequest(MentorRoadmapSaveRequest request) { if (request.nodes().isEmpty()) { throw new ServiceException("400", "로드맵은 적어도 하나 이상의 노드를 포함해야 합니다."); } + validateStepOrderSequence(request.nodes()); } - // 로드맵 저장 (노드들도 cascade로 함께 저장) - private MentorRoadmap saveRoadmap(MentorRoadmap mentorRoadmap) { - // 노드에 roadmapId가 이미 설정된 상태로 한 번만 저장 - return mentorRoadmapRepository.save(mentorRoadmap); + // stepOrder 연속성 검증 (멘토 로드맵은 선형 구조) + private void validateStepOrderSequence(List nodes) { + List stepOrders = nodes.stream() + .map(RoadmapNodeRequest::stepOrder) + .toList(); + + // 중복 검증 먼저 수행 + long distinctCount = stepOrders.stream().distinct().count(); + if (distinctCount != stepOrders.size()) { + throw new ServiceException("400", "stepOrder에 중복된 값이 있습니다."); + } + + // 정렬 후 연속성 검증 + List sortedStepOrders = stepOrders.stream().sorted().toList(); + + // 1부터 시작하는 연속된 숫자인지 검증 + for (int i = 0; i < sortedStepOrders.size(); i++) { + int expectedOrder = i + 1; + if (!sortedStepOrders.get(i).equals(expectedOrder)) { + throw new ServiceException("400", + String.format("stepOrder는 1부터 시작하는 연속된 숫자여야 합니다. 현재: %s, 기대값: %d", + sortedStepOrders, expectedOrder)); + } + } } + // Task 유효성 검증 후 RoadmapNode 리스트 생성 (roadmapId 포함) private List createValidatedNodesWithRoadmapId(List nodeRequests, Long roadmapId) { // Task 유효성 검증 + 캐싱 (중복 조회 방지) @@ -199,7 +238,14 @@ private List createAllNodesWithRoadmapId( Task task = nodeRequest.taskId() != null ? tasksMap.get(nodeRequest.taskId()) : null; String taskName = (task != null) ? task.getName() : nodeRequest.taskName(); - RoadmapNode node = new RoadmapNode(taskName, nodeRequest.description(), task, nodeRequest.stepOrder(), roadmapId, RoadmapNode.RoadmapType.MENTOR); + RoadmapNode node = RoadmapNode.builder() + .taskName(taskName) + .description(nodeRequest.description()) + .task(task) + .stepOrder(nodeRequest.stepOrder()) + .roadmapId(roadmapId) + .roadmapType(RoadmapNode.RoadmapType.MENTOR) + .build(); nodes.add(node); } diff --git a/back/src/test/java/com/back/domain/roadmap/roadmap/service/MentorRoadmapServiceTest.java b/back/src/test/java/com/back/domain/roadmap/roadmap/service/MentorRoadmapServiceTest.java index 9b96d35b..2e66c6dc 100644 --- a/back/src/test/java/com/back/domain/roadmap/roadmap/service/MentorRoadmapServiceTest.java +++ b/back/src/test/java/com/back/domain/roadmap/roadmap/service/MentorRoadmapServiceTest.java @@ -303,6 +303,114 @@ void t12() { assertThat(retrieved.nodes().get(1).taskName()).isEqualTo("직접 입력 노드"); } + @Test + @DisplayName("stepOrder 검증 실패 - 비연속 숫자") + void t13() { + // Given + MentorRoadmapSaveRequest request = new MentorRoadmapSaveRequest( + "잘못된 로드맵", "stepOrder가 비연속", + List.of( + new RoadmapNodeRequest(null, "Java", "1단계", 1), + new RoadmapNodeRequest(null, "Spring", "3단계", 3), // 2가 빠짐 + new RoadmapNodeRequest(null, "Database", "5단계", 5) // 4가 빠짐 + ) + ); + + // When & Then + assertThatThrownBy(() -> mentorRoadmapService.create(mentorId, request)) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("stepOrder는 1부터 시작하는 연속된 숫자여야 합니다"); + } + + @Test + @DisplayName("stepOrder 검증 실패 - 중복된 값") + void t14() { + // Given + MentorRoadmapSaveRequest request = new MentorRoadmapSaveRequest( + "잘못된 로드맵", "stepOrder가 중복", + List.of( + new RoadmapNodeRequest(null, "Java", "1단계", 1), + new RoadmapNodeRequest(null, "Spring", "중복 1단계", 1), // 중복 + new RoadmapNodeRequest(null, "Database", "2단계", 2) + ) + ); + + // When & Then + assertThatThrownBy(() -> mentorRoadmapService.create(mentorId, request)) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("stepOrder에 중복된 값이 있습니다"); + } + + @Test + @DisplayName("stepOrder 검증 실패 - 2부터 시작하는 연속 숫자") + void t15() { + // Given - 1부터 시작하지 않는 경우 + MentorRoadmapSaveRequest request = new MentorRoadmapSaveRequest( + "잘못된 로드맵", "stepOrder가 2부터 시작", + List.of( + new RoadmapNodeRequest(null, "Java", "2단계", 2), // 2부터 시작 + new RoadmapNodeRequest(null, "Spring", "3단계", 3) + ) + ); + + // When & Then + assertThatThrownBy(() -> mentorRoadmapService.create(mentorId, request)) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("stepOrder는 1부터 시작하는 연속된 숫자여야 합니다"); + } + + @Test + @DisplayName("stepOrder 검증 성공 - 순서 무관한 입력") + void t16() { + // Given - 입력 순서와 stepOrder가 다름 (정렬 후 검증) + MentorRoadmapSaveRequest request = new MentorRoadmapSaveRequest( + "순서 무관 로드맵", "입력 순서와 stepOrder가 달라도 성공", + List.of( + new RoadmapNodeRequest(null, "Database", "3단계", 3), + new RoadmapNodeRequest(null, "Java", "1단계", 1), + new RoadmapNodeRequest(null, "Spring", "2단계", 2) + ) + ); + + // When + MentorRoadmapSaveResponse response = mentorRoadmapService.create(mentorId, request); + + // Then + assertThat(response.nodeCount()).isEqualTo(3); + + MentorRoadmapResponse retrieved = mentorRoadmapService.getById(response.id()); + assertThat(retrieved.nodes()).hasSize(3); + // 결과는 stepOrder 순으로 정렬되어 반환 + assertThat(retrieved.nodes().get(0).stepOrder()).isEqualTo(1); + assertThat(retrieved.nodes().get(0).taskName()).isEqualTo("Java"); + assertThat(retrieved.nodes().get(1).stepOrder()).isEqualTo(2); + assertThat(retrieved.nodes().get(1).taskName()).isEqualTo("Spring"); + assertThat(retrieved.nodes().get(2).stepOrder()).isEqualTo(3); + assertThat(retrieved.nodes().get(2).taskName()).isEqualTo("Database"); + } + + @Test + @DisplayName("stepOrder 검증 - 수정 시에도 동일하게 적용") + void t17() { + // Given + MentorRoadmapSaveRequest originalRequest = createSampleRequest(); + MentorRoadmapSaveResponse created = mentorRoadmapService.create(mentorId, originalRequest); + + // 잘못된 stepOrder로 수정 시도 + MentorRoadmapSaveRequest updateRequest = new MentorRoadmapSaveRequest( + "수정된 로드맵", "잘못된 stepOrder", + List.of( + new RoadmapNodeRequest(null, "Java", "1단계", 1), + new RoadmapNodeRequest(null, "Spring", "3단계", 3) // 2가 빠짐 + ) + ); + + // When & Then + assertThatThrownBy(() -> mentorRoadmapService.update(created.id(), mentorId, updateRequest)) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("stepOrder는 1부터 시작하는 연속된 숫자여야 합니다"); + } + private MentorRoadmapSaveRequest createSampleRequest() { return new MentorRoadmapSaveRequest( "백엔드 개발자 로드맵",