Skip to content

Commit 566b195

Browse files
authored
[Feat] 멘토 로드맵 CRUD (#96)
* feat: 멘토 로드맵 생성,조회,삭제 구현 * refactor: 엔티티 수정에 따른 로직 수정 * feat: 멘토 로드맵 기능 구현 수정 * feat: 멘토 로드맵 수정 구현 * feat: 멘토로드맵 CRUD 테스트 작성
1 parent 79fa3b3 commit 566b195

File tree

13 files changed

+925
-18
lines changed

13 files changed

+925
-18
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package com.back.domain.roadmap.roadmap.controller;
2+
3+
import com.back.domain.member.member.entity.Member;
4+
import com.back.domain.roadmap.roadmap.dto.request.MentorRoadmapSaveRequest;
5+
import com.back.domain.roadmap.roadmap.dto.response.MentorRoadmapSaveResponse;
6+
import com.back.domain.roadmap.roadmap.dto.response.MentorRoadmapResponse;
7+
import com.back.domain.roadmap.roadmap.service.MentorRoadmapService;
8+
import com.back.global.exception.ServiceException;
9+
import com.back.global.rq.Rq;
10+
import com.back.global.rsData.RsData;
11+
import io.swagger.v3.oas.annotations.Operation;
12+
import io.swagger.v3.oas.annotations.tags.Tag;
13+
import jakarta.validation.Valid;
14+
import lombok.RequiredArgsConstructor;
15+
import org.springframework.web.bind.annotation.*;
16+
17+
@RestController
18+
@RequestMapping("/mentor-roadmaps")
19+
@RequiredArgsConstructor
20+
@Tag(name = "MentorRoadmap", description = "멘토 로드맵 관리 API")
21+
public class MentorRoadmapController {
22+
private final MentorRoadmapService mentorRoadmapService;
23+
private final Rq rq;
24+
25+
@Operation(
26+
summary = "멘토 로드맵 생성",
27+
description = """
28+
멘토가 자신의 커리어 로드맵을 생성합니다.
29+
- 멘토는 하나의 로드맵만 생성 가능
30+
- TaskId는 nullable (DB에 있는 Task 중 선택하게 하고, 원하는 Task 없는 경우 null 가능)
31+
- TaskName은 필수 (표시용 이름. DB에 있는 Task 선택시 해당 taskName으로 저장, 없는 경우 입력한 이름으로 저장)
32+
- stepOrder는 1부터 시작하는 연속된 숫자로, 로드맵 상 노드의 순서를 나타냄
33+
- 노드들은 stepOrder 순으로 자동 정렬(멘토 로드맵은 선형으로만 구성)
34+
35+
사용 시나리오:
36+
1. TaskController로 Task 검색
37+
2. Task 선택 시 TaskId와 TaskName 획득
38+
3. Task 없는 경우 TaskId null, TaskName 직접 입력
39+
4. 노드 설명과 입력
40+
"""
41+
)
42+
@PostMapping
43+
public RsData<MentorRoadmapSaveResponse> create(@Valid @RequestBody MentorRoadmapSaveRequest request) {
44+
Member member = validateMentorAuth();
45+
46+
MentorRoadmapSaveResponse response = mentorRoadmapService.create(member.getId(), request);
47+
48+
return new RsData<>(
49+
"201",
50+
"멘토 로드맵이 성공적으로 생성되었습니다.",
51+
response
52+
);
53+
}
54+
55+
@Operation(
56+
summary = "멘토 로드맵 상세 조회",
57+
description = """
58+
로드맵 ID로 멘토 로드맵 상세 정보를 조회합니다.
59+
60+
반환 정보:
61+
- 로드맵 기본 정보 (로드맵 ID, 멘토 ID, 제목, 설명, 생성일, 수정일 등)
62+
- 모든 노드 정보 (stepOrder 순으로 정렬)
63+
"""
64+
)
65+
@GetMapping("/{id}")
66+
public RsData<MentorRoadmapResponse> getByMentorId(@PathVariable Long id) {
67+
MentorRoadmapResponse response = mentorRoadmapService.getById(id);
68+
69+
return new RsData<>(
70+
"200",
71+
"멘토 로드맵 조회 성공",
72+
response
73+
);
74+
}
75+
76+
@Operation(summary = "멘토 로드맵 수정", description = "로드맵 ID로 로드맵을 찾아 수정합니다. 본인이 생성한 로드맵만 수정할 수 있습니다.")
77+
@PutMapping("/{id}")
78+
public RsData<MentorRoadmapSaveResponse> update(@PathVariable Long id, @Valid @RequestBody MentorRoadmapSaveRequest request) {
79+
Member member = validateMentorAuth();
80+
81+
MentorRoadmapSaveResponse response = mentorRoadmapService.update(id, member.getId(), request);
82+
83+
return new RsData<>(
84+
"200",
85+
"멘토 로드맵이 성공적으로 수정되었습니다.",
86+
response
87+
);
88+
}
89+
90+
@Operation(summary = "멘토 로드맵 삭제", description = "로드맵 ID로 로드맵을 삭제합니다. 본인이 생성한 로드맵만 삭제할 수 있습니다.")
91+
@DeleteMapping("/{id}")
92+
public RsData<Void> delete( @PathVariable Long id) {
93+
94+
Member member = validateMentorAuth();
95+
96+
mentorRoadmapService.delete(id, member.getId());
97+
98+
return new RsData<>(
99+
"200",
100+
"멘토 로드맵이 성공적으로 삭제되었습니다.",
101+
null
102+
);
103+
}
104+
105+
// 멘토 권한 검증
106+
private Member validateMentorAuth() {
107+
Member member = rq.getActor();
108+
if (member == null) {
109+
throw new ServiceException("401", "로그인이 필요합니다.");
110+
}
111+
if (member.getRole() != Member.Role.MENTOR) {
112+
throw new ServiceException("403", "멘토만 접근 가능합니다.");
113+
}
114+
return member;
115+
}
116+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.back.domain.roadmap.roadmap.dto.request;
2+
3+
import jakarta.validation.Valid;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.NotEmpty;
6+
import jakarta.validation.constraints.Size;
7+
8+
import java.util.List;
9+
10+
public record MentorRoadmapSaveRequest(
11+
@NotBlank(message = "로드맵 제목은 필수입니다.")
12+
@Size(max = 100, message = "로드맵 제목은 100자를 초과할 수 없습니다.")
13+
String title,
14+
15+
@Size(max = 1000, message = "로드맵 설명은 1000자를 초과할 수 없습니다.")
16+
String description,
17+
18+
@NotEmpty(message = "로드맵 노드는 최소 1개 이상 필요합니다.")
19+
@Valid
20+
List<RoadmapNodeRequest> nodes
21+
) {}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.back.domain.roadmap.roadmap.dto.request;
2+
3+
import jakarta.validation.constraints.Min;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Size;
6+
7+
public record RoadmapNodeRequest(
8+
Long taskId, // nullable - Task와 연결되지 않은 경우 null
9+
10+
@NotBlank(message = "Task 이름은 필수입니다.")
11+
@Size(max = 100, message = "Task 이름은 100자를 초과할 수 없습니다.")
12+
String taskName, // 표시용 Task 이름 (rawTaskName으로 저장)
13+
14+
@NotBlank(message = "노드 설명은 필수입니다.")
15+
@Size(max = 2000, message = "노드 설명은 2000자를 초과할 수 없습니다.")
16+
String description,
17+
18+
@Min(value = 1, message = "단계 순서는 1 이상이어야 합니다.")
19+
int stepOrder
20+
) {}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.back.domain.roadmap.roadmap.dto.response;
2+
3+
import com.back.domain.roadmap.roadmap.entity.MentorRoadmap;
4+
import java.time.LocalDateTime;
5+
import java.util.List;
6+
7+
public record MentorRoadmapResponse(
8+
Long id,
9+
Long mentorId,
10+
String title,
11+
String description,
12+
List<RoadmapNodeResponse> nodes,
13+
LocalDateTime createdDate,
14+
LocalDateTime modifiedDate
15+
) {
16+
17+
// 정적 팩터리 메서드 - MentorRoadmap로부터 Response DTO 생성
18+
public static MentorRoadmapResponse from(MentorRoadmap mentorRoadmap) {
19+
List<RoadmapNodeResponse> nodeResponses = mentorRoadmap.getNodes().stream()
20+
.map(RoadmapNodeResponse::from)
21+
.toList();
22+
23+
return new MentorRoadmapResponse(
24+
mentorRoadmap.getId(),
25+
mentorRoadmap.getMentorId(),
26+
mentorRoadmap.getTitle(),
27+
mentorRoadmap.getDescription(),
28+
nodeResponses,
29+
mentorRoadmap.getCreateDate(),
30+
mentorRoadmap.getModifyDate()
31+
);
32+
}
33+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.back.domain.roadmap.roadmap.dto.response;
2+
3+
import java.time.LocalDateTime;
4+
5+
// 순수 데이터 전송 객체 - 엔티티에 의존하지 않음
6+
public record MentorRoadmapSaveResponse(
7+
Long id,
8+
Long mentorId,
9+
String title,
10+
String description,
11+
int nodeCount,
12+
LocalDateTime createDate
13+
) {}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.back.domain.roadmap.roadmap.dto.response;
2+
3+
import com.back.domain.roadmap.roadmap.entity.RoadmapNode;
4+
5+
public record RoadmapNodeResponse(
6+
Long id,
7+
Long taskId, // Task와 연결된 경우의 표준 Task ID
8+
String taskName, // 표시용 Task 이름(Task와 연결된 경우 해당 Task 이름, 자유 입력시 입력값)
9+
String description,
10+
int stepOrder,
11+
boolean isLinkedToTask // Task와 연결 여부
12+
) {
13+
14+
// 정적 팩터리 메서드 - RoadmapNode로부터 Response DTO 생성
15+
public static RoadmapNodeResponse from(RoadmapNode node) {
16+
return new RoadmapNodeResponse(
17+
node.getId(),
18+
node.getTask() != null ? node.getTask().getId() : null,
19+
node.getTask() != null ? node.getTask().getName() : node.getRawTaskName(),
20+
node.getDescription(),
21+
node.getStepOrder(),
22+
node.getTask() != null
23+
);
24+
}
25+
}

back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmap.java

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
import lombok.Getter;
66
import lombok.NoArgsConstructor;
77
import lombok.Setter;
8+
import org.hibernate.annotations.SQLRestriction;
9+
10+
import java.util.ArrayList;
11+
import java.util.List;
812

913
@Entity
1014
@Table(name = "job_roadmap")
@@ -14,12 +18,19 @@ public class JobRoadmap extends BaseEntity {
1418
@Column(name = "job_id", nullable = false)
1519
private Long jobId; // Job FK
1620

17-
@OneToOne(fetch = FetchType.LAZY)
18-
@JoinColumn(name = "root_node_id", nullable = false)
19-
private RoadmapNode rootNode;
21+
@OneToMany(fetch = FetchType.LAZY)
22+
@JoinColumn(name = "roadmap_id") // RoadmapNode.roadmapId 참조
23+
@SQLRestriction("roadmap_type = 'JOB'")
24+
@OrderBy("stepOrder ASC")
25+
private List<RoadmapNode> nodes;
2026

21-
public JobRoadmap(Long jobId, RoadmapNode rootNode) {
27+
public JobRoadmap(Long jobId) {
2228
this.jobId = jobId;
23-
this.rootNode = rootNode;
29+
this.nodes = new ArrayList<>();
30+
}
31+
32+
public RoadmapNode getRootNode() {
33+
return nodes.isEmpty() ? null : nodes.get(0);
2434
}
35+
2536
}

back/src/main/java/com/back/domain/roadmap/roadmap/entity/MentorRoadmap.java

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44
import jakarta.persistence.*;
55
import lombok.Getter;
66
import lombok.NoArgsConstructor;
7-
import lombok.Setter;
7+
import org.hibernate.annotations.SQLRestriction;
8+
9+
import java.util.ArrayList;
10+
import java.util.List;
811

912
@Entity
1013
@Table(name = "mentor_roadmap")
11-
@Getter @Setter
14+
@Getter
1215
@NoArgsConstructor
1316
public class MentorRoadmap extends BaseEntity {
1417
@Column(name = "title", nullable = false)
@@ -20,14 +23,53 @@ public class MentorRoadmap extends BaseEntity {
2023
@Column(name = "mentor_id", nullable = false)
2124
private Long mentorId; // Mentor 엔티티 FK
2225

23-
@OneToOne(fetch = FetchType.LAZY)
24-
@JoinColumn(name = "root_node_id", nullable = false)
25-
private RoadmapNode rootNode;
26+
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
27+
@JoinColumn(name = "roadmap_id")
28+
@SQLRestriction("roadmap_type = 'MENTOR'")
29+
@OrderBy("stepOrder ASC")
30+
private List<RoadmapNode> nodes;
31+
2632

27-
public MentorRoadmap(Long mentorId, String title, String description, RoadmapNode rootNode) {
33+
public MentorRoadmap(Long mentorId, String title, String description) {
2834
this.mentorId = mentorId;
2935
this.title = title;
3036
this.description = description;
31-
this.rootNode = rootNode;
37+
this.nodes = new ArrayList<>();
38+
}
39+
40+
public RoadmapNode getRootNode() {
41+
return nodes.isEmpty() ? null : nodes.get(0);
42+
}
43+
44+
// 노드 추가 헬퍼 메서드 (이미 완전히 초기화된 노드 추가)
45+
public void addNode(RoadmapNode node) {
46+
if (node == null) {
47+
throw new IllegalArgumentException("추가할 노드는 null일 수 없습니다.");
48+
}
49+
// 노드는 이미 생성자에서 완전히 초기화되어 전달됨
50+
this.nodes.add(node);
51+
}
52+
53+
// 여러 노드 일괄 추가
54+
public void addNodes(List<RoadmapNode> nodes) {
55+
nodes.forEach(this::addNode);
56+
}
57+
58+
59+
// 제목 수정 (비즈니스 로직)
60+
public void updateTitle(String newTitle) {
61+
if (newTitle == null || newTitle.trim().isEmpty()) {
62+
throw new IllegalArgumentException("로드맵 제목은 필수입니다.");
63+
}
64+
this.title = newTitle.trim();
65+
}
66+
67+
// 설명 수정 (비즈니스 로직)
68+
public void updateDescription(String newDescription) {
69+
this.description = newDescription;
70+
}
71+
72+
public void clearNodes() {
73+
this.nodes.clear();
3274
}
3375
}

0 commit comments

Comments
 (0)