Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.back.domain.roadmap.roadmap.controller;

import com.back.domain.roadmap.roadmap.dto.response.JobRoadmapListResponse;
import com.back.domain.roadmap.roadmap.dto.response.JobRoadmapPagingResponse;
import com.back.domain.roadmap.roadmap.dto.response.JobRoadmapResponse;
import com.back.domain.roadmap.roadmap.service.JobRoadmapService;
import com.back.global.rsData.RsData;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/job-roadmaps")
@RequiredArgsConstructor
@Tag(name = "JobRoadmap Controller", description = "직업별 통합 로드맵 관련 API")
public class JobRoadmapController {
private final JobRoadmapService jobRoadmapService;

@GetMapping
@Operation(
summary = "직업 로드맵 다건 조회",
description = "직업 로드맵 목록을 페이징과 키워드 검색으로 조회합니다."
)
public RsData<JobRoadmapPagingResponse> getJobRoadmaps(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword
) {
Page<JobRoadmapListResponse> jobRoadmapPage = jobRoadmapService.getJobRoadmaps(keyword, page, size);
JobRoadmapPagingResponse response = JobRoadmapPagingResponse.from(jobRoadmapPage);

return new RsData<>("200", "직업 로드맵 목록 조회 성공", response);
}

@GetMapping("/{id}")
@Operation(
summary = "직업 로드맵 상세 조회",
description = "특정 직업 로드맵의 상세 정보(직업 정보 + 모든 노드)를 조회합니다."
)
public RsData<JobRoadmapResponse> getJobRoadmapById(@PathVariable Long id) {
JobRoadmapResponse roadmap = jobRoadmapService.getJobRoadmapById(id);
return new RsData<>("200", "직업 로드맵 상세 조회 성공", roadmap);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.back.domain.roadmap.roadmap.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "직업 로드맵 다건 조회 응답")
public record JobRoadmapListResponse(
@Schema(description = "로드맵 ID")
Long id,

@Schema(description = "직업명")
String jobName,

@Schema(description = "직업 설명 (150자 제한)")
String jobDescription
) {
// 정적 팩토리 메서드
public static JobRoadmapListResponse of(Long id, String jobName, String jobDescription) {
return new JobRoadmapListResponse(id, jobName, truncateDescription(jobDescription));
}

// description 자르기 로직 (150자 초과 시 "..." 추가)
private static String truncateDescription(String description) {
if (description == null) return null;
return description.length() > 150 ? description.substring(0, 150) + "..." : description;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.back.domain.roadmap.roadmap.dto.response;

import com.back.domain.roadmap.roadmap.entity.RoadmapNode;
import com.fasterxml.jackson.annotation.JsonInclude;

import java.util.List;

public record JobRoadmapNodeResponse(
Long id,
Long parentId, // 부모 노드 ID (null이면 루트 노드)
List<Long> childIds, // 자식 노드 ID 목록 (프론트엔드 렌더링용)
Long taskId, // Task와 연결된 경우의 표준 Task ID
String taskName, // 표시용 Task 이름
String description,
int stepOrder,
int level, // 트리 깊이 (0: 루트, 1: 1단계 자식...)
boolean isLinkedToTask,
Double weight, // 이 노드의 가중치 (JobRoadmapNodeStat에서)

@JsonInclude(JsonInclude.Include.NON_EMPTY)
List<JobRoadmapNodeResponse> children
) {

// 정적 팩토리 메서드 - RoadmapNode로부터 Response DTO 생성 (자식 노드 정보 포함)
public static JobRoadmapNodeResponse from(RoadmapNode node, List<JobRoadmapNodeResponse> children) {
List<Long> childIds = children != null ?
children.stream().map(JobRoadmapNodeResponse::id).toList() :
List.of();

return new JobRoadmapNodeResponse(
node.getId(),
node.getParent() != null ? node.getParent().getId() : null,
childIds,
node.getTask() != null ? node.getTask().getId() : null,
node.getTask() != null ? node.getTask().getName() : node.getTaskName(),
node.getDescription(),
node.getStepOrder(),
node.getLevel(),
node.getTask() != null,
null, // weight는 서비스에서 별도로 설정
children != null ? children : List.of()
);
}

// 가중치 설정 헬퍼 메서드 (불변 객체이므로 새 인스턴스 반환)
public JobRoadmapNodeResponse withWeight(Double weight) {
return new JobRoadmapNodeResponse(
this.id, this.parentId, this.childIds, this.taskId, this.taskName, this.description,
this.stepOrder, this.level, this.isLinkedToTask, weight, this.children
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.back.domain.roadmap.roadmap.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.data.domain.Page;

import java.util.List;

@Schema(description = "직업 로드맵 페이징 조회 응답")
public record JobRoadmapPagingResponse(
@Schema(description = "직업 로드맵 목록")
List<JobRoadmapListResponse> jobRoadmaps,

@Schema(description = "현재 페이지 (0부터 시작)")
int currentPage,

@Schema(description = "총 페이지")
int totalPage,

@Schema(description = "총 개수")
long totalElements,

@Schema(description = "다음 페이지 존재 여부")
boolean hasNext
) {
public static JobRoadmapPagingResponse from(Page<JobRoadmapListResponse> page) {
return new JobRoadmapPagingResponse(
page.getContent(),
page.getNumber(),
page.getTotalPages(),
page.getTotalElements(),
page.hasNext()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.back.domain.roadmap.roadmap.dto.response;

import com.back.domain.roadmap.roadmap.entity.JobRoadmap;
import com.back.domain.roadmap.roadmap.entity.RoadmapNode;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public record JobRoadmapResponse(
Long id,
Long jobId,
String jobName,
List<JobRoadmapNodeResponse> nodes,
int totalNodeCount,
LocalDateTime createdDate,
LocalDateTime modifiedDate
) {

// 정적 팩터리 메서드 - JobRoadmap과 Job 정보로부터 Response DTO 생성
public static JobRoadmapResponse from(JobRoadmap jobRoadmap, String jobName) {
// 부모-자식 관계 맵 생성
Map<Long, List<RoadmapNode>> childrenMap = jobRoadmap.getNodes().stream()
.filter(node -> node.getParent() != null)
.collect(Collectors.groupingBy(node -> node.getParent().getId()));

// 노드를 재귀적으로 변환하는 함수
Map<Long, JobRoadmapNodeResponse> nodeResponseMap = new HashMap<>();

// 모든 노드를 bottom-up 방식으로 변환 (자식부터 부모 순서)
buildNodeResponses(jobRoadmap.getNodes(), childrenMap, nodeResponseMap);

// 루트 노드들만 반환 (자식 노드들은 children 필드에 포함되어 전체 트리 구조 제공)
List<JobRoadmapNodeResponse> nodes = jobRoadmap.getNodes().stream()
.filter(node -> node.getParent() == null)
.map(node -> nodeResponseMap.get(node.getId()))
.sorted((a, b) -> {
int levelCompare = Integer.compare(a.level(), b.level());
return levelCompare != 0 ? levelCompare : Integer.compare(a.stepOrder(), b.stepOrder());
})
.toList();

return new JobRoadmapResponse(
jobRoadmap.getId(),
jobRoadmap.getJob().getId(),
jobName,
nodes,
jobRoadmap.getNodes().size(),
jobRoadmap.getCreateDate(),
jobRoadmap.getModifyDate()
);
}

// 노드 응답 객체들을 재귀적으로 구성하는 헬퍼 메서드
private static void buildNodeResponses(
List<RoadmapNode> allNodes,
Map<Long, List<RoadmapNode>> childrenMap,
Map<Long, JobRoadmapNodeResponse> nodeResponseMap) {

// 노드들을 level 역순으로 정렬 (깊은 노드부터 처리)
List<RoadmapNode> sortedNodes = allNodes.stream()
.sorted((a, b) -> Integer.compare(b.getLevel(), a.getLevel()))
.toList();

for (RoadmapNode node : sortedNodes) {
// 자식 노드들의 응답 객체 가져오기
List<JobRoadmapNodeResponse> childResponses = childrenMap
.getOrDefault(node.getId(), List.of())
.stream()
.map(child -> nodeResponseMap.get(child.getId()))
.filter(response -> response != null)
.sorted((a, b) -> Integer.compare(a.stepOrder(), b.stepOrder()))
.toList();

// 현재 노드의 응답 객체 생성
JobRoadmapNodeResponse nodeResponse = JobRoadmapNodeResponse.from(node, childResponses);
nodeResponseMap.put(node.getId(), nodeResponse);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,32 +1,35 @@
package com.back.domain.roadmap.roadmap.entity;

import com.back.domain.job.job.entity.Job;
import com.back.global.jpa.BaseEntity;
import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.SQLRestriction;

import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "job_roadmap")
@Getter @Setter
@Getter
@NoArgsConstructor
public class JobRoadmap extends BaseEntity {
@Column(name = "job_id", nullable = false)
private Long jobId; // Job FK
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "job_id", nullable = false)
private Job job;

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

public JobRoadmap(Long jobId) {
this.jobId = jobId;
this.nodes = new ArrayList<>();
@Builder
public JobRoadmap(Job job, List<RoadmapNode> nodes) {
this.job = job;
this.nodes = nodes != null ? nodes : new ArrayList<>();
}

public RoadmapNode getRootNode() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,45 @@

import com.back.global.jpa.BaseEntity;
import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Table(name = "job_roadmap_node_stat")
@Getter @Setter
@Getter
@NoArgsConstructor
public class JobRoadmapNodeStat extends BaseEntity {
@Column(name = "step_order")
private Integer stepOrder;

@Column(name = "weight", nullable = false)
private Double weight = 0.0;
private Double weight;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "node_id", nullable = false)
private RoadmapNode node;

// ---- 추가 통계 필드 ----
@Column(name = "average_position")
private Double averagePosition; // 각 노드가 멘토 로드맵에서 평균적으로 위치한 인덱스(1..N)

@Column(name = "mentor_count")
private Integer mentorCount; // 몇 명의 멘토 로드맵에 등장했는지 (unique mentor count)

@Column(name = "outgoing_transitions")
private Integer outgoingTransitions; // 이 노드에서 다른 노드로 이동한 총 전이수

@Column(name = "incoming_transitions")
private Integer incomingTransitions; // 타 노드에서 이 노드로 들어오는 전이수

@Column(name = "transition_counts", columnDefinition = "TEXT")
private String transitionCounts; // (선택) JSON 직렬화: { "T:5":3, "T:7":1 } 형태로 보관 가능

@Builder
public JobRoadmapNodeStat(Integer stepOrder, Double weight, RoadmapNode node) {
this.stepOrder = stepOrder;
this.weight = weight != null ? weight : 0.0;
this.node = node;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public class MentorRoadmap extends BaseEntity {
private Mentor mentor;

@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "roadmap_id")
@JoinColumn(name = "roadmap_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
@SQLRestriction("roadmap_type = 'MENTOR'")
@OrderBy("stepOrder ASC")
private List<RoadmapNode> nodes;
Expand Down
Loading