Skip to content

Commit 8110143

Browse files
authored
Feat: 직업 로드맵 조회 api 구현 (#127)
* feat: 멘토 로드맵 수정 * refactor: 권한 검증 로직 변경 * chore: 주석 수정 * feat: 직업 로드맵 조회 api 구현 * fix: 실패하는 테스트 수정 * refactor: 직업 로드맵 엔티티에서 @Setter 제거, 생성자에 빌더 패턴 적용 * feat: JobRoadmapNodeStat 필드 추가 및 직업 ID로 멘토 로드맵 조회 메서드 추가 * refactor: Response dto 수정 * feat: 직업 로드맵 조회 api 테스트용 샘플 데이터 추가 * chore: initSampleJobRoadmap() 비활성화
1 parent 235400c commit 8110143

File tree

15 files changed

+1086
-27
lines changed

15 files changed

+1086
-27
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.back.domain.roadmap.roadmap.controller;
2+
3+
import com.back.domain.roadmap.roadmap.dto.response.JobRoadmapListResponse;
4+
import com.back.domain.roadmap.roadmap.dto.response.JobRoadmapPagingResponse;
5+
import com.back.domain.roadmap.roadmap.dto.response.JobRoadmapResponse;
6+
import com.back.domain.roadmap.roadmap.service.JobRoadmapService;
7+
import com.back.global.rsData.RsData;
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.tags.Tag;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.data.domain.Page;
12+
import org.springframework.web.bind.annotation.*;
13+
14+
@RestController
15+
@RequestMapping("/job-roadmaps")
16+
@RequiredArgsConstructor
17+
@Tag(name = "JobRoadmap Controller", description = "직업별 통합 로드맵 관련 API")
18+
public class JobRoadmapController {
19+
private final JobRoadmapService jobRoadmapService;
20+
21+
@GetMapping
22+
@Operation(
23+
summary = "직업 로드맵 다건 조회",
24+
description = "직업 로드맵 목록을 페이징과 키워드 검색으로 조회합니다."
25+
)
26+
public RsData<JobRoadmapPagingResponse> getJobRoadmaps(
27+
@RequestParam(defaultValue = "0") int page,
28+
@RequestParam(defaultValue = "10") int size,
29+
@RequestParam(required = false) String keyword
30+
) {
31+
Page<JobRoadmapListResponse> jobRoadmapPage = jobRoadmapService.getJobRoadmaps(keyword, page, size);
32+
JobRoadmapPagingResponse response = JobRoadmapPagingResponse.from(jobRoadmapPage);
33+
34+
return new RsData<>("200", "직업 로드맵 목록 조회 성공", response);
35+
}
36+
37+
@GetMapping("/{id}")
38+
@Operation(
39+
summary = "직업 로드맵 상세 조회",
40+
description = "특정 직업 로드맵의 상세 정보(직업 정보 + 모든 노드)를 조회합니다."
41+
)
42+
public RsData<JobRoadmapResponse> getJobRoadmapById(@PathVariable Long id) {
43+
JobRoadmapResponse roadmap = jobRoadmapService.getJobRoadmapById(id);
44+
return new RsData<>("200", "직업 로드맵 상세 조회 성공", roadmap);
45+
}
46+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.back.domain.roadmap.roadmap.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
5+
@Schema(description = "직업 로드맵 다건 조회 응답")
6+
public record JobRoadmapListResponse(
7+
@Schema(description = "로드맵 ID")
8+
Long id,
9+
10+
@Schema(description = "직업명")
11+
String jobName,
12+
13+
@Schema(description = "직업 설명 (150자 제한)")
14+
String jobDescription
15+
) {
16+
// 정적 팩토리 메서드
17+
public static JobRoadmapListResponse of(Long id, String jobName, String jobDescription) {
18+
return new JobRoadmapListResponse(id, jobName, truncateDescription(jobDescription));
19+
}
20+
21+
// description 자르기 로직 (150자 초과 시 "..." 추가)
22+
private static String truncateDescription(String description) {
23+
if (description == null) return null;
24+
return description.length() > 150 ? description.substring(0, 150) + "..." : description;
25+
}
26+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.back.domain.roadmap.roadmap.dto.response;
2+
3+
import com.back.domain.roadmap.roadmap.entity.RoadmapNode;
4+
import com.fasterxml.jackson.annotation.JsonInclude;
5+
6+
import java.util.List;
7+
8+
public record JobRoadmapNodeResponse(
9+
Long id,
10+
Long parentId, // 부모 노드 ID (null이면 루트 노드)
11+
List<Long> childIds, // 자식 노드 ID 목록 (프론트엔드 렌더링용)
12+
Long taskId, // Task와 연결된 경우의 표준 Task ID
13+
String taskName, // 표시용 Task 이름
14+
String description,
15+
int stepOrder,
16+
int level, // 트리 깊이 (0: 루트, 1: 1단계 자식...)
17+
boolean isLinkedToTask,
18+
Double weight, // 이 노드의 가중치 (JobRoadmapNodeStat에서)
19+
20+
@JsonInclude(JsonInclude.Include.NON_EMPTY)
21+
List<JobRoadmapNodeResponse> children
22+
) {
23+
24+
// 정적 팩토리 메서드 - RoadmapNode로부터 Response DTO 생성 (자식 노드 정보 포함)
25+
public static JobRoadmapNodeResponse from(RoadmapNode node, List<JobRoadmapNodeResponse> children) {
26+
List<Long> childIds = children != null ?
27+
children.stream().map(JobRoadmapNodeResponse::id).toList() :
28+
List.of();
29+
30+
return new JobRoadmapNodeResponse(
31+
node.getId(),
32+
node.getParent() != null ? node.getParent().getId() : null,
33+
childIds,
34+
node.getTask() != null ? node.getTask().getId() : null,
35+
node.getTask() != null ? node.getTask().getName() : node.getTaskName(),
36+
node.getDescription(),
37+
node.getStepOrder(),
38+
node.getLevel(),
39+
node.getTask() != null,
40+
null, // weight는 서비스에서 별도로 설정
41+
children != null ? children : List.of()
42+
);
43+
}
44+
45+
// 가중치 설정 헬퍼 메서드 (불변 객체이므로 새 인스턴스 반환)
46+
public JobRoadmapNodeResponse withWeight(Double weight) {
47+
return new JobRoadmapNodeResponse(
48+
this.id, this.parentId, this.childIds, this.taskId, this.taskName, this.description,
49+
this.stepOrder, this.level, this.isLinkedToTask, weight, this.children
50+
);
51+
}
52+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.back.domain.roadmap.roadmap.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import org.springframework.data.domain.Page;
5+
6+
import java.util.List;
7+
8+
@Schema(description = "직업 로드맵 페이징 조회 응답")
9+
public record JobRoadmapPagingResponse(
10+
@Schema(description = "직업 로드맵 목록")
11+
List<JobRoadmapListResponse> jobRoadmaps,
12+
13+
@Schema(description = "현재 페이지 (0부터 시작)")
14+
int currentPage,
15+
16+
@Schema(description = "총 페이지")
17+
int totalPage,
18+
19+
@Schema(description = "총 개수")
20+
long totalElements,
21+
22+
@Schema(description = "다음 페이지 존재 여부")
23+
boolean hasNext
24+
) {
25+
public static JobRoadmapPagingResponse from(Page<JobRoadmapListResponse> page) {
26+
return new JobRoadmapPagingResponse(
27+
page.getContent(),
28+
page.getNumber(),
29+
page.getTotalPages(),
30+
page.getTotalElements(),
31+
page.hasNext()
32+
);
33+
}
34+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package com.back.domain.roadmap.roadmap.dto.response;
2+
3+
import com.back.domain.roadmap.roadmap.entity.JobRoadmap;
4+
import com.back.domain.roadmap.roadmap.entity.RoadmapNode;
5+
6+
import java.time.LocalDateTime;
7+
import java.util.HashMap;
8+
import java.util.List;
9+
import java.util.Map;
10+
import java.util.stream.Collectors;
11+
12+
public record JobRoadmapResponse(
13+
Long id,
14+
Long jobId,
15+
String jobName,
16+
List<JobRoadmapNodeResponse> nodes,
17+
int totalNodeCount,
18+
LocalDateTime createdDate,
19+
LocalDateTime modifiedDate
20+
) {
21+
22+
// 정적 팩터리 메서드 - JobRoadmap과 Job 정보로부터 Response DTO 생성
23+
public static JobRoadmapResponse from(JobRoadmap jobRoadmap, String jobName) {
24+
// 부모-자식 관계 맵 생성
25+
Map<Long, List<RoadmapNode>> childrenMap = jobRoadmap.getNodes().stream()
26+
.filter(node -> node.getParent() != null)
27+
.collect(Collectors.groupingBy(node -> node.getParent().getId()));
28+
29+
// 노드를 재귀적으로 변환하는 함수
30+
Map<Long, JobRoadmapNodeResponse> nodeResponseMap = new HashMap<>();
31+
32+
// 모든 노드를 bottom-up 방식으로 변환 (자식부터 부모 순서)
33+
buildNodeResponses(jobRoadmap.getNodes(), childrenMap, nodeResponseMap);
34+
35+
// 루트 노드들만 반환 (자식 노드들은 children 필드에 포함되어 전체 트리 구조 제공)
36+
List<JobRoadmapNodeResponse> nodes = jobRoadmap.getNodes().stream()
37+
.filter(node -> node.getParent() == null)
38+
.map(node -> nodeResponseMap.get(node.getId()))
39+
.sorted((a, b) -> {
40+
int levelCompare = Integer.compare(a.level(), b.level());
41+
return levelCompare != 0 ? levelCompare : Integer.compare(a.stepOrder(), b.stepOrder());
42+
})
43+
.toList();
44+
45+
return new JobRoadmapResponse(
46+
jobRoadmap.getId(),
47+
jobRoadmap.getJob().getId(),
48+
jobName,
49+
nodes,
50+
jobRoadmap.getNodes().size(),
51+
jobRoadmap.getCreateDate(),
52+
jobRoadmap.getModifyDate()
53+
);
54+
}
55+
56+
// 노드 응답 객체들을 재귀적으로 구성하는 헬퍼 메서드
57+
private static void buildNodeResponses(
58+
List<RoadmapNode> allNodes,
59+
Map<Long, List<RoadmapNode>> childrenMap,
60+
Map<Long, JobRoadmapNodeResponse> nodeResponseMap) {
61+
62+
// 노드들을 level 역순으로 정렬 (깊은 노드부터 처리)
63+
List<RoadmapNode> sortedNodes = allNodes.stream()
64+
.sorted((a, b) -> Integer.compare(b.getLevel(), a.getLevel()))
65+
.toList();
66+
67+
for (RoadmapNode node : sortedNodes) {
68+
// 자식 노드들의 응답 객체 가져오기
69+
List<JobRoadmapNodeResponse> childResponses = childrenMap
70+
.getOrDefault(node.getId(), List.of())
71+
.stream()
72+
.map(child -> nodeResponseMap.get(child.getId()))
73+
.filter(response -> response != null)
74+
.sorted((a, b) -> Integer.compare(a.stepOrder(), b.stepOrder()))
75+
.toList();
76+
77+
// 현재 노드의 응답 객체 생성
78+
JobRoadmapNodeResponse nodeResponse = JobRoadmapNodeResponse.from(node, childResponses);
79+
nodeResponseMap.put(node.getId(), nodeResponse);
80+
}
81+
}
82+
}

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

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,35 @@
11
package com.back.domain.roadmap.roadmap.entity;
22

3+
import com.back.domain.job.job.entity.Job;
34
import com.back.global.jpa.BaseEntity;
45
import jakarta.persistence.*;
6+
import lombok.Builder;
57
import lombok.Getter;
68
import lombok.NoArgsConstructor;
7-
import lombok.Setter;
89
import org.hibernate.annotations.SQLRestriction;
910

1011
import java.util.ArrayList;
1112
import java.util.List;
1213

1314
@Entity
1415
@Table(name = "job_roadmap")
15-
@Getter @Setter
16+
@Getter
1617
@NoArgsConstructor
1718
public class JobRoadmap extends BaseEntity {
18-
@Column(name = "job_id", nullable = false)
19-
private Long jobId; // Job FK
19+
@ManyToOne(fetch = FetchType.LAZY)
20+
@JoinColumn(name = "job_id", nullable = false)
21+
private Job job;
2022

21-
@OneToMany(fetch = FetchType.LAZY)
22-
@JoinColumn(name = "roadmap_id") // RoadmapNode.roadmapId 참조
23+
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
24+
@JoinColumn(name = "roadmap_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
2325
@SQLRestriction("roadmap_type = 'JOB'")
24-
@OrderBy("stepOrder ASC")
26+
@OrderBy("level ASC, stepOrder ASC")
2527
private List<RoadmapNode> nodes;
2628

27-
public JobRoadmap(Long jobId) {
28-
this.jobId = jobId;
29-
this.nodes = new ArrayList<>();
29+
@Builder
30+
public JobRoadmap(Job job, List<RoadmapNode> nodes) {
31+
this.job = job;
32+
this.nodes = nodes != null ? nodes : new ArrayList<>();
3033
}
3134

3235
public RoadmapNode getRootNode() {

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

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,45 @@
22

33
import com.back.global.jpa.BaseEntity;
44
import jakarta.persistence.*;
5+
import lombok.Builder;
56
import lombok.Getter;
67
import lombok.NoArgsConstructor;
7-
import lombok.Setter;
88

99
@Entity
1010
@Table(name = "job_roadmap_node_stat")
11-
@Getter @Setter
11+
@Getter
1212
@NoArgsConstructor
1313
public class JobRoadmapNodeStat extends BaseEntity {
1414
@Column(name = "step_order")
1515
private Integer stepOrder;
1616

1717
@Column(name = "weight", nullable = false)
18-
private Double weight = 0.0;
18+
private Double weight;
1919

2020
@OneToOne(fetch = FetchType.LAZY)
2121
@JoinColumn(name = "node_id", nullable = false)
2222
private RoadmapNode node;
23+
24+
// ---- 추가 통계 필드 ----
25+
@Column(name = "average_position")
26+
private Double averagePosition; // 각 노드가 멘토 로드맵에서 평균적으로 위치한 인덱스(1..N)
27+
28+
@Column(name = "mentor_count")
29+
private Integer mentorCount; // 몇 명의 멘토 로드맵에 등장했는지 (unique mentor count)
30+
31+
@Column(name = "outgoing_transitions")
32+
private Integer outgoingTransitions; // 이 노드에서 다른 노드로 이동한 총 전이수
33+
34+
@Column(name = "incoming_transitions")
35+
private Integer incomingTransitions; // 타 노드에서 이 노드로 들어오는 전이수
36+
37+
@Column(name = "transition_counts", columnDefinition = "TEXT")
38+
private String transitionCounts; // (선택) JSON 직렬화: { "T:5":3, "T:7":1 } 형태로 보관 가능
39+
40+
@Builder
41+
public JobRoadmapNodeStat(Integer stepOrder, Double weight, RoadmapNode node) {
42+
this.stepOrder = stepOrder;
43+
this.weight = weight != null ? weight : 0.0;
44+
this.node = node;
45+
}
2346
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public class MentorRoadmap extends BaseEntity {
2626
private Mentor mentor;
2727

2828
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
29-
@JoinColumn(name = "roadmap_id")
29+
@JoinColumn(name = "roadmap_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
3030
@SQLRestriction("roadmap_type = 'MENTOR'")
3131
@OrderBy("stepOrder ASC")
3232
private List<RoadmapNode> nodes;

0 commit comments

Comments
 (0)